From 57e6396fbbf82a23138f63d218e8fac031529487 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Mon, 12 Oct 2026 15:01:03 -0400 Subject: [PATCH] Tangent hole contacts split no faces, consistently (#307) (#364) * Tangent hole contacts: every symmetric contact gives the same faces (#307) Three things made the four symmetric contacts of reference case 42 differ: - The 2D prism boolean's ray-parity test miscounted a ray grazing the bottom of a patterned hole, whose two halves are read back with centres a few ulps apart: one half counted the touch, the other missed it. The loop chaining then failed and those cuts fell back to the general boolean, which treats circles tangent within its tolerance as overlapping and splits the hole. The ray is now moved off vertices and arc extremes by far less than any modelling tolerance. - The prism result's volume check used a coarser mesh than the operands, so a plate with many holes read too large and correct results were rejected. - The merged topology did not join a hole's two pieces across the line where two tangent holes touch (four faces meet there). Case 42 now gives 39 faces (33 holes, 6 planes); its expected.json pins it. Fixes #307 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT * Kernel test: tangent hole pinch no longer splits the wall (#307) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT --------- Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/face_tests.rs | 17 +++++++ crates/kernel/src/ops.rs | 7 ++- crates/kernel/src/prism.rs | 46 +++++++++++++++++++ crates/kernel/src/tests.rs | 7 +-- crates/kernel/src/topo.rs | 18 ++++++++ .../42-overlapping-patterns/expected.json | 3 +- 6 files changed, 93 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/face_tests.rs b/crates/engine/src/cmd/face_tests.rs index 1de4ff2f..b2defefd 100644 --- a/crates/engine/src/cmd/face_tests.rs +++ b/crates/engine/src/cmd/face_tests.rs @@ -186,6 +186,23 @@ fn repeated_source_face_profile_requires_and_uses_the_exact_active_occurrence() } } +/// #307: four pairs of holes touching at one line each (a ring pattern against a grid). Every +/// contact is the same up to rounding, so every hole is one face: 33 holes and 6 planes. +#[test] +fn tangent_hole_contacts_split_no_faces() { + let recipe: Value = serde_json::from_str(RECIPE_307).unwrap(); + let mut s = Session::default(); + for c in recipe["commands"].as_array().unwrap() { + s.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap(); + } + let m = s.execute("inspect.measure", &json!({})).unwrap(); + let body = &m["bodies"][0]; + assert_eq!(body["face_types"], json!({"cylinder": 33, "plane": 6}), "{m}"); + assert_eq!(body["faces"], 39, "{m}"); +} + +const RECIPE_307: &str = r#"{"commands": [{"command": "sketch.create", "params": {"name": "Plate_sketch", "plane": "XY"}}, {"command": "sketch.line", "params": {"construction": false, "ids": ["l1"], "points": [[-50, -50], [50, -50]]}}, {"command": "sketch.line", "params": {"construction": false, "ids": ["l2"], "points": ["l1.end", [50, 50]]}}, {"command": "sketch.line", "params": {"construction": false, "ids": ["l3"], "points": ["l2.end", [-50, 50]]}}, {"command": "sketch.line", "params": {"construction": false, "ids": ["l4"], "points": ["l3.end", "l1.start"]}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"body_names": ["Plate"], "direction": "positive", "distance": 5, "name": "Plate", "operation": "new", "profiles": [{"loops": [{"curves": ["l1", "l2", "l3", "l4"], "outer": true}], "sketch": "Plate_sketch"}], "sketch": "Plate_sketch"}}, {"command": "sketch.create", "params": {"name": "HoleA", "plane": "XY"}}, {"command": "sketch.circle.center", "params": {"center": [-30, -30], "construction": false, "id": "c1", "radius": 5}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"body_names": ["Plate"], "direction": "positive", "name": "HoleA", "operation": "cut", "profiles": [{"loops": [{"curves": ["c1"], "outer": true}], "sketch": "HoleA"}], "sketch": "HoleA", "through_all": true}}, {"command": "solid.pattern.rectangular", "params": {"count1": 5, "count2": 5, "dir1": [1, 0, 0], "dir2": [0, 1, 0], "features": ["HoleA"], "name": "GridPattern", "spacing1": 15, "spacing2": 15}}, {"command": "sketch.create", "params": {"name": "HoleB", "plane": "XY"}}, {"command": "sketch.circle.center", "params": {"center": [25, 0], "construction": false, "id": "c1", "radius": 5}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"body_names": ["Plate"], "direction": "positive", "name": "HoleB", "operation": "cut", "profiles": [{"loops": [{"curves": ["c1"], "outer": true}], "sketch": "HoleB"}], "sketch": "HoleB", "through_all": true}}, {"command": "solid.pattern.circular", "params": {"angle": 360, "axis": {"dir": [0, 0, 1], "origin": [0, 0, 0]}, "count": 8, "features": ["HoleB"], "name": "RingPattern"}}]}"#; + /// #308: a blind cut ending inside a cylinder leaves thin strips of the cylinder wall beside /// the cut; they must count as cylinder, not as a sphere or a freeform face. #[test] diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index e5027cd4..50abc25c 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -357,7 +357,12 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { }; match &r { Some(body) => { - let v = volume(body); + // Measured as the operands were: a coarser mesh of a plate with many holes + // overstates its volume by more than the slack. + let v = match &meshes { + (Some(_), Some(_)) => body.tessellate_with(tol_m, true).map(|m| m.measure().volume).unwrap_or(f64::NAN), + _ => volume(body), + }; let bounds = match op { BoolOp::Union => v >= va.max(vb) - slack && v <= va + vb + slack, BoolOp::Cut => v >= va - vb - slack && v <= va + slack, diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs index 61c9b82a..f4b410af 100644 --- a/crates/kernel/src/prism.rs +++ b/crates/kernel/src/prism.rs @@ -743,6 +743,32 @@ fn crossings(s: &Seg2, t: &Seg2, tol: f64) -> Vec<(f64, f64)> { /// crossing and flip the parity of points level with the corner. Arcs decide their own ends /// within an angular tolerance (below), so joints with an arc keep that rule. fn inside_loop(lp: &Loop2, p: Vec2) -> bool { + // A ray through a vertex or grazing an arc's top or bottom is decided by the half-open + // rule, which needs both curves meeting there to agree on that point exactly. Arcs read + // back from a solid (a patterned hole's two halves) can disagree by a few ulps, so one half + // counts the touch and the other misses it. Move the ray off such points: a shift far below + // any modelling tolerance cannot change the answer for a point that is not on the loop. + let scale = 1.0 + p.x.abs().max(p.y.abs()); + // A point on the loop keeps the half-open answer. + if lp.segs.iter().any(|s| s.point_at(s.closest_param(p)).dist(p) < scale * 1e-9) { + return inside_loop_ray(lp, p); + } + let grazes = |q: Vec2| { + let near = |y: f64| (y - q.y).abs() < scale * 1e-9; + lp.segs.iter().any(|s| { + near(s.start().y) + || match *s { + Seg2::Arc { center, radius, .. } => near(center.y + radius) || near(center.y - radius), + _ => false, + } + }) + }; + let ray = [0.0, 1.0, -1.0, 2.0, -2.0, 3.0, -3.0].into_iter().map(|k| Vec2::new(p.x, p.y + k * scale * 1e-7)).find(|q| !grazes(*q)).unwrap_or(p); + inside_loop_ray(lp, ray) +} + +/// [`inside_loop`] with the ray along +x from `p` taken as given. +fn inside_loop_ray(lp: &Loop2, p: Vec2) -> bool { let mut odd = false; let nexts = lp.segs.iter().skip(1).chain(lp.segs.first()); for (seg, next) in lp.segs.iter().zip(nexts) { @@ -2159,6 +2185,26 @@ mod planar_tests { assert!(!inside_loop(&lp, Vec2::new(-5.0, 0.0))); assert!(!inside_loop(&lp, Vec2::new(-5.0, -10.0))); } + // #307: a circle read back as two halves whose centres disagree by a few ulps, split + // at its bottom. A ray grazing that bottom must not count one half's touch only. + let halves = Loop2 { + segs: vec![ + Seg2::Arc { center: Vec2::new(0.0, 25.0), radius: 5.0, start: -std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI }, + Seg2::Arc { + center: Vec2::new(2.9e-15, 25.000000000000004), + radius: 4.99999999999999, + start: std::f64::consts::FRAC_PI_2, + sweep: std::f64::consts::PI, + }, + ], + }; + for lp in [halves.clone(), halves.reversed()] { + for x in [-30.0, -5.0, -0.1] { + assert!(!inside_loop(&lp, Vec2::new(x, 20.0)), "{x}"); + assert!(!inside_loop(&lp, Vec2::new(x, 30.0)), "{x}"); + } + assert!(inside_loop(&lp, Vec2::new(0.0, 25.0))); + } } /// Loops are closed only within the chaining tolerance. A ray level with one end of such a diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index db543053..ebc2e0fb 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -1799,8 +1799,9 @@ fn loft_section_order_gives_the_same_body() { #[test] fn tangent_holes_pinch_alike() { - // Holes at ±15 and ±25 touch along a line (x = ±20); ±30 overlaps ±25. The ±25 wall is cut - // in two at the pinch, the same way on both sides (as Fusion counts it). + // Holes at ±15 and ±25 touch along a line (x = ±20); ±30 overlaps ±25. A tangent line + // contact does not split a face (#307, as the reference kernel counts it): the ±25 wall is one + // face on both sides, so 6 planes and 6 hole walls. let mut b = box_solid(Vec3::new(-50.0, -20.0, 0.0), Vec3::new(50.0, 20.0, 5.0)).unwrap(); for x in [15.0, 25.0, 30.0, -15.0, -25.0, -30.0] { let plane = Plane::XY.offset(-1.0); @@ -1809,7 +1810,7 @@ fn tangent_holes_pinch_alike() { b = boolean(&b, &t, BoolOp::Cut).unwrap().unwrap(); } let m = measure(&b).unwrap(); - assert_eq!(m.merged.faces, 14, "{:?}", m.merged); + assert_eq!(m.merged.faces, 12, "{:?}", m.merged); } /// A d10 tube bent at R5: the inside of the bend is a horn torus pinched to a point. One torus diff --git a/crates/kernel/src/topo.rs b/crates/kernel/src/topo.rs index 58e99f4f..8c6b63b6 100644 --- a/crates/kernel/src/topo.rs +++ b/crates/kernel/src/topo.rs @@ -492,6 +492,24 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result { } } } + // Two tangent holes touch along a line that both carry: there four faces meet, two on + // each hole's surface. Each surface's two pieces are one face, as for any other edge. + for (ei, faces) in &edge_faces { + if faces.len() <= 2 || faces.len() > 8 || (keep_split && poly(*ei).is_some_and(|pl| crate::heal::on_split_line(pl, b_keep))) { + continue; + } + for (i, &a) in faces.iter().enumerate() { + for &b in faces.iter().skip(i + 1) { + let analytic = !matches!(surfs.get(a), Some(Surf::Other(_)) | None); + if analytic && same_surface(&surfs, &verts, poly(*ei), a, b, tol * 10.0) { + let (ra, rb) = (find(&mut fp, a), find(&mut fp, b)); + if let Some(s) = fp.get_mut(ra) { + *s = rb; + } + } + } + } + } let mut groups: BTreeMap = BTreeMap::new(); let mut face_types: BTreeMap = BTreeMap::new(); for f in 0..nf { diff --git a/tests/oracle/42-overlapping-patterns/expected.json b/tests/oracle/42-overlapping-patterns/expected.json index 829339d1..b90c20ef 100644 --- a/tests/oracle/42-overlapping-patterns/expected.json +++ b/tests/oracle/42-overlapping-patterns/expected.json @@ -20,5 +20,6 @@ 50.0, 5.0000001 ] - } + }, + "faces": 39 }