diff --git a/ROADMAP.md b/ROADMAP.md index 7e3e0ec2..991c5a89 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -53,7 +53,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. | M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 | | M8 | Surface workspace | deferred | — | | M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 | -| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature | in progress | 36 | +| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular) chosen by the rule | in progress | 36 | | M11 | 2D drawings | deferred | — | | M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 | | M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 | diff --git a/crates/doc/src/format.rs b/crates/doc/src/format.rs index 97b04586..37644def 100644 --- a/crates/doc/src/format.rs +++ b/crates/doc/src/format.rs @@ -51,13 +51,17 @@ //! anchors can name a sheet metal panel side (`sheet:::`). Version 14 //! designs need no transformation; the version stamp lets older readers request an update //! instead of encountering an unknown certificate or link kind. +//! - 16: sheet metal rules can choose a corner relief shape (`corner_relief_shape`: notch, +//! round or rectangular). Version 15 designs need no transformation (an absent shape is the +//! notch they always had); the version stamp lets older readers request an update instead of +//! silently building notches where a design asks for another shape. use serde_json::Value; use crate::{DocError, Document, Result}; /// The format this build writes. -pub const FORMAT: u32 = 15; +pub const FORMAT: u32 = 16; pub fn format_string(n: u32) -> String { format!("solidcraft/{n}") @@ -74,7 +78,7 @@ pub fn version_of(s: &str) -> Option { /// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`. type JsonStep = fn(&mut Value) -> Result<()>; -const JSON_STEPS: [JsonStep; 14] = [ +const JSON_STEPS: [JsonStep; 15] = [ |_| Ok(()), v2_to_v3, v3_to_v4, @@ -89,6 +93,7 @@ const JSON_STEPS: [JsonStep; 14] = [ |_| Ok(()), v13_to_v14, |_| Ok(()), + |_| Ok(()), ]; /// Stored face and edge names were worked out from geometry before format 14; they get the diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index f554975c..7b441645 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -157,6 +157,11 @@ pub struct SheetRule { 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, @@ -178,6 +183,9 @@ fn rule_relief_depth() -> String { 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() } @@ -193,6 +201,7 @@ impl SheetRule { 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(), } @@ -214,6 +223,40 @@ impl SheetSettings { } } +/// 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 { + 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 { @@ -223,6 +266,7 @@ pub struct RuleValues { pub relief_width: f64, pub relief_depth: f64, pub corner_relief: f64, + pub corner_relief_shape: ReliefShape, pub hem_gap: f64, pub gap: f64, } @@ -255,6 +299,8 @@ impl Document { 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)?, }; @@ -328,6 +374,10 @@ pub struct SheetBody { 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)>, @@ -479,6 +529,9 @@ fn clip(poly: &[Vec2], o: Vec2, n: Vec2) -> Vec { /// 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)] @@ -570,7 +623,8 @@ impl SheetBody { } /// The part of a bend along its parent's edge: where a later flange trimmed that edge back - /// (a corner), the bend stops and the flange panel runs on alone (a corner relief). + /// (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); @@ -1361,13 +1415,13 @@ impl SheetBody { /// 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 (`close_corners`). + /// 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> { 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). + /// (they are left open, as the bends leave them) or relieved (they get no relief). pub fn add_flanges_noted( &mut self, picks: &[Vec3], @@ -1502,7 +1556,8 @@ impl SheetBody { out } - /// Close the corners the new flanges make. Returns a note for every corner left open. + /// 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 { let corners = self.corners(made); let mut notes = Vec::new(); @@ -1515,6 +1570,14 @@ impl SheetBody { 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 } @@ -1610,6 +1673,84 @@ impl SheetBody { 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 { let (panel, a, b, top, dist) = self.edge_at(pick).ok_or_else(|| DocError::Invalid("no sheet edge there".into()))?; @@ -2294,6 +2435,8 @@ pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region pockets: Vec::new(), flat: false, gap: rv.gap, + relief: rv.corner_relief_shape, + relief_size: rv.corner_relief, base_curves: Vec::new(), } } @@ -2346,6 +2489,8 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt 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) }; @@ -2461,6 +2606,7 @@ mod tests { relief_width: 2.5, relief_depth: 1.25, corner_relief: 10.0, + corner_relief_shape: ReliefShape::Notch, hem_gap: 0.01, gap: 2.5, } @@ -2827,6 +2973,7 @@ mod tests { relief_width: 2.0, relief_depth: 1.0, corner_relief: 8.0, + corner_relief_shape: ReliefShape::Notch, hem_gap: 0.01, gap: 2.0, }; @@ -2908,6 +3055,7 @@ mod tests { relief_width: 2.0, relief_depth: 1.0, corner_relief: 8.0, + corner_relief_shape: ReliefShape::Notch, hem_gap: 0.01, gap: 0.5, } @@ -3087,6 +3235,248 @@ mod tests { 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(¬ch) - 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) { + 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(¬ch) - 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(¬ch) - 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 @@ -3371,4 +3761,32 @@ mod tests { } 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(¬ch) - 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); + } + } } diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 53ffc7d2..bad83698 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -36,7 +36,9 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?"), CommandSpec::new("sheet.manage_rules", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("sheet_rules").params( "name (new or existing); thickness?, k_factor?, bend_radius?, relief_width?, relief_depth?, corner_relief?, hem_gap?, gap? (expressions; `Thickness` is the rule's; \ - gap: between flanges that meet at a corner, whose ends are closed toward each other at any angle); active?: bool; delete?: bool", + gap: between flanges that meet at a corner, whose ends are closed toward each other at any angle); \ + corner_relief_shape?: notch|round|rectangular (what is cut where two bends meet at a corner of the base, `corner_relief` across; default notch); \ + active?: bool; delete?: bool", ), CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().doc_only().params("→ rules with their expressions and values, and the active one"), CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", flat_pattern) @@ -269,6 +271,10 @@ fn rules(s: &mut Session, p: &Value) -> Result { *slot = e; } } + if let Some(shape) = p.get("corner_relief_shape").filter(|v| !v.is_null()) { + let shape = shape.as_str().and_then(solidcraft_doc::sheet::ReliefShape::parse); + rule.corner_relief_shape = shape.ok_or_else(|| bad(cmd, "`corner_relief_shape` must be notch, round or rectangular"))?.name().into(); + } let (vals, _) = s.doc.param_values(); let values = s.doc.rule_values(&vals, &rule).map_err(|e| bad(cmd, e.to_string()))?; let doc = s.doc_mut(); diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index 01b3bccd..31c82d2d 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -2060,6 +2060,103 @@ fn closed_corners_reach_the_flat_pattern_and_the_dxf() { } } +#[test] +fn corner_reliefs_of_the_rule_reach_the_part_the_flat_pattern_and_the_dxf() { + use solidcraft_geom::Vec2; + // The tray at 72°: its bends meet at the corners of the base, s0 = 3·tan 36° in from the sheet's + // edges, and are BA = 2θ wide in the flat. A relief 8 mm across reaches r = 4 mm along both edges + // and takes, at each corner, r²·π/4 (round) or r² (rectangular) of the base and r of two bends. + let th = 72f64.to_radians(); + let (s0, ba) = (3.0 * (th / 2.0).tan(), th * 2.0); + let corners = [(s0, s0), (120.0 - s0, s0), (120.0 - s0, 90.0 - s0), (s0, 90.0 - s0)]; + let export = |s: &mut Session| run(s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string(); + let mut notch = Session::default(); + alu_tray(&mut notch, 72.0, json!({"corner_relief": "8 mm"})); + let v0 = volume(&mut notch); + assert_eq!(export(&mut notch).matches("0\nARC\n").count(), 0); + run(&mut notch, "sheet.unfold", json!({})); + let flat0 = volume(&mut notch); + for (shape, of_the_base) in [("round", 4.0 * PI), ("rectangular", 16.0)] { + let mut s = Session::default(); + alu_tray(&mut s, 72.0, json!({"corner_relief_shape": shape, "corner_relief": "8 mm"})); + assert!(s.model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()), "{shape}: {:?}", s.model.results); + let listed = run(&mut s, "sheet.rules", json!({})); + assert_eq!(listed["rules"][0]["rule"]["corner_relief_shape"], shape); + assert_eq!(listed["rules"][0]["values"]["corner_relief_shape"], shape); + // The folded part loses what the flat pattern loses (K = 0.5 keeps the volume of a bend). + let (v, want) = (volume(&mut s), 4.0 * (of_the_base + 8.0 * ba) * 2.0); + assert!((v0 - v - want).abs() < 0.1, "{shape}: {v0} - {v} vs {want}"); + // The bend table: every bend is 8 mm shorter. + let f = flat(&mut s); + let lengths: Vec = + f["bends"].as_array().map(|b| b.iter().map(|b| b["length"].as_f64().unwrap_or(f64::NAN)).collect()).unwrap_or_default(); + for (got, edge) in lengths.iter().zip([120.0, 90.0, 120.0, 90.0]) { + assert!(lengths.len() == 4 && (got - (edge - 2.0 * s0 - 8.0)).abs() < 1e-6, "{shape}: {lengths:?}"); + } + assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{shape}"); + // The DXF: the round reliefs are arcs of radius 4 about the four corners, the square ones lines. + let dxf = export(&mut s); + let arcs: Vec<(Vec2, f64)> = solidcraft_io::read_dxf(&dxf) + .unwrap_or_default() + .iter() + .filter_map(|g| if let solidcraft_io::Geom2::Arc { c, a, .. } = g { Some((*c, a.dist(*c))) } else { None }) + .collect(); + if shape == "round" { + assert_eq!(arcs.len(), 4, "{arcs:?}"); + for (x, y) in corners { + assert!(arcs.iter().any(|(c, r)| c.dist(Vec2::new(x, y)) < 1e-6 && (r - 4.0).abs() < 1e-6), "({x}, {y}): {arcs:?}"); + } + } else { + assert!(arcs.is_empty() && dxf.contains(&format!("{:.6}\n20\n{:.6}\n", s0 + 4.0, s0 + 4.0)), "{arcs:?}"); + } + // Unfold and refold keep it, and a cut made folded still goes through: a Ø20 hole in the base. + run(&mut s, "sheet.unfold", json!({})); + assert!((flat0 - volume(&mut s) - want).abs() < 0.1, "{shape}: unfolded {flat0} - {} vs {want}", volume(&mut s)); + run(&mut s, "sheet.refold", json!({})); + assert!((volume(&mut s) - v).abs() < 1e-6 * v, "{shape}"); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"})); + run(&mut s, "sketch.circle.center", json!({"center": [60, 45], "diameter": 20})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Hole", "distance": 10, "operation": "cut", "through_all": true})); + assert!((v - volume(&mut s) - PI * 100.0 * 2.0).abs() < 0.5, "{shape}: {v} - {}", volume(&mut s)); + let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert!((volume(&mut reopened) - volume(&mut s)).abs() < 1e-6, "{shape}"); + } +} + +#[test] +fn the_corner_relief_shape_is_a_rule_setting_checked_and_undone() { + let mut s = Session::default(); + alu_tray(&mut s, 72.0, json!({})); + let shape = |s: &mut Session| run(s, "sheet.rules", json!({}))["rules"][0]["rule"]["corner_relief_shape"].clone(); + let v0 = volume(&mut s); + assert_eq!(shape(&mut s), "notch"); + // Only the three names are shapes; a refused one changes nothing. + for wrong in [json!("star"), json!("square"), json!(""), json!(2)] { + let e = s.execute("sheet.manage_rules", &json!({"name": "AL2", "corner_relief_shape": wrong})).map(|_| ()).unwrap_err().to_string(); + assert!(e.contains("`corner_relief_shape` must be notch, round or rectangular"), "{e}"); + } + assert!(shape(&mut s) == "notch" && volume(&mut s) == v0); + // Changing the rule changes the parts made with it; other settings leave the shape alone. + let r = run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "corner_relief_shape": "Round"})); + assert_eq!((&r["rule"]["corner_relief_shape"], &r["values"]["corner_relief_shape"]), (&json!("round"), &json!("round"))); + let round = volume(&mut s); + assert!(round < v0 - 200.0, "{v0} -> {round}"); + run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "hem_gap": "0.02 mm"})); + assert!(shape(&mut s) == "round" && volume(&mut s) == round); + run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "corner_relief_shape": null, "corner_relief": "6 mm"})); + assert!(shape(&mut s) == "round" && volume(&mut s) > round); + for _ in 0..3 { + run(&mut s, "edit.undo", json!({})); + } + assert!(shape(&mut s) == "notch" && volume(&mut s) == v0); + // A relief longer than the bends is left out and reported on the flange; the part is the notched one. + run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "corner_relief_shape": "rectangular", "corner_relief": "200 mm"})); + let w = s.model.results.last().and_then(|r| r.warning.clone()).unwrap_or_default(); + assert_eq!(w.matches("no corner relief at (").count(), 4, "{w}"); + assert!(w.contains("would take a whole bend away") && volume(&mut s) == v0, "{w}"); +} + #[test] fn corners_left_open_are_reported_on_the_flange() { let mut s = Session::default(); diff --git a/crates/ui-egui/src/dialogs_sheet.rs b/crates/ui-egui/src/dialogs_sheet.rs index d7532600..f07fa504 100644 --- a/crates/ui-egui/src/dialogs_sheet.rs +++ b/crates/ui-egui/src/dialogs_sheet.rs @@ -32,6 +32,9 @@ const RULE_FIELDS: [(&str, &str); 8] = [ ("hem_gap", "Hem Gap"), ("gap", "Flange Gap"), ]; +/// The corner relief shapes the manager offers (the rule's `corner_relief_shape`) and their labels. +const RELIEF_SHAPES: [&str; 3] = ["notch", "round", "rectangular"]; +const RELIEF_SHAPE_LABELS: [&str; 3] = ["Notch", "Round", "Rectangular"]; #[derive(Clone, Debug)] pub enum Sm { @@ -73,6 +76,8 @@ pub enum Sm { pick: usize, name: String, values: Vec, + /// The corner relief shape (index into `RELIEF_SHAPES`). + relief: usize, active: bool, loaded: Option, }, @@ -148,7 +153,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { let active = app.session.doc.sheet.active.clone(); let rules = rule_list(app); let pick = active.and_then(|a| rules.iter().position(|r| r.name == a)).unwrap_or(0); - (Sm::Rules { pick, name: String::new(), values: Vec::new(), active: true, loaded: None }, vec![]) + (Sm::Rules { pick, name: String::new(), values: Vec::new(), relief: 0, active: true, loaded: None }, vec![]) } "sheet.flat_pattern" => (Sm::FlatPattern { data: None, key: None, status: None }, vec![SelInput::new("Body (last if none)", BODIES, false)]), _ => return None, @@ -291,7 +296,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel } Sm::Unfold { .. } => {} Sm::Convert { rule } => rule_combo(app, ui, "sm_conv_rule", rule), - Sm::Rules { pick, name, values, active, loaded } => { + Sm::Rules { pick, name, values, relief, active, loaded } => { let rules = rule_list(app); let mut names: Vec = rules.iter().map(|r| r.name.clone()).collect(); names.push("New rule…".into()); @@ -312,6 +317,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel base.hem_gap.clone(), base.gap.clone(), ]; + *relief = RELIEF_SHAPES.iter().position(|s| s.eq_ignore_ascii_case(base.corner_relief_shape.trim())).unwrap_or(0); *active = app.session.doc.sheet_rule(None).name == base.name || *pick >= rules.len(); if *pick >= rules.len() { *name = format!("Rule{}", rules.len() + 1); @@ -323,7 +329,13 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel ui.text_edit_singleline(name); ui.end_row(); } - for ((_, label), v) in RULE_FIELDS.iter().zip(values.iter_mut()) { + for ((key, label), v) in RULE_FIELDS.iter().zip(values.iter_mut()) { + if *key == "corner_relief" { + // The shape goes with its size, the row below. + row_label(ui, "Corner Relief Shape"); + combo(ui, "sm_relief_shape", &RELIEF_SHAPE_LABELS, relief); + ui.end_row(); + } row_label(ui, label); enter |= field(ui, v); ui.end_row(); @@ -581,7 +593,7 @@ pub fn commands(app: &SolveApp, k: &Sm, inputs: &[SelInput], extra: &Map { + Sm::Rules { pick, name, values, relief, active, .. } => { let rules = rule_list(app); let n = match rules.get(*pick) { Some(r) => r.name.clone(), @@ -590,7 +602,7 @@ pub fn commands(app: &SolveApp, k: &Sm, inputs: &[SelInput], extra: &Map Some(*relief), + _ => None, + }; + assert_eq!(rounds(&h.app), Some(0)); + h.step(&json!({"start": "sheet.manage_rules"})).unwrap(); + h.frames(3); + assert_eq!(shown(&h.app), Some(0), "a rule starts with the notch"); + h.step(&json!({"click": {"handle": "dialog:Corner Relief Shape"}})).unwrap(); + h.step(&json!({"click": {"handle": "dialog:Corner Relief Shape:Round"}})).unwrap(); + assert_eq!(shown(&h.app), Some(1)); + h.step(&json!({"widget": "OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog); + assert_eq!(h.app.session.doc.sheet_rule(None).corner_relief_shape, "round"); + assert_eq!(rounds(&h.app), Some(4), "one round at each corner of the tray"); + h.step(&json!({"start": "sheet.manage_rules"})).unwrap(); + h.frames(3); + assert_eq!(shown(&h.app), Some(1), "the dialog shows the rule's shape"); +} + #[test] fn pocket_depth_labels_follow_inches_without_changing_flat_data() { let mut app = plate(); diff --git a/crates/ui-egui/tests/scenarios/s51_sheet_metal_corner_relief.json b/crates/ui-egui/tests/scenarios/s51_sheet_metal_corner_relief.json new file mode 100644 index 00000000..c561db3f --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s51_sheet_metal_corner_relief.json @@ -0,0 +1,217 @@ +[ + { + "note": "Sheet Metal Rules: the Corner Relief Shape row sets the rule's corner_relief_shape, and the tray made with the rule follows. The bend on the 120 mm edge is 120 - 2*s0 long (s0 = 3*tan 36 deg) and 8 mm shorter with a relief 8 mm across at both its ends." + }, + { + "call": "ui.set", + "params": { + "tab": "SHEET METAL" + } + }, + { + "call": "engine.execute", + "params": { + "command": "sheet.manage_rules", + "params": { + "name": "Sheet 2 mm", + "thickness": "2 mm", + "k_factor": 0.5, + "bend_radius": "1 mm", + "gap": "0.5 mm", + "corner_relief": "8 mm", + "active": true + } + } + }, + { + "call": "engine.execute", + "params": { + "command": "sketch.create", + "params": { + "plane": "XY", + "name": "Base" + } + } + }, + { + "call": "engine.execute", + "params": { + "command": "sketch.rectangle.two_point", + "params": { + "p0": [ + 0, + 0 + ], + "p1": [ + 120, + 90 + ] + } + } + }, + { + "call": "engine.execute", + "params": { + "command": "sketch.finish", + "params": {} + } + }, + { + "call": "engine.execute", + "params": { + "command": "sheet.flange", + "params": { + "sketch": "Base" + } + } + }, + { + "call": "engine.execute", + "params": { + "command": "sheet.flange", + "params": { + "edges": [ + [ + 60, + 0, + 2 + ], + [ + 120, + 45, + 2 + ], + [ + 60, + 90, + 2 + ], + [ + 0, + 45, + 2 + ] + ], + "height": 20, + "angle": "72 deg" + } + } + }, + { + "call": "ui.view", + "params": { + "view": "iso" + } + }, + { + "expect": { + "bodies": 1, + "errors": 0, + "query": { + "command": "sheet.flat_pattern", + "path": [ + "bends", + 0, + "length" + ], + "value": 115.640744832 + } + } + }, + { + "start": "sheet.manage_rules" + }, + { + "click": { + "handle": "dialog:Corner Relief Shape" + } + }, + { + "click": { + "handle": "dialog:Corner Relief Shape:Round" + } + }, + { + "widget": "OK" + }, + { + "expect": { + "dialog": null, + "errors": 0, + "query": { + "command": "sheet.rules", + "path": [ + "rules", + 0, + "rule", + "corner_relief_shape" + ], + "value": "round" + } + } + }, + { + "expect": { + "query": { + "command": "sheet.flat_pattern", + "path": [ + "bends", + 0, + "length" + ], + "value": 107.640744832 + } + } + }, + { + "shot": "s51_round_reliefs" + }, + { + "start": "sheet.manage_rules" + }, + { + "click": { + "handle": "dialog:Corner Relief Shape" + } + }, + { + "click": { + "handle": "dialog:Corner Relief Shape:Rectangular" + } + }, + { + "widget": "OK" + }, + { + "expect": { + "dialog": null, + "errors": 0, + "query": { + "command": "sheet.rules", + "path": [ + "rules", + 0, + "rule", + "corner_relief_shape" + ], + "value": "rectangular" + } + } + }, + { + "expect": { + "query": { + "command": "sheet.flat_pattern", + "path": [ + "bends", + 0, + "length" + ], + "value": 107.640744832 + } + } + }, + { + "shot": "s51_rectangular_reliefs" + } +] diff --git a/docs/book/src/guide/files.md b/docs/book/src/guide/files.md index af7fefac..2db1f5bf 100644 --- a/docs/book/src/guide/files.md +++ b/docs/book/src/guide/files.md @@ -51,7 +51,7 @@ keeps the current design. Previews cannot be edited or measured. This experiment requires `preview: true` in an engine Save/Save As call; ordinary saves omit display caches. The repository's `docs/design-container.md` describes limits and encoding details. -Current designs use format 15, including sketch face anchors on cylinder caps, sketch axis +Current designs use format 16, including sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis links, persistent names from exact face history, exact planar shell-face anchors, persistent analytic offset boundary roles and association profile references, durable sketch face anchors, periodic fit splines and versioned diff --git a/docs/book/src/guide/sheet-metal.md b/docs/book/src/guide/sheet-metal.md index 9552dcd4..85442911 100644 --- a/docs/book/src/guide/sheet-metal.md +++ b/docs/book/src/guide/sheet-metal.md @@ -14,6 +14,25 @@ The SHEET METAL tab builds parts from a sheet of constant thickness under a **sh Bends follow the rule's K-factor, so flat lengths match what a press brake will make. Tests check flat lengths against hand-computed bend allowances. +Where two bends of one edge flange feature meet at a corner of the base, the rule's **corner +relief shape** (`corner_relief_shape`; Corner Relief Shape in Sheet Metal Rules) says what is +cut there. `notch`, the default, cuts nothing more: the bends end square at the corner. `round` +and `rectangular` cut a relief the rule's **corner relief** across, centred on the corner where +the two bend lines meet: out of the base a round with half that size as its radius, or a square +with half that size as its side, and both bends stop where it begins, cut straight across. The +flange panels keep their ends. The relief is in the folded part, the flat pattern, the bend +table (the bends are shorter) and the DXF (a round relief is an arc). At a corner that isn't +square the round follows the angle of the base and the square becomes a kite, its two cuts +square to the edges. Changing the shape in a rule changes the parts made with that rule. A +relief that doesn't fit (under 0.01 mm across, leaving nothing of a bend, or reaching as far +from its corner as another edge of the base is) is left out, and the flange feature reports a +warning saying where. + +```json +{"command":"sheet.manage_rules","params":{"name":"Sheet 2 mm","thickness":"2 mm","k_factor":0.5, + "bend_radius":"1 mm","corner_relief_shape":"round","corner_relief":"8 mm","active":true}} +``` + Where the edge flanges of one feature meet at a corner of the base, their panels are closed toward each other at any bend angle: each end is cut square to the sheet, on a line in the flat chosen so that the edges nearest each other (on the inner faces) run parallel, the rule's diff --git a/docs/design-container.md b/docs/design-container.md index 2dd9d9a4..0c3984f1 100644 --- a/docs/design-container.md +++ b/docs/design-container.md @@ -7,7 +7,7 @@ self-contained input/output format. `io::write_design` keeps its JSON contract; `io::write_design_container` is the fallible container writer. Packages have `container.json` with `{"format":"solidcraft-container/1"}` and -`document.json` with the versioned parametric document (currently `solidcraft/15`). +`document.json` with the versioned parametric document (currently `solidcraft/16`). The independent versions let document migrations evolve without changing blob encodings. Existing serialized document fields are retained automatically, without a second copy of the document schema or a deep clone of embedded payloads.