diff --git a/ROADMAP.md b/ROADMAP.md index 1a646754..8fd0df41 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index 13dcdbab..88b5dc3a 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -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 { @@ -370,6 +377,32 @@ fn dedup(p: &[Vec2]) -> Vec { 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 { + 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 { 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 = 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 = 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 = + if curved { c.outer.iter().flat_map(|e| e.polyline(1e-4)).collect() } else { c.outer.iter().map(Seg2::start).collect() }; + let ss: Vec = 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 = 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::(); + 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 { diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index ba8993de..af4f54db 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -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] diff --git a/crates/kernel/src/sew.rs b/crates/kernel/src/sew.rs index a5835ddd..8631435d 100644 --- a/crates/kernel/src/sew.rs +++ b/crates/kernel/src/sew.rs @@ -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 { + 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 { 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> { + 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 = 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>().into() + l.iter() + .enumerate() + .map(|(ei, e)| cache.edge(e, sheets.get(&(fi, li, ei)).copied().unwrap_or(0))) + .collect::>>() + .map(Into::into) }) - .collect(); - let face = match f.surface { + .collect::>()?; + 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 { + 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| 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| 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 = (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}"); + } }