Cut an extrude's profiles that stand apart in one boolean (#848)

When an extrude cuts with profiles whose outlines stay more than 1 µm apart (the letters of a text), their tools are apart too, so they are joined and cut from the body in one boolean instead of one per profile. A ten-letter text cut drops from about 13 s to 1.5 s with the same result. Sheet metal targets and touching profiles keep cutting tool by tool. Tapered extrudes are unaffected.
This commit is contained in:
phanoob authored and GitHub committed 2026-10-13 18:29:49 -04:00
1 parent 96f6424aec
commit 1f7f37d1ef
6 files changed
+194 -3

No files matched your search

+21 -1
View File
@@ -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 &regions {
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<Body> = 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::<Vec<_>>())];
}
}
if let Some(tb) = curved.and_then(|bi| st.bodies.get(bi)) {
let start = if hi > 0.0 { lo + off } else { hi + off };
@@ -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);
}
}
+1 -1
View File
@@ -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).
+125
View File
@@ -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<Vec<Vec2>>,
lo: Vec2,
hi: Vec2,
inside: Vec2,
}
let mut outlines = Vec::with_capacity(regions.len());
for r in regions {
let loops: Vec<Vec<Vec2>> = 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<usize> = (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) };
+1 -1
View File
@@ -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};
+11
View File
@@ -883,6 +883,17 @@ pub fn union_apart(bodies: &[Body]) -> Result<Option<Body>> {
}
}
}
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<Option<Body>> {
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) };