From 4ff8f3e14d9c7232dacd5848f2d933bed6829668 Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:18:15 +0200 Subject: [PATCH] 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). --- crates/doc/src/eval_more.rs | 13 +- crates/doc/src/sheet.rs | 215 ++++++++++++--- crates/engine/src/cmd/sheet_tests.rs | 69 +++++ crates/geom/src/arcfit.rs | 391 +++++++++++++++++++++++++++ crates/geom/src/lib.rs | 2 + crates/kernel/src/lib.rs | 2 +- crates/kernel/src/prism.rs | 265 ++++++++++++++++-- docs/book/src/guide/sheet-metal.md | 7 +- 8 files changed, 894 insertions(+), 70 deletions(-) create mode 100644 crates/geom/src/arcfit.rs diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index afeecab6..ee82cbef 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -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 = 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 = (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() diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index dfb118a5..769bd23f 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -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]) -> Result { 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 { + 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 { + Ok(Region2 { outer: fitted_loop(®ion.outer)?, holes: region.holes.iter().map(fitted_loop).collect::>()? }) +} + +/// 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 { + let chorded = |lp: &Loop2| -> Result { if lp.is_line_arc() { Ok(lp.clone()) } else { Ok(Loop2::polygon(&flat_polyline(lp)?)) } }; + Ok(Region2 { + outer: chorded(®ion.outer)?.ccw(), + holes: region.holes.iter().map(|h| chorded(h).map(|l| l.ccw().reversed())).collect::>()?, + }) +} + +/// 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 { if region.holes.len() > MAX_FLAT_PIECES { return Err(flat_budget_error()); } region_complexity(std::slice::from_ref(region))?; - let convert = |lp: &Loop2| -> Result { - 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(®ion.outer)?.ccw(), - holes: region.holes.iter().map(|h| convert(h).map(|l| l.ccw().reversed())).collect::>()?, - }) + // 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) -> Result< return Err(flat_budget_error()); } for lp in std::iter::once(®ion.outer).chain(®ion.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(®ion))?.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(®ion)?; + let panel = fitted_region(&planar_region(®ion)?)?; 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(®ion))?.1); @@ -1050,7 +1096,12 @@ impl SheetBody { /// The flat pattern as one extrusion (outline with its cut-outs). pub fn flat_solid(&self) -> Result { - 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::>()?; + } 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::>>()?; + 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::>>()?; 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> { 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 { - 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::>())) - } 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::>()?, - }) + 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 = ®ions[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); diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index 30783b34..7793d371 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -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 = ®ions[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> = 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 = ®ions[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(); diff --git a/crates/geom/src/arcfit.rs b/crates/geom/src/arcfit.rs new file mode 100644 index 00000000..332cee33 --- /dev/null +++ b/crates/geom/src/arcfit.rs @@ -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> { + 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 = 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 { + 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 { + 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 = (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 { + 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> { + 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 { + 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()); + } +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 8ca68e3c..f15ce9ec 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -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}; diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index d8844de0..30b7e198 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -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}; diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs index 7d428aa4..84dbab50 100644 --- a/crates/kernel/src/prism.rs +++ b/crates/kernel/src/prism.rs @@ -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> { 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> { + if regions.iter().all(Region2::is_line_arc) { + return Ok(Cow::Borrowed(regions)); + } + let mut fit = |lp: &Loop2| -> Result { + 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> = + regions.iter().map(|r| Ok(Region2 { outer: fit(&r.outer)?, holes: r.holes.iter().map(&mut fit).collect::>()? })).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, exact: &[(Loop2, Loop2)], tol: f64) -> Vec { + 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 { @@ -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 { - 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 { + 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, b: Option) -> 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, 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], 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], p: Vec2, tol: f64) -> Option { + 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, 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![Vec::new(); sa.len()]; let mut cut_b: Vec> = 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 Option Option 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 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 = (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 = + (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] { diff --git a/docs/book/src/guide/sheet-metal.md b/docs/book/src/guide/sheet-metal.md index 904ab104..b4adbf75 100644 --- a/docs/book/src/guide/sheet-metal.md +++ b/docs/book/src/guide/sheet-metal.md @@ -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.