From 5b9749aad6eb9c9959b7ca696658a726945f16c2 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 09:36:23 +0000 Subject: [PATCH] Fold on flange panels (the side toward the flange's own bend stays; flanges and folds on the moving part ride along) and through cut-outs whose edges run along or across the bend; slanted or curved edges across a bend still say not supported (they would need helical edges) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/sheet.rs | 89 ++++++++++++++++++---------- crates/engine/src/cmd/sheet_tests.rs | 65 ++++++++++++++++++++ 2 files changed, 123 insertions(+), 31 deletions(-) diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index 574f9699..074cb3d2 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -909,12 +909,13 @@ impl SheetBody { 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. + /// Fold a panel (the base or a flange's) 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). On the base the side holding `fixed` (default: the larger side) stays put; on a + /// flange the side toward its own bend does. `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) { @@ -923,31 +924,51 @@ impl SheetBody { 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)); + // The panel the line lies on: both ends on its top or bottom face, the middle inside it. 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 mut found = None; + for (pi, r) in self.panels() { + let Some(inv) = mat_inverse(&self.world(pi)) else { continue }; + let (fa, fb) = (apply_point(&inv, a), apply_point(&inv, b)); + if !(on_face(fa) && on_face(fb) && (fa.z - fb.z).abs() < 1e-4) { + continue; + } + let pts = dedup(&r.outer.ccw().polyline(1e-3)); + let (a2, b2) = (Vec2::new(fa.x, fa.y), Vec2::new(fb.x, fb.y)); + if point_in(&pts, (a2 + b2) * 0.5) || polys_cross(&pts, &[a2, b2, (a2 + b2) * 0.5]) { + found = Some((pi, inv, a2, b2, pts)); + break; + } } - let (a2, b2) = (Vec2::new(fa.x, fa.y), Vec2::new(fb.x, fb.y)); + let (panel, inv, a2, b2, pts) = found.ok_or_else(|| DocError::Invalid("the fold line must lie on a flat face of the sheet".into()))?; 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. + // Which side moves. A flange keeps the side its own bend is on. let side_area = |n: Vec2| signed_area(&clip(&pts, n * l0, n)).abs(); - let moves_n = match fixed { - Some(p) => { + let moves_n = match (panel, fixed) { + (Some(j), _) => { + let f = self.flanges.get(j).ok_or_else(|| DocError::Invalid("flange".into()))?; + let ba = self.allowance(f); + // The panel's attaching edge must stay whole on the fixed side. + let (e0, e1) = (f.p + f.n * ba - f.d * f.ext.0, f.p + f.n * ba + f.d * (f.len + f.ext.1)); + let (s0, s1) = (e0.dot(n) - l0, e1.dot(n) - l0); + if s0 * s1 < -1e-12 { + return Err(DocError::Invalid("a fold on a flange must run across it, clear of the flange's own bend".into())); + } + (s0 + s1) < 0.0 + } + (None, 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), + (None, 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 probe = SheetFlange { parent: panel, 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, @@ -962,28 +983,32 @@ impl SheetBody { 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. + // The bend zone must be a rectangle across the panel (a slanted or curved edge would + // run across the bend as a helix, which the sheet's faces can't carry yet). 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(); + let ccw = |mut v: Vec| { + if signed_area(&v) < 0.0 { + v.reverse(); + } + v.into_iter().map(snap).collect::>() + }; + match panel { + None => self.base = Region2 { outer: Loop2::polygon(&fixed_part).ccw(), holes: self.base.holes.clone() }, + Some(j) => { + if let Some(f) = self.flanges.get_mut(j) { + f.shape = Some(ccw(fixed_part.clone())); + } + } } - self.base = Region2 { outer: Loop2::polygon(&fixed_part).ccw(), holes: self.base.holes.clone() }; self.flanges.push(SheetFlange { - parent: None, + parent: panel, p: snap(p0), d, len: u1 - u0, @@ -992,13 +1017,13 @@ impl SheetBody { radius, leg, ext: (0.0, 0.0), - shape: Some(ccw.into_iter().map(snap).collect()), + shape: Some(ccw(moving)), }); 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() { + if f.parent != panel { continue; } let (e0, e1) = ((f.p - p0).dot(n), (f.p + f.d * f.len - p0).dot(n)); @@ -1010,6 +1035,8 @@ impl SheetBody { return Err(DocError::Invalid("not supported yet: a fold through a flange's bend".into())); } } + // Cut-outs may cross the bend where their edges run along or across it (the faces + // check the rest when the sheet is built). Ok(i) } diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index e3c94399..f591f171 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -233,3 +233,68 @@ fn fold_carries_flanges_on_the_moving_side() { 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"); } + +#[test] +fn fold_on_a_flange_panel() { + 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); + run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 0, 2.5]], "height": 30})); + let flat0 = flat(&mut s)["flat_size_mm"].clone(); + let v0 = volume(&mut s); + // The flange stands up at the plate's y = 0 edge: its outer face is the body's min y. + let y = bbox(&mut s)["min"][1].as_f64().unwrap_or(f64::NAN); + run(&mut s, "SheetMetalFoldCmd", json!({"points": [[10, y, 18], [90, y, 18]]})); + let f = flat(&mut s); + for i in 0..2 { + assert!( + (f["flat_size_mm"][i].as_f64().unwrap_or(0.0) - flat0[i].as_f64().unwrap_or(1.0)).abs() < 1e-6, + "the flat pattern keeps its size: {f}" + ); + } + assert_eq!(f["bends"].as_array().map(Vec::len), Some(2)); + let len = f["bends"][1]["length"].as_f64().unwrap_or(0.0); + assert!(len > 90.0, "{f}"); + let want = v0 - ba * t * len + FRAC_PI_2 * t * (r + t / 2.0) * len; + assert!(rel(volume(&mut s), want) < 1e-4, "{} {want}", volume(&mut s)); + // The top part now leans over the plate (folded up, toward its top face). + let b = bbox(&mut s); + assert!(b["max"][2].as_f64().unwrap_or(99.0) < 25.0, "{b}"); + // Folding across the flange's own bend is refused. + assert!(s.execute("SheetMetalFoldCmd", &json!({"points": [[50, y, 2], [50, y, 25]]})).is_err()); +} + +#[test] +fn fold_through_cut_outs() { + let mut s = Session::default(); + base(&mut s, 100.0, 60.0); + // A slot across the fold line (edges along and across the bend). + run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Slot"})); + run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [30, 20], "p1": [50, 40]})); + run(&mut s, "SketchStop", json!({})); + run(&mut s, "Extrude", json!({"sketch": "Slot", "through_all": true, "operation": "cut", "direction": "symmetric"})); + let mut plain = Session::default(); + base(&mut plain, 100.0, 60.0); + fold_line(&mut plain); + run(&mut plain, "SheetMetalFoldCmd", json!({"sketch": "FoldLine", "curve": "fl"})); + fold_line(&mut s); + run(&mut s, "SheetMetalFoldCmd", json!({"sketch": "FoldLine", "curve": "fl"})); + let f = flat(&mut s); + assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1), "{f}"); + let (v, vp) = (volume(&mut s), volume(&mut plain)); + // The slot takes 20 x 20 of the flat sheet; across the bend it removes the bend's share. + assert!(v < vp - 900.0 && v > vp - 1100.0, "{v} {vp}"); + run(&mut s, "FusionSheetmetalUnfoldCommand", json!({})); + assert!((volume(&mut s) - (15000.0 - 1000.0)).abs() < 1e-3, "{}", volume(&mut s)); + // A round hole across the bend is not supported yet, and says so. + let mut c = Session::default(); + base(&mut c, 100.0, 60.0); + run(&mut c, "SketchCreate", json!({"plane": "XY", "name": "Hole"})); + run(&mut c, "CircleCenterRadius", json!({"center": [40, 30], "radius": 5})); + run(&mut c, "SketchStop", json!({})); + run(&mut c, "Extrude", json!({"sketch": "Hole", "through_all": true, "operation": "cut", "direction": "symmetric"})); + fold_line(&mut c); + let e = c.execute("SheetMetalFoldCmd", &json!({"sketch": "FoldLine", "curve": "fl"})).map(|_| ()).unwrap_err().to_string(); + assert!(e.contains("not supported yet"), "{e}"); +}