feat: fold sheet metal cut-outs across bends at any angle or curved (#425)
A cut-out made in the flat pattern could cross a bend only with edges
running along the bend or square to it: a round hole or a slanted slot
over a bend line failed to fold ("not supported yet: curved cut-outs
across a bend", "... cut-out edges running across a bend at an angle").
Sheet: a flat edge in a bend zone now folds whichever way it runs: along
the bend to a line, across it to an arc, and any other way to a curve on
the bend's cylinder (a slanted line to a helix). The curve is carried as
a chain of cubic Hermite pieces fitted within 1e-7 mm on both faces of
the sheet, with breaks that depend on the flat edge only, so the top
face, the bottom face and the wall share them. The wall between the two
chains is a ruled surface with radial rulings: the cut-out's wall stays
square to the sheet. The bend's own faces stay exact cylinders, now sized
to the whole boundary of their cell (a curved edge may bulge past its
corners).
Kernel: such an edge has no line, arc or conic form, so the sewing
interface gets EdgeSpec::Chain (a chain of cubic Bézier pieces: a cubic
B-spline with triple inner knots, its ends on the shared vertices) and
SurfSpec::Ruled (two chains joined by straight lines, so a wall built
from the chains holds them exactly). The sheet module's rev_edge becomes
EdgeSpec::reversed, so the new variant is handled where edges are
reversed.
The test for "along" and "across" is in millimetres (1e-6 mm at the
edge's ends) instead of relative 1e-9, so coordinates rounded by a script
fold as drawn.
Tests. Kernel: a box with one wall bulging as a cubic (chains of two
pieces at the bottom and the top, the wall ruled between them) sews to a
valid solid of the analytic volume, 27.6 (measured 27.59965); chains with
the wrong number of control points, a NaN or too many pieces, and a ruled
wall between chains of different piece counts, are refused. Sheet: a slot
turned 30° and a round hole across 72°, 90° and 108° bends fold to valid
solids that lose the cut-out's flat area times the thickness (K = 0.5:
400 and 100.53 mm³, measured within 0.02); a slanted flat edge folds to
its exact helix within 1e-7 mm (measured 1.9e-8) on both faces; lines
along and across the bend keep their line and arc. Engine:
fold_through_cut_outs now expects a round hole across a fold to fold, and
to unfold to the same round hole; the per-bend machining guard (#296)
sews a bend that carries a round cut-out, so a hole beside it cuts and a
hole in the bend is still refused.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: bherbruck <[email protected]>
This commit is contained in:
4 files changed
+430
-49
No files matched your search
+3
-1
@@ -309,7 +309,9 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
- Sheet-metal material: inherited holes, crossing edge notches, overlapping cuts and retained
|
||||
islands share the same boundaries in flat preview, DXF and unfolded solids. Detached material
|
||||
pieces survive unfold/refold as one logical sheet group, including split flanged sheets.
|
||||
Circular cuts crossing bends remain unsupported; free-form bases use bounded sampling.
|
||||
Cut-outs made in the flat pattern cross bends at any angle or curved: their edges fold to
|
||||
helices and curves on the bend, their walls square to the sheet. Free-form bases use bounded
|
||||
sampling.
|
||||
Parallel prismatic partial-depth cuts retain pocket side and depth through transfer and
|
||||
unfold/refold, with separate pocket contours in preview and DXF. Planar Intersect preserves
|
||||
exact holes and retained islands, including the union of multiple selected profiles, and
|
||||
|
||||
+216
-18
@@ -351,10 +351,17 @@ fn is_zero_pair(v: &(f64, f64)) -> bool {
|
||||
}
|
||||
|
||||
fn rev_edge(e: EdgeSpec) -> EdgeSpec {
|
||||
match e {
|
||||
EdgeSpec::Line { a, b } => EdgeSpec::Line { a: b, b: a },
|
||||
EdgeSpec::Arc { a, b, mid } => EdgeSpec::Arc { a: b, b: a, mid },
|
||||
}
|
||||
e.reversed()
|
||||
}
|
||||
|
||||
/// Largest distance between a folded bend edge and the cubic chain standing in for it.
|
||||
const BEND_CHAIN_TOL: f64 = 1e-7;
|
||||
/// Most halvings of a bend edge's parameter while fitting its chain.
|
||||
const BEND_CHAIN_DEPTH: usize = 10;
|
||||
|
||||
fn cubic_at(p0: Vec3, c1: Vec3, c2: Vec3, p1: Vec3, t: f64) -> Vec3 {
|
||||
let u = 1.0 - t;
|
||||
p0 * (u * u * u) + c1 * (3.0 * u * u * t) + c2 * (3.0 * u * t * t) + p1 * (t * t * t)
|
||||
}
|
||||
|
||||
fn dedup(p: &[Vec2]) -> Vec<Vec2> {
|
||||
@@ -370,6 +377,32 @@ fn dedup(p: &[Vec2]) -> Vec<Vec2> {
|
||||
v
|
||||
}
|
||||
|
||||
/// How a flat edge of a bend zone folds: along the bend to a line, across it to an arc, or
|
||||
/// any other way (slanted lines and curves) to a curve on the bend's cylinder.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum ZoneEdge {
|
||||
Along,
|
||||
Across,
|
||||
Curved,
|
||||
}
|
||||
|
||||
/// How far (mm) a flat edge's ends may stray from a line along or across its bend and still
|
||||
/// fold as that line or arc: scripts round coordinates, so the test is in millimetres, not
|
||||
/// relative to the edge's length.
|
||||
const ZONE_SQUARE_TOL: f64 = 1e-6;
|
||||
|
||||
fn zone_edge_kind(f: &SheetFlange, e: &Seg2) -> ZoneEdge {
|
||||
let Seg2::Line { a, b } = *e else { return ZoneEdge::Curved };
|
||||
let dlt = b - a;
|
||||
if dlt.dot(f.n).abs() <= ZONE_SQUARE_TOL {
|
||||
ZoneEdge::Along
|
||||
} else if dlt.dot(f.d).abs() <= ZONE_SQUARE_TOL {
|
||||
ZoneEdge::Across
|
||||
} else {
|
||||
ZoneEdge::Curved
|
||||
}
|
||||
}
|
||||
|
||||
/// A bend for the bend table.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct BendInfo {
|
||||
@@ -583,6 +616,75 @@ impl SheetBody {
|
||||
}
|
||||
}
|
||||
|
||||
/// A flat point of flange `f`'s bend zone folded onto the bend at height `z` (not clamped
|
||||
/// to the zone), with its rates of change along the zone's flat `n` and `d` directions.
|
||||
fn zone_point(&self, f: &SheetFlange, q: Vec2, z: f64) -> (Vec3, Vec3, Vec3) {
|
||||
let s = (q - f.p).dot(f.n);
|
||||
let u = (q - f.p).dot(f.d);
|
||||
let neutral = (f.radius + self.k * self.t).max(1e-12);
|
||||
let phi = s / neutral;
|
||||
let (axis_o, k, turn) = self.bend_axis(f);
|
||||
let sigma = if turn < 0.0 { -1.0 } else { 1.0 };
|
||||
// From the axis to the bend's start line at height z, and that turned a right angle.
|
||||
let ez = Vec3::Z * (z - axis_o.z);
|
||||
let ek = k.cross(ez) * sigma;
|
||||
let (sn, cs) = phi.sin_cos();
|
||||
let w = self.world(f.parent);
|
||||
let local = axis_o + v3(f.d) * u + ez * cs + ek * sn;
|
||||
let ds = (ez * -sn + ek * cs) * (1.0 / neutral);
|
||||
(apply_point(&w, local), apply_vector(&w, ds), apply_vector(&w, v3(f.d)))
|
||||
}
|
||||
|
||||
/// A flat edge of flange `f`'s bend zone, folded at height `z`, at parameter `t`: the point
|
||||
/// and its derivative.
|
||||
fn zone_edge_at(&self, f: &SheetFlange, e: &Seg2, z: f64, t: f64) -> (Vec3, Vec3) {
|
||||
let (p, ds, du) = self.zone_point(f, e.point_at(t), z);
|
||||
let dq = e.derivative(t);
|
||||
(p, ds * dq.dot(f.n) + du * dq.dot(f.d))
|
||||
}
|
||||
|
||||
/// Where to break a bend zone's flat edge so cubic Hermite pieces follow the folded edge
|
||||
/// to within [`BEND_CHAIN_TOL`] on both faces. The breaks depend on the flat edge only, so
|
||||
/// the top face, bottom face and wall of a cut-out share one chain per height.
|
||||
fn zone_breaks(&self, f: &SheetFlange, e: &Seg2) -> Vec<f64> {
|
||||
let mut out = vec![0.0];
|
||||
let mut todo = vec![(0.0, 1.0, 0usize)];
|
||||
while let Some((t0, t1, depth)) = todo.pop() {
|
||||
let fits = depth >= BEND_CHAIN_DEPTH
|
||||
|| [0.0, self.t].iter().all(|&z| {
|
||||
let h = t1 - t0;
|
||||
let ((p0, d0), (p1, d1)) = (self.zone_edge_at(f, e, z, t0), self.zone_edge_at(f, e, z, t1));
|
||||
let (c1, c2) = (p0 + d0 * (h / 3.0), p1 - d1 * (h / 3.0));
|
||||
[0.25, 0.5, 0.75].iter().all(|&x| cubic_at(p0, c1, c2, p1, x).dist(self.zone_edge_at(f, e, z, t0 + h * x).0) <= BEND_CHAIN_TOL)
|
||||
});
|
||||
if fits {
|
||||
out.push(t1);
|
||||
} else {
|
||||
let m = (t0 + t1) / 2.0;
|
||||
// Depth first, right half last on the stack: breaks come out in order.
|
||||
todo.push((m, t1, depth + 1));
|
||||
todo.push((t0, m, depth + 1));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A bend zone's flat edge folded at height `z`: a chain of cubic Hermite pieces.
|
||||
fn zone_chain(&self, f: &SheetFlange, e: &Seg2, z: f64) -> EdgeSpec {
|
||||
let breaks = self.zone_breaks(f, e);
|
||||
let mut ctrl = Vec::with_capacity(breaks.len() * 3);
|
||||
for w in breaks.windows(2) {
|
||||
let (t0, t1) = (w[0], w[1]);
|
||||
let h = t1 - t0;
|
||||
let ((p0, d0), (p1, d1)) = (self.zone_edge_at(f, e, z, t0), self.zone_edge_at(f, e, z, t1));
|
||||
if ctrl.is_empty() {
|
||||
ctrl.push(p0);
|
||||
}
|
||||
ctrl.extend([p0 + d0 * (h / 3.0), p1 - d1 * (h / 3.0), p1]);
|
||||
}
|
||||
EdgeSpec::Chain { ctrl: ctrl.into() }
|
||||
}
|
||||
|
||||
/// A flat edge of a piece as a 3D edge at height `z`.
|
||||
fn edge3(&self, piece: Piece, e: &Seg2, z: f64) -> Result<EdgeSpec> {
|
||||
let (a, b) = (e.start(), e.end());
|
||||
@@ -590,18 +692,16 @@ impl SheetBody {
|
||||
match (piece, e) {
|
||||
(Piece::Panel(_), Seg2::Line { .. }) => Ok(EdgeSpec::Line { a: pa, b: pb }),
|
||||
(Piece::Panel(_), Seg2::Arc { .. }) => Ok(EdgeSpec::Arc { a: pa, b: pb, mid: self.map3(piece, e.point_at(0.5), z) }),
|
||||
(Piece::Zone(i), Seg2::Line { .. }) => {
|
||||
(Piece::Zone(i), _) => {
|
||||
let f = self.flanges.get(i).ok_or_else(|| DocError::Invalid("bend".into()))?;
|
||||
let dlt = b - a;
|
||||
if dlt.dot(f.n).abs() < 1e-9 * dlt.len().max(1.0) {
|
||||
Ok(EdgeSpec::Line { a: pa, b: pb })
|
||||
} else if dlt.dot(f.d).abs() < 1e-9 * dlt.len().max(1.0) {
|
||||
Ok(EdgeSpec::Arc { a: pa, b: pb, mid: self.map3(piece, (a + b) * 0.5, z) })
|
||||
} else {
|
||||
Err(DocError::Invalid("not supported yet: cut-out edges running across a bend at an angle".into()))
|
||||
match zone_edge_kind(f, e) {
|
||||
ZoneEdge::Along => Ok(EdgeSpec::Line { a: pa, b: pb }),
|
||||
ZoneEdge::Across => Ok(EdgeSpec::Arc { a: pa, b: pb, mid: self.map3(piece, (a + b) * 0.5, z) }),
|
||||
// Slanted lines fold to helices, curves to curves on the bend's cylinder:
|
||||
// neither has a line, arc or conic form.
|
||||
ZoneEdge::Curved => Ok(self.zone_chain(f, e, z)),
|
||||
}
|
||||
}
|
||||
(Piece::Zone(_), _) => Err(DocError::Invalid("not supported yet: curved cut-outs across a bend".into())),
|
||||
(_, _) => Err(DocError::Invalid("not supported yet: spline or conic cut-outs in sheet metal".into())),
|
||||
}
|
||||
}
|
||||
@@ -749,10 +849,21 @@ impl SheetBody {
|
||||
Piece::Panel(_) => SurfSpec::Plane,
|
||||
Piece::Zone(i) => {
|
||||
let f = self.flanges.get(i).ok_or_else(|| DocError::Invalid("bend".into()))?;
|
||||
let ss: Vec<f64> = c.outer.iter().map(|e| (e.start() - f.p).dot(f.n)).collect();
|
||||
let (s0, s1) = (ss.iter().cloned().fold(f64::MAX, f64::min), ss.iter().cloned().fold(f64::MIN, f64::max));
|
||||
let us: Vec<f64> = c.outer.iter().map(|e| (e.start() - f.p).dot(f.d)).collect();
|
||||
let (u0, u1) = (us.iter().cloned().fold(f64::MAX, f64::min), us.iter().cloned().fold(f64::MIN, f64::max));
|
||||
// The surface must hold the whole boundary: curved cut-out edges may
|
||||
// bulge past the cell's corners, so they are sampled (and the surface
|
||||
// padded a little, never past the zone's own width).
|
||||
let curved = c.outer.iter().any(|e| zone_edge_kind(f, e) == ZoneEdge::Curved);
|
||||
let pts: Vec<Vec2> =
|
||||
if curved { c.outer.iter().flat_map(|e| e.polyline(1e-4)).collect() } else { c.outer.iter().map(Seg2::start).collect() };
|
||||
let ss: Vec<f64> = pts.iter().map(|q| (*q - f.p).dot(f.n)).collect();
|
||||
let (mut s0, mut s1) = (ss.iter().cloned().fold(f64::MAX, f64::min), ss.iter().cloned().fold(f64::MIN, f64::max));
|
||||
let us: Vec<f64> = pts.iter().map(|q| (*q - f.p).dot(f.d)).collect();
|
||||
let (mut u0, mut u1) = (us.iter().cloned().fold(f64::MAX, f64::min), us.iter().cloned().fold(f64::MIN, f64::max));
|
||||
if curved {
|
||||
let pad = 1e-3;
|
||||
(s0, s1) = ((s0 - pad).max(0.0), (s1 + pad).min(self.allowance(f)));
|
||||
(u0, u1) = (u0 - pad, u1 + pad);
|
||||
}
|
||||
let neutral = (f.radius + self.k * self.t).max(1e-12);
|
||||
let (axis_o, k, turn) = self.bend_axis(f);
|
||||
let wp = self.world(f.parent);
|
||||
@@ -777,7 +888,7 @@ impl SheetBody {
|
||||
}
|
||||
let (a, b) = (e.start(), e.end());
|
||||
let bottom = self.edge3(c.piece, e, z0)?;
|
||||
let top = rev_edge(self.edge3(c.piece, e, z1)?);
|
||||
let top_ab = self.edge3(c.piece, e, z1)?;
|
||||
let up_b = EdgeSpec::Line { a: self.map3(c.piece, b, z0), b: self.map3(c.piece, b, z1) };
|
||||
let down_a = EdgeSpec::Line { a: self.map3(c.piece, a, z1), b: self.map3(c.piece, a, z0) };
|
||||
let surface = match (c.piece, e) {
|
||||
@@ -785,8 +896,12 @@ impl SheetBody {
|
||||
let (pa, pb, pm) = (self.map3(c.piece, a, z0), self.map3(c.piece, b, z0), self.map3(c.piece, e.point_at(0.5), z0));
|
||||
SurfSpec::Extruded { a: pa, b: pb, mid: pm, dir: self.map3(c.piece, a, z1) - pa }
|
||||
}
|
||||
// A cut-out wall across a bend at an angle: square to the sheet, so its
|
||||
// straight lines run radially from the bottom face's edge to the top's.
|
||||
(Piece::Zone(_), _) if matches!(bottom, EdgeSpec::Chain { .. }) => SurfSpec::Ruled { a: bottom.clone(), b: top_ab.clone() },
|
||||
_ => SurfSpec::Plane,
|
||||
};
|
||||
let top = rev_edge(top_ab);
|
||||
let mid = e.point_at(0.5);
|
||||
let tdir = e.point_at(0.51) - e.point_at(0.49);
|
||||
let right = Vec2::new(tdir.y, -tdir.x);
|
||||
@@ -2485,6 +2600,89 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A 100 × 80 plate with a 20 mm flange on its y = 0 edge: 2 mm sheet, inner radius 1,
|
||||
/// K 0.5. The neutral radius is the mid radius, so the folded sheet holds exactly its flat
|
||||
/// area times the thickness.
|
||||
fn flanged(angle: f64) -> SheetBody {
|
||||
let rule = RuleValues {
|
||||
thickness: 2.0,
|
||||
k_factor: 0.5,
|
||||
bend_radius: 1.0,
|
||||
relief_width: 2.0,
|
||||
relief_depth: 1.0,
|
||||
corner_relief: 8.0,
|
||||
hem_gap: 0.01,
|
||||
gap: 2.0,
|
||||
};
|
||||
let r = rect(Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(100.0, 80.0), Vec2::new(0.0, 80.0));
|
||||
let mut s = base_flange("Body1", "AL2", &rule, Plane::XY, r);
|
||||
s.add_flanges(&[Vec3::new(50.0, 0.0, 2.0)], 20.0, angle, 1.0, "inside", false).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slanted_and_round_cut_outs_across_a_bend_fold() {
|
||||
let area = |s: &SheetBody| s.flat_regions().unwrap().iter().map(Region2::area).sum::<f64>();
|
||||
for angle in [90f64, 72.0, 108.0] {
|
||||
let plain = flanged(angle.to_radians());
|
||||
let (a0, v0) = (area(&plain), vol(&plain.solid().unwrap()));
|
||||
// A 10 × 20 slot turned 30° about (50, 0) and a Ø8 circle at (30, 0.5): both reach
|
||||
// from the base over the whole bend zone into the flange.
|
||||
let (c, a) = (Vec2::new(50.0, 0.0), 30f64.to_radians());
|
||||
let (ux, uy) = (Vec2::from_angle(a) * 5.0, Vec2::from_angle(a + std::f64::consts::FRAC_PI_2) * 10.0);
|
||||
let slot = Region2 { outer: Loop2::polygon(&[c - ux - uy, c + ux - uy, c + ux + uy, c - ux + uy]).ccw(), holes: vec![] };
|
||||
let circle = Region2 { outer: Loop2::circle(Vec2::new(30.0, 0.5), 4.0), holes: vec![] };
|
||||
for (cut, removed) in [(slot, 200.0), (circle, std::f64::consts::PI * 16.0)] {
|
||||
let mut s = plain.clone();
|
||||
s.holes.push(cut);
|
||||
assert!((a0 - area(&s) - removed).abs() < 1e-6, "{angle}°: flat area {}", a0 - area(&s));
|
||||
let body = s.solid().unwrap();
|
||||
assert!(body.validity().is_empty(), "{angle}°: {:?}", body.validity());
|
||||
// The folded sheet loses the cut-out's flat area times the thickness.
|
||||
assert!((v0 - vol(&body) - removed * 2.0).abs() < 0.05, "{angle}°: {} vs {}", v0 - vol(&body), removed * 2.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slanted_flat_edge_folds_to_its_exact_helix() {
|
||||
// A 72° flange, inside: the bend's axis runs along x through y = setback, z = T + R,
|
||||
// and a point at height z in the sheet stays T + R − z away from it.
|
||||
let angle = 72f64.to_radians();
|
||||
let s = flanged(angle);
|
||||
let f = s.flanges[0].clone();
|
||||
let setback = 3.0 * (angle / 2.0).tan();
|
||||
// The unclamped zone map agrees with the fold inside the zone.
|
||||
for (sv, u, z) in [(0.3, 10.0, 0.0), (1.2, 40.0, 1.0), (2.4, 90.0, 2.0)] {
|
||||
let q = f.p + f.n * sv + f.d * u;
|
||||
assert!(s.zone_point(&f, q, z).0.dist(s.map3(Piece::Zone(0), q, z)) < 1e-12);
|
||||
}
|
||||
// A flat line across the whole zone, 15 mm along the bend: the bend angle and the
|
||||
// distance along the axis both grow evenly along it.
|
||||
let e = Seg2::Line { a: f.p + f.d * 20.0, b: f.p + f.n * s.allowance(&f) + f.d * 35.0 };
|
||||
let breaks = s.zone_breaks(&f, &e);
|
||||
for z in [0.0, 2.0] {
|
||||
let helix = |t: f64| {
|
||||
let (phi, rho) = (angle * t, 3.0 - z);
|
||||
Vec3::new(e.point_at(t).x, setback - rho * phi.sin(), 3.0 - rho * phi.cos())
|
||||
};
|
||||
let EdgeSpec::Chain { ctrl } = s.zone_chain(&f, &e, z) else { panic!("a slanted edge folds to a chain") };
|
||||
assert_eq!(ctrl.len(), 3 * (breaks.len() - 1) + 1);
|
||||
for (k, w) in breaks.windows(2).enumerate() {
|
||||
for x in [0.0, 0.1, 0.37, 0.5, 0.81, 1.0] {
|
||||
let p = cubic_at(ctrl[3 * k], ctrl[3 * k + 1], ctrl[3 * k + 2], ctrl[3 * k + 3], x);
|
||||
let exact = helix(w[0] + (w[1] - w[0]) * x);
|
||||
assert!(p.dist(exact) < BEND_CHAIN_TOL, "height {z}, piece {k}: {}", p.dist(exact));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Lines along the bend and square to it keep their exact line and arc.
|
||||
let along = Seg2::Line { a: f.p + f.n * 1.0 + f.d * 20.0, b: f.p + f.n * 1.0 + f.d * 35.0 };
|
||||
let across = Seg2::Line { a: f.p + f.d * 20.0, b: f.p + f.n * s.allowance(&f) + f.d * 20.0 };
|
||||
assert!(matches!(s.edge3(Piece::Zone(0), &along, 2.0), Ok(EdgeSpec::Line { .. })));
|
||||
assert!(matches!(s.edge3(Piece::Zone(0), &across, 2.0), Ok(EdgeSpec::Arc { .. })));
|
||||
}
|
||||
|
||||
/// 2 mm aluminium, R 1, K 0.5 (folding keeps the volume), flanges 0.5 mm apart at corners.
|
||||
fn alu() -> RuleValues {
|
||||
RuleValues {
|
||||
|
||||
@@ -644,7 +644,8 @@ fn fold_through_cut_outs() {
|
||||
assert!(v < vp - 900.0 && v > vp - 1100.0, "{v} {vp}");
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((volume(&mut s) - (15000.0 - 1000.0)).abs() < 1e-3, "{}", volume(&mut s));
|
||||
// A round hole across the bend is not supported yet, and says so.
|
||||
// A round hole across the bend folds too: its edge runs over the bend as a curve on the
|
||||
// bend's cylinder, and unfolds to the same round hole.
|
||||
let mut c = Session::default();
|
||||
base(&mut c, 100.0, 60.0);
|
||||
run(&mut c, "sketch.create", json!({"plane": "XY", "name": "Hole"}));
|
||||
@@ -652,8 +653,14 @@ fn fold_through_cut_outs() {
|
||||
run(&mut c, "sketch.finish", json!({}));
|
||||
run(&mut c, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "operation": "cut", "direction": "symmetric"}));
|
||||
fold_line(&mut c);
|
||||
let e = c.execute("sheet.fold", &json!({"sketch": "FoldLine", "curve": "fl"})).map(|_| ()).unwrap_err().to_string();
|
||||
assert!(e.contains("not supported yet"), "{e}");
|
||||
run(&mut c, "sheet.fold", json!({"sketch": "FoldLine", "curve": "fl"}));
|
||||
let f = flat(&mut c);
|
||||
assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1), "{f}");
|
||||
let folded = volume(&mut c);
|
||||
assert!(folded < 15000.0 - 150.0, "{folded}");
|
||||
run(&mut c, "sheet.unfold", json!({}));
|
||||
let want = 15000.0 - std::f64::consts::PI * 25.0 * 2.5;
|
||||
assert!((volume(&mut c) - want).abs() < 0.05, "{} vs {want}", volume(&mut c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1718,6 +1725,41 @@ fn hole_beside_two_adjacent_edge_flanges_cuts_and_bends_stay_guarded() {
|
||||
assert!(error.contains("bend"), "{error}");
|
||||
}
|
||||
|
||||
/// The guard of #296 sews every bend on its own. A bend that carries a folded round cut-out
|
||||
/// (curved edges on both of its cylinders, a ruled wall between them) sews like any other: a hole
|
||||
/// beside it cuts, and a hole in the bend is still refused.
|
||||
#[test]
|
||||
fn hole_beside_a_bend_with_a_round_cut_out_cuts_and_the_bend_stays_guarded() {
|
||||
let build = |s: &mut Session| {
|
||||
base(s, 80.0, 40.0);
|
||||
run(s, "sheet.flange", json!({"edges":[[40.0, 0.0, 2.5]],"height":30,"angle":90}));
|
||||
run(s, "sheet.unfold", json!({}));
|
||||
run(s, "sketch.create", json!({"plane":"XY","name":"Across"}));
|
||||
run(s, "sketch.circle.center", json!({"center":[60.0, 0.0],"radius":4}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"sketch":"Across","through_all":true,"operation":"cut","direction":"symmetric"}));
|
||||
run(s, "sheet.refold", json!({}));
|
||||
};
|
||||
let hole = |s: &mut Session, name: &str, centre: [f64; 2]| {
|
||||
run(s, "sketch.create", json!({"plane":"XY","name":name}));
|
||||
run(s, "sketch.circle.center", json!({"center":centre,"radius":3}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
s.execute("solid.extrude", &json!({"sketch":name,"through_all":true,"operation":"cut"}))
|
||||
};
|
||||
let mut s = Session::default();
|
||||
build(&mut s);
|
||||
let before = volume(&mut s);
|
||||
if let Err(e) = hole(&mut s, "Hole", [25.0, 20.0]) {
|
||||
panic!("hole beside a bend with a round cut-out: {e}");
|
||||
}
|
||||
let after = volume(&mut s);
|
||||
assert!((before - after - 9.0 * PI * 2.5).abs() < 0.05, "{before} -> {after}");
|
||||
let mut bend = Session::default();
|
||||
build(&mut bend);
|
||||
let error = hole(&mut bend, "Bend", [25.0, 1.5]).unwrap_err().to_string();
|
||||
assert!(error.contains("bend"), "{error}");
|
||||
}
|
||||
|
||||
/// A fold whose stored `fixed` point ends up off the sheet after a parameter change keeps working:
|
||||
/// the point still says which side stays (#370). Only a new pick off the sheet is refused (#263).
|
||||
#[test]
|
||||
|
||||
+166
-27
@@ -1,10 +1,12 @@
|
||||
//! Sewing a solid from face descriptions: each face is its boundary loops (lines and circular
|
||||
//! arcs, counter-clockwise about the outward normal; holes clockwise) and its surface (planar,
|
||||
//! revolved from a line, or extruded from an arc). Vertices and edges at the same places are
|
||||
//! shared, so the faces close into one shell without booleans — for solids whose faces are
|
||||
//! known exactly, such as folded sheet metal.
|
||||
//! Sewing a solid from face descriptions: each face is its boundary loops (lines, circular
|
||||
//! arcs and cubic Bézier chains, counter-clockwise about the outward normal; holes clockwise)
|
||||
//! and its surface (planar, revolved from a line, extruded from an arc, or ruled between two
|
||||
//! Bézier chains). Vertices and edges at the same places are shared, so the faces close into
|
||||
//! one shell without booleans — for solids whose faces are known exactly, such as folded
|
||||
//! sheet metal.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use mt::builder;
|
||||
use solidcraft_geom::Vec3;
|
||||
@@ -13,8 +15,11 @@ use truck_modeling as mt;
|
||||
use crate::body::{Body, Solid, p3, v3};
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
/// Most control points of one Bézier chain edge.
|
||||
pub const MAX_CHAIN_POINTS: usize = 3 * 1024 + 1;
|
||||
|
||||
/// A boundary edge.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum EdgeSpec {
|
||||
Line {
|
||||
a: Vec3,
|
||||
@@ -26,18 +31,56 @@ pub enum EdgeSpec {
|
||||
b: Vec3,
|
||||
mid: Vec3,
|
||||
},
|
||||
/// A chain of cubic Bézier pieces: 3n + 1 control points (n ≥ 1); the curve passes
|
||||
/// through `ctrl[0]`, `ctrl[3]`, … `ctrl[3n]`. For curves with no exact line, arc or conic
|
||||
/// form, such as a cut-out edge traced across a sheet metal bend.
|
||||
Chain {
|
||||
ctrl: Arc<[Vec3]>,
|
||||
},
|
||||
}
|
||||
|
||||
impl EdgeSpec {
|
||||
fn ends(&self) -> (Vec3, Vec3) {
|
||||
match *self {
|
||||
EdgeSpec::Line { a, b } | EdgeSpec::Arc { a, b, .. } => (a, b),
|
||||
match self {
|
||||
EdgeSpec::Line { a, b } | EdgeSpec::Arc { a, b, .. } => (*a, *b),
|
||||
EdgeSpec::Chain { ctrl } => (ctrl.first().copied().unwrap_or(Vec3::ZERO), ctrl.last().copied().unwrap_or(Vec3::ZERO)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The same edge, run the other way.
|
||||
pub fn reversed(&self) -> EdgeSpec {
|
||||
match self {
|
||||
EdgeSpec::Line { a, b } => EdgeSpec::Line { a: *b, b: *a },
|
||||
EdgeSpec::Arc { a, b, mid } => EdgeSpec::Arc { a: *b, b: *a, mid: *mid },
|
||||
EdgeSpec::Chain { ctrl } => EdgeSpec::Chain { ctrl: ctrl.iter().rev().copied().collect() },
|
||||
}
|
||||
}
|
||||
|
||||
/// A point telling apart edges with the same ends, whichever way they run.
|
||||
fn mid_key(&self) -> Option<Key> {
|
||||
match self {
|
||||
EdgeSpec::Line { .. } => None,
|
||||
EdgeSpec::Arc { mid, .. } => Some(key(*mid)),
|
||||
EdgeSpec::Chain { ctrl } => {
|
||||
let n = ctrl.len().max(1) as f64;
|
||||
Some(key(ctrl.iter().fold(Vec3::ZERO, |s, p| s + *p) * (1.0 / n)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn valid(&self) -> bool {
|
||||
match self {
|
||||
EdgeSpec::Line { a, b } => a.is_finite() && b.is_finite(),
|
||||
EdgeSpec::Arc { a, b, mid } => a.is_finite() && b.is_finite() && mid.is_finite(),
|
||||
EdgeSpec::Chain { ctrl } => {
|
||||
ctrl.len() >= 4 && ctrl.len() <= MAX_CHAIN_POINTS && (ctrl.len() - 1) % 3 == 0 && ctrl.iter().all(|p| p.is_finite())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A face's surface.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SurfSpec {
|
||||
/// The plane of the boundary.
|
||||
Plane,
|
||||
@@ -45,6 +88,9 @@ pub enum SurfSpec {
|
||||
Revolved { a: Vec3, b: Vec3, axis_origin: Vec3, axis: Vec3, angle: f64 },
|
||||
/// The surface an arc sweeps moving along `dir`.
|
||||
Extruded { a: Vec3, b: Vec3, mid: Vec3, dir: Vec3 },
|
||||
/// The straight lines joining equal parameters of two Bézier chains with the same number
|
||||
/// of pieces, `a` and `b` running the same way; both chains lie on it as boundary edges.
|
||||
Ruled { a: EdgeSpec, b: EdgeSpec },
|
||||
}
|
||||
|
||||
/// A face: boundary loops, surface, and a point on it with its outward normal (curved faces).
|
||||
@@ -72,26 +118,50 @@ impl Cache {
|
||||
fn vertex(&mut self, p: Vec3) -> mt::Vertex {
|
||||
self.verts.entry(key(p)).or_insert_with(|| builder::vertex(p3(p))).clone()
|
||||
}
|
||||
fn edge(&mut self, e: &EdgeSpec, sheet: usize) -> mt::Edge {
|
||||
fn edge(&mut self, e: &EdgeSpec, sheet: usize) -> Result<mt::Edge> {
|
||||
let (a, b) = e.ends();
|
||||
let mid = match e {
|
||||
EdgeSpec::Arc { mid, .. } => Some(key(*mid)),
|
||||
EdgeSpec::Line { .. } => None,
|
||||
};
|
||||
let mid = e.mid_key();
|
||||
let (ka, kb) = (key(a), key(b));
|
||||
if let Some(found) = self.edges.get(&(ka, kb, mid, sheet)) {
|
||||
return found.clone();
|
||||
return Ok(found.clone());
|
||||
}
|
||||
if let Some(found) = self.edges.get(&(kb, ka, mid, sheet)) {
|
||||
return found.inverse();
|
||||
return Ok(found.inverse());
|
||||
}
|
||||
let (va, vb) = (self.vertex(a), self.vertex(b));
|
||||
let made = match *e {
|
||||
let made = match e {
|
||||
EdgeSpec::Line { .. } => builder::line(&va, &vb),
|
||||
EdgeSpec::Arc { mid, .. } => arc_edge(&va, &vb, mid),
|
||||
EdgeSpec::Arc { mid, .. } => arc_edge(&va, &vb, *mid),
|
||||
EdgeSpec::Chain { .. } => {
|
||||
let curve = self.chain(e)?;
|
||||
mt::Edge::try_new(&va, &vb, mt::Curve::BSplineCurve(curve)).map_err(|e| KernelError::Failed(format!("sew: chain edge: {e}")))?
|
||||
}
|
||||
};
|
||||
self.edges.insert((ka, kb, mid, sheet), made.clone());
|
||||
made
|
||||
Ok(made)
|
||||
}
|
||||
|
||||
/// A Bézier chain as a cubic B-spline (triple inner knots) ending on the shared vertices.
|
||||
fn chain(&mut self, e: &EdgeSpec) -> Result<mt::BSplineCurve<mt::Point3>> {
|
||||
let EdgeSpec::Chain { ctrl } = e else { return Err(KernelError::Invalid("sew: not a Bézier chain".into())) };
|
||||
if !e.valid() {
|
||||
return Err(KernelError::Invalid("sew: a Bézier chain needs 3n + 1 finite control points".into()));
|
||||
}
|
||||
let n = (ctrl.len() - 1) / 3;
|
||||
let mut knots = vec![0.0; 4];
|
||||
for i in 1..n {
|
||||
knots.extend([i as f64; 3]);
|
||||
}
|
||||
knots.extend([n as f64; 4]);
|
||||
let mut pts: Vec<mt::Point3> = ctrl.iter().map(|p| p3(*p)).collect();
|
||||
let (a, b) = e.ends();
|
||||
if let Some(first) = pts.first_mut() {
|
||||
*first = self.vertex(a).point();
|
||||
}
|
||||
if let Some(last) = pts.last_mut() {
|
||||
*last = self.vertex(b).point();
|
||||
}
|
||||
mt::BSplineCurve::try_new(mt::KnotVec::from(knots), pts).map_err(|e| KernelError::Failed(format!("sew: chain curve: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,7 +436,7 @@ fn sew_impl(faces: &[FaceSpec], touching: bool, sources: Option<&[Option<crate::
|
||||
for e in l {
|
||||
let (a, b) = e.ends();
|
||||
// Ends that weld to one vertex would make truck panic on a zero-length edge (#268).
|
||||
if !a.is_finite() || !b.is_finite() || a.dist(b) < 1e-9 || key(a) == key(b) {
|
||||
if !e.valid() || !a.is_finite() || !b.is_finite() || a.dist(b) < 1e-9 || key(a) == key(b) {
|
||||
return Err(KernelError::Invalid("sew: a degenerate edge".into()));
|
||||
}
|
||||
}
|
||||
@@ -382,23 +452,35 @@ fn sew_impl(faces: &[FaceSpec], touching: bool, sources: Option<&[Option<crate::
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(li, l)| {
|
||||
l.iter().enumerate().map(|(ei, e)| cache.edge(e, sheets.get(&(fi, li, ei)).copied().unwrap_or(0))).collect::<Vec<_>>().into()
|
||||
l.iter()
|
||||
.enumerate()
|
||||
.map(|(ei, e)| cache.edge(e, sheets.get(&(fi, li, ei)).copied().unwrap_or(0)))
|
||||
.collect::<Result<Vec<_>>>()
|
||||
.map(Into::into)
|
||||
})
|
||||
.collect();
|
||||
let face = match f.surface {
|
||||
.collect::<Result<_>>()?;
|
||||
let face = match &f.surface {
|
||||
SurfSpec::Plane => builder::try_attach_plane(&wires).map_err(|e| KernelError::Failed(format!("sew: face {fi}: {e}")))?,
|
||||
SurfSpec::Revolved { a, b, axis_origin, axis, angle } => {
|
||||
let k = axis.normalized().ok_or_else(|| KernelError::Invalid("sew: axis".into()))?;
|
||||
let (va, vb) = (builder::vertex(p3(a)), builder::vertex(p3(b)));
|
||||
let swept: mt::Shell = builder::rsweep(&builder::line(&va, &vb), p3(axis_origin), v3(k), mt::Rad(angle));
|
||||
let (va, vb) = (builder::vertex(p3(*a)), builder::vertex(p3(*b)));
|
||||
let swept: mt::Shell = builder::rsweep(&builder::line(&va, &vb), p3(*axis_origin), v3(k), mt::Rad(*angle));
|
||||
let tmp = swept.face_iter().next().cloned().ok_or_else(|| KernelError::Failed("sew: revolved surface".into()))?;
|
||||
curved(tmp.oriented_surface(), wires, f.probe, fi)?
|
||||
}
|
||||
SurfSpec::Extruded { a, b, mid, dir } => {
|
||||
let (va, vb) = (builder::vertex(p3(a)), builder::vertex(p3(b)));
|
||||
let tmp: mt::Face = builder::tsweep(&arc_edge(&va, &vb, mid), v3(dir));
|
||||
let (va, vb) = (builder::vertex(p3(*a)), builder::vertex(p3(*b)));
|
||||
let tmp: mt::Face = builder::tsweep(&arc_edge(&va, &vb, *mid), v3(*dir));
|
||||
curved(tmp.oriented_surface(), wires, f.probe, fi)?
|
||||
}
|
||||
SurfSpec::Ruled { a, b } => {
|
||||
let (ca, cb) = (cache.chain(a)?, cache.chain(b)?);
|
||||
if ca.control_points().len() != cb.control_points().len() {
|
||||
return Err(KernelError::Invalid("sew: ruled chains differ in pieces".into()));
|
||||
}
|
||||
let surface = mt::Surface::BSplineSurface(mt::BSplineSurface::homotopy(ca, cb));
|
||||
curved(surface, wires, f.probe, fi)?
|
||||
}
|
||||
};
|
||||
out.push(face);
|
||||
}
|
||||
@@ -595,4 +677,61 @@ mod tests {
|
||||
assert_eq!(body.face_count(), 6);
|
||||
assert!(sew(&faces[..5]).is_err());
|
||||
}
|
||||
|
||||
/// A 4 × 3 × 2 box whose +x wall bulges out as the cubic (4,0) (5,0) (5,3) (4,3), given as a
|
||||
/// chain of its two halves at the bottom (`z = 0`) and the top, the wall ruled between them.
|
||||
fn bulged_box(bottom: EdgeSpec, top: EdgeSpec) -> Vec<FaceSpec> {
|
||||
let p = |x: f64, y: f64, z: f64| Vec3::new(x, y, z);
|
||||
let (a, b, c, d) = (p(0., 0., 0.), p(4., 0., 0.), p(4., 3., 0.), p(0., 3., 0.));
|
||||
let (e, f, g, h) = (p(0., 0., 2.), p(4., 0., 2.), p(4., 3., 2.), p(0., 3., 2.));
|
||||
let line = |a: Vec3, b: Vec3| EdgeSpec::Line { a, b };
|
||||
let flat = |edges: Vec<EdgeSpec>| FaceSpec { loops: vec![edges], surface: SurfSpec::Plane, probe: None };
|
||||
vec![
|
||||
flat(vec![line(a, d), line(d, c), bottom.reversed(), line(b, a)]),
|
||||
flat(vec![line(e, f), top.clone(), line(g, h), line(h, e)]),
|
||||
quad(a, b, f, e),
|
||||
quad(c, d, h, g),
|
||||
quad(d, a, e, h),
|
||||
FaceSpec {
|
||||
loops: vec![vec![bottom.clone(), line(c, g), top.reversed(), line(f, b)]],
|
||||
surface: SurfSpec::Ruled { a: bottom, b: top },
|
||||
probe: Some((p(4.75, 1.5, 1.0), Vec3::X)),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// The bulge's cubic split at its middle (de Casteljau), at height `z`.
|
||||
fn bulge(z: f64) -> EdgeSpec {
|
||||
let ctrl = [(4.0, 0.0), (4.5, 0.0), (4.75, 0.75), (4.75, 1.5), (4.75, 2.25), (4.5, 3.0), (4.0, 3.0)];
|
||||
EdgeSpec::Chain { ctrl: ctrl.iter().map(|(x, y)| Vec3::new(*x, *y, z)).collect() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ruled_wall_between_bezier_chains_closes_the_solid() {
|
||||
let body = sew(&bulged_box(bulge(0.0), bulge(2.0))).unwrap();
|
||||
assert_eq!(body.face_count(), 6);
|
||||
assert!(body.validity().is_empty(), "{:?}", body.validity());
|
||||
// x = 4 + 3t(1 − t), y = 3(3t² − 2t³): the bulge adds ∫ (x − 4) dy = 54 ∫ t²(1 − t)² dt
|
||||
// = 1.8 to the 4 × 3 section; the solid is that section 2 high.
|
||||
let m = crate::measure(&body).unwrap();
|
||||
assert!((m.volume - 27.6).abs() < 2e-3, "{}", m.volume);
|
||||
assert!((m.bbox.max.x - 4.75).abs() < 1e-3, "{:?}", m.bbox);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_bezier_chains_are_refused() {
|
||||
let chain = |ctrl: Vec<Vec3>| EdgeSpec::Chain { ctrl: ctrl.into() };
|
||||
let EdgeSpec::Chain { ctrl } = bulge(2.0) else { panic!() };
|
||||
// Not 3n + 1 control points; a control point that is not a number; too many pieces.
|
||||
let mut nan = ctrl.to_vec();
|
||||
nan[2].x = f64::NAN;
|
||||
let long: Vec<Vec3> = (0..MAX_CHAIN_POINTS + 3).map(|i| Vec3::new(4.0, 3.0 * i as f64 / (MAX_CHAIN_POINTS + 2) as f64, 2.0)).collect();
|
||||
for top in [chain(ctrl[..6].to_vec()), chain(ctrl[..3].to_vec()), chain(Vec::new()), chain(nan), chain(long)] {
|
||||
assert!(sew(&bulged_box(bulge(0.0), top)).is_err());
|
||||
}
|
||||
// A ruled wall needs chains with the same number of pieces.
|
||||
let whole = chain(vec![Vec3::new(4.0, 0.0, 2.0), Vec3::new(5.0, 0.0, 2.0), Vec3::new(5.0, 3.0, 2.0), Vec3::new(4.0, 3.0, 2.0)]);
|
||||
let e = sew(&bulged_box(bulge(0.0), whole)).unwrap_err().to_string();
|
||||
assert!(e.contains("ruled chains differ"), "{e}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user