diff --git a/crates/engine/src/cmd/sketch_project_tests.rs b/crates/engine/src/cmd/sketch_project_tests.rs index cf1dc905..f56dc583 100644 --- a/crates/engine/src/cmd/sketch_project_tests.rs +++ b/crates/engine/src/cmd/sketch_project_tests.rs @@ -526,3 +526,62 @@ fn faces_split_by_hidden_seams_project_whole() { assert_eq!(radii.len(), 4, "{round}"); assert!(radii[..2].iter().all(|r| (r - 17.).abs() < 1e-6) && radii[2..].iter().all(|r| (r - 20.).abs() < 1e-6), "{round}"); } + +/// #635: a rectangle whose corner is made coincident with a projected edge of the solid under +/// the sketch, so that its bottom side lies on that edge, is still one closed profile (the +/// projected line stays, and names the shared edge as it was there first), and it extrudes to +/// its own volume, also when the design is opened again. +#[test] +fn a_rectangle_put_onto_a_projected_edge_stays_a_closed_profile() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 20})); + let block = s.model.state().bodies[0].name.clone(); + let id = run(&mut s, "sketch.create", json!({"plane": "XY"}))["sketch"].as_u64().unwrap(); + // The solid's bottom edge along X, from (0, 0) to (40, 0). + let r = run(&mut s, "sketch.project", json!({"refs": [{"edge": [20, 0, 0]}]})); + let projected = r["links"][0]["curves"][0].as_str().unwrap().to_string(); + // A 20 x 15 rectangle clear of it is a closed profile of its own. + let sides = ids(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 10], "p1": [25, 25]}))["curves"]); + run(&mut s, "sketch.dimension", json!({"entities": [sides[0]], "value": 20})); + run(&mut s, "sketch.dimension", json!({"entities": [sides[1]], "value": 15})); + let mut own = sides.clone(); + own.sort(); + let profile_curves = |si: &Value| -> Vec> { + si["profiles"] + .as_array() + .unwrap() + .iter() + .map(|p| { + let mut c = ids(&p["curves"]); + c.sort(); + c + }) + .collect() + }; + assert_eq!(profile_curves(&inspect(&mut s)), vec![own.clone()]); + // Its bottom-left corner onto the projected line: the bottom side lies on it. + run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{}.start", sides[0]), "b": projected})); + let si = inspect(&mut s); + assert_eq!(si["status"], "solved", "{si}"); + let curve = |id: &str| si["curves"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap().clone(); + let bottom = curve(&sides[0]); + for end in ["start_at", "end_at"] { + assert!(bottom[end][1].as_f64().unwrap().abs() < 1e-9, "{bottom}"); + } + assert!(curve(&projected).get("link").is_some(), "the projected line stays"); + let mut on_line = vec![projected.clone(), sides[1].clone(), sides[2].clone(), sides[3].clone()]; + on_line.sort(); + assert_eq!(profile_curves(&si), vec![on_line], "{si}"); + assert!((si["profiles"][0]["area_mm2"].as_f64().unwrap() - 300.0).abs() < 1e-6, "{si}"); + // Picked by a point inside, it extrudes to a 20 x 15 x 10 block of its own. + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": id, "profiles": [{"point": [15, 7.5]}], "distance": 10, "operation": "new", "body_name": "Pad"})); + let volume = |s: &Session, name: &str| s.model.state().body(name).unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume; + assert!((volume(&s, "Pad") - 3000.0).abs() < 1e-6, "{}", volume(&s, "Pad")); + assert!((volume(&s, &block) - 24000.0).abs() < 1e-6, "{}", volume(&s, &block)); + let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!((volume(&reopened, "Pad") - 3000.0).abs() < 1e-6, "{}", volume(&reopened, "Pad")); +} diff --git a/crates/sketch/src/profiles.rs b/crates/sketch/src/profiles.rs index 42aedbbe..15b67405 100644 --- a/crates/sketch/src/profiles.rs +++ b/crates/sketch/src/profiles.rs @@ -61,6 +61,47 @@ struct Edge { /// A piece of an exact free-form segment: (curve, segment, piece, pieces). tag: Option, coverage: Option, + /// A piece of a line, arc or circle (not one sampled from a free-form curve). + analytic: bool, +} + +/// Do two circles lie within `tol` of each other all the way round? +fn same_circle(c: Vec2, r: f64, c2: Vec2, r2: f64, tol: f64) -> bool { + c.dist(c2) + (r - r2).abs() <= tol +} + +/// Which edges stay in the graph: of pieces lying on top of each other between the same two +/// nodes (a side drawn onto a projected edge, which the solver puts there only up to round-off), +/// the first, so the earliest curve in sketch order names the shared edge, as when the pieces +/// lie exactly on each other. Kept both, they would bound a sliver of no area, and the +/// round-off in their directions would make the turns at their ends disagree, so the regions on +/// both sides would be lost (#635). +fn without_overlaps(edges: &[Edge], tol: f64) -> Vec { + // Between the same nodes, two pieces of lines run along one path, and so do two pieces of + // arcs or circles on one circle through the same middle (not the two halves of a circle). A + // line and an arc never do, nor does a sampled piece of a free-form curve: a chord and its + // curve bound a region, however thin. + let same_path = |a: &Edge, b: &Edge| match (a.seg, b.seg) { + _ if !(a.analytic && b.analytic) => false, + (Seg2::Line { .. }, Seg2::Line { .. }) => true, + (Seg2::Arc { center, radius, .. }, Seg2::Arc { center: c2, radius: r2, .. }) => { + same_circle(center, radius, c2, r2, tol) && a.seg.mid().dist(b.seg.mid()) <= 2.0 * tol + } + _ => false, + }; + let mut kept: std::collections::HashMap<(usize, usize), Vec> = std::collections::HashMap::new(); + edges + .iter() + .enumerate() + .map(|(i, e)| { + let between = kept.entry((e.u.min(e.v), e.u.max(e.v))).or_default(); + let first = !between.iter().any(|k| edges.get(*k).is_some_and(|f| same_path(f, e))); + if first { + between.push(i); + } + first + }) + .collect() } type Piece = (usize, usize, usize, usize); @@ -119,6 +160,20 @@ fn complete_loop(tags: &[Option<(Piece, bool)>], expected: &std::collections::Ha Some(ids) } +/// Do two shapes of lines, arcs or circles lie on one line or circle (within `tol` all along)? +/// Where they overlap they don't cross: the points where one ends on the other cut them (each +/// end lies within `tol` of the other). Their crossing, computed from round-off alone, would +/// land anywhere along them and cut both apart there. Two whole circles have no ends to cut +/// them, so they keep their crossings. +fn overlapping(x: &Shape, y: &Shape, tol: f64) -> bool { + let near_line = |a: Vec2, b: Vec2, ps: [Vec2; 2]| (b - a).normalized().is_some_and(|d| ps.iter().all(|p| (*p - a).cross(d).abs() <= tol)); + match (*x, *y) { + (Shape::Line { a, b }, Shape::Line { a: c, b: d }) => near_line(a, b, [c, d]) && near_line(c, d, [a, b]), + (Shape::Round { c, r, .. }, Shape::Round { c: c2, r: r2, .. }) => !(x.is_full() && y.is_full()) && same_circle(c, r, c2, r2, tol), + _ => false, + } +} + /// Parameter of `p` at an end of the shape (lines: 0 or 1), for intersections at end points. fn end_param(sh: &Shape, p: Vec2, tol: f64) -> Option { match *sh { @@ -493,6 +548,9 @@ pub fn find_profiles(sk: &Sketch) -> Vec { fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { let tol = MERGE_TOL * 10.0; + // Lines, arcs and circles (free-form curves take part as sampled pieces). + let analytic = + |ci: usize| sk.curves.get(ci).is_some_and(|c| matches!(c.kind, CurveKind::Line { .. } | CurveKind::Arc { .. } | CurveKind::Circle { .. })); // Analytic shapes of the profile curves. let mut shapes: Vec<(usize, Shape)> = Vec::new(); let mut tags: Vec> = Vec::new(); @@ -640,14 +698,51 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { p.sort_unstable(); p.dedup(); } + // Lines (and arcs) lying on a line (or an arc) are one path with it, carried by it: another + // curve meets them all where it meets the carrier, so they are cut at the same points. Met + // one by one, a curve touching them, or crossing them at a shallow angle, would meet each a + // little apart, and pieces cut at different points would not be one edge. Their crossings + // with each other, computed from round-off alone, are no cuts. The carrier is the first of + // them, and each lies on it itself (not just on one that lies on it); a carrier takes one + // whole circle at most, as two whole circles keep their crossings. + let mut carrier: Vec = (0..shapes.len()).collect(); + let mut anchors: Vec = Vec::new(); + if shapes.len() <= 50_000 { + let mut whole: Vec = shapes.iter().map(|(_, s)| s.is_full()).collect(); + for i in 0..shapes.len() { + let Some((ci, x)) = shapes.get(i) else { continue }; + if carrier.get(i) != Some(&i) || !analytic(*ci) { + continue; + } + for &j in partners.get(i).map(Vec::as_slice).unwrap_or(&[]) { + let Some((cj, y)) = shapes.get(j) else { continue }; + let taken = y.is_full() && whole.get(i).copied().unwrap_or(true); + if carrier.get(j) == Some(&j) && !taken && overlap(i, j) && analytic(*cj) && overlapping(x, y, tol) { + if let Some(c) = carrier.get_mut(j) { + *c = i; + } + if let Some(w) = whole.get_mut(i) { + *w |= y.is_full(); + } + } + } + } + } if shapes.len() <= 50_000 { for i in 0..shapes.len() { for &j in partners.get(i).map(Vec::as_slice).unwrap_or(&[]) { if !overlap(i, j) { continue; } - let (Some((_, x)), Some((_, y))) = (shapes.get(i), shapes.get(j)) else { continue }; - let mut pts = intersections(x, y); + let (gi, gj) = (carrier.get(i).copied().unwrap_or(i), carrier.get(j).copied().unwrap_or(j)); + let (Some((_, x)), Some((_, y)), Some((_, gx)), Some((_, gy))) = (shapes.get(i), shapes.get(j), shapes.get(gi), shapes.get(gj)) + else { + continue; + }; + if gi == gj { + continue; + } + let mut pts = intersections(gx, gy); // A tangent touch gives two nearly equal roots: one point. if let [p, q] = pts[..] && p.dist(q) < tol * 100.0 @@ -682,6 +777,9 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { { c.push(ty); } + if (gi, gj) != (i, j) { + anchors.push(p); + } } } } @@ -708,6 +806,11 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { grid.entry((cx, cy)).or_default().push(i); i }; + // Where curves meet at their carriers' crossing, the cuts on each lie within the tolerance of + // that point, though not always of each other: they meet at a node there. + for a in &anchors { + node(*a); + } let mut loops: Vec<(Loop2, Vec, usize, Option>)> = Vec::new(); // (ccw loop, curve ids, component) let mut edges: Vec = Vec::new(); for (k, (ci, sh)) in shapes.iter().enumerate() { @@ -728,6 +831,7 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { ts.pop(); } let id = sk.curves.get(*ci).map(|c| c.id.clone()).unwrap_or_default(); + let analytic = analytic(*ci); let coverage = tags.get(k).copied().flatten().or_else(|| { sk.curves .get(*ci) @@ -746,7 +850,7 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { let (p, q) = (w[0], w[1]); if p.dist(q) > MERGE_TOL { let (u, v) = (node(p), node(q)); - edges.push(Edge { seg: Seg2::Line { a: p, b: q }, u, v, id: id.clone(), tag, coverage }); + edges.push(Edge { seg: Seg2::Line { a: p, b: q }, u, v, id: id.clone(), tag, coverage, analytic }); } } } @@ -769,14 +873,14 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec { } let seg = Seg2::Arc { center: c, radius: r, start: start + t0, sweep: t1 - t0 }; let (u, v) = (node(seg.start()), node(seg.end())); - edges.push(Edge { seg, u, v, id: id.clone(), tag: None, coverage }); + edges.push(Edge { seg, u, v, id: id.clone(), tag: None, coverage, analytic }); } } } } let n = nodes.len(); - // Prune dangling edges. - let mut alive = vec![true; edges.len()]; + // One edge where pieces overlap, then prune dangling edges. + let mut alive = without_overlaps(&edges, tol); loop { let mut deg = vec![0usize; n]; for (e, a) in edges.iter().zip(&alive) { diff --git a/crates/sketch/src/tests.rs b/crates/sketch/src/tests.rs index 564b2214..a376fe73 100644 --- a/crates/sketch/src/tests.rs +++ b/crates/sketch/src/tests.rs @@ -320,6 +320,246 @@ fn profiles_split_at_crossings_and_touch_points() { assert!(total > PI * 500.0, "{total}"); } +/// 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 = diff --git a/crates/ui-egui/tests/projection_preselection.rs b/crates/ui-egui/tests/projection_preselection.rs index d4e7066e..b8871e06 100644 --- a/crates/ui-egui/tests/projection_preselection.rs +++ b/crates/ui-egui/tests/projection_preselection.rs @@ -120,3 +120,35 @@ fn oversized_preselection_does_not_silently_project_a_subset() { assert_eq!(h.app.session.undo.len(), undo); assert_eq!(h.app.session.selection.len(), 1001); } + +/// #635: a rectangle whose corner is made coincident with a projected edge of the solid, so +/// that its bottom side lies on the edge, is still a closed profile: a click in its middle +/// selects it, bounded by the rectangle's other three sides and the projected edge under the +/// fourth. +#[test] +fn a_rectangle_put_onto_a_projected_edge_is_still_a_profile_to_click() { + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.app.run("solid.extrude", json!({"distance":20})).unwrap(); + let sketch = h.app.run("sketch.create", json!({"plane":"XY"})).unwrap()["sketch"].as_u64().unwrap(); + let projected = h.app.run("sketch.project", json!({"refs":[{"edge":[20,0,0]}]})).unwrap()["links"][0]["curves"][0].as_str().unwrap().to_string(); + let rectangle = h.app.run("sketch.rectangle.two_point", json!({"p0":[5,10],"p1":[25,25]})).unwrap(); + let mut sides: Vec = rectangle["curves"].as_array().unwrap().iter().map(|c| c.as_str().unwrap().to_string()).collect(); + h.app.run("sketch.constraint.coincident", json!({"a":format!("{}.start", sides[0]),"b":projected})).unwrap(); + // The bottom side lies under the projected edge. + sides[0] = projected.clone(); + // From below, the sketch plane is in front of the solid standing on it. + h.call("ui.view", json!({"view":"bottom"})); + h.step(&json!({"click":{"sketch":[15,12]}})).unwrap(); + assert_eq!(h.app.session.selection, vec![Sel::Profile { sketch, index: 0 }]); + let state = h.app.session.world_state(); + let ss = state.sketch(sketch).unwrap(); + assert_eq!(ss.profiles.len(), 1); + let mut bounded_by = ss.profiles[0].outer_curves.clone(); + bounded_by.sort(); + sides.sort(); + assert_eq!(bounded_by, sides); + assert!(ss.sketch.curves.iter().any(|curve| curve.id == projected && curve.link.is_some()), "the projected edge stays"); +}