Sheet metal Fold (SheetMetalFoldCmd): fold the base panel along a sketch line (followed when the sketch changes) or two points on its face; angle, bend radius and K-factor from the rule; the bend zone (BA = θ(R + K·T)) comes out of the existing sheet so the flat pattern keeps its size; the line at the bend's centre, start, end or on the mould line; the side holding a picked point stays (default the larger), flip folds down; flanges on the moving part ride along

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 11:37:33 +00:00
1 parent 4ae4b45d91
commit 9f8915996d
9 files changed
+327

No files matched your search

+7
View File
@@ -193,6 +193,13 @@ impl FeatureKind {
FeatureKind::Thread { face, .. } => pt(face),
FeatureKind::SheetFlange { edges, .. } | FeatureKind::SheetHem { edges, .. } => edges.iter_mut().for_each(pt),
FeatureKind::SheetConvert { face, .. } => pt(face),
FeatureKind::SheetFold { a, b, fixed, .. } => {
pt(a);
pt(b);
if let Some(f) = fixed {
pt(f);
}
}
FeatureKind::Boss { position, direction, .. } => {
pt(position);
dir(direction);
+1
View File
@@ -49,6 +49,7 @@ impl FeatureKind {
sk(path_sketch);
}
FeatureKind::Pipe { path_sketch, .. } => sk(path_sketch),
FeatureKind::SheetFold { sketch: Some(s), .. } => sk(s),
FeatureKind::Loft { sections, .. } => sections.iter_mut().for_each(|s| sk(&mut s.sketch)),
FeatureKind::Hole { points: Some(p), .. } => sk(&mut p.sketch),
FeatureKind::Pattern { features, pattern, .. } => {
+30
View File
@@ -460,6 +460,28 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
},
/// Fold a sheet along a line on its base face: a sketch line (followed when the sketch
/// changes), else the points `a`, `b`.
SheetFold {
#[serde(default, skip_serializing_if = "Option::is_none")]
sketch: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
curve: Option<String>,
a: Vec3,
b: Vec3,
angle: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
radius: Option<String>,
/// centerline | start | end | mould
position: String,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
flip: bool,
/// A point on the side that stays put (default: the larger side).
#[serde(default, skip_serializing_if = "Option::is_none")]
fixed: Option<Vec3>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
},
/// Unfold a sheet body flat (all bends), or refold it.
SheetUnfold {
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -742,6 +764,7 @@ impl FeatureKind {
| FeatureKind::SheetContour { .. }
| FeatureKind::SheetFlange { .. }
| FeatureKind::SheetHem { .. }
| FeatureKind::SheetFold { .. }
| FeatureKind::SheetUnfold { .. }
| FeatureKind::SheetConvert { .. }
)
@@ -787,6 +810,7 @@ impl FeatureKind {
FeatureKind::SheetContour { .. } => "ContourFlangeFeature",
FeatureKind::SheetFlange { .. } => "EdgeFlangeFeature",
FeatureKind::SheetHem { .. } => "HemFeature",
FeatureKind::SheetFold { .. } => "FoldFeature",
FeatureKind::SheetUnfold { refold: false, .. } => "UnfoldFeature",
FeatureKind::SheetUnfold { .. } => "RefoldFeature",
FeatureKind::SheetConvert { .. } => "ConvertToSheetMetalFeature",
@@ -840,6 +864,7 @@ impl FeatureKind {
FeatureKind::SheetContour { .. } => "ContourFlange",
FeatureKind::SheetFlange { .. } => "EdgeFlange",
FeatureKind::SheetHem { .. } => "Hem",
FeatureKind::SheetFold { .. } => "Fold",
FeatureKind::SheetUnfold { refold: false, .. } => "Unfold",
FeatureKind::SheetUnfold { .. } => "Refold",
FeatureKind::SheetConvert { .. } => "ConvertToSheetMetal",
@@ -946,6 +971,10 @@ impl FeatureKind {
v.extend([height.as_str(), angle]);
v.extend(radius.iter().map(String::as_str));
}
FeatureKind::SheetFold { angle, radius, .. } => {
v.push(angle);
v.extend(radius.iter().map(String::as_str));
}
FeatureKind::SheetHem { length, gap, .. } => {
v.push(length);
v.extend(gap.iter().map(String::as_str));
@@ -1275,6 +1304,7 @@ impl Document {
FeatureKind::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } if *path_sketch == id => gone.push(f.id),
FeatureKind::Emboss { sketch, .. } | FeatureKind::Rib { sketch, .. } if *sketch == id => gone.push(f.id),
FeatureKind::SheetBase { sketch, .. } | FeatureKind::SheetContour { sketch, .. } if *sketch == id => gone.push(f.id),
FeatureKind::SheetFold { sketch: Some(s), .. } if *s == id => gone.push(f.id),
_ => {}
}
}
+1
View File
@@ -1521,6 +1521,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
| FeatureKind::SheetContour { .. }
| FeatureKind::SheetFlange { .. }
| FeatureKind::SheetHem { .. }
| FeatureKind::SheetFold { .. }
| FeatureKind::SheetUnfold { .. }
| FeatureKind::SheetConvert { .. } => more::sheet_eval(doc, vals, f, st),
FeatureKind::Boss { .. } | FeatureKind::Lip { .. } | FeatureKind::SnapFit { .. } | FeatureKind::Rest { .. } => {
+29
View File
@@ -368,6 +368,35 @@ pub(super) fn sheet_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Fea
}
rebuild_sheet(st, i)
}
FeatureKind::SheetFold { sketch, curve, a, b, angle, radius, position, flip, fixed, body } => {
// The sketch line, where it is now.
let (a, b) = match (sketch, curve) {
(Some(sk), Some(c)) => {
let ss = st.sketch(*sk).ok_or_else(|| DocError::Unknown(format!("sketch {sk}")))?;
let ci = ss.sketch.curve_index(c).ok_or_else(|| DocError::Unknown(format!("curve `{c}`")))?;
match ss.sketch.segs(ci).as_slice() {
[Seg2::Line { a, b }] => (ss.plane.to_world(*a), ss.plane.to_world(*b)),
_ => return Err(DocError::Invalid("the fold line must be a sketch line".into())),
}
}
_ => (*a, *b),
};
let i = sheet_for(st, body, Some((a + b) * 0.5))?;
let ang = val(vals, angle, Kind::Angle)?;
let rr = match radius {
Some(e) => val(vals, e, Kind::Length)?,
None => {
let rule = st.sheets.get(i).map(|s| s.rule.clone()).unwrap_or_default();
doc.rule_values(vals, &doc.sheet_rule(Some(&rule)))?.bend_radius
}
};
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 folding it".into()));
}
sh.add_fold(a, b, ang, rr, position, *flip, *fixed)?;
rebuild_sheet(st, i)
}
FeatureKind::SheetUnfold { body, refold } => {
let i = match body {
Some(_) => sheet_for(st, body, None)?,
+6
View File
@@ -187,6 +187,12 @@ impl FeatureKind {
v.push(("Bend radius", r, L));
}
}
FeatureKind::SheetFold { angle, radius, .. } => {
v.push(("Angle", angle, A));
if let Some(r) = radius {
v.push(("Bend radius", r, L));
}
}
FeatureKind::SheetHem { length, gap, .. } => {
v.push(("Length", length, L));
if let Some(g) = gap {
+114
View File
@@ -156,6 +156,10 @@ pub struct SheetFlange {
/// The panel runs on past the bend's ends by these (toward a neighbouring flange).
#[serde(default)]
pub ext: (f64, f64),
/// A fold's panel: the part of the sheet past the bend (flat coordinates, counter-clockwise)
/// instead of a rectangle `leg` long.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shape: Option<Vec<Vec2>>,
}
/// A sheet metal body (see the module docs).
@@ -318,6 +322,9 @@ impl SheetBody {
/// Flat region of a flange's panel.
pub fn panel(&self, f: &SheetFlange) -> Region2 {
if let Some(sh) = &f.shape {
return Region2 { outer: Loop2::polygon(sh).ccw(), holes: Vec::new() };
}
let ba = self.allowance(f);
let a = f.p + f.n * ba - f.d * f.ext.0;
let b = f.p + f.n * ba + f.d * (f.len + f.ext.1);
@@ -831,6 +838,7 @@ impl SheetBody {
angle: if up { angle } else { -angle },
radius,
leg: leg.max(0.0),
shape: None,
ext: (0.0, 0.0),
});
made.push(self.flanges.len() - 1);
@@ -896,10 +904,115 @@ impl SheetBody {
radius: gap,
leg,
ext: (0.0, 0.0),
shape: None,
});
Ok(self.flanges.len() - 1)
}
/// Fold the base panel along a line on its top or bottom face (world points `a`, `b`).
/// The bend zone (width BA = θ·(R + K·T)) is taken out of the existing sheet, so the flat
/// pattern keeps its size; `position` puts the line at the zone's centre (`centerline`),
/// start or end (`start`/`end`, from the fixed side), or on the mould line (`mould`: where
/// the outer faces meet, so the fixed side measures the same to it folded as flat).
/// The side holding `fixed` (default: the larger side) stays put; `flip` folds the other way.
#[allow(clippy::too_many_arguments)]
pub fn add_fold(&mut self, a: Vec3, b: Vec3, angle: f64, radius: f64, position: &str, flip: bool, fixed: Option<Vec3>) -> Result<usize> {
if !(angle > 1e-6 && angle <= std::f64::consts::PI - 1e-6) {
return Err(DocError::Invalid("the fold angle must be between 0 and 180°".into()));
}
if !(radius >= 0.0) {
return Err(DocError::Invalid("the bend radius must not be negative".into()));
}
let inv = mat_inverse(&self.world(None)).ok_or_else(|| DocError::Invalid("sheet frame".into()))?;
let (fa, fb) = (apply_point(&inv, a), apply_point(&inv, b));
let on_face = |q: Vec3| q.z.abs() < 1e-4 || (q.z - self.t).abs() < 1e-4;
if !(on_face(fa) && on_face(fb)) {
return Err(DocError::Invalid("the fold line must lie on the base face of the sheet (folds on flanges are not supported yet)".into()));
}
let (a2, b2) = (Vec2::new(fa.x, fa.y), Vec2::new(fb.x, fb.y));
let d = (b2 - a2).normalized().ok_or_else(|| DocError::Invalid("the fold line has no length".into()))?;
let pts = dedup(&self.base.outer.ccw().polyline(1e-3));
let mut n = Vec2::new(-d.y, d.x);
let l0 = a2.dot(n);
// Which side moves: away from `fixed`, else the smaller side.
let side_area = |n: Vec2| signed_area(&clip(&pts, n * l0, n)).abs();
let moves_n = match fixed {
Some(p) => {
let q = apply_point(&inv, p);
Vec2::new(q.x, q.y).dot(n) < l0
}
None => side_area(n) <= side_area(n * -1.0),
};
if !moves_n {
n = n * -1.0;
}
let l = a2.dot(n);
let probe = SheetFlange { parent: None, p: a2, d, len: 1.0, n, angle, radius, leg: 0.0, ext: (0.0, 0.0), shape: None };
let ba = self.allowance(&probe);
let s0 = match position {
"centerline" | "center" | "centre" => l - ba / 2.0,
"start" => l,
"end" => l - ba,
"mould" | "mold" => l - (radius + self.t) * (angle / 2.0).tan(),
o => return Err(DocError::Invalid(format!("unknown fold position `{o}` (centerline, start, end or mould)"))),
};
let fixed_part = clip(&pts, n * s0, n * -1.0);
let moving = clip(&pts, n * (s0 + ba), n);
let strip = clip(&clip(&pts, n * s0, n), n * (s0 + ba), n * -1.0);
if signed_area(&fixed_part).abs() < 1e-9 || signed_area(&moving).abs() < 1e-9 {
return Err(DocError::Invalid("the fold line must cross the sheet with material on both sides of the bend".into()));
}
// The bend zone must be a rectangle across the sheet.
let us: Vec<f64> = strip.iter().map(|q| q.dot(d)).collect();
let (u0, u1) = (us.iter().cloned().fold(f64::MAX, f64::min), us.iter().cloned().fold(f64::MIN, f64::max));
if ((u1 - u0) * ba - signed_area(&strip).abs()).abs() > 1e-6 * (u1 - u0).max(1.0) * ba.max(1.0) {
return Err(DocError::Invalid("not supported yet: folds whose bend zone crosses a slanted or curved edge of the sheet".into()));
}
if self.holes.iter().any(|h| {
let hp = dedup(&h.outer.ccw().polyline(1e-3));
hp.iter().any(|q| point_in(&strip, *q)) || polys_cross(&strip, &hp)
}) {
return Err(DocError::Invalid("not supported yet: folds through a cut-out".into()));
}
let up = !flip;
let leg = moving.iter().map(|q| q.dot(n) - s0 - ba).fold(0.0, f64::max);
let p0 = n * s0 + d * u0;
let mut ccw = moving.clone();
if signed_area(&ccw) < 0.0 {
ccw.reverse();
}
self.base = Region2 { outer: Loop2::polygon(&fixed_part).ccw(), holes: self.base.holes.clone() };
self.flanges.push(SheetFlange {
parent: None,
p: snap(p0),
d,
len: u1 - u0,
n,
angle: if up { angle } else { -angle },
radius,
leg,
ext: (0.0, 0.0),
shape: Some(ccw.into_iter().map(snap).collect()),
});
let i = self.flanges.len() - 1;
// Flanges on the moving part's edges now hang off the fold and move with it.
for k in 0..i {
let Some(f) = self.flanges.get(k) else { continue };
if f.parent.is_some() {
continue;
}
let (e0, e1) = ((f.p - p0).dot(n), (f.p + f.d * f.len - p0).dot(n));
if e0.min(e1) >= ba - 1e-9 {
if let Some(f) = self.flanges.get_mut(k) {
f.parent = Some(i);
}
} else if e0.max(e1) > 1e-9 {
return Err(DocError::Invalid("not supported yet: a fold through a flange's bend".into()));
}
}
Ok(i)
}
/// Map a region in a world plane to flat coordinates through a panel's placement.
pub fn region_to_flat(&self, panel: Option<usize>, plane: &Plane, r: &Region2) -> Option<Region2> {
let inv = mat_inverse(&self.world(panel))?;
@@ -1179,6 +1292,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
radius: r,
leg: leg.max(0.0),
ext: (0.0, 0.0),
shape: None,
};
let ba = sheet.allowance(&f);
sheet.flanges.push(f);
+47
View File
@@ -22,6 +22,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SHEET METAL", "CREATE")
.icon("hem")
.params("edges: [[x,y,z] on sheet edges]; length (to the outer face); gap? (inner radius, default: rule); flip?; body?"),
CommandSpec::new("SheetMetalFoldCmd", "Fold", fold).at("SHEET METAL", "CREATE").icon("fold").params(
"line: sketch + curve (a sketch line on the sheet's base face; followed when the sketch changes) or points: [[x,y,z], [x,y,z]]; \
angle? (default 90 deg); radius? (default: rule); position?: centerline|start|end|mould (where the line sits in the bend, default centerline); \
fixed?: [x,y,z] on the side that stays (default: the larger side); flip? (fold the other way); body?",
),
CommandSpec::new("FusionSheetmetalUnfoldCommand", "Unfold", unfold).at("SHEET METAL", "MODIFY").icon("unfold").params("body? (default: the last sheet body) — all bends"),
CommandSpec::new("sheet.refold", "Refold", refold).at("SHEET METAL", "MODIFY").icon("refold").params("body? (default: the last unfolded sheet)"),
CommandSpec::new("ConvertToSheetMetalCmd", "Convert to Sheet Metal", convert)
@@ -133,6 +138,48 @@ fn hem(s: &mut Session, p: &Value) -> Result<Value> {
)
}
fn fold(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SheetMetalFoldCmd";
let (sketch, curve, a, b) = match p.get("points") {
Some(v) => {
let pts = v.as_array().map(|a| a.iter().filter_map(vec3).collect::<Vec<Vec3>>()).unwrap_or_default();
let [a, b] = pts[..] else { return Err(bad(cmd, "`points` must be two points [x, y, z]")) };
(None, None, a, b)
}
None => {
let sk = sketch_id(s, p.get("sketch"), cmd, "sketch")?;
let c = str_(p, "curve").ok_or_else(|| bad(cmd, "give `sketch` + `curve` (a sketch line) or `points`"))?.to_string();
let st = s.model.state();
let ss = st.sketch(sk).ok_or_else(|| bad(cmd, "no such sketch"))?;
let ci = ss.sketch.curve_index(&c).ok_or_else(|| bad(cmd, format!("no curve `{c}` in the sketch")))?;
let (a, b) = match ss.sketch.segs(ci).as_slice() {
[solvecraft_geom::Seg2::Line { a, b }] => (ss.plane.to_world(*a), ss.plane.to_world(*b)),
_ => return Err(bad(cmd, "the fold line must be a sketch line")),
};
(Some(sk), Some(c), a, b)
}
};
if a.dist(b) < 1e-9 {
return Err(bad(cmd, "the fold line has no length"));
}
let angle = expr(p, "angle").unwrap_or_else(|| "90 deg".into());
check_expr(s, &angle, Kind::Angle, cmd, "angle")?;
let radius = expr(p, "radius");
if let Some(r) = &radius {
check_expr(s, r, Kind::Length, cmd, "radius")?;
}
let position = str_(p, "position").unwrap_or("centerline").to_ascii_lowercase();
if !matches!(position.as_str(), "centerline" | "start" | "end" | "mould" | "mold") {
return Err(bad(cmd, "`position` must be centerline, start, end or mould"));
}
let fixed = match p.get("fixed") {
Some(v) => Some(vec3(v).ok_or_else(|| bad(cmd, "`fixed` must be a point [x, y, z]"))?),
None => None,
};
let body = str_(p, "body").map(str::to_string);
add_feature(s, p, FeatureKind::SheetFold { sketch, curve, a, b, angle, radius, position, flip: bool_(p, "flip").unwrap_or(false), fixed, body })
}
fn unfold(s: &mut Session, p: &Value) -> Result<Value> {
add_feature(s, p, FeatureKind::SheetUnfold { body: str_(p, "body").map(str::to_string), refold: false })
}
+92
View File
@@ -141,3 +141,95 @@ fn unfold_cut_refold_and_convert() {
run(&mut t, "PrimitiveCylinder", json!({"radius": 10, "height": 30, "body_name": "Rod"}));
assert!(t.execute("ConvertToSheetMetalCmd", &json!({"body": "Rod", "face": [0, 0, 30]})).is_err());
}
/// A sketch line across a 100 x 60 plate's top face at x = 40.
fn fold_line(s: &mut Session) {
run(s, "SketchCreate", json!({"plane": "XY", "offset": 2.5, "name": "FoldLine"}));
run(s, "DrawPolyline", json!({"points": [[40, 0], [40, 60]], "ids": ["fl"]}));
run(s, "SketchStop", json!({}));
}
fn bbox(s: &mut Session) -> Value {
run(s, "MeasureCommand", json!({}))["bodies"][0]["bbox"].clone()
}
#[test]
fn fold_takes_the_bend_out_of_the_flat_sheet() {
let (t, r, k) = (2.5, 2.5, 0.44);
let ba = FRAC_PI_2 * (r + k * t);
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
fold_line(&mut s);
run(&mut s, "SheetMetalFoldCmd", json!({"sketch": "FoldLine", "curve": "fl"}));
// The flat pattern keeps its size; the bend's centre line is the sketch line.
let f = flat(&mut s);
assert!((f["flat_size_mm"][0].as_f64().unwrap_or(0.0) - 100.0).abs() < 1e-6, "{f}");
assert!((f["flat_size_mm"][1].as_f64().unwrap_or(0.0) - 60.0).abs() < 1e-6, "{f}");
assert!((f["bends"][0]["allowance"].as_f64().unwrap_or(0.0) - ba).abs() < 1e-9, "{f}");
// Folded: the zone's flat volume BA·T·w becomes the bend's θ·T·(R + T/2)·w.
let want = 15000.0 - ba * t * 60.0 + FRAC_PI_2 * t * (r + t / 2.0) * 60.0;
assert!(rel(volume(&mut s), want) < 1e-4, "{} {want}", volume(&mut s));
// The smaller side (x < 40) stands up: outer face at the zone start less R + T, leg 40 − BA/2
// tall above the bend.
let b = bbox(&mut s);
assert!((b["min"][0].as_f64().unwrap_or(0.0) - (40.0 + ba / 2.0 - r - t)).abs() < 1e-6, "{b}");
assert!((b["max"][2].as_f64().unwrap_or(0.0) - (t + r + 40.0 - ba / 2.0)).abs() < 1e-6, "{b}");
assert!((b["max"][0].as_f64().unwrap_or(0.0) - 100.0).abs() < 1e-6, "{b}");
}
#[test]
fn fold_positions_fixed_side_and_flip() {
let (t, r, k) = (2.5, 2.5, 0.44);
let ba = FRAC_PI_2 * (r + k * t);
let case = |p: Value| {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
fold_line(&mut s);
let mut q = json!({"sketch": "FoldLine", "curve": "fl"});
for (k, v) in p.as_object().into_iter().flatten() {
q[k] = v.clone();
}
run(&mut s, "SheetMetalFoldCmd", q);
bbox(&mut s)
};
let x0 = |b: &Value| b["min"][0].as_f64().unwrap_or(f64::NAN);
// Start: the bend starts at the line (on the fixed side), so the folded outer face is R + T
// short of it. End: the bend ends there. Mould: the outer face lands on the line.
assert!((x0(&case(json!({"position": "start"}))) - (40.0 - r - t)).abs() < 1e-6);
assert!((x0(&case(json!({"position": "end"}))) - (40.0 + ba - r - t)).abs() < 1e-6);
assert!((x0(&case(json!({"position": "mould"}))) - 40.0).abs() < 1e-6);
// Keep the small side: the large one (x > 40) stands up instead.
let b = case(json!({"fixed": [10, 30, 2.5]}));
assert!((b["max"][0].as_f64().unwrap_or(0.0) - (40.0 - ba / 2.0 + r + t)).abs() < 1e-6, "{b}");
assert!((b["max"][2].as_f64().unwrap_or(0.0) - (t + r + 60.0 - ba / 2.0)).abs() < 1e-6, "{b}");
// Flipped: folds down, below the sheet.
let b = case(json!({"flip": true}));
assert!((b["min"][2].as_f64().unwrap_or(0.0) + (r + 40.0 - ba / 2.0)).abs() < 1e-6, "{b}");
// A 45° fold: the leg leans.
let b = case(json!({"angle": "45 deg"}));
assert!(b["max"][2].as_f64().unwrap_or(0.0) < t + r + 40.0 - ba / 2.0);
}
#[test]
fn fold_carries_flanges_on_the_moving_side() {
let mut s = Session::default();
base(&mut s, 100.0, 60.0);
// A flange on the left edge, then a fold between it and the rest.
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[0, 30, 2.5]], "height": 20}));
let v0 = volume(&mut s);
fold_line(&mut s);
run(&mut s, "SheetMetalFoldCmd", json!({"sketch": "FoldLine", "curve": "fl", "fixed": [90, 30, 2.5]}));
// The flange rode along: it is above the plate now, nothing left at z < 0.
let b = bbox(&mut s);
assert!(b["min"][2].as_f64().unwrap_or(-1.0) > -1e-6, "{b}");
let ba = FRAC_PI_2 * (2.5 + 0.44 * 2.5);
let want = v0 - ba * 2.5 * 60.0 + FRAC_PI_2 * 2.5 * (2.5 + 1.25) * 60.0;
assert!(rel(volume(&mut s), want) < 1e-4, "{} {want}", volume(&mut s));
assert!(flat(&mut s)["bends"].as_array().map(Vec::len) == Some(2));
// Errors: off the sheet, through the flange's bend.
let mut e = Session::default();
base(&mut e, 100.0, 60.0);
assert!(e.execute("SheetMetalFoldCmd", &json!({"points": [[40, 0, 30], [40, 60, 30]]})).is_err());
assert!(e.execute("SheetMetalFoldCmd", &json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "position": "sideways"})).is_err());
assert!(e.execute("SheetMetalFoldCmd", &json!({"points": [[0.5, 0, 2.5], [0.5, 60, 2.5]]})).is_err(), "no material past the bend");
}