diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 1c0a4826..956d1f20 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, OnceLock}; use serde::Serialize; use solidcraft_geom::tess::TessPlan; -use solidcraft_geom::{Mesh, Plane, Region2, Vec2, Vec3}; +use solidcraft_geom::{Mesh, Plane, Region2, Vec2, Vec3, regions_apart}; use solidcraft_kernel::{self as kernel, Body, BoolOp}; use solidcraft_sketch::{CurveKind, Profile, SolveReport, find_profiles}; @@ -2292,6 +2292,9 @@ fn centered_extrude(plane: &Plane, region: &Region2, start: f64, end: f64, sketc .collect() } +/// How far apart (mm) profiles must stay for their extruded tools to be cut as one. +const APART_GAP: f64 = 1e-3; + /// The tool bodies a feature adds or removes (extrude, revolve, primitives), in the current state. fn feature_tools_with_constructor( doc: &Document, @@ -2386,6 +2389,8 @@ fn feature_tools_with_constructor( } let mut tools = Vec::new(); 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()); for r in ®ions { if *operation == Operation::Join { let exact = centered_extrude(&ss.plane, r, lo + off, hi + off, Some(&ss))?; @@ -2398,6 +2403,7 @@ fn feature_tools_with_constructor( // Preserve a whole touching face: extending into a chamfer replaces // the sewable seam with a coincident curved intersection. tools.extend(exact); + footprints.push(r.clone()); continue; } } @@ -2407,6 +2413,20 @@ fn feature_tools_with_constructor( let r = grow_profile_for_coplanar(st, r, *operation, targets, &probe); untrimmed &= l2 == lo + off && h2 == hi + off && &r == original; tools.extend(centered_extrude(&ss.plane, &r, l2, h2, Some(&ss))?); + footprints.push(r); + } + // 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.) + 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 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::>())]; + } } if let Some(tb) = curved.and_then(|bi| st.bodies.get(bi)) { let start = if hi > 0.0 { lo + off } else { hi + off }; diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index 1a2c3ede..510e90de 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -694,3 +694,38 @@ fn blind_bore_into_a_joined_boss_meshes() { let m = measure(&mut s); assert_eq!(m["bodies"].as_array().map(Vec::len), Some(1), "{m}"); } + +#[test] +fn cutting_a_text_removes_each_letter_whether_its_letters_stand_apart_or_touch() { + // Letters apart (cut at once by all of them), and two touching blocks (cut one after the other). + for (text, touching) in [("SOLIDCRAFT", false), ("", true)] { + 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":[200,40]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance":10})); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + if touching { + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[10,10],"p1":[20,20]})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[20,12],"p1":[30,18]})); + } else { + run(&mut s, "sketch.text", json!({"text":text,"at":[5,12],"height":14})); + } + let state = s.world_state(); + let ss = state.sketch(s.active_sketch.unwrap()).unwrap(); + // The profiles cut: the letters, not their counters (the inside of an O, whose outline + // is a hole of its letter). + let sorted = |ids: &[String]| { + let mut ids = ids.to_vec(); + ids.sort(); + ids + }; + let counter = |p: &solidcraft_sketch::Profile| ss.profiles.iter().any(|q| q.hole_curves.iter().any(|h| sorted(h) == sorted(&p.outer_curves))); + let area: f64 = ss.profiles.iter().filter(|p| !counter(p)).map(|p| p.area).sum(); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance":2,"operation":"cut"})); + let volume = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap(); + assert!((volume - (200.0 * 40.0 * 10.0 - 2.0 * area)).abs() < 1e-3 * area, "{text:?}: {volume} for {area} mm² cut 2 mm deep"); + assert_eq!(s.world_state().bodies.len(), 1); + } +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index eb31da69..fa6c9432 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -23,7 +23,7 @@ mod vec; pub use arcfit::MAX_FIT_PIECES; pub use mesh::{Aabb3, BoxedTriangle, InsideIndex, Mesh, MeshMeasure, find_close_pair}; pub use plane::Plane; -pub use profile::{Loop2, MAX_BEZIER_DEGREE, Region2, Seg2, bezier}; +pub use profile::{Loop2, MAX_BEZIER_DEGREE, Region2, Seg2, bezier, regions_apart}; pub use vec::{Vec2, Vec3}; /// Geometric tolerance for coincidence tests (mm). diff --git a/crates/geom/src/profile.rs b/crates/geom/src/profile.rs index 4145c585..22910989 100644 --- a/crates/geom/src/profile.rs +++ b/crates/geom/src/profile.rs @@ -568,10 +568,135 @@ impl Region2 { } } +/// Most boundary segment pairs [`regions_apart`] compares before it gives up (and says no). +const APART_BUDGET: usize = 50_000_000; + +/// Whether no two of the regions meet or come within `gap` of each other (their boundaries +/// are farther apart than `gap` and none lies inside another; one inside another's hole is +/// apart). Tools made from them alike (extruded over one interval) are then apart too, and +/// can be cut as one. False when unsure (a degenerate region, too much to compare). +pub fn regions_apart(regions: &[Region2], gap: f64) -> bool { + if !(gap.is_finite() && gap > 0.0) || regions.len() > 10_000 { + return false; + } + // The polylines lie within `tol` of the curves: boundaries this much farther than the gap + // apart are apart. + let tol = gap / 4.0; + let reach = gap + 2.0 * tol; + struct Outline { + loops: Vec>, + lo: Vec2, + hi: Vec2, + inside: Vec2, + } + let mut outlines = Vec::with_capacity(regions.len()); + for r in regions { + let loops: Vec> = std::iter::once(&r.outer).chain(&r.holes).map(|l| l.polyline(tol)).collect(); + let Some(outer) = loops.first().filter(|l| l.len() >= 2) else { return false }; + if outer.iter().any(|p| !p.is_finite()) { + return false; + } + let lo = outer.iter().fold(Vec2::new(f64::MAX, f64::MAX), |m, p| Vec2::new(m.x.min(p.x), m.y.min(p.y))); + let hi = outer.iter().fold(Vec2::new(f64::MIN, f64::MIN), |m, p| Vec2::new(m.x.max(p.x), m.y.max(p.y))); + let Some(inside) = outer.first().copied() else { return false }; + outlines.push(Outline { loops, lo, hi, inside }); + } + let mut order: Vec = (0..outlines.len()).collect(); + order.sort_by(|&i, &j| { + let x = |k: usize| outlines.get(k).map(|o| o.lo.x).unwrap_or(0.0); + x(i).total_cmp(&x(j)) + }); + let mut work = 0usize; + for (k, &i) in order.iter().enumerate() { + let Some(a) = outlines.get(i) else { return false }; + for &j in order.iter().skip(k + 1) { + let Some(b) = outlines.get(j) else { return false }; + if b.lo.x > a.hi.x + reach { + break; + } + if b.lo.y > a.hi.y + reach || a.lo.y > b.hi.y + reach { + continue; + } + // A boundary point of one inside the other: one holds the other (boundaries that do + // not come near each other do not cross). + let (Some(ra), Some(rb)) = (regions.get(i), regions.get(j)) else { return false }; + if ra.contains(b.inside) || rb.contains(a.inside) { + return false; + } + for la in &a.loops { + for lb in &b.loops { + work = work.saturating_add(la.len().saturating_mul(lb.len())); + if work > APART_BUDGET { + return false; + } + // (Each loop closed: its last point back to its first.) + for (p, q) in la.iter().zip(la.iter().skip(1).chain(la.first())) { + for (r, s) in lb.iter().zip(lb.iter().skip(1).chain(lb.first())) { + if segment_distance(*p, *q, *r, *s) <= reach { + return false; + } + } + } + } + } + } + } + true +} + +/// The distance between segments `ab` and `cd` (0 where they cross). +fn segment_distance(a: Vec2, b: Vec2, c: Vec2, d: Vec2) -> f64 { + let side = |p: Vec2, q: Vec2, r: Vec2| (q - p).cross(r - p); + let (d1, d2, d3, d4) = (side(c, d, a), side(c, d, b), side(a, b, c), side(a, b, d)); + if ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0)) && ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0)) { + return 0.0; + } + let to = |p: Vec2, s: Vec2, e: Vec2| { + let v = e - s; + let len2 = v.dot(v); + let t = if len2 > 0.0 { ((p - s).dot(v) / len2).clamp(0.0, 1.0) } else { 0.0 }; + p.dist(s + v * t) + }; + to(a, c, d).min(to(b, c, d)).min(to(c, a, b)).min(to(d, a, b)) +} + #[cfg(test)] mod tests { use super::*; + fn square(x: f64, y: f64, size: f64) -> Loop2 { + Loop2::polygon(&[Vec2::new(x, y), Vec2::new(x + size, y), Vec2::new(x + size, y + size), Vec2::new(x, y + size)]) + } + + fn solid(l: Loop2) -> Region2 { + Region2 { outer: l, holes: Vec::new() } + } + + #[test] + fn regions_are_apart_only_when_nothing_meets_or_holds_another() { + let gap = 1e-3; + // Side by side, a millimetre apart. + assert!(regions_apart(&[solid(square(0.0, 0.0, 5.0)), solid(square(6.0, 0.0, 5.0))], gap)); + // Touching along a side, or at a corner, or closer than the gap: not apart. + assert!(!regions_apart(&[solid(square(0.0, 0.0, 5.0)), solid(square(5.0, 0.0, 5.0))], gap)); + assert!(!regions_apart(&[solid(square(0.0, 0.0, 5.0)), solid(square(5.0, 5.0, 5.0))], gap)); + assert!(!regions_apart(&[solid(square(0.0, 0.0, 5.0)), solid(square(5.0005, 0.0, 5.0))], gap)); + // Overlapping, or one inside another: not apart. + assert!(!regions_apart(&[solid(square(0.0, 0.0, 5.0)), solid(square(3.0, 3.0, 5.0))], gap)); + assert!(!regions_apart(&[solid(square(0.0, 0.0, 10.0)), solid(square(2.0, 2.0, 2.0))], gap)); + // Inside another's hole, clear of its edge: apart (a letter in the middle of an O). + let ring = Region2 { outer: square(0.0, 0.0, 10.0), holes: vec![square(2.0, 2.0, 6.0).reversed()] }; + assert!(regions_apart(&[ring.clone(), solid(square(4.0, 4.0, 2.0))], gap)); + // Filling the hole: its edge is the hole's. + assert!(!regions_apart(&[ring, solid(square(2.0, 2.0, 6.0))], gap)); + // Curved boundaries: two circles a little apart, then touching. + assert!(regions_apart(&[solid(Loop2::circle(Vec2::ZERO, 1.0)), solid(Loop2::circle(Vec2::new(2.01, 0.0), 1.0))], gap)); + assert!(!regions_apart(&[solid(Loop2::circle(Vec2::ZERO, 1.0)), solid(Loop2::circle(Vec2::new(2.0, 0.0), 1.0))], gap)); + // Nothing to compare, and nonsense gaps. + assert!(regions_apart(&[], gap) && regions_apart(&[solid(square(0.0, 0.0, 1.0))], gap)); + assert!(!regions_apart(&[solid(square(0.0, 0.0, 1.0)), solid(square(5.0, 0.0, 1.0))], f64::NAN)); + } + #[test] fn closest_line_projection_and_degeneracy() { let line = Seg2::Line { a: Vec2::new(-3.0, 4.0), b: Vec2::new(7.0, 4.0) }; diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 15efb822..9ff27e2c 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -101,7 +101,7 @@ pub use mesh_convert::convert_prismatic_mesh; pub use meshbody::{MAX_TRIANGLES, mesh_body}; pub use native::{decode_native_body, encode_native_body, native_preview_mesh}; pub use offset::with_same_surface; -pub use ops::{BoolOp, boolean, can_join_touching_faces, split_by_plane, transform, transform_matrix, union_apart}; +pub use ops::{BoolOp, boolean, can_join_touching_faces, join_apart, split_by_plane, transform, transform_matrix, union_apart}; pub use polybool::{faceted_boolean, planar_boolean}; pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell}; pub use prism::{PLANAR_FIT_TOL, extrude_planar_regions, planar_regions_boolean}; diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 55336411..ce69aa4e 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -883,6 +883,17 @@ pub fn union_apart(bodies: &[Body]) -> Result> { } } } + join_apart(bodies) +} + +/// Bodies the caller knows are apart (no two meet or touch), as one body of several pieces, +/// without any intersection work: [`union_apart`] without its check, for tools whose making +/// shows they are apart (profiles apart in their sketch, extruded alike). `None` for fewer than +/// two bodies or a mesh. +pub fn join_apart(bodies: &[Body]) -> Result> { + if bodies.len() < 2 || bodies.len() > 10_000 || bodies.iter().any(Body::is_mesh) { + return Ok(None); + } // The pieces stay as they are, side by side: each face and edge of the result is its piece's // own (the history over `bodies`). let Some(joined) = join_pieces(bodies.to_vec())? else { return Ok(None) };