diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index 86b187f1..eddac976 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -382,6 +382,9 @@ pub struct SheetBody { /// (the rule's `corner_relief_shape` and `corner_relief`). pub relief: ReliefShape, pub relief_size: f64, + /// The rule's bend relief (`relief_width`, `relief_depth`), cut into the base where a + /// flange's bend ends beside more of the base. + pub bend_relief: (f64, 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)>, @@ -536,6 +539,9 @@ 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; +/// The narrowest bend relief that is cut: a narrower one would leave hairline edges. +const MIN_BEND_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)] @@ -1455,21 +1461,29 @@ impl SheetBody { let o = a + inward * by; let mut cut = clip(&pts, o, inward); // Only the material along this edge goes: other parts of the outline past the - // line (tabs side by side on one line) stay. + // line (tabs side by side on one line) stay, and so does the base where it runs + // on past the edge's own line (beyond an inside corner at the edge's end). let mut work = FlatWork::new(); let off = merge_loops(&[clip(&pts, o, inward * -1.0)], &mut work)?; - if off.len() > 1 { - let mid = (a + b) * 0.5; - let touches = |p: &Vec| { - let m = p.len(); - (0..m).filter_map(|i| Some((*p.get(i)?, *p.get((i + 1) % m)?))).any(|(p0, p1)| { - let e = p1 - p0; - let t = if e.len2() > 1e-18 { ((mid - p0).dot(e) / e.len2()).clamp(0.0, 1.0) } else { 0.0 }; - (p0 + e * t).dist(mid) < 1e-6 - }) - }; - let mut keep = vec![cut]; - keep.extend(off.into_iter().filter(|p| !touches(p))); + let mid = (a + b) * 0.5; + let touches = |p: &Vec| { + let m = p.len(); + (0..m).filter_map(|i| Some((*p.get(i)?, *p.get((i + 1) % m)?))).any(|(p0, p1)| { + let e = p1 - p0; + let t = if e.len2() > 1e-18 { ((mid - p0).dot(e) / e.len2()).clamp(0.0, 1.0) } else { 0.0 }; + (p0 + e * t).dist(mid) < 1e-6 + }) + }; + let mut keep = Vec::new(); + for p in off { + if touches(&p) { + keep.extend(kept_past_edge(&p, a, b, &mut work)?); + } else { + keep.push(p); + } + } + if !keep.is_empty() { + keep.insert(0, cut); cut = match merge_loops(&keep, &mut work)?.as_slice() { [one] => one.clone(), _ => return Err(DocError::Invalid("not supported yet: a flange setback that splits the sheet".into())), @@ -1530,7 +1544,9 @@ impl SheetBody { /// leg), so unequal flanges close like equal ones. A flange on another flange's far edge (a /// return) runs along that edge end to end, following its parent's closed end; two of them /// whose parents meet at a corner are closed toward each other as well, and one alone at a - /// corner is kept clear of the flange beside its parent (`close_return_corners`). + /// corner is kept clear of the flange beside its parent (`close_return_corners`). A bend on + /// the base that ends beside more of the base gets the rule's bend relief there + /// (`relieve_bend_ends`). pub fn add_flanges_each(&mut self, edges: &[EdgeFlange], radius: f64, position: &str, flip: bool) -> Result<(Vec, Vec)> { if edges.is_empty() || edges.len() > 1000 { return Err(DocError::Invalid("pick 1…1000 edges".into())); @@ -1629,11 +1645,61 @@ impl SheetBody { }); made.push(self.flanges.len() - 1); } - let mut notes = self.close_corners(&made); + let mut notes = Vec::new(); + for &i in &made { + notes.extend(self.relieve_bend_ends(i)); + } + notes.extend(self.close_corners(&made)); notes.extend(self.close_return_corners(&made)); Ok((made, notes)) } + /// Where the bend of flange `i` on the base ends beside more of the base (the flange's edge + /// ends at an inside corner and the base runs on past it), cut the rule's bend relief into + /// the base there: a slot `relief_width` wide beside the bend's end, from `relief_depth` + /// before the bend line to `relief_width` past the flange's far edge in the flat pattern, + /// so that the bend and the flange come free of the base, folded and flat. Returns a note + /// for every such end left as it is. + fn relieve_bend_ends(&mut self, i: usize) -> Vec { + let Some(f) = self.flanges.get(i).cloned() else { return Vec::new() }; + if f.parent.is_some() || f.shape.is_some() { + return Vec::new(); + } + let (width, depth) = (self.bend_relief.0, self.bend_relief.1.max(0.0)); + let reach = self.allowance(&f) + f.leg; + let (u0, u1) = self.zone_span(&f); + let mut notes = Vec::new(); + for (u, s) in [(u0, -1.0), (u1, 1.0)] { + // `du` past this end of the bend, `dv` past the bend line (into the flange). + let at = |du: f64, dv: f64| f.p + f.d * (u + s * du) + f.n * dv; + if !self.base.contains(at(1e-4, 1e-4)) { + continue; + } + let cut = if !(width >= MIN_BEND_RELIEF) { + Err(format!("the relief width {width:.4} mm is below {MIN_BEND_RELIEF} mm")) + } else if !self.base.contains(at(width + 1e-4, 1e-4)) { + Err(format!("a relief {width:.3} mm wide would take away all of the base beside the bend")) + } else { + let relief = rect(at(0.0, -depth), at(width, -depth), at(width, reach + width), at(0.0, reach + width)); + planar_region(&self.base) + .and_then(|base| material_boolean(&[base], &[relief], kernel::BoolOp::Cut, &mut FlatWork::new())) + .map_err(|e| e.to_string()) + .and_then(|mut left| match (left.pop(), left.is_empty()) { + (Some(base), true) if base.holes.is_empty() => Ok(base), + _ => Err("it would not leave the base in one piece".to_string()), + }) + }; + match cut { + Ok(base) => self.base = base, + Err(why) => { + let w = apply_point(&self.frame_mat(), v3(at(0.0, 0.0))); + notes.push(format!("no bend relief at ({:.3}, {:.3}, {:.3}): {why}", w.x, w.y, w.z)); + } + } + } + notes + } + /// Flanges on the base that meet at a convex corner of it, at least one of them among `among`: /// (a, a_end, b, b_end, the corner). Their bend lines cross at the corner and both bends end /// there, or both the same distance short of it: where a corner relief was cut, or where the @@ -2946,6 +3012,42 @@ fn clear_of(a: Vec2, d: Vec2, len: f64, cuts: &[Region2]) -> Vec<(f64, f64)> { out } +/// What stays of `piece` (counter-clockwise), the part of a base that trimming its edge a→b back +/// along the edge's whole line would take: where the base runs on past the edge's own line +/// (beyond an inside corner at an end of the edge), that material and the strip beneath it, up +/// to the corner. Nothing where the base ends at the line. +fn kept_past_edge(piece: &[Vec2], a: Vec2, b: Vec2, work: &mut FlatWork) -> Result>> { + let Some(d) = (b - a).normalized() else { return Ok(Vec::new()) }; + let outward = Vec2::new(d.y, -d.x); + let mut kept = merge_loops(&[clip(piece, a, outward)], work)?; + // Where that material stands on the edge's line, nearest the edge's middle on either side. + let um = a.dist(b) / 2.0; + let (mut lo, mut hi) = (f64::NEG_INFINITY, f64::INFINITY); + for q in &kept { + let m = q.len(); + for i in 0..m { + let (Some(&p0), Some(&p1)) = (q.get(i), q.get((i + 1) % m)) else { continue }; + if (p0 - a).dot(outward).abs() > 1e-6 || (p1 - a).dot(outward).abs() > 1e-6 { + continue; + } + let (u0, u1) = ((p0 - a).dot(d), (p1 - a).dot(d)); + if u0.max(u1) <= um { + lo = lo.max(u0.max(u1)); + } else if u0.min(u1) >= um { + hi = hi.min(u0.min(u1)); + } + } + } + let strip = clip(piece, a, outward * -1.0); + if lo.is_finite() { + kept.push(clip(&strip, a + d * lo, d * -1.0)); + } + if hi.is_finite() { + kept.push(clip(&strip, a + d * hi, d)); + } + Ok(kept) +} + /// Do two polygons' edges cross? fn polys_cross(a: &[Vec2], b: &[Vec2]) -> bool { let seg = |p: &[Vec2], i: usize| -> Option<(Vec2, Vec2)> { Some((*p.get(i)?, *p.get((i + 1) % p.len())?)) }; @@ -2983,6 +3085,7 @@ pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region gap: rv.gap, relief: rv.corner_relief_shape, relief_size: rv.corner_relief, + bend_relief: (rv.relief_width, rv.relief_depth), base_curves: Vec::new(), } } @@ -3037,6 +3140,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt gap: rv.gap, relief: rv.corner_relief_shape, relief_size: rv.corner_relief, + bend_relief: (rv.relief_width, rv.relief_depth), base_curves: Vec::new(), }; let (y0, y1) = if ysign > 0.0 { (0.0, width) } else { (-width, 0.0) }; @@ -4109,6 +4213,154 @@ mod tests { } } + /// A 160 x 30 bar with a leg 20 wide standing `leg` past its top edge at the left end: the + /// top edge (x 20…160) ends at an inside corner of the base. + fn bar_with_leg(rv: &RuleValues, leg: f64) -> SheetBody { + let pts = [(0.0, 0.0), (160.0, 0.0), (160.0, 30.0), (20.0, 30.0), (20.0, 30.0 + leg), (0.0, 30.0 + leg)].map(|(x, y)| Vec2::new(x, y)); + base_flange("Body1", "AL2", rv, Plane::XY, Region2 { outer: Loop2::polygon(&pts).ccw(), holes: vec![] }) + } + + /// The base's outline has these corners, and their polygon's area. + fn base_is(s: &SheetBody, want: &[(f64, f64)], what: &str) { + let want: Vec = want.iter().map(|&(x, y)| Vec2::new(x, y)).collect(); + let got = corners_of(&s.base); + for p in &want { + assert!(got.iter().any(|q| q.dist(*p) < 1e-9), "{what}: {p:?} not in {got:?}"); + } + let area = signed_area(&want).abs(); + assert!((s.base.area() - area).abs() < 1e-9, "{what}: {} vs {area}", s.base.area()); + } + + #[test] + fn a_flange_beside_a_leg_of_the_base_trims_its_own_edge_and_gets_a_bend_relief() { + // A 90° flange 15 high on the bar's top edge: setback s0 = (R + T)·tan 45° = 3 inside, + // half of it in the middle, none outside; bend allowance BA = (π/2)·(R + K·T) = π; panel + // 15 − s0 = 12. Only the strip under that edge goes, so the bend runs from x = 160 to the + // leg at x = 20 and the leg stays. Beside the bend's end the rule's relief (2 wide, 1 deep) + // is cut into the leg: x 18…20, from 1 before the bend line to 2 past the flange's far + // edge in the flat (BA + 12 past the line), or to the leg's end where that comes first. + let rv = alu(); + let (ba, panel) = (std::f64::consts::PI, 12.0); + for leg in [30.0, 10.0] { + for (position, line) in [("inside", 27.0), ("middle", 28.5), ("outside", 30.0)] { + let what = format!("{position}, leg {leg}"); + let mut s = bar_with_leg(&rv, leg); + let (made, notes) = + s.add_flanges_noted(&[Vec3::new(90.0, 30.0, 2.0)], 15.0, std::f64::consts::FRAC_PI_2, 1.0, position, false).unwrap(); + assert!(notes.is_empty(), "{what}: {notes:?}"); + let (top, end) = (30.0 + leg, line + ba + panel + 2.0); + let mut want = vec![(0.0, 0.0), (160.0, 0.0), (160.0, line), (20.0, line), (20.0, line - 1.0), (18.0, line - 1.0)]; + if end < top { + want.extend([(18.0, end), (20.0, end), (20.0, top), (0.0, top)]); + } else { + want.extend([(18.0, top), (0.0, top)]); + } + base_is(&s, &want, &what); + let f = &s.flanges[made[0]]; + assert!(f.p.dist(Vec2::new(160.0, line)) < 1e-9 && (f.len - 140.0).abs() < 1e-9, "{what}: {f:?}"); + assert!((s.bends()[0].length - 140.0).abs() < 1e-9, "{what}: {:?}", s.bends()); + // The flat pattern is the base and 140 × (BA + 12) of flange in one piece; folded, + // the part is one valid shell holding the same material. + let base = 160.0 * line + 20.0 * (top - line) - 2.0 * (end.min(top) - (line - 1.0)); + assert!((flat_area(&s) - (base + 140.0 * (ba + panel))).abs() < 1e-6, "{what}: {}", flat_area(&s)); + holds_the_flat_material(&s, &what); + } + } + } + + #[test] + fn flanges_on_both_sides_of_a_leg_each_trim_their_own_edge() { + // A 160 x 30 bar with a 20 x 10 leg in the middle of its top edge, and flanges on the two + // parts of that edge (x 0…70 and 90…160) in one call: each takes the strip under its own + // part, and the leg gets a relief on either side (x 70…72 and 88…90) up to its end. + let pts = [(0.0, 0.0), (160.0, 0.0), (160.0, 30.0), (90.0, 30.0), (90.0, 40.0), (70.0, 40.0), (70.0, 30.0), (0.0, 30.0)]; + let outer = Loop2::polygon(&pts.map(|(x, y)| Vec2::new(x, y))).ccw(); + let mut s = base_flange("Body1", "AL2", &alu(), Plane::XY, Region2 { outer, holes: vec![] }); + let picks = [Vec3::new(125.0, 30.0, 2.0), Vec3::new(35.0, 30.0, 2.0)]; + let (made, notes) = s.add_flanges_noted(&picks, 15.0, std::f64::consts::FRAC_PI_2, 1.0, "inside", false).unwrap(); + assert!(notes.is_empty() && s.corners(&made).is_empty(), "{notes:?}"); + let right = [(0.0, 0.0), (160.0, 0.0), (160.0, 27.0), (90.0, 27.0), (90.0, 26.0), (88.0, 26.0), (88.0, 40.0)]; + let left = [(72.0, 40.0), (72.0, 26.0), (70.0, 26.0), (70.0, 27.0), (0.0, 27.0)]; + base_is(&s, &[&right[..], &left[..]].concat(), "tee"); + assert!(s.flanges.iter().all(|f| (f.len - 70.0).abs() < 1e-9), "{:?}", s.flanges); + holds_the_flat_material(&s, "tee"); + } + + #[test] + fn a_flange_on_the_side_of_a_leg_leaves_the_base_the_leg_stands_on() { + // The leg's right side (x = 20, y 30…60): past that edge's line the whole bar runs on. The + // setback takes x 17…20 of the leg alone, and the relief goes into the bar beside the + // bend's end at y = 30: y 28…30, from x = 16 (1 before the bend line) to 2 past the + // flange's far edge in the flat at x = 17 + BA + 12. + let mut s = bar_with_leg(&alu(), 30.0); + let (made, notes) = s.add_flanges_noted(&[Vec3::new(20.0, 45.0, 2.0)], 15.0, std::f64::consts::FRAC_PI_2, 1.0, "inside", false).unwrap(); + assert!(notes.is_empty(), "{notes:?}"); + let end = 17.0 + std::f64::consts::PI + 12.0 + 2.0; + let want = + [(0.0, 0.0), (160.0, 0.0), (160.0, 30.0), (end, 30.0), (end, 28.0), (16.0, 28.0), (16.0, 30.0), (17.0, 30.0), (17.0, 60.0), (0.0, 60.0)]; + base_is(&s, &want, "side"); + let f = &s.flanges[made[0]]; + assert!(f.p.dist(Vec2::new(17.0, 30.0)) < 1e-9 && (f.len - 30.0).abs() < 1e-9, "{f:?}"); + holds_the_flat_material(&s, "side"); + } + + #[test] + fn a_bend_relief_at_one_end_leaves_the_corner_at_the_other_as_it_was() { + // Flanges on the bar's top and right edges in one call: their bends meet at (157, 27), + // where the corner is closed and relieved by the rule's shape, and the top one ends + // beside the leg at (20, 27), where it gets the bend relief. Against the bar without + // the leg, the same corner and all, the flat pattern gains the leg from the bend line + // up, 20 × (60 − 27), less the relief, 2 × (1 + BA + 12 + 2), and loses 20 of the top + // flange's length, 20 × (BA + 12). + let (ba, panel) = (std::f64::consts::PI, 12.0); + let picks = [Vec3::new(90.0, 30.0, 2.0), Vec3::new(160.0, 15.0, 2.0)]; + for shape in [ReliefShape::Notch, ReliefShape::Round, ReliefShape::Rectangular] { + let what = format!("{shape:?}"); + let rv = RuleValues { corner_relief_shape: shape, ..alu() }; + let bar = rect(Vec2::new(0.0, 0.0), Vec2::new(160.0, 0.0), Vec2::new(160.0, 30.0), Vec2::new(0.0, 30.0)); + let mut plain = base_flange("Body1", "AL2", &rv, Plane::XY, bar); + let (_, notes) = plain.add_flanges_noted(&picks, 15.0, std::f64::consts::FRAC_PI_2, 1.0, "inside", false).unwrap(); + assert!(notes.is_empty(), "{what}, no leg: {notes:?}"); + let mut s = bar_with_leg(&rv, 30.0); + let (made, notes) = s.add_flanges_noted(&picks, 15.0, std::f64::consts::FRAC_PI_2, 1.0, "inside", false).unwrap(); + assert!(notes.is_empty() && made.len() == 2, "{what}: {notes:?}"); + // The corner's relief reaches 4 along both bends (8 across); the notch none. + let cut = if shape == ReliefShape::Notch { 0.0 } else { 4.0 }; + let lengths: Vec = s.bends().iter().map(|b| b.length).collect(); + assert!((lengths[0] - (137.0 - cut)).abs() < 1e-9 && (lengths[1] - (27.0 - cut)).abs() < 1e-9, "{what}: {lengths:?}"); + let got = corners_of(&s.base); + let end = 27.0 + ba + panel + 2.0; + for p in [(20.0, 27.0), (20.0, 26.0), (18.0, 26.0), (18.0, end), (20.0, end), (20.0, 60.0), (0.0, 60.0)] { + assert!(got.iter().any(|q| q.dist(Vec2::new(p.0, p.1)) < 1e-9), "{what}: {p:?} not in {got:?}"); + } + let want = flat_area(&plain) + 20.0 * 33.0 - 2.0 * (ba + panel + 3.0) - 20.0 * (ba + panel); + assert!((flat_area(&s) - want).abs() < 1e-6, "{what}: {} vs {want}", flat_area(&s)); + holds_the_flat_material(&s, &what); + } + } + + #[test] + fn a_bend_relief_that_cannot_be_cut_is_left_out_with_a_note() { + let leg = [(20.0, 60.0), (0.0, 60.0)]; + // The same leg held by a neck 2 wide: a notch from the left (x 0…18, y 43…45) under its end. + let neck = [(20.0, 60.0), (0.0, 60.0), (0.0, 45.0), (18.0, 45.0), (18.0, 43.0), (0.0, 43.0)]; + // No width to cut; a relief wider than the leg, which would take the leg away; and a + // relief through the neck, which would cut the leg's end off. + for (width, top, why) in [(0.0, &leg[..], "relief width"), (25.0, &leg[..], "beside the bend"), (2.0, &neck[..], "one piece")] { + let rv = RuleValues { relief_width: width, ..alu() }; + let outline = |line: f64| [&[(0.0, 0.0), (160.0, 0.0), (160.0, line), (20.0, line)][..], top].concat(); + let pts: Vec = outline(30.0).iter().map(|&(x, y)| Vec2::new(x, y)).collect(); + let mut s = base_flange("Body1", "AL2", &rv, Plane::XY, Region2 { outer: Loop2::polygon(&pts).ccw(), holes: vec![] }); + let (_, notes) = s.add_flanges_noted(&[Vec3::new(90.0, 30.0, 2.0)], 15.0, std::f64::consts::FRAC_PI_2, 1.0, "inside", false).unwrap(); + assert!( + notes.len() == 1 && notes[0].starts_with("no bend relief at (20.000, 27.000, 0.000)") && notes[0].contains(why), + "{why}: {notes:?}" + ); + // The flange still took its own edge only. + base_is(&s, &outline(27.0), why); + } + } + /// Edge flanges on `base`, each with its own height and angle, added in one call. Returns the /// sheet and the notes on corners left open. fn flanges_each(base: Region2, rv: &RuleValues, each: &[EdgeFlange]) -> (SheetBody, Vec) { diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index fc4e9177..629a937a 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -1940,6 +1940,72 @@ fn edge_flanges_on_two_collinear_tab_ends_in_one_feature() { assert!(rel(volume(&mut s), volume(&mut separate)) < 1e-6); } +/// A 160 x 30 bar with a 20 x 30 leg standing past its top edge at the left end (2 mm, R 1, +/// K 0.5, so folding keeps the volume): a base flange, or a plate converted to sheet metal. +fn bar_with_leg(s: &mut Session, converted: bool) { + run(s, "sheet.manage_rules", json!({"name": "AL2", "thickness": "2 mm", "k_factor": 0.5, "bend_radius": "1 mm", "active": true})); + run(s, "sketch.create", json!({"plane": "XY", "name": "Bar"})); + run(s, "sketch.line", json!({"points": [[0, 0], [160, 0], [160, 30], [20, 30], [20, 60], [0, 60]], "closed": true})); + run(s, "sketch.finish", json!({})); + if converted { + run(s, "solid.extrude", json!({"sketch": "Bar", "distance": 2, "body_name": "Plate"})); + run(s, "sheet.convert", json!({"body": "Plate", "face": [80, 15, 2]})); + } else { + run(s, "sheet.flange", json!({"sketch": "Bar"})); + } +} + +/// An edge flange takes its setback off its own edge only: the leg standing past the edge's end +/// stays, and the rule's bend relief frees the bend and the flange from it. +#[test] +fn an_edge_flange_beside_a_leg_of_the_base_trims_its_own_edge_and_relieves_its_bend() { + // Bend allowance (π/2)·(R + K·T) = π; the rule's relief is Thickness wide, half of it deep. + let (ba, width, depth) = (PI, 2.0, 1.0); + for (converted, position, setback) in [(false, "inside", 3.0), (false, "outside", 0.0), (true, "inside", 3.0)] { + let what = format!("converted {converted}, {position}"); + let mut s = Session::default(); + bar_with_leg(&mut s, converted); + // Where the sheet's bottom face lies. + let z0 = bbox(&mut s)["min"][2].as_f64().unwrap_or(f64::NAN); + // A 90° flange 15 high on the bar's top edge (x 20…160); the leg stands past its left end. + run(&mut s, "sheet.flange", json!({"edges": [[90, 30, z0 + 2.0]], "height": 15, "angle": 90, "position": position})); + assert!(no_errors_or_warnings(&s), "{what}: {:?}", s.model.results); + // By hand: the base loses the setback under that edge alone, and the relief beside the + // bend's end at x = 20, from `depth` before the bend line to `width` past the flange's + // far edge in the flat (BA and the 12 mm panel past the line). + let (line, panel) = (30.0 - setback, 12.0); + let relief = width * (depth + ba + panel + width); + let base = 160.0 * 30.0 + 20.0 * 30.0 - 140.0 * setback - relief; + let want = 2.0 * (base + 140.0 * (ba + panel)); + assert!(rel(volume(&mut s), want) < 1e-5, "{what}: {} vs {want}", volume(&mut s)); + assert!(contains(&s, [10.0, 50.0, z0 + 1.0]), "{what}: the leg is kept"); + assert!(!contains(&s, [19.0, line + 0.5, z0 + 1.0]), "{what}: the relief is cut"); + assert!(contains(&s, [17.0, line + 0.5, z0 + 1.0]), "{what}: the leg beside the relief stays"); + assert!(contains(&s, [19.0, 50.0, z0 + 1.0]), "{what}: the leg past the relief's end stays"); + // The flange's mid plane is R + T/2 = 2 past the bend line; it ends at the leg. + assert!(contains(&s, [90.0, line + 2.0, z0 + 10.0]), "{what}: the flange stands"); + assert!(!contains(&s, [10.0, line + 2.0, z0 + 10.0]), "{what}: the flange ends at the leg"); + let f = flat(&mut s); + assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{what}: {f}"); + assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(0), "{what}: {f}"); + assert!((f["bends"][0]["length"].as_f64().unwrap_or(f64::NAN) - 140.0).abs() < 1e-9, "{what}: {f}"); + run(&mut s, "sheet.unfold", json!({})); + assert!(rel(volume(&mut s), want) < 1e-9, "{what}: flat {} vs {want}", volume(&mut s)); + } +} + +/// A bend relief the rule makes too narrow to cut is left out, and the flange says where. +#[test] +fn a_bend_relief_left_out_is_reported_on_the_flange() { + let mut s = Session::default(); + bar_with_leg(&mut s, false); + run(&mut s, "sheet.manage_rules", json!({"name": "AL2", "relief_width": "0 mm"})); + run(&mut s, "sheet.flange", json!({"edges": [[90, 30, 2]], "height": 15, "angle": 90})); + let w = s.model.results.last().and_then(|r| r.warning.clone()).unwrap_or_default(); + assert!(w.contains("no bend relief at (20.000, 27.000, 0.000)") && w.contains("relief width"), "{w}"); + assert!(contains(&s, [10.0, 50.0, 1.0]), "the leg is kept"); +} + /// `fixed` inside a cut-out touches no material: the fold must refuse it or say which side it kept (#263). #[test] fn fold_fixed_point_off_the_sheet_is_not_silently_ignored() { diff --git a/docs/book/src/guide/sheet-metal.md b/docs/book/src/guide/sheet-metal.md index 54121504..a0da2967 100644 --- a/docs/book/src/guide/sheet-metal.md +++ b/docs/book/src/guide/sheet-metal.md @@ -14,6 +14,16 @@ 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. +An edge flange takes its setback off its own edge only. Where that edge ends at an inside +corner of the base (the base runs on past it, like the leg of an L), the rest of the base +stays, and the rule's **bend relief** (`relief_width` and `relief_depth`; Relief Width and +Relief Depth in Sheet Metal Rules) is cut into it beside the bend's end: a slot the relief +width wide, from the relief depth before the bend line to the relief width past the flange's +far edge in the flat pattern. The bend and the flange are then free of the base, folded and +flat. A relief that can't be cut (a width under 0.01 mm, one as wide as the base beside the +bend, or a slot that would cut the base apart) is left out, and the flange feature reports a +warning saying where. + 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`