feat: free-form cut-outs stay exact in sheet-metal flat patterns (#692)

Sheet-metal cut-outs and base flange outlines drawn with ellipses, splines or text stay exact curves in the flat pattern and the unfolded body; the folded body follows them with tangent arcs within 0.001 mm (#659).
This commit is contained in:
NOWN-DEV authored and GitHub committed 2026-10-13 10:18:15 -04:00
1 parent eac4d66adc
commit 4ff8f3e14d
8 files changed
+894 -70

No files matched your search

+5 -8
View File
@@ -340,20 +340,17 @@ fn on_sheet_material(st: &ModelState, i: usize, p: Vec3) -> bool {
offsets.iter().any(|&dx| offsets.iter().any(|&dy| offsets.iter().any(|&dz| mesh.contains(p + Vec3::new(dx, dy, dz)))))
}
/// A region mirrored in the sketch x axis (y → −y); curves other than lines and arcs as polylines.
/// A region mirrored in the sketch x axis (y → −y), exactly: free-form curves through their
/// control points.
fn mirror_y(r: &Region2) -> Region2 {
let f = |p: Vec2| Vec2::new(p.x, -p.y);
let m = |l: &Loop2| {
let segs: Vec<Seg2> = l
.segs
.iter()
.flat_map(|s| match *s {
Seg2::Line { a, b } => vec![Seg2::Line { a: f(a), b: f(b) }],
Seg2::Arc { center, radius, start, sweep } => vec![Seg2::Arc { center: f(center), radius, start: -start, sweep: -sweep }],
other => {
let pts: Vec<Vec2> = (0..=16).map(|k| f(other.point_at(k as f64 / 16.0))).collect();
pts.windows(2).map(|w| Seg2::Line { a: w[0], b: w[1] }).collect()
}
.map(|s| match *s {
Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center: f(center), radius, start: -start, sweep: -sweep },
other => sheet::map_controls(&other, f),
})
.collect();
Loop2 { segs }.ccw()
+176 -39
View File
@@ -18,8 +18,8 @@ use crate::{DocError, Document, IDENTITY, Mat, Result, apply_point, apply_vector
// Bound the aggregate geometry, including sampled curves and intermediate clipping pieces.
// These are work limits, never geometry truncation: a larger input returns a useful error.
// Exact lines and arcs count once each (the planar booleans work on them whole); sampled
// curves count their 0.001 mm chords.
// Exact lines and arcs count once each (the planar booleans work on them whole); free-form
// curves (conics, splines, text) count the arcs the planar booleans follow them with.
const MAX_FLAT_POINTS: usize = 16_384;
// The 0.001 mm chords of everything, arcs included: what the planar booleans sample (their
// own limit is the same).
@@ -71,23 +71,57 @@ fn flat_points(polys: &[Vec<Vec2>]) -> Result<usize> {
Ok(n)
}
/// A loop as the planar booleans arrange it: free-form segments (conics, splines, text)
/// followed by tangent arcs within the kernel's fit tolerance.
fn fitted_loop(lp: &Loop2) -> Result<Loop2> {
if lp.is_line_arc() {
return Ok(lp.clone());
}
if lp.segs.len() > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
lp.fit_arcs(kernel::PLANAR_FIT_TOL).ok_or_else(flat_budget_error)
}
/// A region of lines and arcs only (see [`fitted_loop`]): what the folded solid is sewn from.
fn fitted_region(region: &Region2) -> Result<Region2> {
Ok(Region2 { outer: fitted_loop(&region.outer)?, holes: region.holes.iter().map(fitted_loop).collect::<Result<_>>()? })
}
/// A region with every free-form loop as its 0.001 mm chords: the outline a pocket is
/// machined along. The body booleans take a tool of many small cylinders several times
/// slower than one of planes.
fn chorded_region(region: &Region2) -> Result<Region2> {
let chorded = |lp: &Loop2| -> Result<Loop2> { if lp.is_line_arc() { Ok(lp.clone()) } else { Ok(Loop2::polygon(&flat_polyline(lp)?)) } };
Ok(Region2 {
outer: chorded(&region.outer)?.ccw(),
holes: region.holes.iter().map(|h| chorded(h).map(|l| l.ccw().reversed())).collect::<Result<_>>()?,
})
}
/// A line or free-form segment with its points mapped by `f`: exact under any affine map (a
/// conic keeps its weight). An arc is given by angles, not points, and stays as it is.
pub(crate) fn map_controls(seg: &Seg2, f: impl Fn(Vec2) -> Vec2) -> Seg2 {
match *seg {
Seg2::Line { a, b } => Seg2::Line { a: f(a), b: f(b) },
Seg2::Cubic { p0, p1, p2, p3 } => Seg2::Cubic { p0: f(p0), p1: f(p1), p2: f(p2), p3: f(p3) },
Seg2::Conic { a, apex, b, w } => Seg2::Conic { a: f(a), apex: f(apex), b: f(b), w },
Seg2::Bezier { n, mut p } => {
p.iter_mut().take(usize::from(n) + 1).for_each(|q| *q = f(*q));
Seg2::Bezier { n, p }
}
arc @ Seg2::Arc { .. } => arc,
}
}
fn planar_region(region: &Region2) -> Result<Region2> {
if region.holes.len() > MAX_FLAT_PIECES {
return Err(flat_budget_error());
}
region_complexity(std::slice::from_ref(region))?;
let convert = |lp: &Loop2| -> Result<Loop2> {
if lp.segs.iter().all(|s| matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) {
Ok(lp.clone())
} else {
// Keep free-form profiles through controlled 0.001 mm chord sampling.
Ok(Loop2::polygon(&flat_polyline(lp)?))
}
};
Ok(Region2 {
outer: convert(&region.outer)?.ccw(),
holes: region.holes.iter().map(|h| convert(h).map(|l| l.ccw().reversed())).collect::<Result<_>>()?,
})
// Free-form loops stay exact: the planar booleans follow them with arcs and hand a loop
// they leave whole its exact segments back.
Ok(Region2 { outer: region.outer.ccw(), holes: region.holes.iter().map(|h| h.ccw().reversed()).collect() })
}
fn region_complexity(regions: &[Region2]) -> Result<(usize, usize)> {
@@ -103,13 +137,21 @@ fn regions_complexity<'a>(regions: impl Iterator<Item = &'a Region2>) -> Result<
return Err(flat_budget_error());
}
for lp in std::iter::once(&region.outer).chain(&region.holes) {
// A free-form loop costs what the planar booleans work on: its fitted arcs.
let fitted;
let lp = if lp.is_line_arc() {
lp
} else {
fitted = fitted_loop(lp)?;
&fitted
};
edges = edges.saturating_add(lp.segs.len());
if edges > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
for seg in &lp.segs {
let chords = seg.polyline(1e-3).len().saturating_sub(1);
points = points.saturating_add(if matches!(seg, Seg2::Line { .. } | Seg2::Arc { .. }) { 1 } else { chords });
points = points.saturating_add(1);
samples = samples.saturating_add(chords);
if points > MAX_FLAT_POINTS || samples > MAX_FLAT_SAMPLES {
return Err(flat_budget_error());
@@ -843,22 +885,26 @@ impl SheetBody {
for (i, f) in self.flanges.iter().enumerate() {
pieces.push((Piece::Zone(i), self.zone(f)));
}
// The folded solid is sewn from lines and arcs: free-form outlines and cut-outs follow
// their fitted arcs there (the flat pattern keeps them exact).
let mut cuts = Vec::new();
let mut cut_points = 0usize;
for hole in &self.holes {
let region = planar_region(hole)?;
let region = fitted_region(&planar_region(hole)?)?;
cut_points = cut_points.saturating_add(region_complexity(std::slice::from_ref(&region))?.1);
if cut_points > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
cuts.push(region);
}
cuts.extend(self.through_pocket_regions(work)?);
for pocket in self.through_pocket_regions(work)? {
cuts.push(fitted_region(&pocket)?);
}
let cuts = material_union(cuts, work)?;
let mut out = Vec::new();
let mut points = 0usize;
for (piece, region) in pieces {
let panel = planar_region(&region)?;
let panel = fitted_region(&planar_region(&region)?)?;
let material = if cuts.is_empty() { vec![panel] } else { material_boolean(&[panel], &cuts, kernel::BoolOp::Cut, work)? };
for region in material {
points = points.saturating_add(region_complexity(std::slice::from_ref(&region))?.1);
@@ -1050,7 +1096,12 @@ impl SheetBody {
/// The flat pattern as one extrusion (outline with its cut-outs).
pub fn flat_solid(&self) -> Result<Body> {
let regions = self.flat_regions()?;
let mut regions = self.flat_regions()?;
if !self.pockets.is_empty() {
// Pockets are machined by the prism booleans, which read walls of planes and
// cylinders: the blank they cut follows its fitted arcs then.
regions = regions.iter().map(fitted_region).collect::<Result<_>>()?;
}
let mut local = self.clone();
local.frame = Plane::XY;
local.flat = true;
@@ -1128,13 +1179,14 @@ impl SheetBody {
if self.pockets.len() > 1000 {
return Err(flat_budget_error());
}
regions_complexity(self.pockets.iter().map(|p| &p.region))?;
let outlines = self.pockets.iter().map(|p| chorded_region(&p.region)).collect::<Result<Vec<_>>>()?;
regions_complexity(outlines.iter())?;
let panels = if self.flat {
vec![(None, self.flat_regions()?)]
} else {
self.material_panels(None, work)?.into_iter().map(|(pi, r)| (pi, vec![r])).collect()
};
for pocket in &self.pockets {
for (pocket, outline) in self.pockets.iter().zip(&outlines) {
if !pocket.z0.is_finite() || !pocket.z1.is_finite() || pocket.z0 < 0.0 || pocket.z1 > self.t || pocket.z0 >= pocket.z1 {
return Err(DocError::Invalid("invalid sheet pocket depth".into()));
}
@@ -1148,10 +1200,13 @@ impl SheetBody {
}
}
for (pi, panel) in &panels {
let clipped = material_boolean(panel, std::slice::from_ref(&pocket.region), kernel::BoolOp::Intersect, work)?;
let clipped = material_boolean(panel, std::slice::from_ref(outline), kernel::BoolOp::Intersect, work)?;
if clipped.is_empty() {
continue;
}
// What the tool takes over from a free-form cut-out of the panel follows its
// fitted arcs: walls of planes and cylinders, which the prism booleans take.
let clipped = clipped.iter().map(fitted_region).collect::<Result<Vec<_>>>()?;
let w = if self.flat { self.frame_mat() } else { self.world(*pi) };
let plane = plane_of(&w, Vec3::ZERO, Vec3::X, Vec3::Y)?;
// BRep replay is costlier than planar clipping; share the command budget.
@@ -1243,7 +1298,9 @@ impl SheetBody {
/// Actual flat material boundaries after cut-outs. The same panel and cut regions build
/// the folded solid; planar unions remove panel/bend seams and turn edge-crossing cuts
/// into notches. Lines and circular arcs remain exact through clipping and merging.
/// into notches. Lines and circular arcs remain exact through clipping and merging; a
/// free-form loop that comes out whole is the curve it was drawn as, one that is clipped
/// follows it with tangent arcs.
pub fn flat_regions(&self) -> Result<Vec<Region2>> {
let regions = self.flat_material()?;
if regions.is_empty() {
@@ -2995,21 +3052,12 @@ impl SheetBody {
Seg2::Arc { center: map(center), radius, start: rot + start, sweep }
}
}
other => Seg2::Line { a: map(other.start()), b: map(other.end()) },
// Free-form segments map exactly, through their control points.
other => map_controls(&other, map),
}
};
let map_loop = |lp: &Loop2| -> Result<Loop2> {
let curved_other = lp.segs.iter().any(|x| !matches!(x, Seg2::Line { .. } | Seg2::Arc { .. }));
if curved_other {
Ok(Loop2::polygon(&flat_polyline(lp)?.into_iter().map(map).collect::<Vec<_>>()))
} else {
Ok(Loop2 { segs: lp.segs.iter().map(seg).collect() })
}
};
Ok(Region2 {
outer: map_loop(&r.outer)?.ccw(),
holes: r.holes.iter().map(|h| map_loop(h).map(|l| l.ccw().reversed())).collect::<Result<_>>()?,
})
let map_loop = |lp: &Loop2| Loop2 { segs: lp.segs.iter().map(seg).collect() };
Ok(Region2 { outer: map_loop(&r.outer).ccw(), holes: r.holes.iter().map(|h| map_loop(h).ccw().reversed()).collect() })
}
/// Panels whose plane is parallel to `plane` (world): (panel, region).
@@ -3730,24 +3778,113 @@ mod tests {
}
#[test]
fn freeform_profile_uses_bounded_sampling_with_exact_round_holes() {
fn freeform_profile_stays_exact_with_exact_round_holes() {
let mut s = plate(100.0, 60.0);
let cubic =
Seg2::Cubic { p0: Vec2::new(100.0, 60.0), p1: Vec2::new(200.0 / 3.0, 80.0), p2: Vec2::new(100.0 / 3.0, 80.0), p3: Vec2::new(0.0, 60.0) };
s.base.outer.segs = vec![
Seg2::Line { a: Vec2::ZERO, b: Vec2::new(100.0, 0.0) },
Seg2::Line { a: Vec2::new(100.0, 0.0), b: Vec2::new(100.0, 60.0) },
Seg2::Cubic { p0: Vec2::new(100.0, 60.0), p1: Vec2::new(200.0 / 3.0, 80.0), p2: Vec2::new(100.0 / 3.0, 80.0), p3: Vec2::new(0.0, 60.0) },
cubic,
Seg2::Line { a: Vec2::new(0.0, 60.0), b: Vec2::ZERO },
];
s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(50.0, 30.0), 3.0), holes: vec![] });
let regions = s.flat_regions().unwrap();
assert!(regions[0].outer.segs.iter().all(|s| matches!(s, Seg2::Line { .. })));
// The outline the cut leaves whole keeps its exact cubic, the hole its exact arcs.
assert_eq!(regions[0].outer.segs.iter().filter(|s| matches!(s, Seg2::Line { .. })).count(), 3);
assert!(regions[0].outer.segs.contains(&cubic), "{:?}", regions[0].outer);
assert!(regions[0].holes[0].segs.iter().all(|s| matches!(s, Seg2::Arc { .. })));
// The cubic has x=100(1-t), y=60+60t(1-t), hence an exact blank area of 7000.
assert!((regions[0].area() - (7000.0 - std::f64::consts::PI * 9.0)).abs() < 1e-9, "{}", regions[0].area());
let expected = (7000.0 - std::f64::consts::PI * 9.0) * 2.5;
assert!((vol(&s.solid().unwrap()) - expected).abs() < 0.5);
assert!((vol(&s.flat_solid().unwrap()) - expected).abs() < 0.5);
}
/// An ellipse about `c` with semi-axes `a` (along x) and `b`, as four exact conics.
fn ellipse(c: Vec2, a: f64, b: f64) -> Region2 {
let corners = [c + Vec2::new(a, 0.0), c + Vec2::new(0.0, b), c - Vec2::new(a, 0.0), c - Vec2::new(0.0, b)];
let w = std::f64::consts::FRAC_1_SQRT_2;
let segs = (0..4).map(|i| Seg2::Conic { a: corners[i], apex: corners[i] + corners[(i + 1) % 4] - c, b: corners[(i + 1) % 4], w }).collect();
Region2 { outer: Loop2 { segs }, holes: vec![] }
}
#[test]
fn free_form_cut_outs_stay_exact_in_the_flat_pattern_and_follow_arcs_in_the_folded_solid() {
let mut s = plate(100.0, 60.0);
s.add_flange(Vec3::new(50.0, 0.0, 2.5), 20.0, std::f64::consts::FRAC_PI_2, 2.5, "inside", false).unwrap();
let (blank, folded) = (s.flat_regions().unwrap()[0].area(), vol(&s.solid().unwrap()));
let hole = ellipse(Vec2::new(40.0, 30.0), 15.0, 8.0);
let cut = std::f64::consts::PI * 15.0 * 8.0;
s.holes.push(hole.clone());
// The flat pattern's cut-out is the ellipse itself (four conics, running as a hole) and
// its area is the blank less π·15·8.
let regions = s.flat_regions().unwrap();
assert_eq!(regions.len(), 1);
let h = &regions[0].holes[0];
assert!(h.segs.len() == 4 && h.segs.iter().all(|x| hole.outer.segs.contains(&x.reversed())), "{h:?}");
assert!((regions[0].area() - (blank - cut)).abs() < 1e-9, "{} vs {}", regions[0].area(), blank - cut);
// The folded solid is sewn from lines and arcs within the fit tolerance (times the
// perimeter, 74 mm): fewer than half the 264 chords of 0.001 mm.
let cells = s.cells_with_work(&mut FlatWork::new()).unwrap();
let base = cells.iter().find(|c| c.piece == Piece::Panel(None)).unwrap();
assert!(base.holes.len() == 1 && base.holes[0].len() < 132 && base.holes[0].iter().all(Seg2::is_line_or_arc), "{}", base.holes[0].len());
assert!((Loop2 { segs: base.holes[0].clone() }.signed_area().abs() - cut).abs() < kernel::PLANAR_FIT_TOL * 74.0);
assert!((folded - vol(&s.solid().unwrap()) - cut * 2.5).abs() < 0.5);
// The flat solid has the ellipse's wall as four faces.
let flat = s.flat_solid().unwrap();
assert!((vol(&flat) - (blank - cut) * 2.5).abs() < 0.5);
assert_eq!(flat.face_count(), regions[0].outer.segs.len() + 4 + 2);
}
#[test]
fn regions_map_into_the_flat_frame_with_their_exact_curves() {
let s = plate(100.0, 60.0);
let hole = ellipse(Vec2::new(40.0, 30.0), 15.0, 8.0);
// Drawn on the sheet's own plane: the region as it is.
assert_eq!(s.region_to_flat(None, &Plane::XY, &hole).unwrap(), hole);
// Drawn from below (y mirrored) at (40, −30), with a cubic and a quartic lobe on its
// right: the same ellipse on the sheet, every curve through its mirrored controls.
let below = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Y * -1.0 };
let mirror = |p: Vec2| Vec2::new(p.x, -p.y);
let seen = Region2 { outer: Loop2 { segs: hole.outer.segs.iter().map(|x| map_controls(x, mirror)).collect() }, holes: vec![] };
let mapped = s.region_to_flat(None, &below, &seen).unwrap();
assert!(mapped.outer.is_ccw() && mapped.outer.segs.len() == 4 && mapped.outer.segs.iter().all(|x| hole.outer.segs.contains(x)), "{mapped:?}");
// A cubic and a quartic closed by a line, clockwise in that plane: counter-clockwise on
// the sheet, every control point mirrored.
let (a, b, c) = (Vec2::new(60.0, -20.0), Vec2::new(60.0, -40.0), Vec2::new(80.0, -30.0));
let (cubic, quartic) = (
[a, a + Vec2::new(10.0, 4.0), c + Vec2::new(0.0, 8.0), c],
[c, c + Vec2::new(0.0, -6.0), c + Vec2::new(-6.0, -12.0), b + Vec2::new(8.0, -2.0), b],
);
let lobe = |f: fn(Vec2) -> Vec2| Loop2 {
segs: vec![
solidcraft_geom::bezier(&cubic.map(f)).unwrap(),
solidcraft_geom::bezier(&quartic.map(f)).unwrap(),
Seg2::Line { a: f(b), b: f(a) },
],
};
let drawn = lobe(|p| p);
let mapped = s.region_to_flat(None, &below, &Region2 { outer: drawn.clone(), holes: vec![] }).unwrap();
assert_eq!(mapped.outer, lobe(mirror));
assert!(mapped.holes.is_empty() && (mapped.area() + drawn.signed_area()).abs() < 1e-9 && mapped.area() > 100.0, "{}", mapped.area());
}
#[test]
fn pockets_with_free_form_outlines_are_machined_beside_free_form_cut_outs() {
let mut s = plate(100.0, 60.0);
s.holes.push(ellipse(Vec2::new(30.0, 30.0), 15.0, 8.0));
let pocket = ellipse(Vec2::new(70.0, 30.0), 10.0, 6.0);
s.record_pocket(pocket.clone(), 0.0, 1.0, &mut FlatWork::new()).unwrap();
// The plate less the through ellipse (π·15·8 of 2.5 mm) and the pocket (π·10·6 of 1 mm).
let expected = (6000.0 - std::f64::consts::PI * 120.0) * 2.5 - std::f64::consts::PI * 60.0;
assert!((vol(&s.solid().unwrap()) - expected).abs() < 0.5, "{}", vol(&s.solid().unwrap()));
assert!((vol(&s.flat_solid().unwrap()) - expected).abs() < 0.5, "{}", vol(&s.flat_solid().unwrap()));
// The machining record keeps the pocket's exact outline.
let machined = s.machining().unwrap();
assert!(machined.len() == 1 && machined[0].region.outer == pocket.outer, "{} records", machined.len());
}
#[test]
fn flat_geometry_budgets_reject_aggregate_and_intermediate_work() {
let mut s = plate(100.0, 60.0);
+69
View File
@@ -1333,6 +1333,75 @@ fn inherited_round_hole_survives_flange_and_unfold_in_flipped_frames() {
}
}
fn faces(s: &mut Session) -> (u64, u64) {
let body = run(s, "inspect.measure", json!({}))["bodies"][0].clone();
(body["faces"].as_u64().unwrap(), body["face_types"]["cylinder"].as_u64().unwrap_or(0))
}
#[test]
fn elliptic_cut_out_stays_exact_in_the_flat_pattern_and_follows_arcs_when_folded() {
use solidcraft_geom::Seg2;
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
let (uncut, blank) = (volume(&mut s), s.model.state().sheets[0].flat_regions().unwrap()[0].area());
let (plain_faces, plain_cylinders) = faces(&mut s);
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cut"}));
run(&mut s, "sketch.ellipse", json!({"center": [40, 30], "major": [55, 30], "minor_radius": 8}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Cut", "through_all": true, "operation": "cut", "direction": "symmetric"}));
// The ellipse takes π·15·8 of the 2.5 mm sheet (a body's volume is measured on its mesh).
let cut = PI * 15.0 * 8.0;
let folded = volume(&mut s);
assert!((uncut - folded - cut * 2.5).abs() < 0.5, "{}", uncut - folded);
// The flat pattern holds the ellipse itself, so its area is the blank less π·15·8.
let regions = s.model.state().sheets[0].flat_regions().unwrap();
let hole = &regions[0].holes[0];
assert!(hole.segs.len() == 4 && hole.segs.iter().all(|x| matches!(x, Seg2::Conic { .. })), "{} segments", hole.segs.len());
assert!((regions[0].area() - (blank - cut)).abs() < 1e-9, "{}", regions[0].area());
// The flat pattern JSON lists it as those four curves, one `polyline` each, with every
// point on the ellipse ((x − 40)/15)² + ((y − 30)/8)² = 1.
let shapes = flat(&mut s)["cutout_shapes"].as_array().cloned().unwrap_or_default();
let curves: Vec<Vec<Value>> = shapes[0]["segments"].as_array().into_iter().flatten().filter_map(|x| x["polyline"].as_array().cloned()).collect();
assert!(shapes.len() == 1 && curves.len() == 4 && shapes[0]["segments"].as_array().is_some_and(|a| a.len() == 4), "{} curves", curves.len());
let off = |p: &Value| ((p[0].as_f64().unwrap_or(0.0) - 40.0) / 15.0).powi(2) + ((p[1].as_f64().unwrap_or(0.0) - 30.0) / 8.0).powi(2) - 1.0;
assert!(curves.iter().all(|c| c.len() > 2 && c.iter().all(|p| off(p).abs() < 1e-9)));
// Folded, the cut-out's wall is cylinders over tangent arcs: fewer than half the 264
// chords of 0.001 mm (four quarters of √(2·17/0.008) = 66 each).
let (cut_faces, cut_cylinders) = faces(&mut s);
let wall = cut_faces - plain_faces;
assert!(wall < 132 && cut_cylinders - plain_cylinders + 4 >= wall, "{wall} wall faces, {} cylinders", cut_cylinders - plain_cylinders);
// Unfolded, it is the four conic faces of the ellipse.
run(&mut s, "sheet.unfold", json!({}));
assert!((volume(&mut s) - (blank - cut) * 2.5).abs() < 0.5);
assert_eq!(faces(&mut s).0, regions[0].outer.segs.len() as u64 + 4 + 2);
run(&mut s, "sheet.refold", json!({}));
assert!((volume(&mut s) - folded).abs() < 0.1);
}
#[test]
fn flipped_base_flange_keeps_its_elliptic_outline() {
for flip in [false, true] {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Oval"}));
run(&mut s, "sketch.ellipse", json!({"center": [0, 0], "major": [25, 0], "minor_radius": 12}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Oval", "flip": flip}));
let regions = s.model.state().sheets[0].flat_regions().unwrap();
let outline = &regions[0].outer.segs;
assert!(
outline.len() == 4 && outline.iter().all(|x| matches!(x, solidcraft_geom::Seg2::Conic { .. })),
"flip {flip}: {} segments",
outline.len()
);
// π·25·12 of the 2.5 mm sheet: exactly in the flat pattern; within 0.5 mm³ in the body,
// which follows the outline by arcs and is measured on its mesh (a 64-sided outline
// would be 3.8 mm³ short).
assert!((regions[0].area() - PI * 300.0).abs() < 1e-9, "flip {flip}: {}", regions[0].area());
assert!((volume(&mut s) - PI * 300.0 * 2.5).abs() < 0.5, "flip {flip}: {}", volume(&mut s));
}
}
#[test]
fn crossing_only_disconnecting_cut_preserves_all_material_states() {
let mut s = Session::default();
+391
View File
@@ -0,0 +1,391 @@
//! Arc fitting: free-form segments (conics, cubic and higher Béziers) as chains of circular
//! arcs and lines within a tolerance, tangent-continuous inside each segment (biarcs).
//!
//! Code that works on lines and arcs alone (planar booleans, the folded sheet-metal solid)
//! keeps smooth curves this way with far fewer pieces than a chord polyline of the same
//! tolerance: a biarc's error falls with the cube of its length, a chord's with the square.
use std::f64::consts::{PI, TAU};
use crate::{Loop2, Region2, Seg2, Vec2};
/// Most pieces one segment is fitted with.
pub const MAX_FIT_PIECES: usize = 2048;
/// Deepest subdivision of one segment.
const MAX_DEPTH: u32 = 30;
/// Samples inside each candidate span that must lie within the tolerance.
const SAMPLES: usize = 12;
impl Seg2 {
/// A straight line or a circular arc (what [`Seg2::fit_arcs`] produces).
pub fn is_line_or_arc(&self) -> bool {
matches!(self, Seg2::Line { .. } | Seg2::Arc { .. })
}
/// Lines and circular arcs that follow the segment within `tol` (mm), in order, the first
/// starting at the segment's start and the last ending at its end; consecutive pieces share
/// their end points and tangents. Lines and arcs come back unchanged. `None` when the
/// segment is not finite or would need more than [`MAX_FIT_PIECES`] pieces.
pub fn fit_arcs(&self, tol: f64) -> Option<Vec<Seg2>> {
if self.is_line_or_arc() {
return Some(vec![*self]);
}
if !(tol.is_finite() && tol > 0.0) {
return None;
}
let tol = tol.max(1e-9);
let mut out: Vec<Seg2> = Vec::new();
// Spans still to fit; the last pushed is fitted first, so pushing the right half before
// the left one keeps the output in parameter order.
let mut todo = vec![(0.0f64, 1.0f64, 0u32)];
let mut steps = 0usize;
while let Some((t0, t1, depth)) = todo.pop() {
steps += 1;
if steps > 4 * MAX_FIT_PIECES {
return None;
}
let (p0, p1) = (self.point_at(t0), self.point_at(t1));
if !(p0.is_finite() && p1.is_finite()) {
return None;
}
if p0.dist(p1) < 1e-12 && self.span_extent(t0, t1) < tol {
continue;
}
let fit = match (self.tangent_out(t0, t1), self.tangent_in(t0, t1)) {
(Some(d0), Some(d1)) => biarc(p0, d0, p1, d1).filter(|arcs| self.within(arcs, t0, t1, tol)),
_ => None,
};
match fit {
Some(arcs) => {
out.extend(arcs);
if out.len() > MAX_FIT_PIECES {
return None;
}
}
None if depth < MAX_DEPTH => {
let tm = 0.5 * (t0 + t1);
todo.push((tm, t1, depth + 1));
todo.push((t0, tm, depth + 1));
}
None => {
// Too fine to split further (a cusp): the chord is within the tolerance
// here or nothing is.
let chord = [Seg2::Line { a: p0, b: p1 }];
if !self.within(&chord, t0, t1, tol) {
return None;
}
out.push(chord[0]);
}
}
}
// The pieces join where their spans meet; pin the outer ends to the segment's own.
if let Some(first) = out.first_mut() {
*first = with_start(*first, self.start());
}
if let Some(last) = out.last_mut() {
*last = with_end(*last, self.end());
}
Some(out)
}
/// Unit direction of travel leaving `t` towards `t1`.
fn tangent_out(&self, t: f64, t1: f64) -> Option<Vec2> {
self.derivative(t).normalized().or_else(|| (self.point_at(t + (t1 - t) * 1e-4) - self.point_at(t)).normalized())
}
/// Unit direction of travel arriving at `t1` from `t0`.
fn tangent_in(&self, t0: f64, t1: f64) -> Option<Vec2> {
self.derivative(t1).normalized().or_else(|| (self.point_at(t1) - self.point_at(t1 - (t1 - t0) * 1e-4)).normalized())
}
/// Largest distance of the span's sample points from its start (a loop's size).
fn span_extent(&self, t0: f64, t1: f64) -> f64 {
let p0 = self.point_at(t0);
(1..=SAMPLES).map(|i| self.point_at(t0 + (t1 - t0) * i as f64 / (SAMPLES + 1) as f64).dist(p0)).fold(0.0, f64::max)
}
/// Do `pieces` follow the span `t0..t1` within `tol` both ways?
fn within(&self, pieces: &[Seg2], t0: f64, t1: f64, tol: f64) -> bool {
if pieces.iter().any(|p| matches!(p, Seg2::Arc { sweep, .. } if sweep.abs() > PI)) {
return false;
}
let samples: Vec<Vec2> = (0..=SAMPLES + 1).map(|i| self.point_at(t0 + (t1 - t0) * i as f64 / (SAMPLES + 1) as f64)).collect();
// The curve near the pieces...
if samples.iter().any(|p| pieces.iter().map(|s| dist_to(s, *p)).fold(f64::INFINITY, f64::min) > tol) {
return false;
}
// ...and the pieces near the curve (their quarter points against the curve's closest
// point on the span).
pieces.iter().all(|s| {
[0.25, 0.5, 0.75].iter().all(|f| {
let q = s.point_at(*f);
self.dist_on_span(q, t0, t1, &samples) <= tol
})
})
}
/// Distance from `q` to the span `t0..t1` (closest sample, then Newton steps).
fn dist_on_span(&self, q: Vec2, t0: f64, t1: f64, samples: &[Vec2]) -> f64 {
let n = samples.len().saturating_sub(1).max(1);
let (k, mut best) = samples.iter().enumerate().map(|(i, p)| (i, p.dist(q))).fold((0, f64::INFINITY), |a, b| if b.1 < a.1 { b } else { a });
let mut t = t0 + (t1 - t0) * k as f64 / n as f64;
for _ in 0..12 {
let (p, d) = (self.point_at(t), self.derivative(t));
let dd = d.dot(d);
if !(dd > 1e-300) {
break;
}
let nt = (t - (p - q).dot(d) / dd).clamp(t0, t1);
best = best.min(self.point_at(nt).dist(q));
if (nt - t).abs() < 1e-15 {
break;
}
t = nt;
}
best
}
}
impl Loop2 {
/// The loop with every free-form segment fitted by lines and arcs (see
/// [`Seg2::fit_arcs`]). `None` when a segment cannot be fitted.
pub fn fit_arcs(&self, tol: f64) -> Option<Loop2> {
let mut segs = Vec::with_capacity(self.segs.len());
for s in &self.segs {
segs.extend(s.fit_arcs(tol)?);
if segs.len() > MAX_FIT_PIECES * 8 {
return None;
}
}
Some(Loop2 { segs })
}
/// Only lines and arcs?
pub fn is_line_arc(&self) -> bool {
self.segs.iter().all(Seg2::is_line_or_arc)
}
}
impl Region2 {
/// Only lines and arcs?
pub fn is_line_arc(&self) -> bool {
self.outer.is_line_arc() && self.holes.iter().all(Loop2::is_line_arc)
}
}
/// Distance from `p` to a line or arc segment (other segments: to their closest point).
fn dist_to(s: &Seg2, p: Vec2) -> f64 {
match *s {
Seg2::Line { a, b } => {
let d = b - a;
let l2 = d.len2();
let t = if l2 > 1e-300 { ((p - a).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
(a + d * t).dist(p)
}
Seg2::Arc { center, radius, start, sweep } => {
let ang = (p - center).angle();
let delta = if sweep >= 0.0 { (ang - start).rem_euclid(TAU) } else { (start - ang).rem_euclid(TAU) };
if delta <= sweep.abs() { (p.dist(center) - radius).abs() } else { p.dist(s.start()).min(p.dist(s.end())) }
}
_ => s.point_at(s.closest_param(p)).dist(p),
}
}
/// The two arcs (or lines) from `p0` leaving along unit `d0` to `p1` arriving along unit `d1`,
/// meeting tangentially: the tangent lines from both ends are cut at one length `d`, and the
/// arcs join at the middle of the line between the two cut points (which is `2 d` long).
fn biarc(p0: Vec2, d0: Vec2, p1: Vec2, d1: Vec2) -> Option<Vec<Seg2>> {
let v = p1 - p0;
let vv = v.dot(v);
if vv < 1e-24 {
return None;
}
let len = vv.sqrt();
let chord = v / len;
if d0.cross(chord).abs() < 1e-12 && d1.cross(chord).abs() < 1e-12 && d0.dot(chord) > 0.0 && d1.dot(chord) > 0.0 {
return Some(vec![Seg2::Line { a: p0, b: p1 }]);
}
// |v − d (d0 + d1)| = 2 d, the positive root.
let t = d0 + d1;
let vt = v.dot(t);
let a = 2.0 * (1.0 - d0.dot(d1));
let d = if a < 1e-12 {
if vt <= 1e-12 * len {
return None;
}
vv / (2.0 * vt)
} else {
(-vt + (vt * vt + a * vv).max(0.0).sqrt()) / a
};
if !(d.is_finite() && d > 0.0) {
return None;
}
let j = ((p0 + d0 * d) + (p1 - d1 * d)) * 0.5;
let mut out = Vec::with_capacity(2);
if j.dist(p0) > 1e-12 {
out.push(arc_from(p0, d0, j)?);
}
if j.dist(p1) > 1e-12 {
out.push(arc_from(p1, -d1, j)?.reversed());
}
(!out.is_empty()).then_some(out)
}
/// The arc leaving `p` along unit `d` that ends at `q` (a line when `q` lies straight ahead).
fn arc_from(p: Vec2, d: Vec2, q: Vec2) -> Option<Seg2> {
let c = q - p;
let cc = c.dot(c);
let n = d.perp();
let h = n.dot(c);
// Sagitta of the arc over the chord: a line when the bulge is negligible.
if h.abs() * cc.sqrt() < 1e-12 * cc || (cc / (2.0 * h)).abs() > 1e7 {
return (d.dot(c) > 0.0).then_some(Seg2::Line { a: p, b: q });
}
let r = cc / (2.0 * h);
let center = p + n * r;
let start = (p - center).angle();
let mut sweep = (q - center).angle() - start;
if r > 0.0 {
while sweep <= 0.0 {
sweep += TAU;
}
} else {
while sweep >= 0.0 {
sweep -= TAU;
}
}
(sweep.is_finite() && sweep.abs() <= TAU).then_some(Seg2::Arc { center, radius: r.abs(), start, sweep })
}
/// `s` moved to start exactly at `p` (an arc keeps its centre; `p` is within a rounding error).
fn with_start(s: Seg2, p: Vec2) -> Seg2 {
match s {
Seg2::Line { b, .. } => Seg2::Line { a: p, b },
other => other,
}
}
/// `s` moved to end exactly at `p`.
fn with_end(s: Seg2, p: Vec2) -> Seg2 {
match s {
Seg2::Line { a, .. } => Seg2::Line { a, b: p },
other => other,
}
}
#[cfg(test)]
mod tests {
use std::f64::consts::{FRAC_1_SQRT_2, FRAC_PI_2};
use super::*;
/// Largest distance of 401 points of `seg` from `pieces`: 30 times as many as the fit
/// itself looks at in a span.
fn max_dev(seg: &Seg2, pieces: &[Seg2]) -> f64 {
(0..=400)
.map(|i| seg.point_at(i as f64 / 400.0))
.map(|p| pieces.iter().map(|s| dist_to(s, p)).fold(f64::INFINITY, f64::min))
.fold(0.0, f64::max)
}
fn chained(seg: &Seg2, pieces: &[Seg2]) {
assert!(pieces.first().unwrap().start().dist(seg.start()) < 1e-9);
assert!(pieces.last().unwrap().end().dist(seg.end()) < 1e-9);
for w in pieces.windows(2) {
assert!(w[0].end().dist(w[1].start()) < 1e-9, "{w:?}");
// Tangent-continuous inside the segment (biarc joints and span joints).
assert!(w[0].end_tangent().dot(w[1].start_tangent()) > 1.0 - 1e-6, "{w:?}");
}
assert!(pieces.iter().all(Seg2::is_line_or_arc));
}
fn ellipse(c: Vec2, a: f64, b: f64) -> Loop2 {
let corners = [c + Vec2::new(a, 0.0), c + Vec2::new(0.0, b), c - Vec2::new(a, 0.0), c - Vec2::new(0.0, b)];
Loop2 {
segs: (0..4)
.map(|i| Seg2::Conic { a: corners[i], apex: corners[i] + corners[(i + 1) % 4] - c, b: corners[(i + 1) % 4], w: FRAC_1_SQRT_2 })
.collect(),
}
}
#[test]
fn a_circular_arc_given_as_a_conic_comes_back_as_arcs_of_its_circle() {
// A quarter circle of radius 10 about (5, −3): the biarc through its ends and end
// tangents lies on the circle itself.
let c = Vec2::new(5.0, -3.0);
let q = Seg2::Conic { a: c + Vec2::new(10.0, 0.0), apex: c + Vec2::new(10.0, 10.0), b: c + Vec2::new(0.0, 10.0), w: FRAC_1_SQRT_2 };
let arcs = q.fit_arcs(1e-6).unwrap();
assert_eq!(arcs.len(), 2, "{arcs:?}");
let mut total = 0.0;
for s in &arcs {
let Seg2::Arc { center, radius, sweep, .. } = *s else { panic!("{s:?}") };
assert!(center.dist(c) < 1e-9 && (radius - 10.0).abs() < 1e-9, "{s:?}");
total += sweep;
}
assert!((total - FRAC_PI_2).abs() < 1e-9, "{total}");
chained(&q, &arcs);
}
#[test]
fn quarter_ellipse_fits_within_the_tolerance_with_few_arcs() {
let q = ellipse(Vec2::new(200.0, 150.0), 30.0, 12.0).segs[0];
for tol in [1e-2, 1e-3, 1e-4] {
let arcs = q.fit_arcs(tol).unwrap();
chained(&q, &arcs);
assert!(max_dev(&q, &arcs) <= tol * 1.01, "tol {tol}: {}", max_dev(&q, &arcs));
// Far fewer pieces than a chord polyline of the same tolerance.
let chords = q.polyline(tol).len() - 1;
assert!(arcs.len() * 2 < chords, "tol {tol}: {} arcs vs {chords} chords", arcs.len());
}
// The chords of 0.001 mm: √(2·√(30² + 12²) / 0.008) = 89.9, so 90.
assert_eq!(q.polyline(1e-3).len() - 1, 90);
assert!(q.fit_arcs(1e-3).unwrap().len() <= 32);
}
#[test]
fn glyph_like_quadratics_cubics_and_s_curves_fit() {
let segs = [
Seg2::Conic { a: Vec2::new(0.0, 0.0), apex: Vec2::new(1.0, 2.0), b: Vec2::new(3.0, 0.5), w: 1.0 },
Seg2::Cubic { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(1.0, 3.0), p2: Vec2::new(2.0, -3.0), p3: Vec2::new(3.0, 0.0) },
// A cusp-like cubic and one with a coincident control point.
Seg2::Cubic { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(4.0, 2.0), p2: Vec2::new(-1.0, 2.0), p3: Vec2::new(3.0, 0.0) },
Seg2::Cubic { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(0.0, 0.0), p2: Vec2::new(2.0, 1.0), p3: Vec2::new(3.0, 0.0) },
Seg2::Conic { a: Vec2::new(0.0, 0.0), apex: Vec2::new(5.0, 5.0), b: Vec2::new(10.0, 0.0), w: 3.0 },
crate::bezier(&[Vec2::new(0.0, 0.0), Vec2::new(1.0, 2.0), Vec2::new(2.0, -1.0), Vec2::new(3.0, 2.0), Vec2::new(4.0, 0.0)]).unwrap(),
];
for s in segs {
let arcs = s.fit_arcs(1e-3).unwrap();
assert!(max_dev(&s, &arcs) <= 1.01e-3, "{s:?}: {}", max_dev(&s, &arcs));
assert!(arcs.first().unwrap().start().dist(s.start()) < 1e-9 && arcs.last().unwrap().end().dist(s.end()) < 1e-9);
for w in arcs.windows(2) {
assert!(w[0].end().dist(w[1].start()) < 1e-9);
}
}
// A straight "curve" is one line; lines and arcs come back as they are.
let flat = Seg2::Cubic { p0: Vec2::ZERO, p1: Vec2::new(1.0, 0.0), p2: Vec2::new(2.0, 0.0), p3: Vec2::new(3.0, 0.0) };
assert_eq!(flat.fit_arcs(1e-3).unwrap(), vec![Seg2::Line { a: Vec2::ZERO, b: Vec2::new(3.0, 0.0) }]);
let arc = Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: 1.0 };
assert_eq!(arc.fit_arcs(1e-3).unwrap(), vec![arc]);
// Hostile input.
let bad = Seg2::Conic { a: Vec2::new(f64::NAN, 0.0), apex: Vec2::ZERO, b: Vec2::new(1.0, 0.0), w: 1.0 };
assert!(bad.fit_arcs(1e-3).is_none());
assert!(segs[0].fit_arcs(0.0).is_none() && segs[0].fit_arcs(f64::NAN).is_none());
}
#[test]
fn fitted_ellipse_loop_keeps_its_area() {
let lp = ellipse(Vec2::ZERO, 15.0, 8.0);
let fitted = lp.fit_arcs(1e-3).unwrap();
assert!(fitted.is_closed(1e-9) && fitted.is_line_arc() && !lp.is_line_arc());
// π·a·b, within the tolerance times the perimeter (74 mm).
let exact = PI * 15.0 * 8.0;
assert!((lp.signed_area() - exact).abs() < 1e-9, "{}", lp.signed_area());
assert!((fitted.signed_area() - exact).abs() < 1e-3 * 74.0, "{} vs {exact}", fitted.signed_area());
assert!(fitted.segs.len() * 2 < lp.polyline(1e-3).len(), "{} pieces", fitted.segs.len());
// As a hole of a 40 × 20 plate.
let plate = Loop2::polygon(&[Vec2::new(-20.0, -10.0), Vec2::new(20.0, -10.0), Vec2::new(20.0, 10.0), Vec2::new(-20.0, 10.0)]);
let (free, arcs) = (Region2 { outer: plate.clone(), holes: vec![lp.reversed()] }, Region2 { outer: plate, holes: vec![fitted.reversed()] });
assert!(arcs.is_line_arc() && !free.is_line_arc());
assert!((free.area() - (800.0 - exact)).abs() < 1e-9 && (arcs.area() - (800.0 - exact)).abs() < 1e-3 * 74.0, "{}", arcs.area());
}
}
+2
View File
@@ -8,6 +8,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod arcfit;
pub mod brep;
pub mod bspline;
pub mod env;
@@ -18,6 +19,7 @@ mod profile;
pub mod tspline;
mod vec;
pub use arcfit::MAX_FIT_PIECES;
pub use mesh::{Aabb3, InsideIndex, Mesh, MeshMeasure};
pub use plane::Plane;
pub use profile::{Loop2, MAX_BEZIER_DEGREE, Region2, Seg2, bezier};
+1 -1
View File
@@ -97,7 +97,7 @@ 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 polybool::{faceted_boolean, planar_boolean};
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
pub use prism::{extrude_planar_regions, planar_regions_boolean};
pub use prism::{PLANAR_FIT_TOL, extrude_planar_regions, planar_regions_boolean};
pub use prism_measure::certified_prism_equivalent;
pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew};
pub use splitface::{SplitTool, split_body, split_faces, split_faces_with_map};
+245 -20
View File
@@ -6,6 +6,8 @@
//! Stepped extrusions (case floors and walls) use sections between every cap level, so a
//! circular boss can meet the rim without shifting either operand off its exact plane.
use std::borrow::Cow;
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
use truck_modeling as mt;
@@ -19,13 +21,23 @@ const MAX_PLANAR_SEGMENTS: usize = 8192;
const MAX_PLANAR_EVENTS: usize = 131_072;
const MAX_PLANAR_PAIRS: usize = 16_000_000;
/// Boolean of planar regions bounded by straight lines and circular arcs. Boundaries stay
/// analytic; disjoint results and retained islands are returned as separate regions.
/// Free-form boundary segments (conics, Béziers) take part in planar booleans as lines and
/// arcs within this distance (mm); loops that come out whole get their exact segments back.
pub const PLANAR_FIT_TOL: f64 = 1e-3;
/// Boolean of planar regions bounded by straight lines, circular arcs and free-form segments.
/// Lines and arcs stay analytic. Free-form loops are arranged as tangent arcs within
/// [`PLANAR_FIT_TOL`]: a loop the operation leaves whole comes back exact, one it cuts keeps
/// the arcs. Disjoint results and retained islands are returned as separate regions.
pub fn planar_regions_boolean(a: &[Region2], b: &[Region2], op: BoolOp, tolerance: f64) -> Result<Vec<Region2>> {
crate::guard("planar region boolean", || {
if !tolerance.is_finite() || !(1e-9..=1.0).contains(&tolerance) {
return Err(KernelError::Invalid("planar boolean tolerance must be finite and between 1e-9 and 1 mm".into()));
}
let mut exact: Vec<(Loop2, Loop2)> = Vec::new();
let mut pieces = 0usize;
let (a, b) = (fit_free_form(a, &mut exact, &mut pieces)?, fit_free_form(b, &mut exact, &mut pieces)?);
let (a, b): (&[Region2], &[Region2]) = (&a, &b);
let loops = || a.iter().chain(b).flat_map(|r| std::iter::once(&r.outer).chain(&r.holes));
let mut edges = 0usize;
let mut samples = 0usize;
@@ -59,11 +71,72 @@ pub fn planar_regions_boolean(a: &[Region2], b: &[Region2], op: BoolOp, toleranc
}
}
}
boolean2d(a, b, op, tolerance)
.ok_or_else(|| KernelError::Failed("planar region arrangement failed or exceeded its bounded processing budget".into()))
let out = boolean2d(a, b, op, tolerance)
.ok_or_else(|| KernelError::Failed("planar region arrangement failed or exceeded its bounded processing budget".into()))?;
Ok(restore_exact_loops(out, &exact, tolerance))
})
}
/// Regions with their free-form loops fitted by lines and arcs; `exact` records each fitted
/// loop with the loop it stands for, `pieces` counts the fitted segments. Regions of lines and
/// arcs alone are passed on as they are.
fn fit_free_form<'a>(regions: &'a [Region2], exact: &mut Vec<(Loop2, Loop2)>, pieces: &mut usize) -> Result<Cow<'a, [Region2]>> {
if regions.iter().all(Region2::is_line_arc) {
return Ok(Cow::Borrowed(regions));
}
let mut fit = |lp: &Loop2| -> Result<Loop2> {
if lp.is_line_arc() {
return Ok(lp.clone());
}
let fitted = lp.fit_arcs(PLANAR_FIT_TOL).filter(|f| {
*pieces = pieces.saturating_add(f.segs.len());
*pieces <= MAX_PLANAR_SEGMENTS
});
let fitted = fitted.ok_or_else(|| {
KernelError::Invalid("planar boolean: a free-form boundary is not finite or takes more arcs than the geometry budget".into())
})?;
exact.push((fitted.clone(), lp.clone()));
Ok(fitted)
};
let fitted: Result<Vec<Region2>> =
regions.iter().map(|r| Ok(Region2 { outer: fit(&r.outer)?, holes: r.holes.iter().map(&mut fit).collect::<Result<_>>()? })).collect();
fitted.map(Cow::Owned)
}
/// Output loops that are a fitted free-form loop, whole (from any start, either way round), get
/// the exact loop back, running the same way.
fn restore_exact_loops(regions: Vec<Region2>, exact: &[(Loop2, Loop2)], tol: f64) -> Vec<Region2> {
if exact.is_empty() {
return regions;
}
let near = |p: Vec2, q: Vec2| p.dist(q) <= tol * 10.0;
let same_run = |l: &Loop2, f: &Loop2| -> bool {
let n = f.segs.len();
let Some(first) = l.segs.first() else { return false };
(0..n).any(|k| {
f.segs.get(k).is_some_and(|s| near(s.start(), first.start()))
&& l.segs.iter().enumerate().all(|(i, s)| {
f.segs.get((k + i) % n).is_some_and(|g| near(g.start(), s.start()) && near(g.end(), s.end()) && near(g.mid(), s.mid()))
})
})
};
let restore = |l: Loop2| -> Loop2 {
for (fitted, original) in exact {
if fitted.segs.len() != l.segs.len() {
continue;
}
if same_run(&l, fitted) {
return original.clone();
}
if same_run(&l, &fitted.reversed()) {
return original.reversed();
}
}
l
};
regions.into_iter().map(|r| Region2 { outer: restore(r.outer), holes: r.holes.into_iter().map(&restore).collect() }).collect()
}
/// Extrude normalized planar material as one logical compound, retaining every disjoint
/// region and its holes. Planar arrangement certifies separation before shells are joined.
pub fn extrude_planar_regions(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Body> {
@@ -95,8 +168,10 @@ pub fn extrude_planar_regions(plane: &Plane, regions: &[Region2], start: f64, en
if normalized.is_empty() {
return Err(KernelError::Invalid("empty planar material extrusion".into()));
}
for (i, a) in normalized.iter().enumerate() {
for b in normalized.iter().skip(i + 1) {
// The separation test knows lines and arcs: free-form loops take part as their arcs.
let fitted = fit_free_form(&normalized, &mut Vec::new(), &mut 0)?;
for (i, a) in fitted.iter().enumerate() {
for b in fitted.iter().skip(i + 1) {
for s in std::iter::once(&a.outer).chain(&a.holes).flat_map(|l| &l.segs) {
for t in std::iter::once(&b.outer).chain(&b.holes).flat_map(|l| &l.segs) {
remaining_pairs = remaining_pairs
@@ -887,19 +962,61 @@ fn dist_to(regions: &[Region2], p: Vec2) -> f64 {
.fold(f64::INFINITY, f64::min)
}
/// Direction of the boundary of `regions` through `p` (on it), as the boundary runs.
fn dir_at(regions: &[Region2], p: Vec2, tol: f64) -> Option<Vec2> {
for r in regions {
for l in std::iter::once(&r.outer).chain(&r.holes) {
for s in &l.segs {
let t = s.closest_param(p);
if s.point_at(t).dist(p) < tol {
return s.derivative(t).normalized();
/// The bounding box of a line or an arc, grown by twice the tolerance (and by the rounding of
/// its own corners): (min, max). Whatever lies within the tolerance of the segment lies in the
/// box. `None` for other segments and for values that are not finite: no box, and nothing is
/// skipped for it.
type SegBox = (Vec2, Vec2);
fn seg_box(s: &Seg2, tol: f64) -> Option<SegBox> {
let mut pts = vec![s.start(), s.end()];
match *s {
Seg2::Line { .. } => {}
Seg2::Arc { center, radius, start, sweep } => {
// The axis extremes the arc passes.
for k in 0..4 {
let ang = k as f64 * std::f64::consts::FRAC_PI_2;
let delta =
if sweep >= 0.0 { (ang - start).rem_euclid(std::f64::consts::TAU) } else { (start - ang).rem_euclid(std::f64::consts::TAU) };
if delta <= sweep.abs() {
pts.push(center + Vec2::from_angle(ang) * radius);
}
}
}
_ => return None,
}
None
let lo = pts.iter().fold(Vec2::new(f64::INFINITY, f64::INFINITY), |m, p| Vec2::new(m.x.min(p.x), m.y.min(p.y)));
let hi = pts.iter().fold(Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY), |m, p| Vec2::new(m.x.max(p.x), m.y.max(p.y)));
let g = tol * 2.0 + 1e-12 * pts.iter().fold(0.0, |m: f64, p| m.max(p.x.abs()).max(p.y.abs()));
(pts.iter().all(|p| p.is_finite()) && g.is_finite()).then_some((Vec2::new(lo.x - g, lo.y - g), Vec2::new(hi.x + g, hi.y + g)))
}
fn boxes_meet(a: Option<SegBox>, b: Option<SegBox>) -> bool {
match (a, b) {
(Some((a0, a1)), Some((b0, b1))) => a0.x <= b1.x && b0.x <= a1.x && a0.y <= b1.y && b0.y <= a1.y,
_ => true,
}
}
fn box_holds(b: Option<SegBox>, p: Vec2) -> bool {
b.is_none_or(|(lo, hi)| p.x >= lo.x && p.x <= hi.x && p.y >= lo.y && p.y <= hi.y)
}
/// Distance from `p` to the nearest of `segs` whose box holds it: infinite when none does (no
/// segment is within the tolerance of `p` then).
fn near_dist(segs: &[Seg2], boxes: &[Option<SegBox>], p: Vec2) -> f64 {
segs.iter().zip(boxes).filter(|(_, b)| box_holds(**b, p)).map(|(s, _)| s.point_at(s.closest_param(p)).dist(p)).fold(f64::INFINITY, f64::min)
}
/// Direction of the first of `segs` through `p` (within `tol`), as it runs.
fn near_dir(segs: &[Seg2], boxes: &[Option<SegBox>], p: Vec2, tol: f64) -> Option<Vec2> {
let (s, t) = segs
.iter()
.zip(boxes)
.filter(|(_, b)| box_holds(**b, p))
.map(|(s, _)| (s, s.closest_param(p)))
.find(|(s, t)| s.point_at(*t).dist(p) < tol)?;
s.derivative(t).normalized()
}
/// 2D boolean of regions bounded by lines and arcs.
@@ -922,12 +1039,19 @@ fn boolean2d_tagged(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Se
if sa.iter().chain(&sb).any(|s| !matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) {
return None;
}
// Only segments whose bounding boxes meet can cross, touch or lie on each other: the other
// pairs skip the exact tests (a closest-point search each).
let (box_a, box_b): (Vec<_>, Vec<_>) = (sa.iter().map(|s| seg_box(s, tol)).collect(), sb.iter().map(|s| seg_box(s, tol)).collect());
let apart = |i: usize, j: usize| !boxes_meet(box_a.get(i).copied().flatten(), box_b.get(j).copied().flatten());
// Split every segment where it crosses the other side's boundary.
let mut cut_a: Vec<Vec<f64>> = vec![Vec::new(); sa.len()];
let mut cut_b: Vec<Vec<f64>> = vec![Vec::new(); sb.len()];
let mut events = 0usize;
for (i, s) in sa.iter().enumerate() {
for (j, t) in sb.iter().enumerate() {
if apart(i, j) {
continue;
}
for (u, v) in crossings(s, t, tol) {
events += 2;
if events > MAX_PLANAR_EVENTS {
@@ -940,7 +1064,10 @@ fn boolean2d_tagged(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Se
}
// Ends of the other side's segments lying on a segment split it too (touching corners).
for (i, s) in sa.iter().enumerate() {
for t in &sb {
for (j, t) in sb.iter().enumerate() {
if apart(i, j) {
continue;
}
for p in [t.start(), t.end()] {
let u = s.closest_param(p);
if s.point_at(u).dist(p) < tol {
@@ -954,7 +1081,10 @@ fn boolean2d_tagged(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Se
}
}
for (j, t) in sb.iter().enumerate() {
for s in &sa {
for (i, s) in sa.iter().enumerate() {
if apart(i, j) {
continue;
}
for p in [s.start(), s.end()] {
let v = t.closest_param(p);
if t.point_at(v).dist(p) < tol {
@@ -997,9 +1127,9 @@ fn boolean2d_tagged(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Se
let (n, tag) = (split.len(), ta.get(i).copied().unwrap_or(BLANK));
for (k, p) in split.into_iter().enumerate() {
let m = p.point_at(0.5);
let on = dist_to(&b, m) < tol;
let on = near_dist(&sb, &box_b, m) < tol;
let keep = if on {
let same = dir_at(&b, m, tol).is_some_and(|d| d.dot(p.derivative(0.5)) > 0.0);
let same = near_dir(&sb, &box_b, m, tol).is_some_and(|d| d.dot(p.derivative(0.5)) > 0.0);
match op {
BoolOp::Union | BoolOp::Intersect => same,
BoolOp::Cut => !same,
@@ -1028,7 +1158,7 @@ fn boolean2d_tagged(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Se
let (n, tag) = (split.len(), tb.get(j).copied().unwrap_or(BLANK));
for (k, p) in split.into_iter().enumerate() {
let m = p.point_at(0.5);
if dist_to(&a, m) < tol {
if near_dist(&sa, &box_a, m) < tol {
continue;
}
let ins = inside(&a, m);
@@ -2205,6 +2335,101 @@ mod planar_tests {
assert!(extrude_planar_regions(&plane, &circles, 0.0, 2.0).is_err());
}
/// An ellipse about `c` with semi-axes `a` (along x) and `b`, as four exact conics.
fn ellipse(c: Vec2, a: f64, b: f64) -> Loop2 {
let corners = [c + Vec2::new(a, 0.0), c + Vec2::new(0.0, b), c - Vec2::new(a, 0.0), c - Vec2::new(0.0, b)];
let w = std::f64::consts::FRAC_1_SQRT_2;
Loop2 {
segs: (0..4).map(|i| Seg2::Conic { a: corners[i], apex: corners[i] + corners[(i + 1) % 4] - c, b: corners[(i + 1) % 4], w }).collect(),
}
}
#[test]
fn free_form_cut_outs_stay_exact_when_whole_and_follow_arcs_when_cut() {
let plate = rectangle(Vec2::ZERO, Vec2::new(100.0, 60.0));
let hole = Region2 { outer: ellipse(Vec2::new(40.0, 30.0), 15.0, 8.0), holes: vec![] };
let out = planar_regions_boolean(std::slice::from_ref(&plate), std::slice::from_ref(&hole), BoolOp::Cut, 1e-7).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].holes.len(), 1);
// The whole ellipse comes back as its four exact conics, running as a hole (clockwise):
// the plate less π·15·8.
let h = &out[0].holes[0];
assert_eq!(h.segs.len(), 4);
assert!(h.segs.iter().all(|s| hole.outer.segs.contains(&s.reversed())), "{h:?}");
assert!(h.signed_area() < 0.0);
let exact = 6000.0 - std::f64::consts::PI * 15.0 * 8.0;
assert!((out[0].area() - exact).abs() < 1e-9, "{} vs {exact}", out[0].area());
// What the cut takes is the ellipse, whole and exact, counter-clockwise.
let taken = planar_regions_boolean(std::slice::from_ref(&plate), std::slice::from_ref(&hole), BoolOp::Intersect, 1e-7).unwrap();
assert!(taken.len() == 1 && taken[0].outer.segs.iter().all(|s| hole.outer.segs.contains(s)), "{taken:?}");
// An ellipse centred on the plate's edge makes a notch of half the ellipse: its part
// inside follows arcs within the fit tolerance (times half the perimeter, 37 mm).
let over = Region2 { outer: ellipse(Vec2::new(100.0, 30.0), 15.0, 8.0), holes: vec![] };
let out = planar_regions_boolean(std::slice::from_ref(&plate), std::slice::from_ref(&over), BoolOp::Cut, 1e-7).unwrap();
assert_eq!(out.len(), 1);
assert!(out[0].holes.is_empty() && out[0].outer.is_line_arc());
let notched = 6000.0 - std::f64::consts::PI * 15.0 * 8.0 / 2.0;
assert!((out[0].area() - notched).abs() < PLANAR_FIT_TOL * 37.0, "{} vs {notched}", out[0].area());
// Two ellipses that overlap join into one outline of arcs; apart, both stay exact.
let beside = |dx: f64| Region2 { outer: ellipse(Vec2::new(40.0 + dx, 30.0), 15.0, 8.0), holes: vec![] };
let joined = planar_regions_boolean(std::slice::from_ref(&hole), &[beside(20.0)], BoolOp::Union, 1e-7).unwrap();
assert!(joined.len() == 1 && joined[0].outer.is_line_arc(), "{joined:?}");
let apart = planar_regions_boolean(std::slice::from_ref(&hole), &[beside(40.0)], BoolOp::Union, 1e-7).unwrap();
assert!(apart.len() == 2 && apart.iter().all(|r| r.outer.segs.len() == 4 && !r.outer.is_line_arc()), "{apart:?}");
// The flat material extrudes with the exact elliptic wall: four conic faces, two caps.
let body = extrude_planar_regions(&Plane::XY, &planar_regions_boolean(&[plate], &[hole], BoolOp::Cut, 1e-7).unwrap(), 0.0, 2.0).unwrap();
assert_eq!(body.face_count(), 4 + 4 + 2);
let v = crate::measure(&body).unwrap().volume;
assert!((v - exact * 2.0).abs() < 1e-3 * exact, "{v}");
}
#[test]
fn free_form_loops_that_cannot_be_followed_are_refused() {
let plate = rectangle(Vec2::ZERO, Vec2::new(100.0, 60.0));
let mut bad = ellipse(Vec2::new(40.0, 30.0), 15.0, 8.0);
bad.segs[1] = Seg2::Conic { a: bad.segs[1].start(), apex: Vec2::new(f64::NAN, 0.0), b: bad.segs[1].end(), w: 1.0 };
let e = planar_regions_boolean(std::slice::from_ref(&plate), &[Region2 { outer: bad, holes: vec![] }], BoolOp::Cut, 1e-7).unwrap_err();
assert!(e.to_string().contains("free-form"), "{e}");
// More fitted arcs than the arrangement's segment budget.
let many: Vec<Region2> = (0..400).map(|i| Region2 { outer: ellipse(Vec2::new(40.0 * i as f64, 0.0), 15.0, 8.0), holes: vec![] }).collect();
assert!(planar_regions_boolean(&[plate], &many, BoolOp::Cut, 1e-7).is_err());
}
#[test]
fn segment_boxes_hold_what_lies_within_the_tolerance() {
// An arc of radius 10 about the origin from 45° to 135° passes the top of its circle:
// x within ±10/√2, y from 10/√2 to 10, grown by twice the tolerance.
let r = 10.0 * std::f64::consts::FRAC_1_SQRT_2;
let arc = Seg2::Arc { center: Vec2::ZERO, radius: 10.0, start: std::f64::consts::FRAC_PI_4, sweep: std::f64::consts::FRAC_PI_2 };
for s in [arc, arc.reversed()] {
let (lo, hi) = seg_box(&s, 1e-3).unwrap();
assert!(lo.dist(Vec2::new(-r - 2e-3, r - 2e-3)) < 1e-9 && hi.dist(Vec2::new(r + 2e-3, 10.0 + 2e-3)) < 1e-9, "{lo:?} {hi:?}");
}
let (lo, hi) = seg_box(&Seg2::Line { a: Vec2::new(3.0, -1.0), b: Vec2::new(-2.0, 4.0) }, 0.5).unwrap();
assert!(lo.dist(Vec2::new(-3.0, -2.0)) < 1e-9 && hi.dist(Vec2::new(4.0, 5.0)) < 1e-9, "{lo:?} {hi:?}");
// A line 3 tolerances above the arc's top meets its box (each is grown by two); one
// 5 tolerances above does not. No box (a free-form segment, a value that is not
// finite) hides nothing.
let level = |y: f64| seg_box(&Seg2::Line { a: Vec2::new(-1.0, y), b: Vec2::new(1.0, y) }, 1e-3);
assert!(boxes_meet(seg_box(&arc, 1e-3), level(10.003)) && !boxes_meet(seg_box(&arc, 1e-3), level(10.005)));
let free = ellipse(Vec2::ZERO, 2.0, 1.0).segs[0];
assert!(seg_box(&free, 1e-3).is_none() && seg_box(&Seg2::Line { a: Vec2::new(f64::NAN, 0.0), b: Vec2::X }, 1e-3).is_none());
assert!(boxes_meet(None, level(500.0)) && box_holds(None, Vec2::new(500.0, 500.0)));
}
#[test]
fn many_small_cut_outs_far_apart_cut_as_before() {
// 400 round holes in a strip: almost every pair of segments is far apart.
let plate = rectangle(Vec2::ZERO, Vec2::new(2000.0, 20.0));
let holes: Vec<Region2> =
(0..400).map(|i| Region2 { outer: Loop2::circle(Vec2::new(2.5 + 5.0 * i as f64, 10.0), 1.8), holes: vec![] }).collect();
let out = planar_regions_boolean(&[plate], &holes, BoolOp::Cut, 1e-7).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].holes.len(), 400);
let exact = 40_000.0 - 400.0 * std::f64::consts::PI * 1.8 * 1.8;
assert!((out[0].area() - exact).abs() < 1e-6, "{}", out[0].area());
}
#[test]
fn analytic_circle_classification_respects_signed_sweeps_and_seams() {
for seam in [0.0, 0.13, std::f64::consts::FRAC_PI_2, -std::f64::consts::FRAC_PI_2] {
+5 -2
View File
@@ -125,10 +125,13 @@ adaptively. The `OUTLINE`, `BEND_UP` and `BEND_DOWN` layers separate cuts from b
Base-flange profiles keep their interior holes. Through cuts that cross a sheet edge become
notches in the flat outline; overlapping and repeated cuts remove their combined area once.
The flat preview, DXF and unfolded solid use the same material boundaries. Lines and circular
arcs remain exact; other profile curves use a 0.001 mm chord tolerance.
arcs remain exact. An ellipse, a spline or text stays the curve it was drawn as in the flat
pattern and the unfolded solid; the folded solid follows it with tangent arcs within 0.001 mm,
and the DXF and the point lists write it as chords within 0.001 mm.
Flat-pattern JSON lists each cut-out twice, in the same order: as points (`cutouts`), and
exactly (`cutout_shapes`): a round hole as `{"circle": {"center", "radius"}}`, any other as its
`line`, `arc` (centre, radius, start and sweep in degrees) and `polyline` segments.
`line`, `arc` (centre, radius, start and sweep in degrees) and `polyline` segments (one
`polyline` for each ellipse quarter, spline or glyph curve).
Cuts can leave separate material pieces, including the centre of a ring cut. These pieces
remain one logical sheet group through Unfold / Refold and export as separate flat outlines.