From 9a4d936b7b3414ecb3df11cb8d528fce3008e4be Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:08:12 +0200 Subject: [PATCH] fix: text cut into a large part stays on the exact 2D route (#1903) Thanks @NOWN-DEV. --- crates/doc/src/eval.rs | 12 ++++++-- crates/engine/src/cmd/features_more_tests.rs | 26 ++++++++++++++++ crates/kernel/src/prism.rs | 9 ++++++ crates/kernel/src/prism_curve.rs | 31 +++++++++++++++++++- 4 files changed, 74 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index db6bce95..985aec5e 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1754,7 +1754,9 @@ fn fill_void_holes(st: &ModelState, r: &Region2, op: Operation, targets: &[Strin let size = bodies.iter().map(|b| b.body.size()).fold(0.0, f64::max); let margin = (size * 1e-4).max(1e-4); let n = plane.normal(); - let inside_any = |p: Vec3| meshes.iter().any(|m| m.contains(p)); + // Many probes on one mesh each: indexed, the same answers as `Mesh::contains`. + let inside: Vec<_> = meshes.iter().map(|m| m.inside_index()).collect(); + let inside_any = |p: Vec3| inside.iter().any(|m| m.contains(p)); let want = op == Operation::Join; let mut holes = Vec::with_capacity(r.holes.len()); for h in &r.holes { @@ -1816,7 +1818,9 @@ fn grow_profile_for_coplanar(st: &ModelState, r: &Region2, op: Operation, target _ => None, } }; - let inside_any = |p: Vec3| meshes.iter().any(|m| m.contains(p)); + // Many probes on one mesh each: indexed, the same answers as `Mesh::contains`. + let inside: Vec<_> = meshes.iter().map(|m| m.inside_index()).collect(); + let inside_any = |p: Vec3| inside.iter().any(|m| m.contains(p)); let mut grow = vec![0.0; segs.len()]; let mut any = false; for (i, seg) in segs.iter().enumerate() { @@ -1912,7 +1916,9 @@ fn extend_for_coplanar(st: &ModelState, plane: &Plane, r: &Region2, lo: f64, hi: let meshes: Vec> = bodies.iter().map(|b| b.mesh()).collect(); let samples = region_samples(r); let n = plane.normal(); - let inside_any = |p: Vec3| meshes.iter().any(|m| m.contains(p)); + // Many probes on one mesh each: indexed, the same answers as `Mesh::contains`. + let inside: Vec<_> = meshes.iter().map(|m| m.inside_index()).collect(); + let inside_any = |p: Vec3| inside.iter().any(|m| m.contains(p)); // Heights of the targets' faces parallel to the profile plane. let tol = (size * 1e-7).max(1e-9); let levels: Vec = meshes diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index edcb2a6c..f21bd390 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -1251,3 +1251,29 @@ fn a_line_of_text_cut_again_deeper_is_one_exact_boolean() { let again = cut(&mut s, 2.0); assert!(rel(again, first) < 1e-3, "{first} {again}"); } + +/// Text cut through a large plate (2 m) is decided in 2D in one exact boolean, as it is in a +/// small one. The 2D route's tolerances grow with the part: a font's curve that is merely near a +/// circle was read as an arc ending a micron off the next curve, so the letter's outline did not +/// close, and where the curves of a 3 come close the arrangement left a sliver that closed up to +/// nothing. The cut went to the B-rep boolean instead, which took tens of seconds and moved the +/// tool to find a result at all. +#[test] +fn text_cut_through_a_large_plate_is_one_exact_boolean() { + let taken = |w: f64, h: f64, at: [f64; 2]| { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":w,"width":h,"height":1.5,"corner":[0,0,0]})); + let before = volume(&mut s); + run(&mut s, "sketch.create", json!({"plane":"XY","offset":1.5})); + run(&mut s, "sketch.text", json!({"text":"PART-23","at":at,"height":7})); + run(&mut s, "sketch.finish", json!({})); + let id = run(&mut s, "solid.extrude", json!({"distance":-2,"operation":"cut"}))["feature"].as_u64().unwrap(); + let r = s.model.result(id).unwrap(); + assert!(r.error.is_none() && r.boolean_diagnostics.events.is_empty(), "{w} x {h}: {r:?}"); + before - volume(&mut s) + }; + let (small, large) = (taken(200.0, 120.0, [60.0, 40.0]), taken(2000.0, 1200.0, [860.0, 125.0])); + // The letters right through the plate, their counters left as pieces of their own: the same + // material in either plate (measured on meshes, which are coarser on the larger one). + assert!(small > 10.0 && rel(large, small) < 5e-2, "{small} {large}"); +} diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs index 71d0475a..41f989b7 100644 --- a/crates/kernel/src/prism.rs +++ b/crates/kernel/src/prism.rs @@ -1252,6 +1252,15 @@ fn exact_again(out: Section, sources: &[Seg2], tol: f64) -> Section { let mut loop_tags = Vec::new(); for (l, lp) in std::iter::once(®.outer).chain(®.holes).enumerate() { let (segs, ts) = exact_loop(lp, tags.get(l).map(Vec::as_slice).unwrap_or(&[]), sources, near); + // A sliver the arrangement found where a curve's arcs nearly touch another part of + // the outline (at the waist of a 3) closes up to nothing on the exact curves: it + // encloses nothing and would make edges with no length, so it is left out. + if segs.iter().map(Seg2::length).sum::() <= near { + if l == 0 { + break; + } + continue; + } loops.push(Loop2 { segs }); loop_tags.push(ts); } diff --git a/crates/kernel/src/prism_curve.rs b/crates/kernel/src/prism_curve.rs index 27ec3b76..e4e03dd2 100644 --- a/crates/kernel/src/prism_curve.rs +++ b/crates/kernel/src/prism_curve.rs @@ -5,6 +5,9 @@ use solidcraft_geom::{Plane, Seg2, Vec2}; use truck_modeling::{self as mt, BoundedCurve, Invertible, ParametricCurve}; const MAX_DEGREE: usize = 32; +/// How far an arc read off a curve may end from the curve's end (mm): half the gap a profile +/// loop may have between segments and still close (`crate::build`). +const ARC_END_TOL: f64 = 5e-7; const MAX_CONTROLS: usize = 4096; type H = [f64; 3]; @@ -353,7 +356,10 @@ fn arc(c: &mt::Curve, spans: &[Vec], plane: &Plane, a: Vec2, b: Vec2, tol: f6 } let start = (a - center).angle(); let end = center + Vec2::new((start + sweep).cos(), (start + sweep).sin()) * radius; - if end.dist(b) + error > tol { + // The arc ends where the curve does, as closely as a profile loop must close: within `tol` + // only (which grows with the model), a curve that is merely near a circle (a font's) would + // come back as an arc ending off the next segment's start, and the loop would not close. + if end.dist(b) + error > tol.min(ARC_END_TOL) { return None; } let closed = a.dist(b) < tol; @@ -471,6 +477,29 @@ mod tests { assert!(segment(&arc, &Plane::XY, 1e-5, &mut 0).is_none()); } + /// A curve read as an arc ends where the curve does, however large the tolerance (it grows + /// with the part): a font's quadratic that is within a micron of a circle, not on one, is + /// kept as the curve it is. Read as an arc whose end is a micron off, the outline of a letter + /// cut into a 2 m plate did not close. + #[test] + fn a_curve_near_a_circle_is_not_an_arc_ending_off_it() { + // A parabola within a micron of the circle through its ends and middle. + let e = edge(&[0., 2., 0.], &[0., 0., 0.2], &[1.], 2); + let (a, b) = (Vec2::new(0., 0.), Vec2::new(2., 0.2)); + assert!(from_p3(e.back().point()).dist(solidcraft_geom::Vec3::new(2., 0.2, 0.)) < 1e-12); + for tol in [1e-5, 2e-3] { + let segs = segment(&e, &Plane::XY, tol, &mut 2_000_000).unwrap(); + let (first, last) = (segs.first().unwrap(), segs.last().unwrap()); + assert!(first.start().dist(a) < 1e-9 && last.end().dist(b) < 1e-9, "{tol}: {segs:?}"); + } + // A true arc is still one at either tolerance. + let (p, q) = (mt::Vertex::new(mt::Point3::new(10., 0., 0.)), mt::Vertex::new(mt::Point3::new(0., 10., 0.))); + let arc = builder::circle_arc(&p, &q, mt::Point3::new(10. / 2f64.sqrt(), 10. / 2f64.sqrt(), 0.)); + for tol in [1e-5, 2e-3] { + assert!(matches!(segment(&arc, &Plane::XY, tol, &mut 2_000_000).unwrap()[..], [Seg2::Arc { .. }])); + } + } + #[test] fn discontinuous_and_multiple_winding_curves_decline() { let edge_from = |knots: Vec, points: Vec| {