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 <[email protected]>
This commit is contained in:
MqriusandClaude Opus 5.5 authored and GitHub committed 2026-10-13 00:11:59 -04:00
1 parent 2f7046efac
commit bb431f7ac2
3 files changed
+82 -1

No files matched your search

+45
View File
@@ -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<Value> = 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();
+34 -1
View File
@@ -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<Seg2>, joins: &impl Fn(&Seg2, &Seg2) -> bool) -> Vec<Seg2> {
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<Seg2> = 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<Region2> {
@@ -1022,6 +1044,17 @@ pub fn merge_regions(regions: &[Region2]) -> Vec<Region2> {
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<Vec2> = 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<Seg2> = 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<Region2> {
}
}
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();
+3
View File
@@ -334,6 +334,9 @@ fn merging_adjacent_regions() {
let mut areas: Vec<f64> = 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::<Vec<_>>();
assert!(sides.contains(&4) && sides.contains(&3), "{sides:?}");
}
#[test]