From 2cab09f65bd9f39795122e3ea49da07fc64ae1ca Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:18:09 +0200 Subject: [PATCH] Join keeps its tool exact where the tool ends in the part's faces (#1244) --- crates/doc/src/eval_more.rs | 19 ++++++-- crates/engine/src/tests.rs | 15 ++++++ .../engine/src/tests/boolean_diagnostics.rs | 32 +++++++++++++ crates/kernel/src/join_between_caps_tests.rs | 47 +++++++++++++++++++ crates/kernel/src/lib.rs | 2 + crates/kernel/src/ops.rs | 28 ++++++++--- crates/kernel/src/prism.rs | 25 ++++++++-- 7 files changed, 151 insertions(+), 17 deletions(-) create mode 100644 crates/kernel/src/join_between_caps_tests.rs diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 58d7d1f9..6c130fdf 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -157,16 +157,25 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, } } -/// Unit outward normal of the planar face through `p`. +/// Unit outward normal of the planar face through `p`: of the planar faces whose plane runs +/// through it, the one nearest it. A face elsewhere in such a plane (the wall of a notch, the end +/// of a rib, in line with a rim) is no pick of `p`, though its plane runs through it as exactly. fn planar_normal_at(b: &Body, p: Vec3) -> Option { let tol = (b.size() * 2e-3).max(1e-3); let faces = b.faces(tol).ok()?; + let mesh = b.tessellate(tol).ok()?; + let mut near = vec![f64::INFINITY; faces.len()]; + for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) { + if let (Some([u, v, w]), Some(d)) = (mesh.tri(t), near.get_mut(*f as usize)) { + *d = d.min(crate::appearance::closest_on_triangle(p, u, v, w).dist(p)); + } + } faces .iter() - .filter_map(|f| f.plane_normal.map(|n| (n, ((p - f.centroid).dot(n)).abs()))) - .filter(|(_, d)| *d < tol * 5.0) - .min_by(|a, b| a.1.total_cmp(&b.1)) - .map(|(n, _)| n) + .filter_map(|f| f.plane_normal.map(|n| (n, ((p - f.centroid).dot(n)).abs(), near.get(f.index).copied().unwrap_or(f64::INFINITY)))) + .filter(|(_, d, _)| *d < tol * 5.0) + .min_by(|a, b| a.2.total_cmp(&b.2).then(a.1.total_cmp(&b.1))) + .map(|(n, _, _)| n) } /// The curves of a chain as one polyline in sketch coordinates, in order. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9ecfc01a..16183f4f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2420,6 +2420,21 @@ fn lip_after_boss() { } } +/// A lip on a rim with a face elsewhere in line with the pick: a notch under the floor whose +/// wall lies in the plane y = 25 through the picked point. The pick is the rim's, the face the +/// point lies on, not the first face whose plane runs through it. +#[test] +fn lip_on_a_rim_in_line_with_another_face() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 80, "width": 50, "height": 30})); + run(&mut s, "solid.shell", json!({"faces": [[40, 25, 30]], "thickness": 2})); + run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 1, "corner": [30, 25, 0], "operation": "cut", "targets": ["Body1"]})); + let v0 = volume(&mut s); + run(&mut s, "plastic.lip", json!({"face": [1, 25, 30], "width": 1, "height": 2})); + // The band inside the rim, 1 wide and 2 high: (78 × 48 − 76 × 46) × 2. + assert!(rel(volume(&mut s) - v0, 496.0) < 1e-6, "{}", volume(&mut s) - v0); +} + /// A drilled hole as deep as the plate: the drill point breaks through the bottom. #[test] fn hole_as_deep_as_the_plate() { diff --git a/crates/engine/src/tests/boolean_diagnostics.rs b/crates/engine/src/tests/boolean_diagnostics.rs index ed391b1a..3d643522 100644 --- a/crates/engine/src/tests/boolean_diagnostics.rs +++ b/crates/engine/src/tests/boolean_diagnostics.rs @@ -50,3 +50,35 @@ fn boolean_retry_warning_and_details_survive_cache_undo_and_reopen() { assert!(last.warning.is_none()); assert!(last.boolean_diagnostics.events.is_empty()); } + +/// A barrel joined under a plate's back edge, its ends flush with the sides of the leaf it hangs +/// from (#827 without its fillets): the join is exact, so the command's reply carries no Boolean +/// retry, and the barrel's ends stay in the leaf's sides (it is not nudged to make the join go). +#[test] +fn a_barrel_joined_between_the_sides_of_a_leaf_needs_no_retry() { + let mut session = Session::default(); + run(&mut session, "solid.box", json!({"length": 40, "width": 20, "height": 6, "corner": [0, 0, 5], "body_name": "Plate"})); + run(&mut session, "solid.box", json!({"length": 14, "width": 10, "height": 6, "corner": [10, 10, 0], "operation": "join", "targets": ["Plate"]})); + let reply = run( + &mut session, + "solid.cylinder", + json!({"radius": 5, "height": 14, "axis": "X", "base": [10, 20, 0], "operation": "join", "targets": ["Plate"]}), + ); + assert!(reply["warning"].is_null(), "{reply}"); + let result = session.model.result(reply["feature"].as_u64().unwrap()).unwrap(); + assert!(result.boolean_diagnostics.events.is_empty(), "{:?}", result.boolean_diagnostics); + let state = session.model.state(); + let plate = &state.body("Plate").unwrap().body; + assert_eq!(plate.shell_count(), 1); + assert!(plate.validity().is_empty(), "{:?}", plate.validity()); + // Every edge ends on a level along x of the plate, the leaf or the barrel. + for edge in plate.edges(1e-3).unwrap() { + for p in [edge.points.first(), edge.points.last()].into_iter().flatten() { + assert!([0., 10., 24., 40.].iter().any(|x| (p.x - x).abs() < 1e-9), "an edge ends at {p:?}"); + } + } + // A quarter of the barrel lies in the leaf. + let expected = 5500. + 0.75 * PI * 25. * 14.; + let actual = plate.tessellate(1e-4).unwrap().measure().volume; + assert!(rel(actual, expected) < 1e-5, "{actual} vs {expected}"); +} diff --git a/crates/kernel/src/join_between_caps_tests.rs b/crates/kernel/src/join_between_caps_tests.rs new file mode 100644 index 00000000..a5c171fc --- /dev/null +++ b/crates/kernel/src/join_between_caps_tests.rs @@ -0,0 +1,47 @@ +//! A join whose tool ends at other levels than the part, both prisms along that direction: a +//! hinge barrel under a plate's back edge, its ends flush with the sides of the leaf it hangs +//! from, the plate running on past them (#827 without its fillets). The barrel's ends lie in the +//! leaf's sides, which the B-rep boolean can't intersect, and there is no room to push them +//! apart (the plate goes on beyond them). The prisms are joined in 2D, exactly: the barrel is no +//! longer nudged (moved and turned by a hair) for the boolean to go through. + +use std::f64::consts::PI; + +use solidcraft_geom::Vec3; + +use crate::body::from_p3; +use crate::*; + +/// A 40 × 20 × 6 plate (z 5 … 11) with a 14 × 10 × 6 leaf under its back edge, 1 mm into it: +/// 5500 mm³. +fn plate_with_leaf() -> Body { + let plate = box_solid(Vec3::new(0., 0., 5.), Vec3::new(40., 20., 11.)).unwrap(); + let leaf = box_solid(Vec3::new(10., 10., 0.), Vec3::new(24., 20., 6.)).unwrap(); + boolean(&plate, &leaf, BoolOp::Union).unwrap().unwrap() +} + +/// Barrels along x with their axis on the leaf's back bottom edge (y 20, z 0): radius 5 (its top +/// meets the plate's back edge) and 4.9, ends flush with both of the leaf's sides, with one, and +/// running on past both under the plate. A quarter of the barrel lies in the leaf (where the +/// leaf is). Each join is exact: no retry, one solid, and every vertex on a level along x of the +/// plate, the leaf or the barrel. +#[test] +fn a_barrel_between_the_sides_of_a_leaf_joins_exactly() { + let part = plate_with_leaf(); + for (r, x0, x1) in [(5., 10., 24.), (4.9, 10., 24.), (5., 10., 23.), (5., 8.5, 25.5)] { + let what = format!("radius {r}, x {x0} … {x1}"); + let barrel = cylinder(Vec3::new(x0, 20., 0.), Vec3::X, r, x1 - x0).unwrap(); + let (joined, d) = capture_boolean_diagnostics(|| boolean(&part, &barrel, BoolOp::Union)); + let joined = joined.unwrap().unwrap(); + assert!(d.events.is_empty(), "{what}: {d:?}"); + assert_eq!(joined.shell_count(), 1, "{what}"); + assert!(joined.validity().is_empty(), "{what}: {:?}", joined.validity()); + let inside = (x1.min(24.) - x0.max(10.)) / 4.; + let want = 5500. + PI * r * r * (x1 - x0 - inside); + let v = joined.tessellate(1e-4).unwrap().measure().volume; + assert!((v - want).abs() < want * 1e-5, "{what}: volume {v} vs {want}"); + for p in joined.solid.vertex_iter().map(|v| from_p3(v.point())) { + assert!([0., 10., 24., 40., x0, x1].iter().any(|x| (p.x - x).abs() < 1e-9), "{what}: a vertex at {p:?}"); + } + } +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 2a21c3fe..1e2fcd99 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -54,6 +54,8 @@ mod issue_788_tests; mod issue_851_tests; #[cfg(test)] mod issue_871_tests; +#[cfg(test)] +mod join_between_caps_tests; mod lip; mod loopblend; mod measure; diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index f0b1c14f..8a5fdca3 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -425,10 +425,13 @@ fn plausible(v: f64, op: BoolOp, va: f64, vb: f64, slack: f64) -> bool { } } -/// The prism route's result: `Some` when both operands are prisms along one direction (see -/// [`crate::prism::prism_boolean`]), with a union's slab seams healed as the general B-rep -/// boolean does. -fn prism_result(a: &Body, b: &Body, op: BoolOp, size: f64, counts: &[(usize, usize)]) -> Option>> { +/// A boolean in 2D of prisms along one direction (see [`prism_result`]). +type Route = fn(&Body, &Body, BoolOp) -> Option>>; + +/// A prism route's result (`route`: [`crate::prism::prism_boolean`] or +/// [`crate::prism::prism_union_between_caps`]): `Some` when both operands are prisms along one +/// direction, with a union's slab seams healed as the general B-rep boolean does. +fn prism_result(a: &Body, b: &Body, op: BoolOp, size: f64, counts: &[(usize, usize)], route: Route) -> Option>> { // Slab-by-slab prism booleans sew a closed solid, but leave cap and wall pieces along slab // boundaries: a cut along Z through a box whose side wall already has a port in it splits // that wall into strips at the port's top and bottom, though the cut never touches it. Merge @@ -437,7 +440,7 @@ fn prism_result(a: &Body, b: &Body, op: BoolOp, size: f64, counts: &[(usize, usi // protection) keep their pieces. let keep: Vec> = a.split_keep().iter().chain(b.split_keep()).cloned().collect(); let authored = |x: &Body| x.regularization_protected || x.paint().is_some_and(|p| !p.faces.is_empty()); - crate::prism::prism_boolean(a, b, op).map(|result| { + route(a, b, op).map(|result| { result.map(|body| { body.map(|body| { if op == BoolOp::Union { @@ -490,7 +493,7 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { // join the sampled shortcut does. A pair the route cannot answer still takes the path below, // unchanged. let early_prism = matches!(op, BoolOp::Cut | BoolOp::Intersect); - let prism = early_prism.then(|| prism_result(a, b, op, size, &counts)).flatten(); + let prism = early_prism.then(|| prism_result(a, b, op, size, &counts, crate::prism::prism_boolean)).flatten(); if let Some(Ok(Some(body))) = &prism { let (va0, vb0) = (volume(a), volume(b)); let (v0, slack0) = (volume(body), 2e-3 * (va0 + vb0) + 1e-9); @@ -563,7 +566,7 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { // Prisms along one direction (plates with holes): exact, in 2D (so only the volume bounds // are checked). For a cut or an intersection this already ran above; the other operations // ask here, after the sampled shortcut, which is where that shortcut is cheapest. - let prism = prism.or_else(|| (!early_prism).then(|| prism_result(a, b, op, size, &counts)).flatten()); + let prism = prism.or_else(|| (!early_prism).then(|| prism_result(a, b, op, size, &counts, crate::prism::prism_boolean)).flatten()); if let Some(result) = prism { match result { Ok(Some(body)) => { @@ -799,6 +802,17 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { } Err(e) => last = format!("{last}; {e}"), } + // Prisms along one direction that end at different levels (a barrel joined between two + // faces of a wider part, its ends flush with them) were left to the B-rep boolean, which + // the coincident ends defeat, with no room to push them apart (the part goes on past + // them). In 2D they join exactly: asked before the tool is moved. + if let Some(Ok(Some(body))) = prism_result(a, b, op, size, &counts, crate::prism::prism_union_between_caps) { + let (ok, v) = check(&body); + if ok { + return Ok(Some(body)); + } + last = format!("{last}; the prisms joined in 2D gave an implausible volume {v:.4}"); + } // Last resort: nudge the tool by a micron-scale screw motion (turned 2e-6 rad about a // skew axis and moved 2e-6 of the size), which breaks tangencies and seams lying exactly on // the other body. The result moves by no more than that. diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs index e1bc2dba..17fe0539 100644 --- a/crates/kernel/src/prism.rs +++ b/crates/kernel/src/prism.rs @@ -1866,11 +1866,13 @@ fn stack_tagged(b: &Body, d: Vec3, plane: &Plane, tol: f64, index: &LazyIndex) - #[cfg(test)] fn stack_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> { - stack_indexed(a, b, op, [&LazyIndex::new(a, 0), &LazyIndex::new(b, 1)]) + stack_indexed(a, b, op, [&LazyIndex::new(a, 0), &LazyIndex::new(b, 1)], true) } -/// [`prism_boolean`] of stepped prisms, the operands' indexes given. -fn stack_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2]) -> Option>> { +/// [`prism_boolean`] of stepped prisms, the operands' indexes given. A union is taken of +/// operands that end at a common level when `common_cap` is set, else of those that don't (see +/// [`prism_union_between_caps`]). +fn stack_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2], common_cap: bool) -> Option>> { // Common-cap joins and overlapping partial-depth cuts of aligned stepped prisms can // retain their exact section boundaries instead of intersecting meshes. // Nonaligned bodies retain their existing B-rep path. @@ -1901,8 +1903,9 @@ fn stack_indexed(a: &Body, b: &Body, op: BoolOp, [ia, ib]: [&LazyIndex; 2]) -> O let pa = stack_tagged(a, d, &plane, tol, ia)?; let pb = stack_tagged(b, d, &plane, tol, ib)?; let (ea, eb) = (ends(&pa), ends(&pb)); + let shared = (ea.0 - eb.0).abs() < tol || (ea.1 - eb.1).abs() < tol; match op { - BoolOp::Union if (ea.0 - eb.0).abs() >= tol && (ea.1 - eb.1).abs() >= tol => return None, + BoolOp::Union if shared != common_cap => return None, // A cut tool may extend past a cap into empty space. Exact slab sections // discard that space rather than intersecting coincident cylinder walls. BoolOp::Cut if ea.1 <= eb.0 + tol || eb.1 <= ea.0 + tol => return None, @@ -1982,7 +1985,19 @@ pub(crate) fn prism_boolean_free_form(a: &Body, b: &Body, op: BoolOp) -> Option< pub(crate) fn prism_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> { // Each operand's index is built once, and only if it is read as a prism. let index = [&LazyIndex::new(a, 0), &LazyIndex::new(b, 1)]; - crate::guard("prism boolean", || Ok(simple_prism_indexed(a, b, op, index).or_else(|| stack_indexed(a, b, op, index)))).ok().flatten() + crate::guard("prism boolean", || Ok(simple_prism_indexed(a, b, op, index).or_else(|| stack_indexed(a, b, op, index, true)))).ok().flatten() +} + +/// [`prism_boolean`] of a union of stepped prisms along one direction that end at different +/// levels (a barrel joined between two faces of a wider part, its ends flush with them), which +/// that route leaves to the B-rep boolean: for when that boolean has failed. `None` for other +/// operations and operands. +pub(crate) fn prism_union_between_caps(a: &Body, b: &Body, op: BoolOp) -> Option>> { + if op != BoolOp::Union { + return None; + } + let index = [&LazyIndex::new(a, 0), &LazyIndex::new(b, 1)]; + crate::guard("prism boolean", || Ok(stack_indexed(a, b, op, index, false))).ok().flatten() } #[cfg(test)]