From 39f6d68a5031008e5b240fc0162ad8e0b692ccd7 Mon Sep 17 00:00:00 2001 From: phanoob Date: Thu, 15 Oct 2026 21:33:01 +0200 Subject: [PATCH] Text cut through a part, or joined in relief, in one exact boolean (#1446) Checked locally on main merged with this: cargo check --workspace, the kernel and doc tests, and all robustness goldens (all_0 to all_3) pass. --- crates/doc/src/eval.rs | 27 +++++++--- crates/engine/src/cmd/features_more_tests.rs | 32 ++++++++++++ crates/kernel/src/prism.rs | 53 ++++++++++++++++++-- 3 files changed, 103 insertions(+), 9 deletions(-) diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 56308822..21186f45 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -2827,6 +2827,8 @@ fn feature_tools_with_constructor( let mut untrimmed = extent.to.is_none() && extent.from.is_none() && !extent.through_all; // The footprint of each tool in the sketch: a tool lies in the prism over it. let mut footprints = Vec::with_capacity(regions.len()); + // Each tool's extent along the normal (they differ where one is run on into a face). + let mut extents: Vec<(f64, f64)> = Vec::with_capacity(regions.len()); for r in ®ions { if *operation == Operation::Join { let exact = centered_extrude(&ss.plane, r, lo + off, hi + off, Some(&ss))?; @@ -2840,6 +2842,7 @@ fn feature_tools_with_constructor( // the sewable seam with a coincident curved intersection. tools.extend(exact); footprints.push(r.clone()); + extents.push((lo + off, hi + off)); continue; } } @@ -2850,15 +2853,27 @@ fn feature_tools_with_constructor( untrimmed &= l2 == lo + off && h2 == hi + off && &r == original; tools.extend(centered_extrude(&ss.plane, &r, l2, h2, Some(&ss))?); footprints.push(r); + extents.push((l2, h2)); } - // A cut by profiles apart in the sketch (the letters of a text): each tool lies in the - // prism over its footprint, so the tools are apart too (whatever their heights, and - // trimmed or not). The body is then cut once by all of them rather than once per - // profile, each cut working on a body grown by the one before. - // (Sheet metal keeps its own way of cutting, tool by tool, to keep its flat pattern.) + // A cut or a join by profiles apart in the sketch (the letters of a text): each tool + // lies in the prism over its footprint, so the tools are apart too (whatever their + // heights, and trimmed or not). The body is then cut once by all of them, or joined to + // all of them at once, rather than once per profile, each on a body grown or cut by + // the ones before. (A join meeting several bodies keeps joining them tool by tool; + // sheet metal keeps its own way of cutting, tool by tool, to keep its flat pattern.) let sheet = if targets.is_empty() { !st.sheets.is_empty() } else { targets.iter().any(|t| st.sheets.iter().any(|sheet| &sheet.body == t)) }; - if *operation == Operation::Cut && !sheet && tools.len() > 1 && regions_apart(&footprints, APART_GAP) { + let one_target = if targets.is_empty() { st.bodies.len() == 1 } else { targets.len() == 1 }; + // A join at once only of tools alike in height (one prism of them all, which the + // boolean joins exactly): tools that start at different levels (some run on into the + // face, one hanging over its edge) keep joining one by one. + let alike = extents.windows(2).all(|w| (w[0].0 - w[1].0).abs() < 1e-9 && (w[0].1 - w[1].1).abs() < 1e-9); + let at_once = match operation { + Operation::Cut => !sheet, + Operation::Join => one_target && !sheet && alike, + _ => false, + }; + if at_once && tools.len() > 1 && regions_apart(&footprints, APART_GAP) { let bodies: Vec = tools.iter().map(|t| t.body.clone()).collect(); if let Ok(Some(one)) = kernel::join_apart(&bodies) { tools = vec![Traced::step(one, &tools.iter().collect::>())]; diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index d91905de..765ec5eb 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -1044,3 +1044,35 @@ fn a_line_of_text_is_cut_in_one_exact_boolean() { let off = cut([-6.0, 20.0], 1.0); assert!(off > 0.0 && off < one, "{off} {one}"); } + +/// A line of text cut right through a part, and one joined on in relief, are decided in 2D in +/// one boolean too. Through, each letter's counter (inside an "e" or an "a") comes out as a +/// piece of its own. In relief the letters are joined to the part all at once: one by one, each +/// joined to a part grown by the ones before, it took minutes. +#[test] +fn a_line_of_text_cut_through_or_joined_in_relief_is_one_boolean() { + let text = |op: &str, distance: f64| { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[105,26]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance":40})); + let before = volume(&mut s); + run(&mut s, "sketch.create", json!({"plane":{"face":[50,13,40]}})); + run(&mut s, "sketch.text", json!({"text":"textvraiment tres long","at":[3,9],"height":6.5})); + run(&mut s, "sketch.finish", json!({})); + let id = run(&mut s, "solid.extrude", json!({"distance":distance,"operation":op}))["feature"].as_u64().unwrap(); + let r = s.model.result(id).unwrap(); + assert!(r.error.is_none() && r.boolean_diagnostics.events.is_empty(), "{op}: {r:?}"); + (before, volume(&mut s), s.model.state().bodies.len()) + }; + let (before, through, pieces) = text("cut", -40.0); + let (_, one, _) = text("cut", -1.0); + // Through: the letters' area all the way down, with the counters left as pieces. + assert!(pieces > 1, "{pieces}"); + assert!(rel(before - through, 40.0 * (before - one)) < 2e-3, "{} vs {}", before - through, 40.0 * (before - one)); + // In relief 2 high: twice what a 1 mm cut takes. + let (_, relief, pieces) = text("join", 2.0); + assert_eq!(pieces, 1); + assert!(rel(relief - before, 2.0 * (before - one)) < 2e-3, "{} vs {}", relief - before, 2.0 * (before - one)); +} diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs index 88444214..7e5642c7 100644 --- a/crates/kernel/src/prism.rs +++ b/crates/kernel/src/prism.rs @@ -1286,10 +1286,13 @@ fn exact_loop(lp: &Loop2, tags: &[Tag], sources: &[Seg2], near: f64) -> (Vec (Vec near { + continue; + } + let arc = |s: &Seg2| matches!(s, Seg2::Arc { .. }); + if !arc(&y) { + if let Some(s) = segs.get_mut(j) { + *s = seg_with_ends(&y, x.end(), y.end()); + } + } else if !arc(&x) + && let Some(s) = segs.get_mut(i) + { + *s = seg_with_ends(&x, x.start(), y.start()); + } + } (segs, ts) } @@ -1902,6 +1928,27 @@ fn sew_connected_sections( && s.length() <= 2.0 * tol && matches!((weld(&ends, &grid, s.start()), weld(&ends, &grid, s.end())), (Some(p), Some(q)) if p == q) }; + // One curve between two ends, as the walls and the caps beside them each read it: their + // sections come from different booleans, which split it from different pieces, so the + // control points can differ by a hair, and sewing tells edges apart by them. The first + // reading stands for all of them. + let chains: std::cell::RefCell> = std::cell::RefCell::new(Vec::new()); + let same_chain = |c: [Vec3; 4]| -> [Vec3; 4] { + let mid = |k: &[Vec3; 4]| (k[0] + (k[1] + k[2]) * 3.0 + k[3]) * 0.125; + let mut known = chains.borrow_mut(); + for k in known.iter() { + if mid(k).dist(mid(&c)) <= tol * 10.0 { + if k[0] == c[0] && k[3] == c[3] { + return *k; + } + if k[0] == c[3] && k[3] == c[0] { + return [k[3], k[2], k[1], k[0]]; + } + } + } + known.push(c); + c + }; let at = |u: Vec2, z: f64| plane.to_world(u) + d * z; let welded = |u: Vec2, z: f64| at(weld(&ends, &grid, u).unwrap_or(u), z); let edge = |sg: &Seg2, z: f64| -> Option { @@ -1910,7 +1957,7 @@ fn sew_connected_sections( Seg2::Arc { .. } => EdgeSpec::Arc { a: welded(sg.start(), z), b: welded(sg.end(), z), mid: at(sg.point_at(0.5), z) }, _ => { let [p0, p1, p2, p3] = cubic_of(sg)?; - EdgeSpec::Chain { ctrl: vec![welded(p0, z), at(p1, z), at(p2, z), welded(p3, z)].into() } + EdgeSpec::Chain { ctrl: same_chain([welded(p0, z), at(p1, z), at(p2, z), welded(p3, z)]).to_vec().into() } } }) };