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 }