Sheet metal: an Edge Flange can choose its own corner relief (#1481)

An Edge Flange feature can set the shape and size of the relief at the corners its flanges make, in place of the sheet metal rule's. Design format 24.

Co-authored-by: NOWN-DEV <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-15 20:22:31 -04:00
1 parent c361630954
commit 4cbf9cf607
22 files changed
+543 -43

No files matched your search

+9
View File
@@ -1013,6 +1013,15 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(body)]
body: Option<String>,
/// The relief cut where two of this feature's bends meet at a corner of the base, in place
/// of the rule's: `corner_relief_shape` (notch | round | rectangular | tear) and
/// `corner_relief`, the size across. Either one left out follows the rule.
#[serde(default, skip_serializing_if = "Option::is_none")]
corner_relief_shape: Option<String>,
/// Walked last, after the edges' values, so the inputs before it keep their names.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[walk(length = "Corner relief")]
corner_relief: Option<String>,
},
/// Flat hems on sheet edges.
SheetHem {
+16 -2
View File
@@ -449,7 +449,7 @@ pub(super) fn sheet_eval(
st.sheets.push(sh);
Ok(())
}
FeatureKind::SheetFlange { edges, height, angle, radius, position, flip, body } => {
FeatureKind::SheetFlange { edges, height, angle, radius, position, flip, body, corner_relief_shape, corner_relief } => {
let i = sheet_for(st, body, edges.first().map(|e| e.point))?;
let (h, a) = (val(vals, height, Kind::Length)?, val(vals, angle, Kind::Angle)?);
// Each edge's own height and angle, else the feature's.
@@ -472,6 +472,20 @@ pub(super) fn sheet_eval(
doc.rule_values(vals, &doc.sheet_rule(Some(&rule)))?.bend_radius
}
};
// The corner relief these flanges ask for in place of the rule's.
let relief = sheet::ReliefOverride {
shape: match corner_relief_shape {
Some(name) => Some(
sheet::ReliefShape::parse(name)
.ok_or_else(|| DocError::Invalid(format!("unknown corner relief shape `{name}` (notch, round, rectangular or tear)")))?,
),
None => None,
},
size: match corner_relief {
Some(e) => Some(val(vals, e, Kind::Length)?),
None => None,
},
};
let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
if sh.flat {
return Err(DocError::Invalid("refold the sheet before adding flanges".into()));
@@ -483,7 +497,7 @@ pub(super) fn sheet_eval(
.map(|(pick, (height, angle))| sheet::EdgeFlange { pick, height, angle })
.collect();
let n0 = sh.flanges.len();
let (_, notes) = sh.add_flanges_each(&each, rr, position, *flip)?;
let (_, notes) = sh.add_flanges_each(&each, rr, position, *flip, relief)?;
if !notes.is_empty() {
let notes = notes.join("; ");
*warning = Some(match warning.take() {
@@ -114,7 +114,7 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
("sheet_contour", json!({"type": "sheet_contour", "sketch": 1, "curves": ["l1"], "distance": "contour.distance"})),
(
"sheet_flange",
json!({"type": "sheet_flange", "edges": [{"point": o, "angle": "flange.e1_angle", "height": "flange.e1_height"}, {"point": o, "angle": null, "height": "flange.e2_height"}], "height": "flange.height", "angle": "flange.angle", "radius": "flange.radius"}),
json!({"type": "sheet_flange", "edges": [{"point": o, "angle": "flange.e1_angle", "height": "flange.e1_height"}, {"point": o, "angle": null, "height": "flange.e2_height"}], "height": "flange.height", "angle": "flange.angle", "radius": "flange.radius", "corner_relief_shape": "round", "corner_relief": "flange.relief"}),
),
("sheet_hem", json!({"type": "sheet_hem", "edges": [o], "length": "hem.length", "gap": "hem.gap"})),
(
@@ -243,7 +243,8 @@ sheet_flange
Edge angle = flange.e1_angle (Angle)
Edge height = flange.e1_height (Length)
Edge height = flange.e2_height (Length)
expressions: flange.height, flange.angle, flange.radius, flange.e1_angle, flange.e1_height, flange.e2_height
Corner relief = flange.relief (Length)
expressions: flange.height, flange.angle, flange.radius, flange.e1_angle, flange.e1_height, flange.e2_height, flange.relief
sheet_hem
inputs:
Length = hem.length (Length)
+47 -2
View File
@@ -233,13 +233,18 @@
//! read through cadmpeg); its `step` is still the STEP text the file was restated as, so the
//! feature's fields are unchanged and version 22 designs need no transformation; 0.13.x asks
//! for an update before it meets these imports.
//! - 24: an Edge Flange feature can choose its own corner relief (`corner_relief_shape`: notch,
//! round, rectangular or tear, and `corner_relief`, the size across), for the corners its
//! flanges make. Version 23 designs need no transformation: a feature without them follows the
//! sheet metal rule, as it always did, and saves as before; 0.13.0 (which reads up to 22) asks
//! for an update instead of silently building the rule's relief where a design asks for another.
use serde_json::Value;
use crate::{DocError, Document, Result};
/// The format this build writes.
pub const FORMAT: u32 = 23;
pub const FORMAT: u32 = 24;
pub fn format_string(n: u32) -> String {
format!("solidcraft/{n}")
@@ -256,7 +261,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; 22] = [
const JSON_STEPS: [JsonStep; 23] = [
|_| Ok(()),
v2_to_v3,
v3_to_v4,
@@ -279,6 +284,7 @@ const JSON_STEPS: [JsonStep; 22] = [
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
|_| Ok(()),
];
/// Stored face and edge names were worked out from geometry before format 14; they get the
@@ -1011,6 +1017,45 @@ fn normalise(d: &mut Document) -> Result<()> {
mod tests {
use super::*;
/// 24 added the corner relief of an edge flange: a no-op step carries a 23 design up, a flange
/// without a relief of its own saves as before, and one with it is saved under 24 and kept.
#[test]
fn version_twenty_three_designs_open_and_an_edge_flange_relief_is_saved_in_twenty_four() {
assert_eq!(JSON_STEPS.len(), FORMAT as usize - 1, "one upgrade step for each format");
let flange = |shape: Option<&str>, size: Option<&str>| crate::FeatureKind::SheetFlange {
edges: vec![crate::FlangeEdge::at(solidcraft_geom::Vec3::ZERO)],
height: "10 mm".into(),
angle: "90 deg".into(),
radius: None,
position: "inside".into(),
flip: false,
body: None,
corner_relief_shape: shape.map(Into::into),
corner_relief: size.map(Into::into),
};
let saved = |document: &Document| -> Value { serde_json::from_str(&document.to_json()).unwrap() };
// A flange that follows the rule saves neither key, and a design of format 23 opens as it was.
let mut plain = Document::new("A");
plain.add_feature(flange(None, None), None).unwrap();
let mut file = saved(&plain);
let feature = file["features"].as_array().and_then(|f| f.last()).cloned().unwrap();
assert_eq!(feature["type"], "sheet_flange");
assert!(feature.get("corner_relief_shape").is_none() && feature.get("corner_relief").is_none(), "{feature}");
file["format"] = Value::from("solidcraft/23");
let opened = read(&file.to_string()).unwrap();
assert_eq!(opened.format, format_string(FORMAT));
assert_eq!(opened.features, plain.features);
// One that chooses its relief says so in the file, which is stamped 24 and keeps it.
let mut own = Document::new("A");
own.add_feature(flange(Some("round"), Some("6 mm")), None).unwrap();
let file = saved(&own);
let feature = file["features"].as_array().and_then(|f| f.last()).cloned().unwrap();
assert_eq!((&feature["corner_relief_shape"], &feature["corner_relief"]), (&Value::from("round"), &Value::from("6 mm")));
// A build that reads up to 23 asks for an update on that stamp instead of dropping the relief.
assert_eq!(version_of(file["format"].as_str().unwrap_or_default()), Some(FORMAT));
assert_eq!(read(&file.to_string()).unwrap(), own);
}
#[test]
fn version_thirteen_names_keep_their_meaning_under_the_legacy_mark() {
let file = serde_json::json!({
+39 -20
View File
@@ -361,6 +361,15 @@ impl ReliefShape {
}
}
/// The corner relief an edge flange feature asks for in place of the rule's. A value left out
/// follows the rule.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ReliefOverride {
pub shape: Option<ReliefShape>,
/// How far across the relief is (mm).
pub size: Option<f64>,
}
/// A rule's values (mm, unit-less K).
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct RuleValues {
@@ -2151,7 +2160,7 @@ impl SheetBody {
flip: bool,
) -> Result<(Vec<usize>, Vec<String>)> {
let each: Vec<EdgeFlange> = picks.iter().map(|&pick| EdgeFlange { pick, height, angle }).collect();
self.add_flanges_each(&each, radius, position, flip)
self.add_flanges_each(&each, radius, position, flip, ReliefOverride::default())
}
/// Edge flanges that each have their own height and angle, in one go: every edge is trimmed
@@ -2164,8 +2173,16 @@ impl SheetBody {
/// the base that ends beside more of the base gets the rule's bend relief there
/// (`relieve_bend_ends`). Where cut-outs have taken part of a picked edge away, the flange
/// runs along the stretch they leave nearest the pick and takes its setback off that stretch
/// only; an edge they took whole is refused.
pub fn add_flanges_each(&mut self, edges: &[EdgeFlange], radius: f64, position: &str, flip: bool) -> Result<(Vec<usize>, Vec<String>)> {
/// only; an edge they took whole is refused. The corners between these flanges are relieved
/// as the rule says, except where `relief` names another shape or size.
pub fn add_flanges_each(
&mut self,
edges: &[EdgeFlange],
radius: f64,
position: &str,
flip: bool,
relief: ReliefOverride,
) -> Result<(Vec<usize>, Vec<String>)> {
if edges.is_empty() || edges.len() > 1000 {
return Err(DocError::Invalid("pick 1…1000 edges".into()));
}
@@ -2293,7 +2310,7 @@ impl SheetBody {
for (&i, &free) in made.iter().zip(&freed) {
notes.extend(self.relieve_bend_ends(i, free));
}
notes.extend(self.close_corners(&made));
notes.extend(self.close_corners(&made, relief));
notes.extend(self.close_return_corners(&made));
Ok((made, notes))
}
@@ -2711,9 +2728,10 @@ impl SheetBody {
out
}
/// Close the corners the new flanges make, then cut the rule's relief at each (or tear it).
/// Returns a note for every corner left open, without its relief or not torn.
fn close_corners(&mut self, made: &[usize]) -> Vec<String> {
/// Close the corners the new flanges make, then cut the relief at each (or tear it): the
/// rule's, but for the shape or size `relief` names. Returns a note for every corner left
/// open, without its relief or not torn.
fn close_corners(&mut self, made: &[usize], relief: ReliefOverride) -> Vec<String> {
let corners = self.corners(made);
let mut notes = Vec::new();
let mut closed = Vec::with_capacity(corners.len());
@@ -2723,7 +2741,7 @@ impl SheetBody {
Err(why) => notes.push(format!("the flanges at {} are left open: {why}", self.shown(k.c))),
}
}
notes.extend(self.relieve_corners(&corners, &closed));
notes.extend(self.relieve_corners(&corners, &closed, relief));
notes
}
@@ -2733,16 +2751,18 @@ impl SheetBody {
format!("({:.3}, {:.3}, {:.3})", w.x, w.y, w.z)
}
/// Cut the rule's relief at each of `corners`, or tear those of them that are `closed`.
/// Returns a note for every corner without its relief or not torn.
fn relieve_corners(&mut self, corners: &[Corner], closed: &[Corner]) -> Vec<String> {
/// Cut the relief at each of `corners`, or tear those of them that are `closed`: the rule's,
/// but for the shape or size `relief` names. Returns a note for every corner without its
/// relief or not torn.
fn relieve_corners(&mut self, corners: &[Corner], closed: &[Corner], relief: ReliefOverride) -> Vec<String> {
let mut notes = Vec::new();
match self.relief {
let (shape, size) = (relief.shape.unwrap_or(self.relief), relief.size.unwrap_or(self.relief_size));
match shape {
ReliefShape::Notch => {}
// The reliefs come last: they move the bends' ends, which is how the corners were found.
ReliefShape::Round | ReliefShape::Rectangular => {
for k in corners {
if let Err(why) = self.relieve_corner(k, corners) {
if let Err(why) = self.relieve_corner(k, corners, shape, size) {
notes.push(format!("no corner relief at {}: {why}", self.shown(k.c)));
}
}
@@ -2918,7 +2938,7 @@ impl SheetBody {
Ok(())
}
/// Cut the rule's relief where two bends meet at a corner of the base, `relief_size / 2`
/// Cut a relief `size` across where two bends meet at a corner of the base, `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
@@ -2928,8 +2948,7 @@ impl SheetBody {
/// 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;
fn relieve_corner(&mut self, k: &Corner, corners: &[Corner], shape: ReliefShape, size: f64) -> std::result::Result<(), String> {
if !(size >= MIN_CORNER_RELIEF) {
return Err(format!("the relief size {size:.4} mm is below {MIN_CORNER_RELIEF} mm"));
}
@@ -2961,7 +2980,7 @@ impl SheetBody {
}
// 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 {
let (relief, reach) = match shape {
ReliefShape::Notch | ReliefShape::Tear => return Ok(()),
ReliefShape::Round => (vec![Seg2::Arc { center: c, radius: r, start: da.angle(), sweep: -alpha }], r),
ReliefShape::Rectangular => {
@@ -6075,7 +6094,7 @@ mod tests {
/// sheet and the notes on corners left open.
fn flanges_each(base: Region2, rv: &RuleValues, each: &[EdgeFlange]) -> (SheetBody, Vec<String>) {
let mut s = base_flange("Body1", "AL2", rv, Plane::XY, base);
let notes = s.add_flanges_each(each, rv.bend_radius, "inside", false).unwrap().1;
let notes = s.add_flanges_each(each, rv.bend_radius, "inside", false, ReliefOverride::default()).unwrap().1;
(s, notes)
}
@@ -6630,7 +6649,7 @@ mod tests {
let (mut s, _) = tray(&rv, 90.0, 20.0);
let each: Vec<EdgeFlange> =
(0..4).map(|i| EdgeFlange { pick: far_edge_pick(&s, i, true), height: 12.0, angle: [90f64, 70.0][i % 2].to_radians() }).collect();
let (made, notes) = s.add_flanges_each(&each, 1.0, "inside", false).unwrap();
let (made, notes) = s.add_flanges_each(&each, 1.0, "inside", false, ReliefOverride::default()).unwrap();
assert!(notes.is_empty() && made == [4, 5, 6, 7], "{made:?} {notes:?}");
for (a, a_end, b, b_end) in TRAY_RETURNS {
let (fa, fb) = (&s.flanges[a], &s.flanges[b]);
@@ -6803,7 +6822,7 @@ mod tests {
EdgeFlange { pick: far_edge_pick(&s, i, true), height: 12.0, angle: deg.to_radians() }
})
.collect();
let (_, notes) = s.add_flanges_each(&each, 1.0, "inside", false).unwrap();
let (_, notes) = s.add_flanges_each(&each, 1.0, "inside", false, ReliefOverride::default()).unwrap();
assert!(notes.is_empty(), "{what}: {notes:?}");
returns_keep_out_of_the_walls(&s, &what);
holds_the_flat_material(&s, &what);
+2 -2
View File
@@ -53,7 +53,7 @@ use std::f64::consts::{FRAC_PI_2, PI, TAU};
use solidcraft_geom::{Loop2, Mesh, Plane, Region2, Seg2, Vec2, Vec3};
use solidcraft_kernel::{Body, FaceSurface, face_surface};
use super::{FlatWork, RuleValues, SheetBody, SheetFlange, base_flange, point_in, polys_cross, signed_area, snap};
use super::{FlatWork, ReliefOverride, RuleValues, SheetBody, SheetFlange, base_flange, point_in, polys_cross, signed_area, snap};
use crate::{DocError, ModelBody, Result, apply_point, mat_inverse};
const MAX_BENDS: usize = 1000;
@@ -1002,7 +1002,7 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
Err(why) => notes.push(format!("the corner at {} stays cut open: {why}", sh.shown(k.c))),
}
}
notes.extend(sh.relieve_corners(&corners, &closed));
notes.extend(sh.relieve_corners(&corners, &closed, ReliefOverride::default()));
Ok((sh, notes))
}