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.