Skip to main content

geop_core_sketch/
lib.rs

1//! Constraint sketches: 2-D ones — points, lines, arcs, circles and
2//! splines, the typical CAD constraints between them, a solver, and
3//! conversion of the solved sketch into closed profiles for extrude and
4//! revolve — and 3-D ones, the paths and rails sweeps run along.
5//!
6//! - [`sketch`]: the entities and constraints: design data.
7//! - [`solve`]: [`Sketch::solve`] / [`Sketch::solve_with_drag`], and
8//!   [`Sketch::enclose`]: the solution as the kernel builds on it.
9//! - [`profile`]: [`Sketch::regions`] and [`ProfileLoop::to_nurbs`].
10//! - [`copies`] and [`offset`]: geometry made from geometry, tied to it by
11//!   constraints — mirrored, patterned, offset.
12//! - [`plain`]: plane geometry in plain numbers, where tools place what
13//!   they build before it is solved; [`dimension`], how a dimension is
14//!   drawn in a plane — a sketch's and a drawing's alike.
15//! - [`space`]: 3-D sketches — points, lines, arcs and splines in space,
16//!   solved the same way, joined into chains of NURBS curves: paths and
17//!   rails.
18//! - [`text`]: the glyph outlines a text in a sketch is drawn as.
19
20pub mod copies;
21pub mod dimension;
22pub mod geometry;
23pub mod offset;
24pub mod plain;
25pub mod profile;
26pub mod sketch;
27pub mod solve;
28pub mod space;
29pub mod text;
30mod variables;
31
32pub use profile::{ProfileEdge, ProfileJoint, ProfileLoop, ProfilePiece, Region, Shape};
33pub use sketch::{
34    Constraint, ConstraintId, Curve, CurveId, CurveKind, Enclosure, Point, PointId, Positions,
35    Sketch, SplineShape,
36};
37pub use solve::SolveReport;
38
39#[cfg(test)]
40mod tests {
41    use super::*;
42    use crate::plain::xy;
43    use geop_core_math::{scalars::Scalar, vector::Vector2};
44    use std::f64::consts::{FRAC_PI_2, PI};
45
46    fn close(a: f64, b: f64) -> bool {
47        (a - b).abs() < 1e-7
48    }
49
50    fn n(x: f64) -> Scalar {
51        Scalar::from_f64(x)
52    }
53
54    fn v2(p: [f64; 2]) -> Vector2 {
55        Vector2::from_array(p.map(Scalar::from_f64))
56    }
57
58    /// A sloppily drawn quadrilateral, its corners, constrained to the
59    /// 2 x 1 rectangle anchored at the origin, from there counter-clockwise.
60    fn sloppy_rectangle() -> (Sketch, [PointId; 4]) {
61        let mut s = Sketch::new();
62        let p = [
63            s.add_point(n(0.1), n(-0.1)),
64            s.add_point(n(2.2), n(0.2)),
65            s.add_point(n(1.9), n(1.3)),
66            s.add_point(n(-0.2), n(0.8)),
67        ];
68        let l: Vec<CurveId> = (0..4).map(|i| s.add_line(p[i], p[(i + 1) % 4])).collect();
69        s.constrain(Constraint::Fix {
70            point: p[0],
71            x: n(0.0),
72            y: n(0.0),
73        });
74        s.constrain(Constraint::Horizontal { line: l[0] });
75        s.constrain(Constraint::Horizontal { line: l[2] });
76        s.constrain(Constraint::Vertical { line: l[1] });
77        s.constrain(Constraint::Vertical { line: l[3] });
78        s.constrain(Constraint::Length {
79            curve: l[0],
80            value: n(2.0),
81        });
82        s.constrain(Constraint::Distance {
83            a: p[1],
84            b: p[2],
85            value: n(1.0),
86        });
87        (s, p)
88    }
89
90    /// A sloppily drawn quadrilateral becomes an exact, fully constrained
91    /// 2 x 1 rectangle anchored at the origin.
92    #[test]
93    fn rectangle_is_solved_and_fully_constrained() {
94        let (mut s, p) = sloppy_rectangle();
95
96        let report = s.solve().unwrap();
97        assert!(report.converged, "{report:?}");
98        assert_eq!(report.dof, 0, "{report:?}");
99        assert!(report.free_points.values().all(|f| !f), "{report:?}");
100        let expect = [[0.0, 0.0], [2.0, 0.0], [2.0, 1.0], [0.0, 1.0]];
101        for (pi, e) in p.iter().zip(expect) {
102            let q = xy(&s, *pi);
103            assert!(close(q[0], e[0]) && close(q[1], e[1]), "{q:?} vs {e:?}");
104        }
105    }
106
107    /// Without the anchor and dimensions the rectangle keeps its width,
108    /// height and position free: 4 degrees of freedom, every point free.
109    #[test]
110    fn unanchored_rectangle_has_two_dof() {
111        let mut s = Sketch::new();
112        let p = [
113            s.add_point(n(0.0), n(0.0)),
114            s.add_point(n(2.0), n(0.1)),
115            s.add_point(n(2.0), n(1.0)),
116            s.add_point(n(0.0), n(1.0)),
117        ];
118        let l: Vec<CurveId> = (0..4).map(|i| s.add_line(p[i], p[(i + 1) % 4])).collect();
119        s.constrain(Constraint::Horizontal { line: l[0] });
120        s.constrain(Constraint::Horizontal { line: l[2] });
121        s.constrain(Constraint::Vertical { line: l[1] });
122        s.constrain(Constraint::Vertical { line: l[3] });
123        let report = s.solve().unwrap();
124        assert!(report.converged);
125        assert_eq!(report.dof, 4, "width, height and translation");
126        assert!(report.free_points.values().all(|f| *f));
127    }
128
129    /// Coincident points become one: the constraint needs no residual and the
130    /// points end up exactly equal.
131    #[test]
132    fn coincident_points_merge() {
133        let mut s = Sketch::new();
134        let a = s.add_point(n(0.0), n(0.0));
135        let b = s.add_point(n(1.0), n(0.0));
136        let c = s.add_point(n(1.1), n(0.05));
137        let d = s.add_point(n(1.5), n(1.0));
138        s.add_line(a, b);
139        s.add_line(c, d);
140        s.constrain(Constraint::Coincident { a: b, b: c });
141        let report = s.solve().unwrap();
142        assert!(report.converged);
143        assert_eq!(xy(&s, b), xy(&s, c));
144        assert_eq!(report.dof, 6);
145    }
146
147    /// A line tangent to an arc at their shared endpoint, with the arc's
148    /// radius fixed: the classic slot end.
149    #[test]
150    fn line_arc_tangent_at_shared_endpoint() {
151        let mut s = Sketch::new();
152        let a = s.add_point(n(0.0), n(0.0));
153        let b = s.add_point(n(2.0), n(0.0));
154        let c = s.add_point(n(2.0), n(1.0));
155        let line = s.add_line(a, b);
156        let arc = s.add_arc(b, c, n(2.0 * 0.75f64.asin()));
157        s.constrain(Constraint::Fix {
158            point: a,
159            x: n(0.0),
160            y: n(0.0),
161        });
162        s.constrain(Constraint::Horizontal { line });
163        s.constrain(Constraint::Length {
164            curve: line,
165            value: n(2.0),
166        });
167        s.constrain(Constraint::Tangent { a: line, b: arc });
168        s.constrain(Constraint::Radius {
169            curve: arc,
170            value: n(0.5),
171        });
172        s.constrain(Constraint::Fix {
173            point: c,
174            x: n(2.0),
175            y: n(1.0),
176        });
177        let report = s.solve().unwrap();
178        assert!(report.converged, "{report:?}");
179        let CurveKind::Arc { sweep, .. } = s.curves[&arc].kind else {
180            unreachable!()
181        };
182        // A half circle of radius 0.5 turning left from (2, 0) to (2, 1).
183        assert!(close(sweep.to_f64(), PI), "sweep {sweep:?}");
184        assert_eq!(report.dof, 0, "{report:?}");
185    }
186
187    /// A circle tangent to two perpendicular lines, with its center then
188    /// pinned by the tangencies and the radius.
189    #[test]
190    fn circle_tangent_to_lines() {
191        let mut s = Sketch::new();
192        let o = s.add_point(n(0.0), n(0.0));
193        let x = s.add_point(n(3.0), n(0.0));
194        let y = s.add_point(n(0.0), n(3.0));
195        let lx = s.add_line(o, x);
196        let ly = s.add_line(o, y);
197        let c = s.add_point(n(0.8), n(1.3));
198        let circle = s.add_circle(c, n(0.7));
199        for (p, xy) in [(o, [0.0, 0.0]), (x, [3.0, 0.0]), (y, [0.0, 3.0])] {
200            s.constrain(Constraint::Fix {
201                point: p,
202                x: n(xy[0]),
203                y: n(xy[1]),
204            });
205        }
206        s.constrain(Constraint::Tangent { a: lx, b: circle });
207        s.constrain(Constraint::Tangent { a: circle, b: ly });
208        s.constrain(Constraint::Radius {
209            curve: circle,
210            value: n(1.0),
211        });
212        let report = s.solve().unwrap();
213        assert!(report.converged, "{report:?}");
214        let q = xy(&s, c);
215        assert!(close(q[0], 1.0) && close(q[1], 1.0), "{q:?}");
216    }
217
218    /// Contradicting constraints are reported, not hidden.
219    #[test]
220    fn conflicting_constraints_do_not_converge() {
221        let mut s = Sketch::new();
222        let a = s.add_point(n(0.0), n(0.0));
223        let b = s.add_point(n(1.0), n(0.0));
224        let l = s.add_line(a, b);
225        s.constrain(Constraint::Length {
226            curve: l,
227            value: n(1.0),
228        });
229        s.constrain(Constraint::Distance {
230            a,
231            b,
232            value: n(2.0),
233        });
234        let report = s.solve().unwrap();
235        assert!(!report.converged);
236        assert!(!report.failed_constraints.is_empty());
237    }
238
239    /// Dragging a free point moves it to the cursor; dragging a fixed one
240    /// leaves it where the constraints say.
241    #[test]
242    fn drag_follows_cursor_only_where_free() {
243        let mut s = Sketch::new();
244        let a = s.add_point(n(0.0), n(0.0));
245        let b = s.add_point(n(1.0), n(0.0));
246        let l = s.add_line(a, b);
247        s.constrain(Constraint::Fix {
248            point: a,
249            x: n(0.0),
250            y: n(0.0),
251        });
252        s.constrain(Constraint::Length {
253            curve: l,
254            value: n(1.0),
255        });
256        let report = s.solve_with_drag(&[(b, v2([0.0, 3.0]))]).unwrap();
257        assert!(report.converged, "{report:?}");
258        let q = xy(&s, b);
259        assert!(close(q[0], 0.0) && close(q[1], 1.0), "{q:?}: {report:?}");
260
261        let report = s.solve_with_drag(&[(a, v2([5.0, 5.0]))]).unwrap();
262        assert!(report.converged);
263        let q = xy(&s, a);
264        assert!(close(q[0], 0.0) && close(q[1], 0.0), "{q:?}");
265    }
266
267    /// Symmetric, midpoint, perpendicular, equal, and angle constraints
268    /// together: an isosceles triangle with a 60° apex, i.e. equilateral.
269    #[test]
270    fn equilateral_triangle_from_symmetry_and_angle() {
271        let mut s = Sketch::new();
272        let a = s.add_point(n(-1.0), n(0.1));
273        let b = s.add_point(n(1.2), n(-0.1));
274        let c = s.add_point(n(0.1), n(1.5));
275        let base = s.add_line(a, b);
276        let left = s.add_line(c, a);
277        let right = s.add_line(c, b);
278        let m = s.add_point(n(0.0), n(0.0));
279        let axis_top = s.add_point(n(0.0), n(2.0));
280        let axis = s.add_line(m, axis_top);
281        s.set_construction(axis, true);
282        s.constrain(Constraint::Fix {
283            point: m,
284            x: n(0.0),
285            y: n(0.0),
286        });
287        s.constrain(Constraint::Vertical { line: axis });
288        s.constrain(Constraint::Midpoint {
289            point: m,
290            curve: base,
291        });
292        s.constrain(Constraint::Symmetric { a, b, line: axis });
293        s.constrain(Constraint::PointOnCurve {
294            point: c,
295            curve: axis,
296        });
297        s.constrain(Constraint::Equal { a: left, b: right });
298        s.constrain(Constraint::Angle {
299            a: left,
300            b: right,
301            value: n(PI / 3.0),
302        });
303        s.constrain(Constraint::Length {
304            curve: base,
305            value: n(2.0),
306        });
307        let report = s.solve().unwrap();
308        assert!(report.converged, "{report:?}");
309        let q = xy(&s, c);
310        assert!(close(q[0], 0.0) && close(q[1], 3f64.sqrt()), "{q:?}");
311    }
312
313    /// A slot: two lines joined by two half circles, around a circular hole.
314    /// One region, with the circle as its hole, and NURBS loops that close
315    /// up exactly.
316    #[test]
317    fn slot_with_hole_regions() {
318        let mut s = Sketch::new();
319        let p = [
320            s.add_point(n(0.0), n(0.0)),
321            s.add_point(n(2.0), n(0.0)),
322            s.add_point(n(2.0), n(1.0)),
323            s.add_point(n(0.0), n(1.0)),
324        ];
325        s.add_line(p[0], p[1]);
326        s.add_arc(p[1], p[2], n(PI));
327        s.add_line(p[2], p[3]);
328        s.add_arc(p[3], p[0], n(PI));
329        let c = s.add_point(n(1.0), n(0.5));
330        s.add_circle(c, n(0.25));
331        // A dangling helper line is ignored.
332        let q = s.add_point(n(-1.0), n(-1.0));
333        s.add_line(p[0], q);
334
335        let regions = s.regions().unwrap();
336        assert_eq!(regions.len(), 1);
337        assert_eq!(regions[0].outer.edges.len(), 4);
338        assert_eq!(regions[0].holes.len(), 1);
339
340        let geometry = s.enclose().unwrap();
341        for (lp, count) in [(&regions[0].outer, 6), (&regions[0].holes[0], 4)] {
342            let pieces = lp.to_nurbs(&s, &geometry).unwrap();
343            let curves: Vec<_> = pieces.iter().map(|p| &p.curve).collect();
344            assert_eq!(curves.len(), count);
345            // The joints chain up exactly like the curves do.
346            for (i, piece) in pieces.iter().enumerate() {
347                assert_eq!(piece.end, pieces[(i + 1) % pieces.len()].start);
348            }
349            for (i, c) in curves.iter().enumerate() {
350                let (t0, t1) = c.domain();
351                assert!(t0.could_be_equal(Scalar::ZERO) && t1.could_be_equal(Scalar::ONE));
352                let next = curves[(i + 1) % curves.len()];
353                let end = c.evaluate(Scalar::ONE).unwrap();
354                let start = next.evaluate(Scalar::ZERO).unwrap();
355                assert!(end.could_be_equal(&start), "{end:?} vs {start:?}");
356            }
357        }
358        // The half circle on the right passes through (2.5, 0.5).
359        let outer = regions[0].outer.to_nurbs(&s, &geometry).unwrap();
360        let far = Vector2::from_array([Scalar::from_f64(2.5), Scalar::from_f64(0.5)]);
361        assert!(
362            outer
363                .iter()
364                .any(|p| p.curve.evaluate(Scalar::ONE).unwrap().could_be_equal(&far)),
365            "no quarter piece ends at the right apex: {:?}",
366            outer
367                .iter()
368                .map(|p| p.curve.evaluate(Scalar::ONE).unwrap())
369                .collect::<Vec<_>>()
370        );
371    }
372
373    /// Nested loops alternate between outer boundaries and holes, and outer
374    /// loops come out counter-clockwise even when drawn clockwise.
375    #[test]
376    fn nested_squares_alternate() {
377        let mut s = Sketch::new();
378        for (size, clockwise) in [(3.0, true), (2.0, false), (1.0, true)] {
379            let h = size / 2.0;
380            let mut corners = [[-h, -h], [h, -h], [h, h], [-h, h]];
381            if clockwise {
382                corners.reverse();
383            }
384            let p: Vec<PointId> = corners
385                .iter()
386                .map(|c| s.add_point(n(c[0]), n(c[1])))
387                .collect();
388            for i in 0..4 {
389                s.add_line(p[i], p[(i + 1) % 4]);
390            }
391        }
392        let regions = s.regions().unwrap();
393        assert_eq!(regions.len(), 2);
394        let with_hole = regions.iter().filter(|r| r.holes.len() == 1).count();
395        assert_eq!(with_hole, 1);
396        // The outermost loop was drawn clockwise and is flipped.
397        let outermost = regions.iter().find(|r| r.holes.len() == 1).unwrap();
398        assert!(outermost.outer.edges.iter().all(|e| e.reversed));
399    }
400
401    /// Two circles a hair apart: the hole is still nested, because nesting
402    /// is decided on the curves themselves. The gap here (5e-5 of the
403    /// radius) is smaller than the sagitta of any polyline anyone would
404    /// draw these circles with — sampling them and testing the polygons
405    /// could not tell this apart from the two touching or crossing.
406    #[test]
407    fn nesting_resolves_a_gap_finer_than_any_sampling() {
408        let mut s = Sketch::new();
409        let outer = s.add_point(n(0.0), n(0.0));
410        s.add_circle(outer, n(1.0));
411        let inner = s.add_point(n(0.0), n(0.0));
412        s.add_circle(inner, n(1.0 - 5e-5));
413
414        let regions = s.regions().unwrap();
415        assert_eq!(regions.len(), 1, "one region: the ring between them");
416        assert_eq!(regions[0].holes.len(), 1);
417        // The outer loop runs counter-clockwise and the hole the other way,
418        // whatever order they were drawn in.
419        assert!(!regions[0].outer.edges[0].reversed);
420        assert!(regions[0].holes[0].edges[0].reversed);
421    }
422
423    /// Region finding runs on every solve while a point is being dragged,
424    /// so it has to stay quick on a sketch of real size: two 20-sided loops
425    /// here, one inside the other.
426    #[test]
427    fn regions_of_a_large_sketch_are_found_quickly() {
428        let mut s = Sketch::new();
429        for radius in [3.0, 1.0] {
430            let p: Vec<PointId> = (0..20)
431                .map(|k| {
432                    let a = std::f64::consts::TAU * k as f64 / 20.0;
433                    s.add_point(n(radius * a.cos()), n(radius * a.sin()))
434                })
435                .collect();
436            for i in 0..20 {
437                s.add_line(p[i], p[(i + 1) % 20]);
438            }
439        }
440        let start = std::time::Instant::now();
441        let regions = s.regions().unwrap();
442        let elapsed = start.elapsed();
443        assert_eq!(regions.len(), 1);
444        assert_eq!(regions[0].holes.len(), 1);
445        assert_eq!(regions[0].outer.edges.len(), 20);
446        assert!(
447            elapsed < std::time::Duration::from_millis(500),
448            "finding the regions of 40 curves took {elapsed:?}"
449        );
450    }
451
452    #[test]
453    fn branching_profile_is_rejected() {
454        let mut s = Sketch::new();
455        let p: Vec<PointId> = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]
456            .iter()
457            .map(|c| s.add_point(n(c[0]), n(c[1])))
458            .collect();
459        for i in 0..4 {
460            s.add_line(p[i], p[(i + 1) % 4]);
461        }
462        s.add_line(p[0], p[2]);
463        assert!(s.regions().is_err());
464    }
465
466    /// An arc drawn with a curvature is the minor arc with that curvature.
467    #[test]
468    fn arc_from_curvature() {
469        let mut s = Sketch::new();
470        let a = s.add_point(n(1.0), n(0.0));
471        let b = s.add_point(n(0.0), n(1.0));
472        let arc = s.add_arc(a, b, n(FRAC_PI_2));
473        let CurveKind::Arc { sweep, .. } = s.curves[&arc].kind else {
474            unreachable!()
475        };
476        assert!(close(sweep.to_f64(), FRAC_PI_2));
477    }
478
479    /// A spline and an arc joined tangentially into a closed loop.
480    #[test]
481    fn spline_tangent_to_arc() {
482        let mut s = Sketch::new();
483        let a = s.add_point(n(0.0), n(0.0));
484        let b = s.add_point(n(1.0), n(0.5));
485        let c = s.add_point(n(2.0), n(-0.3));
486        let d = s.add_point(n(3.0), n(0.0));
487        let spline = s.add_spline(vec![a, b, c, d]);
488        let arc = s.add_arc(d, a, n(2.5));
489        s.constrain(Constraint::Tangent { a: spline, b: arc });
490        s.constrain(Constraint::Fix {
491            point: a,
492            x: n(0.0),
493            y: n(0.0),
494        });
495        s.constrain(Constraint::Fix {
496            point: d,
497            x: n(3.0),
498            y: n(0.0),
499        });
500        s.constrain(Constraint::Fix {
501            point: b,
502            x: n(1.0),
503            y: n(0.5),
504        });
505        let report = s.solve().unwrap();
506        assert!(report.converged, "{report:?}");
507        let regions = s.regions().unwrap();
508        assert_eq!(regions.len(), 1);
509    }
510
511    /// Every NURBS piece remembers the sketch curve it came from and the
512    /// joints it runs between, in the curve's own direction even when the
513    /// loop runs against it: a circle's quarters are `c#0..c#3` between its
514    /// split points `[email protected]@3`, and a line keeps its end points.
515    #[test]
516    fn profile_pieces_name_their_sketch_origin() {
517        let mut s = Sketch::new();
518        let c = s.add_point(n(0.0), n(0.0));
519        let circle = s.add_circle(c, n(1.0));
520        let p: Vec<PointId> = [[-2.0, -2.0], [2.0, -2.0], [2.0, 2.0], [-2.0, 2.0]]
521            .iter()
522            .map(|c| s.add_point(n(c[0]), n(c[1])))
523            .collect();
524        let lines: Vec<CurveId> = (0..4).map(|i| s.add_line(p[i], p[(i + 1) % 4])).collect();
525
526        let regions = s.regions().unwrap();
527        let geometry = s.enclose().unwrap();
528        let outer = regions[0].outer.to_nurbs(&s, &geometry).unwrap();
529        let names: Vec<String> = outer.iter().map(|p| p.name()).collect();
530        assert_eq!(names.len(), 4);
531        for l in &lines {
532            assert!(names.contains(&format!("{l}")), "{names:?}");
533        }
534        assert!(
535            outer
536                .iter()
537                .all(|piece| matches!(piece.start, ProfileJoint::Point(_)))
538        );
539
540        // The hole runs clockwise, against the circle's own direction.
541        let hole = regions[0].holes[0].to_nurbs(&s, &geometry).unwrap();
542        let joints: Vec<String> = hole.iter().map(|p| p.start.to_string()).collect();
543        assert_eq!(
544            joints,
545            [
546                format!("{circle}@0"),
547                format!("{circle}@3"),
548                format!("{circle}@2"),
549                format!("{circle}@1")
550            ]
551        );
552        let names: Vec<String> = hole.iter().map(|p| p.name()).collect();
553        assert_eq!(
554            names,
555            [
556                format!("{circle}#3"),
557                format!("{circle}#2"),
558                format!("{circle}#1"),
559                format!("{circle}")
560            ]
561        );
562    }
563
564    /// A sketch serializes with its entities keyed by id, and reads back
565    /// unchanged — ids included, so references into it survive the trip.
566    #[test]
567    fn sketch_json_round_trip_keeps_ids() {
568        let mut s = Sketch::new();
569        let a = s.add_point(n(0.0), n(0.0));
570        let b = s.add_point(n(1.0), n(0.0));
571        let l = s.add_line(a, b);
572        s.constrain(Constraint::Horizontal { line: l });
573        let json = serde_json::to_string(&s).unwrap();
574        assert!(json.contains(&format!("\"{}\":", l.0)), "{json}");
575        let back: Sketch = serde_json::from_str(&json).unwrap();
576        assert_eq!(back, s);
577        back.validate().unwrap();
578    }
579
580    /// Removing a curve takes its constraints with it, and the points only
581    /// it used; a lone point drawn on purpose, and every id left, stay.
582    #[test]
583    fn remove_takes_what_depends_on_it() {
584        let mut s = Sketch::new();
585        let a = s.add_point(n(0.0), n(0.0));
586        let b = s.add_point(n(1.0), n(0.0));
587        let c = s.add_point(n(1.0), n(1.0));
588        let lone = s.add_point(n(5.0), n(5.0));
589        let ab = s.add_line(a, b);
590        let bc = s.add_line(b, c);
591        let horizontal = s.constrain(Constraint::Horizontal { line: ab });
592        let vertical = s.constrain(Constraint::Vertical { line: bc });
593        let fix = s.constrain(Constraint::Fix {
594            point: a,
595            x: n(0.0),
596            y: n(0.0),
597        });
598        s.remove(&[], &[ab], &[]);
599        assert!(!s.curves.contains_key(&ab) && s.curves.contains_key(&bc));
600        assert!(!s.constraints.contains_key(&horizontal));
601        assert!(s.constraints.contains_key(&vertical) && s.constraints.contains_key(&fix));
602        // `a` is still fixed, so still used; `b` is `bc`'s.
603        assert!(s.points.contains_key(&a) && s.points.contains_key(&b));
604
605        s.remove(&[], &[], &[fix]);
606        assert!(!s.points.contains_key(&a), "a was only the fix's");
607        s.remove(&[c], &[], &[]);
608        assert!(s.curves.is_empty() && s.constraints.is_empty());
609        assert_eq!(s.points.keys().copied().collect::<Vec<_>>(), [lone]);
610        s.validate().unwrap();
611    }
612
613    /// A solved sketch encloses the exact solution of its constraints: a
614    /// rectangle whose sides the solver only made nearly horizontal and
615    /// vertical is built from boxes around its exact corners — narrow, but
616    /// each holding the corner the constraints mean.
617    #[test]
618    fn solutions_are_enclosed() {
619        let (mut s, p) = sloppy_rectangle();
620        assert!(s.solve().unwrap().converged);
621        let enclosed = s.enclose().unwrap();
622        let exact = [[0.0, 0.0], [2.0, 0.0], [2.0, 1.0], [0.0, 1.0]];
623        for (pi, e) in p.iter().zip(exact) {
624            let q = enclosed.points[pi];
625            for k in 0..2 {
626                assert!(
627                    q[k].could_be_equal(Scalar::from_f64(e[k])),
628                    "{q:?} vs {e:?}"
629                );
630                assert!(q[k].width().to_f64() < 1e-12, "{q:?}");
631            }
632        }
633    }
634
635    /// A slot's end drawn as a half circle tangent to the two sides it joins
636    /// — a D, closed by an upright side: its solution is enclosed. Rebuilt
637    /// from a part whose loft failed while enclosing it.
638    #[test]
639    fn half_circle_tangent_to_parallel_sides_is_enclosed() {
640        let mut s = Sketch::new();
641        let p = [
642            s.add_point(n(-0.8559366928294662), n(0.6532261597610688)),
643            s.add_point(n(0.6690494426064505), n(0.6532261597610689)),
644            s.add_point(n(0.6690494426064503), n(-0.40501734890700397)),
645            s.add_point(n(-0.8559366928294662), n(-0.405017348907004)),
646        ];
647        let top = s.add_line(p[0], p[1]);
648        let end = s.add_arc(p[1], p[2], n(-PI));
649        let bottom = s.add_line(p[2], p[3]);
650        let side = s.add_line(p[3], p[0]);
651        s.constrain(Constraint::Horizontal { line: top });
652        s.constrain(Constraint::Tangent { a: top, b: end });
653        s.constrain(Constraint::Horizontal { line: bottom });
654        s.constrain(Constraint::Tangent { a: end, b: bottom });
655        s.constrain(Constraint::Vertical { line: side });
656        assert!(s.solve().unwrap().converged);
657        s.enclose().unwrap();
658    }
659
660    /// What the constraints leave free is the designer's choice, kept
661    /// exactly as drawn; only what they determine is enclosed.
662    #[test]
663    fn free_choices_stay_sharp() {
664        let mut s = Sketch::new();
665        let a = s.add_point(n(0.3), n(0.7));
666        let b = s.add_point(n(1.9), n(0.75));
667        let line = s.add_line(a, b);
668        s.constrain(Constraint::Horizontal { line });
669        assert!(s.solve().unwrap().converged);
670        let enclosed = s.enclose().unwrap();
671        // Three of the four coordinates are free; one is set by the others.
672        let sharp = [enclosed.points[&a], enclosed.points[&b]]
673            .iter()
674            .flat_map(|q| [q[0], q[1]])
675            .filter(|v| v.is_sharp())
676            .count();
677        assert!(sharp >= 3, "{enclosed:?}");
678        assert!(enclosed.points[&a][1].could_be_equal(enclosed.points[&b][1]));
679        // Unmet constraints leave the sketch as drawn.
680        s.constrain(Constraint::Vertical { line });
681        s.constrain(Constraint::Fix {
682            point: a,
683            x: n(0.0),
684            y: n(0.0),
685        });
686        s.constrain(Constraint::Fix {
687            point: b,
688            x: n(1.0),
689            y: n(0.0),
690        });
691        let report = s.solve().unwrap();
692        assert!(!report.converged);
693        assert_eq!(s.enclose().unwrap(), Enclosure::as_drawn(&s));
694    }
695
696    /// A fixed point is given, not solved for: a line hanging off it with
697    /// a length swings its free end, the fixed one stays, and a point made
698    /// coincident with it lands on it — whatever order the ids are in.
699    #[test]
700    fn fixed_points_stay_where_given() {
701        let mut s = Sketch::new();
702        let free = s.add_point(n(0.2), n(0.1));
703        let end = s.add_point(n(2.0), n(0.3));
704        let anchor = s.add_fixed_point(n(1.0), n(1.0));
705        s.add_line(anchor, end);
706        s.constrain(Constraint::Coincident { a: free, b: anchor });
707        let line = s.add_line(free, end);
708        s.constrain(Constraint::Length {
709            curve: line,
710            value: n(1.0),
711        });
712        let report = s.solve().unwrap();
713        assert!(report.converged, "{report:?}");
714        assert_eq!(xy(&s, anchor), [1.0, 1.0]);
715        assert_eq!(xy(&s, free), [1.0, 1.0]);
716        let e = xy(&s, end);
717        assert!(close((e[0] - 1.0).hypot(e[1] - 1.0), 1.0), "{e:?}");
718        // Only the free end's angle is left.
719        assert_eq!(report.dof, 1, "{report:?}");
720        assert!(!report.free_points[&anchor] && report.free_points[&end]);
721        let enclosed = s.enclose().unwrap();
722        assert!(enclosed.points[&anchor][0].is_sharp());
723    }
724
725    /// Two fixed points made coincident either are where the other is, or
726    /// the coincidence fails — neither is moved to make it hold.
727    #[test]
728    fn coincident_fixed_points_are_checked_not_merged() {
729        let mut s = Sketch::new();
730        let a = s.add_fixed_point(n(0.0), n(0.0));
731        let b = s.add_fixed_point(n(1.0), n(0.0));
732        let k = s.constrain(Constraint::Coincident { a, b });
733        let report = s.solve().unwrap();
734        assert_eq!(report.failed_constraints, vec![k]);
735        assert_eq!(xy(&s, b), [1.0, 0.0]);
736    }
737
738    /// A fixed curve keeps its own radius: a circle fixed at radius 2
739    /// cannot be given another.
740    #[test]
741    fn fixed_curves_keep_their_parameter() {
742        let mut s = Sketch::new();
743        let c = s.add_fixed_point(n(0.0), n(0.0));
744        let circle = s.add_circle(c, n(2.0));
745        s.curves.get_mut(&circle).unwrap().fixed = true;
746        s.validate().unwrap();
747        let k = s.constrain(Constraint::Diameter {
748            curve: circle,
749            value: n(3.0),
750        });
751        let report = s.solve().unwrap();
752        assert_eq!(report.failed_constraints, vec![k]);
753        let CurveKind::Circle { radius, .. } = s.curves[&circle].kind else {
754            unreachable!()
755        };
756        assert_eq!(radius.to_f64(), 2.0);
757        // A fixed curve on a free point is no fixed curve.
758        let free = s.add_point(n(5.0), n(0.0));
759        let other = s.add_circle(free, n(1.0));
760        s.curves.get_mut(&other).unwrap().fixed = true;
761        assert!(s.validate().is_err());
762    }
763
764    /// A diameter sizes a circle and an arc alike.
765    #[test]
766    fn diameter_sizes_circles_and_arcs() {
767        let mut s = Sketch::new();
768        let c = s.add_point(n(0.0), n(0.0));
769        let circle = s.add_circle(c, n(0.7));
770        s.constrain(Constraint::Diameter {
771            curve: circle,
772            value: n(3.0),
773        });
774        let a = s.add_point(n(5.0), n(0.0));
775        let b = s.add_point(n(7.0), n(0.0));
776        let arc = s.add_arc(a, b, n(1.0));
777        s.constrain(Constraint::Fix {
778            point: a,
779            x: n(5.0),
780            y: n(0.0),
781        });
782        s.constrain(Constraint::Fix {
783            point: b,
784            x: n(7.0),
785            y: n(0.0),
786        });
787        s.constrain(Constraint::Diameter {
788            curve: arc,
789            value: n(4.0),
790        });
791        let report = s.solve().unwrap();
792        assert!(report.converged, "{report:?}");
793        let CurveKind::Circle { radius, .. } = s.curves[&circle].kind else {
794            unreachable!()
795        };
796        assert!(close(radius.to_f64(), 1.5));
797        let CurveKind::Arc { sweep, .. } = s.curves[&arc].kind else {
798            unreachable!()
799        };
800        // A chord of 2 on a circle of diameter 4 subtends 60°.
801        assert!(close(sweep.to_f64(), PI / 3.0), "{sweep:?}");
802    }
803
804    /// A spline with a shape of its own is that NURBS: a rational quadratic
805    /// with the middle weight `cos 45°` is a quarter circle.
806    #[test]
807    fn rational_splines_are_exact() {
808        let mut s = Sketch::new();
809        let p = [
810            s.add_point(n(1.0), n(0.0)),
811            s.add_point(n(1.0), n(1.0)),
812            s.add_point(n(0.0), n(1.0)),
813        ];
814        let spline = s.add_curve(CurveKind::Spline {
815            control_points: p.to_vec(),
816            shape: Some(SplineShape {
817                degree: 2,
818                knots: [0.0, 0.0, 0.0, 2.0, 2.0, 2.0].map(n).to_vec(),
819                weights: [1.0, 0.5f64.sqrt(), 1.0].map(n).to_vec(),
820            }),
821        });
822        s.validate().unwrap();
823        for q in profile::curve_polyline(&s, spline).unwrap() {
824            let r = q[0].to_f64().hypot(q[1].to_f64());
825            assert!(close(r, 1.0), "{q:?}");
826        }
827    }
828
829    /// A point made an arc's center stays at the center as the arc's ends
830    /// are moved: the center of a center-point arc.
831    #[test]
832    fn center_follows_the_arc() {
833        let mut s = Sketch::new();
834        let c = s.add_point(n(0.3), n(0.2));
835        let a = s.add_point(n(1.0), n(0.0));
836        let b = s.add_point(n(0.0), n(1.0));
837        let arc = s.add_arc(a, b, n(FRAC_PI_2));
838        s.constrain(Constraint::Center {
839            point: c,
840            curve: arc,
841        });
842        for (p, at) in [(a, [2.0, 0.0]), (b, [0.0, 2.0])] {
843            s.constrain(Constraint::Fix {
844                point: p,
845                x: n(at[0]),
846                y: n(at[1]),
847            });
848        }
849        s.constrain(Constraint::Radius {
850            curve: arc,
851            value: n(2.0),
852        });
853        let report = s.solve().unwrap();
854        assert!(report.converged, "{report:?}");
855        let q = xy(&s, c);
856        assert!(close(q[0], 0.0) && close(q[1], 0.0), "{q:?}");
857    }
858
859    /// A text's letters are areas of their own, each with the holes its
860    /// glyph has — a `B` two, an `o` one, a `T` none — and the loops' pieces
861    /// are named apart: every piece and every joint of the text once.
862    #[test]
863    fn letters_are_areas_with_their_holes() {
864        let mut s = Sketch::new();
865        let anchor = s.add_point(n(3.0), n(-2.0));
866        let text = s.add_text(anchor, "BoT", n(5.0), n(0.2));
867        let regions = s.regions().unwrap();
868        let mut holes: Vec<usize> = regions.iter().map(|r| r.holes.len()).collect();
869        holes.sort();
870        assert_eq!(holes, [0, 1, 2], "{regions:?}");
871        let geometry = s.enclose().unwrap();
872        let mut names = Vec::new();
873        let mut joints = Vec::new();
874        for lp in regions
875            .iter()
876            .flat_map(|r| std::iter::once(&r.outer).chain(&r.holes))
877        {
878            let pieces = lp.to_nurbs(&s, &geometry).unwrap();
879            for (k, p) in pieces.iter().enumerate() {
880                assert_eq!(p.source, text);
881                assert_eq!(p.end, pieces[(k + 1) % pieces.len()].start, "{lp:?}");
882                names.push(p.name());
883                joints.push(p.start);
884            }
885        }
886        let count = names.len();
887        names.sort();
888        names.dedup();
889        joints.sort();
890        joints.dedup();
891        assert_eq!((names.len(), joints.len()), (count, count));
892    }
893
894    /// Drawn round with a rectangle, an `o` is a hole in it, and the hole
895    /// inside the `o` an island of its own: the areas are what the curves
896    /// enclose, whatever drew them.
897    #[test]
898    fn a_letter_inside_a_frame_is_a_hole_in_it() {
899        let mut s = Sketch::new();
900        let corners = [[-1.0, -2.0], [5.0, -2.0], [5.0, 6.0], [-1.0, 6.0]];
901        let p: Vec<PointId> = corners.iter().map(|&c| s.new_point_at(c)).collect();
902        for k in 0..4 {
903            s.add_line(p[k], p[(k + 1) % 4]);
904        }
905        let anchor = s.add_point(n(0.0), n(0.0));
906        s.add_text(anchor, "o", n(4.0), n(0.0));
907        let regions = s.regions().unwrap();
908        let mut holes: Vec<usize> = regions.iter().map(|r| r.holes.len()).collect();
909        holes.sort();
910        assert_eq!(holes, [0, 1], "{regions:?}");
911    }
912
913    /// A text is placed by its anchor: constrained, the anchor moves, and
914    /// the outlines with it; a constraint on the text itself is refused.
915    #[test]
916    fn a_text_follows_its_anchor() {
917        let mut s = Sketch::new();
918        let anchor = s.add_point(n(1.0), n(1.0));
919        let text = s.add_text(anchor, "l", n(2.0), n(0.0));
920        let low = |s: &Sketch| {
921            let lines = profile::curve_polylines(s, text).unwrap();
922            lines
923                .iter()
924                .flatten()
925                .map(|q| q[1].to_f64())
926                .fold(f64::INFINITY, f64::min)
927        };
928        assert!(close(low(&s), 1.0), "{}", low(&s));
929        s.constrain(Constraint::Fix {
930            point: anchor,
931            x: n(4.0),
932            y: n(-3.0),
933        });
934        assert!(s.solve().unwrap().converged);
935        assert!(close(low(&s), -3.0), "{}", low(&s));
936        let far = s.add_point(n(9.0), n(9.0));
937        let line = s.add_line(anchor, far);
938        let parallel = Constraint::Parallel { a: line, b: text };
939        assert!(s.validate_constraint(&parallel).is_err());
940    }
941}