//! Sheet metal: rules (thickness, K-factor, bend radius, reliefs as expressions that may use //! parameters), and the sheet body model behind every sheet-metal feature. //! //! A sheet body is kept as its flat pattern: the base panel, flanges hanging off panel edges //! (each a bend zone of width BA = θ·(R + K·T) followed by a flat panel), and cut-outs, all in //! the flat frame (the base panel's bottom face; thickness grows along its normal). Folding maps //! each flange's flat coordinates through its parents' bends: past a bend line, a point loses //! the bend zone's width and turns by θ about the bend axis (R from the inner face). The folded //! solid is built from extruded pieces (panels, and bend zones as annular sectors) joined //! together; the flat solid is the outline extruded through the thickness. use serde::{Deserialize, Serialize}; use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3}; use solvecraft_kernel::{self as kernel, Body, EdgeSpec, FaceSpec, SurfSpec}; use crate::expr::{self, Kind, Value}; use crate::{DocError, Document, IDENTITY, Mat, Result, apply_point, apply_vector, mat_inverse, mat_mul}; // 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. const MAX_FLAT_POINTS: usize = 16_384; const MAX_FLAT_PIECES: usize = 4096; const MAX_FLAT_WORK: usize = 500_000_000; fn flat_budget_error() -> DocError { DocError::Invalid("sheet metal flat geometry exceeds the bounded processing budget".into()) } pub(crate) struct FlatWork(usize); impl FlatWork { pub(crate) fn new() -> Self { Self(MAX_FLAT_WORK) } fn charge(&mut self, work: usize) -> Result<()> { self.0 = self.0.checked_sub(work).ok_or_else(flat_budget_error)?; Ok(()) } } fn flat_polyline(lp: &Loop2) -> Result> { if lp.segs.len() > MAX_FLAT_POINTS { return Err(flat_budget_error()); } let mut points = Vec::new(); for seg in &lp.segs { let p = seg.polyline(1e-3); let add = p.len().saturating_sub(1); if points.len().saturating_add(add) > MAX_FLAT_POINTS { return Err(flat_budget_error()); } points.extend(p.iter().take(add)); } Ok(points) } fn flat_points(polys: &[Vec]) -> Result { let n = polys.iter().try_fold(0usize, |n, p| n.checked_add(p.len()).ok_or_else(flat_budget_error))?; if n > MAX_FLAT_POINTS || polys.len() > MAX_FLAT_PIECES { return Err(flat_budget_error()); } Ok(n) } 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 { .. })) { flat_polyline(lp)?; 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::>()?, }) } fn region_complexity(regions: &[Region2]) -> Result<(usize, usize)> { regions_complexity(regions.iter()) } fn regions_complexity<'a>(regions: impl Iterator) -> Result<(usize, usize)> { let (mut edges, mut points) = (0usize, 0usize); let mut pieces = 0usize; for region in regions { pieces += 1; if pieces > MAX_FLAT_PIECES || region.holes.len() > MAX_FLAT_PIECES { return Err(flat_budget_error()); } for lp in std::iter::once(®ion.outer).chain(®ion.holes) { edges = edges.saturating_add(lp.segs.len()); if edges > MAX_FLAT_POINTS { return Err(flat_budget_error()); } points = points.saturating_add(flat_polyline(lp)?.len()); if points > MAX_FLAT_POINTS { return Err(flat_budget_error()); } } } Ok((edges, points)) } pub(crate) fn material_boolean(a: &[Region2], b: &[Region2], op: kernel::BoolOp, work: &mut FlatWork) -> Result> { let (ae, ap) = region_complexity(a)?; let (be, bp) = region_complexity(b)?; let edges = ae.saturating_add(be); work.charge(edges.saturating_mul(edges).saturating_add(edges.saturating_mul(ap.saturating_add(bp))).saturating_mul(8))?; let regions = kernel::planar_regions_boolean(a, b, op, 1e-7)?; region_complexity(®ions)?; Ok(regions) } // Merge in balanced rounds so hundreds of disjoint holes or adjoining panels do not // repeatedly arrange a growing accumulated boundary against just one small region. pub(crate) fn material_union(regions: Vec, work: &mut FlatWork) -> Result> { region_complexity(®ions)?; let mut groups: Vec> = regions.into_iter().map(|r| vec![r]).collect(); while groups.len() > 1 { let mut next = Vec::new(); let mut iter = groups.into_iter(); while let Some(a) = iter.next() { match iter.next() { Some(b) => next.push(material_boolean(&a, &b, kernel::BoolOp::Union, work)?), None => next.push(a), } } groups = next; } Ok(groups.pop().unwrap_or_default()) } /// A sheet metal rule. Values are expressions; `Thickness` inside them is the rule's thickness. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct SheetRule { pub name: String, pub thickness: String, pub k_factor: String, pub bend_radius: String, #[serde(default = "rule_relief_width")] pub relief_width: String, #[serde(default = "rule_relief_depth")] pub relief_depth: String, #[serde(default = "rule_corner_relief")] pub corner_relief: String, /// Inner radius of flat hems. #[serde(default = "rule_hem_gap")] pub hem_gap: String, /// Gap between flanges that meet at a corner. #[serde(default = "rule_gap")] pub gap: String, } fn rule_gap() -> String { "Thickness".into() } fn rule_relief_width() -> String { "Thickness".into() } fn rule_relief_depth() -> String { "Thickness * 0.5".into() } fn rule_corner_relief() -> String { "Thickness * 4.0".into() } fn rule_hem_gap() -> String { "0.01 mm".into() } impl SheetRule { /// Fusion's default: Steel (mm), 2.5 mm, K 0.44, bend radius = Thickness. pub fn steel() -> SheetRule { SheetRule { name: "Steel (mm)".into(), thickness: "2.5 mm".into(), k_factor: "0.44".into(), bend_radius: "Thickness".into(), relief_width: rule_relief_width(), relief_depth: rule_relief_depth(), corner_relief: rule_corner_relief(), hem_gap: rule_hem_gap(), gap: rule_gap(), } } } /// The design's sheet metal rules and the one new sheet bodies use. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct SheetSettings { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub rules: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub active: Option, } impl SheetSettings { pub fn is_empty(&self) -> bool { self.rules.is_empty() && self.active.is_none() } } /// A rule's values (mm, unit-less K). #[derive(Clone, Copy, Debug, PartialEq, Serialize)] pub struct RuleValues { pub thickness: f64, pub k_factor: f64, pub bend_radius: f64, pub relief_width: f64, pub relief_depth: f64, pub corner_relief: f64, pub hem_gap: f64, pub gap: f64, } impl Document { /// A rule by name, the active one, or the default steel rule. pub fn sheet_rule(&self, name: Option<&str>) -> SheetRule { let want = name.or(self.sheet.active.as_deref()); want.and_then(|n| self.sheet.rules.iter().find(|r| r.name == n)) .or_else(|| self.sheet.rules.first()) .cloned() .unwrap_or_else(SheetRule::steel) } /// Evaluate a rule: `Thickness` in its expressions is the rule's thickness. pub fn rule_values(&self, vals: &std::collections::BTreeMap, r: &SheetRule) -> Result { let look = |n: &str| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))); let t = expr::eval_with(&r.thickness, &look)?.to_kind(Kind::Length)?; if !(t > 0.0 && t < 1e4) { return Err(DocError::Invalid("the sheet thickness must be positive".into())); } let with_t = |e: &str, k: Kind| -> Result { let f = |n: &str| if n == "Thickness" && !vals.contains_key("Thickness") { Ok(Value::length(t)) } else { look(n) }; expr::eval_with(e, &f)?.to_kind(k) }; let v = RuleValues { thickness: t, k_factor: with_t(&r.k_factor, Kind::Unitless)?, bend_radius: with_t(&r.bend_radius, Kind::Length)?, relief_width: with_t(&r.relief_width, Kind::Length)?, relief_depth: with_t(&r.relief_depth, Kind::Length)?, corner_relief: with_t(&r.corner_relief, Kind::Length)?, hem_gap: with_t(&r.hem_gap, Kind::Length)?, gap: with_t(&r.gap, Kind::Length)?, }; if !(0.0..=1.0).contains(&v.k_factor) || v.bend_radius < 0.0 { return Err(DocError::Invalid("the K-factor must be 0…1 and the bend radius positive".into())); } Ok(v) } } /// A flange: a bend along a panel edge and the flat panel past it (flat coordinates). #[derive(Clone, Debug, PartialEq, Serialize)] pub struct SheetFlange { /// The flange it hangs off (None = the base panel). pub parent: Option, /// Bend start line: from `p` along `d` for `len`; `n` points into the flange. pub p: Vec2, pub d: Vec2, pub len: f64, pub n: Vec2, /// Bend angle (radians; positive bends toward the top face, +z of the flat frame). pub angle: f64, /// Inner bend radius. pub radius: f64, /// Flat length of the panel past the bend zone. pub leg: f64, /// The panel runs on past the bend's ends by these (toward a neighbouring flange). #[serde(default)] pub ext: (f64, f64), /// A fold's panel: the part of the sheet past the bend (flat coordinates, counter-clockwise) /// instead of a rectangle `leg` long. #[serde(default, skip_serializing_if = "Option::is_none")] pub shape: Option>, /// Which feature made it: `F.` (persistent edge names). #[serde(default, skip_serializing_if = "String::is_empty")] pub tag: String, } /// A sheet metal body (see the module docs). #[derive(Clone, Debug, PartialEq, Serialize)] pub struct SheetBody { pub body: String, pub rule: String, pub t: f64, pub k: f64, /// The flat frame: base panel bottom face; z = thickness direction. pub frame: Plane, pub base: Region2, pub flanges: Vec, /// Cut-outs (flat coordinates) through the sheet. pub holes: Vec, /// Planar machining in flat coordinates, with depths measured from the flat bottom. /// Derived from feature history; these records are not a document schema change. #[serde(skip_serializing_if = "Vec::is_empty")] pub pockets: Vec, /// Unfolded. pub flat: bool, /// Gap between flanges that meet at a corner. pub gap: f64, /// The base sketch's lines (id, ends in flat coordinates), naming the base panel's edges. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub base_curves: Vec<(String, Vec2, Vec2)>, } #[derive(Clone, Debug, PartialEq, Serialize)] pub struct SheetPocket { pub region: Region2, pub z0: f64, pub z1: f64, } /// A piece of the sheet: a panel (None = the base) or a flange's bend zone. #[derive(Clone, Copy, Debug, PartialEq)] enum Piece { Panel(Option), Zone(usize), } /// A flat piece of the sheet (counter-clockwise outer loop, clockwise holes). #[derive(Clone, Debug)] struct Cell { piece: Piece, outer: Vec, holes: Vec>, } 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 }, } } fn dedup(p: &[Vec2]) -> Vec { let mut v: Vec = Vec::new(); for q in p { if v.last().is_none_or(|l| l.dist(*q) > 1e-9) { v.push(*q); } } if v.len() > 1 && v.first().zip(v.last()).is_some_and(|(a, b)| a.dist(*b) < 1e-9) { v.pop(); } v } /// A bend for the bend table. #[derive(Clone, Debug, PartialEq, Serialize)] pub struct BendInfo { pub index: usize, pub angle_deg: f64, pub radius: f64, pub up: bool, pub allowance: f64, pub length: f64, /// Centre line in flat coordinates. pub start: Vec2, pub end: Vec2, } fn rot_about(o: Vec3, k: Vec3, a: f64) -> Mat { crate::rigid(Vec3::ZERO, o, k, a) } fn translate(t: Vec3) -> Mat { let mut m = IDENTITY; m[3] = [t.x, t.y, t.z, 1.0]; m } fn v3(v: Vec2) -> Vec3 { Vec3::new(v.x, v.y, 0.0) } fn plane_of(m: &Mat, o: Vec3, x: Vec3, y: Vec3) -> Result { Plane::new(apply_point(m, o), apply_vector(m, x), apply_vector(m, y)).ok_or_else(|| DocError::Invalid("sheet: degenerate plane".into())) } fn rect(a: Vec2, b: Vec2, c: Vec2, d: Vec2) -> Region2 { let mut lp = Loop2::polygon(&[a, b, c, d]); if lp.signed_area() < 0.0 { lp = lp.reversed(); } Region2 { outer: lp, holes: Vec::new() } } /// Round to a 1e-9 mm grid (keeps trimmed edges exactly where the kernel expects them). fn snap(v: Vec2) -> Vec2 { let r = |x: f64| (x * 1e9).round() / 1e9; Vec2::new(r(v.x), r(v.y)) } /// Clip a polygon to the half-plane (p − o)·n ≥ 0. fn clip(poly: &[Vec2], o: Vec2, n: Vec2) -> Vec { let mut out = Vec::new(); let m = poly.len(); for i in 0..m { let (Some(&a), Some(&b)) = (poly.get(i), poly.get((i + 1) % m)) else { continue }; let (da, db) = ((a - o).dot(n), (b - o).dot(n)); if da >= 0.0 { out.push(a); } if (da >= 0.0) != (db >= 0.0) { let t = da / (da - db); out.push(snap(a + (b - a) * t)); } } out } impl SheetBody { /// Bend allowance of a flange. pub fn allowance(&self, f: &SheetFlange) -> f64 { f.angle.abs() * (f.radius + self.k * self.t) } /// Fold transform of flange `i`'s panel (flat 3D coordinates → folded, in the flat frame). pub fn fold(&self, i: usize) -> Mat { let mut chain = Vec::new(); let mut cur = Some(i); for _ in 0..1000 { let Some(c) = cur else { break }; chain.push(c); cur = self.flanges.get(c).and_then(|f| f.parent); } let mut m = IDENTITY; for c in chain.into_iter().rev() { if let Some(f) = self.flanges.get(c) { m = mat_mul(&m, &self.bend_motion(f)); } } m } /// The fold of one bend: drop the bend zone, then turn about the bend axis. fn bend_motion(&self, f: &SheetFlange) -> Mat { let ba = self.allowance(f); let (axis_o, k, a) = self.bend_axis(f); mat_mul(&rot_about(axis_o, k, a), &translate(v3(f.n) * -ba)) } /// Bend axis point, direction and signed turn (flat coordinates). fn bend_axis(&self, f: &SheetFlange) -> (Vec3, Vec3, f64) { let up = f.angle >= 0.0; let z = if up { self.t + f.radius } else { -f.radius }; let o = v3(f.p) + Vec3::Z * z; let k = v3(f.n).cross(Vec3::Z); (o, k, if up { f.angle.abs() } else { -f.angle.abs() }) } /// Flat region of a flange's panel. pub fn panel(&self, f: &SheetFlange) -> Region2 { if let Some(sh) = &f.shape { return Region2 { outer: Loop2::polygon(sh).ccw(), holes: Vec::new() }; } let ba = self.allowance(f); let a = f.p + f.n * ba - f.d * f.ext.0; let b = f.p + f.n * ba + f.d * (f.len + f.ext.1); rect(a, b, b + f.n * f.leg, a + f.n * f.leg) } /// Flat region of a flange's bend zone. pub fn zone(&self, f: &SheetFlange) -> Region2 { let ba = self.allowance(f); let (u0, u1) = self.zone_span(f); let (a, b) = (f.p + f.d * u0, f.p + f.d * u1); rect(a, b, b + f.n * ba, a + f.n * ba) } /// The part of a bend along its parent's edge: where a later flange trimmed that edge back /// (a corner), the bend stops and the flange panel runs on alone (a corner relief). pub fn zone_span(&self, f: &SheetFlange) -> (f64, f64) { if f.parent.is_some() { return (0.0, f.len); } let pts = self.base.outer.ccw().polyline(1e-3); let m = pts.len(); let mut span: Option<(f64, f64)> = None; for k in 0..m { let (Some(&a), Some(&b)) = (pts.get(k), pts.get((k + 1) % m)) else { continue }; let on = (a - f.p).dot(f.n).abs() < 1e-6 && (b - f.p).dot(f.n).abs() < 1e-6; if !on { continue; } let (ua, ub) = ((a - f.p).dot(f.d), (b - f.p).dot(f.d)); let (lo, hi) = (ua.min(ub).max(0.0), ua.max(ub).min(f.len)); if hi > lo + 1e-9 { span = Some(span.map_or((lo, hi), |(x, y)| (x.min(lo), y.max(hi)))); } } span.unwrap_or((0.0, f.len)) } fn frame_mat(&self) -> Mat { let fr = &self.frame; let n = fr.normal(); [[fr.x.x, fr.x.y, fr.x.z, 0.0], [fr.y.x, fr.y.y, fr.y.z, 0.0], [n.x, n.y, n.z, 0.0], [fr.origin.x, fr.origin.y, fr.origin.z, 1.0]] } /// Flat coordinates (x, y, z) → world, through the fold of flange `i` (None = base). pub fn world(&self, i: Option) -> Mat { let f = match i { Some(i) if !self.flat => self.fold(i), _ => IDENTITY, }; mat_mul(&self.frame_mat(), &f) } /// Where a flat point of a piece lands (folded), at height `z` in the sheet. fn map3(&self, piece: Piece, q: Vec2, z: f64) -> Vec3 { match piece { Piece::Panel(i) => apply_point(&self.world(i), v3(q) + Vec3::Z * z), Piece::Zone(i) => { let Some(f) = self.flanges.get(i) else { return Vec3::ZERO }; let s = (q - f.p).dot(f.n); let q0 = q - f.n * s; let neutral = (f.radius + self.k * self.t).max(1e-12); let phi = (s / neutral).clamp(0.0, f.angle.abs()); let (axis_o, k, turn) = self.bend_axis(f); let m = mat_mul(&self.world(f.parent), &rot_about(axis_o, k, turn.signum() * phi)); apply_point(&m, v3(q0) + Vec3::Z * z) } } } /// 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()); let (pa, pb) = (self.map3(piece, a, z), self.map3(piece, b, z)); 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 { .. }) => { 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())) } } (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())), } } /// The pieces of the sheet as cells: base and flange panels and bend zones, each less /// the combined cut-out material. Planar clipping retains holes and disconnected islands. fn cells_with_work(&self, work: &mut FlatWork) -> Result> { if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES || self.holes.len() > MAX_FLAT_PIECES { return Err(flat_budget_error()); } let raw = self .flanges .iter() .fold(self.base.outer.segs.len(), |n, f| n.saturating_add(f.shape.as_ref().map_or(4, Vec::len))) .saturating_add(self.holes.iter().fold(0usize, |n, h| n.saturating_add(h.outer.segs.len()))); if raw > MAX_FLAT_POINTS { return Err(flat_budget_error()); } let mut pieces: Vec<(Piece, Region2)> = self.panels().into_iter().map(|(pi, r)| (Piece::Panel(pi), r)).collect(); for (i, f) in self.flanges.iter().enumerate() { pieces.push((Piece::Zone(i), self.zone(f))); } let mut cuts = Vec::new(); let mut cut_points = 0usize; for hole in &self.holes { let 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)?); 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 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); if points > MAX_FLAT_POINTS || out.len() >= MAX_FLAT_PIECES { return Err(flat_budget_error()); } out.push(Cell { piece, outer: region.outer.segs, holes: region.holes.into_iter().map(|l| l.segs).collect() }); } } Ok(out) } /// Faces of the solid made by `cells` between heights `z0` and `z1`. fn faces(&self, cells: &[Cell], z0: f64, z1: f64, work: &mut FlatWork) -> Result> { // Split straight edges where other cells' corners touch them, so neighbours share edges. let edges = cells .iter() .fold(0usize, |n, c| n.saturating_add(c.outer.len()).saturating_add(c.holes.iter().fold(0usize, |m, h| m.saturating_add(h.len())))); if edges > MAX_FLAT_POINTS || cells.len() > MAX_FLAT_PIECES { return Err(flat_budget_error()); } work.charge(edges.saturating_mul(edges).saturating_mul(4))?; let corners: Vec = cells.iter().flat_map(|c| c.outer.iter().chain(c.holes.iter().flatten()).map(Seg2::start)).collect(); let mut split_edges = 0usize; let mut split_loop = |segs: &[Seg2]| -> Result> { let mut v = Vec::new(); for e in segs { let Seg2::Line { a, b } = *e else { split_edges += 1; if split_edges > MAX_FLAT_POINTS { return Err(flat_budget_error()); } v.push(*e); continue; }; let d = b - a; let l2 = d.len2(); let mut ts: Vec = corners .iter() .filter_map(|p| { let t = (*p - a).dot(d) / l2.max(1e-30); ((t > 1e-9 && t < 1.0 - 1e-9) && (a + d * t).dist(*p) < 1e-7).then_some(t) }) .collect(); ts.sort_by(|x, y| x.total_cmp(y)); ts.dedup_by(|x, y| (*x - *y).abs() < 1e-12); let mut prev = a; for t in ts { split_edges += 1; if split_edges > MAX_FLAT_POINTS { return Err(flat_budget_error()); } let q = a + d * t; v.push(Seg2::Line { a: prev, b: q }); prev = q; } split_edges += 1; if split_edges > MAX_FLAT_POINTS { return Err(flat_budget_error()); } v.push(Seg2::Line { a: prev, b }); } Ok(v) }; let mut split_cells = Vec::new(); for c in cells { let outer = split_loop(&c.outer)?; let holes = c.holes.iter().map(|h| split_loop(h)).collect::>()?; split_cells.push(Cell { piece: c.piece, outer, holes }); } // Reserve the complete adjacency scan before visiting any wall. work.charge(split_edges.saturating_mul(split_edges).saturating_mul(2))?; let cells = split_cells; let near = |p: Vec2, q: Vec2| p.dist(q) < 1e-6; // Shared with a neighbour: the same flat edge the other way, landing in the same place // (flat neighbours across a corner of two bends do not). let shared = |ci: usize, e: &Seg2| { let Some(me) = cells.get(ci) else { return false }; let here = self.map3(me.piece, e.point_at(0.5), z0); cells.iter().enumerate().any(|(cj, c)| { cj != ci && c.outer.iter().chain(c.holes.iter().flatten()).any(|g| { near(g.start(), e.end()) && near(g.end(), e.start()) && matches!((g, e), (Seg2::Line { .. }, Seg2::Line { .. })) && self.map3(c.piece, g.point_at(0.5), z0).dist(here) < 1e-6 }) }) }; let mut faces = Vec::new(); for (ci, c) in cells.iter().enumerate() { // Top (z1, outward +z) and bottom (z0, outward −z, loops reversed). let loops = |z: f64, rev: bool| -> Result>> { std::iter::once(&c.outer) .chain(c.holes.iter()) .map(|l| { let mut v: Vec = l.iter().map(|e| self.edge3(c.piece, e, z)).collect::>()?; if rev { v = v.into_iter().rev().map(rev_edge).collect(); } Ok(v) }) .collect() }; let centre = c.outer.iter().map(Seg2::start).fold(Vec2::default(), |a, b| a + b) * (1.0 / c.outer.len().max(1) as f64); for (z, rev, sign) in [(z1, false, 1.0), (z0, true, -1.0)] { let surface = match c.piece { 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)); 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); let q = |u: f64| f.p + f.n * s0 + f.d * u; SurfSpec::Revolved { a: self.map3(c.piece, q(u0), z), b: self.map3(c.piece, q(u1), z), axis_origin: apply_point(&wp, axis_o), axis: apply_vector(&wp, k), angle: turn.signum() * (s1 - s0) / neutral, } } }; let p = self.map3(c.piece, centre, z); let outward = (self.map3(c.piece, centre, z + 1.0) - p) * sign; faces.push(FaceSpec { loops: loops(z, rev)?, surface, probe: Some((p, outward)) }); } // Walls along edges no other cell shares. for e in c.outer.iter().chain(c.holes.iter().flatten()) { if shared(ci, e) { continue; } 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 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) { (Piece::Panel(_), Seg2::Arc { .. }) => { 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 } } _ => SurfSpec::Plane, }; 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); let pm = self.map3(c.piece, mid, (z0 + z1) / 2.0); let outward = self.map3(c.piece, mid + right * 1e-3, (z0 + z1) / 2.0) - pm; faces.push(FaceSpec { loops: vec![vec![bottom, up_b, top, down_a]], surface, probe: Some((pm, outward)) }); } } Ok(faces) } /// The folded (or, when unfolded, flat) solid, sewn from its faces. pub fn solid(&self) -> Result { if self.flat { return self.flat_solid(); } let mut work = FlatWork::new(); let cells = self.cells_with_work(&mut work)?; let body = kernel::sew(&self.faces(&cells, 0.0, self.t, &mut work)?)?; self.machine_pockets(body, &mut work) } /// The flat pattern as one extrusion (outline with its cut-outs). pub fn flat_solid(&self) -> Result { let regions = self.flat_regions()?; let pl = plane_of(&self.frame_mat(), Vec3::ZERO, Vec3::X, Vec3::Y)?; let body = kernel::extrude_planar_regions(&pl, ®ions, 0.0, self.t)?; self.machine_pockets(body, &mut FlatWork::new()) } /// Replay exact planar machining after forming the full-thickness material. fn machine_pockets(&self, mut body: Body, work: &mut FlatWork) -> Result { if self.pockets.len() > 1000 { return Err(flat_budget_error()); } regions_complexity(self.pockets.iter().map(|p| &p.region))?; 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 { 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())); } if !self.flat { for flange in &self.flanges { if flat_regions_overlap(&pocket.region, &self.zone(flange), work)? { return Err(DocError::Invalid( "a sheet pocket crosses a bend zone; planar pocket machining cannot yet preserve curved bend material".into(), )); } } } for (pi, panel) in &panels { let clipped = material_boolean(panel, std::slice::from_ref(&pocket.region), kernel::BoolOp::Intersect, work)?; if clipped.is_empty() { continue; } 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. work.charge(100_000)?; let tool = kernel::extrude_planar_regions(&plane, &clipped, pocket.z0, pocket.z1)?; body = kernel::boolean(&body, &tool, kernel::BoolOp::Cut)? .ok_or_else(|| DocError::Invalid("sheet pocket removes all sheet material".into()))?; } } Ok(body) } /// Actual curved material, used to certify unsupported folded machining before mutation. pub(crate) fn unmodeled_solid(&self, plane: &Plane) -> Result> { if self.flat || self.flanges.is_empty() { return Ok(None); } let mut work = FlatWork::new(); let cells: Vec<_> = self .cells_with_work(&mut work)? .into_iter() .filter(|c| match c.piece { Piece::Zone(_) => true, Piece::Panel(pi) => apply_vector(&self.world(pi), Vec3::Z).cross(plane.normal()).len() > 1e-6, }) .collect(); if cells.is_empty() { return Ok(None); } Ok(Some(kernel::sew(&self.faces(&cells, 0.0, self.t, &mut work)?)?)) } fn through_pocket_regions(&self, work: &mut FlatWork) -> Result> { if self.pockets.is_empty() { return Ok(Vec::new()); } if self.pockets.len() > 1000 { return Err(flat_budget_error()); } regions_complexity(self.pockets.iter().map(|p| &p.region))?; let mut levels = vec![0.0, self.t]; for p in &self.pockets { if !p.z0.is_finite() || !p.z1.is_finite() || p.z0 < 0.0 || p.z1 > self.t || p.z0 >= p.z1 { return Err(DocError::Invalid("invalid sheet pocket depth".into())); } levels.extend([p.z0, p.z1]); } levels.sort_by(f64::total_cmp); levels.dedup(); work.charge(levels.len().saturating_mul(self.pockets.len()))?; // Most pockets leave an untouched thickness layer. Certify that cheap case // before arranging hundreds of unrelated profiles at every depth level. if levels.windows(2).any(|zs| !self.pockets.iter().any(|p| p.z0 <= zs[0] && p.z1 >= zs[1])) { return Ok(Vec::new()); } let mut through: Option> = None; for zs in levels.windows(2) { let active: Vec<_> = self.pockets.iter().filter(|p| p.z0 <= zs[0] && p.z1 >= zs[1]).map(|p| p.region.clone()).collect(); if active.is_empty() { return Ok(Vec::new()); } let removed = material_union(active, work)?; through = Some(match through { None => removed, Some(previous) => material_boolean(&previous, &removed, kernel::BoolOp::Intersect, work)?, }); if through.as_ref().is_some_and(Vec::is_empty) { return Ok(Vec::new()); } } Ok(through.unwrap_or_default()) } /// Pocket operations clipped to the surviving flat material, with exact depth intervals. pub fn machining(&self) -> Result> { let material = self.flat_regions()?; let mut work = FlatWork::new(); let mut out = Vec::new(); for pocket in &self.pockets { for region in material_boolean(&material, std::slice::from_ref(&pocket.region), kernel::BoolOp::Intersect, &mut work)? { if out.len() >= 1000 { return Err(flat_budget_error()); } out.push(SheetPocket { region, z0: pocket.z0, z1: pocket.z1 }); } } Ok(out) } /// 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. pub fn flat_regions(&self) -> Result> { let regions = self.flat_material()?; if regions.is_empty() { return Err(DocError::Invalid("empty flat pattern".into())); } Ok(regions) } pub(crate) fn flat_material(&self) -> Result> { let mut work = FlatWork::new(); // Merge structural pieces before cutting: clipping each panel first introduces // coincident notch endpoints at seams, while a single material cut has no seams. if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES || self.holes.len() > MAX_FLAT_PIECES { return Err(flat_budget_error()); } let raw = self.flanges.iter().fold(self.base.outer.segs.len(), |n, f| n.saturating_add(f.shape.as_ref().map_or(4, Vec::len))); if raw > MAX_FLAT_POINTS { return Err(flat_budget_error()); } let mut regions = Vec::new(); let mut panel_points = 0usize; for (_, r) in self.panels() { let r = planar_region(&r)?; panel_points = panel_points.saturating_add(region_complexity(std::slice::from_ref(&r))?.1); if panel_points > MAX_FLAT_POINTS { return Err(flat_budget_error()); } regions.push(r); } for f in &self.flanges { regions.push(planar_region(&self.zone(f))?); region_complexity(®ions)?; } let regions = material_union(regions, &mut work)?; let mut cuts = Vec::new(); let mut cut_points = 0usize; for hole in &self.holes { let 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(&mut work)?); let cuts = material_union(cuts, &mut work)?; let regions = if cuts.is_empty() { regions } else { material_boolean(®ions, &cuts, kernel::BoolOp::Cut, &mut work)? }; Ok(regions) } /// Outline loops of the flat pattern (flat coordinates): the pieces' edges, shared ones left out. pub fn outline(&self) -> Result>> { if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES { return Err(flat_budget_error()); } let mut polys: Vec> = vec![dedup(&flat_polyline(&self.base.outer.ccw())?)]; for f in &self.flanges { polys.push(dedup(&flat_polyline(&self.zone(f).outer.ccw())?)); if f.leg > 1e-9 { polys.push(dedup(&flat_polyline(&self.panel(f).outer.ccw())?)); } flat_points(&polys)?; } let loops = merge_loops(&polys, &mut FlatWork::new())?; if loops.is_empty() { return Err(DocError::Invalid("empty flat pattern".into())); } Ok(loops) } /// The bends: angle, radius, direction, allowance and centre line (flat coordinates). pub fn bends(&self) -> Vec { self.flanges .iter() .enumerate() .map(|(i, f)| { let ba = self.allowance(f); let (u0, u1) = self.zone_span(f); let a = f.p + f.n * (ba / 2.0) + f.d * u0; BendInfo { index: i + 1, angle_deg: f.angle.abs().to_degrees(), radius: f.radius, up: f.angle >= 0.0, allowance: ba, length: u1 - u0, start: a, end: a + f.d * (u1 - u0), } }) .collect() } /// Topology counted as Fusion does: a round hole is one face bounded by two closed circular /// edges (one vertex each), where the sewn solid splits it into arcs. Returns the change to /// the sewn (faces, edges, vertices). pub fn round_hole_correction(&self) -> (i64, i64, i64) { let (mut df, mut de, mut dv) = (0i64, 0i64, 0i64); let Ok(regions) = self.flat_regions() else { return (0, 0, 0) }; for h in regions.iter().flat_map(|r| &r.holes) { let arcs: Vec<(Vec2, f64)> = h.segs.iter().filter_map(|s| if let Seg2::Arc { center, radius, .. } = s { Some((*center, *radius)) } else { None }).collect(); let n = h.segs.len() as i64; let full = !arcs.is_empty() && arcs.len() == h.segs.len() && arcs.iter().all(|(c, r)| c.dist(arcs[0].0) < 1e-9 && (r - arcs[0].1).abs() < 1e-9); if full && n > 1 { df += 1 - n; de += 2 - 3 * n; dv += 2 - 2 * n; } } (df, de, dv) } /// Bounding size of the flat pattern (x, y in the flat frame). pub fn flat_size(&self) -> Result<(f64, f64)> { let loops: Vec> = self.flat_regions()?.iter().map(|r| r.outer.polyline(1e-3)).collect(); let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN)); for p in loops.iter().flatten() { lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y)); hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y)); } Ok((hi.x - lo.x, hi.y - lo.y)) } /// Panels with their regions and world placements: (flange index or None, region). fn panels(&self) -> Vec<(Option, Region2)> { let mut v = vec![(None, self.base.clone())]; for (i, f) in self.flanges.iter().enumerate() { if f.leg > 1e-9 { v.push((Some(i), self.panel(f))); } } v } /// The panel edge nearest a world point: (panel, edge a, edge b in flat coordinates, on the /// top face?, distance). pub fn edge_at(&self, p: Vec3) -> Option<(Option, Vec2, Vec2, bool, f64)> { let mut best: Option<(Option, Vec2, Vec2, bool, f64)> = None; for (pi, r) in self.panels() { let w = self.world(pi); let Some(inv) = mat_inverse(&w) else { continue }; let q = apply_point(&inv, p); let pts = r.outer.ccw().polyline(1e-3); let m = pts.len(); for i in 0..m { let (Some(&a), Some(&b)) = (pts.get(i), pts.get((i + 1) % m)) else { continue }; if a.dist(b) < 1e-9 { continue; } for (z, top) in [(0.0, false), (self.t, true)] { let q2 = Vec2::new(q.x, q.y); let d = b - a; let t = ((q2 - a).dot(d) / d.len2()).clamp(0.0, 1.0); let dist = ((a + d * t) - q2).len().hypot(q.z - z); if best.as_ref().is_none_or(|x| dist < x.4) { best = Some((pi, a, b, top, dist)); } } } } best } /// Trim a panel's edge a→b back by `by` (toward the panel interior). fn trim(&mut self, panel: Option, a: Vec2, b: Vec2, by: f64) -> Result<()> { if by.abs() < 1e-12 { return Ok(()); } let d = (b - a).normalized().ok_or_else(|| DocError::Invalid("edge".into()))?; // Interior side of a counter-clockwise loop is to the left. let inward = Vec2::new(-d.y, d.x); match panel { None => { let pts = self.base.outer.ccw().polyline(1e-3); let cut = clip(&pts, a + inward * by, inward); if cut.len() < 3 { return Err(DocError::Invalid("the flange's setback removes the whole panel".into())); } self.base = Region2 { outer: Loop2::polygon(&cut).ccw(), holes: self.base.holes.clone() }; } Some(i) => { // A flange panel: its far edge shortens the leg; side edges are not trimmed. let Some(f) = self.flanges.get_mut(i) else { return Err(DocError::Invalid("flange".into())) }; if inward.dot(f.n * -1.0) > 0.99 { f.leg -= by; if f.leg < -1e-9 { return Err(DocError::Invalid("the flange's setback is longer than the panel".into())); } } else { return Err(DocError::Invalid("not supported yet: a flange on the side of another flange".into())); } } } Ok(()) } /// Add an edge flange on the panel edge nearest `pick` (world): `height` to the outer face /// from the virtual sharp, `angle` (0…180°), inside / outside / middle bend position. pub fn add_flange(&mut self, pick: Vec3, height: f64, angle: f64, radius: f64, position: &str, flip: bool) -> Result { self.add_flanges(&[pick], height, angle, radius, position, flip)?.into_iter().next().ok_or_else(|| DocError::Invalid("flange".into())) } /// Edge flanges on several edges at once: every edge is trimmed back first, so flanges on /// adjacent edges stop at each other's bends (the corners stay open). pub fn add_flanges(&mut self, picks: &[Vec3], height: f64, angle: f64, radius: f64, position: &str, flip: bool) -> Result> { if !(angle > 1e-6 && angle <= std::f64::consts::PI - 1e-6) { return Err(DocError::Invalid("the flange angle must be between 0 and 180°".into())); } if picks.is_empty() || picks.len() > 1000 { return Err(DocError::Invalid("pick 1…1000 edges".into())); } let sharp = (radius + self.t) * (angle / 2.0).tan(); let setback = match position { "inside" | "" => sharp, "outside" => 0.0, "middle" | "mid" => sharp / 2.0, other => return Err(DocError::Invalid(format!("unknown bend position `{other}` (inside, outside or middle)"))), }; let leg = height - sharp; if leg < -1e-9 { return Err(DocError::Invalid(format!("the flange height must be at least {sharp:.3} mm for this bend"))); } // Find every edge before anything moves. let mut found = Vec::new(); for p in picks { let (panel, a, b, top, dist) = self.edge_at(*p).ok_or_else(|| DocError::Invalid("no sheet edge there".into()))?; if dist > self.t * 2.0 + 1e-3 { return Err(DocError::Invalid("pick an edge of the sheet's top or bottom face".into())); } let d = (b - a).normalized().ok_or_else(|| DocError::Invalid("edge".into()))?; found.push((panel, a, d, top)); } for (panel, a, d, _) in &found { self.trim(*panel, *a, *a + *d, setback)?; } let mut made = Vec::new(); for (panel, a, d, top) in found { let outward = Vec2::new(d.y, -d.x); let line_o = a - outward * setback; // The trimmed edge: along d, on the moved line. let (start, len) = match panel { None => { let pts = self.base.outer.ccw().polyline(1e-3); let m = pts.len(); (0..m) .filter_map(|k| { let (p0, p1) = (*pts.get(k)?, *pts.get((k + 1) % m)?); let e = p1 - p0; let on = (p0 - line_o).dot(outward).abs() < 1e-6 && (p1 - line_o).dot(outward).abs() < 1e-6; (on && e.dot(d) > 0.0).then_some((p0, e.len())) }) .next() .ok_or_else(|| DocError::Invalid("the flange's edge was trimmed away".into()))? } Some(i) => { let f = self.flanges.get(i).ok_or_else(|| DocError::Invalid("flange".into()))?; let u0 = (line_o - f.p).dot(f.d).clamp(0.0, f.len); (f.p + f.n * (self.allowance(f) + f.leg) + f.d * u0, f.len) } }; let up = top != flip; self.flanges.push(SheetFlange { parent: panel, p: snap(start), d, len, n: outward, angle: if up { angle } else { -angle }, radius, leg: leg.max(0.0), shape: None, tag: String::new(), ext: (0.0, 0.0), }); made.push(self.flanges.len() - 1); } self.close_corners(&made); Ok(made) } /// Where two new 90° flanges on the base meet at a corner, run their panels on toward each /// other until the gap between their inner corners is the rule's gap. fn close_corners(&mut self, made: &[usize]) { let ends: Vec<(usize, bool, Vec2)> = made .iter() .filter_map(|&i| self.flanges.get(i).map(|f| (i, f))) .filter(|(_, f)| f.parent.is_none() && (f.angle.abs() - std::f64::consts::FRAC_PI_2).abs() < 1e-9) .flat_map(|(i, f)| [(i, false, f.p), (i, true, f.p + f.d * f.len)]) .collect(); for &(i, at_end, c) in &ends { let Some(f) = self.flanges.get(i).cloned() else { continue }; let other = ends.iter().find(|&&(j, _, c2)| j != i && c2.dist(c) < 1e-6).and_then(|&(j, _, _)| self.flanges.get(j).cloned()); let Some(g) = other else { continue }; if g.n.dot(f.n).abs() > 1e-6 || (g.angle > 0.0) != (f.angle > 0.0) { continue; } // The neighbour's setback: its bend starts that far in from the corner of the outer faces. let setback = (g.radius + self.t) * (g.angle.abs() / 2.0).tan(); let ext = setback - (self.t + self.gap / std::f64::consts::SQRT_2); if ext <= 1e-9 { continue; } if let Some(x) = self.flanges.get_mut(i) { if at_end { x.ext.1 = ext; } else { x.ext.0 = ext; } } } } /// A flat hem: a 180° bend with a tiny radius at the panel edge, `length` to its outer face. pub fn add_hem(&mut self, pick: Vec3, length: f64, gap: f64, flip: bool) -> Result { let (panel, a, b, top, dist) = self.edge_at(pick).ok_or_else(|| DocError::Invalid("no sheet edge there".into()))?; if dist > self.t * 2.0 + 1e-3 { return Err(DocError::Invalid("pick an edge of the sheet".into())); } let d = (b - a).normalized().ok_or_else(|| DocError::Invalid("edge".into()))?; let outward = Vec2::new(d.y, -d.x); let leg = length - (gap + self.t); if leg < 0.0 { return Err(DocError::Invalid("the hem is shorter than its bend".into())); } // A hem folds back over the face it starts from (toward the bottom unless flipped). let up = top != flip; let up = !up; self.flanges.push(SheetFlange { parent: panel, p: a, d, len: a.dist(b), n: outward, angle: if up { std::f64::consts::PI } else { -std::f64::consts::PI }, radius: gap, leg, ext: (0.0, 0.0), shape: None, tag: String::new(), }); Ok(self.flanges.len() - 1) } /// Fold a panel (the base or a flange's) along a line on its top or bottom face (world /// points `a`, `b`). The bend zone (width BA = θ·(R + K·T)) is taken out of the existing /// sheet, so the flat pattern keeps its size; `position` puts the line at the zone's centre /// (`centerline`), start or end (`start`/`end`, from the fixed side), or on the mould line /// (`mould`: where the outer faces meet, so the fixed side measures the same to it folded as /// flat). On the base the side holding `fixed` (default: the larger side) stays put; on a /// flange the side toward its own bend does. `flip` folds the other way. #[allow(clippy::too_many_arguments)] pub fn add_fold(&mut self, a: Vec3, b: Vec3, angle: f64, radius: f64, position: &str, flip: bool, fixed: Option) -> Result { if !(angle > 1e-6 && angle <= std::f64::consts::PI - 1e-6) { return Err(DocError::Invalid("the fold angle must be between 0 and 180°".into())); } if !(radius >= 0.0) { return Err(DocError::Invalid("the bend radius must not be negative".into())); } // The panel the line lies on: both ends on its top or bottom face, the middle inside it. let on_face = |q: Vec3| q.z.abs() < 1e-4 || (q.z - self.t).abs() < 1e-4; let mut found = None; for (pi, r) in self.panels() { let Some(inv) = mat_inverse(&self.world(pi)) else { continue }; let (fa, fb) = (apply_point(&inv, a), apply_point(&inv, b)); if !(on_face(fa) && on_face(fb) && (fa.z - fb.z).abs() < 1e-4) { continue; } let pts = dedup(&r.outer.ccw().polyline(1e-3)); let (a2, b2) = (Vec2::new(fa.x, fa.y), Vec2::new(fb.x, fb.y)); if point_in(&pts, (a2 + b2) * 0.5) || polys_cross(&pts, &[a2, b2, (a2 + b2) * 0.5]) { found = Some((pi, inv, a2, b2, pts)); break; } } let (panel, inv, a2, b2, pts) = found.ok_or_else(|| DocError::Invalid("the fold line must lie on a flat face of the sheet".into()))?; let d = (b2 - a2).normalized().ok_or_else(|| DocError::Invalid("the fold line has no length".into()))?; let mut n = Vec2::new(-d.y, d.x); let l0 = a2.dot(n); // Which side moves. A flange keeps the side its own bend is on. let on_line = n * l0; let side_area = |m: Vec2| signed_area(&clip(&pts, on_line, m)).abs(); let moves_n = match (panel, fixed) { (Some(j), _) => { let f = self.flanges.get(j).ok_or_else(|| DocError::Invalid("flange".into()))?; let ba = self.allowance(f); // The panel's attaching edge must stay whole on the fixed side. let (e0, e1) = (f.p + f.n * ba - f.d * f.ext.0, f.p + f.n * ba + f.d * (f.len + f.ext.1)); let (s0, s1) = (e0.dot(n) - l0, e1.dot(n) - l0); if s0 * s1 < -1e-12 { return Err(DocError::Invalid("a fold on a flange must run across it, clear of the flange's own bend".into())); } (s0 + s1) < 0.0 } (None, Some(p)) => { let q = apply_point(&inv, p); Vec2::new(q.x, q.y).dot(n) < l0 } (None, None) => side_area(n) <= side_area(n * -1.0), }; if !moves_n { n = n * -1.0; } let l = a2.dot(n); let probe = SheetFlange { parent: panel, p: a2, d, len: 1.0, n, angle, radius, leg: 0.0, ext: (0.0, 0.0), shape: None, tag: String::new() }; let ba = self.allowance(&probe); let s0 = match position { "centerline" | "center" | "centre" => l - ba / 2.0, "start" => l, "end" => l - ba, "mould" | "mold" => l - (radius + self.t) * (angle / 2.0).tan(), o => return Err(DocError::Invalid(format!("unknown fold position `{o}` (centerline, start, end or mould)"))), }; let fixed_part = clip(&pts, n * s0, n * -1.0); let moving = clip(&pts, n * (s0 + ba), n); let strip = clip(&clip(&pts, n * s0, n), n * (s0 + ba), n * -1.0); if signed_area(&fixed_part).abs() < 1e-9 || signed_area(&moving).abs() < 1e-9 { return Err(DocError::Invalid("the fold line must cross the sheet with material on both sides of the bend".into())); } // The bend zone must be a rectangle across the panel (a slanted or curved edge would // run across the bend as a helix, which the sheet's faces can't carry yet). let us: Vec = strip.iter().map(|q| q.dot(d)).collect(); let (u0, u1) = (us.iter().cloned().fold(f64::MAX, f64::min), us.iter().cloned().fold(f64::MIN, f64::max)); if ((u1 - u0) * ba - signed_area(&strip).abs()).abs() > 1e-6 * (u1 - u0).max(1.0) * ba.max(1.0) { return Err(DocError::Invalid("not supported yet: folds whose bend zone crosses a slanted or curved edge of the sheet".into())); } let up = !flip; let leg = moving.iter().map(|q| q.dot(n) - s0 - ba).fold(0.0, f64::max); let p0 = n * s0 + d * u0; let ccw = |mut v: Vec| { if signed_area(&v) < 0.0 { v.reverse(); } v.into_iter().map(snap).collect::>() }; match panel { None => self.base = Region2 { outer: Loop2::polygon(&fixed_part).ccw(), holes: self.base.holes.clone() }, Some(j) => { if let Some(f) = self.flanges.get_mut(j) { f.shape = Some(ccw(fixed_part.clone())); } } } self.flanges.push(SheetFlange { parent: panel, p: snap(p0), d, len: u1 - u0, n, angle: if up { angle } else { -angle }, radius, leg, ext: (0.0, 0.0), shape: Some(ccw(moving)), tag: String::new(), }); let i = self.flanges.len() - 1; // Flanges on the moving part's edges now hang off the fold and move with it. for k in 0..i { let Some(f) = self.flanges.get(k) else { continue }; if f.parent != panel { continue; } let (e0, e1) = ((f.p - p0).dot(n), (f.p + f.d * f.len - p0).dot(n)); if e0.min(e1) >= ba - 1e-9 { if let Some(f) = self.flanges.get_mut(k) { f.parent = Some(i); } } else if e0.max(e1) > 1e-9 { return Err(DocError::Invalid("not supported yet: a fold through a flange's bend".into())); } } // Cut-outs may cross the bend where their edges run along or across it (the faces // check the rest when the sheet is built). Ok(i) } /// Name a panel edge (flat coordinates a→b on `panel`, top or bottom face): base edges by the /// base sketch line they lie on, flange edges by the flange (its tag) and side. fn edge_name_of(&self, panel: Option, a: Vec2, b: Vec2, top: bool) -> Option { let face = if top { "top" } else { "bottom" }; let d = (b - a).normalized()?; match panel { None => { let on = |p: Vec2, s: Vec2, e: Vec2| { let t = e - s; t.len() > 1e-9 && ((p - s).cross(t) / t.len()).abs() < 1e-6 }; self.base_curves.iter().find(|(_, s, e)| on(a, *s, *e) && on(b, *s, *e)).map(|(id, _, _)| format!("base:{id}:{face}")) } Some(i) => { let f = self.flanges.get(i)?; if f.tag.is_empty() { return None; } let side = if d.cross(f.d).abs() < 1e-6 { // Along the bend: the far edge (or the bend side, never picked). if ((a - f.p).dot(f.n)) > self.allowance(f) + 1e-6 { "far" } else { "bend" } } else if ((a + b) * 0.5 - f.p).dot(f.d) < f.len / 2.0 { "side0" } else { "side1" }; Some(format!("{}:{side}:{face}", f.tag)) } } } /// The persistent name of the sheet edge nearest a world point. pub fn edge_name(&self, pick: Vec3) -> Option { let (panel, a, b, top, _) = self.edge_at(pick)?; self.edge_name_of(panel, a, b, top) } /// A world point on the sheet edge with this name (the one nearest `near` if several). pub fn edge_by_name(&self, name: &str, near: Vec3) -> Option { let mut best: Option<(f64, Vec3)> = None; for (pi, r) in self.panels() { let pts = r.outer.ccw().polyline(1e-3); let m = pts.len(); let w = self.world(pi); for i in 0..m { let (Some(&a), Some(&b)) = (pts.get(i), pts.get((i + 1) % m)) else { continue }; if a.dist(b) < 1e-9 { continue; } for (z, top) in [(0.0, false), (self.t, true)] { if self.edge_name_of(pi, a, b, top).as_deref() == Some(name) { let mid = (a + b) * 0.5; let q = apply_point(&w, Vec3::new(mid.x, mid.y, z)); if best.is_none_or(|(d, _)| q.dist(near) < d) { best = Some((q.dist(near), q)); } } } } } best.map(|(_, q)| q) } /// Map a region in a world plane to flat coordinates through a panel's placement. pub fn region_to_flat(&self, panel: Option, plane: &Plane, r: &Region2) -> Result { region_complexity(std::slice::from_ref(r))?; let inv = mat_inverse(&self.world(panel)).ok_or_else(|| DocError::Invalid("invalid sheet panel frame".into()))?; let map = |p: Vec2| { let q = apply_point(&inv, plane.to_world(p)); Vec2::new(q.x, q.y) }; // A rigid map of the plane: lines and arcs stay lines and arcs (mirrored or not). let (o, ex, ey) = (map(Vec2::default()), map(Vec2::new(1.0, 0.0)), map(Vec2::new(0.0, 1.0))); let (ax, ay) = (ex - o, ey - o); let mirrored = ax.cross(ay) < 0.0; let rot = ax.y.atan2(ax.x); let seg = |sg: &Seg2| -> Seg2 { match *sg { Seg2::Line { a, b } => Seg2::Line { a: map(a), b: map(b) }, Seg2::Arc { center, radius, start, sweep } => { if mirrored { Seg2::Arc { center: map(center), radius, start: rot - start, sweep: -sweep } } else { Seg2::Arc { center: map(center), radius, start: rot + start, sweep } } } other => Seg2::Line { a: map(other.start()), b: map(other.end()) }, } }; 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::>()?, }) } /// Panels whose plane is parallel to `plane` (world): (panel, region). pub fn parallel_panels(&self, plane: &Plane) -> Vec<(Option, Region2)> { self.panels() .into_iter() .filter(|(pi, _)| { let w = self.world(*pi); let n = apply_vector(&w, Vec3::Z); n.cross(plane.normal()).len() < 1e-6 }) .collect() } pub(crate) fn material_panels(&self, plane: Option<&Plane>, work: &mut FlatWork) -> Result, Region2)>> { Ok(self .cells_with_work(work)? .into_iter() .filter_map(|c| { let Piece::Panel(pi) = c.piece else { return None; }; if plane.is_some_and(|p| apply_vector(&self.world(pi), Vec3::Z).cross(p.normal()).len() > 1e-6) { return None; } Some((pi, Region2 { outer: Loop2 { segs: c.outer }, holes: c.holes.into_iter().map(|segs| Loop2 { segs }).collect() })) }) .collect()) } } fn point_in(poly: &[Vec2], p: Vec2) -> bool { let mut inside = false; let m = poly.len(); for i in 0..m { let (Some(&a), Some(&b)) = (poly.get(i), poly.get((i + m - 1) % m)) else { continue }; if (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x { inside = !inside; } } inside } /// The boundary of a union of counter-clockwise polygons that meet edge to edge: edges are /// split where corners touch them, edges running both ways cancel, the rest chain into loops /// (outer loops counter-clockwise, holes clockwise; straight runs merged). fn merge_loops(polys: &[Vec], work: &mut FlatWork) -> Result>> { let n = flat_points(polys)?; work.charge(n.saturating_mul(n).saturating_mul(2))?; let mut segs: Vec<(Vec2, Vec2)> = Vec::new(); for p in polys { let m = p.len(); for i in 0..m { if let (Some(&a), Some(&b)) = (p.get(i), p.get((i + 1) % m)) { segs.push((a, b)); } } } let pts: Vec = segs.iter().flat_map(|(a, b)| [*a, *b]).collect(); let mut split: Vec<(Vec2, Vec2)> = Vec::new(); for (a, b) in &segs { let d = *b - *a; let l2 = d.len2(); if l2 < 1e-18 { continue; } let mut ts: Vec = pts .iter() .filter_map(|p| { let t = (*p - *a).dot(d) / l2; ((t > 1e-9 && t < 1.0 - 1e-9) && (*a + d * t).dist(*p) < 1e-7).then_some(t) }) .collect(); ts.push(0.0); ts.push(1.0); ts.sort_by(|x, y| x.total_cmp(y)); ts.dedup_by(|x, y| (*x - *y).abs() < 1e-12); for w in ts.windows(2) { if split.len() >= MAX_FLAT_POINTS { return Err(flat_budget_error()); } split.push((*a + d * w[0], *a + d * w[1])); } } work.charge(split.len().saturating_mul(split.len()).saturating_mul(2))?; let same = |p: Vec2, q: Vec2| p.dist(q) < 1e-6; let keep: Vec<(Vec2, Vec2)> = split .iter() .enumerate() .filter(|(i, (a, b))| !split.iter().enumerate().any(|(j, (c, d))| j != *i && same(*a, *d) && same(*b, *c))) .map(|(_, s)| *s) .collect(); let mut loops = Vec::new(); let mut used = vec![false; keep.len()]; for s0 in 0..keep.len() { if used.get(s0).copied().unwrap_or(true) { continue; } let mut lp = Vec::new(); let mut cur = s0; for _ in 0..keep.len() + 1 { if let Some(u) = used.get_mut(cur) { *u = true; } let Some(&(a, b)) = keep.get(cur) else { break }; lp.push(a); match (0..keep.len()).find(|j| !used.get(*j).copied().unwrap_or(true) && keep.get(*j).is_some_and(|(c, _)| same(*c, b))) { Some(n) => cur = n, None => break, } } let m = lp.len(); let clean: Vec = (0..m) .filter_map(|i| { let (p, q, r) = (*lp.get((i + m - 1) % m)?, *lp.get(i)?, *lp.get((i + 1) % m)?); ((q - p).cross(r - q).abs() > 1e-9 * (q - p).len().max(1.0) * (r - q).len().max(1.0)).then_some(q) }) .collect(); if clean.len() >= 3 { loops.push(clean); } } Ok(loops) } fn signed_area(p: &[Vec2]) -> f64 { let m = p.len(); (0..m).filter_map(|i| Some(p.get(i)?.cross(*p.get((i + 1) % m)?))).sum::() / 2.0 } /// Do two polygons' edges cross? fn polys_cross(a: &[Vec2], b: &[Vec2]) -> bool { let seg = |p: &[Vec2], i: usize| -> Option<(Vec2, Vec2)> { Some((*p.get(i)?, *p.get((i + 1) % p.len())?)) }; (0..a.len()).filter_map(|i| seg(a, i)).any(|(p, q)| { (0..b.len()).filter_map(|j| seg(b, j)).any(|(r, s)| { let d1 = (q - p).cross(r - p); let d2 = (q - p).cross(s - p); let d3 = (s - r).cross(p - r); let d4 = (s - r).cross(q - r); d1 * d2 < -1e-12 && d3 * d4 < -1e-12 }) }) } /// Bounded overlap predicate, including crossing-only cuts whose vertices lie outside a panel. pub(crate) fn flat_regions_overlap(a: &Region2, b: &Region2, work: &mut FlatWork) -> Result { let (a, b) = (planar_region(a)?, planar_region(b)?); Ok(!material_boolean(&[a], &[b], kernel::BoolOp::Intersect, work)?.is_empty()) } /// A sheet from a closed profile (base flange): the region in `plane`, thickness along its normal. pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region: Region2) -> SheetBody { let holes = region.holes.into_iter().map(|h| Region2 { outer: h.ccw(), holes: Vec::new() }).collect(); SheetBody { body: body.into(), rule: rule.into(), t: rv.thickness, k: rv.k_factor, frame: plane, base: Region2 { outer: region.outer, holes: Vec::new() }, flanges: Vec::new(), holes, pockets: Vec::new(), flat: false, gap: rv.gap, base_curves: Vec::new(), } } /// A sheet from an open chain of lines (contour flange): `pts` in `plane`, the material on the /// right of the travel (or the left when `flip`), `width` along the plane normal. pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pts: &[Vec2], width: f64, flip: bool) -> Result { if pts.len() < 2 { return Err(DocError::Invalid("a contour flange needs an open chain of lines".into())); } let (t, r) = (rv.thickness, rv.bend_radius); let seg_dir = |i: usize| -> Result<(Vec2, f64)> { let (Some(a), Some(b)) = (pts.get(i), pts.get(i + 1)) else { return Err(DocError::Invalid("chain".into())) }; let l = a.dist(*b); Ok(((*b - *a).normalized().ok_or_else(|| DocError::Invalid("zero-length line in the contour".into()))?, l)) }; let side = if flip { -1.0 } else { 1.0 }; // Material side in the sketch: right of travel. let (d0, _) = seg_dir(0)?; let m0 = Vec2::new(d0.y, -d0.x) * side; let start = pts.first().copied().unwrap_or_default(); let (x, z) = (plane.dir_to_world(d0), plane.dir_to_world(m0)); let frame = Plane::new(plane.to_world(start), x, z.cross(x)).ok_or_else(|| DocError::Invalid("contour frame".into()))?; // Width along the sketch normal: +y or −y of the frame. let ysign = if frame.y.dot(plane.normal()) >= 0.0 { 1.0 } else { -1.0 }; let n = pts.len() - 1; // Turn at each corner: angle and whether it bends toward the material (up). let mut corners = Vec::new(); for i in 0..n.saturating_sub(1) { let (da, _) = seg_dir(i)?; let (db, _) = seg_dir(i + 1)?; let cross = da.cross(db) * side; let ang = da.dot(db).clamp(-1.0, 1.0).acos(); if ang < 1e-9 { return Err(DocError::Invalid("straight corners in a contour flange".into())); } // Right-hand material: turning right (negative cross) bends toward it. corners.push((ang, cross < 0.0)); } let setback = |ang: f64, up: bool| if up { (r + t) * (ang / 2.0).tan() } else { r * (ang / 2.0).tan() }; let mut sheet = SheetBody { body: body.into(), rule: rule.into(), t, k: rv.k_factor, frame, base: Region2 { outer: Loop2 { segs: Vec::new() }, holes: Vec::new() }, flanges: Vec::new(), holes: Vec::new(), pockets: Vec::new(), flat: false, gap: rv.gap, base_curves: Vec::new(), }; let (y0, y1) = if ysign > 0.0 { (0.0, width) } else { (-width, 0.0) }; // First segment: the base panel. let (_, l0) = seg_dir(0)?; let end0 = l0 - corners.first().map(|(a, u)| setback(*a, *u)).unwrap_or(0.0); if end0 <= 0.0 { return Err(DocError::Invalid("a contour line is shorter than its bends".into())); } sheet.base = rect(Vec2::new(0.0, y0), Vec2::new(end0, y0), Vec2::new(end0, y1), Vec2::new(0.0, y1)); let mut parent: Option = None; let mut at = end0; for (i, (ang, up)) in corners.iter().enumerate() { let (_, l) = seg_dir(i + 1)?; let before = setback(*ang, *up); let after = corners.get(i + 1).map(|(a, u)| setback(*a, *u)).unwrap_or(0.0); let leg = l - before - after; if leg < -1e-9 { return Err(DocError::Invalid("a contour line is shorter than its bends".into())); } // The material flips sides relative to the panel when the bend goes down. let f = SheetFlange { parent, p: Vec2::new(at, y0), d: Vec2::new(0.0, 1.0), len: y1 - y0, n: Vec2::new(1.0, 0.0), angle: if *up { *ang } else { -*ang }, radius: r, leg: leg.max(0.0), ext: (0.0, 0.0), shape: None, tag: String::new(), }; let ba = sheet.allowance(&f); sheet.flanges.push(f); parent = Some(sheet.flanges.len() - 1); at += ba + leg.max(0.0); } Ok(sheet) } /// Persistent names of the sheet edges nearest each world point (empty when none). pub fn sheet_edge_names(st: &crate::ModelState, pts: &[Vec3]) -> Vec { pts.iter() .map(|p| { st.sheets .iter() .filter_map(|sh| sh.edge_at(*p).map(|e| (e.4, sh))) .min_by(|a, b| a.0.total_cmp(&b.0)) .and_then(|(_, sh)| sh.edge_name(*p)) .unwrap_or_default() }) .collect() } #[cfg(test)] mod tests { use super::*; fn steel() -> RuleValues { RuleValues { thickness: 2.5, k_factor: 0.44, bend_radius: 2.5, relief_width: 2.5, relief_depth: 1.25, corner_relief: 10.0, hem_gap: 0.01, gap: 2.5, } } fn plate(w: f64, h: f64) -> SheetBody { let r = rect(Vec2::new(0.0, 0.0), Vec2::new(w, 0.0), Vec2::new(w, h), Vec2::new(0.0, h)); base_flange( "Body1", "Steel (mm)", &steel(), Plane::new(Vec3::ZERO, Vec3::X, Vec3::Y) .unwrap_or_else(|| Plane::named("XY").unwrap_or(Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Y })), r, ) } fn vol(b: &Body) -> f64 { kernel::measure(b).map(|m| m.volume).unwrap_or(f64::NAN) } #[test] fn edge_flange_matches_fusion() { // Oracle 68: 100 x 60 plate, 20 mm flange on the y = 0 top edge. 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 b = s.solid().unwrap(); assert!((vol(&b) - 18972.622).abs() < 2.0, "{}", vol(&b)); let (w, h) = s.flat_size().unwrap(); assert!((w - 100.0).abs() < 1e-9 && (h - 75.654867).abs() < 1e-5, "{w} {h}"); let bends = s.bends(); assert_eq!(bends.len(), 1); assert!((bends[0].allowance - std::f64::consts::FRAC_PI_2 * 3.6).abs() < 1e-9); // Unfolded: the same volume, flat. s.flat = true; let f = s.solid().unwrap(); assert!((vol(&f) - 100.0 * 75.654867 * 2.5).abs() < 2.0); // Oracle 70: 45°, height 15. let mut s = plate(100.0, 60.0); s.add_flange(Vec3::new(50.0, 0.0, 2.5), 15.0, std::f64::consts::FRAC_PI_4, 2.5, "inside", false).unwrap(); assert!((vol(&s.solid().unwrap()) - 18450.777).abs() < 2.0); assert!((s.flat_size().unwrap().1 - 73.685298).abs() < 1e-5); } #[test] fn contour_flange_matches_fusion() { // Oracle 72: XZ chain (0,0)→(40,0)→(40,−30)→(70,−30), 50 wide. let xz = Plane::new(Vec3::ZERO, Vec3::X, Vec3::new(0.0, 0.0, -1.0)).unwrap(); let pts = [Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0), Vec2::new(40.0, -30.0), Vec2::new(70.0, -30.0)]; let s = contour_flange("Body1", "Steel (mm)", &steel(), &xz, &pts, 50.0, false).unwrap(); let b = s.solid().unwrap(); assert!((vol(&b) - 12097.622).abs() < 1.2, "{}", vol(&b)); let m = kernel::measure(&b).unwrap(); assert!((m.bbox.max.z - 32.5).abs() < 1e-6 && (m.bbox.max.y - 50.0).abs() < 1e-6, "{:?}", m.bbox); assert!((s.flat_size().unwrap().0 - 96.309734).abs() < 1e-5, "{:?}", s.flat_size()); } #[test] fn hem_and_holes() { // Oracle 73: flange then a flat hem of 8 on its top edge. 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(); s.add_hem(Vec3::new(50.0, 0.0, 20.0), 8.0, 0.01, false).unwrap(); let b = s.solid().unwrap(); assert!((vol(&b) - 21334.723).abs() < 2.0, "{}", vol(&b)); assert!((s.flat_size().unwrap().1 - 84.632035).abs() < 1e-4, "{:?}", s.flat_size()); // A hole in the base and a slot across the bend. let mut s = plate(80.0, 50.0); s.add_flange(Vec3::new(40.0, 0.0, 2.5), 20.0, std::f64::consts::FRAC_PI_2, 2.5, "inside", false).unwrap(); s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(40.0, 30.0), 3.0), holes: vec![] }); let v0 = vol(&s.solid().unwrap()); s.holes.push(rect(Vec2::new(35.0, -12.0), Vec2::new(45.0, -12.0), Vec2::new(45.0, 10.0), Vec2::new(35.0, 10.0))); let v1 = vol(&s.solid().unwrap()); let ba = std::f64::consts::FRAC_PI_2 * 3.6; let want = 10.0 * 2.5 * (22.0 - ba) + 10.0 * std::f64::consts::FRAC_PI_2 * (2.5 + 1.25) * 2.5; assert!((v0 - v1 - want).abs() < 1.0, "{v0} {v1} {want}"); s.flat = true; assert!(s.solid().is_ok()); } #[test] fn disconnected_material_keeps_each_hole_with_its_own_region() { let mut s = plate(100.0, 60.0); s.holes.push(rect(Vec2::new(45.0, -5.0), Vec2::new(55.0, -5.0), Vec2::new(55.0, 65.0), Vec2::new(45.0, 65.0))); s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 30.0), 5.0), holes: vec![] }); let regions = s.flat_regions().unwrap(); assert_eq!(regions.len(), 2); let left = regions.iter().find(|r| r.outer.contains(Vec2::new(10.0, 10.0))).unwrap(); let right = regions.iter().find(|r| r.outer.contains(Vec2::new(90.0, 10.0))).unwrap(); assert_eq!(left.holes.len(), 1); assert!(left.holes[0].segs.iter().all(|s| matches!(s, Seg2::Arc { radius, .. } if (*radius - 5.0).abs() < 1e-9))); assert!(right.holes.is_empty()); let area: f64 = regions.iter().map(Region2::area).sum(); assert!((area - (5400.0 - std::f64::consts::PI * 25.0)).abs() < 1e-6); assert!((vol(&s.flat_solid().unwrap()) - area * 2.5).abs() < 0.1); } #[test] fn ring_cut_retains_exact_central_material_island() { let mut s = plate(100.0, 60.0); s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(50.0, 30.0), 10.0), holes: vec![Loop2::circle(Vec2::new(50.0, 30.0), 5.0)] }); let regions = s.flat_regions().unwrap(); assert_eq!(regions.len(), 2); let island = regions.iter().find(|r| r.outer.contains(Vec2::new(50.0, 30.0))).unwrap(); assert!(island.holes.is_empty()); assert!(island.outer.segs.iter().all(|s| matches!(s, Seg2::Arc { radius, .. } if (*radius - 5.0).abs() < 1e-9))); let area: f64 = regions.iter().map(Region2::area).sum(); assert!((area - (6000.0 - std::f64::consts::PI * 75.0)).abs() < 1e-6); assert!((vol(&s.flat_solid().unwrap()) - area * 2.5).abs() < 0.1); } #[test] fn freeform_profile_uses_bounded_sampling_with_exact_round_holes() { let mut s = plate(100.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) }, 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 { .. }))); 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. 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); } #[test] fn flat_geometry_budgets_reject_aggregate_and_intermediate_work() { let mut s = plate(100.0, 60.0); // Each arc is individually legal, but their combined sampled points exceed the cap. s.base.outer.segs = vec![Seg2::Arc { center: Vec2::ZERO, radius: 1000.0, start: 0.0, sweep: std::f64::consts::TAU }; 8]; assert!(s.flat_regions().unwrap_err().to_string().contains("budget")); let mut s = plate(100.0, 60.0); s.holes = vec![Region2 { outer: Loop2::circle(Vec2::new(20.0, 20.0), 1.0), holes: vec![] }; MAX_FLAT_PIECES + 1]; assert!(s.flat_regions().unwrap_err().to_string().contains("budget")); let p = vec![vec![Vec2::ZERO; MAX_FLAT_POINTS]]; assert!(merge_loops(&p, &mut FlatWork::new()).unwrap_err().to_string().contains("budget")); // Exhausting a clipping/merge allowance must report an error before work begins. assert!(merge_loops(&[vec![Vec2::ZERO, Vec2::X, Vec2::Y]], &mut FlatWork(0)).is_err()); } #[test] fn overlapping_round_cutouts_union_with_exact_arc_boundaries() { let mut s = plate(100.0, 60.0); s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 30.0), 5.0), holes: vec![] }); s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(22.0, 30.0), 5.0), holes: vec![] }); let regions = s.flat_regions().unwrap(); assert_eq!(regions[0].holes.len(), 1); assert!(regions[0].holes[0].segs.iter().all(|s| matches!(s, Seg2::Arc { radius, .. } if (*radius - 5.0).abs() < 1e-9))); let intersection = 50.0 * (0.2f64).acos() - (96.0f64).sqrt(); let area = 6000.0 - (50.0 * std::f64::consts::PI - intersection); assert!((regions[0].area() - area).abs() < 1e-6); assert!((vol(&s.solid().unwrap()) - area * 2.5).abs() < 0.1); } #[test] fn nested_and_repeated_round_cutouts_keep_only_the_largest_exact_boundary() { for reverse in [false, true] { let mut s = plate(100.0, 60.0); let mut radii = vec![3.0, 5.0, 5.0]; if reverse { radii.reverse(); } for radius in radii { s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 30.0), radius), holes: vec![] }); } let regions = s.flat_regions().unwrap(); assert_eq!(regions[0].holes.len(), 1); assert!((regions[0].area() - (6000.0 - std::f64::consts::PI * 25.0)).abs() < 1e-6); let volume = vol(&s.solid().unwrap()); // Body measure tessellates curved faces; the exact region area above is the // stronger analytic check, while this follows the existing sheet measure budget. assert!((volume - (6000.0 - std::f64::consts::PI * 25.0) * 2.5).abs() < 0.1, "{volume}"); assert_eq!(s.round_hole_correction(), (-1, -4, -2)); } } #[test] fn sewing_budget_rejects_edge_splitting_growth_before_allocating_faces() { let s = plate(100.0, 60.0); let cells: Vec = (0..250) .map(|i| Cell { piece: Piece::Panel(None), outer: Loop2::polygon(&[Vec2::new(i as f64, 0.0), Vec2::new(250.0, 0.0), Vec2::new(i as f64, 1.0)]).segs, holes: vec![], }) .collect(); assert!(s.faces(&cells, 0.0, 2.5, &mut FlatWork::new()).unwrap_err().to_string().contains("budget")); assert!(s.faces(&cells[..1], 0.0, 2.5, &mut FlatWork(0)).unwrap_err().to_string().contains("budget")); } #[test] fn pocket_geometry_and_depth_budgets_are_checked_before_replay() { let pocket = SheetPocket { region: rect(Vec2::new(20.0, 20.0), Vec2::new(30.0, 20.0), Vec2::new(30.0, 30.0), Vec2::new(20.0, 30.0)), z0: 0.0, z1: 1.0, }; let mut s = plate(100.0, 60.0); s.pockets = vec![pocket.clone(); 1001]; assert!(s.flat_solid().unwrap_err().to_string().contains("budget")); s.pockets = vec![pocket.clone()]; s.pockets[0].region.outer.segs = vec![Seg2::Line { a: Vec2::ZERO, b: Vec2::X }; MAX_FLAT_POINTS + 1]; assert!(s.flat_solid().unwrap_err().to_string().contains("budget")); for (z0, z1) in [(f64::NAN, 1.0), (-1.0, 1.0), (0.0, 3.0), (1.0, 1.0)] { s.pockets = vec![SheetPocket { z0, z1, ..pocket.clone() }]; assert!(s.flat_solid().unwrap_err().to_string().contains("depth")); } } }