From 9f8915996d873e1010448d7ab395439a99d5c7ad Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 07:58:38 +0000 Subject: [PATCH] =?UTF-8?q?Sheet=20metal=20Fold=20(SheetMetalFoldCmd):=20f?= =?UTF-8?q?old=20the=20base=20panel=20along=20a=20sketch=20line=20(followe?= =?UTF-8?q?d=20when=20the=20sketch=20changes)=20or=20two=20points=20on=20i?= =?UTF-8?q?ts=20face;=20angle,=20bend=20radius=20and=20K-factor=20from=20t?= =?UTF-8?q?he=20rule;=20the=20bend=20zone=20(BA=20=3D=20=CE=B8(R=20+=20K?= =?UTF-8?q?=C2=B7T))=20comes=20out=20of=20the=20existing=20sheet=20so=20th?= =?UTF-8?q?e=20flat=20pattern=20keeps=20its=20size;=20the=20line=20at=20th?= =?UTF-8?q?e=20bend's=20centre,=20start,=20end=20or=20on=20the=20mould=20l?= =?UTF-8?q?ine;=20the=20side=20holding=20a=20picked=20point=20stays=20(def?= =?UTF-8?q?ault=20the=20larger),=20flip=20folds=20down;=20flanges=20on=20t?= =?UTF-8?q?he=20moving=20part=20ride=20along?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/assembly.rs | 7 ++ crates/doc/src/clipboard.rs | 1 + crates/doc/src/document.rs | 30 +++++++ crates/doc/src/eval.rs | 1 + crates/doc/src/eval_more.rs | 29 +++++++ crates/doc/src/params.rs | 6 ++ crates/doc/src/sheet.rs | 114 +++++++++++++++++++++++++++ crates/engine/src/cmd/sheet.rs | 47 +++++++++++ crates/engine/src/cmd/sheet_tests.rs | 92 +++++++++++++++++++++ 9 files changed, 327 insertions(+) diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index b8b2c1a7..9b8a77ff 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -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); diff --git a/crates/doc/src/clipboard.rs b/crates/doc/src/clipboard.rs index 0326c5b5..023f3f66 100644 --- a/crates/doc/src/clipboard.rs +++ b/crates/doc/src/clipboard.rs @@ -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, .. } => { diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index a7c5aa79..984d299f 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -460,6 +460,28 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "Option::is_none")] body: Option, }, + /// 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + curve: Option, + a: Vec3, + b: Vec3, + angle: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + radius: Option, + /// 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + body: Option, + }, /// 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), _ => {} } } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 2763301d..ff2a1af8 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1521,6 +1521,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, 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 { .. } => { diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 6a551611..3cc62e32 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -368,6 +368,35 @@ pub(super) fn sheet_eval(doc: &Document, vals: &BTreeMap, 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)?, diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index 5f5a8a6e..28670472 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -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 { diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index 544ec519..574f9699 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -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>, } /// 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) -> Result { + 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 = 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, plane: &Plane, r: &Region2) -> Option { 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); diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 8440f799..0a8228ee 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -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 { ) } +fn fold(s: &mut Session, p: &Value) -> Result { + 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::>()).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 { add_feature(s, p, FeatureKind::SheetUnfold { body: str_(p, "body").map(str::to_string), refold: false }) } diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index b29f67d8..e3c94399 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -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"); +}