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

+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};