From bb431f7ac250c33dd3759d50b510665419a3762e Mon Sep 17 00:00:00 2001 From: Mqrius Date: Tue, 13 Oct 2026 06:11:59 +0200 Subject: [PATCH] fix(sketch): merged adjacent profiles have whole edges where shared ones ended (#509) When adjacent profiles are merged for one extrude, collinear lines on either side of where a shared boundary ended are joined back into one edge, so the body has no split faces or extra vertices for later booleans to trip on. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests.rs | 45 +++++++++++++++++++++++++++++++++++ crates/sketch/src/profiles.rs | 35 ++++++++++++++++++++++++++- crates/sketch/src/tests.rs | 3 +++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 394685fa..e08f54fb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -363,6 +363,51 @@ fn revolve_a_spherical_shell_off_the_origin() { } } +#[test] +fn extrude_of_adjacent_profiles_has_whole_edges() { + // A holder from two regions of one sketch (a disc and the ring around it, both cut off by a + // chord): the chord used to stay split where the inner circle met it, leaving vertices right + // on the surface of a handle of that radius, and cutting the handle out of the holder failed. + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "Holder"})); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 40})); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 35})); + let x = (40.0f64 * 40.0 - 33.0 * 33.0).sqrt(); + run(&mut s, "sketch.line", json!({"points": [[-x, -33], [x, -33]]})); + run(&mut s, "sketch.finish", json!({})); + let si = run(&mut s, "sketch.inspect", json!({"sketch": "Holder"})); + let below: Vec = si["profiles"] + .as_array() + .unwrap() + .iter() + .filter(|p| (500.0..4900.0).contains(&p["area_mm2"].as_f64().unwrap())) + .map(|p| p["index"].clone()) + .collect(); + assert_eq!(below.len(), 2, "{si}"); + run(&mut s, "solid.extrude", json!({"sketch": "Holder", "profiles": below, "distance": 23, "body_name": "Holder"})); + let topo = run(&mut s, "model.faces", json!({"body": "Holder"})); + assert_eq!(topo["faces"].as_array().unwrap().len(), 5, "a cut-off cylinder: {topo}"); + let lo = run(&mut s, "inspect.measure", json!({"bodies": ["Holder"]}))["bodies"][0]["bbox"]["min"][1].as_f64().unwrap(); + run(&mut s, "solid.move", json!({"bodies": ["Holder"], "translate": [0, -5.0 - lo, 0]})); + // The handle: r35 on the Y axis with a bevelled end. + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Handle"})); + for pts in [ + [[0.0, 0.0], [0.0, 230.0]], + [[0.0, 0.0], [25.0, 0.0]], + [[25.0, 0.0], [35.0, 11.5]], + [[35.0, 11.5], [35.0, 230.0]], + [[35.0, 230.0], [0.0, 230.0]], + ] { + run(&mut s, "sketch.line", json!({ "points": pts })); + } + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.revolve", json!({"sketch": "Handle", "profiles": [0], "axis": "Y", "body_name": "Handle"})); + let before = run(&mut s, "inspect.measure", json!({"bodies": ["Holder"]}))["total"]["volume_mm3"].as_f64().unwrap(); + run(&mut s, "solid.combine", json!({"target": "Holder", "tools": ["Handle"], "operation": "cut", "keep_tools": true})); + let after = run(&mut s, "inspect.measure", json!({"bodies": ["Holder"]}))["total"]["volume_mm3"].as_f64().unwrap(); + assert!(after > 1000.0 && after < before * 0.5, "{before} -> {after}"); +} + #[test] fn sketch_tools_and_constraints() { let mut s = Session::default(); diff --git a/crates/sketch/src/profiles.rs b/crates/sketch/src/profiles.rs index 2678a3d2..f2b0d8aa 100644 --- a/crates/sketch/src/profiles.rs +++ b/crates/sketch/src/profiles.rs @@ -985,6 +985,28 @@ fn same_seg(a: &Seg2, b: &Seg2, tol: f64) -> bool { } } +/// A closed loop's segments with each run that `joins` says continue one another made one line. +fn rejoined(segs: Vec, joins: &impl Fn(&Seg2, &Seg2) -> bool) -> Vec { + let n = segs.len(); + let join = |i: usize| segs.get((i + n - 1) % n).zip(segs.get(i)).is_some_and(|(s, t)| joins(s, t)); + // Start where a run starts; a loop that is all one run (it can't close) stays as it is. + let Some(k) = (0..n).find(|&i| !join(i)) else { return segs }; + let mut out: Vec = Vec::with_capacity(n); + for j in 0..n { + let i = (k + j) % n; + let Some(s) = segs.get(i).copied() else { continue }; + if j > 0 + && join(i) + && let Some(Seg2::Line { b, .. }) = out.last_mut() + { + *b = s.end(); + continue; + } + out.push(s); + } + out +} + /// Union of regions that touch along shared boundary segments (one extrude of several adjacent /// profiles makes one body). Disjoint regions stay separate. pub fn merge_regions(regions: &[Region2]) -> Vec { @@ -1022,6 +1044,17 @@ pub fn merge_regions(regions: &[Region2]) -> Vec { if !shared { return regions.to_vec(); } + // Where the shared boundaries ended, the outline is left split in two: the lines on either + // side are one edge again (else they make split faces and vertices that booleans trip on). + let ends: Vec = segs.iter().zip(&alive).filter(|(_, a)| !**a).flat_map(|(s, _)| [s.start(), s.end()]).collect(); + let rejoin = |s: &Seg2, t: &Seg2| match (*s, *t) { + (Seg2::Line { a, b }, Seg2::Line { a: b2, b: c }) => { + b.dist(b2) <= tol + && ends.iter().any(|e| e.dist(b) <= tol) + && (b - a).normalized().zip((c - b).normalized()).is_some_and(|(u, v)| u.dot(v) > 1.0 - 1e-12) + } + _ => false, + }; let rest: Vec = segs.iter().zip(&alive).filter(|(_, a)| **a).map(|(s, _)| *s).collect(); // Chain into loops, taking the sharpest left turn where several continue. let mut used = vec![false; rest.len()]; @@ -1058,7 +1091,7 @@ pub fn merge_regions(regions: &[Region2]) -> Vec { } } if !lp.is_empty() && lp.first().zip(lp.last()).is_some_and(|(f, l)| l.end().dist(f.start()) <= tol) { - loops.push(Loop2 { segs: lp }); + loops.push(Loop2 { segs: rejoined(lp, &rejoin) }); } } let outers: Vec<&Loop2> = loops.iter().filter(|l| l.signed_area() > 0.0).collect(); diff --git a/crates/sketch/src/tests.rs b/crates/sketch/src/tests.rs index f8c8d37e..daf4070a 100644 --- a/crates/sketch/src/tests.rs +++ b/crates/sketch/src/tests.rs @@ -334,6 +334,9 @@ fn merging_adjacent_regions() { 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:?}"); } #[test]