use std::f64::consts::{FRAC_PI_2, PI}; use solidcraft_geom::Vec2; use crate::*; pub(crate) fn v(x: f64, y: f64) -> Vec2 { Vec2::new(x, y) } /// Deterministic pseudo-random numbers for the solver harnesses. pub(crate) struct Lcg(pub u64); impl Lcg { pub(crate) fn next(&mut self) -> f64 { self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); (self.0 >> 11) as f64 / (1u64 << 53) as f64 } pub(crate) fn range(&mut self, lo: f64, hi: f64) -> f64 { lo + self.next() * (hi - lo) } /// ±1, evenly. pub(crate) fn sign(&mut self) -> f64 { if self.next() < 0.5 { 1.0 } else { -1.0 } } } /// Run `f` with the constructive seed on or off, without the seed-on/seed-off comparison (for /// tests that measure the difference). pub(crate) fn seeded(on: bool, f: impl FnOnce() -> T) -> T { use crate::construct::switch; switch::without_differential(|| if on { f() } else { switch::without_seed(f) }) } /// The points that are constants for a plain solve. pub(crate) fn given(sk: &Sketch) -> Vec { crate::solver::held_points(sk, &Hold::default()) } /// The constructive seed of a plain solve. pub(crate) fn seed_of(sk: &Sketch) -> crate::construct::Seed { crate::construct::seed(sk, &given(sk), None, true, &crate::solver::points_on_curves(sk), &mut crate::decompose::Budget::full()) } /// Rough rectangle from four lines sharing corner points. fn rect(sk: &mut Sketch, p: [Vec2; 4]) -> [usize; 4] { let a = sk.add_point(p[0], None).unwrap(); let b = sk.add_point(p[1], None).unwrap(); let c = sk.add_point(p[2], None).unwrap(); let d = sk.add_point(p[3], None).unwrap(); [ sk.add_line_pts(a, b, None).unwrap(), sk.add_line_pts(b, c, None).unwrap(), sk.add_line_pts(c, d, None).unwrap(), sk.add_line_pts(d, a, None).unwrap(), ] } fn line_pts(sk: &Sketch, l: usize) -> (Vec2, Vec2) { match sk.curves[l].kind { CurveKind::Line { a, b } => (sk.points[a].pos, sk.points[b].pos), _ => panic!(), } } #[test] fn fully_constrained_rectangle() { let mut sk = Sketch::new(); let l = rect(&mut sk, [v(0.3, -0.2), v(38.0, 1.0), v(41.0, 29.0), v(-1.0, 31.0)]); use ConstraintKind::*; sk.add_constraint(Horizontal { l: l[0] }, None).unwrap(); sk.add_constraint(Horizontal { l: l[2] }, None).unwrap(); sk.add_constraint(Vertical { l: l[1] }, None).unwrap(); sk.add_constraint(Vertical { l: l[3] }, None).unwrap(); let (a, _) = match sk.curves[l[0]].kind { CurveKind::Line { a, b } => (a, b), _ => panic!(), }; sk.add_constraint(Coincident { p: a, q: 0 }, None).unwrap(); sk.add_constraint(Length { l: l[0], value: 40.0 }, Some("d1".into())).unwrap(); sk.add_constraint(Length { l: l[1], value: 30.0 }, Some("d2".into())).unwrap(); let rep = solve(&mut sk); assert!(rep.ok(), "{rep:?}"); assert_eq!(rep.dof, 0, "{rep:?}"); assert!(rep.point_determined.iter().all(|d| *d)); let (p0, p1) = line_pts(&sk, l[0]); assert!(p0.dist(v(0.0, 0.0)) < 1e-7, "{p0:?}"); assert!(p1.dist(v(40.0, 0.0)) < 1e-7, "{p1:?}"); let (_, p2) = line_pts(&sk, l[1]); assert!(p2.dist(v(40.0, 30.0)) < 1e-7, "{p2:?}"); // Change a dimension and re-solve. sk.constraints[5].kind.set_value(55.0); assert!(solve(&mut sk).ok()); let (_, p1) = line_pts(&sk, l[0]); assert!(p1.dist(v(55.0, 0.0)) < 1e-7); } #[test] fn under_constrained_reports_dof() { let mut sk = Sketch::new(); let l = sk.add_line(v(0.0, 0.0), v(10.0, 1.0), None, None, None).unwrap(); sk.add_constraint(ConstraintKind::Horizontal { l }, None).unwrap(); let rep = solve(&mut sk); assert!(rep.ok()); assert_eq!(rep.dof, 3); let (a, b) = line_pts(&sk, l); assert!((a.y - b.y).abs() < 1e-9); // Minimal movement: the line moved by about half a unit each. assert!((a.y - 0.5).abs() < 0.05 && (b.y - 0.5).abs() < 0.05, "{a:?} {b:?}"); } #[test] fn conflicting_constraints_fail_and_restore() { let mut sk = Sketch::new(); let l = sk.add_line(v(0.0, 0.0), v(10.0, 0.0), None, None, None).unwrap(); sk.add_constraint(ConstraintKind::Horizontal { l }, None).unwrap(); sk.add_constraint(ConstraintKind::Vertical { l }, None).unwrap(); sk.add_constraint(ConstraintKind::Length { l, value: 10.0 }, None).unwrap(); let before = sk.clone(); let rep = solve(&mut sk); assert_eq!(rep.status, SolveStatus::Failed); assert!(!rep.failing.is_empty()); assert_eq!(sk.points, before.points); } #[test] fn circles_tangent_equal_concentric() { let mut sk = Sketch::new(); let c1 = sk.add_circle(v(0.0, 0.0), 5.0, Some(0), None).unwrap(); let c2 = sk.add_circle(v(12.0, 1.0), 3.0, None, None).unwrap(); use ConstraintKind::*; sk.add_constraint(Radius { c: c1, value: 10.0 }, None).unwrap(); sk.add_constraint(Equal { a: c1, b: c2 }, None).unwrap(); sk.add_constraint(Tangent { a: c1, b: c2 }, None).unwrap(); let rep = solve(&mut sk); assert!(rep.ok(), "{rep:?}"); assert!((sk.radius(c2).unwrap() - 10.0).abs() < 1e-7); assert!((sk.center(c2).unwrap().len() - 20.0).abs() < 1e-7); let c3 = sk.add_circle(v(3.0, 3.0), 2.0, None, None).unwrap(); sk.add_constraint(Concentric { a: c3, b: c2 }, None).unwrap(); assert!(solve(&mut sk).ok()); assert!(sk.center(c3).unwrap().dist(sk.center(c2).unwrap()) < 1e-7); } #[test] fn line_tangent_to_circle_and_angles() { let mut sk = Sketch::new(); let c = sk.add_circle(v(0.0, 0.0), 10.0, Some(0), None).unwrap(); use ConstraintKind::*; sk.add_constraint(Radius { c, value: 10.0 }, None).unwrap(); let l = sk.add_line(v(-20.0, 12.0), v(20.0, 11.0), None, None, None).unwrap(); sk.add_constraint(Tangent { a: l, b: c }, None).unwrap(); sk.add_constraint(Horizontal { l }, None).unwrap(); assert!(solve(&mut sk).ok()); let (a, _) = line_pts(&sk, l); assert!((a.y.abs() - 10.0).abs() < 1e-7, "{a:?}"); let m = sk.add_line(v(0.0, 0.0), v(10.0, 3.0), Some(0), None, None).unwrap(); sk.add_constraint(Angle { a: l, b: m, value: PI / 6.0, flip: false }, None).unwrap(); sk.add_constraint(Length { l: m, value: 8.0 }, None).unwrap(); assert!(solve(&mut sk).ok()); let (p, q) = line_pts(&sk, m); let (la, lb) = line_pts(&sk, l); assert!(((q - p).angle() - (lb - la).angle() - PI / 6.0).abs() < 1e-7, "{la:?} {lb:?} {p:?} {q:?}"); let n2 = sk.add_line(v(1.0, 1.0), v(2.0, 5.0), None, None, None).unwrap(); sk.add_constraint(Perpendicular { a: m, b: n2 }, None).unwrap(); sk.add_constraint(Parallel { a: n2, b: n2 }, None).unwrap(); assert!(solve(&mut sk).ok()); let (p2, q2) = line_pts(&sk, n2); assert!(((q2 - p2).angle() - (PI / 6.0 + FRAC_PI_2)).abs() < 1e-6 || ((q2 - p2).angle() - (PI / 6.0 - FRAC_PI_2)).abs() < 1e-6); } #[test] fn midpoint_symmetry_point_on_curve_arc() { let mut sk = Sketch::new(); use ConstraintKind::*; let l = sk.add_line(v(-5.0, 0.0), v(5.0, 0.0), None, None, None).unwrap(); let p = sk.add_point(v(1.0, 1.0), None).unwrap(); sk.add_constraint(Midpoint { p, l }, None).unwrap(); let a = sk.add_arc(v(0.0, 0.0), v(4.0, 0.0), v(0.0, 4.0), [None, None, None], None).unwrap(); sk.add_constraint(Radius { c: a, value: 6.0 }, None).unwrap(); let q = sk.add_point(v(3.0, 3.0), None).unwrap(); sk.add_constraint(PointOnCurve { p: q, c: a }, None).unwrap(); let s1 = sk.add_point(v(-2.0, 1.0), None).unwrap(); let s2 = sk.add_point(v(3.0, -1.5), None).unwrap(); let axis = sk.add_line(v(0.0, -10.0), v(0.0, 10.0), None, None, None).unwrap(); sk.add_constraint(Vertical { l: axis }, None).unwrap(); sk.add_constraint(Symmetric { p: s1, q: s2, l: axis }, None).unwrap(); let rep = solve(&mut sk); assert!(rep.ok(), "{rep:?}"); let (la, lb) = line_pts(&sk, l); assert!(sk.points[p].pos.dist((la + lb) * 0.5) < 1e-7); assert!((sk.radius(a).unwrap() - 6.0).abs() < 1e-7); let c = sk.center(a).unwrap(); assert!((sk.points[q].pos.dist(c) - 6.0).abs() < 1e-7); let (ax, _) = line_pts(&sk, axis); assert!((sk.points[s1].pos.x + sk.points[s2].pos.x - 2.0 * ax.x).abs() < 1e-7); assert!((sk.points[s1].pos.y - sk.points[s2].pos.y).abs() < 1e-7); } #[test] fn profiles_rectangle_with_hole_and_split() { let mut sk = Sketch::new(); rect(&mut sk, [v(0.0, 0.0), v(40.0, 0.0), v(40.0, 30.0), v(0.0, 30.0)]); sk.add_circle(v(20.0, 15.0), 5.0, None, None).unwrap(); let ps = find_profiles(&sk); assert_eq!(ps.len(), 2, "{ps:?}"); let big = ps.iter().find(|p| p.region.holes.len() == 1).unwrap(); assert!((big.area - (1200.0 - PI * 25.0)).abs() < 1e-6); let disc = ps.iter().find(|p| p.region.holes.is_empty()).unwrap(); assert!((disc.area - PI * 25.0).abs() < 1e-6); assert_eq!(big.hole_curves.len(), 1); // A line across the rectangle splits it into two regions (with the hole in one). let mut sk2 = Sketch::new(); let l = rect(&mut sk2, [v(0.0, 0.0), v(40.0, 0.0), v(40.0, 30.0), v(0.0, 30.0)]); let (b0, _) = line_pts(&sk2, l[0]); let _ = b0; let pa = sk2.add_point(v(10.0, 0.0), None).unwrap(); let pb = sk2.add_point(v(10.0, 30.0), None).unwrap(); // Re-split bottom and top edges at x = 10 by rebuilding the sketch as 7 lines. let mut s3 = Sketch::new(); let pts = [v(0.0, 0.0), v(10.0, 0.0), v(40.0, 0.0), v(40.0, 30.0), v(10.0, 30.0), v(0.0, 30.0)]; let ids: Vec = pts.iter().map(|p| s3.add_point(*p, None).unwrap()).collect(); for i in 0..6 { s3.add_line_pts(ids[i], ids[(i + 1) % 6], None).unwrap(); } s3.add_line_pts(ids[1], ids[4], None).unwrap(); let _ = (pa, pb); let ps = find_profiles(&s3); assert_eq!(ps.len(), 2); let mut areas: Vec = ps.iter().map(|p| p.area).collect(); areas.sort_by(f64::total_cmp); assert!((areas[0] - 300.0).abs() < 1e-9 && (areas[1] - 900.0).abs() < 1e-9, "{areas:?}"); } #[test] fn profile_with_arcs_and_construction_ignored() { // Slot: two lines and two half arcs. let mut sk = Sketch::new(); let p = [v(0.0, 0.0), v(20.0, 0.0), v(20.0, 10.0), v(0.0, 10.0)]; let ids: Vec = p.iter().map(|q| sk.add_point(*q, None).unwrap()).collect(); sk.add_line_pts(ids[0], ids[1], None).unwrap(); sk.add_arc(v(20.0, 5.0), p[1], p[2], [None, Some(ids[1]), Some(ids[2])], None).unwrap(); sk.add_line_pts(ids[2], ids[3], None).unwrap(); sk.add_arc(v(0.0, 5.0), p[3], p[0], [None, Some(ids[3]), Some(ids[0])], None).unwrap(); let c = sk.add_line(v(-5.0, -5.0), v(30.0, 20.0), None, None, None).unwrap(); sk.curves[c].construction = true; let ps = find_profiles(&sk); assert_eq!(ps.len(), 1, "{ps:?}"); assert!((ps[0].area - (200.0 + PI * 25.0)).abs() < 1e-6, "{}", ps[0].area); assert_eq!(ps[0].outer_curves.len(), 4); } #[test] fn removal_and_references() { let mut sk = Sketch::new(); let l1 = sk.add_line(v(0.0, 0.0), v(1.0, 0.0), None, None, Some("l1")).unwrap(); let e = sk.resolve_point("l1.end").unwrap(); let l2 = sk.add_line(v(1.0, 0.0), v(1.0, 1.0), Some(e), None, Some("l2")).unwrap(); sk.add_constraint(ConstraintKind::Horizontal { l: l1 }, None).unwrap(); sk.add_constraint(ConstraintKind::Vertical { l: l2 }, None).unwrap(); assert!(sk.add_line(v(0.0, 0.0), v(1.0, 0.0), None, None, Some("l1")).is_err()); sk.remove_curves(&[l1]); assert_eq!(sk.curves.len(), 1); assert_eq!(sk.constraints.len(), 1); assert!(sk.resolve_point("l2.start").is_some()); assert!(sk.resolve_point("l1.start").is_none()); assert_eq!(sk.points.len(), 3, "origin + l2's two points"); assert!(solve(&mut sk).ok()); let json = serde_json::to_string(&sk).unwrap(); let back: Sketch = serde_json::from_str(&json).unwrap(); assert_eq!(back, sk); } #[test] fn hostile_values_rejected() { let mut sk = Sketch::new(); assert!(sk.add_point(v(f64::NAN, 0.0), None).is_err()); assert!(sk.add_circle(v(0.0, 0.0), -1.0, None, None).is_err()); assert!(sk.add_circle(v(0.0, 0.0), f64::INFINITY, None, None).is_err()); assert!(sk.add_constraint(ConstraintKind::Horizontal { l: 99 }, None).is_err()); let c = sk.add_circle(v(0.0, 0.0), 1.0, None, None).unwrap(); assert!(sk.add_constraint(ConstraintKind::Horizontal { l: c }, None).is_err()); assert!(sk.add_constraint(ConstraintKind::Radius { c, value: f64::NAN }, None).is_err()); assert!(sk.add_constraint(ConstraintKind::Radius { c, value: -2.0 }, None).is_err()); } #[test] fn profiles_split_at_crossings_and_touch_points() { // A line across a circle: two half discs. let mut sk = Sketch::new(); sk.add_circle(v(0.0, 0.0), 10.0, None, None).unwrap(); sk.add_line(v(-15.0, 0.0), v(15.0, 0.0), None, None, None).unwrap(); let ps = find_profiles(&sk); assert_eq!(ps.len(), 2, "{ps:?}"); for p in &ps { assert!((p.area - PI * 50.0).abs() < 1e-6, "{}", p.area); } // Belt: two circles joined by two tangent lines whose ends sit on the circles. let mut b = Sketch::new(); let c1 = b.add_circle(v(0.0, 0.0), 20.0, Some(0), None).unwrap(); let c2 = b.add_circle(v(50.0, 0.0), 10.0, None, None).unwrap(); let l1 = b.add_line(v(3.0, 19.0), v(47.0, 13.0), None, None, None).unwrap(); let l2 = b.add_line(v(1.0, -22.0), v(44.0, -8.0), None, None, None).unwrap(); use ConstraintKind::*; for (l, end, c) in [(l1, "start", c1), (l1, "end", c2), (l2, "start", c1), (l2, "end", c2)] { let p = b.resolve_point(&format!("{}.{end}", b.curves[l].id)).unwrap(); b.add_constraint(PointOnCurve { p, c }, None).unwrap(); } for (l, c) in [(l1, c1), (l1, c2), (l2, c1), (l2, c2)] { b.add_constraint(Tangent { a: l, b: c }, None).unwrap(); } b.add_constraint(Radius { c: c1, value: 20.0 }, None).unwrap(); b.add_constraint(Radius { c: c2, value: 10.0 }, None).unwrap(); let r = solve(&mut b); assert!(r.ok(), "{r:?}"); let ps = find_profiles(&b); assert_eq!(ps.len(), 3, "two discs and the band between them: {:?}", ps.iter().map(|p| p.area).collect::>()); let total: f64 = ps.iter().map(|p| p.area).sum(); assert!(total > PI * 500.0, "{total}"); } /// Curves made tangent by the solver touch within rounding noise, either side of exact (#588): /// the touch still splits them. Lines running past their tangent points close the band between /// two circles, and a square's side tangent to a circle closes the square off from the disc. #[test] fn solver_tangencies_split_profiles() { use ConstraintKind::*; // Two fixed circles and two free lines, longer than the band, made tangent to both. for (r1, r2, d) in [(15.0, 8.0, 40.0), (20.0, 10.0, 50.0), (7.3, 7.3, 21.1)] { let mut b = Sketch::new(); let c1 = b.add_circle(v(0.0, 0.0), r1, None, None).unwrap(); let c2 = b.add_circle(v(d, 0.0), r2, None, None).unwrap(); let l1 = b.add_line(v(-r1, r1 + 2.0), v(d + r2 + 5.0, r2 - 1.0), None, None, None).unwrap(); let l2 = b.add_line(v(-r1, -r1 - 2.0), v(d + r2 + 5.0, 1.0 - r2), None, None, None).unwrap(); for c in [c1, c2] { let p = b.resolve_point(&format!("{}.center", b.curves[c].id)).unwrap(); b.add_constraint(Fix { p }, None).unwrap(); } b.add_constraint(Radius { c: c1, value: r1 }, None).unwrap(); b.add_constraint(Radius { c: c2, value: r2 }, None).unwrap(); for (l, c) in [(l1, c1), (l1, c2), (l2, c1), (l2, c2)] { b.add_constraint(Tangent { a: l, b: c }, None).unwrap(); } assert!(solve(&mut b).ok()); let ps = find_profiles(&b); // The band: the trapezoid between the tangent points less the circular segments the // circles take from it (as exact sums: the hull less the two discs). let alpha = ((r1 - r2) / d).asin(); let tangent_len = (d * d - (r1 - r2).powi(2)).sqrt(); let hull = r1 * r1 * (PI / 2.0 + alpha) + r2 * r2 * (PI / 2.0 - alpha) + (r1 + r2) * tangent_len; let total: f64 = ps.iter().map(|p| p.area).sum(); assert_eq!(ps.len(), 3, "r {r1}, {r2}: {:?}", ps.iter().map(|p| p.area).collect::>()); assert!((total - hull).abs() < 1e-6 * hull, "r {r1}, {r2}: {total} vs {hull}"); } // A square tangent to a circle inside it and outside it, the circle placed by the solver. for (inside, centre) in [(true, v(5.0, 6.3)), (false, v(5.0, 16.7))] { let mut s = Sketch::new(); let sides = [v(0.0, 0.0), v(10.0, 0.0), v(10.0, 10.0), v(0.0, 10.0)]; let mut lines = Vec::new(); for k in 0..4 { lines.push(s.add_line(sides[k], sides[(k + 1) % 4], None, None, None).unwrap()); } for k in 0..4 { let (a, b) = ( s.resolve_point(&format!("{}.end", s.curves[lines[k]].id)).unwrap(), s.resolve_point(&format!("{}.start", s.curves[lines[(k + 1) % 4]].id)).unwrap(), ); s.add_constraint(Coincident { p: a, q: b }, None).unwrap(); s.add_constraint(Fix { p: a }, None).unwrap(); } let c = s.add_circle(centre, 3.7, None, None).unwrap(); s.add_constraint(Radius { c, value: 3.7 }, None).unwrap(); s.add_constraint(Tangent { a: lines[2], b: c }, None).unwrap(); assert!(solve(&mut s).ok()); let ps = find_profiles(&s); let disc = PI * 3.7 * 3.7; let mut areas: Vec = ps.iter().map(|p| p.area).collect(); areas.sort_by(f64::total_cmp); let want = if inside { [disc, 100.0 - disc] } else { [disc, 100.0] }; let mut want = want.to_vec(); want.sort_by(f64::total_cmp); assert_eq!(areas.len(), 2, "inside {inside}: {areas:?}"); for (a, w) in areas.iter().zip(&want) { assert!((a - w).abs() < 1e-6, "inside {inside}: {areas:?} vs {want:?}"); } } } /// Sorted curve ids. fn id_set(sk: &Sketch, curves: &[usize]) -> Vec { let mut ids: Vec = curves.iter().map(|c| sk.curves[*c].id.clone()).collect(); ids.sort(); ids } /// A projected line of `a`–`b` in `sk`. fn reference_line(sk: &mut Sketch, a: Vec2, b: Vec2) -> usize { let link = sk.add_link(LinkKind::Project, LinkSource::SketchCurve { sketch: 1, curve: "edge".into() }, &[LinkGeom::Line(a, b)]).unwrap(); sk.link_curves(&link)[0] } /// #635: a side constrained onto a longer projected line lies on it only up to the solver's /// round-off. Where they overlap they are one edge of the drawing, so the rectangle is still a /// closed profile, and the curve that came first names that edge, as when the side lies on the /// line exactly. #[test] fn a_side_on_a_projected_line_still_closes_its_rectangle() { // Round-off left on the side's two ends (the first pair as solved in #635), and none. for (y0, y1) in [(1.6e-12, 2.2e-12), (2.2e-12, 1.6e-12), (-1e-12, 1e-12), (1e-12, -1e-12), (-3e-11, -1e-11), (-3e-10, 3e-10), (2e-10, 4e-10), (0.0, 0.0)] { for reference_first in [true, false] { for (ra, rb) in [(v(40.0, 0.0), v(-10.0, 0.0)), (v(-10.0, 0.0), v(40.0, 0.0))] { let mut sk = Sketch::new(); let early = reference_first.then(|| reference_line(&mut sk, ra, rb)); let sides = rect(&mut sk, [v(5.0, y0), v(25.0, y1), v(25.0, 15.0), v(5.0, 15.0)]); let reference = early.unwrap_or_else(|| reference_line(&mut sk, ra, rb)); let case = format!("ends at y {y0}, {y1}; reference {} {ra:?} to {rb:?}", if reference_first { "first" } else { "last" }); let ps = find_profiles(&sk); assert_eq!(ps.len(), 1, "{case}: {:?}", ps.iter().map(|p| (p.area, &p.outer_curves)).collect::>()); assert!((ps[0].area - 300.0).abs() < 1e-6, "{case}: {}", ps[0].area); let mut got = ps[0].outer_curves.clone(); got.sort(); let want = id_set(&sk, &[if reference_first { reference } else { sides[0] }, sides[1], sides[2], sides[3]]); assert_eq!(got, want, "{case}"); // Whole curves make a lasting identity; a piece of the reference cut at the corners does not. let whole = (!reference_first).then(|| (want, Vec::new())); assert_eq!(ps[0].whole_curve_boundaries(), whole, "{case}"); } } } } /// A third curve meeting a side and the projected line under it would meet each a little apart: /// a circle touching the line from inside the rectangle (it misses or crosses the side by the /// round-off), and a line crossing both at a shallow angle (the crossings 3e-5 mm apart). It /// meets both where it meets the first of them, so they still make one edge, whichever way the /// side runs. #[test] fn a_curve_meeting_a_side_on_a_projected_line_meets_both_at_once() { for y0 in [3e-10, -3e-10, 0.0] { for reference_first in [true, false] { for reversed in [false, true] { let case = format!("side at y {y0}, reference {}, side reversed {reversed}", if reference_first { "first" } else { "last" }); let corners = |x1: f64| { let (a, b) = if reversed { (x1, 5.0) } else { (5.0, x1) }; [v(a, y0), v(b, y0), v(b, 15.0), v(a, 15.0)] }; let mut sk = Sketch::new(); let early = reference_first.then(|| reference_line(&mut sk, v(-10.0, 0.0), v(40.0, 0.0))); rect(&mut sk, corners(25.0)); sk.add_circle(v(15.0, 5.0), 5.0, None, None).unwrap(); early.unwrap_or_else(|| reference_line(&mut sk, v(-10.0, 0.0), v(40.0, 0.0))); let mut areas: Vec = find_profiles(&sk).iter().map(|p| p.area).collect(); areas.sort_by(f64::total_cmp); assert_eq!(areas.len(), 2, "touching circle, {case}: {areas:?}"); assert!((areas[0] - 25.0 * PI).abs() < 1e-6 && (areas[1] - (300.0 - 25.0 * PI)).abs() < 1e-6, "touching circle, {case}: {areas:?}"); let mut sk = Sketch::new(); let early = reference_first.then(|| reference_line(&mut sk, v(-10.0, 0.0), v(440.0, 0.0))); rect(&mut sk, corners(405.0)); // Through (200, 0), rising 1e-5 per mm: a sliver of 205 x 0.00205 / 2 under it. sk.add_line(v(0.0, -0.002), v(430.0, 0.0023), None, None, None).unwrap(); early.unwrap_or_else(|| reference_line(&mut sk, v(-10.0, 0.0), v(440.0, 0.0))); let mut areas: Vec = find_profiles(&sk).iter().map(|p| p.area).collect(); areas.sort_by(f64::total_cmp); assert_eq!(areas.len(), 2, "shallow line, {case}: {areas:?}"); assert!((areas[0] - 0.210125).abs() < 1e-6 && (areas[1] - (6000.0 - 0.210125)).abs() < 1e-6, "shallow line, {case}: {areas:?}"); } } } } /// The same for drawn curves on drawn curves: two rectangles drawn side by side, the second's /// side solved onto the first's; and a quarter disc whose arc is solved onto a projected circle. #[test] fn curves_solved_onto_each_other_keep_the_regions_on_both_sides() { for (x0, x1) in [(1e-12, -1e-12), (-2e-12, -1e-12), (2.2e-12, 1.6e-12), (0.0, 0.0)] { let mut sk = Sketch::new(); let a = rect(&mut sk, [v(0.0, 0.0), v(20.0, 0.0), v(20.0, 15.0), v(0.0, 15.0)]); let b = rect(&mut sk, [v(20.0 + x0, 0.0), v(40.0, 0.0), v(40.0, 15.0), v(20.0 + x1, 15.0)]); let ps = find_profiles(&sk); let areas: Vec = ps.iter().map(|p| p.area).collect(); assert_eq!(ps.len(), 2, "{x0}, {x1}: {areas:?}"); assert!(areas.iter().all(|a| (a - 300.0).abs() < 1e-6), "{x0}, {x1}: {areas:?}"); // The shared side is the first rectangle's (it was there first). let mut first = ps[0].outer_curves.clone(); first.sort(); assert_eq!(first, id_set(&sk, &a), "{x0}, {x1}"); let mut second = ps[1].outer_curves.clone(); second.sort(); assert_eq!(second, id_set(&sk, &[b[0], b[1], b[2], a[1]]), "{x0}, {x1}"); } let hole = |sk: &mut Sketch| { let link = sk .add_link(LinkKind::Project, LinkSource::SketchCurve { sketch: 1, curve: "hole".into() }, &[LinkGeom::Circle(v(0.0, 0.0), 10.0)]) .unwrap(); sk.link_curves(&link)[0] }; for (dx, dr) in [(1e-12, 2e-12), (-1e-12, -1e-12), (0.0, 0.0)] { for circle_first in [true, false] { let mut sk = Sketch::new(); let early = circle_first.then(|| hole(&mut sk)); let c = sk.add_point(v(dx, -dx), None).unwrap(); let (p0, p1) = (sk.add_point(v(10.0 + dr, 0.0), None).unwrap(), sk.add_point(v(0.0, 10.0 + dr), None).unwrap()); let arc = sk.add_arc(v(dx, -dx), v(10.0 + dr, 0.0), v(0.0, 10.0 + dr), [Some(c), Some(p0), Some(p1)], None).unwrap(); let (l0, l1) = (sk.add_line_pts(c, p0, None).unwrap(), sk.add_line_pts(p1, c, None).unwrap()); let circle = early.unwrap_or_else(|| hole(&mut sk)); let case = format!("{dx}, {dr}, circle {}", if circle_first { "first" } else { "last" }); let ps = find_profiles(&sk); let areas: Vec = ps.iter().map(|p| p.area).collect(); assert_eq!(ps.len(), 2, "{case}: {areas:?}"); let quarter = ps.iter().find(|p| p.area < 100.0).unwrap(); assert!((quarter.area - 25.0 * PI).abs() < 1e-6, "{case}: {areas:?}"); // One piece each: round-off alone does not cut the arc where it crosses the circle. let mut got = quarter.outer_curves.clone(); got.sort(); assert_eq!(got, id_set(&sk, &[if circle_first { circle } else { arc }, l0, l1]), "{case}"); let rest = ps.iter().find(|p| p.area > 100.0).unwrap(); assert!((rest.area - 75.0 * PI).abs() < 1e-6, "{case}: {areas:?}"); } } } /// What does not run along one path stays apart: a chord and an arc bulging past it by about /// the merge tolerance still bound their thin region, and two whole circles further apart than /// it are still cut where they cross, into a lens and two crescents. Closer than it, their /// halves are one path: one disc. #[test] fn near_overlaps_that_are_not_one_path_keep_their_regions() { let s = 1.5e-5; let r = (25.0 + s * s) / (2.0 * s); let mut sk = Sketch::new(); let (a, b) = (sk.add_point(v(-5.0, 0.0), None).unwrap(), sk.add_point(v(5.0, 0.0), None).unwrap()); let chord = sk.add_line_pts(a, b, None).unwrap(); let arc = sk.add_arc(v(0.0, s - r), v(5.0, 0.0), v(-5.0, 0.0), [None, Some(b), Some(a)], None).unwrap(); let ps = find_profiles(&sk); assert_eq!(ps.len(), 1, "{:?}", ps.iter().map(|p| p.area).collect::>()); // Two thirds of chord times height, to well within the rounding of the area. assert!((ps[0].area - 2.0 / 3.0 * 10.0 * s).abs() < 1e-8, "{}", ps[0].area); let mut got = ps[0].outer_curves.clone(); got.sort(); assert_eq!(got, id_set(&sk, &[chord, arc])); // The same with a free-form curve: a flat cubic (height s) over its chord. It takes part as // sampled pieces, one of them on the chord, but its exact curve bounds the region. let s = 8e-6; let mut sk = Sketch::new(); let ids: Vec = [v(-5.0, 0.0), v(-5.0 / 3.0, 4.0 * s / 3.0), v(5.0 / 3.0, 4.0 * s / 3.0), v(5.0, 0.0)] .iter() .map(|p| sk.add_point(*p, None).unwrap()) .collect(); sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: true, degree: 3, periodic: false }, None).unwrap(); sk.add_line_pts(ids[3], ids[0], None).unwrap(); let ps = find_profiles(&sk); assert_eq!(ps.len(), 1, "{:?}", ps.iter().map(|p| p.area).collect::>()); assert!((ps[0].area - 20.0 * s / 3.0).abs() < 1e-9, "{}", ps[0].area); // An arc a little off a circle, more than the merge tolerance apart at its ends (so they // don't cut the circle): the two still cut each other where they cross, and no profile is // the uncut circle. let mut sk = Sketch::new(); let circle = sk.add_circle(v(0.0, 0.0), 10.0, None, None).unwrap(); let (c, r) = (v(8e-6, 0.0), 10.0 + 6e-6); let (p0, p1) = (c + Vec2::from_angle(PI / 4.0) * r, c + Vec2::from_angle(-PI / 4.0) * r); let (a0, a1, tip) = (sk.add_point(p0, None).unwrap(), sk.add_point(p1, None).unwrap(), sk.add_point(v(30.0, 0.0), None).unwrap()); sk.add_arc(c, p0, p1, [None, Some(a0), Some(a1)], None).unwrap(); sk.add_line_pts(a1, tip, None).unwrap(); sk.add_line_pts(tip, a0, None).unwrap(); let ps = find_profiles(&sk); let circle_id = sk.curves[circle].id.clone(); assert!(ps.iter().all(|p| p.outer_curves != [circle_id.clone()] && p.whole_curve_boundaries().is_none()), "{ps:?}"); // Profiles don't overlap: together they cover the disc and the arc's region once, that is the // disc and the triangle to the tip less the disc's segment inside it (to within the slivers // between arc and circle). let beyond = 0.5 * (p0 - v(30.0, 0.0)).cross(p1 - v(30.0, 0.0)).abs() - 50.0 * (PI / 2.0 - 1.0); let covered = ps.iter().map(|p| p.area).sum::(); assert!((covered - PI * 100.0 - beyond).abs() < 1e-2, "{:?}", ps.iter().map(|p| p.area).collect::>()); let r = 10.0_f64; for (d, regions) in [(1e-3, 3), (1e-4, 3), (8e-6, 1)] { let mut sk = Sketch::new(); let first = sk.add_circle(v(0.0, 0.0), r, None, None).unwrap(); sk.add_circle(v(d, 0.0), r, None, None).unwrap(); let ps = find_profiles(&sk); let areas: Vec = ps.iter().map(|p| p.area).collect(); assert_eq!(ps.len(), regions, "{d}: {areas:?}"); let lens = 2.0 * r * r * (d / (2.0 * r)).acos() - d / 2.0 * (4.0 * r * r - d * d).sqrt(); let union = if regions == 1 { PI * r * r } else { 2.0 * PI * r * r - lens }; assert!((areas.iter().sum::() - union).abs() < 1e-6, "{d}: {areas:?}"); // Cut where they cross: no whole-circle identity. assert!(ps.iter().all(|p| p.whole_curve_boundaries().is_none()), "{d}"); if regions == 1 { assert_eq!(ps[0].outer_curves, vec![sk.curves[first].id.clone(); 2], "{d}: the first circle's halves"); } } } /// Curves count as lying on one another only within the merge tolerance of the one carrying /// them, not along a chain: an arc between two whole circles 1.6e-5 apart (each within 8e-6 of /// it) does not stop the circles cutting each other where they cross. #[test] fn curves_lying_on_one_another_do_not_chain_past_the_tolerance() { let (d, r) = (16e-6, 10.0_f64); let mut sk = Sketch::new(); sk.add_circle(v(0.0, 0.0), r, None, None).unwrap(); sk.add_circle(v(d, 0.0), r, None, None).unwrap(); let c = v(d / 2.0, 0.0); let (p0, p1) = (c + Vec2::from_angle(PI / 4.0) * r, c + Vec2::from_angle(3.0 * PI / 4.0) * r); sk.add_arc(c, p0, p1, [None, None, None], None).unwrap(); let areas: Vec = find_profiles(&sk).iter().map(|p| p.area).collect(); let lens = 2.0 * r * r * (d / (2.0 * r)).acos() - d / 2.0 * (4.0 * r * r - d * d).sqrt(); // The discs' union once, not each disc whole. assert!((areas.iter().sum::() - (2.0 * PI * r * r - lens)).abs() < 1e-6, "{areas:?}"); // Two dividers of a square, each carried by a short line 8e-6 off it that does not reach // their crossing: they still cross at one node, and the square has four quarters. let mut sk = Sketch::new(); sk.add_line(v(-20.0, 0.0), v(-10.0, 0.0), None, None, None).unwrap(); sk.add_line(v(0.0, -20.0), v(0.0, -10.0), None, None, None).unwrap(); rect(&mut sk, [v(-15.0, -15.0), v(15.0, -15.0), v(15.0, 15.0), v(-15.0, 15.0)]); sk.add_line(v(-15.0, 8e-6), v(15.0, 8e-6), None, None, None).unwrap(); sk.add_line(v(8e-6, -15.0), v(8e-6, 15.0), None, None, None).unwrap(); let areas: Vec = find_profiles(&sk).iter().map(|p| p.area).collect(); assert_eq!(areas.len(), 4, "{areas:?}"); assert!((areas.iter().sum::() - 900.0).abs() < 1e-6, "{areas:?}"); } #[test] fn merging_adjacent_regions() { let a = solidcraft_geom::Region2 { outer: solidcraft_geom::Loop2::polygon(&[v(0.0, 0.0), v(10.0, 0.0), v(10.0, 10.0), v(0.0, 10.0)]), holes: vec![] }; let b = solidcraft_geom::Region2 { outer: solidcraft_geom::Loop2::polygon(&[v(10.0, 0.0), v(20.0, 0.0), v(20.0, 10.0), v(10.0, 10.0)]), holes: vec![], }; let c = solidcraft_geom::Region2 { outer: solidcraft_geom::Loop2::polygon(&[v(50.0, 0.0), v(60.0, 0.0), v(60.0, 10.0)]), holes: vec![] }; let m = merge_regions(&[a, b, c]); assert_eq!(m.len(), 2); let mut areas: Vec = m.iter().map(|r| r.area()).collect(); areas.sort_by(f64::total_cmp); assert!((areas[0] - 50.0).abs() < 1e-9 && (areas[1] - 200.0).abs() < 1e-9, "{areas:?}"); // The two squares make one rectangle: the outline isn't left split where the shared edge was. let sides = m.iter().map(|r| r.outer.segs.len()).collect::>(); assert!(sides.contains(&4) && sides.contains(&3), "{sides:?}"); } /// A sketch holding `text` the way `sketch.text` makes it: one text link of the built-in font's /// outlines. fn text_sketch(text: &str, at: Vec2, height: f64) -> Sketch { let mut sk = Sketch::new(); let source = LinkSource::Text { text: text.into(), at, height, angle: 0.0, font: None, style: Default::default() }; sk.add_link(LinkKind::Text, source, &text_geometry(text, at, height, 0.0).unwrap()).unwrap(); sk } fn sorted_areas(ps: &[Profile]) -> Vec { let mut a: Vec = ps.iter().map(|p| p.area).collect(); a.sort_by(f64::total_cmp); a } fn assert_areas(got: &[f64], mut want: Vec) { want.sort_by(f64::total_cmp); assert_eq!(got.len(), want.len(), "{got:?} against {want:?}"); for (g, w) in got.iter().zip(&want) { assert!((g - w).abs() < 2e-3, "{got:?} against {want:?}"); } } #[test] fn text_counters_are_holes_of_their_letters_not_profiles_of_their_own() { // Areas in mm² at a capital height of 10 mm, summed from the Liberation Sans contours (shoelace // formula, quadratic pieces flattened finely): a glyph's outer contour, then its counters. // O: 83.3133 - 47.4584 A: 39.2159 - 9.0647 B: 68.9283 - 12.9336 - 16.6277 // 8: 56.8394 - 10.6451 - 13.7347 let (o, o_counter) = (83.3133 - 47.4584, 47.4584); let (a, a_counter) = (39.2159 - 9.0647, 9.0647); let (b, b_counters) = (68.9283 - 12.9336 - 16.6277, [12.9336, 16.6277]); let (eight, eight_counters) = (56.8394 - 10.6451 - 13.7347, [10.6451, 13.7347]); let check = |text: &str, letters: Vec, counters: Vec| { let sk = text_sketch(text, Vec2::ZERO, 10.0); let all = find_drawn_profiles(&sk); // Every counter is a closed region of its own, next to its letter with the hole in it... assert_areas(&sorted_areas(&all), letters.iter().chain(&counters).copied().collect()); // ...and "all profiles" of text means the letters. assert_areas(&sorted_areas(&without_text_counters(&sk, all)), letters); }; check("O", vec![o], vec![o_counter]); check("8", vec![eight], eight_counters.to_vec()); check("AB", vec![a, b], [vec![a_counter], b_counters.to_vec()].concat()); // A letter without a counter is untouched. check("I", vec![13.5557], vec![]); } #[test] fn text_beside_other_curves_is_drawn_geometry() { // A 30 x 20 rectangle with an "I" (a 10 mm tall bar, 13.5557 mm² in Liberation Sans) beside it. let mut sk = text_sketch("I", v(40.0, 0.0), 10.0); for (p, q) in [((0.0, 0.0), (30.0, 0.0)), ((30.0, 0.0), (30.0, 20.0)), ((30.0, 20.0), (0.0, 20.0)), ((0.0, 20.0), (0.0, 0.0))] { sk.add_line(v(p.0, p.1), v(q.0, q.1), None, None, None).unwrap(); } assert_areas(&sorted_areas(&find_drawn_profiles(&sk)), vec![13.5557, 600.0]); // Projected reference geometry still doesn't count as drawn. let square = |s: f64| { vec![ LinkGeom::Line(v(100.0, 0.0), v(100.0 + s, 0.0)), LinkGeom::Line(v(100.0 + s, 0.0), v(100.0 + s, s)), LinkGeom::Line(v(100.0 + s, s), v(100.0, s)), LinkGeom::Line(v(100.0, s), v(100.0, 0.0)), ] }; sk.add_link(LinkKind::Project, LinkSource::Origin, &square(5.0)).unwrap(); assert_eq!(find_profiles(&sk).len(), 3); assert_areas(&sorted_areas(&find_drawn_profiles(&sk)), vec![13.5557, 600.0]); } #[test] fn a_letter_merged_with_its_counter_is_the_filled_letter() { // The ring of an O and its counter share the counter's two conics; merged, they cancel along it. let sk = text_sketch("O", Vec2::ZERO, 10.0); let regions: Vec<_> = find_profiles(&sk).into_iter().map(|p| p.region).collect(); assert_eq!(regions.len(), 2); let merged = merge_regions(®ions); assert_eq!(merged.len(), 1, "{} regions", merged.len()); assert!(merged[0].holes.is_empty(), "{} holes", merged[0].holes.len()); assert!((merged[0].area() - 83.3133).abs() < 2e-3, "{}", merged[0].area()); } #[test] fn links_update_in_place_rebuild_and_break() { use crate::{LinkGeom, LinkKind, LinkSource}; let mut sk = Sketch::new(); let src = LinkSource::Origin; let sq = |s: f64| { vec![ LinkGeom::Line(v(0.0, 0.0), v(s, 0.0)), LinkGeom::Line(v(s, 0.0), v(s, s)), LinkGeom::Line(v(s, s), v(0.0, s)), LinkGeom::Line(v(0.0, s), v(0.0, 0.0)), ] }; let j = sk.add_link(LinkKind::Project, src, &sq(10.0)).unwrap(); assert_eq!(sk.link_curves(&j).len(), 4); // Corners are shared: 4 points, all fixed. let pts = sk.link_points(&j); assert_eq!(pts.len(), 4); assert!(pts.iter().all(|p| sk.points[*p].fixed)); // A free line dimensioned to a projected corner keeps its constraint across an update. let l = sk.add_line(v(3.0, 3.0), v(5.0, 7.0), None, None, None).unwrap(); let (la, _) = match sk.curves[l].kind { CurveKind::Line { a, b } => (a, b), _ => panic!(), }; sk.add_constraint(ConstraintKind::Coincident { p: la, q: pts[2] }, None).unwrap(); let ids: Vec = sk.link_curves(&j).iter().map(|c| sk.curves[*c].id.clone()).collect(); assert!(!sk.update_link(&j, &sq(20.0)).unwrap(), "same shape updates in place"); let ids2: Vec = sk.link_curves(&j).iter().map(|c| sk.curves[*c].id.clone()).collect(); assert_eq!(ids, ids2); assert!(solve(&mut sk).ok()); assert!(sk.points[la].pos.dist(v(20.0, 20.0)) < 1e-9); assert_eq!(find_profiles(&sk).len(), 1); // A different shape rebuilds (the coincidence on the old corner goes). assert!(sk.update_link(&j, &[LinkGeom::Circle(v(1.0, 1.0), 4.0)]).unwrap()); assert_eq!(sk.link_curves(&j).len(), 1); assert_eq!(sk.constraints.len(), 0); // Linked circles keep their radius in the solver. let c = sk.link_curves(&j)[0]; let rep = solve(&mut sk); assert!(rep.curve_determined[c], "{rep:?}"); // Breaking the link frees it. sk.break_link(&j).unwrap(); assert!(sk.links.is_empty()); assert!(sk.curves[c].link.is_none()); let rep = solve(&mut sk); assert!(!rep.curve_determined[c]); // Deleting all of a link's curves removes the link. let j2 = sk.add_link(LinkKind::Project, LinkSource::Origin, &[LinkGeom::Line(v(0.0, 0.0), v(1.0, 0.0))]).unwrap(); let cs = sk.link_curves(&j2); sk.remove_curves(&cs); assert!(sk.link(&j2).is_none()); // Nothing to project is an error, not an empty link. assert!(sk.add_link(LinkKind::Project, LinkSource::Origin, &[LinkGeom::Line(v(0.0, 0.0), v(0.0, 0.0))]).is_err()); // Round trip through JSON. let j3 = sk.add_link(LinkKind::Intersect, LinkSource::Axis { name: "X".into() }, &[LinkGeom::Point(v(2.0, 2.0))]).unwrap(); let back: Sketch = serde_json::from_str(&serde_json::to_string(&sk).unwrap()).unwrap(); assert_eq!(back, sk); assert_eq!(back.link(&j3).unwrap().kind, LinkKind::Intersect); } /// Many independent constrained rectangles solve quickly (per-component work). #[test] fn large_sketch_solves_fast() { let mut sk = Sketch::new(); for i in 0..150 { let o = v(i as f64 * 50.0, 0.0); let l = rect(&mut sk, [o, o + v(40.0, 0.5), o + v(40.5, 30.0), o + v(-0.3, 30.2)]); use ConstraintKind::*; sk.add_constraint(Horizontal { l: l[0] }, None).unwrap(); sk.add_constraint(Horizontal { l: l[2] }, None).unwrap(); sk.add_constraint(Vertical { l: l[1] }, None).unwrap(); sk.add_constraint(Vertical { l: l[3] }, None).unwrap(); sk.add_constraint(Length { l: l[0], value: 40.0 }, None).unwrap(); sk.add_constraint(Length { l: l[1], value: 30.0 }, None).unwrap(); } let t = std::time::Instant::now(); let rep = solve(&mut sk); let ms = t.elapsed().as_millis(); assert!(rep.ok()); assert_eq!(rep.dof, 300); assert!(ms < 10_000, "{ms} ms"); eprintln!("large sketch: {ms} ms"); } #[test] fn long_connected_chain_solves() { let mut sk = Sketch::new(); let n = 300; let mut prev = 0usize; for i in 0..n { let p = sk.add_point(v((i + 1) as f64 * 10.0 + 0.3, if i % 2 == 0 { 0.4 } else { -0.2 }), None).unwrap(); let l = sk.add_line_pts(prev, p, None).unwrap(); sk.add_constraint(ConstraintKind::Horizontal { l }, None).unwrap(); sk.add_constraint(ConstraintKind::Length { l, value: 10.0 }, None).unwrap(); prev = p; } let t = std::time::Instant::now(); let rep = solve(&mut sk); let ms = t.elapsed().as_millis(); eprintln!("chain: {ms} ms, iters {}", rep.iterations); assert!(rep.ok(), "{rep:?}"); assert_eq!(rep.dof, 0); assert!(ms < 10_000, "{ms} ms"); } /// Free-form curves stay exact in profiles: a degree-5 spline closed by a line, and a fit spline /// cut in two by a line through it (each side gets an exact sub-curve ending on the line). #[test] fn exact_profiles_for_high_degree_and_cut_free_form_curves() { use solidcraft_geom::Seg2; let exact_loop = |p: &Profile| p.region.outer.segs.iter().all(|s| !matches!(s, Seg2::Line { .. }) || s.length() > 1.0); let closed = |p: &Profile| { let s = &p.region.outer.segs; s.iter().zip(s.iter().cycle().skip(1)).all(|(a, b)| a.end().dist(b.start()) < 1e-9) }; // Degree 5 over six points, closed by a line. let mut sk = Sketch::default(); let ids: Vec = [v(0.0, 0.0), v(4.0, 10.0), v(10.0, 12.0), v(16.0, 12.0), v(22.0, 10.0), v(26.0, 0.0)] .iter() .map(|p| sk.add_point(*p, None).unwrap()) .collect(); sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: true, degree: 5, periodic: false }, None).unwrap(); sk.add_line_pts(ids[5], ids[0], None).unwrap(); let pr = find_profiles(&sk); assert_eq!(pr.len(), 1); assert!(pr[0].region.outer.segs.iter().any(|s| matches!(s, Seg2::Bezier { n: 5, .. })), "{:?}", pr[0].region.outer.segs); assert!(closed(&pr[0])); // A fit spline arch over a base line, cut by a vertical line at x = 10. let mut sk = Sketch::default(); let ids: Vec = [v(0.0, 0.0), v(6.0, 9.0), v(14.0, 9.0), v(20.0, 0.0)].iter().map(|p| sk.add_point(*p, None).unwrap()).collect(); sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: false, degree: 3, periodic: false }, None).unwrap(); sk.add_line_pts(ids[3], ids[0], None).unwrap(); let (a, b) = (sk.add_point(v(10.0, -5.0), None).unwrap(), sk.add_point(v(10.0, 20.0), None).unwrap()); sk.add_line_pts(a, b, None).unwrap(); let pr = find_profiles(&sk); assert_eq!(pr.len(), 2, "{pr:?}"); let whole: f64 = pr.iter().map(|p| p.area).sum(); for p in &pr { assert!(closed(p), "{:?}", p.region.outer.segs); assert!(exact_loop(p), "polyline pieces left: {:?}", p.region.outer.segs); // The crossing lies on the line x = 10. assert!( p.region .outer .segs .iter() .any(|s| matches!(s, Seg2::Cubic { .. }) && ((s.start().x - 10.0).abs() < 1e-9 || (s.end().x - 10.0).abs() < 1e-9)) ); } // Same total area as the uncut arch. sk.curves.pop(); let uncut: f64 = find_profiles(&sk).iter().map(|p| p.area).sum(); assert!((whole - uncut).abs() < 1e-6 * uncut, "{whole} vs {uncut}"); } /// A face boundary saved with stations along its straight edges (#1049: booleans used to leave /// vertices nothing else used) keeps its saved pieces when the face comes back with each /// straight edge whole: the same region, and features that name the pieces still find them. #[test] fn projected_boundary_keeps_its_pieces_when_only_stations_go() { use crate::{LinkGeom, LinkKind, LinkSource}; let mut sk = Sketch::new(); let v = Vec2::new; let split = vec![ LinkGeom::Line(v(0.0, 0.0), v(5.0, 0.0)), LinkGeom::Line(v(5.0, 0.0), v(10.0, 0.0)), LinkGeom::Line(v(10.0, 0.0), v(10.0, 4.0)), LinkGeom::Line(v(10.0, 4.0), v(10.0, 10.0)), LinkGeom::Line(v(10.0, 10.0), v(0.0, 10.0)), LinkGeom::Line(v(0.0, 10.0), v(0.0, 0.0)), ]; let j = sk.add_link(LinkKind::Project, LinkSource::Origin, &split).unwrap(); let ids: Vec = sk.link_curves(&j).iter().map(|c| sk.curves[*c].id.clone()).collect(); let whole = vec![ LinkGeom::Line(v(0.0, 0.0), v(10.0, 0.0)), LinkGeom::Line(v(10.0, 0.0), v(10.0, 10.0)), LinkGeom::Line(v(10.0, 10.0), v(0.0, 10.0)), LinkGeom::Line(v(0.0, 10.0), v(0.0, 0.0)), ]; assert!(!sk.update_link(&j, &whole).unwrap(), "only the stations went: the pieces stay"); let kept: Vec = sk.link_curves(&j).iter().map(|c| sk.curves[*c].id.clone()).collect(); assert_eq!(ids, kept); // A boundary that really changed is rebuilt. let smaller = vec![ LinkGeom::Line(v(0.0, 0.0), v(8.0, 0.0)), LinkGeom::Line(v(8.0, 0.0), v(8.0, 10.0)), LinkGeom::Line(v(8.0, 10.0), v(0.0, 10.0)), LinkGeom::Line(v(0.0, 10.0), v(0.0, 0.0)), ]; assert!(sk.update_link(&j, &smaller).unwrap()); assert_eq!(sk.link_curves(&j).len(), 4); }