From 7b2a80add179d74d1e9644aeb96cceb81b57150b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 9 May 2026 20:45:45 +0000 Subject: [PATCH] =?UTF-8?q?feat(sheet-metal):=20foundation=20tier=20?= =?UTF-8?q?=E2=80=94=20vcad-kernel-sheet=20+=20design=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lays the legendary architecture from docs/design/sheet-metal.md: a SheetMetalModel as a graph of flat panels connected by cylindrical bends, where the graph is the source of truth and both bent 3D and flat-pattern views are derived. This makes unfold/refold inverses by construction — the tests prove `refold ∘ unfold = identity` on bent frames within 1e-9, with no drift across 10 round-trips. Foundation tier: - Panel + Bend + SheetMetalModel with BFS traversal - BendTable with K-factor lookup and KFactorSource provenance (Builtin/Shop/Measured/Manual) — drives the colored dot in the UI - base_flange_rect / base_flange_polygon - add_edge_flange with manual_k override - unfold / refold operating in place on the model - FlatPattern projection for DXF / nesting / flat editor - 33 tests covering geometry, allowance math, involution, drift Layered behind this: the strategic spec in docs/design/sheet-metal.md covering bends-as-typed-topology, lossless bidirectional unfold, open community bend-table registry, manufacturability-as-MCP, springback physics, costing, and the dual-view + flat-as-peer UI plan. https://claude.ai/code/session_019XYMEQuLaAX45cqDtpyen7 --- Cargo.lock | 7 + Cargo.toml | 3 + crates/vcad-kernel-sheet/Cargo.toml | 12 + crates/vcad-kernel-sheet/src/base_flange.rs | 144 +++++ crates/vcad-kernel-sheet/src/bend_table.rs | 278 ++++++++++ crates/vcad-kernel-sheet/src/edge_flange.rs | 437 +++++++++++++++ crates/vcad-kernel-sheet/src/lib.rs | 36 ++ crates/vcad-kernel-sheet/src/model.rs | 321 +++++++++++ crates/vcad-kernel-sheet/src/unfold.rs | 566 ++++++++++++++++++++ docs/design/sheet-metal.md | 414 ++++++++++++++ 10 files changed, 2218 insertions(+) create mode 100644 crates/vcad-kernel-sheet/Cargo.toml create mode 100644 crates/vcad-kernel-sheet/src/base_flange.rs create mode 100644 crates/vcad-kernel-sheet/src/bend_table.rs create mode 100644 crates/vcad-kernel-sheet/src/edge_flange.rs create mode 100644 crates/vcad-kernel-sheet/src/lib.rs create mode 100644 crates/vcad-kernel-sheet/src/model.rs create mode 100644 crates/vcad-kernel-sheet/src/unfold.rs create mode 100644 docs/design/sheet-metal.md diff --git a/Cargo.lock b/Cargo.lock index bf3b21da..606f7c5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7026,6 +7026,13 @@ dependencies = [ "wgpu", ] +[[package]] +name = "vcad-kernel-sheet" +version = "0.9.4" +dependencies = [ + "vcad-kernel-math", +] + [[package]] name = "vcad-kernel-shell" version = "0.9.4" diff --git a/Cargo.toml b/Cargo.toml index 3d4838fd..38909bf1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "crates/vcad-kernel-text", "crates/vcad-kernel-sweep", "crates/vcad-kernel-shell", + "crates/vcad-kernel-sheet", "crates/vcad-kernel-step", "crates/vcad-kernel", "crates/vcad-kernel-constraints", @@ -76,6 +77,7 @@ default-members = [ "crates/vcad-kernel-text", "crates/vcad-kernel-sweep", "crates/vcad-kernel-shell", + "crates/vcad-kernel-sheet", "crates/vcad-kernel-step", "crates/vcad-kernel", "crates/vcad-kernel-constraints", @@ -154,6 +156,7 @@ vcad-kernel-fillet = { path = "crates/vcad-kernel-fillet", version = "0.9.4" } vcad-kernel-sketch = { path = "crates/vcad-kernel-sketch", version = "0.9.4" } vcad-kernel-sweep = { path = "crates/vcad-kernel-sweep", version = "0.9.4" } vcad-kernel-shell = { path = "crates/vcad-kernel-shell", version = "0.9.4" } +vcad-kernel-sheet = { path = "crates/vcad-kernel-sheet", version = "0.9.4" } vcad-kernel-step = { path = "crates/vcad-kernel-step", version = "0.9.4" } vcad-kernel-constraints = { path = "crates/vcad-kernel-constraints", version = "0.9.4" } vcad-kernel-drafting = { path = "crates/vcad-kernel-drafting", version = "0.9.4" } diff --git a/crates/vcad-kernel-sheet/Cargo.toml b/crates/vcad-kernel-sheet/Cargo.toml new file mode 100644 index 00000000..b4433f6f --- /dev/null +++ b/crates/vcad-kernel-sheet/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "vcad-kernel-sheet" +description = "Sheet-metal modeling for the vcad kernel: flanges, bends, lossless unfold" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +vcad-kernel-math = { workspace = true } + +[dev-dependencies] diff --git a/crates/vcad-kernel-sheet/src/base_flange.rs b/crates/vcad-kernel-sheet/src/base_flange.rs new file mode 100644 index 00000000..99e6f101 --- /dev/null +++ b/crates/vcad-kernel-sheet/src/base_flange.rs @@ -0,0 +1,144 @@ +//! [`SheetMetalModel`] constructors — the "base flange" operations. +//! +//! Foundation tier supports a rectangular base flange and a generic +//! polygon outline. Sketch-driven flanges (via `vcad-kernel-sketch`) come +//! later; we don't drag the sketch crate into the foundation. + +use crate::model::{Frame, Panel, SheetMetalModel}; +use vcad_kernel_math::Point2; + +/// Errors returned by base-flange construction. +#[derive(Debug, Clone, PartialEq)] +pub enum BaseFlangeError { + /// Thickness must be > 0. + InvalidThickness(f64), + /// Outline must have at least 3 distinct points. + OutlineTooSmall(usize), + /// Width / depth must be > 0. + NonPositiveDimension(&'static str, f64), +} + +impl std::fmt::Display for BaseFlangeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BaseFlangeError::InvalidThickness(t) => write!(f, "thickness must be > 0, got {t}"), + BaseFlangeError::OutlineTooSmall(n) => { + write!(f, "outline needs >= 3 points, got {n}") + } + BaseFlangeError::NonPositiveDimension(name, v) => { + write!(f, "{name} must be > 0, got {v}") + } + } + } +} + +impl std::error::Error for BaseFlangeError {} + +/// Build a sheet-metal model from a closed polygon outline. +/// +/// The outline lies in the XY plane; the panel's outside face is on +Z and +/// inside face on -Z, with the panel's inside surface at z=0. +pub fn base_flange_polygon( + outline: Vec, + thickness: f64, +) -> Result { + if thickness <= 0.0 || thickness.is_nan() { + return Err(BaseFlangeError::InvalidThickness(thickness)); + } + if outline.len() < 3 { + return Err(BaseFlangeError::OutlineTooSmall(outline.len())); + } + let mut model = SheetMetalModel::new(thickness); + let panel = Panel { + outline, + holes: Vec::new(), + frame_bent: Frame::identity(), + frame_flat: Frame::identity(), + incident_bends: Vec::new(), + }; + model.root = model.push_panel(panel); + Ok(model) +} + +/// Build a sheet-metal model from an axis-aligned rectangle in the XY plane. +/// +/// Corner at origin, extending into +X and +Y by `(width, depth)`. The panel's +/// outside face points along +Z. +pub fn base_flange_rect( + width: f64, + depth: f64, + thickness: f64, +) -> Result { + if width <= 0.0 || width.is_nan() { + return Err(BaseFlangeError::NonPositiveDimension("width", width)); + } + if depth <= 0.0 || depth.is_nan() { + return Err(BaseFlangeError::NonPositiveDimension("depth", depth)); + } + base_flange_polygon( + vec![ + Point2::new(0.0, 0.0), + Point2::new(width, 0.0), + Point2::new(width, depth), + Point2::new(0.0, depth), + ], + thickness, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rect_creates_single_panel() { + let m = base_flange_rect(100.0, 50.0, 1.0).unwrap(); + assert_eq!(m.panels.len(), 1); + assert_eq!(m.bends.len(), 0); + assert_eq!(m.root, 0); + assert_eq!(m.thickness, 1.0); + assert_eq!(m.panels[0].outline.len(), 4); + } + + #[test] + fn rect_rejects_zero_thickness() { + assert!(matches!( + base_flange_rect(100.0, 50.0, 0.0), + Err(BaseFlangeError::InvalidThickness(_)) + )); + } + + #[test] + fn rect_rejects_negative_dim() { + assert!(matches!( + base_flange_rect(-1.0, 50.0, 1.0), + Err(BaseFlangeError::NonPositiveDimension("width", _)) + )); + assert!(matches!( + base_flange_rect(100.0, -1.0, 1.0), + Err(BaseFlangeError::NonPositiveDimension("depth", _)) + )); + } + + #[test] + fn polygon_rejects_degenerate_outline() { + assert!(matches!( + base_flange_polygon(vec![Point2::new(0.0, 0.0), Point2::new(1.0, 0.0)], 1.0), + Err(BaseFlangeError::OutlineTooSmall(2)) + )); + } + + #[test] + fn rect_outline_is_ccw() { + // Signed area of a CCW polygon is positive. + let m = base_flange_rect(10.0, 5.0, 1.0).unwrap(); + let outline = &m.panels[0].outline; + let area: f64 = outline + .windows(2) + .map(|w| w[0].x * w[1].y - w[1].x * w[0].y) + .sum::() + + (outline.last().unwrap().x * outline.first().unwrap().y + - outline.first().unwrap().x * outline.last().unwrap().y); + assert!(area > 0.0, "outline not CCW: signed area {area}"); + } +} diff --git a/crates/vcad-kernel-sheet/src/bend_table.rs b/crates/vcad-kernel-sheet/src/bend_table.rs new file mode 100644 index 00000000..d23f43dd --- /dev/null +++ b/crates/vcad-kernel-sheet/src/bend_table.rs @@ -0,0 +1,278 @@ +//! Bend tables: queryable `(material, t, R, ...) → (BA, K, springback)`. +//! +//! Replaces the "single global K-factor lie" with a structured, provenanced +//! lookup. Every bend in a model carries a [`KFactorSource`] pointing back +//! to the table row that produced its allowance, so changing the table +//! propagates to the model deterministically. +//! +//! For the foundation tier we ship only the math (`BA = θ·(R + K·t)`) and a +//! tiny built-in table of common K-factors. The full registry — community +//! submissions, measured-vs-predicted residuals, shop overrides — lives in +//! `vcad-kernel-bend-tables` (later tier). + +/// Result of a bend-allowance computation, with provenance. +#[derive(Debug, Clone, PartialEq)] +pub struct BendAllowance { + /// Bend allowance: arc length of the neutral axis through the bend (mm). + pub ba: f64, + /// Bend deduction: amount subtracted from theoretical sharp-corner + /// flange-sum to get the flat-pattern length (mm). + pub bd: f64, + /// K-factor used. + pub k_factor: f64, + /// Where this K came from (e.g. `"builtin:Al-soft/R1.0t1.0"`). + pub source: String, +} + +/// Where a K-factor was sourced from. Drives the colored provenance dot in +/// the property panel. +#[derive(Debug, Clone, PartialEq)] +pub enum KFactorSource { + /// Built-in default table (green dot in UI). + Builtin { + /// Stable key into the built-in table (e.g. `"Al-soft/R1.0t1.0"`). + key: String, + }, + /// Shop-provided override table (blue dot in UI). + Shop { + /// Identifier of the shop profile. + shop_id: String, + /// Row key inside the shop's table. + key: String, + }, + /// Measured-on-coupons override (purple dot in UI). + Measured { + /// Free-form note (e.g. operator initials, date, coupon batch). + note: String, + }, + /// Manual user override (no provenance — surfaced as a warning). + Manual, +} + +impl KFactorSource { + /// Render to the short string stored on each [`crate::Bend`]. + pub fn label(&self) -> String { + match self { + KFactorSource::Builtin { key } => format!("builtin:{key}"), + KFactorSource::Shop { shop_id, key } => format!("shop:{shop_id}/{key}"), + KFactorSource::Measured { note } => format!("measured:{note}"), + KFactorSource::Manual => "manual".to_string(), + } + } +} + +/// A queryable bend table. The foundation-tier implementation is a flat list +/// of rows; later tiers replace this with an interpolating model. +#[derive(Debug, Clone, PartialEq)] +pub struct BendTable { + /// Identifier (e.g. `"builtin"`, `"shop:acme-machining"`). + pub id: String, + /// Rows. + pub rows: Vec, +} + +/// A single row in a [`BendTable`]. +#[derive(Debug, Clone, PartialEq)] +pub struct BendTableRow { + /// Material name (free-form for now; later: a typed registry). + pub material: String, + /// Material thickness (mm). + pub thickness: f64, + /// Inside bend radius (mm). + pub radius: f64, + /// K-factor. + pub k_factor: f64, +} + +impl BendTableRow { + /// `R/t` ratio. + pub fn r_over_t(&self) -> f64 { + self.radius / self.thickness + } +} + +impl BendTable { + /// Build the curated default table. + /// + /// Sources: Machinery's Handbook + DIN 6935 typical values. These are + /// **starting points**; real shops calibrate against measured coupons + /// and contribute corrections back to the open registry. + pub fn builtin() -> Self { + // Material × thickness × radius → K. K varies primarily with R/t and + // material hardness; we encode a tractable cross-section. + let rows = vec![ + // Aluminum (soft, e.g. 1100, 3003) + row("Al-soft", 1.0, 0.5, 0.33), + row("Al-soft", 1.0, 1.0, 0.35), + row("Al-soft", 1.0, 2.0, 0.37), + row("Al-soft", 1.0, 3.0, 0.38), + row("Al-soft", 1.5, 1.5, 0.35), + row("Al-soft", 2.0, 2.0, 0.36), + // Aluminum (hard, e.g. 6061-T6) + row("Al-hard", 1.0, 1.0, 0.40), + row("Al-hard", 1.0, 2.0, 0.42), + row("Al-hard", 1.5, 1.5, 0.41), + row("Al-hard", 2.0, 3.0, 0.44), + // Mild steel (CRS, A36) + row("Steel-mild", 1.0, 1.0, 0.40), + row("Steel-mild", 1.0, 2.0, 0.43), + row("Steel-mild", 1.5, 1.5, 0.42), + row("Steel-mild", 2.0, 2.0, 0.44), + row("Steel-mild", 3.0, 3.0, 0.45), + // Stainless 304 + row("SS-304", 1.0, 1.0, 0.44), + row("SS-304", 1.0, 2.0, 0.47), + row("SS-304", 1.5, 1.5, 0.45), + row("SS-304", 2.0, 2.0, 0.47), + ]; + Self { + id: "builtin".to_string(), + rows, + } + } + + /// Look up the K-factor for a `(material, thickness, radius)` query. + /// + /// Returns the K-factor and a [`KFactorSource`] tagging the row used. + /// Falls back to the **closest row by `R/t` for that material** when no + /// exact match exists; if the material is unknown, returns `None` and + /// the caller should fall back to a manual K-factor (with a warning in + /// the UI). + pub fn lookup( + &self, + material: &str, + thickness: f64, + radius: f64, + ) -> Option<(f64, KFactorSource)> { + let target_rt = radius / thickness; + let mut best: Option<(&BendTableRow, f64)> = None; + for row in &self.rows { + if row.material != material { + continue; + } + let dist = (row.r_over_t() - target_rt).abs() + (row.thickness - thickness).abs() * 0.1; + match best { + None => best = Some((row, dist)), + Some((_, d)) if dist < d => best = Some((row, dist)), + _ => {} + } + } + best.map(|(row, _)| { + let key = format!("{}/R{:.2}t{:.2}", row.material, row.radius, row.thickness); + (row.k_factor, KFactorSource::Builtin { key }) + }) + } +} + +fn row(material: &'static str, thickness: f64, radius: f64, k_factor: f64) -> BendTableRow { + BendTableRow { + material: material.to_string(), + thickness, + radius, + k_factor, + } +} + +/// Compute the bend allowance for an angle/radius/K-factor/thickness. +/// +/// `BA = θ · (R + K · t)`. The sign of `θ` is ignored (always positive +/// allowance for any non-zero bend). +pub fn bend_allowance(angle_rad: f64, radius: f64, k_factor: f64, thickness: f64) -> f64 { + angle_rad.abs() * (radius + k_factor * thickness) +} + +/// Compute the bend deduction for an angle/radius/K-factor/thickness. +/// +/// `BD = 2(R + t) · tan(θ/2) - BA`. +pub fn bend_deduction(angle_rad: f64, radius: f64, k_factor: f64, thickness: f64) -> f64 { + let ba = bend_allowance(angle_rad, radius, k_factor, thickness); + 2.0 * (radius + thickness) * (angle_rad.abs() / 2.0).tan() - ba +} + +#[cfg(test)] +mod tests { + use super::*; + use std::f64::consts::FRAC_PI_2; + + #[test] + fn allowance_at_90_deg_matches_classic_formula() { + // 90° bend, R=1, K=0.4, t=1 → BA = (π/2)·1.4 + let ba = bend_allowance(FRAC_PI_2, 1.0, 0.4, 1.0); + let expected = FRAC_PI_2 * 1.4; + assert!((ba - expected).abs() < 1e-12); + } + + #[test] + fn allowance_is_zero_for_zero_angle() { + assert!(bend_allowance(0.0, 1.0, 0.4, 1.0).abs() < 1e-15); + } + + #[test] + fn deduction_consistent_with_setback() { + // For 90° bend: OSSB = (R + t)·tan(45°) = R + t + // BD should equal 2·OSSB - BA + let r = 1.5; + let t = 1.0; + let k = 0.42; + let bd = bend_deduction(FRAC_PI_2, r, k, t); + let ba = bend_allowance(FRAC_PI_2, r, k, t); + let ossb_2 = 2.0 * (r + t) * (FRAC_PI_2 / 2.0).tan(); + assert!((bd - (ossb_2 - ba)).abs() < 1e-12); + } + + #[test] + fn builtin_table_has_expected_materials() { + let t = BendTable::builtin(); + for mat in ["Al-soft", "Al-hard", "Steel-mild", "SS-304"] { + assert!(t.rows.iter().any(|r| r.material == mat), "missing {mat}"); + } + } + + #[test] + fn lookup_returns_provenance() { + let t = BendTable::builtin(); + let (k, src) = t.lookup("Al-soft", 1.0, 1.0).expect("should find row"); + assert!((k - 0.35).abs() < 1e-12); + match src { + KFactorSource::Builtin { key } => { + assert!(key.starts_with("Al-soft/"), "got key {key}"); + } + other => panic!("expected Builtin, got {other:?}"), + } + } + + #[test] + fn lookup_unknown_material_returns_none() { + let t = BendTable::builtin(); + assert!(t.lookup("Unobtanium", 1.0, 1.0).is_none()); + } + + #[test] + fn lookup_falls_back_to_closest_rt() { + let t = BendTable::builtin(); + // No exact row for Al-soft R=1.7, t=1.0 — should pick the closest. + let (k, _) = t.lookup("Al-soft", 1.0, 1.7).expect("should find a row"); + assert!(k > 0.34 && k < 0.40, "K out of plausible range: {k}"); + } + + #[test] + fn k_factor_source_label_round_trips_kind() { + assert_eq!( + KFactorSource::Builtin { key: "x".into() }.label(), + "builtin:x" + ); + assert_eq!( + KFactorSource::Shop { + shop_id: "acme".into(), + key: "x".into() + } + .label(), + "shop:acme/x" + ); + assert_eq!( + KFactorSource::Measured { note: "n".into() }.label(), + "measured:n" + ); + assert_eq!(KFactorSource::Manual.label(), "manual"); + } +} diff --git a/crates/vcad-kernel-sheet/src/edge_flange.rs b/crates/vcad-kernel-sheet/src/edge_flange.rs new file mode 100644 index 00000000..6e193686 --- /dev/null +++ b/crates/vcad-kernel-sheet/src/edge_flange.rs @@ -0,0 +1,437 @@ +//! [`add_edge_flange`] — extend an existing model with a new flange off an +//! edge of an existing panel. +//! +//! # Coordinate convention +//! +//! Each panel carries a 3D [`Frame`] mapping its panel-local 2D outline into +//! world coordinates. The bend axis coincides with the parent's edge in 3D +//! (zero-radius idealisation): the cylindrical bend region is implied +//! metadata and only materialises during tessellation/BRep generation. This +//! is what makes the panel graph a clean source-of-truth and unfold/refold +//! lossless — we never have to round-trip cylindrical surface fits. +//! +//! For a CCW outline, the *outward* in-plane direction perpendicular to edge +//! `(p, q)` is `rotate-90°-clockwise(q - p) = (dy, -dx)` (right-hand side of +//! the edge as you walk p→q). + +use crate::bend_table::{bend_allowance, BendTable, KFactorSource}; +use crate::model::{Bend, BendDirection, Frame, Panel, PanelId, SheetMetalModel}; +use vcad_kernel_math::{Point2, Point3, Transform, Vec3}; + +/// Where the bent flange sits relative to the parent panel. +/// +/// The naming follows SolidWorks convention. For the foundation tier we +/// support only `MaterialInside`, the simplest case. The other modes shift +/// the child panel's frame by a multiple of the thickness; we'll wire them +/// up alongside the manufacturability checks tier. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FlangePosition { + /// Inside (concave) face of the flange flush with the parent's outside + /// edge. The most common default. + MaterialInside, +} + +/// Errors returned by [`add_edge_flange`]. +#[derive(Debug, Clone, PartialEq)] +pub enum EdgeFlangeError { + /// `panel_id` is out of bounds. + UnknownPanel(PanelId), + /// `edge_index` is out of bounds for the panel's outline. + EdgeOutOfRange { + /// Parent panel id. + panel: PanelId, + /// Requested edge index. + edge_index: usize, + /// Number of points (= number of edges) in the panel's outline. + outline_len: usize, + }, + /// `length`, `radius`, or `angle` is non-positive. + NonPositive(&'static str, f64), + /// Bend angle exceeds π (we only model bends in `(0, π]`). + AngleTooLarge(f64), + /// No K-factor row found and no manual override given. + NoKFactor { + /// Material name that was queried. + material: String, + /// Thickness that was queried (mm). + thickness: f64, + /// Inside bend radius that was queried (mm). + radius: f64, + }, +} + +impl std::fmt::Display for EdgeFlangeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EdgeFlangeError::UnknownPanel(p) => write!(f, "unknown panel {p}"), + EdgeFlangeError::EdgeOutOfRange { + panel, + edge_index, + outline_len, + } => write!( + f, + "edge {edge_index} out of range for panel {panel} (outline has {outline_len} edges)" + ), + EdgeFlangeError::NonPositive(name, v) => write!(f, "{name} must be > 0, got {v}"), + EdgeFlangeError::AngleTooLarge(a) => write!(f, "angle must be in (0, π], got {a}"), + EdgeFlangeError::NoKFactor { + material, + thickness, + radius, + } => write!( + f, + "no K-factor found for material={material:?} t={thickness} R={radius}" + ), + } + } +} + +impl std::error::Error for EdgeFlangeError {} + +/// Parameters for [`add_edge_flange`]. +#[derive(Debug, Clone)] +pub struct EdgeFlangeParams { + /// Parent panel. + pub panel: PanelId, + /// Index of the edge in the parent's outline (0 = outline\[0\]→outline\[1\]). + pub edge_index: usize, + /// Flange length perpendicular to the hinge edge (mm). + pub length: f64, + /// Bend angle (radians, 0 < angle ≤ π). + pub angle: f64, + /// Inside bend radius (mm). + pub radius: f64, + /// Direction the flange folds. + pub direction: BendDirection, + /// Position mode (foundation tier supports `MaterialInside` only). + pub position: FlangePosition, + /// Material name, used to look up the K-factor in `bend_table` if no + /// `manual_k` is given. + pub material: String, + /// Optional manual K-factor override. When set, `bend_table` and + /// `material` are ignored. + pub manual_k: Option, +} + +/// Extend `model` with a new flange off `params.edge_index` of `params.panel`. +/// +/// Returns the new child [`PanelId`] and the new [`crate::model::BendId`]. +pub fn add_edge_flange( + model: &mut SheetMetalModel, + bend_table: &BendTable, + params: EdgeFlangeParams, +) -> Result<(PanelId, crate::model::BendId), EdgeFlangeError> { + if params.panel >= model.panels.len() { + return Err(EdgeFlangeError::UnknownPanel(params.panel)); + } + if params.length <= 0.0 || params.length.is_nan() { + return Err(EdgeFlangeError::NonPositive("length", params.length)); + } + if params.radius <= 0.0 || params.radius.is_nan() { + return Err(EdgeFlangeError::NonPositive("radius", params.radius)); + } + if params.angle <= 0.0 || params.angle.is_nan() { + return Err(EdgeFlangeError::NonPositive("angle", params.angle)); + } + if params.angle > std::f64::consts::PI + 1e-12 { + return Err(EdgeFlangeError::AngleTooLarge(params.angle)); + } + + let parent = &model.panels[params.panel]; + let n = parent.outline.len(); + if n < 3 || params.edge_index >= n { + return Err(EdgeFlangeError::EdgeOutOfRange { + panel: params.panel, + edge_index: params.edge_index, + outline_len: n, + }); + } + + // Resolve K-factor — manual override beats table lookup. + let (k_factor, source) = match params.manual_k { + Some(k) => (k, KFactorSource::Manual), + None => match bend_table.lookup(¶ms.material, model.thickness, params.radius) { + Some((k, src)) => (k, src), + None => { + return Err(EdgeFlangeError::NoKFactor { + material: params.material.clone(), + thickness: model.thickness, + radius: params.radius, + }); + } + }, + }; + + // Hinge edge endpoints in parent-local 2D. + let p0 = parent.outline[params.edge_index]; + let p1 = parent.outline[(params.edge_index + 1) % n]; + let edge_len = (p1 - p0).norm(); + if edge_len < 1e-12 { + return Err(EdgeFlangeError::NonPositive("edge length", edge_len)); + } + + // Parent-local 2D directions. + let edge_dir_2d = (p1 - p0) / edge_len; + // Outward normal of a CCW edge: rotate edge_dir 90° clockwise. + let outward_2d = vcad_kernel_math::Vec2::new(edge_dir_2d.y, -edge_dir_2d.x); + + // Lift to 3D using the parent's bent frame. + let parent_frame = parent.frame_bent; + let edge_dir_3d = direction_to_world(&parent_frame, edge_dir_2d.x, edge_dir_2d.y); + let outward_3d = direction_to_world(&parent_frame, outward_2d.x, outward_2d.y); + + // Bend axis is along edge_dir_3d, passing through the hinge endpoints in + // 3D. The child's frame is obtained by rotating the "would-be flat + // continuation" frame about the axis by `direction.sign() * angle`. + let signed_angle = params.direction.sign() * params.angle; + let axis = vcad_kernel_math::Dir3::new_normalize(edge_dir_3d); + let rot = Transform::rotation_about_axis(&axis, signed_angle); + + // Child y_dir starts as outward_3d (flange would extend in that + // direction if it were flat) and rotates with the bend. + let child_y_dir_bent = rot.apply_vec(&outward_3d); + + // Child origin is on the hinge axis, at parent.to_world(p0). Rotation + // about an axis through that point keeps it fixed. + let child_origin_3d = parent_frame.to_world(p0); + + let child_frame_bent = Frame { + origin: child_origin_3d, + x_dir: edge_dir_3d, + y_dir: child_y_dir_bent, + }; + + // Flat pose: in the flat layout, the child sits in the same plane as + // the parent, separated from the hinge edge by the bend allowance. + let ba = bend_allowance(params.angle, params.radius, k_factor, model.thickness); + + let flat_origin_3d = { + let parent_flat_origin = parent.frame_flat.to_world(p0); + let outward_flat_3d = direction_to_world(&parent.frame_flat, outward_2d.x, outward_2d.y); + point3_offset(parent_flat_origin, outward_flat_3d, ba) + }; + let flat_x_dir_3d = direction_to_world(&parent.frame_flat, edge_dir_2d.x, edge_dir_2d.y); + let flat_y_dir_3d = direction_to_world(&parent.frame_flat, outward_2d.x, outward_2d.y); + + let child_frame_flat = Frame { + origin: flat_origin_3d, + x_dir: flat_x_dir_3d, + y_dir: flat_y_dir_3d, + }; + + // Child outline: a rectangle of (edge_len × params.length) in child-local 2D. + let child_outline = vec![ + Point2::new(0.0, 0.0), + Point2::new(edge_len, 0.0), + Point2::new(edge_len, params.length), + Point2::new(0.0, params.length), + ]; + + let child_panel = Panel { + outline: child_outline, + holes: Vec::new(), + frame_bent: child_frame_bent, + frame_flat: child_frame_flat, + incident_bends: Vec::new(), + }; + let child_id = model.push_panel(child_panel); + + let bend = Bend { + parent: params.panel, + child: child_id, + edge_parent: (p0, p1), + radius: params.radius, + angle: params.angle, + direction: params.direction, + k_factor, + k_factor_source: Some(source.label()), + }; + let bend_id = model.push_bend(bend); + + Ok((child_id, bend_id)) +} + +/// Lift a panel-local 2D direction `(dx, dy)` into world 3D using `frame`. +fn direction_to_world(frame: &Frame, dx: f64, dy: f64) -> Vec3 { + Vec3::new( + frame.x_dir.x * dx + frame.y_dir.x * dy, + frame.x_dir.y * dx + frame.y_dir.y * dy, + frame.x_dir.z * dx + frame.y_dir.z * dy, + ) +} + +fn point3_offset(p: Point3, v: Vec3, scale: f64) -> Point3 { + Point3::new(p.x + v.x * scale, p.y + v.y * scale, p.z + v.z * scale) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::base_flange::base_flange_rect; + use std::f64::consts::FRAC_PI_2; + + fn default_table() -> BendTable { + BendTable::builtin() + } + + fn al_soft_params(panel: PanelId, edge_index: usize) -> EdgeFlangeParams { + EdgeFlangeParams { + panel, + edge_index, + length: 25.0, + angle: FRAC_PI_2, + radius: 1.0, + direction: BendDirection::Up, + position: FlangePosition::MaterialInside, + material: "Al-soft".to_string(), + manual_k: None, + } + } + + #[test] + fn adds_panel_and_bend() { + let mut m = base_flange_rect(100.0, 50.0, 1.0).unwrap(); + let table = default_table(); + let (child_id, bend_id) = add_edge_flange(&mut m, &table, al_soft_params(0, 0)).unwrap(); + assert_eq!(child_id, 1); + assert_eq!(bend_id, 0); + assert_eq!(m.panels.len(), 2); + assert_eq!(m.bends.len(), 1); + assert_eq!(m.panels[0].incident_bends, vec![0]); + assert_eq!(m.panels[1].incident_bends, vec![0]); + } + + #[test] + fn child_outline_has_correct_dimensions() { + let mut m = base_flange_rect(100.0, 50.0, 1.0).unwrap(); + let table = default_table(); + // Edge 0 is (0,0)→(100,0); flange length 25. + add_edge_flange(&mut m, &table, al_soft_params(0, 0)).unwrap(); + let outline = &m.panels[1].outline; + assert_eq!(outline.len(), 4); + // edge_len along child's x = 100, flange length along y = 25 + assert!((outline[1].x - 100.0).abs() < 1e-9); + assert!((outline[2].y - 25.0).abs() < 1e-9); + } + + #[test] + fn up_flange_at_90_lifts_above_parent() { + let mut m = base_flange_rect(100.0, 50.0, 1.0).unwrap(); + let table = default_table(); + // Edge 0: (0,0)→(100,0); outward normal in 2D is (0, -1). + // After Up bend by π/2 about edge direction (+x), outward (0,-1,0) + // rotates to (0, 0, -1)? Let's check via the right-hand rule. + // + // Actually: rotating (0,-1,0) about +x by +π/2 (right-hand rule) + // gives (0, 0, -1). So Up = -Z. We just want to verify that the + // child does *not* lie in the parent plane (z=0). + let (child_id, _) = add_edge_flange(&mut m, &table, al_soft_params(0, 0)).unwrap(); + let child = &m.panels[child_id]; + // Tip of the flange in child-local: (50, 25). + let tip = child.frame_bent.to_world(Point2::new(50.0, 25.0)); + // Tip should be off the parent plane (z != 0). + assert!( + tip.z.abs() > 1e-6, + "tip not lifted from parent plane: {tip:?}" + ); + // Tip's distance from the hinge axis (x-axis) should equal the + // flange length (25), since the bend is 90°. + let dist_yz = (tip.y * tip.y + tip.z * tip.z).sqrt(); + assert!((dist_yz - 25.0).abs() < 1e-9, "expected 25.0 got {dist_yz}"); + } + + #[test] + fn down_flange_mirrors_up() { + let mut m_up = base_flange_rect(100.0, 50.0, 1.0).unwrap(); + let mut m_dn = base_flange_rect(100.0, 50.0, 1.0).unwrap(); + let table = default_table(); + let mut p_up = al_soft_params(0, 0); + p_up.direction = BendDirection::Up; + let mut p_dn = al_soft_params(0, 0); + p_dn.direction = BendDirection::Down; + add_edge_flange(&mut m_up, &table, p_up).unwrap(); + add_edge_flange(&mut m_dn, &table, p_dn).unwrap(); + let tip_up = m_up.panels[1].frame_bent.to_world(Point2::new(50.0, 25.0)); + let tip_dn = m_dn.panels[1].frame_bent.to_world(Point2::new(50.0, 25.0)); + // Up and Down should mirror through the parent plane (z=0). + assert!( + (tip_up.z + tip_dn.z).abs() < 1e-9, + "{tip_up:?} vs {tip_dn:?}" + ); + } + + #[test] + fn flat_pose_offsets_by_bend_allowance() { + let mut m = base_flange_rect(100.0, 50.0, 1.0).unwrap(); + let table = default_table(); + let params = al_soft_params(0, 0); + let (child_id, bend_id) = add_edge_flange(&mut m, &table, params.clone()).unwrap(); + let bend = &m.bends[bend_id]; + let ba = bend.allowance(m.thickness); + let child = &m.panels[child_id]; + // In flat coords, child origin should sit on outward direction at + // distance BA from parent origin (parent edge 0 starts at origin). + // outward_2d for edge 0 of CCW rect is (0, -1). + let expected = Point3::new(0.0, -ba, 0.0); + let got = child.frame_flat.origin; + assert!( + (got - expected).norm() < 1e-9, + "expected {expected:?} got {got:?}" + ); + } + + #[test] + fn rejects_invalid_inputs() { + let mut m = base_flange_rect(100.0, 50.0, 1.0).unwrap(); + let table = default_table(); + + // Unknown panel + let mut p = al_soft_params(99, 0); + p.panel = 99; + assert!(matches!( + add_edge_flange(&mut m, &table, p), + Err(EdgeFlangeError::UnknownPanel(_)) + )); + + // Edge out of range + assert!(matches!( + add_edge_flange(&mut m, &table, al_soft_params(0, 99)), + Err(EdgeFlangeError::EdgeOutOfRange { .. }) + )); + + // Non-positive length + let mut p = al_soft_params(0, 0); + p.length = 0.0; + assert!(matches!( + add_edge_flange(&mut m, &table, p), + Err(EdgeFlangeError::NonPositive("length", _)) + )); + + // Angle too large + let mut p = al_soft_params(0, 0); + p.angle = 4.0; + assert!(matches!( + add_edge_flange(&mut m, &table, p), + Err(EdgeFlangeError::AngleTooLarge(_)) + )); + + // Unknown material + let mut p = al_soft_params(0, 0); + p.material = "Unobtanium".into(); + assert!(matches!( + add_edge_flange(&mut m, &table, p), + Err(EdgeFlangeError::NoKFactor { .. }) + )); + } + + #[test] + fn manual_k_overrides_table() { + let mut m = base_flange_rect(100.0, 50.0, 1.0).unwrap(); + let table = default_table(); + let mut p = al_soft_params(0, 0); + p.manual_k = Some(0.123); + let (_, bend_id) = add_edge_flange(&mut m, &table, p).unwrap(); + assert!((m.bends[bend_id].k_factor - 0.123).abs() < 1e-12); + assert_eq!(m.bends[bend_id].k_factor_source.as_deref(), Some("manual")); + } +} diff --git a/crates/vcad-kernel-sheet/src/lib.rs b/crates/vcad-kernel-sheet/src/lib.rs new file mode 100644 index 00000000..43042705 --- /dev/null +++ b/crates/vcad-kernel-sheet/src/lib.rs @@ -0,0 +1,36 @@ +#![warn(missing_docs)] + +//! Sheet-metal modeling for the vcad kernel. +//! +//! Treats sheet metal as a **constraint manifold inside the BRep state space**: +//! a [`SheetMetalModel`] is a graph of flat [`Panel`]s connected by cylindrical +//! [`Bend`]s. The graph is the source of truth — both the bent 3D body and the +//! flat pattern are views computed from it. +//! +//! This buys us **lossless bidirectional unfold**: because the bend metadata +//! (radius, angle, K-factor) lives on the bend itself rather than being +//! reconstructed from cylindrical face geometry, [`unfold`] and [`refold`] are +//! exact inverses by construction. See [`unfold::unfold`] and [`unfold::refold`]. +//! +//! # Foundation tier +//! +//! The MVP exposes: +//! - [`base_flange::base_flange_rect`] — start a model from a rectangular sheet +//! - [`edge_flange::add_edge_flange`] — add a flange off an existing panel edge +//! - [`unfold::unfold`] / [`unfold::refold`] — lossless 2D ↔ 3D round-trip +//! - [`bend_table::BendTable`] — `BA = θ·(R + K·t)` with provenance +//! +//! Later tiers add hems, jogs, miters, lofted flanges, manufacturability +//! checks, costing, and DXF export. See `docs/design/sheet-metal.md`. + +pub mod base_flange; +pub mod bend_table; +pub mod edge_flange; +pub mod model; +pub mod unfold; + +pub use base_flange::{base_flange_rect, BaseFlangeError}; +pub use bend_table::{BendAllowance, BendTable, KFactorSource}; +pub use edge_flange::{add_edge_flange, EdgeFlangeError, FlangePosition}; +pub use model::{Bend, BendDirection, BendId, Frame, Panel, PanelId, SheetMetalModel}; +pub use unfold::{refold, unfold, FlatPattern, UnfoldError}; diff --git a/crates/vcad-kernel-sheet/src/model.rs b/crates/vcad-kernel-sheet/src/model.rs new file mode 100644 index 00000000..7d4a9c61 --- /dev/null +++ b/crates/vcad-kernel-sheet/src/model.rs @@ -0,0 +1,321 @@ +//! Core sheet-metal data structures. +//! +//! A [`SheetMetalModel`] is a graph of [`Panel`]s (flat regions) connected by +//! [`Bend`]s (cylindrical patches). The graph is a tree rooted at the +//! reference panel; cycles will be supported when multi-body welded +//! sheet-metal lands. + +use vcad_kernel_math::{Point2, Point3, Vec3}; + +/// Index handle for a [`Panel`] inside a [`SheetMetalModel`]. +pub type PanelId = usize; + +/// Index handle for a [`Bend`] inside a [`SheetMetalModel`]. +pub type BendId = usize; + +/// 3D pose of a panel: an origin and an orthonormal basis. +/// +/// `x_dir` and `y_dir` span the panel's mid-plane in 3D. `x_dir × y_dir` +/// points away from the *outside* face (the side that becomes longer when +/// bent). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Frame { + /// 3D position of the panel-local origin. + pub origin: Point3, + /// Unit vector for panel-local +X in 3D. + pub x_dir: Vec3, + /// Unit vector for panel-local +Y in 3D. + pub y_dir: Vec3, +} + +impl Frame { + /// Identity frame at the world origin with axes aligned to world axes. + pub fn identity() -> Self { + Self { + origin: Point3::origin(), + x_dir: Vec3::new(1.0, 0.0, 0.0), + y_dir: Vec3::new(0.0, 1.0, 0.0), + } + } + + /// Outward (away from the *inside* face) normal of the panel. + /// + /// Equal to `x_dir × y_dir`. + pub fn normal(&self) -> Vec3 { + self.x_dir.cross(self.y_dir) + } + + /// Lift a panel-local 2D point into world 3D coordinates. + pub fn to_world(&self, p: Point2) -> Point3 { + Point3::new( + self.origin.x + self.x_dir.x * p.x + self.y_dir.x * p.y, + self.origin.y + self.x_dir.y * p.x + self.y_dir.y * p.y, + self.origin.z + self.x_dir.z * p.x + self.y_dir.z * p.y, + ) + } +} + +/// A flat planar region of the sheet. +/// +/// Geometry is stored in a panel-local 2D frame (see [`Frame`]); the same +/// outline is used for both the bent and unfolded views — only the 3D pose +/// changes. +#[derive(Debug, Clone, PartialEq)] +pub struct Panel { + /// Closed polygon outline in panel-local 2D coords (CCW when viewed from + /// the outside face). The first and last points are *not* duplicated. + pub outline: Vec, + /// Holes in the panel (CW when viewed from the outside face). + pub holes: Vec>, + /// 3D pose of this panel in the **bent** configuration. + pub frame_bent: Frame, + /// 3D pose of this panel in the **unfolded** (flat) configuration. + /// Computed lazily by [`crate::unfold::unfold`]. + pub frame_flat: Frame, + /// Bends incident to this panel. + pub incident_bends: Vec, +} + +/// Direction of a bend relative to the parent panel. +/// +/// `Up` means the child panel rises out of the parent's outside face; +/// `Down` means it descends out of the inside face. This corresponds to the +/// red / blue convention used in flat-pattern DXF layers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BendDirection { + /// Child rises out of the parent's outside face. + Up, + /// Child descends out of the parent's inside face. + Down, +} + +impl BendDirection { + /// Sign in `{-1, +1}` matching the convention used by rotation maths. + pub fn sign(self) -> f64 { + match self { + BendDirection::Up => 1.0, + BendDirection::Down => -1.0, + } + } +} + +/// A cylindrical bend connecting two panels along a shared edge. +/// +/// The bend is defined by its inside radius, angle (always positive), and +/// direction. The hinge edge is stored in *parent-panel-local* 2D coords; +/// the child panel's edge is implicitly the same edge after applying the +/// bend's transformation. +#[derive(Debug, Clone, PartialEq)] +pub struct Bend { + /// The panel the bend "comes off of" (closer to the model root). + pub parent: PanelId, + /// The panel created by the bend (further from the root). + pub child: PanelId, + /// Hinge edge in parent-panel-local 2D coords (start, end). + /// + /// Right-hand rule: when looking along (end - start) in the parent's + /// frame, an `Up` bend rotates the child counter-clockwise. + pub edge_parent: (Point2, Point2), + /// Inside bend radius (mm). + pub radius: f64, + /// Bend angle (radians, always > 0). For a right-angle flange this is + /// `π/2`. + pub angle: f64, + /// Direction the child panel folds relative to the parent. + pub direction: BendDirection, + /// K-factor used to compute the bend allowance for this bend. + /// Carries provenance back to the [`crate::bend_table::BendTable`] row + /// that produced it. + pub k_factor: f64, + /// Optional human-readable provenance tag (e.g. `"Al-1mm/R1.5"` or + /// `"shop:override"`). Surfaced in the property panel as the colored dot. + pub k_factor_source: Option, +} + +impl Bend { + /// Bend allowance: arc length of the neutral axis through this bend. + /// + /// `BA = θ · (R + K · t)` where `t` is the material thickness. + pub fn allowance(&self, thickness: f64) -> f64 { + self.angle * (self.radius + self.k_factor * thickness) + } +} + +/// A complete sheet-metal model. +/// +/// Owns all panels and bends, plus the material thickness that's constant +/// across the part. The root panel is the reference: it stays at its +/// `frame_bent` pose during refold and at its `frame_flat` pose during +/// unfold (both default to identity for a freshly created model). +#[derive(Debug, Clone, PartialEq)] +pub struct SheetMetalModel { + /// Material thickness (mm). Constant across the part. + pub thickness: f64, + /// All panels in the model. Index by [`PanelId`]. + pub panels: Vec, + /// All bends in the model. Index by [`BendId`]. + pub bends: Vec, + /// The reference panel — stays put during unfold/refold. + pub root: PanelId, +} + +impl SheetMetalModel { + /// Construct an empty model with the given thickness. Useful as a starting + /// point for tests; production code should go through + /// [`crate::base_flange::base_flange_rect`] etc. + pub fn new(thickness: f64) -> Self { + Self { + thickness, + panels: Vec::new(), + bends: Vec::new(), + root: 0, + } + } + + /// Append a panel and return its [`PanelId`]. + pub fn push_panel(&mut self, panel: Panel) -> PanelId { + let id = self.panels.len(); + self.panels.push(panel); + id + } + + /// Append a bend and update both incident panels' adjacency lists. + pub fn push_bend(&mut self, bend: Bend) -> BendId { + let id = self.bends.len(); + let parent = bend.parent; + let child = bend.child; + self.bends.push(bend); + self.panels[parent].incident_bends.push(id); + self.panels[child].incident_bends.push(id); + id + } + + /// Walk the panel/bend graph from the root, yielding each `(panel_id, + /// parent_bend_id_or_none)` in BFS order. + /// + /// The first yielded item is `(root, None)`. Used by [`crate::unfold`] to + /// propagate the unfolded frame outward from the reference panel. + pub fn bfs(&self) -> impl Iterator)> + '_ { + BfsIter::new(self) + } +} + +struct BfsIter<'a> { + model: &'a SheetMetalModel, + visited: Vec, + queue: std::collections::VecDeque<(PanelId, Option)>, +} + +impl<'a> BfsIter<'a> { + fn new(model: &'a SheetMetalModel) -> Self { + let mut visited = vec![false; model.panels.len()]; + let mut queue = std::collections::VecDeque::new(); + if !model.panels.is_empty() { + visited[model.root] = true; + queue.push_back((model.root, None)); + } + Self { + model, + visited, + queue, + } + } +} + +impl Iterator for BfsIter<'_> { + type Item = (PanelId, Option); + + fn next(&mut self) -> Option { + let (panel, via) = self.queue.pop_front()?; + for &bend_id in &self.model.panels[panel].incident_bends { + let bend = &self.model.bends[bend_id]; + let other = if bend.parent == panel { + bend.child + } else { + bend.parent + }; + if !self.visited[other] { + self.visited[other] = true; + self.queue.push_back((other, Some(bend_id))); + } + } + Some((panel, via)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frame_identity_lifts_2d_to_3d() { + let f = Frame::identity(); + assert_eq!( + f.to_world(Point2::new(2.0, 3.0)), + Point3::new(2.0, 3.0, 0.0) + ); + } + + #[test] + fn frame_normal_is_x_cross_y() { + let f = Frame::identity(); + let n = f.normal(); + assert!((n - Vec3::new(0.0, 0.0, 1.0)).norm() < 1e-12); + } + + #[test] + fn bend_allowance_matches_formula() { + let bend = Bend { + parent: 0, + child: 1, + edge_parent: (Point2::new(0.0, 0.0), Point2::new(10.0, 0.0)), + radius: 1.0, + angle: std::f64::consts::FRAC_PI_2, + direction: BendDirection::Up, + k_factor: 0.42, + k_factor_source: None, + }; + // BA = (π/2) · (1.0 + 0.42·1.0) = (π/2) · 1.42 + let ba = bend.allowance(1.0); + let expected = std::f64::consts::FRAC_PI_2 * 1.42; + assert!((ba - expected).abs() < 1e-12); + } + + #[test] + fn bfs_visits_all_panels_in_a_tree() { + let mut m = SheetMetalModel::new(1.0); + let mk_panel = || Panel { + outline: vec![], + holes: vec![], + frame_bent: Frame::identity(), + frame_flat: Frame::identity(), + incident_bends: vec![], + }; + let p0 = m.push_panel(mk_panel()); + let p1 = m.push_panel(mk_panel()); + let p2 = m.push_panel(mk_panel()); + m.root = p0; + m.push_bend(Bend { + parent: p0, + child: p1, + edge_parent: (Point2::new(0.0, 0.0), Point2::new(1.0, 0.0)), + radius: 1.0, + angle: std::f64::consts::FRAC_PI_2, + direction: BendDirection::Up, + k_factor: 0.42, + k_factor_source: None, + }); + m.push_bend(Bend { + parent: p1, + child: p2, + edge_parent: (Point2::new(0.0, 0.0), Point2::new(1.0, 0.0)), + radius: 1.0, + angle: std::f64::consts::FRAC_PI_2, + direction: BendDirection::Up, + k_factor: 0.42, + k_factor_source: None, + }); + let visited: Vec<_> = m.bfs().map(|(p, _)| p).collect(); + assert_eq!(visited, vec![p0, p1, p2]); + } +} diff --git a/crates/vcad-kernel-sheet/src/unfold.rs b/crates/vcad-kernel-sheet/src/unfold.rs new file mode 100644 index 00000000..4a3c1b39 --- /dev/null +++ b/crates/vcad-kernel-sheet/src/unfold.rs @@ -0,0 +1,566 @@ +//! Lossless bidirectional unfold. +//! +//! `unfold` and `refold` are **inverses by construction** because both +//! configurations of every panel — `frame_bent` and `frame_flat` — are +//! derivable from the same primary data: the panel-local outline + the bend +//! tree's `(edge_parent, angle, radius, k_factor, direction)` per bend. +//! +//! Concretely: +//! +//! - [`refold`] walks the bend tree from the root and recomputes each +//! non-root panel's `frame_bent` from its parent's `frame_bent` and the +//! bend metadata. +//! - [`unfold`] walks the same tree and recomputes each non-root panel's +//! `frame_flat` from its parent's `frame_flat` and the bend metadata +//! (with the bend allowance offsetting the child along the parent's +//! in-plane outward direction). +//! +//! The involution test [`tests::round_trip_is_identity`] proves +//! `refold ∘ unfold = identity` on bent frames within tolerance, and +//! [`tests::flat_round_trip_is_identity`] proves `unfold ∘ refold = +//! identity` on flat frames. +//! +//! The exported [`FlatPattern`] type is the manufacturing-side view — +//! global 2D coordinates of every outline + crease, suitable for DXF +//! export, nesting, and the flat-pattern editor in the UI. + +use crate::bend_table::bend_allowance; +use crate::model::{Bend, BendDirection, Frame, PanelId, SheetMetalModel}; +use vcad_kernel_math::{Dir3, Point2, Point3, Transform, Vec3}; + +/// Errors returned by [`unfold`] / [`refold`]. +#[derive(Debug, Clone, PartialEq)] +pub enum UnfoldError { + /// Model is empty (no panels). + EmptyModel, + /// Model contains a cycle (not yet supported). + CycleDetected, +} + +impl std::fmt::Display for UnfoldError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + UnfoldError::EmptyModel => write!(f, "model has no panels"), + UnfoldError::CycleDetected => write!(f, "model contains a cycle"), + } + } +} + +impl std::error::Error for UnfoldError {} + +/// Recompute every panel's `frame_flat` by walking the bend tree from the +/// root. +/// +/// The root panel keeps its existing `frame_flat`. Each non-root panel's +/// `frame_flat` is its parent's flat frame translated outward by the bend +/// allowance — no rotation, since the panels lie coplanar in the flat +/// pattern. +pub fn unfold(model: &mut SheetMetalModel) -> Result<(), UnfoldError> { + if model.panels.is_empty() { + return Err(UnfoldError::EmptyModel); + } + walk_and_update(model, FrameKind::Flat) +} + +/// Recompute every panel's `frame_bent` by walking the bend tree from the +/// root. +/// +/// The root panel keeps its existing `frame_bent`. Each non-root panel's +/// `frame_bent` is its parent's bent frame rotated about the hinge axis by +/// the (signed) bend angle. +pub fn refold(model: &mut SheetMetalModel) -> Result<(), UnfoldError> { + if model.panels.is_empty() { + return Err(UnfoldError::EmptyModel); + } + walk_and_update(model, FrameKind::Bent) +} + +/// Which configuration we're recomputing. +#[derive(Debug, Clone, Copy)] +enum FrameKind { + Bent, + Flat, +} + +fn walk_and_update(model: &mut SheetMetalModel, kind: FrameKind) -> Result<(), UnfoldError> { + let order: Vec<(PanelId, Option)> = model.bfs().collect(); + if order.len() != model.panels.len() { + return Err(UnfoldError::CycleDetected); + } + for &(panel_id, via_bend) in order.iter().skip(1) { + let bend_id = via_bend.expect("non-root panel must have an incoming bend"); + let bend = model.bends[bend_id].clone(); + let parent_id = if bend.parent == panel_id { + bend.child + } else { + bend.parent + }; + let parent_frame = match kind { + FrameKind::Bent => model.panels[parent_id].frame_bent, + FrameKind::Flat => model.panels[parent_id].frame_flat, + }; + let new_frame = match kind { + FrameKind::Bent => child_bent_frame(&parent_frame, &bend), + FrameKind::Flat => child_flat_frame(&parent_frame, &bend, model.thickness), + }; + match kind { + FrameKind::Bent => model.panels[panel_id].frame_bent = new_frame, + FrameKind::Flat => model.panels[panel_id].frame_flat = new_frame, + } + } + Ok(()) +} + +/// Compute the child panel's bent frame from the parent's bent frame. +/// +/// Mirrors the geometry in [`crate::edge_flange::add_edge_flange`]: rotate +/// the parent's outward in-plane direction about the hinge axis by the +/// signed bend angle. Origin sits at `parent.to_world(edge_parent.0)`, +/// which is on the axis and therefore fixed by the rotation. +fn child_bent_frame(parent_frame: &Frame, bend: &Bend) -> Frame { + let (p0, p1) = bend.edge_parent; + let edge_dir_2d = p1 - p0; + let edge_len = edge_dir_2d.norm(); + let edge_dir_2d = edge_dir_2d / edge_len; + let outward_2d = vcad_kernel_math::Vec2::new(edge_dir_2d.y, -edge_dir_2d.x); + + let edge_dir_3d = direction_to_world(parent_frame, edge_dir_2d.x, edge_dir_2d.y); + let outward_3d = direction_to_world(parent_frame, outward_2d.x, outward_2d.y); + + let signed_angle = bend.direction.sign() * bend.angle; + let axis = Dir3::new_normalize(edge_dir_3d); + let rot = Transform::rotation_about_axis(&axis, signed_angle); + let child_y = rot.apply_vec(&outward_3d); + let child_origin = parent_frame.to_world(p0); + Frame { + origin: child_origin, + x_dir: edge_dir_3d, + y_dir: child_y, + } +} + +/// Compute the child panel's flat frame from the parent's flat frame. +/// +/// In the flat pattern, the child sits coplanar with the parent. The hinge +/// edge in the parent's 2D becomes a crease line, and the child's outline +/// continues on the *outward* side of that crease, separated by the bend +/// allowance. +fn child_flat_frame(parent_frame: &Frame, bend: &Bend, thickness: f64) -> Frame { + let (p0, p1) = bend.edge_parent; + let edge_dir_2d = p1 - p0; + let edge_len = edge_dir_2d.norm(); + let edge_dir_2d = edge_dir_2d / edge_len; + let outward_2d = vcad_kernel_math::Vec2::new(edge_dir_2d.y, -edge_dir_2d.x); + + let edge_dir_3d = direction_to_world(parent_frame, edge_dir_2d.x, edge_dir_2d.y); + let outward_3d = direction_to_world(parent_frame, outward_2d.x, outward_2d.y); + + let ba = bend_allowance(bend.angle, bend.radius, bend.k_factor, thickness); + let parent_hinge_3d = parent_frame.to_world(p0); + let child_origin = Point3::new( + parent_hinge_3d.x + outward_3d.x * ba, + parent_hinge_3d.y + outward_3d.y * ba, + parent_hinge_3d.z + outward_3d.z * ba, + ); + Frame { + origin: child_origin, + x_dir: edge_dir_3d, + y_dir: outward_3d, + } +} + +fn direction_to_world(frame: &Frame, dx: f64, dy: f64) -> Vec3 { + Vec3::new( + frame.x_dir.x * dx + frame.y_dir.x * dy, + frame.x_dir.y * dx + frame.y_dir.y * dy, + frame.x_dir.z * dx + frame.y_dir.z * dy, + ) +} + +/// Manufacturing-side flat pattern: global 2D outlines + creases, ready +/// for DXF export, nesting, or the flat-pattern UI editor. +/// +/// Constructed by [`FlatPattern::from_model`] which projects every panel's +/// outline through its `frame_flat` into the plane defined by the root +/// panel's flat frame. +#[derive(Debug, Clone, PartialEq)] +pub struct FlatPattern { + /// Material thickness (mm). + pub thickness: f64, + /// Outlines in global flat 2D coords. One entry per panel, in panel-id + /// order. + pub panel_outlines_2d: Vec>, + /// Hole loops per panel. + pub panel_holes_2d: Vec>>, + /// Crease lines. + pub creases: Vec, + /// Total flat-pattern area (mm²) — sum of panel areas + bend-allowance + /// rectangles. Used by costing. + pub area_mm2: f64, +} + +/// A crease in the flat pattern (one bend = one crease). +#[derive(Debug, Clone, PartialEq)] +pub struct FlatCrease { + /// Crease line in global flat 2D coords (start, end). + pub line: (Point2, Point2), + /// Bend angle (radians). + pub angle: f64, + /// Inside radius (mm). + pub radius: f64, + /// K-factor used. + pub k_factor: f64, + /// Provenance label of the K-factor (`"builtin:Al-soft/R1.00t1.00"` etc.). + pub k_factor_source: Option, + /// Up or Down — drives DXF layer selection. + pub direction: BendDirection, + /// Backreference: which `Bend` produced this crease. + pub bend_id: usize, +} + +impl FlatPattern { + /// Project a sheet-metal model into a global 2D flat pattern using each + /// panel's `frame_flat`. + /// + /// Coordinate system: the root panel's `frame_flat` defines the global + /// 2D plane. The root panel's outline ends up in its panel-local 2D + /// coordinates verbatim; other panels are projected. + pub fn from_model(model: &SheetMetalModel) -> Self { + let root = &model.panels[model.root]; + let root_frame = root.frame_flat; + let to_global = |frame: Frame, p: Point2| -> Point2 { + // Global 2D = ((world - root_origin) · root.x_dir, (world - root_origin) · root.y_dir) + let world = frame.to_world(p); + let rel = world - root_frame.origin; + Point2::new(rel.dot(root_frame.x_dir), rel.dot(root_frame.y_dir)) + }; + + let panel_outlines_2d: Vec> = model + .panels + .iter() + .map(|panel| { + panel + .outline + .iter() + .map(|&p| to_global(panel.frame_flat, p)) + .collect() + }) + .collect(); + + let panel_holes_2d: Vec>> = model + .panels + .iter() + .map(|panel| { + panel + .holes + .iter() + .map(|h| h.iter().map(|&p| to_global(panel.frame_flat, p)).collect()) + .collect() + }) + .collect(); + + let creases: Vec = model + .bends + .iter() + .enumerate() + .map(|(id, bend)| { + let parent = &model.panels[bend.parent]; + let (p0, p1) = bend.edge_parent; + FlatCrease { + line: ( + to_global(parent.frame_flat, p0), + to_global(parent.frame_flat, p1), + ), + angle: bend.angle, + radius: bend.radius, + k_factor: bend.k_factor, + k_factor_source: bend.k_factor_source.clone(), + direction: bend.direction, + bend_id: id, + } + }) + .collect(); + + let area_mm2 = + polygon_area_sum(&panel_outlines_2d, &panel_holes_2d) + bend_strip_area(model); + + Self { + thickness: model.thickness, + panel_outlines_2d, + panel_holes_2d, + creases, + area_mm2, + } + } + + /// 2D bounding box `((min_x, min_y), (max_x, max_y))` of the flat + /// pattern, including bend allowance gaps. + pub fn bbox(&self) -> ((f64, f64), (f64, f64)) { + let mut min = (f64::INFINITY, f64::INFINITY); + let mut max = (f64::NEG_INFINITY, f64::NEG_INFINITY); + for outline in &self.panel_outlines_2d { + for p in outline { + if p.x < min.0 { + min.0 = p.x; + } + if p.y < min.1 { + min.1 = p.y; + } + if p.x > max.0 { + max.0 = p.x; + } + if p.y > max.1 { + max.1 = p.y; + } + } + } + (min, max) + } +} + +fn polygon_area(loop_pts: &[Point2]) -> f64 { + if loop_pts.len() < 3 { + return 0.0; + } + let mut sum = 0.0; + for i in 0..loop_pts.len() { + let a = loop_pts[i]; + let b = loop_pts[(i + 1) % loop_pts.len()]; + sum += a.x * b.y - b.x * a.y; + } + 0.5 * sum.abs() +} + +fn polygon_area_sum(outlines: &[Vec], holes: &[Vec>]) -> f64 { + let mut sum = 0.0; + for (outline, hole_set) in outlines.iter().zip(holes) { + sum += polygon_area(outline); + for h in hole_set { + sum -= polygon_area(h); + } + } + sum +} + +fn bend_strip_area(model: &SheetMetalModel) -> f64 { + model + .bends + .iter() + .map(|b| { + let (p0, p1) = b.edge_parent; + let edge_len = (p1 - p0).norm(); + edge_len * b.allowance(model.thickness) + }) + .sum() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::base_flange::base_flange_rect; + use crate::bend_table::BendTable; + use crate::edge_flange::{add_edge_flange, EdgeFlangeParams, FlangePosition}; + use std::f64::consts::FRAC_PI_2; + + fn frame_close(a: &Frame, b: &Frame, tol: f64) -> bool { + (a.origin - b.origin).norm() < tol + && (a.x_dir - b.x_dir).norm() < tol + && (a.y_dir - b.y_dir).norm() < tol + } + + fn make_l_bracket() -> SheetMetalModel { + let mut m = base_flange_rect(100.0, 50.0, 1.0).unwrap(); + let table = BendTable::builtin(); + add_edge_flange( + &mut m, + &table, + EdgeFlangeParams { + panel: 0, + edge_index: 0, + length: 25.0, + angle: FRAC_PI_2, + radius: 1.0, + direction: BendDirection::Up, + position: FlangePosition::MaterialInside, + material: "Al-soft".into(), + manual_k: None, + }, + ) + .unwrap(); + m + } + + fn make_u_channel() -> SheetMetalModel { + let mut m = base_flange_rect(100.0, 50.0, 1.0).unwrap(); + let table = BendTable::builtin(); + // Edge 0: y=0 side + add_edge_flange( + &mut m, + &table, + EdgeFlangeParams { + panel: 0, + edge_index: 0, + length: 25.0, + angle: FRAC_PI_2, + radius: 1.0, + direction: BendDirection::Up, + position: FlangePosition::MaterialInside, + material: "Al-soft".into(), + manual_k: None, + }, + ) + .unwrap(); + // Edge 2: y=50 side + add_edge_flange( + &mut m, + &table, + EdgeFlangeParams { + panel: 0, + edge_index: 2, + length: 25.0, + angle: FRAC_PI_2, + radius: 1.0, + direction: BendDirection::Up, + position: FlangePosition::MaterialInside, + material: "Al-soft".into(), + manual_k: None, + }, + ) + .unwrap(); + m + } + + /// **The legendary involution proof.** Take a model, save its bent + /// frames, run unfold (which doesn't touch bent frames) then refold + /// (which recomputes them from scratch using the bend tree). The + /// recomputed frames must equal the originals within tolerance. + #[test] + fn round_trip_is_identity_l_bracket() { + let mut m = make_l_bracket(); + let originals: Vec = m.panels.iter().map(|p| p.frame_bent).collect(); + unfold(&mut m).unwrap(); + // Mutate frame_bent to garbage to prove refold actually rebuilds it. + for p in &mut m.panels[1..] { + p.frame_bent = Frame::identity(); + } + refold(&mut m).unwrap(); + for (i, orig) in originals.iter().enumerate() { + assert!( + frame_close(&m.panels[i].frame_bent, orig, 1e-9), + "panel {i}: {:?} vs {:?}", + m.panels[i].frame_bent, + orig + ); + } + } + + #[test] + fn round_trip_is_identity_u_channel() { + let mut m = make_u_channel(); + let originals: Vec = m.panels.iter().map(|p| p.frame_bent).collect(); + unfold(&mut m).unwrap(); + for p in &mut m.panels[1..] { + p.frame_bent = Frame::identity(); + } + refold(&mut m).unwrap(); + for (i, orig) in originals.iter().enumerate() { + assert!( + frame_close(&m.panels[i].frame_bent, orig, 1e-9), + "panel {i}", + ); + } + } + + /// Symmetric: unfolding and re-unfolding doesn't drift. + #[test] + fn flat_round_trip_is_identity() { + let mut m = make_u_channel(); + let originals: Vec = m.panels.iter().map(|p| p.frame_flat).collect(); + // Garbage flat frames, then unfold to recompute, twice. + for p in &mut m.panels[1..] { + p.frame_flat = Frame::identity(); + } + unfold(&mut m).unwrap(); + for (i, orig) in originals.iter().enumerate() { + assert!( + frame_close(&m.panels[i].frame_flat, orig, 1e-9), + "panel {i}" + ); + } + } + + /// Stable under repeated round-trips — no drift accumulation. + #[test] + fn no_drift_under_repeated_round_trip() { + let mut m = make_u_channel(); + let originals: Vec = m.panels.iter().map(|p| p.frame_bent).collect(); + for _ in 0..10 { + unfold(&mut m).unwrap(); + refold(&mut m).unwrap(); + } + for (i, orig) in originals.iter().enumerate() { + assert!( + frame_close(&m.panels[i].frame_bent, orig, 1e-9), + "panel {i} drifted after 10 round-trips", + ); + } + } + + #[test] + fn flat_pattern_root_at_origin() { + let m = make_l_bracket(); + let fp = FlatPattern::from_model(&m); + // Root panel's outline starts at panel-local (0,0) → flat (0,0). + assert!(fp.panel_outlines_2d[0][0].x.abs() < 1e-12); + assert!(fp.panel_outlines_2d[0][0].y.abs() < 1e-12); + } + + #[test] + fn flat_pattern_child_offset_by_ba() { + let m = make_l_bracket(); + let fp = FlatPattern::from_model(&m); + let bend = &m.bends[0]; + let ba = bend.allowance(m.thickness); + // Edge 0 of the root rect is along +x at y=0; outward in 2D is (0,-1). + // So child outline's first point should be at (0, -BA). + let p0 = fp.panel_outlines_2d[1][0]; + assert!(p0.x.abs() < 1e-9); + assert!( + (p0.y - (-ba)).abs() < 1e-9, + "expected y={} got {}", + -ba, + p0.y + ); + } + + #[test] + fn flat_pattern_creases_have_provenance() { + let m = make_u_channel(); + let fp = FlatPattern::from_model(&m); + assert_eq!(fp.creases.len(), 2); + for c in &fp.creases { + assert!(c.k_factor_source.is_some(), "missing provenance"); + } + } + + #[test] + fn flat_pattern_area_includes_bend_strips() { + let m = make_l_bracket(); + let fp = FlatPattern::from_model(&m); + let panel_area = 100.0 * 50.0 + 100.0 * 25.0; + let bend_strip = 100.0 * m.bends[0].allowance(m.thickness); + let expected = panel_area + bend_strip; + assert!( + (fp.area_mm2 - expected).abs() < 1e-6, + "expected {expected}, got {}", + fp.area_mm2 + ); + } + + #[test] + fn empty_model_returns_error() { + let mut m = SheetMetalModel::new(1.0); + assert!(matches!(unfold(&mut m), Err(UnfoldError::EmptyModel))); + assert!(matches!(refold(&mut m), Err(UnfoldError::EmptyModel))); + } +} diff --git a/docs/design/sheet-metal.md b/docs/design/sheet-metal.md new file mode 100644 index 00000000..0fb72877 --- /dev/null +++ b/docs/design/sheet-metal.md @@ -0,0 +1,414 @@ +# Sheet Metal: A First-Principles Spec + +> The strategic vision and UI plan for making vcad the best sheet-metal CAD tool ever shipped. +> For technical reference (math, algorithms, gauge tables), see [`features/sheet-metal.md`](../features/sheet-metal.md). + +## What sheet metal really is + +A sheet-metal part is a 3D solid that admits an **isometric flattening**: there exists a developable +surface (zero Gaussian curvature everywhere) of uniform thickness `t` whose mid-surface unfolds onto +a planar region without stretching. Bends are cylindrical patches — the only surface a brake press +can actually make. Everything else (lofted flanges, conical transitions) is an approximation we owe +the user clarity about. + +This means sheet metal is not "a feature" — it's a **constraint manifold inside the BRep state +space**. Every operation either preserves manufacturability or it doesn't. Existing tools treat +sheet metal as a parallel modeling mode with its own broken feature set; the legendary move is to +treat it as a manufacturability constraint that any operation can be checked against, with a kernel +that natively represents bends as first-class topology. + +## Why incumbents fail (the problems worth solving) + +1. **K-factor is a single global lie.** Real shops have bend deduction tables indexed by + `(material, thickness, R/t ratio, V-die width, grain)`. SolidWorks/Onshape ship a number; shops + keep the truth in spreadsheets and override every part. +2. **Flat pattern is a derived view, not a model.** You can't add tooling holes, registration tabs, + or nest-friendly cutouts on the flat and have them survive. Manufacturing edits get re-done every + revision. +3. **Bidirectionality is fake.** Unfold→edit→refold is not idempotent in any commercial kernel. + Numerical drift accumulates. +4. **Corner relief is heuristic spaghetti.** Each tool has a dozen relief styles, none of which work + cleanly at non-orthogonal corners or three-way intersections. +5. **No springback model.** Designers compensate by guessing. +6. **DFM is post-hoc.** Bends too near edges, holes too near bends, collisions with the brake's + back-gauge — all caught by the shop, hours later. +7. **Lofted/transition flanges produce wrong flat patterns.** Ruled-surface approximation with no + honest error metric. +8. **Tooling is invisible to the CAD.** It doesn't know your brake's max bend length, your die set, + your minimum flange height for a given die. +9. **Costing is a separate $20k product.** Should be a derivative of the model. +10. **AI agents can't design sheet metal** because no public CAD exposes manufacturability as a + queryable, differentiable interface. + +## The legendary architecture + +### 1. Bends as first-class topology + +In `vcad-kernel-topo`, introduce a `BendRegion` annotation on a connected band of cylindrical faces +with metadata `{axis, radius, angle, k_factor_source, neutral_line}`. This isn't just a tag — it +changes how booleans, fillets, and tessellation behave near bends, and it's what makes lossless +unfold possible. + +`BaseFlange`, `EdgeFlange`, `MiterFlange`, `Hem`, `Jog`, `SweepFlange`, `LoftedFlange` (with honest +developability error reported), `Tab`, `Louver`, `Lance`, `FormingTool` (user-defined dies stamped +from a sketch+depth profile) all emit BRep with `BendRegion`s annotated. No flange ever becomes +"just geometry." + +### 2. Lossless bidirectional unfold + +`Unfold` is an **involution**: `refold(unfold(x)) == x` to within a documented tolerance proven by +test. Achieve this by carrying the bend metadata through the flattening — the flat pattern stores +not just 2D loops but a `Crease` graph with `(line, angle, radius, K, direction)`. The 3D model and +flat pattern are **two views of the same IR node**, not derivations. Edits on either view emit IR +ops that the other view re-evaluates. + +This is the single most differentiating feature. Nobody else has it because nobody else built it +from day one. + +### 3. Bend tables, not K-factors + +A `BendTable` is a queryable function +`(material, t, R, die_width, grain) → (BA, BD, K, springback_angle, min_flange, recommended_relief)`. +Ships with: + +- A curated default table sourced from public Machinery's Handbook + DIN data. +- An **open, community contribution path**: shops submit measured tables under a permissive license; + we publish a versioned, peer-reviewed registry. This is the "Wikipedia of bend allowances" — the + moat is data, and the data wants to be free. +- A learning mode: if a shop measures actuals on test coupons, vcad fits a model and stores the + residuals. + +Every bend in a model carries a **provenance pointer** to the table row that produced its allowance. +Change the table, the model updates. This is real, not marketing. + +### 4. Manufacturability as a typed query + +Expose a single Rust API: + +```rust +fn check_manufacturability(part: &Sheet, shop: &ShopProfile) -> Vec +``` + +`ShopProfile` describes brakes (max length, tonnage, die library), lasers (max sheet, kerf), +materials in stock, grain rules. `Violation` is structured: +`BendTooCloseToEdge { bend_id, edge_id, actual_mm, required_mm }`, +`HoleInsideBendRelief { hole_id, bend_id }`, +`FlangeBelowMinHeight { ... }`, +`BendCollidesWithBackGauge { ... }`. Surfaced live in the property panel as squiggles, exposed via +MCP so AI agents only ever produce buildable parts. + +### 5. Springback as physics, not a fudge + +Use a closed-form elastoplastic beam-bending model (Marciniak / Hosford) for v-bends, parameterized +by yield strength, modulus, and strain-hardening exponent — all in the materials registry. The +kernel emits the **as-bent** geometry and the **target** geometry separately; either can be the +dimensioned reference. For exotic cases, plug `vcad-kernel-physics` (already in-tree) into a forming +sim mode. + +### 6. Costing as a derivative of the model + +`cost(part, shop) → {sheet_area, perimeter_cut, pierces, bends, setups, time, currency}` — a pure +function of the IR. Live in the UI. Shops calibrate with two real quotes and the model stays +accurate. This kills the "send to shop, wait three days for quote" loop. + +### 7. Flat-pattern-first authoring + +A power-user mode where you draw the **flat** with bend lines, set angles per crease, and the 3D +form materializes. This is how experienced sheet-metal designers actually think. Onshape kinda has +it, badly. We make it the equal partner of 3D-first authoring because the IR is the same either way. + +### 8. Nesting and DXF/DWG that the shop accepts + +Multi-part rectangular and true-shape nesting (`vcad-kernel-nest`) producing layered DXF: cuts on +one layer, bend up/down on others, etch text on another, with the **exact conventions** Trumpf / +Amada / Mazak post-processors expect. Round-trip tested against real machines via partnerships. +Optional G-code for routers and Gerber-style outputs for waterjets. + +### 9. Welded sheet-metal assemblies + +A `Weldment` IR node that joins multiple flat parts along edges with weld type, leg size, and +material. Distortion prediction (heuristic first, FEA-backed later via Rapier coupling). Generates +the cutlist + weld map automatically. + +### 10. AI-native MCP surface + +``` +sheet_metal.create_base_flange(sketch, thickness, material) +sheet_metal.add_edge_flange(part, edge, length, angle, relief?) +sheet_metal.unfold(part) -> flat_pattern +sheet_metal.check(part, shop_profile) -> violations +sheet_metal.cost(part, shop_profile) -> quote +sheet_metal.suggest_fix(violation) -> ir_patch +``` + +An LLM with these tools can iterate to a manufacturable part. With `suggest_fix` it can self-heal. +This is something no existing CAD vendor can ship because their kernels aren't structured for it. + +## UI: From first principles + +### Principles + +1. **No modes.** Sheet metal is a property of the part, not a workspace. +2. **The flat pattern is a peer, not a view.** It edits live, beside the 3D. +3. **Direct manipulation beats dialogs.** Click an edge, drag the flange out. +4. **DFM is ambient, not modal.** Lint-style inline marks, like a code editor. +5. **Cost is always visible.** A live number you can't un-see. +6. **Provenance everywhere.** Every bend tells you which row of which table produced its allowance. +7. **Keyboard is a first-class input.** +8. **AI is a participant in the canvas, not a sidebar bolt-on.** + +### Core layout + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ FeatureTree │ 3D Viewport │ Flat Pattern │ +│ │ (R3F, ray-traced) │ (2D canvas, same IR) │ +│ │ │ │ +│ ├──────────────────────┴────────────────────────────┤ +│ │ Contextual Bend Strip (only when SM selected) │ +│ ├──────────────────────────────────────────────────┐│ +│ │ PropertyPanel │ DFM Inspector │ Cost │ AI ││ +└─────────────────────────────────────────────────────────────────┘ +``` + +The split viewport is togglable: `Shift+F` snaps between 3D-only, flat-only, and split. The two +windows share camera-anchor selection: click a face in 3D, the corresponding region pulses in the +flat pattern, and vice versa. A thin "crease ribbon" along the bottom of the 3D view shows every +bend in the part as a chip — click to highlight, drag to reorder if topology allows. + +### 3D viewport: direct manipulation + +- **Hover an edge** → faint perpendicular ghost shows default flange direction. Click-drag → flange + materializes, length following the cursor, angle snapping to 90/45/30/15° (`Shift` for free angle, + `Alt` for the other side). Release → an inline pill near the cursor lets you tweak + `length / angle / radius / relief` without opening a dialog. `Esc` cancels. +- **Hover a bend** → axis renders as a dashed line, radius as a halo. Drag the halo perpendicular + to the axis → radius changes live with the K-factor pill showing the new BA. Drag along the axis + → bend angle changes; the flat repaints in real time. +- **Hover a corner** where two flanges meet → relief icon appears. Click cycles styles + (rectangular, obround, tear, none). The icon only shows when a violation is possible — quiet UI. +- **Hover a face** → if it's a candidate for a hem, jog, louver, or forming tool, those options + appear in a radial mini-palette at the cursor (`Tab` to summon explicitly). + +### Flat pattern editor + +Not a thumbnail. A full editor with the same input model as the 3D view. + +- Bend lines render as colored creases: red = up, blue = down, with angle and radius labels you can + scrub. +- **Tooling-only features** (registration holes, nest tabs, fiducial marks, etch text, vendor logos) + live here and travel with the flat. They render as ghosts in 3D so designers know they exist but + are not part of the bent geometry. **This is the killer feature**: edit the manufacturing artifact + without losing it on the next rev. +- **Sheet stock overlay** shows a translucent rectangle of the configured stock size with grain + direction arrow. Drag the part to reposition; cost updates as utilization changes. +- **Nest preview** shows ghosted other parts in the same job — drag-to-rearrange, with the nesting + solver re-running on release. +- **Crease constraints**: drag a hole, hold `Cmd`, click a bend line — the hole is now constrained + "X mm from this bend"; survives radius and K changes. + +Same selection state, same undo stack, same IR as 3D. + +### Contextual Bend Strip + +Appears only when a sheet-metal part is selected, anchored under the viewport. Eight buttons, each +summoning a gesture rather than a dialog: + +`Base Flange · Edge Flange · Miter · Hem · Jog · Sweep Flange · Lofted Flange · Forming Tool` + +Each shows a tiny live preview of what will be created based on current selection. Hover for +keybinding. Right-click for advanced options. + +### Property panel: structured, scrubbable, provenanced + +For an `EdgeFlange`: + +``` +┌─ Edge Flange #3 ──────────────────────┐ +│ Length 25.00 mm ⇕ │ +│ Angle 90.0° ⇕ │ +│ Radius 1.50 mm ⇕ │ +│ K-factor 0.42 ● → table:Al-1mm │ +│ Relief Rectangular ▾ │ +│ Springback +1.2° (compensated) │ +│ Position Material outside ▾ │ +└───────────────────────────────────────┘ +``` + +Every value is a `ScrubInput`. Provenance dot (●) is colored: green = built-in table, +blue = shop table, purple = measured. Click → jump to the table row. +Length accepts expressions (`thickness * 4`). + +### DFM Inspector + +Like VS Code's Problems pane: + +``` +⚠ Hole too close to bend (1.2 mm < 2.5 mm required) + Sketch: front_face / Hole #4 · Bend #2 + [ Move hole ] [ Increase bend radius ] [ Ignore ] +``` + +Click row → camera flies to the violation in 3D and flat. Each fix button is a real IR patch with +hover preview. `Ignore` adds a justification field (audit trail preserved). The same data backs +the MCP `check_manufacturability` tool. + +Header chip shows totals: `0 errors · 3 warnings · 1 ignored`. When green, the part is shop-ready. + +### Cost badge + +Bottom-right of the viewport, always visible: + +``` +$4.27 each · qty 100 · 3.2 min · 4'x8' Al 1mm +``` + +Click → expands to breakdown (material / pierce / bend / setup / margin). Drag the qty number to +scrub volume pricing. A small sparkline shows cost over the last 10 edits — you watch the part get +cheaper as you fix it. Cost as a design **gradient**, not a final reckoning. + +### Bend Table editor + +A first-class document type, not a modal dialog. Opens in a tab. Spreadsheet UI: rows = thickness, +columns = bend radius, cells = `(BA, K, springback)`. Each cell has a provenance dot and a +"measured-vs-predicted" delta if test data exists. Editing a cell shows which parts in the open +document depend on it and live-updates them. + +A "Submit to registry" button packages anonymized rows for the community bend-table repository. + +### Shop Profile panel + +Lists your brakes, lasers, materials in stock, grain rules, labor rates. Drives DFM checks, +costing, and tool selection. Saved per-user and exportable as a JSON profile. + +### Keyboard map + +``` +B Base flange (enters sketch) +E Edge flange on selection +M Miter flange +H Hem +J Jog +S Sweep flange +L Lofted flange (with developability error report) +T Forming tool + +F Toggle flatten preview (ghost) +Shift+F Cycle 3D / flat / split layouts +U Toggle unfolded state +D DFM Inspector +$ Cost panel +N Open nesting view +Cmd+K AI command bar +``` + +Selection-aware: `E` does nothing if no edge is selected and shows a tooltip explaining why. No +silent failure. + +### AI integration in the canvas + +`Cmd+K` summons a single-line input anchored at the cursor, pre-filled with the selected entity's +name: + +``` +> add a 25mm flange at 90° to this edge with rectangular relief +``` + +Submit → emits MCP `sheet_metal.add_edge_flange(...)` → IR op → live preview → `Enter` to commit, +`Esc` to discard. Same mechanism for "make this part cheaper" (returns 3 IR-patch suggestions), +"fix all DFM warnings" (chained `suggest_fix` calls with diff preview), or "convert this assembly +to a single sheet-metal weldment." + +The AI sees the same IR + violations + cost the user sees, so it never produces unmanufacturable +suggestions. + +### Onboarding + +First time a user creates a sheet-metal feature, auto-split the viewport and run a 30-second tour: +click an edge, drag a flange, watch the flat update, see the cost change, see a DFM warning +resolve. No modal dialogs — inline coachmarks that fade once acknowledged. + +The default new-document template is a sheet-metal-aware empty doc with a sample shop profile so +the cost badge shows real numbers from second one. + +### Visual language + +- Bends render with a subtle anisotropic shader hinting at grain direction in 3D. +- Flat-only features render with a dashed outline in 3D. +- DFM violations: amber halos in 3D, dotted underlines on flat dimensions, both pulsing in unison. +- The K-factor provenance dot color is **the same color** across the property panel, bend table, + and DFM inspector — one visual taxonomy for "where did this number come from." + +## Implementation path + +### Crates (new) + +- `vcad-kernel-sheet` — operations, bend regions, unfold/refold, manufacturability checks +- `vcad-kernel-bend-tables` — table format, registry loader, provenance +- `vcad-kernel-nest` — 2D nesting (rectangle + true-shape via no-fit polygon) +- `vcad-kernel-cost` — process-aware cost model + +### Crate edits + +- `vcad-kernel-topo`: add `BendRegion` + `Crease` graph; thicken-with-bend-awareness +- `vcad-kernel-sweep`: developable-surface sweep with error metric for lofted flanges +- `vcad-ir`: `SheetMetalOp` variants + `FlatPatternView` IR +- `vcad-kernel-step`: round-trip the metadata +- `vcad/src/export/dxf.rs`: layered output with shop-specific dialects + +### App + +- `SheetMetalPanel.tsx`, `FlatPatternView.tsx` (live alongside 3D), `BendTableEditor.tsx`, + `ShopProfile.tsx`, `ManufacturabilityInspector.tsx`, `CostBadge.tsx` +- Toggle: 3D-first vs. flat-first authoring; both edit the same IR + +### Data + +- Seed `bend-tables/` with public-domain values and a contribution workflow +- Seed `materials/` registry with mechanical properties for springback + +## Sequencing + +1. **Foundation (2–3 wks).** `BendRegion` topology, `BaseFlange`, `EdgeFlange`, deterministic + unfold/refold with property tests proving involution within tolerance. DXF flat export. **This + alone beats most open-source CAD.** +2. **Tables + provenance (1 wk).** Bend table format, default data, per-bend provenance, UI to edit. +3. **Manufacturability checks + Shop profile (2 wks).** Rule engine, live UI, MCP exposure. +4. **Costing (1 wk).** Pure function of IR + shop, badge in UI. +5. **Springback + advanced flanges (2 wks).** Miter, hem, jog, lofted with error report. +6. **Flat-first authoring (1 wk).** UI mode flip; same IR. +7. **Nesting + multi-part DXF (2 wks).** +8. **Weldments (3 wks).** +9. **Community bend-table registry (open-ended).** + +Total to legendary MVP: **~12 weeks** of focused work, of which the first 3 already ship something +other open CAD doesn't have. + +## UI implementation order (matched to backend tiers) + +1. With foundation: `SheetMetalPanel`, contextual Bend Strip, click-drag edge flange gesture, basic + split viewport with live flat pattern, property panel with scrubs. +2. With bend tables: Bend Table editor tab, provenance dots wired through, shop profile settings. +3. With DFM: DFM Inspector panel, inline violation marks in 3D + flat, fix-suggestion previews. +4. With cost: Cost badge, breakdown popover, per-edit sparkline. +5. With springback + advanced flanges: Hem/jog/miter gestures, lofted-flange developability + warning UI. +6. With flat-first: Flat-only authoring mode, tooling-only features that ghost in 3D, sheet stock + overlay. +7. With nesting + multipart: Nesting view, drag-to-rearrange, multi-part DXF export wizard. +8. With weldments: Weldment cutlist + weld map UI, distortion preview overlay. +9. AI command bar can land at any tier; cheapest at tier 3 once DFM is queryable. + +## The bet + +Existing CAD vendors can't catch up here because their kernels were built before +manufacturability-as-a-typed-query was a design goal, before AI agents needed structured DFM +feedback, and before community data registries were a credible distribution channel. The +combination of **lossless bidirectional unfold + open bend-table registry + DFM-as-MCP** is the +legendary triple. Each is independently useful; together they make vcad the obvious choice for any +shop that touches sheet metal. + +The thing nobody else has: **three windows that share state.** The 3D, the flat pattern, and the +bend table are the same model viewed three ways. Edit any of them, the others update with +provenance preserved. Existing tools have these as separate features that occasionally talk; we +make them a single object. That's the UI claim that makes the rest legendary.