Skip to main content

geop_core_sketch/
lib.rs

1//! 2-D constraint sketches: points, lines, arcs, circles and splines, the
2//! typical CAD constraints between them, a BFGS solver, and conversion of the
3//! solved sketch into closed profiles for extrude and revolve.
4//!
5//! - [`sketch`]: the entities and constraints (plain `f64` design data).
6//! - [`solve`]: [`Sketch::solve`] / [`Sketch::solve_with_drag`].
7//! - [`profile`]: [`Sketch::regions`] and [`ProfileLoop::to_nurbs`].
8
9pub mod bfgs;
10pub mod dual;
11pub mod geometry;
12pub mod profile;
13pub mod sketch;
14pub mod solve;
15
16pub use profile::{ProfileEdge, ProfileJoint, ProfileLoop, ProfilePiece, Region};
17pub use sketch::{
18    Constraint, ConstraintId, Curve, CurveId, CurveKind, Point, PointId, Positions, Sketch,
19};
20pub use solve::SolveReport;
21
22#[cfg(test)]
23mod tests {
24    use super::*;
25    use geop_core_math::{
26        for_all_scalars,
27        scalars::{Scalar, scal_in_f64::ScalInF64},
28        vector::Vector2,
29    };
30    use std::f64::consts::{FRAC_PI_2, PI};
31
32    fn close(a: f64, b: f64) -> bool {
33        (a - b).abs() < 1e-7
34    }
35
36    fn xy(s: &Sketch, p: PointId) -> [f64; 2] {
37        s.points[&p].xy()
38    }
39
40    /// A sloppily drawn quadrilateral becomes an exact, fully constrained
41    /// 2 x 1 rectangle anchored at the origin.
42    #[test]
43    fn rectangle_is_solved_and_fully_constrained() {
44        let mut s = Sketch::new();
45        let p = [
46            s.add_point(0.1, -0.1),
47            s.add_point(2.2, 0.2),
48            s.add_point(1.9, 1.3),
49            s.add_point(-0.2, 0.8),
50        ];
51        let l: Vec<CurveId> = (0..4).map(|i| s.add_line(p[i], p[(i + 1) % 4])).collect();
52        s.constrain(Constraint::Fix {
53            point: p[0],
54            x: 0.0,
55            y: 0.0,
56        });
57        s.constrain(Constraint::Horizontal { line: l[0] });
58        s.constrain(Constraint::Horizontal { line: l[2] });
59        s.constrain(Constraint::Vertical { line: l[1] });
60        s.constrain(Constraint::Vertical { line: l[3] });
61        s.constrain(Constraint::Length {
62            curve: l[0],
63            value: 2.0,
64        });
65        s.constrain(Constraint::Distance {
66            a: p[1],
67            b: p[2],
68            value: 1.0,
69        });
70
71        let report = s.solve().unwrap();
72        assert!(report.converged, "{report:?}");
73        assert_eq!(report.dof, 0, "{report:?}");
74        assert!(report.free_points.values().all(|f| !f), "{report:?}");
75        let expect = [[0.0, 0.0], [2.0, 0.0], [2.0, 1.0], [0.0, 1.0]];
76        for (pi, e) in p.iter().zip(expect) {
77            let q = xy(&s, *pi);
78            assert!(close(q[0], e[0]) && close(q[1], e[1]), "{q:?} vs {e:?}");
79        }
80    }
81
82    /// Without the anchor and dimensions the rectangle keeps its width,
83    /// height and position free: 4 degrees of freedom, every point free.
84    #[test]
85    fn unanchored_rectangle_has_two_dof() {
86        let mut s = Sketch::new();
87        let p = [
88            s.add_point(0.0, 0.0),
89            s.add_point(2.0, 0.1),
90            s.add_point(2.0, 1.0),
91            s.add_point(0.0, 1.0),
92        ];
93        let l: Vec<CurveId> = (0..4).map(|i| s.add_line(p[i], p[(i + 1) % 4])).collect();
94        s.constrain(Constraint::Horizontal { line: l[0] });
95        s.constrain(Constraint::Horizontal { line: l[2] });
96        s.constrain(Constraint::Vertical { line: l[1] });
97        s.constrain(Constraint::Vertical { line: l[3] });
98        let report = s.solve().unwrap();
99        assert!(report.converged);
100        assert_eq!(report.dof, 4, "width, height and translation");
101        assert!(report.free_points.values().all(|f| *f));
102    }
103
104    /// Coincident points become one: the constraint needs no residual and the
105    /// points end up exactly equal.
106    #[test]
107    fn coincident_points_merge() {
108        let mut s = Sketch::new();
109        let a = s.add_point(0.0, 0.0);
110        let b = s.add_point(1.0, 0.0);
111        let c = s.add_point(1.1, 0.05);
112        let d = s.add_point(1.5, 1.0);
113        s.add_line(a, b);
114        s.add_line(c, d);
115        s.constrain(Constraint::Coincident { a: b, b: c });
116        let report = s.solve().unwrap();
117        assert!(report.converged);
118        assert_eq!(xy(&s, b), xy(&s, c));
119        assert_eq!(report.dof, 6);
120    }
121
122    /// A line tangent to an arc at their shared endpoint, with the arc's
123    /// radius fixed: the classic slot end.
124    #[test]
125    fn line_arc_tangent_at_shared_endpoint() {
126        let mut s = Sketch::new();
127        let a = s.add_point(0.0, 0.0);
128        let b = s.add_point(2.0, 0.0);
129        let c = s.add_point(2.0, 1.0);
130        let line = s.add_line(a, b);
131        let arc = s.add_arc(b, c, 1.5);
132        s.constrain(Constraint::Fix {
133            point: a,
134            x: 0.0,
135            y: 0.0,
136        });
137        s.constrain(Constraint::Horizontal { line });
138        s.constrain(Constraint::Length {
139            curve: line,
140            value: 2.0,
141        });
142        s.constrain(Constraint::Tangent { a: line, b: arc });
143        s.constrain(Constraint::Radius {
144            curve: arc,
145            value: 0.5,
146        });
147        s.constrain(Constraint::Fix {
148            point: c,
149            x: 2.0,
150            y: 1.0,
151        });
152        let report = s.solve().unwrap();
153        assert!(report.converged, "{report:?}");
154        let CurveKind::Arc { sweep, .. } = s.curves[&arc].kind else {
155            unreachable!()
156        };
157        // A half circle of radius 0.5 turning left from (2, 0) to (2, 1).
158        assert!(close(sweep, PI), "sweep {sweep}");
159        assert_eq!(report.dof, 0, "{report:?}");
160    }
161
162    /// A circle tangent to two perpendicular lines, with its center then
163    /// pinned by the tangencies and the radius.
164    #[test]
165    fn circle_tangent_to_lines() {
166        let mut s = Sketch::new();
167        let o = s.add_point(0.0, 0.0);
168        let x = s.add_point(3.0, 0.0);
169        let y = s.add_point(0.0, 3.0);
170        let lx = s.add_line(o, x);
171        let ly = s.add_line(o, y);
172        let c = s.add_point(0.8, 1.3);
173        let circle = s.add_circle(c, 0.7);
174        for (p, xy) in [(o, [0.0, 0.0]), (x, [3.0, 0.0]), (y, [0.0, 3.0])] {
175            s.constrain(Constraint::Fix {
176                point: p,
177                x: xy[0],
178                y: xy[1],
179            });
180        }
181        s.constrain(Constraint::Tangent { a: lx, b: circle });
182        s.constrain(Constraint::Tangent { a: circle, b: ly });
183        s.constrain(Constraint::Radius {
184            curve: circle,
185            value: 1.0,
186        });
187        let report = s.solve().unwrap();
188        assert!(report.converged, "{report:?}");
189        let q = xy(&s, c);
190        assert!(close(q[0], 1.0) && close(q[1], 1.0), "{q:?}");
191    }
192
193    /// Contradicting constraints are reported, not hidden.
194    #[test]
195    fn conflicting_constraints_do_not_converge() {
196        let mut s = Sketch::new();
197        let a = s.add_point(0.0, 0.0);
198        let b = s.add_point(1.0, 0.0);
199        let l = s.add_line(a, b);
200        s.constrain(Constraint::Length {
201            curve: l,
202            value: 1.0,
203        });
204        s.constrain(Constraint::Distance { a, b, value: 2.0 });
205        let report = s.solve().unwrap();
206        assert!(!report.converged);
207        assert!(!report.failed_constraints.is_empty());
208    }
209
210    /// Dragging a free point moves it to the cursor; dragging a fixed one
211    /// leaves it where the constraints say.
212    #[test]
213    fn drag_follows_cursor_only_where_free() {
214        let mut s = Sketch::new();
215        let a = s.add_point(0.0, 0.0);
216        let b = s.add_point(1.0, 0.0);
217        let l = s.add_line(a, b);
218        s.constrain(Constraint::Fix {
219            point: a,
220            x: 0.0,
221            y: 0.0,
222        });
223        s.constrain(Constraint::Length {
224            curve: l,
225            value: 1.0,
226        });
227        let report = s.solve_with_drag(&[(b, [0.0, 3.0])]).unwrap();
228        assert!(report.converged, "{report:?}");
229        let q = xy(&s, b);
230        assert!(close(q[0], 0.0) && close(q[1], 1.0), "{q:?}");
231
232        let report = s.solve_with_drag(&[(a, [5.0, 5.0])]).unwrap();
233        assert!(report.converged);
234        let q = xy(&s, a);
235        assert!(close(q[0], 0.0) && close(q[1], 0.0), "{q:?}");
236    }
237
238    /// Symmetric, midpoint, perpendicular, equal, and angle constraints
239    /// together: an isosceles triangle with a 60° apex, i.e. equilateral.
240    #[test]
241    fn equilateral_triangle_from_symmetry_and_angle() {
242        let mut s = Sketch::new();
243        let a = s.add_point(-1.0, 0.1);
244        let b = s.add_point(1.2, -0.1);
245        let c = s.add_point(0.1, 1.5);
246        let base = s.add_line(a, b);
247        let left = s.add_line(c, a);
248        let right = s.add_line(c, b);
249        let m = s.add_point(0.0, 0.0);
250        let axis_top = s.add_point(0.0, 2.0);
251        let axis = s.add_line(m, axis_top);
252        s.set_construction(axis, true);
253        s.constrain(Constraint::Fix {
254            point: m,
255            x: 0.0,
256            y: 0.0,
257        });
258        s.constrain(Constraint::Vertical { line: axis });
259        s.constrain(Constraint::Midpoint {
260            point: m,
261            curve: base,
262        });
263        s.constrain(Constraint::Symmetric { a, b, line: axis });
264        s.constrain(Constraint::PointOnCurve {
265            point: c,
266            curve: axis,
267        });
268        s.constrain(Constraint::Equal { a: left, b: right });
269        s.constrain(Constraint::Angle {
270            a: left,
271            b: right,
272            value: PI / 3.0,
273        });
274        s.constrain(Constraint::Length {
275            curve: base,
276            value: 2.0,
277        });
278        let report = s.solve().unwrap();
279        assert!(report.converged, "{report:?}");
280        let q = xy(&s, c);
281        assert!(close(q[0], 0.0) && close(q[1], 3f64.sqrt()), "{q:?}");
282    }
283
284    /// A slot: two lines joined by two half circles, around a circular hole.
285    /// One region, with the circle as its hole, and NURBS loops that close
286    /// up exactly.
287    fn check_slot_with_hole_regions<S: Scalar>() {
288        let mut s = Sketch::new();
289        let p = [
290            s.add_point(0.0, 0.0),
291            s.add_point(2.0, 0.0),
292            s.add_point(2.0, 1.0),
293            s.add_point(0.0, 1.0),
294        ];
295        s.add_line(p[0], p[1]);
296        s.add_arc_with_sweep(p[1], p[2], PI);
297        s.add_line(p[2], p[3]);
298        s.add_arc_with_sweep(p[3], p[0], PI);
299        let c = s.add_point(1.0, 0.5);
300        s.add_circle(c, 0.25);
301        // A dangling helper line is ignored.
302        let q = s.add_point(-1.0, -1.0);
303        s.add_line(p[0], q);
304
305        let regions = s.regions().unwrap();
306        assert_eq!(regions.len(), 1);
307        assert_eq!(regions[0].outer.edges.len(), 4);
308        assert_eq!(regions[0].holes.len(), 1);
309
310        let positions = s.positions();
311        for (lp, count) in [(&regions[0].outer, 6), (&regions[0].holes[0], 4)] {
312            let pieces = lp.to_nurbs::<S>(&s, &positions).unwrap();
313            let curves: Vec<_> = pieces.iter().map(|p| &p.curve).collect();
314            assert_eq!(curves.len(), count);
315            // The joints chain up exactly like the curves do.
316            for (i, piece) in pieces.iter().enumerate() {
317                assert_eq!(piece.end, pieces[(i + 1) % pieces.len()].start);
318            }
319            for (i, c) in curves.iter().enumerate() {
320                let (t0, t1) = c.domain();
321                assert!(t0.could_be_equal(S::ZERO) && t1.could_be_equal(S::ONE));
322                let next = curves[(i + 1) % curves.len()];
323                let end = c.evaluate(S::ONE).unwrap();
324                let start = next.evaluate(S::ZERO).unwrap();
325                assert!(end.could_be_equal(&start), "{end:?} vs {start:?}");
326            }
327        }
328        // The half circle on the right passes through (2.5, 0.5).
329        let outer = regions[0].outer.to_nurbs::<S>(&s, &positions).unwrap();
330        let far = Vector2::from_array([S::from_f64(2.5), S::from_f64(0.5)]);
331        assert!(
332            outer
333                .iter()
334                .any(|p| p.curve.evaluate(S::ONE).unwrap().could_be_equal(&far)),
335            "no quarter piece ends at the right apex"
336        );
337    }
338    #[test]
339    fn slot_with_hole_regions() {
340        for_all_scalars!(check_slot_with_hole_regions);
341    }
342
343    /// Nested loops alternate between outer boundaries and holes, and outer
344    /// loops come out counter-clockwise even when drawn clockwise.
345    #[test]
346    fn nested_squares_alternate() {
347        let mut s = Sketch::new();
348        for (size, clockwise) in [(3.0, true), (2.0, false), (1.0, true)] {
349            let h = size / 2.0;
350            let mut corners = [[-h, -h], [h, -h], [h, h], [-h, h]];
351            if clockwise {
352                corners.reverse();
353            }
354            let p: Vec<PointId> = corners.iter().map(|c| s.add_point(c[0], c[1])).collect();
355            for i in 0..4 {
356                s.add_line(p[i], p[(i + 1) % 4]);
357            }
358        }
359        let regions = s.regions().unwrap();
360        assert_eq!(regions.len(), 2);
361        let with_hole = regions.iter().filter(|r| r.holes.len() == 1).count();
362        assert_eq!(with_hole, 1);
363        // The outermost loop was drawn clockwise and is flipped.
364        let outermost = regions.iter().find(|r| r.holes.len() == 1).unwrap();
365        assert!(outermost.outer.edges.iter().all(|e| e.reversed));
366    }
367
368    /// Two circles a hair apart: the hole is still nested, because nesting
369    /// is decided on the curves themselves. The gap here (5e-5 of the
370    /// radius) is smaller than the sagitta of any polyline anyone would
371    /// draw these circles with — sampling them and testing the polygons
372    /// could not tell this apart from the two touching or crossing.
373    #[test]
374    fn nesting_resolves_a_gap_finer_than_any_sampling() {
375        let mut s = Sketch::new();
376        let outer = s.add_point(0.0, 0.0);
377        s.add_circle(outer, 1.0);
378        let inner = s.add_point(0.0, 0.0);
379        s.add_circle(inner, 1.0 - 5e-5);
380
381        let regions = s.regions().unwrap();
382        assert_eq!(regions.len(), 1, "one region: the ring between them");
383        assert_eq!(regions[0].holes.len(), 1);
384        // The outer loop runs counter-clockwise and the hole the other way,
385        // whatever order they were drawn in.
386        assert!(!regions[0].outer.edges[0].reversed);
387        assert!(regions[0].holes[0].edges[0].reversed);
388    }
389
390    /// Region finding runs on every solve while a point is being dragged,
391    /// so it has to stay quick on a sketch of real size: two 20-sided loops
392    /// here, one inside the other.
393    #[test]
394    fn regions_of_a_large_sketch_are_found_quickly() {
395        let mut s = Sketch::new();
396        for radius in [3.0, 1.0] {
397            let p: Vec<PointId> = (0..20)
398                .map(|k| {
399                    let a = std::f64::consts::TAU * k as f64 / 20.0;
400                    s.add_point(radius * a.cos(), radius * a.sin())
401                })
402                .collect();
403            for i in 0..20 {
404                s.add_line(p[i], p[(i + 1) % 20]);
405            }
406        }
407        let start = std::time::Instant::now();
408        let regions = s.regions().unwrap();
409        let elapsed = start.elapsed();
410        assert_eq!(regions.len(), 1);
411        assert_eq!(regions[0].holes.len(), 1);
412        assert_eq!(regions[0].outer.edges.len(), 20);
413        assert!(
414            elapsed < std::time::Duration::from_millis(500),
415            "finding the regions of 40 curves took {elapsed:?}"
416        );
417    }
418
419    #[test]
420    fn branching_profile_is_rejected() {
421        let mut s = Sketch::new();
422        let p: Vec<PointId> = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]
423            .iter()
424            .map(|c| s.add_point(c[0], c[1]))
425            .collect();
426        for i in 0..4 {
427            s.add_line(p[i], p[(i + 1) % 4]);
428        }
429        s.add_line(p[0], p[2]);
430        assert!(s.regions().is_err());
431    }
432
433    /// An arc drawn with a curvature is the minor arc with that curvature.
434    #[test]
435    fn arc_from_curvature() {
436        let mut s = Sketch::new();
437        let a = s.add_point(1.0, 0.0);
438        let b = s.add_point(0.0, 1.0);
439        let arc = s.add_arc(a, b, 1.0);
440        let CurveKind::Arc { sweep, .. } = s.curves[&arc].kind else {
441            unreachable!()
442        };
443        assert!(close(sweep, FRAC_PI_2));
444    }
445
446    /// A spline and an arc joined tangentially into a closed loop.
447    #[test]
448    fn spline_tangent_to_arc() {
449        let mut s = Sketch::new();
450        let a = s.add_point(0.0, 0.0);
451        let b = s.add_point(1.0, 0.5);
452        let c = s.add_point(2.0, -0.3);
453        let d = s.add_point(3.0, 0.0);
454        let spline = s.add_spline(vec![a, b, c, d]);
455        let arc = s.add_arc_with_sweep(d, a, 2.5);
456        s.constrain(Constraint::Tangent { a: spline, b: arc });
457        s.constrain(Constraint::Fix {
458            point: a,
459            x: 0.0,
460            y: 0.0,
461        });
462        s.constrain(Constraint::Fix {
463            point: d,
464            x: 3.0,
465            y: 0.0,
466        });
467        s.constrain(Constraint::Fix {
468            point: b,
469            x: 1.0,
470            y: 0.5,
471        });
472        let report = s.solve().unwrap();
473        assert!(report.converged, "{report:?}");
474        let regions = s.regions().unwrap();
475        assert_eq!(regions.len(), 1);
476    }
477
478    /// Every NURBS piece remembers the sketch curve it came from and the
479    /// joints it runs between, in the curve's own direction even when the
480    /// loop runs against it: a circle's quarters are `c#0..c#3` between its
481    /// split points `c@0..c@3`, and a line keeps its end points.
482    #[test]
483    fn profile_pieces_name_their_sketch_origin() {
484        let mut s = Sketch::new();
485        let c = s.add_point(0.0, 0.0);
486        let circle = s.add_circle(c, 1.0);
487        let p: Vec<PointId> = [[-2.0, -2.0], [2.0, -2.0], [2.0, 2.0], [-2.0, 2.0]]
488            .iter()
489            .map(|c| s.add_point(c[0], c[1]))
490            .collect();
491        let lines: Vec<CurveId> = (0..4).map(|i| s.add_line(p[i], p[(i + 1) % 4])).collect();
492
493        let regions = s.regions().unwrap();
494        let positions = s.positions();
495        let outer = regions[0]
496            .outer
497            .to_nurbs::<ScalInF64>(&s, &positions)
498            .unwrap();
499        let names: Vec<String> = outer.iter().map(|p| p.name()).collect();
500        assert_eq!(names.len(), 4);
501        for l in &lines {
502            assert!(names.contains(&format!("{l}")), "{names:?}");
503        }
504        assert!(
505            outer
506                .iter()
507                .all(|piece| matches!(piece.start, ProfileJoint::Point(_)))
508        );
509
510        // The hole runs clockwise, against the circle's own direction.
511        let hole = regions[0].holes[0]
512            .to_nurbs::<ScalInF64>(&s, &positions)
513            .unwrap();
514        let joints: Vec<String> = hole.iter().map(|p| p.start.to_string()).collect();
515        assert_eq!(
516            joints,
517            [
518                format!("{circle}@0"),
519                format!("{circle}@3"),
520                format!("{circle}@2"),
521                format!("{circle}@1")
522            ]
523        );
524        let names: Vec<String> = hole.iter().map(|p| p.name()).collect();
525        assert_eq!(
526            names,
527            [
528                format!("{circle}#3"),
529                format!("{circle}#2"),
530                format!("{circle}#1"),
531                format!("{circle}")
532            ]
533        );
534    }
535
536    /// A sketch serializes with its entities keyed by id, and reads back
537    /// unchanged — ids included, so references into it survive the trip.
538    #[test]
539    fn sketch_json_round_trip_keeps_ids() {
540        let mut s = Sketch::new();
541        let a = s.add_point(0.0, 0.0);
542        let b = s.add_point(1.0, 0.0);
543        let l = s.add_line(a, b);
544        s.constrain(Constraint::Horizontal { line: l });
545        let json = serde_json::to_string(&s).unwrap();
546        assert!(json.contains(&format!("\"{}\":", l.0)), "{json}");
547        let back: Sketch = serde_json::from_str(&json).unwrap();
548        assert_eq!(back, s);
549        back.validate().unwrap();
550    }
551}