Files
OneCAD/crates/doc/src/sheet.rs
T
Jonas Wrede 9e2a1eb993 fix: faces a sheet fold moves keep their names (#535) (#585)
Faces that a sheet fold or unfold moves keep their names: points on a moved panel are taken back through the panel they lie on to where the sheet held them before the feature, and named from the faces there. Un-ignores the #535 regression test. Fixes #535.

Co-authored-by: jonaswre <jonas.wrede@beyondit.gmbh>
2026-10-13 04:28:27 -04:00

3853 lines
186 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
use solidcraft_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.
// Exact lines and arcs count once each (the planar booleans work on them whole); sampled
// curves count their 0.001 mm chords.
const MAX_FLAT_POINTS: usize = 16_384;
// The 0.001 mm chords of everything, arcs included: what the planar booleans sample (their
// own limit is the same).
const MAX_FLAT_SAMPLES: usize = 65_536;
const MAX_FLAT_PIECES: usize = 4096;
const MAX_FLAT_WORK: usize = 500_000_000;
/// Starts the warning a fold gives when its `fixed` point touches no material: a recompute keeps
/// the side the point lies on, and the `sheet.fold` command refuses such a point (#263).
pub const FOLD_FIXED_OFF_SHEET: &str = "the fold's fixed point is not on the sheet";
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)
}
pub(crate) 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<Vec<Vec2>> {
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<Vec2>]) -> Result<usize> {
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<Region2> {
if region.holes.len() > MAX_FLAT_PIECES {
return Err(flat_budget_error());
}
region_complexity(std::slice::from_ref(region))?;
let convert = |lp: &Loop2| -> Result<Loop2> {
if lp.segs.iter().all(|s| matches!(s, Seg2::Line { .. } | Seg2::Arc { .. })) {
Ok(lp.clone())
} else {
// Keep free-form profiles through controlled 0.001 mm chord sampling.
Ok(Loop2::polygon(&flat_polyline(lp)?))
}
};
Ok(Region2 {
outer: convert(&region.outer)?.ccw(),
holes: region.holes.iter().map(|h| convert(h).map(|l| l.ccw().reversed())).collect::<Result<_>>()?,
})
}
fn region_complexity(regions: &[Region2]) -> Result<(usize, usize)> {
regions_complexity(regions.iter())
}
fn regions_complexity<'a>(regions: impl Iterator<Item = &'a Region2>) -> Result<(usize, usize)> {
let (mut edges, mut points, mut samples) = (0usize, 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(&region.outer).chain(&region.holes) {
edges = edges.saturating_add(lp.segs.len());
if edges > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
for seg in &lp.segs {
let chords = seg.polyline(1e-3).len().saturating_sub(1);
points = points.saturating_add(if matches!(seg, Seg2::Line { .. } | Seg2::Arc { .. }) { 1 } else { chords });
samples = samples.saturating_add(chords);
if points > MAX_FLAT_POINTS || samples > MAX_FLAT_SAMPLES {
return Err(flat_budget_error());
}
}
}
}
Ok((edges, points))
}
pub(crate) fn material_boolean(a: &[Region2], b: &[Region2], op: kernel::BoolOp, work: &mut FlatWork) -> Result<Vec<Region2>> {
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(&regions)?;
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<Region2>, work: &mut FlatWork) -> Result<Vec<Region2>> {
region_complexity(&regions)?;
let mut groups: Vec<Vec<Region2>> = 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,
/// What is cut where two bends meet at a corner of the base: `notch` (nothing more: the
/// bends end square at the corner), `round` or `rectangular` (a relief `corner_relief`
/// across, centred on the corner the two bends share).
#[serde(default = "rule_corner_relief_shape")]
pub corner_relief_shape: 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_corner_relief_shape() -> String {
ReliefShape::Notch.name().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(),
corner_relief_shape: rule_corner_relief_shape(),
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<SheetRule>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active: Option<String>,
}
impl SheetSettings {
pub fn is_empty(&self) -> bool {
self.rules.is_empty() && self.active.is_none()
}
}
/// The shape of the relief cut where two bends meet at a corner of the base (a rule's
/// `corner_relief_shape`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ReliefShape {
/// Nothing is cut: the bends end square at the corner.
#[default]
Notch,
/// A round about the corner of the bends, through the angle the base fills there.
Round,
/// A square with a corner on the corner of the bends (a kite where the base's corner isn't
/// square: both cuts leave their edge at a right angle).
Rectangular,
}
impl ReliefShape {
pub fn parse(s: &str) -> Option<ReliefShape> {
match s.trim().to_ascii_lowercase().as_str() {
"notch" => Some(ReliefShape::Notch),
"round" => Some(ReliefShape::Round),
"rectangular" => Some(ReliefShape::Rectangular),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
ReliefShape::Notch => "notch",
ReliefShape::Round => "round",
ReliefShape::Rectangular => "rectangular",
}
}
}
/// 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 corner_relief_shape: ReliefShape,
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<String, Value>, r: &SheetRule) -> Result<RuleValues> {
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<f64> {
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)?,
corner_relief_shape: ReliefShape::parse(&r.corner_relief_shape)
.ok_or_else(|| DocError::Invalid(format!("unknown corner relief shape `{}` (notch, round or rectangular)", r.corner_relief_shape)))?,
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<usize>,
/// 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), measured
/// along the bend line; negative values cut the panel back.
#[serde(default)]
pub ext: (f64, f64),
/// How much further each end runs on per unit of panel height: a closed corner between
/// flanges that aren't at 90° ends on a slanted line in the flat.
#[serde(default, skip_serializing_if = "is_zero_pair")]
pub slope: (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<Vec<Vec2>>,
/// Which feature made it: `F<feature>.<k>` (persistent edge names).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub tag: String,
}
/// One edge flange to make: a pick on the sheet's edge, and that flange's height (to the outer
/// face, from the virtual sharp) and angle (radians).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct EdgeFlange {
pub pick: Vec3,
pub height: f64,
pub angle: f64,
}
/// 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<SheetFlange>,
/// Cut-outs (flat coordinates) through the sheet.
pub holes: Vec<Region2>,
/// 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<SheetPocket>,
/// Unfolded.
pub flat: bool,
/// Gap between flanges that meet at a corner.
pub gap: f64,
/// The relief cut where two bends meet at a corner of the base, and how far across it is
/// (the rule's `corner_relief_shape` and `corner_relief`).
pub relief: ReliefShape,
pub relief_size: 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<usize>),
Zone(usize),
}
/// A flat piece of the sheet (counter-clockwise outer loop, clockwise holes).
#[derive(Clone, Debug)]
struct Cell {
piece: Piece,
outer: Vec<Seg2>,
holes: Vec<Vec<Seg2>>,
}
fn is_zero_pair(v: &(f64, f64)) -> bool {
v.0 == 0.0 && v.1 == 0.0
}
fn rev_edge(e: EdgeSpec) -> EdgeSpec {
e.reversed()
}
/// Largest distance between a folded bend edge and the cubic chain standing in for it.
const BEND_CHAIN_TOL: f64 = 1e-7;
/// Most halvings of a bend edge's parameter while fitting its chain.
const BEND_CHAIN_DEPTH: usize = 10;
fn cubic_at(p0: Vec3, c1: Vec3, c2: Vec3, p1: Vec3, t: f64) -> Vec3 {
let u = 1.0 - t;
p0 * (u * u * u) + c1 * (3.0 * u * u * t) + c2 * (3.0 * u * t * t) + p1 * (t * t * t)
}
fn dedup(p: &[Vec2]) -> Vec<Vec2> {
let mut v: Vec<Vec2> = 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
}
/// 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 {
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> {
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<Vec2> {
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
}
/// The smallest gap closed corners are given: closer than this the flanges' ends would touch.
const MIN_CORNER_GAP: f64 = 0.01;
/// The smallest corner relief (across) that is cut: a smaller one would leave hairline edges.
const MIN_CORNER_RELIEF: f64 = 0.01;
/// Two bends on the base meeting at a corner (see [`SheetBody::corners`]): flanges `a` and `b`,
/// which end of each (false = the end at `p`), and the corner point (flat coordinates).
#[derive(Clone, Copy, Debug)]
struct Corner {
a: usize,
a_end: bool,
b: usize,
b_end: bool,
c: Vec2,
}
/// The corners of a straight-edged region (counter-clockwise).
fn corners_of(r: &Region2) -> Vec<Vec2> {
dedup(&r.outer.ccw().polyline(1e-3))
}
/// The area two convex counter-clockwise polygons share.
fn convex_overlap(a: &[Vec2], b: &[Vec2]) -> f64 {
let mut poly = a.to_vec();
let m = b.len();
for i in 0..m {
let (Some(&p), Some(&q)) = (b.get(i), b.get((i + 1) % m)) else { continue };
let d = q - p;
poly = clip(&poly, p, Vec2::new(-d.y, d.x));
if poly.len() < 3 {
return 0.0;
}
}
signed_area(&poly).abs()
}
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 + f.d * (f.slope.1 * f.leg), a + f.n * f.leg - f.d * (f.slope.0 * 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) or a corner relief cut into it, the bend stops and the flange panel runs on
/// alone.
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<usize>) -> 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 point of flange `f`'s bend zone folded onto the bend at height `z` (not clamped
/// to the zone), with its rates of change along the zone's flat `n` and `d` directions.
fn zone_point(&self, f: &SheetFlange, q: Vec2, z: f64) -> (Vec3, Vec3, Vec3) {
let s = (q - f.p).dot(f.n);
let u = (q - f.p).dot(f.d);
let neutral = (f.radius + self.k * self.t).max(1e-12);
let phi = s / neutral;
let (axis_o, k, turn) = self.bend_axis(f);
let sigma = if turn < 0.0 { -1.0 } else { 1.0 };
// From the axis to the bend's start line at height z, and that turned a right angle.
let ez = Vec3::Z * (z - axis_o.z);
let ek = k.cross(ez) * sigma;
let (sn, cs) = phi.sin_cos();
let w = self.world(f.parent);
let local = axis_o + v3(f.d) * u + ez * cs + ek * sn;
let ds = (ez * -sn + ek * cs) * (1.0 / neutral);
(apply_point(&w, local), apply_vector(&w, ds), apply_vector(&w, v3(f.d)))
}
/// A flat edge of flange `f`'s bend zone, folded at height `z`, at parameter `t`: the point
/// and its derivative.
fn zone_edge_at(&self, f: &SheetFlange, e: &Seg2, z: f64, t: f64) -> (Vec3, Vec3) {
let (p, ds, du) = self.zone_point(f, e.point_at(t), z);
let dq = e.derivative(t);
(p, ds * dq.dot(f.n) + du * dq.dot(f.d))
}
/// Where to break a bend zone's flat edge so cubic Hermite pieces follow the folded edge
/// to within [`BEND_CHAIN_TOL`] on both faces. The breaks depend on the flat edge only, so
/// the top face, bottom face and wall of a cut-out share one chain per height.
fn zone_breaks(&self, f: &SheetFlange, e: &Seg2) -> Vec<f64> {
let mut out = vec![0.0];
let mut todo = vec![(0.0, 1.0, 0usize)];
while let Some((t0, t1, depth)) = todo.pop() {
let fits = depth >= BEND_CHAIN_DEPTH
|| [0.0, self.t].iter().all(|&z| {
let h = t1 - t0;
let ((p0, d0), (p1, d1)) = (self.zone_edge_at(f, e, z, t0), self.zone_edge_at(f, e, z, t1));
let (c1, c2) = (p0 + d0 * (h / 3.0), p1 - d1 * (h / 3.0));
[0.25, 0.5, 0.75].iter().all(|&x| cubic_at(p0, c1, c2, p1, x).dist(self.zone_edge_at(f, e, z, t0 + h * x).0) <= BEND_CHAIN_TOL)
});
if fits {
out.push(t1);
} else {
let m = (t0 + t1) / 2.0;
// Depth first, right half last on the stack: breaks come out in order.
todo.push((m, t1, depth + 1));
todo.push((t0, m, depth + 1));
}
}
out
}
/// A bend zone's flat edge folded at height `z`: a chain of cubic Hermite pieces.
fn zone_chain(&self, f: &SheetFlange, e: &Seg2, z: f64) -> EdgeSpec {
let breaks = self.zone_breaks(f, e);
let mut ctrl = Vec::with_capacity(breaks.len() * 3);
for w in breaks.windows(2) {
let (t0, t1) = (w[0], w[1]);
let h = t1 - t0;
let ((p0, d0), (p1, d1)) = (self.zone_edge_at(f, e, z, t0), self.zone_edge_at(f, e, z, t1));
if ctrl.is_empty() {
ctrl.push(p0);
}
ctrl.extend([p0 + d0 * (h / 3.0), p1 - d1 * (h / 3.0), p1]);
}
EdgeSpec::Chain { ctrl: ctrl.into() }
}
/// A flat edge of a piece as a 3D edge at height `z`.
fn edge3(&self, piece: Piece, e: &Seg2, z: f64) -> Result<EdgeSpec> {
let (a, b) = (e.start(), e.end());
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), _) => {
let f = self.flanges.get(i).ok_or_else(|| DocError::Invalid("bend".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)),
}
}
(_, _) => 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<Vec<Cell>> {
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(&region))?.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(&region)?;
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(&region))?.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<Vec<FaceSpec>> {
// 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<Vec2> = 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<Vec<Seg2>> {
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<f64> = 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::<Result<_>>()?;
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<Vec<Vec<EdgeSpec>>> {
std::iter::once(&c.outer)
.chain(c.holes.iter())
.map(|l| {
let mut v: Vec<EdgeSpec> = l.iter().map(|e| self.edge3(c.piece, e, z)).collect::<Result<_>>()?;
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()))?;
// The surface must hold the whole boundary: curved cut-out edges may
// bulge past the cell's corners, so they are sampled (and the surface
// padded a little, never past the zone's own width).
let curved = c.outer.iter().any(|e| zone_edge_kind(f, e) == ZoneEdge::Curved);
let pts: Vec<Vec2> =
if curved { c.outer.iter().flat_map(|e| e.polyline(1e-4)).collect() } else { c.outer.iter().map(Seg2::start).collect() };
let ss: Vec<f64> = pts.iter().map(|q| (*q - f.p).dot(f.n)).collect();
let (mut s0, mut s1) = (ss.iter().cloned().fold(f64::MAX, f64::min), ss.iter().cloned().fold(f64::MIN, f64::max));
let us: Vec<f64> = pts.iter().map(|q| (*q - f.p).dot(f.d)).collect();
let (mut u0, mut u1) = (us.iter().cloned().fold(f64::MAX, f64::min), us.iter().cloned().fold(f64::MIN, f64::max));
if curved {
let pad = 1e-3;
(s0, s1) = ((s0 - pad).max(0.0), (s1 + pad).min(self.allowance(f)));
(u0, u1) = (u0 - pad, u1 + pad);
}
let neutral = (f.radius + self.k * self.t).max(1e-12);
let (axis_o, k, turn) = self.bend_axis(f);
let wp = self.world(f.parent);
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_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) {
(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 }
}
// 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);
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<Body> {
if self.flat {
return self.flat_solid();
}
let mut work = FlatWork::new();
let cells = self.cells_with_work(&mut work)?;
// Validate the bounded material first, then build close to the sheet's own
// origin. Fitting planes after a distant component transfer loses precision.
let mut local = self.clone();
local.frame = Plane::XY;
let body = kernel::sew(&local.faces(&cells, 0.0, self.t, &mut work)?)?;
let body = local.machine_pockets(body, &mut work)?;
self.place_local_solid(body)
}
/// The flat pattern as one extrusion (outline with its cut-outs).
pub fn flat_solid(&self) -> Result<Body> {
let regions = self.flat_regions()?;
let mut local = self.clone();
local.frame = Plane::XY;
local.flat = true;
let body = kernel::extrude_planar_regions(&Plane::XY, &regions, 0.0, self.t)?;
let body = local.machine_pockets(body, &mut FlatWork::new())?;
self.place_local_solid(body)
}
fn place_local_solid(&self, body: Body) -> Result<Body> {
if self.frame == Plane::XY { Ok(body) } else { Ok(kernel::transform_matrix(&body, self.frame_mat())?) }
}
/// Retain bounded derived machining records, without duplicating identical replay work.
pub(crate) fn record_pocket(&mut self, region: Region2, z0: f64, z1: f64, work: &mut FlatWork) -> Result<()> {
if !z0.is_finite() || !z1.is_finite() || z0 < 0.0 || z1 > self.t || z0 >= z1 {
return Err(DocError::Invalid("invalid sheet pocket depth".into()));
}
if self.pockets.len() > 1000 {
return Err(flat_budget_error());
}
let pocket = SheetPocket { region, z0, z1 };
let (old_edges, old_points) = regions_complexity(self.pockets.iter().map(|p| &p.region))?;
let (new_edges, new_points) = region_complexity(std::slice::from_ref(&pocket.region))?;
work.charge(
old_edges.saturating_add(old_points).saturating_add(new_edges.saturating_add(new_points).saturating_mul(self.pockets.len().max(1))),
)?;
if self.pockets.contains(&pocket) {
return Ok(());
}
if self.pockets.len() >= 1000 {
return Err(flat_budget_error());
}
if old_edges.saturating_add(new_edges) > MAX_FLAT_POINTS || old_points.saturating_add(new_points) > MAX_FLAT_POINTS {
return Err(flat_budget_error());
}
self.pockets.push(pocket);
Ok(())
}
/// Certify whether a planar panel has material outside a retained thickness interval.
/// Classification uses exact planar arrangements at every pocket depth boundary.
pub(crate) fn material_outside_depth(&self, panel: &Region2, z0: f64, z1: f64, work: &mut FlatWork) -> Result<bool> {
if !z0.is_finite() || !z1.is_finite() || z0 < 0.0 || z1 > self.t || z0 >= z1 {
return Err(DocError::Invalid("invalid sheet retained depth".into()));
}
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, z0, z1];
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().max(1)))?;
for zs in levels.windows(2) {
if zs[0] >= z0 && zs[1] <= z1 {
continue;
}
let active: Vec<_> = self.pockets.iter().filter(|p| p.z0 <= zs[0] && p.z1 >= zs[1]).map(|p| p.region.clone()).collect();
let removed = material_union(active, work)?;
if !material_boolean(std::slice::from_ref(panel), &removed, kernel::BoolOp::Cut, work)?.is_empty() {
return Ok(true);
}
}
Ok(false)
}
/// Replay exact planar machining after forming the full-thickness material.
fn machine_pockets(&self, mut body: Body, work: &mut FlatWork) -> Result<Body> {
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:
/// one solid per bend zone or nonparallel panel. Each is sewn on its own, because the
/// selected pieces need not form a manifold solid together (two bends meeting at an open
/// corner relief touch only along an edge).
pub(crate) fn unmodeled_solids(&self, plane: &Plane) -> Result<Vec<Body>> {
if self.flat || self.flanges.is_empty() {
return Ok(Vec::new());
}
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();
cells.iter().map(|c| Ok(kernel::sew(&self.faces(std::slice::from_ref(c), 0.0, self.t, &mut work)?)?)).collect()
}
fn through_pocket_regions(&self, work: &mut FlatWork) -> Result<Vec<Region2>> {
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<Vec<Region2>> = 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<Vec<SheetPocket>> {
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<Vec<Region2>> {
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<Vec<Region2>> {
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(&regions)?;
}
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(&region))?.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(&regions, &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<Vec<Vec<Vec2>>> {
if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES {
return Err(flat_budget_error());
}
let mut polys: Vec<Vec<Vec2>> = 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<BendInfo> {
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<Vec<Vec2>> = 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<usize>, 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
}
/// Where a world point of this sheet lay in `before`, the same sheet before a feature
/// changed its folds: the point goes back to flat coordinates through the panel it lies on
/// and is folded again the way `before` folded that material. A panel a fold made was part of
/// the panel it was folded out of. `None` off every panel (on a bend).
pub(crate) fn point_before(&self, before: &SheetBody, p: Vec3) -> Option<Vec3> {
const TOL: f64 = 1e-4;
for (pi, r) in self.panels() {
let Some(inv) = mat_inverse(&self.world(pi)) else { continue };
let q = apply_point(&inv, p);
if q.z < -TOL || q.z > self.t + TOL {
continue;
}
let q2 = Vec2::new(q.x, q.y);
let pts = dedup(&r.outer.ccw().polyline(1e-3));
if !point_in(&pts, q2) && !near_outline(&pts, q2, TOL) {
continue;
}
// Panels `before` did not have yet came out of their parent's (bounded walk).
let mut owner = pi;
for _ in 0..self.flanges.len() {
match owner {
Some(i) if i >= before.flanges.len() => owner = self.flanges.get(i).and_then(|f| f.parent),
_ => break,
}
}
if owner.is_some_and(|i| i >= before.flanges.len()) {
return None;
}
return Some(apply_point(&before.world(owner), q));
}
None
}
/// 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<usize>, Vec2, Vec2, bool, f64)> {
let mut best: Option<(Option<usize>, 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<usize>, 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 o = a + inward * by;
let mut cut = clip(&pts, o, inward);
// Only the material along this edge goes: other parts of the outline past the
// line (tabs side by side on one line) stay.
let mut work = FlatWork::new();
let off = merge_loops(&[clip(&pts, o, inward * -1.0)], &mut work)?;
if off.len() > 1 {
let mid = (a + b) * 0.5;
let touches = |p: &Vec<Vec2>| {
let m = p.len();
(0..m).filter_map(|i| Some((*p.get(i)?, *p.get((i + 1) % m)?))).any(|(p0, p1)| {
let e = p1 - p0;
let t = if e.len2() > 1e-18 { ((mid - p0).dot(e) / e.len2()).clamp(0.0, 1.0) } else { 0.0 };
(p0 + e * t).dist(mid) < 1e-6
})
};
let mut keep = vec![cut];
keep.extend(off.into_iter().filter(|p| !touches(p)));
cut = match merge_loops(&keep, &mut work)?.as_slice() {
[one] => one.clone(),
_ => return Err(DocError::Invalid("not supported yet: a flange setback that splits the sheet".into())),
};
}
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<usize> {
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 between them are then closed with
/// the rule's gap and given the rule's relief (`close_corners`).
pub fn add_flanges(&mut self, picks: &[Vec3], height: f64, angle: f64, radius: f64, position: &str, flip: bool) -> Result<Vec<usize>> {
self.add_flanges_noted(picks, height, angle, radius, position, flip).map(|(made, _)| made)
}
/// [`SheetBody::add_flanges`], also returning notes on corners that could not be closed
/// (they are left open, as the bends leave them) or relieved (they get no relief).
pub fn add_flanges_noted(
&mut self,
picks: &[Vec3],
height: f64,
angle: f64,
radius: f64,
position: &str,
flip: bool,
) -> Result<(Vec<usize>, Vec<String>)> {
let each: Vec<EdgeFlange> = picks.iter().map(|&pick| EdgeFlange { pick, height, angle }).collect();
self.add_flanges_each(&each, radius, position, flip)
}
/// Edge flanges that each have their own height and angle, in one go: every edge is trimmed
/// back by its own setback first, then the flanges are added and the corners between them
/// are closed. The closing works from the two flanges it joins (each one's own fold and
/// leg), so unequal flanges close like equal ones.
pub fn add_flanges_each(&mut self, edges: &[EdgeFlange], radius: f64, position: &str, flip: bool) -> Result<(Vec<usize>, Vec<String>)> {
if edges.is_empty() || edges.len() > 1000 {
return Err(DocError::Invalid("pick 1…1000 edges".into()));
}
// With several edges a message says which one it is about.
let on = |k: usize| if edges.len() > 1 { format!("edge {}: ", k + 1) } else { String::new() };
// Each edge's setback and leg (the panel past the bend) follow its own angle and height.
let mut sizes = Vec::with_capacity(edges.len());
for (k, e) in edges.iter().enumerate() {
if !(e.angle > 1e-6 && e.angle <= std::f64::consts::PI - 1e-6) {
return Err(DocError::Invalid(format!("{}the flange angle must be between 0 and 180°", on(k))));
}
let sharp = (radius + self.t) * (e.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 = e.height - sharp;
if !(leg >= -1e-9) {
return Err(DocError::Invalid(format!("{}the flange height must be at least {sharp:.3} mm for this bend", on(k))));
}
sizes.push((setback, leg));
}
// Find every edge before anything moves.
let mut found = Vec::new();
for (e, &(setback, leg)) in edges.iter().zip(&sizes) {
let (panel, a, b, top, dist) = self.edge_at(e.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's top or bottom face".into()));
}
let d = (b - a).normalized().ok_or_else(|| DocError::Invalid("edge".into()))?;
found.push((panel, a, b, d, top, setback, leg, e.angle));
}
for (panel, a, b, _, _, setback, _, _) in &found {
self.trim(*panel, *a, *b, *setback)?;
}
let mut made = Vec::new();
for (panel, a, b, d, top, setback, leg, angle) 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();
let on_line: Vec<(Vec2, f64)> = (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()))
})
.collect();
// Of several edges on the line (tabs side by side), the one the pick was on.
let (ua, ub) = (a.dot(d), b.dot(d));
on_line
.iter()
.find(|(p0, len)| (p0.dot(d) + len).min(ub) - p0.dot(d).max(ua) > 1e-9)
.or_else(|| on_line.first())
.copied()
.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),
slope: (0.0, 0.0),
});
made.push(self.flanges.len() - 1);
}
let notes = self.close_corners(&made);
Ok((made, notes))
}
/// Corners where two of the new flanges' bends meet on the base: both bend zones end at the
/// corner point. Hems and fold panels are left out, and so is a corner with a flange of an
/// earlier feature: that one keeps its length and the new flange ends at its bend, as before.
fn corners(&self, made: &[usize]) -> Vec<Corner> {
let limit = std::f64::consts::PI - 1e-6;
let ends: Vec<(usize, bool, Vec2)> = self
.flanges
.iter()
.enumerate()
.filter(|(_, f)| f.parent.is_none() && f.shape.is_none() && f.angle.abs() < limit)
.flat_map(|(i, f)| {
let (u0, u1) = self.zone_span(f);
[(i, false, f.p + f.d * u0), (i, true, f.p + f.d * u1)]
})
.collect();
let mut out = Vec::new();
for (k, &(a, a_end, c)) in ends.iter().enumerate() {
for &(b, b_end, c2) in ends.iter().skip(k + 1) {
if a != b && c.dist(c2) < 1e-6 && made.contains(&a) && made.contains(&b) {
out.push(Corner { a, a_end, b, b_end, c });
}
}
}
out
}
/// Close the corners the new flanges make, then cut the rule's relief at each. Returns a
/// note for every corner left open or without its relief.
fn close_corners(&mut self, made: &[usize]) -> Vec<String> {
let corners = self.corners(made);
let mut notes = Vec::new();
let at = |s: &SheetBody, c: Vec2| {
let w = apply_point(&s.frame_mat(), v3(c));
format!("({:.3}, {:.3}, {:.3})", w.x, w.y, w.z)
};
for k in &corners {
if let Err(why) = self.close_corner(k) {
notes.push(format!("the flanges at {} are left open: {why}", at(self, k.c)));
}
}
// The reliefs come last: they move the bends' ends, which is how the corners were found.
if self.relief != ReliefShape::Notch {
for k in &corners {
if let Err(why) = self.relieve_corner(k, &corners) {
notes.push(format!("no corner relief at {}: {why}", at(self, k.c)));
}
}
}
notes
}
/// Run two flanges' panels on toward each other (or cut them back) until their ends meet the
/// plane halfway between the flanges with the rule's gap between them. The ends stay square
/// to the sheet and follow lines in the flat, slanted when the flanges aren't at 90°, so that
/// the edges nearest each other (on the inner faces) are `gap` apart along their length. Any
/// angles, also unequal ones, at any convex corner of the base.
fn close_corner(&mut self, k: &Corner) -> std::result::Result<(), String> {
let (Some(fa), Some(fb)) = (self.flanges.get(k.a).cloned(), self.flanges.get(k.b).cloned()) else {
return Err("a flange is missing".into());
};
let ea = if k.a_end { fa.d * -1.0 } else { fa.d };
let eb = if k.b_end { fb.d * -1.0 } else { fb.d };
// At an inside corner of the base the flanges turn away from each other.
if ea.dot(fb.n) > -1e-9 || eb.dot(fa.n) > -1e-9 {
return Ok(());
}
if fa.leg <= 1e-9 || fb.leg <= 1e-9 {
// No panel past a bend: the bends meet at the corner, there is nothing to close.
return Ok(());
}
if (fa.angle >= 0.0) != (fb.angle >= 0.0) {
return Err("the flanges bend opposite ways".into());
}
let gap = self.gap;
if !(gap >= MIN_CORNER_GAP) {
return Err(format!("the flange gap {gap:.4} mm is below {MIN_CORNER_GAP} mm"));
}
let (wa, wb) = (self.fold(k.a), self.fold(k.b));
// The inner faces (toward the bends' axes), with normals pointing from the outer face in.
let (z_in, sign) = if fa.angle >= 0.0 { (self.t, 1.0) } else { (0.0, -1.0) };
let na = apply_vector(&wa, Vec3::Z) * sign;
let nb = apply_vector(&wb, Vec3::Z) * sign;
let pa = apply_point(&wa, v3(k.c) + Vec3::Z * z_in);
let pb = apply_point(&wb, v3(k.c) + Vec3::Z * z_in);
let m = (nb - na).len();
if !(m > 1e-6) {
return Err("the flanges are parallel".into());
}
// Signed distance from the plane bisecting the inner faces, positive on flange a's side.
let bisector = |p: Vec3| (nb.dot(p - pb) - na.dot(p - pa)) / m;
// A flange's end: its panel keeps u ≥ u_end(s) (u from the corner along its bend, s from
// the bend's start across it), so that every point of the end face is gap/2 or more from
// the bisecting plane. The distance is affine in (u, s, z), so the end is a line in the
// flat: returns u_end at the panel's bend edge and how much further the panel runs on per
// unit of height.
let end = |f: &SheetFlange, w: &Mat, e: Vec2, sign: f64| -> std::result::Result<(f64, f64), String> {
let at = |u: f64, s: f64, z: f64| sign * bisector(apply_point(w, v3(k.c + e * u + f.n * s) + Vec3::Z * z));
let c0 = at(0.0, 0.0, 0.0);
let (cu, cs, cz) = (at(1.0, 0.0, 0.0) - c0, at(0.0, 1.0, 0.0) - c0, at(0.0, 0.0, 1.0) - c0);
if !(cu > 1e-6) || ![c0, cs, cz].iter().all(|x| x.is_finite()) {
return Err("the flanges don't meet across the corner".into());
}
let ba = self.allowance(f);
// At 90° the end is square to the bend: keep the panel an exact rectangle.
let slope = if (cs / cu).abs() < 1e-12 { 0.0 } else { cs / cu };
Ok(((gap / 2.0 - c0 - (cz * self.t).min(0.0) - cs * ba) / cu, slope))
};
let mut new = [fa.clone(), fb.clone()];
for (f, (e, at_end, sign)) in new.iter_mut().zip([(ea, k.a_end, 1.0), (eb, k.b_end, -1.0)]) {
let w = if sign > 0.0 { &wa } else { &wb };
let (u_end, slope) = end(f, w, e, sign)?;
let tc = (k.c - f.p).dot(f.d);
if at_end {
f.ext.1 = tc - u_end - f.len;
f.slope.1 = slope;
} else {
f.ext.0 = -(tc + u_end);
f.slope.0 = slope;
}
let bottom = f.len + f.ext.0 + f.ext.1;
let top = bottom + (f.slope.0 + f.slope.1) * f.leg;
if !(bottom > 1e-6 && top > 1e-6) {
return Err("the gap would cut a panel away".into());
}
}
// The flat pattern must stay one sheet without overlaps.
let [new_a, new_b] = new;
let (qa, qb) = (corners_of(&self.panel(&new_a)), corners_of(&self.panel(&new_b)));
if convex_overlap(&qa, &qb) > 1e-9
|| convex_overlap(&qa, &corners_of(&self.zone(&fb))) > 1e-9
|| convex_overlap(&qb, &corners_of(&self.zone(&fa))) > 1e-9
{
return Err("closed, the flanges would overlap in the flat pattern".into());
}
if let Some(f) = self.flanges.get_mut(k.a) {
*f = new_a;
}
if let Some(f) = self.flanges.get_mut(k.b) {
*f = new_b;
}
Ok(())
}
/// Cut the rule's relief where two bends meet at a corner of the base, `relief_size / 2`
/// from the corner along both edges: a round about the corner through the angle the base
/// fills there, or the square with that side (a kite where the corner isn't square, its
/// cuts square to the two edges). The base's edges then start that far from the corner, so
/// both bends stop there, cut straight across (`zone_span`): their ends stay exact arcs,
/// the round runs on into them tangentially, and the bend table gets the shorter bends.
/// The flange panels keep the ends `close_corner` gave them. A relief too small to cut, one
/// that would leave nothing of an edge it starts on (with the relief at that edge's other
/// end, where there is one), or one reaching as far from the corner as another edge of the
/// base is, is refused with the reason.
fn relieve_corner(&mut self, k: &Corner, corners: &[Corner]) -> std::result::Result<(), String> {
let size = self.relief_size;
if !(size >= MIN_CORNER_RELIEF) {
return Err(format!("the relief size {size:.4} mm is below {MIN_CORNER_RELIEF} mm"));
}
let r = size / 2.0;
let segs = self.base.outer.ccw().segs;
let n = segs.len();
// The base's edges at the corner: `i` ends there and the next one starts there.
let at_corner =
|i: usize| segs.get(i).is_some_and(|s| s.end().dist(k.c) < 1e-6) && segs.get((i + 1) % n).is_some_and(|s| s.start().dist(k.c) < 1e-6);
let Some(i) = (0..n).find(|&i| at_corner(i)) else { return Err("the bends don't meet at a corner of the base".into()) };
let j = (i + 1) % n;
let (Some(&Seg2::Line { a: pa, b: c }), Some(&Seg2::Line { b: pb, .. })) = (segs.get(i), segs.get(j)) else {
return Err("the base's edges at the corner are not straight".into());
};
let (Some(da), Some(db)) = ((pa - c).normalized(), (pb - c).normalized()) else {
return Err("an edge of the base at the corner has no length".into());
};
// The angle the base fills at the corner.
let alpha = db.cross(da).atan2(db.dot(da));
if !(alpha > 1e-6 && alpha < std::f64::consts::PI - 1e-6) {
// An inside corner of the base: the flanges turn away from each other.
return Ok(());
}
// Both bends must keep some length. An edge whose far end is another of the corners has
// to hold a relief there too, so the reliefs at its two ends fit or are left out together.
let takes = |far: Vec2| if corners.iter().any(|q| q.c.dist(far) < 1e-6) { 2.0 * r } else { r };
if takes(pa) >= pa.dist(c) - 1e-6 || takes(pb) >= pb.dist(c) - 1e-6 {
return Err(format!("a relief {size:.3} mm across would take a whole bend away"));
}
// The relief from the edge that ends at the corner to the one that starts there, and
// how far from the corner it reaches.
let (relief, reach) = match self.relief {
ReliefShape::Notch => return Ok(()),
ReliefShape::Round => (vec![Seg2::Arc { center: c, radius: r, start: da.angle(), sweep: -alpha }], r),
ReliefShape::Rectangular => {
let x = snap(c + (da + db) * (r / (1.0 + da.dot(db))));
(vec![Seg2::Line { a: snap(c + da * r), b: x }, Seg2::Line { a: x, b: snap(c + db * r) }], x.dist(c))
}
};
let (Some(start), Some(end)) = (relief.first().map(Seg2::start), relief.last().map(Seg2::end)) else { return Ok(()) };
// The rest of the outline must stay clear of it.
let clear = segs.iter().enumerate().filter(|(e, _)| *e != i && *e != j).all(|(_, s)| {
s.polyline(1e-3).windows(2).all(|w| {
let d = w[1] - w[0];
let t = if d.len2() > 1e-30 { ((c - w[0]).dot(d) / d.len2()).clamp(0.0, 1.0) } else { 0.0 };
c.dist(w[0] + d * t) > reach + 1e-6
})
});
if !clear {
return Err(format!("a relief {size:.3} mm across reaches {reach:.3} mm from the corner, where another edge of the base is nearer"));
}
let mut outer = Vec::with_capacity(n + relief.len());
for (e, s) in segs.iter().enumerate() {
if e == i {
outer.push(Seg2::Line { a: pa, b: start });
outer.extend(relief.iter().copied());
} else if e == j {
outer.push(Seg2::Line { a: end, b: pb });
} else {
outer.push(*s);
}
}
self.base = Region2 { outer: Loop2 { segs: outer }, holes: self.base.holes.clone() };
Ok(())
}
/// 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<usize> {
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),
slope: (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. A `fixed` point that
/// touches no material still picks the side it lies on, and sets `warning` (see
/// [`FOLD_FIXED_OFF_SHEET`]).
#[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<Vec3>,
warning: &mut Option<String>,
) -> Result<usize> {
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);
let q2 = Vec2::new(q.x, q.y);
// A point in the panel's slab must be on material: inside the outline and
// outside every cut-out (a side face of either counts as material).
let near = |poly: &[Vec2]| {
let m = poly.len();
(0..m).any(|i| match (poly.get(i), poly.get((i + 1) % m)) {
(Some(&a), Some(&b)) => {
let d = b - a;
let t = if d.len2() > 1e-18 { ((q2 - a).dot(d) / d.len2()).clamp(0.0, 1.0) } else { 0.0 };
(a + d * t).dist(q2) < 1e-3
}
_ => false,
})
};
let in_slab = q.z > -1e-4 && q.z < self.t + 1e-4;
let in_hole = self.holes.iter().any(|h| h.contains(q2) && !near(&h.outer.ccw().polyline(1e-3)));
if in_slab && ((!point_in(&pts, q2) && !near(&pts)) || in_hole) {
*warning =
Some(format!("{FOLD_FIXED_OFF_SHEET} at ({:.3}, {:.3}, {:.3}); the side of the fold line it lies on stays", p.x, p.y, p.z));
}
q2.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),
slope: (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()));
}
// Separate tabs on one line (the zone falls apart into several pieces): fold the ones
// the line's points span, each as its own bend.
let strips = merge_loops(std::slice::from_ref(&strip), &mut FlatWork::new())?;
if strips.len() > 1 {
let span = (a2.dot(d).min(b2.dot(d)), a2.dot(d).max(b2.dot(d)));
let angle = if flip { -angle } else { angle };
return self.fold_tabs(panel, &pts, (n, d), (s0, ba), span, (angle, radius), &strips);
}
// 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<f64> = 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) {
// A corner of the outline inside the zone (e.g. a fold right at a tab's root): name
// it and how far the line has to move to clear it.
let corners: Vec<(Vec2, f64)> = pts.iter().map(|q| (*q, q.dot(n) - s0)).filter(|(_, s)| *s > 1e-6 && *s < ba - 1e-6).collect();
if let Some(&(c, _)) = corners.first() {
let on = corners.iter().fold(0.0_f64, |m, (_, s)| m.max(*s));
let back = corners.iter().fold(0.0_f64, |m, (_, s)| m.max(ba - *s));
let w = apply_point(&self.world(panel), Vec3::new(c.x, c.y, self.t));
return Err(DocError::Invalid(format!(
"the bend zone ({ba:.3} mm wide) reaches a corner of the sheet's outline at ({:.3}, {:.3}, {:.3}): \
move the fold line at least {on:.3} mm toward the side that folds or {back:.3} mm toward the side that stays",
w.x, w.y, w.z
)));
}
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<Vec2>| {
if signed_area(&v) < 0.0 {
v.reverse();
}
v.into_iter().map(snap).collect::<Vec<_>>()
};
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),
slope: (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)
}
/// A fold whose bend zone falls apart into several pieces (`strips`: tabs on one line). The
/// pieces the line's points span (`span`, along `d`) bend, each with the material beyond it;
/// the rest of the panel stays put. `n` points to the moving side, the zone is `s0..s0 + ba`.
#[allow(clippy::too_many_arguments)]
fn fold_tabs(
&mut self,
panel: Option<usize>,
pts: &[Vec2],
(n, d): (Vec2, Vec2),
(s0, ba): (f64, f64),
span: (f64, f64),
(angle, radius): (f64, f64),
strips: &[Vec<Vec2>],
) -> Result<usize> {
const EPS: f64 = 1e-6;
let urange = |p: &[Vec2]| p.iter().fold((f64::MAX, f64::MIN), |(lo, hi), q| (lo.min(q.dot(d)), hi.max(q.dot(d))));
let overlap = |a: (f64, f64), b: (f64, f64)| a.1.min(b.1) - a.0.max(b.0);
let mut work = FlatWork::new();
// The material beyond the zone, piece by piece, and the zone piece each one hangs from.
let beyond = merge_loops(&[clip(pts, n * (s0 + ba), n)], &mut work)?;
let far_edges = |p: &[Vec2]| -> Vec<(f64, f64)> {
let m = p.len();
(0..m)
.filter_map(|i| Some((*p.get(i)?, *p.get((i + 1) % m)?)))
.filter(|(a, b)| (a.dot(n) - s0 - ba).abs() < EPS && (b.dot(n) - s0 - ba).abs() < EPS)
.map(|(a, b)| (a.dot(d).min(b.dot(d)), a.dot(d).max(b.dot(d))))
.collect()
};
let strip_u: Vec<(f64, f64)> = strips.iter().map(|s| urange(s)).collect();
let mut hangs: Vec<Vec<usize>> = vec![Vec::new(); strips.len()];
for (k, part) in beyond.iter().enumerate() {
let edges = far_edges(part);
let on: Vec<usize> = (0..strips.len()).filter(|&i| strip_u.get(i).is_some_and(|&u| edges.iter().any(|&e| overlap(e, u) > EPS))).collect();
match on.as_slice() {
[i] => {
if let Some(h) = hangs.get_mut(*i) {
h.push(k);
}
}
_ => return Err(DocError::Invalid("not supported yet: folding tabs that join again beyond the bend".into())),
}
}
let chosen: Vec<usize> = (0..strips.len()).filter(|&i| strip_u.get(i).is_some_and(|&u| overlap(u, span) > EPS)).collect();
if chosen.is_empty() {
return Err(DocError::Invalid("the fold line must cross the sheet with material on both sides of the bend".into()));
}
// What stays: the panel short of the zone plus the zone pieces (and what hangs from
// them) that don't fold.
let mut keep = vec![clip(pts, n * s0, n * -1.0)];
for i in 0..strips.len() {
if chosen.contains(&i) {
continue;
}
keep.extend(strips.get(i).cloned());
keep.extend(hangs.get(i).into_iter().flatten().filter_map(|&k| beyond.get(k).cloned()));
}
let fixed = match merge_loops(&keep, &mut work)?.as_slice() {
[one] if signed_area(one) > 1e-9 => one.clone(),
_ => return Err(DocError::Invalid("not supported yet: a fold that splits the part that stays".into())),
};
let mut made = Vec::new();
for &i in &chosen {
let (Some(strip), Some(&(u0, u1))) = (strips.get(i), strip_u.get(i)) else { continue };
// Each piece of the zone must be a rectangle (see `add_fold`).
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 moving = match hangs.get(i).map(Vec::as_slice) {
Some([k]) => beyond.get(*k).cloned().ok_or_else(|| DocError::Invalid("fold".into()))?,
_ => return Err(DocError::Invalid("the fold line must cross the sheet with material on both sides of the bend".into())),
};
let leg = moving.iter().map(|q| q.dot(n) - s0 - ba).fold(0.0, f64::max);
made.push((u0, u1, n * s0 + d * u0, leg, moving));
}
// Flanges already on this panel: those beyond a folding tab move with it.
let mut moves = Vec::new();
for k in 0..self.flanges.len() {
let Some(f) = self.flanges.get(k) else { continue };
if f.parent != panel {
continue;
}
let (e0, e1) = (f.p.dot(n) - s0, (f.p + f.d * f.len).dot(n) - s0);
let fu = (f.p.dot(d).min((f.p + f.d * f.len).dot(d)), f.p.dot(d).max((f.p + f.d * f.len).dot(d)));
let crosses = Err(DocError::Invalid("not supported yet: a fold through a flange's bend".into()));
match made.iter().position(|m| fu.0 >= m.0 - 1e-9 && fu.1 <= m.1 + 1e-9) {
Some(j) if e0.min(e1) >= ba - 1e-9 => moves.push((k, j)),
Some(_) if e0.max(e1) > 1e-9 => return crosses,
None if e0.max(e1) > 1e-9 && made.iter().any(|m| overlap((m.0, m.1), fu) > EPS) => return crosses,
_ => {}
}
}
let ccw = |mut v: Vec<Vec2>| {
if signed_area(&v) < 0.0 {
v.reverse();
}
v.into_iter().map(snap).collect::<Vec<_>>()
};
match panel {
None => self.base = Region2 { outer: Loop2::polygon(&fixed).ccw(), holes: self.base.holes.clone() },
Some(j) => {
if let Some(f) = self.flanges.get_mut(j) {
f.shape = Some(ccw(fixed));
}
}
}
let first = self.flanges.len();
for (u0, u1, p0, leg, moving) in made {
self.flanges.push(SheetFlange {
parent: panel,
p: snap(p0),
d,
len: u1 - u0,
n,
angle,
radius,
leg,
ext: (0.0, 0.0),
slope: (0.0, 0.0),
shape: Some(ccw(moving)),
tag: String::new(),
});
}
for (k, j) in moves {
if let Some(f) = self.flanges.get_mut(k) {
f.parent = Some(first + j);
}
}
Ok(first)
}
/// 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<usize>, a: Vec2, b: Vec2, top: bool) -> Option<String> {
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<String> {
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<Vec3> {
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)
}
/// The persistent name of a panel: `base`, a flange's tag, or `#<index>` for an untagged
/// flange; `flat` for the whole sheet while it is unfolded.
fn panel_name(&self, pi: Option<usize>) -> String {
match pi {
_ if self.flat => "flat".into(),
None => "base".into(),
Some(i) => match self.flanges.get(i) {
Some(f) if !f.tag.is_empty() => f.tag.clone(),
_ => format!("#{i}"),
},
}
}
/// The plane of a flat face of a panel (`top`: the face at the thickness, else the bottom
/// one), facing out of the sheet. Its coordinates are the flat pattern's (the bottom
/// face's mirrored in y), so a sketch on it measures as the flat pattern does.
fn panel_face_plane(&self, world: &Mat, top: bool) -> Option<Plane> {
let (x, y) = (apply_vector(world, Vec3::X), apply_vector(world, Vec3::Y));
Plane::new(apply_point(world, Vec3::new(0.0, 0.0, if top { self.t } else { 0.0 })), x, if top { y } else { y * -1.0 })
}
/// The flat face of the sheet that a point lies on (sheet coordinates, within `tol`, which
/// stays below a quarter of the thickness): its persistent name (`<panel>:top` or
/// `<panel>:bottom`) and its plane (see [`panel_face`](Self::panel_face)).
pub fn panel_face_at(&self, p: Vec3, tol: f64) -> Option<(String, Plane)> {
let tol = tol.min(self.t * 0.25);
let flat = if self.flat { self.flat_regions().ok()? } else { Vec::new() };
let panels = if self.flat { vec![(None, self.base.clone())] } else { self.panels() };
for (pi, region) in panels {
let world = self.world(pi);
let Some(inverse) = mat_inverse(&world) else { continue };
let q = apply_point(&inverse, p);
let top = (q.z - self.t).abs() < q.z.abs();
if (if top { (q.z - self.t).abs() } else { q.z.abs() }) > tol {
continue;
}
let q2 = Vec2::new(q.x, q.y);
if if self.flat { flat.iter().any(|r| r.contains(q2)) } else { region.contains(q2) } {
return Some((format!("{}:{}", self.panel_name(pi), if top { "top" } else { "bottom" }), self.panel_face_plane(&world, top)?));
}
}
None
}
/// The plane of the flat face with this persistent name (see [`panel_face_at`](Self::panel_face_at)).
/// A face named while the sheet was folded follows its panel; one named unfolded (`flat`)
/// is the flat sheet's own plane.
pub fn panel_face(&self, name: &str) -> Option<Plane> {
let (panel, side) = name.rsplit_once(':')?;
let top = match side {
"top" => true,
"bottom" => false,
_ => return None,
};
let pi = match panel {
"flat" | "base" => None,
tag => Some(
self.flanges
.iter()
.position(|f| f.tag == tag)
.or_else(|| tag.strip_prefix('#')?.parse::<usize>().ok().filter(|i| *i < self.flanges.len()))?,
),
};
self.panel_face_plane(&self.world(pi), top)
}
/// Map a region in a world plane to flat coordinates through a panel's placement.
pub fn region_to_flat(&self, panel: Option<usize>, plane: &Plane, r: &Region2) -> Result<Region2> {
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<Loop2> {
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::<Vec<_>>()))
} 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::<Result<_>>()?,
})
}
/// Panels whose plane is parallel to `plane` (world): (panel, region).
pub fn parallel_panels(&self, plane: &Plane) -> Vec<(Option<usize>, 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<Vec<(Option<usize>, 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<Vec2>], work: &mut FlatWork) -> Result<Vec<Vec<Vec2>>> {
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<Vec2> = 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<f64> = 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<Vec2> = (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)
}
/// Whether `q` lies within `tol` of the closed polygon `poly`'s outline.
fn near_outline(poly: &[Vec2], q: Vec2, tol: f64) -> bool {
let m = poly.len();
(0..m).any(|i| match (poly.get(i), poly.get((i + 1) % m)) {
(Some(&a), Some(&b)) => {
let d = b - a;
let t = if d.len2() > 1e-18 { ((q - a).dot(d) / d.len2()).clamp(0.0, 1.0) } else { 0.0 };
(a + d * t).dist(q) < tol
}
_ => false,
})
}
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::<f64>() / 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<bool> {
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,
relief: rv.corner_relief_shape,
relief_size: rv.corner_relief,
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<SheetBody> {
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,
relief: rv.corner_relief_shape,
relief_size: rv.corner_relief,
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<usize> = 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),
slope: (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)
}
/// The name a sketch keeps for a flat face of a sheet: `sheet:<body>:<panel>:<top|bottom>`.
pub fn sheet_face_name(body: &str, face: &str) -> String {
format!("sheet:{body}:{face}")
}
/// Point the sheet face a sketch plane is attached to (also beneath an offset or a rotation)
/// at body `new`, when it is on body `old`.
pub fn rename_plane_sheet_body(plane: &mut crate::PlaneRef, old: &str, new: &str) {
match plane {
crate::PlaneRef::Face { name: Some(name), anchor: None, .. } => {
let renamed = split_sheet_face_name(name).filter(|(body, _)| *body == old).map(|(_, face)| sheet_face_name(new, face));
if let Some(renamed) = renamed {
*name = renamed;
}
}
crate::PlaneRef::Offset { base, .. } | crate::PlaneRef::AtAngle { base, .. } => rename_plane_sheet_body(base, old, new),
_ => {}
}
}
/// The sheet face a sketch plane is attached to (also beneath an offset or a rotation): its
/// [`sheet_face_name`].
pub fn plane_sheet_face(plane: &crate::PlaneRef) -> Option<&str> {
match plane {
crate::PlaneRef::Face { name: Some(name), anchor: None, .. } if split_sheet_face_name(name).is_some() => Some(name),
crate::PlaneRef::Offset { base, .. } | crate::PlaneRef::AtAngle { base, .. } => plane_sheet_face(base),
_ => None,
}
}
/// The body and the `<panel>:<side>` of a [`sheet_face_name`].
pub fn split_sheet_face_name(name: &str) -> Option<(&str, &str)> {
let rest = name.strip_prefix("sheet:")?;
let (head, side) = rest.rsplit_once(':')?;
let (body, panel) = head.rsplit_once(':')?;
(!body.is_empty() && !panel.is_empty() && !side.is_empty()).then_some(())?;
Some((body, rest.get(body.len() + 1..)?))
}
/// The plane of the sheet face with a [`sheet_face_name`], in the body's own coordinates.
pub fn sheet_face_plane(st: &crate::ModelState, name: &str) -> Option<(String, Plane)> {
let (body, face) = split_sheet_face_name(name)?;
let plane = st.sheets.iter().find(|s| s.body == body)?.panel_face(face)?;
Some((body.to_string(), plane))
}
/// Persistent names of the sheet edges nearest each world point (empty when none).
pub fn sheet_edge_names(st: &crate::ModelState, pts: &[Vec3]) -> Vec<String> {
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,
corner_relief_shape: ReliefShape::Notch,
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 flat_faces_of_a_sheet_are_found_and_named_by_panel() {
use std::f64::consts::FRAC_PI_2;
// Steel, 2.5 thick, R = T: a 90° flange on the y = 0 edge stands between the planes
// y = 0 (its outer face) and y = 2.5 (its inner face) from z = 5 up.
let mut s = plate(100.0, 60.0);
s.add_flange(Vec3::new(50.0, 0.0, 2.5), 20.0, FRAC_PI_2, 2.5, "inside", false).unwrap();
s.flanges[0].tag = "F7.0".into();
let face = |s: &SheetBody, p: Vec3| s.panel_face_at(p, 1e-6);
let (name, plane) = face(&s, Vec3::new(50.0, 30.0, 2.5)).unwrap();
assert_eq!(name, "base:top");
assert!(plane.normal().dist(Vec3::Z) < 1e-12 && (plane.origin.z - 2.5).abs() < 1e-12);
// The top face's coordinates are the flat pattern's; the bottom face's mirror y.
assert!(plane.to_local(Vec3::new(30.0, 20.0, 2.5)).dist(Vec2::new(30.0, 20.0)) < 1e-12);
let (name, plane) = face(&s, Vec3::new(50.0, 30.0, 0.0)).unwrap();
assert_eq!(name, "base:bottom");
assert!(plane.normal().dist(Vec3::Z * -1.0) < 1e-12 && plane.origin.z.abs() < 1e-12);
assert!(plane.to_local(Vec3::new(30.0, 20.0, 0.0)).dist(Vec2::new(30.0, -20.0)) < 1e-12);
// The flange's inner face is the sheet's top side there; 7 mm up it is flat y = 5 − BA − 7.
let ba = FRAC_PI_2 * (2.5 + 0.44 * 2.5);
let (inner, plane) = face(&s, Vec3::new(30.0, 2.5, 12.0)).unwrap();
assert_eq!(inner, "F7.0:top");
assert!(plane.normal().dist(Vec3::Y) < 1e-12, "{:?}", plane.normal());
assert!(plane.to_local(Vec3::new(30.0, 2.5, 12.0)).dist(Vec2::new(30.0, 5.0 - ba - 7.0)) < 1e-9);
let (outer, plane) = face(&s, Vec3::new(30.0, 0.0, 12.0)).unwrap();
assert_eq!(outer, "F7.0:bottom");
assert!(plane.normal().dist(Vec3::Y * -1.0) < 1e-12, "{:?}", plane.normal());
// A name gives the same plane again.
for p in [Vec3::new(50.0, 30.0, 2.5), Vec3::new(50.0, 30.0, 0.0), Vec3::new(30.0, 2.5, 12.0), Vec3::new(30.0, 0.0, 12.0)] {
let (name, plane) = face(&s, p).unwrap();
assert_eq!(s.panel_face(&name), Some(plane), "{name}");
}
// Inside the sheet, on the bend, beside the sheet, above the flange: no flat face.
for p in [Vec3::new(50.0, 30.0, 1.0), Vec3::new(50.0, 1.0, 1.0), Vec3::new(150.0, 30.0, 2.5), Vec3::new(30.0, 2.5, 40.0)] {
assert!(face(&s, p).is_none(), "{p:?}");
}
// Names that are no face, or no flange, give no plane.
for name in ["base", "base:side", "nobody:top", "#7:top", "F8.0:top", ""] {
assert!(s.panel_face(name).is_none(), "{name}");
}
// A flange without a tag is named by its index.
s.flanges[0].tag.clear();
assert_eq!(face(&s, Vec3::new(30.0, 2.5, 12.0)).map(|f| f.0).as_deref(), Some("#0:top"));
assert!(s.panel_face("#0:bottom").is_some());
// Unfolded, the whole sheet is one panel.
s.flat = true;
assert_eq!(face(&s, Vec3::new(50.0, -10.0, 2.5)).map(|f| f.0).as_deref(), Some("flat:top"));
assert!(s.panel_face("flat:bottom").is_some());
}
#[test]
fn a_pick_beside_a_thin_sheet_takes_the_nearer_face() {
// The tolerance of a pick on a large part is far above a thin sheet's thickness, and
// never reaches a quarter of it here: the nearer side wins, the middle is no face.
let rv = RuleValues { thickness: 0.5, ..steel() };
let s = base_flange(
"Thin",
"Sheet",
&rv,
Plane::XY,
rect(Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(100.0, 60.0), Vec2::new(0.0, 60.0)),
);
let at = |z: f64| s.panel_face_at(Vec3::new(50.0, 30.0, z), 5.0).map(|f| f.0);
assert_eq!(at(0.4).as_deref(), Some("base:top"));
assert_eq!(at(0.1).as_deref(), Some("base:bottom"));
assert_eq!(at(0.25), None);
assert_eq!(at(3.0), None);
}
#[test]
fn sheet_face_names_split_from_the_right() {
assert_eq!(split_sheet_face_name(&sheet_face_name("Body1", "F7.0:top")), Some(("Body1", "F7.0:top")));
// A body name may hold colons; the panel and the side never do.
assert_eq!(split_sheet_face_name("sheet:Part:2:base:bottom"), Some(("Part:2", "base:bottom")));
for name in ["", "sheet", "sheet:Body1", "sheet:Body1:top", "sheet::base:top", "sheet:Body1::top", "sheet:Body1:base:", "Body1:base:top"] {
assert_eq!(split_sheet_face_name(name), None, "{name}");
}
}
#[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 }; 32];
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 round_cutouts_count_as_exact_arcs_in_the_flat_budget() {
// 240 round 4 mm holes sample to over 16 384 chords of 0.001 mm, but are 480 exact arcs.
let mut s = plate(250.0, 130.0);
for i in 0..24 {
for j in 0..10 {
s.holes.push(Region2 { outer: Loop2::circle(Vec2::new(5.0 + 10.0 * i as f64, 15.0 + 10.0 * j as f64), 2.0), holes: vec![] });
}
}
let regions = s.flat_regions().unwrap();
assert_eq!(regions[0].holes.len(), 240);
let area = 250.0 * 130.0 - 240.0 * std::f64::consts::PI * 2.0 * 2.0;
assert!((regions[0].area() - area).abs() < 1e-6);
}
#[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<Cell> = (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"));
}
}
#[test]
fn pocket_recording_deduplicates_at_limit_and_rejects_growth_before_mutation() {
let mut s = plate(100.0, 60.0);
let pocket =
SheetPocket { region: rect(Vec2::new(1.0, 1.0), Vec2::new(2.0, 1.0), Vec2::new(2.0, 2.0), Vec2::new(1.0, 2.0)), z0: 0.0, z1: 0.5 };
s.pockets = vec![pocket.clone(); 1000];
let mut work = FlatWork::new();
s.record_pocket(pocket.region.clone(), pocket.z0, pocket.z1, &mut work).unwrap();
assert_eq!(s.pockets.len(), 1000);
assert!(s.record_pocket(pocket.region.clone(), 0.0, 0.6, &mut work).is_err());
assert_eq!(s.pockets.len(), 1000);
s.pockets.push(pocket.clone());
assert!(s.record_pocket(pocket.region, 0.0, 0.5, &mut work).is_err());
}
/// A sketch solved again after a parameter change leaves its corners off by ~1e-13 mm, so
/// its edges are not quite square to the axes. Such a tray (four 72° flanges, shrunk to
/// 400 x 300 from a larger size) failed to unfold in the planar arrangement while the same
/// tray drawn at that size unfolded.
#[test]
fn nearly_square_base_unfolds_like_the_exact_one() {
let rv = RuleValues { thickness: 2.0, k_factor: 0.5, bend_radius: 1.0, ..steel() };
let tray = |[a, b, c, d]: [Vec2; 4]| {
let mut s = base_flange("Body1", "AL2", &rv, Plane::XY, rect(a, b, c, d));
let picks = [Vec3::new(200.0, 0.0, 2.0), Vec3::new(400.0, 150.0, 2.0), Vec3::new(200.0, 300.0, 2.0), Vec3::new(0.0, 150.0, 2.0)];
s.add_flanges(&picks, 38.6, 72f64.to_radians(), 1.0, "inside", false).unwrap();
s
};
let flat_area = |s: &SheetBody| s.flat_regions().unwrap().iter().map(|r| r.area().abs()).sum::<f64>();
let square = [Vec2::new(0.0, 0.0), Vec2::new(400.0, 0.0), Vec2::new(400.0, 300.0), Vec2::new(0.0, 300.0)];
let exact = flat_area(&tray(square));
// The corners the solver left after the parameter change, and the rectangle turned by a hair.
let solved = [
Vec2::new(0.0, 0.0),
Vec2::new(400.0000000000001, 4.369543929613394e-13),
Vec2::new(400.00000000000017, 300.0000000000008),
Vec2::new(0.0, 300.0000000000009),
];
let (c, s) = (1e-14f64.cos(), 1e-14f64.sin());
let turned = square.map(|p| Vec2::new(p.x * c - p.y * s, p.x * s + p.y * c));
for corners in [solved, turned] {
let mut sheet = tray(corners);
assert!((flat_area(&sheet) - exact).abs() < 1e-6, "{} {exact}", flat_area(&sheet));
sheet.flat = true;
assert!((vol(&sheet.solid().unwrap()) - exact * 2.0).abs() < 1e-3);
}
}
/// 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,
corner_relief_shape: ReliefShape::Notch,
hem_gap: 0.01,
gap: 2.0,
};
let r = rect(Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(100.0, 80.0), Vec2::new(0.0, 80.0));
let mut s = base_flange("Body1", "AL2", &rule, Plane::XY, r);
s.add_flanges(&[Vec3::new(50.0, 0.0, 2.0)], 20.0, angle, 1.0, "inside", false).unwrap();
s
}
#[test]
fn slanted_and_round_cut_outs_across_a_bend_fold() {
let area = |s: &SheetBody| s.flat_regions().unwrap().iter().map(Region2::area).sum::<f64>();
for angle in [90f64, 72.0, 108.0] {
let plain = flanged(angle.to_radians());
let (a0, v0) = (area(&plain), vol(&plain.solid().unwrap()));
// A 10 × 20 slot turned 30° about (50, 0) and a Ø8 circle at (30, 0.5): both reach
// from the base over the whole bend zone into the flange.
let (c, a) = (Vec2::new(50.0, 0.0), 30f64.to_radians());
let (ux, uy) = (Vec2::from_angle(a) * 5.0, Vec2::from_angle(a + std::f64::consts::FRAC_PI_2) * 10.0);
let slot = Region2 { outer: Loop2::polygon(&[c - ux - uy, c + ux - uy, c + ux + uy, c - ux + uy]).ccw(), holes: vec![] };
let circle = Region2 { outer: Loop2::circle(Vec2::new(30.0, 0.5), 4.0), holes: vec![] };
for (cut, removed) in [(slot, 200.0), (circle, std::f64::consts::PI * 16.0)] {
let mut s = plain.clone();
s.holes.push(cut);
assert!((a0 - area(&s) - removed).abs() < 1e-6, "{angle}°: flat area {}", a0 - area(&s));
let body = s.solid().unwrap();
assert!(body.validity().is_empty(), "{angle}°: {:?}", body.validity());
// The folded sheet loses the cut-out's flat area times the thickness.
assert!((v0 - vol(&body) - removed * 2.0).abs() < 0.05, "{angle}°: {} vs {}", v0 - vol(&body), removed * 2.0);
}
}
}
#[test]
fn a_slanted_flat_edge_folds_to_its_exact_helix() {
// A 72° flange, inside: the bend's axis runs along x through y = setback, z = T + R,
// and a point at height z in the sheet stays T + R − z away from it.
let angle = 72f64.to_radians();
let s = flanged(angle);
let f = s.flanges[0].clone();
let setback = 3.0 * (angle / 2.0).tan();
// The unclamped zone map agrees with the fold inside the zone.
for (sv, u, z) in [(0.3, 10.0, 0.0), (1.2, 40.0, 1.0), (2.4, 90.0, 2.0)] {
let q = f.p + f.n * sv + f.d * u;
assert!(s.zone_point(&f, q, z).0.dist(s.map3(Piece::Zone(0), q, z)) < 1e-12);
}
// A flat line across the whole zone, 15 mm along the bend: the bend angle and the
// distance along the axis both grow evenly along it.
let e = Seg2::Line { a: f.p + f.d * 20.0, b: f.p + f.n * s.allowance(&f) + f.d * 35.0 };
let breaks = s.zone_breaks(&f, &e);
for z in [0.0, 2.0] {
let helix = |t: f64| {
let (phi, rho) = (angle * t, 3.0 - z);
Vec3::new(e.point_at(t).x, setback - rho * phi.sin(), 3.0 - rho * phi.cos())
};
let EdgeSpec::Chain { ctrl } = s.zone_chain(&f, &e, z) else { panic!("a slanted edge folds to a chain") };
assert_eq!(ctrl.len(), 3 * (breaks.len() - 1) + 1);
for (k, w) in breaks.windows(2).enumerate() {
for x in [0.0, 0.1, 0.37, 0.5, 0.81, 1.0] {
let p = cubic_at(ctrl[3 * k], ctrl[3 * k + 1], ctrl[3 * k + 2], ctrl[3 * k + 3], x);
let exact = helix(w[0] + (w[1] - w[0]) * x);
assert!(p.dist(exact) < BEND_CHAIN_TOL, "height {z}, piece {k}: {}", p.dist(exact));
}
}
}
// Lines along the bend and square to it keep their exact line and arc.
let along = Seg2::Line { a: f.p + f.n * 1.0 + f.d * 20.0, b: f.p + f.n * 1.0 + f.d * 35.0 };
let across = Seg2::Line { a: f.p + f.d * 20.0, b: f.p + f.n * s.allowance(&f) + f.d * 20.0 };
assert!(matches!(s.edge3(Piece::Zone(0), &along, 2.0), Ok(EdgeSpec::Line { .. })));
assert!(matches!(s.edge3(Piece::Zone(0), &across, 2.0), Ok(EdgeSpec::Arc { .. })));
}
/// 2 mm aluminium, R 1, K 0.5 (folding keeps the volume), flanges 0.5 mm apart at corners.
fn alu() -> RuleValues {
RuleValues {
thickness: 2.0,
k_factor: 0.5,
bend_radius: 1.0,
relief_width: 2.0,
relief_depth: 1.0,
corner_relief: 8.0,
corner_relief_shape: ReliefShape::Notch,
hem_gap: 0.01,
gap: 0.5,
}
}
/// A 120 x 90 tray: flanges `height` high at `deg` on all four edges (bottom, right, top, left).
fn tray(rv: &RuleValues, deg: f64, height: f64) -> (SheetBody, Vec<String>) {
let r = rect(Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 90.0), Vec2::new(0.0, 90.0));
let mut s = base_flange("Body1", "AL2", rv, Plane::XY, r);
let t = rv.thickness;
let picks = [Vec3::new(60.0, 0.0, t), Vec3::new(120.0, 45.0, t), Vec3::new(60.0, 90.0, t), Vec3::new(0.0, 45.0, t)];
let (_, notes) = s.add_flanges_noted(&picks, height, deg.to_radians(), rv.bend_radius, "inside", false).unwrap();
(s, notes)
}
fn flat_area(s: &SheetBody) -> f64 {
s.flat_regions().unwrap().iter().map(Region2::area).sum()
}
/// Distance between two parallel 3D lines through a0→a1 and b0→b1 (None if not parallel).
fn parallel_gap(a0: Vec3, a1: Vec3, b0: Vec3, b1: Vec3) -> Option<f64> {
let (u, v) = ((a1 - a0).normalized()?, (b1 - b0).normalized()?);
(u.cross(v).len() < 1e-9).then(|| (b0 - a0).cross(u).len())
}
#[test]
fn closed_corners_end_on_the_bisector_with_the_gap() {
let (t, r, g, h) = (2.0, 1.0, 0.5, 38.6);
for deg in [90.0f64, 72.0] {
let (s, notes) = tray(&alu(), deg, h);
assert!(notes.is_empty(), "{notes:?}");
let th = deg.to_radians();
let s0 = (r + t) * (th / 2.0).tan();
let ba = th * (r + 0.5 * t);
let leg = h - s0;
// By hand, for the flange on y = 0 and its mirror image on x = 0: a point of the flange w up
// its outer face from the virtual sharp (the original edge), h along the outward normal
// (−T ≤ h ≤ 0) is (x, −w·cosθ − h·sinθ, w·sinθ − h·cosθ). Keeping x − y ≥ g/√2 on the
// inner face (h = −T) gives the end x(w) = g/√2 + T·sinθ − w·cosθ; the panel spans w = s0 … h.
let x_end = |w: f64| g / std::f64::consts::SQRT_2 + t * th.sin() - w * th.cos();
let (y0, y1) = (s0 - ba, s0 - ba - leg);
let want = [(x_end(s0), y0), (120.0 - x_end(s0), y0), (120.0 - x_end(h), y1), (x_end(h), y1)];
let got = corners_of(&s.panel(&s.flanges[0]));
assert_eq!(got.len(), 4);
for (x, y) in want {
assert!(got.iter().any(|p| p.dist(Vec2::new(x, y)) < 1e-9), "{deg}°: ({x}, {y}) not in {got:?}");
}
if deg == 90.0 {
// The rule 90° corners always had: the panel runs on s0 − (T + g/√2) past its bend.
assert!((s.flanges[0].ext.0 - (s0 - t - g / std::f64::consts::SQRT_2)).abs() < 1e-12);
assert_eq!(s.flanges[0].slope, (0.0, 0.0));
} else {
assert!((s.flanges[0].slope.0 - th.cos()).abs() < 1e-12, "{:?}", s.flanges[0].slope);
}
// Folded, the inner edges of the two ends (bottom flange 0, left flange 3) run parallel,
// `gap` apart.
let (fa, fb) = (s.fold(0), s.fold(3));
let pa = |x: f64, y: f64| apply_point(&fa, Vec3::new(x, y, t));
let pb = |x: f64, y: f64| apply_point(&fb, Vec3::new(x, y, t));
let gap = parallel_gap(pa(x_end(s0), y0), pa(x_end(h), y1), pb(y0, x_end(s0)), pb(y1, x_end(h)));
assert!(gap.is_some_and(|d| (d - g).abs() < 1e-9), "{deg}°: {gap:?}");
// The flat pattern is one piece and the folded part holds the same material (K = 0.5).
let regions = s.flat_regions().unwrap();
assert_eq!(regions.len(), 1);
let b = s.solid().unwrap();
assert!(b.validity().is_empty() && b.shell_count() == 1, "{deg}°: {:?}", b.validity());
let v = vol(&b);
assert!((v - flat_area(&s) * t).abs() < 1e-3 * v, "{deg}°: {v} vs {}", flat_area(&s) * t);
}
}
#[test]
fn a_flange_of_a_later_feature_leaves_the_corner_as_it_was() {
// The convention of reference case 75-sm-bracket-holes: the earlier flange keeps its length
// and the later one ends at its bend. Only flanges made together close toward each other.
let rv = alu();
let base = rect(Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 90.0), Vec2::new(0.0, 90.0));
let mut s = base_flange("Body1", "AL2", &rv, Plane::XY, base);
s.add_flanges(&[Vec3::new(60.0, 0.0, 2.0)], 38.6, 72f64.to_radians(), 1.0, "inside", false).unwrap();
let (made, notes) = s.add_flanges_noted(&[Vec3::new(120.0, 45.0, 2.0)], 25.0, 72f64.to_radians(), 1.0, "inside", false).unwrap();
assert!(notes.is_empty() && s.corners(&made).is_empty(), "{notes:?}");
assert!(s.flanges.iter().all(|f| f.ext == (0.0, 0.0) && f.slope == (0.0, 0.0)));
assert!((s.flanges[0].len - 120.0).abs() < 1e-9);
assert!(s.solid().is_ok() && s.flat_regions().unwrap().len() == 1);
}
#[test]
fn corners_that_are_not_square_close_with_the_gap() {
// A trapezoid: two acute and two obtuse corners.
let pts = [Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(100.0, 90.0), Vec2::new(20.0, 90.0)];
let base = Region2 { outer: Loop2::polygon(&pts).ccw(), holes: vec![] };
let mut s = base_flange("Body1", "AL2", &alu(), Plane::XY, base);
let picks = [Vec3::new(60.0, 0.0, 2.0), Vec3::new(110.0, 45.0, 2.0), Vec3::new(60.0, 90.0, 2.0), Vec3::new(10.0, 45.0, 2.0)];
let (made, notes) = s.add_flanges_noted(&picks, 38.6, 72f64.to_radians(), 1.0, "inside", false).unwrap();
assert!(notes.is_empty(), "{notes:?}");
let corners = s.corners(&made);
assert_eq!(corners.len(), 4);
// Each end's inner edge, folded.
let edge = |i: usize, at_end: bool| {
let f = &s.flanges[i];
let ba = s.allowance(f);
let (q0, q1) = if at_end {
let b = f.p + f.n * ba + f.d * (f.len + f.ext.1);
(b, b + f.n * f.leg + f.d * (f.slope.1 * f.leg))
} else {
let a = f.p + f.n * ba - f.d * f.ext.0;
(a, a + f.n * f.leg - f.d * (f.slope.0 * f.leg))
};
let w = s.fold(i);
(apply_point(&w, Vec3::new(q0.x, q0.y, 2.0)), apply_point(&w, Vec3::new(q1.x, q1.y, 2.0)))
};
for k in &corners {
let ((a0, a1), (b0, b1)) = (edge(k.a, k.a_end), edge(k.b, k.b_end));
let gap = parallel_gap(a0, a1, b0, b1);
assert!(gap.is_some_and(|d| (d - 0.5).abs() < 1e-9), "{k:?}: {gap:?}");
}
let b = s.solid().unwrap();
assert!(b.validity().is_empty() && b.shell_count() == 1, "{:?}", b.validity());
assert!((vol(&b) - flat_area(&s) * 2.0).abs() < 1e-3 * vol(&b));
}
#[test]
fn corners_that_cannot_close_stay_open_with_a_note() {
// A gap of zero would make the ends touch.
let mut rv = alu();
rv.gap = 0.0;
let (s, notes) = tray(&rv, 72.0, 38.6);
assert_eq!(notes.len(), 4, "{notes:?}");
assert!(notes.iter().all(|n| n.contains("left open") && n.contains("gap")), "{notes:?}");
assert!(s.flanges.iter().all(|f| f.ext == (0.0, 0.0) && f.slope == (0.0, 0.0)));
assert!(s.solid().is_ok());
// Flanges bending opposite ways (the second edge picked on the bottom face).
let base = rect(Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 90.0), Vec2::new(0.0, 90.0));
let mut s = base_flange("Body1", "AL2", &alu(), Plane::XY, base);
let picks = [Vec3::new(60.0, 0.0, 2.0), Vec3::new(120.0, 45.0, 0.0)];
let (_, notes) = s.add_flanges_noted(&picks, 20.0, 72f64.to_radians(), 1.0, "inside", false).unwrap();
assert!(notes.len() == 1 && notes[0].contains("opposite"), "{notes:?}");
assert!(s.solid().is_ok());
}
/// Folded, the inner edge of the end of flange `i`'s panel (`at_end`: the end past the far end
/// of its bend).
fn inner_end_edge(s: &SheetBody, i: usize, at_end: bool) -> (Vec3, Vec3) {
let f = &s.flanges[i];
let ba = s.allowance(f);
let (q0, q1) = if at_end {
let b = f.p + f.n * ba + f.d * (f.len + f.ext.1);
(b, b + f.n * f.leg + f.d * (f.slope.1 * f.leg))
} else {
let a = f.p + f.n * ba - f.d * f.ext.0;
(a, a + f.n * f.leg - f.d * (f.slope.0 * f.leg))
};
let (w, z) = (s.fold(i), if f.angle >= 0.0 { s.t } else { 0.0 });
(apply_point(&w, Vec3::new(q0.x, q0.y, z)), apply_point(&w, Vec3::new(q1.x, q1.y, z)))
}
/// At each of `corners` the two panel ends' inner edges run parallel, `gap` apart.
fn ends_are_gap_apart(s: &SheetBody, corners: &[Corner], gap: f64, what: &str) {
for k in corners {
let ((a0, a1), (b0, b1)) = (inner_end_edge(s, k.a, k.a_end), inner_end_edge(s, k.b, k.b_end));
let got = parallel_gap(a0, a1, b0, b1);
assert!(got.is_some_and(|d| (d - gap).abs() < 1e-9), "{what}: {k:?}: {got:?}");
}
}
/// The flat pattern is one piece without hairline edges, and the folded part is one valid shell
/// holding the same material (K = 0.5 keeps the volume).
fn holds_the_flat_material(s: &SheetBody, what: &str) {
let regions = s.flat_regions().unwrap_or_else(|e| panic!("{what}: no flat pattern: {e}"));
assert_eq!(regions.len(), 1, "{what}");
let shortest = regions[0].outer.segs.iter().map(Seg2::length).fold(f64::MAX, f64::min);
assert!(regions[0].holes.is_empty() && shortest > 1e-3, "{what}: shortest outline edge {shortest:e}");
assert_eq!(s.outline().unwrap_or_else(|e| panic!("{what}: no outline: {e}")).len(), 1, "{what}");
let b = s.solid().unwrap_or_else(|e| panic!("{what}: no solid: {e}"));
assert!(b.validity().is_empty() && b.shell_count() == 1, "{what}: {:?}", b.validity());
let v = vol(&b);
assert!((v - flat_area(s) * s.t).abs() < 1e-3 * v, "{what}: {v} vs {}", flat_area(s) * s.t);
}
/// The flat area a corner relief `size` across takes where the base fills the angle `alpha`
/// and both bends are `ba` wide: of the base a sector of radius size/2 (round) or the kite
/// with that side, square to both edges (rectangular), and size/2 of each bend's length.
fn relief_area(shape: ReliefShape, size: f64, alpha: f64, ba: f64) -> f64 {
let r = size / 2.0;
match shape {
ReliefShape::Notch => 0.0,
ReliefShape::Round => r * r * alpha / 2.0 + 2.0 * r * ba,
ReliefShape::Rectangular => r * r * (alpha / 2.0).tan() + 2.0 * r * ba,
}
}
#[test]
fn the_rule_names_its_corner_relief_shape() {
// A rule saved before the shape existed reads as the notch it always had.
let old: SheetRule = serde_json::from_str(r#"{"name":"Old","thickness":"2 mm","k_factor":"0.5","bend_radius":"1 mm"}"#).unwrap();
assert_eq!(old.corner_relief_shape, "notch");
assert_eq!(SheetRule::steel().corner_relief_shape, "notch");
let (doc, vals) = (Document::default(), std::collections::BTreeMap::new());
assert_eq!(doc.rule_values(&vals, &old).unwrap().corner_relief_shape, ReliefShape::Notch);
for (text, shape) in [("round", ReliefShape::Round), (" Rectangular ", ReliefShape::Rectangular), ("NOTCH", ReliefShape::Notch)] {
let rule = SheetRule { corner_relief_shape: text.into(), ..old.clone() };
assert_eq!(doc.rule_values(&vals, &rule).unwrap().corner_relief_shape, shape);
assert_eq!(ReliefShape::parse(shape.name()), Some(shape));
}
// No other name is a shape.
for text in ["square", "circle", ""] {
let err = doc.rule_values(&vals, &SheetRule { corner_relief_shape: text.into(), ..old.clone() }).unwrap_err().to_string();
assert!(err.contains(&format!("unknown corner relief shape `{text}` (notch, round or rectangular)")), "{err}");
}
}
#[test]
fn corner_reliefs_cut_the_base_corner_and_stop_the_bends() {
use std::f64::consts::{FRAC_PI_2, FRAC_PI_4};
// The tray: the bends on x = 0 and y = 0 meet at c = (s0, s0), s0 = (R + T)·tan(θ/2) in from
// the sheet's corner. A relief 8 mm across reaches 4 mm from c along both edges of the base.
for deg in [72.0f64, 90.0] {
let th = deg.to_radians();
let (s0, ba) = (3.0 * (th / 2.0).tan(), th * 2.0);
let len = 120.0 - 2.0 * s0;
let c = Vec2::new(s0, s0);
let (notch, _) = tray(&alu(), deg, 38.6);
assert_eq!(notch.base.outer.segs.len(), 4, "{deg}°: the notch cuts nothing");
for shape in [ReliefShape::Round, ReliefShape::Rectangular] {
let what = format!("{shape:?} at {deg}°");
let (s, notes) = tray(&RuleValues { corner_relief_shape: shape, ..alu() }, deg, 38.6);
assert!(notes.is_empty(), "{what}: {notes:?}");
let segs = &s.base.outer.segs;
if shape == ReliefShape::Round {
// A quarter round of radius 4 about c, from the edge x = s0 round to the edge y = s0.
assert_eq!((segs.len(), segs.iter().filter(|g| matches!(g, Seg2::Arc { .. })).count()), (8, 4), "{what}");
let found = segs.iter().any(|g| {
matches!(g, Seg2::Arc { center, radius, .. } if center.dist(c) < 1e-8 && (radius - 4.0).abs() < 1e-12)
&& g.start().dist(c + Vec2::new(0.0, 4.0)) < 1e-9
&& g.point_at(0.5).dist(c + Vec2::from_angle(FRAC_PI_4) * 4.0) < 1e-9
&& g.end().dist(c + Vec2::new(4.0, 0.0)) < 1e-9
});
assert!(found, "{what}: {segs:?}");
} else {
// A 4 mm square: in from the edge x = s0 to (s0 + 4, s0 + 4), out to the edge y = s0.
let line =
|a: Vec2, b: Vec2| segs.iter().any(|g| matches!(g, Seg2::Line { .. }) && g.start().dist(a) < 1e-9 && g.end().dist(b) < 1e-9);
assert_eq!(segs.len(), 12, "{what}");
assert!(
line(c + Vec2::new(0.0, 4.0), c + Vec2::new(4.0, 4.0)) && line(c + Vec2::new(4.0, 4.0), c + Vec2::new(4.0, 0.0)),
"{what}: {segs:?}"
);
}
// The bend on y = 0 stops 4 mm from the corners at both its ends, and the bend table follows.
let (u0, u1) = s.zone_span(&s.flanges[0]);
assert!((u0 - 4.0).abs() < 1e-9 && (u1 - (len - 4.0)).abs() < 1e-9, "{what}: {u0} {u1}");
let bend = &s.bends()[0];
let (start, end) = (Vec2::new(s0 + 4.0, s0 - ba / 2.0), Vec2::new(120.0 - s0 - 4.0, s0 - ba / 2.0));
assert!((bend.length - (len - 8.0)).abs() < 1e-9 && bend.start.dist(start) < 1e-9 && bend.end.dist(end) < 1e-9, "{what}: {bend:?}");
// Only the base changes: the flanges are the ones the closing left.
assert_eq!(s.flanges, notch.flanges, "{what}");
// The flat pattern loses, at each of its four corners, the relief in the base and 4 mm of two bends.
let (removed, want) = (flat_area(&notch) - flat_area(&s), 4.0 * relief_area(shape, 8.0, FRAC_PI_2, ba));
assert!((removed - want).abs() < 1e-6, "{what}: {removed} vs {want}");
holds_the_flat_material(&s, &what);
let unfolded = vol(&s.flat_solid().unwrap());
assert!((unfolded - flat_area(&s) * 2.0).abs() < 1e-3 * unfolded, "{what}: {unfolded}");
}
}
}
/// The trapezoid of the closing tests with four flanges at `deg`, under rule `rv`.
fn trapezoid(rv: &RuleValues, deg: f64) -> (SheetBody, Vec<String>) {
let pts = [Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(100.0, 90.0), Vec2::new(20.0, 90.0)];
let mut s = base_flange("Body1", "AL2", rv, Plane::XY, Region2 { outer: Loop2::polygon(&pts).ccw(), holes: vec![] });
let picks = [Vec3::new(60.0, 0.0, 2.0), Vec3::new(110.0, 45.0, 2.0), Vec3::new(60.0, 90.0, 2.0), Vec3::new(10.0, 45.0, 2.0)];
let (_, notes) = s.add_flanges_noted(&picks, 38.6, deg.to_radians(), rv.bend_radius, "inside", false).unwrap();
(s, notes)
}
#[test]
fn corner_reliefs_follow_corners_that_are_not_square() {
use std::f64::consts::PI;
// The base fills α = atan(90/20) = 77.47° at the two lower corners of the trapezoid and 180° − α
// at the upper ones. With every edge trimmed by s0, the bends on y = 0 and on the left edge meet
// at c = (s0/tan(α/2), s0), and the left edge leaves c in the direction (cos α, sin α).
let th = 72f64.to_radians();
let (s0, ba) = (3.0 * (th / 2.0).tan(), th * 2.0);
let alpha = 90f64.atan2(20.0);
let c = Vec2::new(s0 / (alpha / 2.0).tan(), s0);
let (along, up) = (Vec2::new(1.0, 0.0), Vec2::from_angle(alpha));
let (notch, _) = trapezoid(&alu(), 72.0);
for shape in [ReliefShape::Round, ReliefShape::Rectangular] {
let what = format!("{shape:?}");
let (s, notes) = trapezoid(&RuleValues { corner_relief_shape: shape, ..alu() }, 72.0);
assert!(notes.is_empty(), "{what}: {notes:?}");
let segs = &s.base.outer.segs;
if shape == ReliefShape::Round {
// An arc of radius 4 about c through the angle α, tangent to both bends' straight ends.
let found = segs.iter().any(|g| {
matches!(g, Seg2::Arc { center, radius, sweep, .. } if center.dist(c) < 1e-8 && (radius - 4.0).abs() < 1e-12 && (sweep + alpha).abs() < 1e-9)
&& g.start().dist(c + up * 4.0) < 1e-8
&& g.end().dist(c + along * 4.0) < 1e-8
&& g.start_tangent().dot(up).abs() < 1e-9
&& g.end_tangent().dot(along).abs() < 1e-9
});
assert!(found, "{what}: {segs:?}");
} else {
// A kite: both cuts square to their edge, meeting 4/cos(α/2) from c on the bisector.
let x = c + Vec2::from_angle(alpha / 2.0) * (4.0 / (alpha / 2.0).cos());
let line =
|a: Vec2, b: Vec2| segs.iter().any(|g| matches!(g, Seg2::Line { .. }) && g.start().dist(a) < 1e-8 && g.end().dist(b) < 1e-8);
assert!(line(c + up * 4.0, x) && line(x, c + along * 4.0), "{what}: {segs:?}");
assert!((x - (c + up * 4.0)).dot(up).abs() < 1e-9 && (x - (c + along * 4.0)).dot(along).abs() < 1e-9);
}
// Two corners of α and two of 180° − α, eight bend ends.
let want = 2.0 * relief_area(shape, 8.0, alpha, ba) + 2.0 * relief_area(shape, 8.0, PI - alpha, ba);
let removed = flat_area(&notch) - flat_area(&s);
assert!((removed - want).abs() < 1e-6, "{what}: {removed} vs {want}");
assert_eq!(s.flanges, notch.flanges, "{what}");
holds_the_flat_material(&s, &what);
}
}
#[test]
fn corner_reliefs_hold_at_every_angle_and_size() {
use std::f64::consts::{FRAC_PI_2, PI};
// The round relief runs into the bends' straight ends tangentially, a contact the planar
// booleans have to keep as one point. So every 5° from 45° to 135° is built on the tray and on
// the trapezoid with both shapes, and on the tray reliefs from 0.5 to 20 mm across.
let alpha = 90f64.atan2(20.0);
for shape in [ReliefShape::Round, ReliefShape::Rectangular] {
for k in 9..=27 {
let deg = k as f64 * 5.0;
let ba = deg.to_radians() * 2.0;
let rv = RuleValues { corner_relief_shape: shape, ..alu() };
for (name, (s, notes), (notch, _), corners) in [
("tray", tray(&rv, deg, 38.6), tray(&alu(), deg, 38.6), [FRAC_PI_2; 4]),
("trapezoid", trapezoid(&rv, deg), trapezoid(&alu(), deg), [alpha, alpha, PI - alpha, PI - alpha]),
] {
let what = format!("{shape:?} on the {name} at {deg}°");
assert!(notes.is_empty(), "{what}: {notes:?}");
let want: f64 = corners.iter().map(|a| relief_area(shape, 8.0, *a, ba)).sum();
let removed = flat_area(&notch) - flat_area(&s);
assert!((removed - want).abs() < 1e-6, "{what}: {removed} vs {want}");
holds_the_flat_material(&s, &what);
}
}
for size in [0.5, 1.0, 3.0, 5.0, 12.0, 20.0] {
for deg in [72.0f64, 90.0] {
let what = format!("{shape:?} {size} mm across at {deg}°");
let (s, notes) = tray(&RuleValues { corner_relief_shape: shape, corner_relief: size, ..alu() }, deg, 38.6);
assert!(notes.is_empty(), "{what}: {notes:?}");
let want = 4.0 * relief_area(shape, size, FRAC_PI_2, deg.to_radians() * 2.0);
let removed = flat_area(&tray(&alu(), deg, 38.6).0) - flat_area(&s);
assert!((removed - want).abs() < 1e-6, "{what}: {removed} vs {want}");
holds_the_flat_material(&s, &what);
}
}
}
}
#[test]
fn a_relief_that_does_not_fit_is_left_out_with_a_note() {
let closed = tray(&alu(), 72.0, 38.6).0;
// 200 mm across reaches 100 mm along edges of which the shorter is 90 − 2·s0 = 85.6 mm long.
// 100 mm across reaches 50 mm: that fits the edge once but not from both its ends, and all four
// corners are left out together. Nothing, or less than nothing, is too small to cut.
let sizes = [(200.0, "whole bend"), (100.0, "whole bend"), (0.0, "below 0.01 mm"), (-8.0, "below 0.01 mm"), (f64::NAN, "below 0.01 mm")];
for (size, why) in sizes {
let (s, notes) = tray(&RuleValues { corner_relief_shape: ReliefShape::Round, corner_relief: size, ..alu() }, 72.0, 38.6);
assert_eq!(notes.len(), 4, "{size}: {notes:?}");
assert!(notes.iter().all(|n| n.starts_with("no corner relief at (") && n.contains(why)), "{size}: {notes:?}");
// The part is the one the notch gives, corners closed.
assert_eq!((&s.base, &s.flanges), (&closed.base, &closed.flanges), "{size}");
}
// A 120 × 12 strip with flanges on its lower and left edges, a relief 16 mm across. Its 8 mm fit
// on both edges at the corner (the left one is 12 − s0 = 9.82 long), but the far corner of the
// square is 8·√2 = 11.3 mm from the corner of the bends, and the strip's upper edge is only 9.82
// from it: no relief may reach as far from its corner as another edge is. The round one reaches
// 8 mm and fits.
let strip = |shape: ReliefShape| {
let base = rect(Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 12.0), Vec2::new(0.0, 12.0));
let mut s = base_flange("Body1", "AL2", &RuleValues { corner_relief_shape: shape, corner_relief: 16.0, ..alu() }, Plane::XY, base);
let picks = [Vec3::new(60.0, 0.0, 2.0), Vec3::new(0.0, 6.0, 2.0)];
let (_, notes) = s.add_flanges_noted(&picks, 20.0, 72f64.to_radians(), 1.0, "inside", false).unwrap();
(s, notes)
};
let (s, notes) = strip(ReliefShape::Rectangular);
assert!(notes.len() == 1 && notes[0].contains("another edge of the base"), "{notes:?}");
assert!(s.base.outer.segs.len() == 4 && s.solid().is_ok());
let (s, notes) = strip(ReliefShape::Round);
assert!(notes.is_empty() && s.base.outer.segs.iter().filter(|g| matches!(g, Seg2::Arc { .. })).count() == 1, "{notes:?}");
holds_the_flat_material(&s, "the strip");
// A right triangle with flanges on its two legs: the base's legs are 100 − 2·s0 = 95.6 long and
// its long edge passes (100 − 2·s0)/√2 = 67.6 mm from the corner of the bends. A round relief of
// radius 75 would cut through that edge; one of radius 65 stays inside.
let triangle = |size: f64| {
let pts = [Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(0.0, 100.0)];
let rv = RuleValues { corner_relief_shape: ReliefShape::Round, corner_relief: size, ..alu() };
let mut s = base_flange("Body1", "AL2", &rv, Plane::XY, Region2 { outer: Loop2::polygon(&pts).ccw(), holes: vec![] });
let picks = [Vec3::new(50.0, 0.0, 2.0), Vec3::new(0.0, 50.0, 2.0)];
let (_, notes) = s.add_flanges_noted(&picks, 20.0, 72f64.to_radians(), 1.0, "inside", false).unwrap();
(s, notes)
};
let (s, notes) = triangle(150.0);
assert!(notes.len() == 1 && notes[0].contains("another edge of the base"), "{notes:?}");
assert!(s.base.outer.segs.len() == 3 && s.solid().is_ok());
let (s, notes) = triangle(130.0);
assert!(notes.is_empty() && s.base.outer.segs.len() == 4, "{notes:?}");
holds_the_flat_material(&s, "the triangle");
}
#[test]
fn a_corner_with_a_flange_of_an_earlier_feature_gets_no_relief() {
// Like the closing, the relief belongs to the corners between the flanges of one feature.
let rv = RuleValues { corner_relief_shape: ReliefShape::Round, ..alu() };
let base = rect(Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 90.0), Vec2::new(0.0, 90.0));
let mut s = base_flange("Body1", "AL2", &rv, Plane::XY, base);
s.add_flanges(&[Vec3::new(60.0, 0.0, 2.0)], 38.6, 72f64.to_radians(), 1.0, "inside", false).unwrap();
let (_, notes) = s.add_flanges_noted(&[Vec3::new(120.0, 45.0, 2.0)], 25.0, 72f64.to_radians(), 1.0, "inside", false).unwrap();
assert!(notes.is_empty(), "{notes:?}");
assert!(s.base.outer.segs.iter().all(|g| matches!(g, Seg2::Line { .. })));
assert!(s.solid().is_ok() && s.flat_regions().unwrap().len() == 1);
}
#[test]
fn closed_corners_keep_their_flat_pattern_at_every_angle() {
// 45° to 135° in steps of 5° on the tray and on a trapezoid. Which angles lose the flat
// pattern to a rounding slip in the planar arrangement is a matter of luck (50°, 60°, 65°,
// 75°, 105° and 110° did on the tray), so every one is built.
let tray = [Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 90.0), Vec2::new(0.0, 90.0)];
let tray_picks = [Vec3::new(60.0, 0.0, 2.0), Vec3::new(120.0, 45.0, 2.0), Vec3::new(60.0, 90.0, 2.0), Vec3::new(0.0, 45.0, 2.0)];
let trapezoid = [Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(100.0, 90.0), Vec2::new(20.0, 90.0)];
let trapezoid_picks = [Vec3::new(60.0, 0.0, 2.0), Vec3::new(110.0, 45.0, 2.0), Vec3::new(60.0, 90.0, 2.0), Vec3::new(10.0, 45.0, 2.0)];
for (name, pts, picks) in [("tray", tray, tray_picks), ("trapezoid", trapezoid, trapezoid_picks)] {
for k in 9..=27 {
let deg = k as f64 * 5.0;
let what = format!("{name} at {deg}°");
let base = Region2 { outer: Loop2::polygon(&pts).ccw(), holes: vec![] };
let mut s = base_flange("Body1", "AL2", &alu(), Plane::XY, base);
let (made, notes) = s.add_flanges_noted(&picks, 38.6, deg.to_radians(), 1.0, "inside", false).unwrap();
assert!(notes.is_empty(), "{what}: {notes:?}");
let corners = s.corners(&made);
assert_eq!(corners.len(), 4, "{what}");
ends_are_gap_apart(&s, &corners, 0.5, &what);
holds_the_flat_material(&s, &what);
}
}
}
/// Edge flanges on `base`, each with its own height and angle, added in one call. Returns the
/// sheet and the notes on corners left open.
fn flanges_each(base: Region2, rv: &RuleValues, each: &[EdgeFlange]) -> (SheetBody, Vec<String>) {
let mut s = base_flange("Body1", "AL2", rv, Plane::XY, base);
let notes = s.add_flanges_each(each, rv.bend_radius, "inside", false).unwrap().1;
(s, notes)
}
/// The 120 x 90 tray's four flanges (bottom, right, top, left), each with its own angle
/// (degrees) and height.
fn tray_each(rv: &RuleValues, angles: [f64; 4], heights: [f64; 4]) -> (SheetBody, Vec<String>) {
let base = rect(Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 90.0), Vec2::new(0.0, 90.0));
let t = rv.thickness;
let picks = [Vec3::new(60.0, 0.0, t), Vec3::new(120.0, 45.0, t), Vec3::new(60.0, 90.0, t), Vec3::new(0.0, 45.0, t)];
let each: Vec<EdgeFlange> = (0..4).map(|i| EdgeFlange { pick: picks[i], height: heights[i], angle: angles[i].to_radians() }).collect();
flanges_each(base, rv, &each)
}
/// The material of a base flange bent up, for asking whether a folded point lies in it. Worked
/// out from the fold on its own (not through `map3`): the panel through the inverse of its fold,
/// the bend from the radius and turn about the bend's axis.
struct Material {
back: Mat,
panel: Vec<Vec2>,
axis: (Vec3, Vec3),
p: Vec2,
d: Vec2,
span: (f64, f64),
radius: f64,
angle: f64,
t: f64,
}
impl Material {
fn of(s: &SheetBody, i: usize) -> Material {
let f = &s.flanges[i];
assert!(f.parent.is_none() && f.angle > 0.0);
let (o, k, _) = s.bend_axis(f);
Material {
back: mat_inverse(&s.fold(i)).unwrap(),
panel: corners_of(&s.panel(f)),
axis: (o, k),
p: f.p,
d: f.d,
span: s.zone_span(f),
radius: f.radius,
angle: f.angle,
t: s.t,
}
}
/// How deep (mm) `q` lies inside the flange's panel or bend; None when it is outside both.
fn depth(&self, q: Vec3) -> Option<f64> {
let flat = apply_point(&self.back, q);
let m = self.panel.len();
let sides = (0..m).map(|j| {
let (a, b) = (self.panel[j], self.panel[(j + 1) % m]);
(b - a).cross(Vec2::new(flat.x, flat.y) - a) / (b - a).len()
});
let in_panel = sides.fold(flat.z.min(self.t - flat.z), f64::min);
// The bend turns the sheet under its axis toward the flange: a point's distance from
// the axis is its radius, its turn from straight below the axis its place across the bend.
let (o, k) = self.axis;
let rel = q - o;
let perp = rel - k * rel.dot(k);
let down = Vec3::Z * -1.0;
let (rho, turn) = (perp.len(), perp.dot(k.cross(down)).atan2(perp.dot(down)));
let u = (Vec2::new(q.x, q.y) - self.p).dot(self.d);
let in_bend = [rho - self.radius, self.radius + self.t - rho, turn * rho, (self.angle - turn) * rho, u - self.span.0, self.span.1 - u]
.into_iter()
.fold(f64::INFINITY, f64::min);
let depth = in_panel.max(in_bend);
(depth > 0.0).then_some(depth)
}
}
/// Folded, the two flanges at corner `k` keep out of each other: no point of one's bend or
/// panel near the corner (sampled every 0.5 mm or finer, at three heights through the sheet)
/// lies in the other's. Each sample is also found inside its own flange, which checks
/// [`Material`] against the fold that places the solid's faces.
fn flanges_keep_clear(s: &SheetBody, k: &Corner, what: &str) {
let (ma, mb) = (Material::of(s, k.a), Material::of(s, k.b));
for (i, at_end, own, other) in [(k.a, k.a_end, &ma, &mb), (k.b, k.b_end, &mb, &ma)] {
let f = &s.flanges[i];
let ba = s.allowance(f);
let mut samples = Vec::new();
// The bend's 12 mm nearest the corner.
let (u0, u1) = s.zone_span(f);
for iu in 0..48 {
let du = 0.02 + 0.25 * iu as f64;
let u = if at_end { u1 - du } else { u0 + du };
for is in 0..24 {
samples.push((Piece::Zone(i), f.p + f.d * u + f.n * (ba * (is as f64 + 0.5) / 24.0)));
}
}
// The panel's 25 mm nearest its end, over its whole height.
let (q0, q1, inward) = if at_end {
let b = f.p + f.n * ba + f.d * (f.len + f.ext.1);
(b, b + f.n * f.leg + f.d * (f.slope.1 * f.leg), f.d * -1.0)
} else {
let a = f.p + f.n * ba - f.d * f.ext.0;
(a, a + f.n * f.leg - f.d * (f.slope.0 * f.leg), f.d)
};
let rows = (f.leg / 0.5).ceil().max(1.0) as usize;
for ir in 0..rows {
let on_end = q0 + (q1 - q0) * ((ir as f64 + 0.5) / rows as f64);
for iu in 0..50 {
samples.push((Piece::Panel(Some(i)), on_end + inward * (0.02 + 0.5 * iu as f64)));
}
}
for (piece, flat) in samples {
for z in [0.1 * s.t, 0.5 * s.t, 0.9 * s.t] {
let q = s.map3(piece, flat, z);
assert!(own.depth(q).is_some(), "{what}: flange {i}: its own point {flat:?} at {z} ({piece:?}) is not found in it");
let d = other.depth(q);
assert!(
d.is_none_or(|d| d < 1e-6),
"{what}: flange {i}'s {piece:?} at {flat:?}, height {z}, lies {d:?} mm inside the other flange"
);
}
}
}
}
/// The panel of flange `i` of the tray by hand, in the flat pattern. Take a flange at θ on the
/// edge y = 0 and its neighbour at φ on x = 0, T the thickness. A point of the first one's inner
/// face, w up its outer face from the virtual sharp, is (x, −w·cosθ + T·sinθ, w·sinθ + T·cosθ);
/// the inner faces are the planes y·sinθ + z·cosθ = T and x·sinφ + z·cosφ = T, and the plane
/// halving them passes through the corner with normal (sinφ, −sinθ, cosφ − cosθ), of length
/// √(2 − 2·cosθ·cosφ). Keeping the inner face gap/2 from it gives the panel's end
/// x(w) = ((g/2)·√(2 − 2·cosθ·cosφ) + T·(1 − cosθ·cosφ) − w·sinθ·cosφ) / sinφ for w = s0 … h, at
/// flat y = s0 − BA − (w − s0). Each flange is taken in its own frame: the origin at the start
/// corner of its edge, u along the edge, v into the base; flange i − 1 is at its start and
/// flange i + 1 at its end.
fn hand_panel(rv: &RuleValues, angles: [f64; 4], heights: [f64; 4], i: usize) -> Vec<Vec2> {
let (t, r, g) = (rv.thickness, rv.bend_radius, rv.gap);
let (o, d, v, len) = [
(Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0), 120.0),
(Vec2::new(120.0, 0.0), Vec2::new(0.0, 1.0), Vec2::new(-1.0, 0.0), 90.0),
(Vec2::new(120.0, 90.0), Vec2::new(-1.0, 0.0), Vec2::new(0.0, -1.0), 120.0),
(Vec2::new(0.0, 90.0), Vec2::new(0.0, -1.0), Vec2::new(1.0, 0.0), 90.0),
][i];
let th = angles[i].to_radians();
let (s0, ba) = ((r + t) * (th / 2.0).tan(), th * (r + rv.k_factor * t));
let x_end = |phi: f64, w: f64| {
let c = th.cos() * phi.cos();
((g / 2.0) * (2.0 - 2.0 * c).sqrt() + t * (1.0 - c) - w * th.sin() * phi.cos()) / phi.sin()
};
let (prev, next) = (angles[(i + 3) % 4].to_radians(), angles[(i + 1) % 4].to_radians());
let flat = |u: f64, w: f64| o + d * u + v * (s0 - ba - (w - s0));
let h = heights[i];
vec![flat(x_end(prev, s0), s0), flat(x_end(prev, h), h), flat(len - x_end(next, h), h), flat(len - x_end(next, s0), s0)]
}
/// The tray's flat area by hand: the base inside the four bend start lines, every bend zone
/// (the base's trimmed edge by the bend allowance) and every panel of [`hand_panel`]. With
/// s0 = (R + T)·tan(θ/2) a flange's setback and BA = θ·(R + K·T) its bend allowance, the base
/// is (120 − s0_left − s0_right) by (90 − s0_bottom − s0_top).
fn hand_flat_area(rv: &RuleValues, angles: [f64; 4], heights: [f64; 4]) -> f64 {
let s0 = angles.map(|a| (rv.bend_radius + rv.thickness) * (a.to_radians() / 2.0).tan());
let ba = angles.map(|a| a.to_radians() * (rv.bend_radius + rv.k_factor * rv.thickness));
let (w, h) = (120.0 - s0[3] - s0[1], 90.0 - s0[0] - s0[2]);
let panels: f64 = (0..4).map(|i| signed_area(&hand_panel(rv, angles, heights, i)).abs()).sum();
w * h + (ba[0] + ba[2]) * w + (ba[1] + ba[3]) * h + panels
}
/// 60°, 90°, 105° and 120° on the tray's bottom, right, top and left edges.
const MIXED: [f64; 4] = [60.0, 90.0, 105.0, 120.0];
#[test]
fn flanges_with_their_own_angle_and_height_close_at_their_corners() {
// Four angles on a 120 x 90 tray, then four heights as well, in one call: every panel ends
// where the hand derivation says, whatever its neighbour's angle and height.
let rv = alu();
for (heights, what) in [([38.6; 4], "one height"), ([38.6, 30.0, 45.0, 25.0], "four heights")] {
let (s, notes) = tray_each(&rv, MIXED, heights);
assert!(notes.is_empty(), "{what}: {notes:?}");
for i in 0..4 {
let (f, th) = (&s.flanges[i], MIXED[i].to_radians());
assert!((f.angle - th).abs() < 1e-12, "{what}: flange {i}");
assert!((f.leg - (heights[i] - (rv.bend_radius + rv.thickness) * (th / 2.0).tan())).abs() < 1e-9, "{what}: flange {i}");
let got = corners_of(&s.panel(f));
assert_eq!(got.len(), 4, "{what}: flange {i}");
for q in hand_panel(&rv, MIXED, heights, i) {
assert!(got.iter().any(|p| p.dist(q) < 1e-9), "{what}: flange {i}: {q:?} not in {got:?}");
}
}
// The flat pattern is the base, the bend zones and those panels, nothing more or less;
// folded it holds the same material (`holds_the_flat_material`), so the volume is this
// area times the thickness.
let (area, want) = (flat_area(&s), hand_flat_area(&rv, MIXED, heights));
assert!((area - want).abs() < 1e-6, "{what}: flat area {area} vs {want}");
let corners = s.corners(&[0, 1, 2, 3]);
assert_eq!(corners.len(), 4, "{what}");
ends_are_gap_apart(&s, &corners, rv.gap, what);
for k in &corners {
flanges_keep_clear(&s, k, what);
}
holds_the_flat_material(&s, what);
}
}
#[test]
fn a_trapezoid_with_unequal_angles_and_heights_closes_at_its_acute_and_obtuse_corners() {
// 77.5° and 102.5° base corners; flanges at 60°, 105°, 75° and 120°, each its own height.
let pts = [Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(100.0, 90.0), Vec2::new(20.0, 90.0)];
let picks = [Vec3::new(60.0, 0.0, 2.0), Vec3::new(110.0, 45.0, 2.0), Vec3::new(60.0, 90.0, 2.0), Vec3::new(10.0, 45.0, 2.0)];
let (angles, heights) = ([60.0f64, 105.0, 75.0, 120.0], [38.6, 30.0, 45.0, 25.0]);
let each: Vec<EdgeFlange> = (0..4).map(|i| EdgeFlange { pick: picks[i], height: heights[i], angle: angles[i].to_radians() }).collect();
let (s, notes) = flanges_each(Region2 { outer: Loop2::polygon(&pts).ccw(), holes: vec![] }, &alu(), &each);
assert!(notes.is_empty(), "{notes:?}");
for i in 0..4 {
let (f, th) = (&s.flanges[i], angles[i].to_radians());
assert!((f.angle - th).abs() < 1e-12 && (f.leg - (heights[i] - 3.0 * (th / 2.0).tan())).abs() < 1e-9, "flange {i}: {f:?}");
}
let corners = s.corners(&[0, 1, 2, 3]);
assert_eq!(corners.len(), 4);
ends_are_gap_apart(&s, &corners, 0.5, "trapezoid");
for k in &corners {
flanges_keep_clear(&s, k, "trapezoid");
}
holds_the_flat_material(&s, "trapezoid");
}
/// The angles of the sweep below.
const SWEEP: [f64; 7] = [45.0, 60.0, 75.0, 90.0, 105.0, 120.0, 135.0];
#[test]
fn every_pair_of_angles_closes_at_any_base_corner() {
// A parallelogram base (120 and 90 long) with a corner of 60°, 90° or 120° at the origin;
// flanges 38.6 high on its two edges there, in one call, at every pair of the angles: 147
// corners, each closed with the gap, the flanges clear of each other, the part one valid
// shell holding the flat pattern's material.
let rv = alu();
let mut cases = 0;
for alpha in [60.0f64, 90.0, 120.0] {
let u = Vec2::new(alpha.to_radians().cos(), alpha.to_radians().sin());
let pts = [Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 0.0) + u * 90.0, u * 90.0];
for a in SWEEP {
for b in SWEEP {
let what = format!("corner {alpha}°, flanges {a}° and {b}°");
let each = [
EdgeFlange { pick: Vec3::new(60.0, 0.0, 2.0), height: 38.6, angle: a.to_radians() },
EdgeFlange { pick: Vec3::new(u.x * 45.0, u.y * 45.0, 2.0), height: 38.6, angle: b.to_radians() },
];
let (s, notes) = flanges_each(Region2 { outer: Loop2::polygon(&pts).ccw(), holes: vec![] }, &rv, &each);
assert!(notes.is_empty(), "{what}: {notes:?}");
assert!((s.flanges[0].angle - a.to_radians()).abs() < 1e-12 && (s.flanges[1].angle - b.to_radians()).abs() < 1e-12, "{what}");
let corners = s.corners(&[0, 1]);
assert_eq!(corners.len(), 1, "{what}");
ends_are_gap_apart(&s, &corners, rv.gap, &what);
flanges_keep_clear(&s, &corners[0], &what);
holds_the_flat_material(&s, &what);
cases += 1;
}
}
}
assert_eq!(cases, 147);
}
#[test]
fn corner_reliefs_are_cut_between_flanges_of_unequal_angle_and_height() {
use std::f64::consts::FRAC_PI_2;
// The relief is cut in the base, so flanges with their own angle and height get the same
// one: 4 mm into the base at each of its four corners, and every bend 4 mm shorter at both
// ends, each over its own width BA = θ·(R + K·T).
let heights = [38.6, 30.0, 45.0, 25.0];
let (notch, _) = tray_each(&alu(), MIXED, heights);
for shape in [ReliefShape::Round, ReliefShape::Rectangular] {
let what = format!("{shape:?}");
let (s, notes) = tray_each(&RuleValues { corner_relief_shape: shape, ..alu() }, MIXED, heights);
assert!(notes.is_empty(), "{what}: {notes:?}");
assert_eq!(s.flanges, notch.flanges, "{what}");
// The bend on y = 0 runs between the setbacks of its neighbours at 120° and 90°:
// 120 − 3·tan 60° − 3·tan 45° long, less the two reliefs.
let (bends, before) = (s.bends(), notch.bends());
assert_eq!((bends.len(), before.len()), (4, 4), "{what}");
assert!((bends[0].length - (120.0 - 3.0 * 60f64.to_radians().tan() - 3.0 - 8.0)).abs() < 1e-9, "{what}: {}", bends[0].length);
for (b, b0) in bends.iter().zip(&before) {
assert!((b.length - (b0.length - 8.0)).abs() < 1e-9, "{what}: {} vs {}", b.length, b0.length);
}
let in_the_bends: f64 = MIXED.iter().map(|deg| 8.0 * deg.to_radians() * 2.0).sum();
let (removed, want) = (flat_area(&notch) - flat_area(&s), 4.0 * relief_area(shape, 8.0, FRAC_PI_2, 0.0) + in_the_bends);
assert!((removed - want).abs() < 1e-6, "{what}: {removed} vs {want}");
holds_the_flat_material(&s, &what);
}
}
}