feat: corner reliefs at sheet flange corners, chosen by the rule (#488)

Sheet Metal Rules get a Corner Relief Shape (notch, round or rectangular), sized by the rule's corner relief, cut where two bends meet at a corner of the base. Notch, the default, keeps existing designs as they were. File format 16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
NOWN-DEVandClaude Opus 5.5 authored and GitHub committed 2026-10-13 00:25:50 -04:00
1 parent c2a9c71436
commit d6af554c6d
11 files changed
+830 -15

No files matched your search

+1 -1
View File
@@ -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 |
+7 -2
View File
@@ -51,13 +51,17 @@
//! anchors can name a sheet metal panel side (`sheet:<body>:<panel>:<side>`). 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<u32> {
/// 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
+422 -4
View File
@@ -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<ReliefShape> {
match s.trim().to_ascii_lowercase().as_str() {
"notch" => Some(ReliefShape::Notch),
"round" => Some(ReliefShape::Round),
"rectangular" => Some(ReliefShape::Rectangular),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
ReliefShape::Notch => "notch",
ReliefShape::Round => "round",
ReliefShape::Rectangular => "rectangular",
}
}
}
/// A rule's values (mm, unit-less K).
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct RuleValues {
@@ -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<Vec2> {
/// 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<Vec<usize>> {
self.add_flanges_noted(picks, height, angle, radius, position, flip).map(|(made, _)| made)
}
/// [`SheetBody::add_flanges`], also returning notes on corners that could not be closed
/// (they are left open, as the bends leave them).
/// (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<String> {
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<usize> {
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(&notch) - flat_area(&s), 4.0 * relief_area(shape, 8.0, FRAC_PI_2, ba));
assert!((removed - want).abs() < 1e-6, "{what}: {removed} vs {want}");
holds_the_flat_material(&s, &what);
let unfolded = vol(&s.flat_solid().unwrap());
assert!((unfolded - flat_area(&s) * 2.0).abs() < 1e-3 * unfolded, "{what}: {unfolded}");
}
}
}
/// The trapezoid of the closing tests with four flanges at `deg`, under rule `rv`.
fn trapezoid(rv: &RuleValues, deg: f64) -> (SheetBody, Vec<String>) {
let pts = [Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(100.0, 90.0), Vec2::new(20.0, 90.0)];
let mut s = base_flange("Body1", "AL2", rv, Plane::XY, Region2 { outer: Loop2::polygon(&pts).ccw(), holes: vec![] });
let picks = [Vec3::new(60.0, 0.0, 2.0), Vec3::new(110.0, 45.0, 2.0), Vec3::new(60.0, 90.0, 2.0), Vec3::new(10.0, 45.0, 2.0)];
let (_, notes) = s.add_flanges_noted(&picks, 38.6, deg.to_radians(), rv.bend_radius, "inside", false).unwrap();
(s, notes)
}
#[test]
fn corner_reliefs_follow_corners_that_are_not_square() {
use std::f64::consts::PI;
// The base fills α = atan(90/20) = 77.47° at the two lower corners of the trapezoid and 180° − α
// at the upper ones. With every edge trimmed by s0, the bends on y = 0 and on the left edge meet
// at c = (s0/tan(α/2), s0), and the left edge leaves c in the direction (cos α, sin α).
let th = 72f64.to_radians();
let (s0, ba) = (3.0 * (th / 2.0).tan(), th * 2.0);
let alpha = 90f64.atan2(20.0);
let c = Vec2::new(s0 / (alpha / 2.0).tan(), s0);
let (along, up) = (Vec2::new(1.0, 0.0), Vec2::from_angle(alpha));
let (notch, _) = trapezoid(&alu(), 72.0);
for shape in [ReliefShape::Round, ReliefShape::Rectangular] {
let what = format!("{shape:?}");
let (s, notes) = trapezoid(&RuleValues { corner_relief_shape: shape, ..alu() }, 72.0);
assert!(notes.is_empty(), "{what}: {notes:?}");
let segs = &s.base.outer.segs;
if shape == ReliefShape::Round {
// An arc of radius 4 about c through the angle α, tangent to both bends' straight ends.
let found = segs.iter().any(|g| {
matches!(g, Seg2::Arc { center, radius, sweep, .. } if center.dist(c) < 1e-8 && (radius - 4.0).abs() < 1e-12 && (sweep + alpha).abs() < 1e-9)
&& g.start().dist(c + up * 4.0) < 1e-8
&& g.end().dist(c + along * 4.0) < 1e-8
&& g.start_tangent().dot(up).abs() < 1e-9
&& g.end_tangent().dot(along).abs() < 1e-9
});
assert!(found, "{what}: {segs:?}");
} else {
// A kite: both cuts square to their edge, meeting 4/cos(α/2) from c on the bisector.
let x = c + Vec2::from_angle(alpha / 2.0) * (4.0 / (alpha / 2.0).cos());
let line =
|a: Vec2, b: Vec2| segs.iter().any(|g| matches!(g, Seg2::Line { .. }) && g.start().dist(a) < 1e-8 && g.end().dist(b) < 1e-8);
assert!(line(c + up * 4.0, x) && line(x, c + along * 4.0), "{what}: {segs:?}");
assert!((x - (c + up * 4.0)).dot(up).abs() < 1e-9 && (x - (c + along * 4.0)).dot(along).abs() < 1e-9);
}
// Two corners of α and two of 180° − α, eight bend ends.
let want = 2.0 * relief_area(shape, 8.0, alpha, ba) + 2.0 * relief_area(shape, 8.0, PI - alpha, ba);
let removed = flat_area(&notch) - flat_area(&s);
assert!((removed - want).abs() < 1e-6, "{what}: {removed} vs {want}");
assert_eq!(s.flanges, notch.flanges, "{what}");
holds_the_flat_material(&s, &what);
}
}
#[test]
fn corner_reliefs_hold_at_every_angle_and_size() {
use std::f64::consts::{FRAC_PI_2, PI};
// The round relief runs into the bends' straight ends tangentially, a contact the planar
// booleans have to keep as one point. So every 5° from 45° to 135° is built on the tray and on
// the trapezoid with both shapes, and on the tray reliefs from 0.5 to 20 mm across.
let alpha = 90f64.atan2(20.0);
for shape in [ReliefShape::Round, ReliefShape::Rectangular] {
for k in 9..=27 {
let deg = k as f64 * 5.0;
let ba = deg.to_radians() * 2.0;
let rv = RuleValues { corner_relief_shape: shape, ..alu() };
for (name, (s, notes), (notch, _), corners) in [
("tray", tray(&rv, deg, 38.6), tray(&alu(), deg, 38.6), [FRAC_PI_2; 4]),
("trapezoid", trapezoid(&rv, deg), trapezoid(&alu(), deg), [alpha, alpha, PI - alpha, PI - alpha]),
] {
let what = format!("{shape:?} on the {name} at {deg}°");
assert!(notes.is_empty(), "{what}: {notes:?}");
let want: f64 = corners.iter().map(|a| relief_area(shape, 8.0, *a, ba)).sum();
let removed = flat_area(&notch) - flat_area(&s);
assert!((removed - want).abs() < 1e-6, "{what}: {removed} vs {want}");
holds_the_flat_material(&s, &what);
}
}
for size in [0.5, 1.0, 3.0, 5.0, 12.0, 20.0] {
for deg in [72.0f64, 90.0] {
let what = format!("{shape:?} {size} mm across at {deg}°");
let (s, notes) = tray(&RuleValues { corner_relief_shape: shape, corner_relief: size, ..alu() }, deg, 38.6);
assert!(notes.is_empty(), "{what}: {notes:?}");
let want = 4.0 * relief_area(shape, size, FRAC_PI_2, deg.to_radians() * 2.0);
let removed = flat_area(&tray(&alu(), deg, 38.6).0) - flat_area(&s);
assert!((removed - want).abs() < 1e-6, "{what}: {removed} vs {want}");
holds_the_flat_material(&s, &what);
}
}
}
}
#[test]
fn a_relief_that_does_not_fit_is_left_out_with_a_note() {
let closed = tray(&alu(), 72.0, 38.6).0;
// 200 mm across reaches 100 mm along edges of which the shorter is 90 − 2·s0 = 85.6 mm long.
// 100 mm across reaches 50 mm: that fits the edge once but not from both its ends, and all four
// corners are left out together. Nothing, or less than nothing, is too small to cut.
let sizes = [(200.0, "whole bend"), (100.0, "whole bend"), (0.0, "below 0.01 mm"), (-8.0, "below 0.01 mm"), (f64::NAN, "below 0.01 mm")];
for (size, why) in sizes {
let (s, notes) = tray(&RuleValues { corner_relief_shape: ReliefShape::Round, corner_relief: size, ..alu() }, 72.0, 38.6);
assert_eq!(notes.len(), 4, "{size}: {notes:?}");
assert!(notes.iter().all(|n| n.starts_with("no corner relief at (") && n.contains(why)), "{size}: {notes:?}");
// The part is the one the notch gives, corners closed.
assert_eq!((&s.base, &s.flanges), (&closed.base, &closed.flanges), "{size}");
}
// A 120 × 12 strip with flanges on its lower and left edges, a relief 16 mm across. Its 8 mm fit
// on both edges at the corner (the left one is 12 − s0 = 9.82 long), but the far corner of the
// square is 8·√2 = 11.3 mm from the corner of the bends, and the strip's upper edge is only 9.82
// from it: no relief may reach as far from its corner as another edge is. The round one reaches
// 8 mm and fits.
let strip = |shape: ReliefShape| {
let base = rect(Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 12.0), Vec2::new(0.0, 12.0));
let mut s = base_flange("Body1", "AL2", &RuleValues { corner_relief_shape: shape, corner_relief: 16.0, ..alu() }, Plane::XY, base);
let picks = [Vec3::new(60.0, 0.0, 2.0), Vec3::new(0.0, 6.0, 2.0)];
let (_, notes) = s.add_flanges_noted(&picks, 20.0, 72f64.to_radians(), 1.0, "inside", false).unwrap();
(s, notes)
};
let (s, notes) = strip(ReliefShape::Rectangular);
assert!(notes.len() == 1 && notes[0].contains("another edge of the base"), "{notes:?}");
assert!(s.base.outer.segs.len() == 4 && s.solid().is_ok());
let (s, notes) = strip(ReliefShape::Round);
assert!(notes.is_empty() && s.base.outer.segs.iter().filter(|g| matches!(g, Seg2::Arc { .. })).count() == 1, "{notes:?}");
holds_the_flat_material(&s, "the strip");
// A right triangle with flanges on its two legs: the base's legs are 100 − 2·s0 = 95.6 long and
// its long edge passes (100 − 2·s0)/√2 = 67.6 mm from the corner of the bends. A round relief of
// radius 75 would cut through that edge; one of radius 65 stays inside.
let triangle = |size: f64| {
let pts = [Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(0.0, 100.0)];
let rv = RuleValues { corner_relief_shape: ReliefShape::Round, corner_relief: size, ..alu() };
let mut s = base_flange("Body1", "AL2", &rv, Plane::XY, Region2 { outer: Loop2::polygon(&pts).ccw(), holes: vec![] });
let picks = [Vec3::new(50.0, 0.0, 2.0), Vec3::new(0.0, 50.0, 2.0)];
let (_, notes) = s.add_flanges_noted(&picks, 20.0, 72f64.to_radians(), 1.0, "inside", false).unwrap();
(s, notes)
};
let (s, notes) = triangle(150.0);
assert!(notes.len() == 1 && notes[0].contains("another edge of the base"), "{notes:?}");
assert!(s.base.outer.segs.len() == 3 && s.solid().is_ok());
let (s, notes) = triangle(130.0);
assert!(notes.is_empty() && s.base.outer.segs.len() == 4, "{notes:?}");
holds_the_flat_material(&s, "the triangle");
}
#[test]
fn a_corner_with_a_flange_of_an_earlier_feature_gets_no_relief() {
// Like the closing, the relief belongs to the corners between the flanges of one feature.
let rv = RuleValues { corner_relief_shape: ReliefShape::Round, ..alu() };
let base = rect(Vec2::new(0.0, 0.0), Vec2::new(120.0, 0.0), Vec2::new(120.0, 90.0), Vec2::new(0.0, 90.0));
let mut s = base_flange("Body1", "AL2", &rv, Plane::XY, base);
s.add_flanges(&[Vec3::new(60.0, 0.0, 2.0)], 38.6, 72f64.to_radians(), 1.0, "inside", false).unwrap();
let (_, notes) = s.add_flanges_noted(&[Vec3::new(120.0, 45.0, 2.0)], 25.0, 72f64.to_radians(), 1.0, "inside", false).unwrap();
assert!(notes.is_empty(), "{notes:?}");
assert!(s.base.outer.segs.iter().all(|g| matches!(g, Seg2::Line { .. })));
assert!(s.solid().is_ok() && s.flat_regions().unwrap().len() == 1);
}
#[test]
fn closed_corners_keep_their_flat_pattern_at_every_angle() {
// 45° to 135° in steps of 5° on the tray and on a trapezoid. Which angles lose the flat
@@ -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(&notch) - flat_area(&s), 4.0 * relief_area(shape, 8.0, FRAC_PI_2, 0.0) + in_the_bends);
assert!((removed - want).abs() < 1e-6, "{what}: {removed} vs {want}");
holds_the_flat_material(&s, &what);
}
}
}
+7 -1
View File
@@ -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<Value> {
*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();
+97
View File
@@ -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<f64> =
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();
+17 -5
View File
@@ -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<String>,
/// The corner relief shape (index into `RELIEF_SHAPES`).
relief: usize,
active: bool,
loaded: Option<usize>,
},
@@ -148,7 +153,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
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<String> = 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<String,
}
("sheet.convert", p)
}
Sm::Rules { pick, name, values, active, .. } => {
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<String,
if n.is_empty() {
return Err("give the rule a name".into());
}
let mut p = json!({"name": n, "active": active});
let mut p = json!({"name": n, "active": active, "corner_relief_shape": RELIEF_SHAPES.get(*relief).copied().unwrap_or("notch")});
for ((k, _), v) in RULE_FIELDS.iter().zip(values) {
if !v.trim().is_empty() {
p[*k] = json!(v);
+41
View File
@@ -311,6 +311,47 @@ fn bend_table_picker_writes_the_current_body() {
assert_eq!(app.session.undo.len(), undo);
}
/// The rule manager's shape selector: picked in the dialog, the shape reaches the rule and the part
/// made with it, and the dialog shows it the next time.
#[test]
fn rules_dialog_picks_the_corner_relief_shape() {
let mut h = crate::scenario::Harness::new();
h.app
.run(
"sheet.manage_rules",
json!({"name": "AL2", "thickness": "2 mm", "k_factor": 0.5, "bend_radius": "1 mm", "gap": "0.5 mm", "active": true}),
)
.unwrap();
h.app.run("sketch.create", json!({"plane": "XY", "name": "Base"})).unwrap();
h.app.run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [120, 90]})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
h.app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
h.app.run("sheet.flange", json!({"edges": [[60, 0, 2], [120, 45, 2], [60, 90, 2], [0, 45, 2]], "height": 20, "angle": "72 deg"})).unwrap();
// The rounds in the base of the tray, and the shape the open dialog shows.
let rounds = |app: &SolveApp| {
let state = app.session.model.state();
state.sheets.last().map(|s| s.base.outer.segs.iter().filter(|g| matches!(g, solidcraft_engine::geom::Seg2::Arc { .. })).count())
};
let shown = |app: &SolveApp| match app.dialog.as_ref().map(|d| &d.kind) {
Some(Kind::Sheet(Sm::Rules { relief, .. })) => 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();
@@ -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"
}
]
+1 -1
View File
@@ -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
+19
View File
@@ -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
+1 -1
View File
@@ -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.