From 8319c35a321d70cd5fd59e703f705d1538bc7711 Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:33:35 +0200 Subject: [PATCH] fix: a base trimmed for an edge flange keeps the arcs of its outline (#839) A base trimmed for an edge flange keeps the arcs of its outline instead of falling back to a polygon. --- crates/doc/src/sheet.rs | 374 ++++++++++++++++++++++++++- crates/engine/src/cmd/sheet_tests.rs | 53 ++++ docs/book/src/guide/sheet-metal.md | 5 + 3 files changed, 424 insertions(+), 8 deletions(-) diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index 727289cd..185f4e98 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -1626,11 +1626,15 @@ impl SheetBody { }) }; let mut keep = Vec::new(); - for p in off { - if touches(&p) { - keep.extend(kept_past_edge(&p, a, b, cut_at, &mut work)?); + // What goes, as strips to cut out of an outline with arcs or curves. + let mut taken = Vec::new(); + for (k, p) in off.iter().enumerate() { + if touches(p) { + let (kept, between) = kept_past_edge(p, a, b, cut_at, &mut work)?; + keep.extend(kept); + taken.push(taken_strip(&off, k, a, b, by, between)); } else { - keep.push(p); + keep.push(p.clone()); } } if !keep.is_empty() { @@ -1643,7 +1647,15 @@ impl SheetBody { if cut.len() < 3 { return Err(DocError::Invalid("the flange's setback removes the whole panel".into())); } - self.base = Region2 { outer: Loop2::polygon(&cut).ccw(), holes: self.base.holes.clone() }; + // An outline of lines is its own polyline. One with arcs or curves keeps them: the + // strips are cut out of the outline as drawn. + let outer = self.base.outer.ccw(); + let exact = if outer.segs.iter().all(|s| matches!(s, Seg2::Line { .. })) { + None + } else { + taken.into_iter().collect::>>().and_then(|strips| trimmed_exactly(&outer, &strips, &cut)) + }; + self.base = Region2 { outer: exact.unwrap_or_else(|| Loop2::polygon(&cut).ccw()), holes: self.base.holes.clone() }; } Some(i) => { // A flange panel: its far edge shortens the leg; side edges are not trimmed. @@ -3442,8 +3454,10 @@ fn clear_of(a: Vec2, d: Vec2, len: f64, cuts: &[Region2]) -> Vec<(f64, f64)> { /// (beyond an inside corner at an end of the edge), that material and the strip beneath it, up /// to the corner; and the strip beyond an end of a→b where a cut-out is (`cut_at`: start, end), /// a→b being what the cut-out left of a longer edge. Nothing where the base ends at the line. -fn kept_past_edge(piece: &[Vec2], a: Vec2, b: Vec2, cut_at: (bool, bool), work: &mut FlatWork) -> Result>> { - let Some(d) = (b - a).normalized() else { return Ok(Vec::new()) }; +/// With it comes the stretch of the edge's line between what stays, as distances from `a`: +/// infinite toward an end beyond which nothing stays. +fn kept_past_edge(piece: &[Vec2], a: Vec2, b: Vec2, cut_at: (bool, bool), work: &mut FlatWork) -> Result<(Vec>, (f64, f64))> { + let Some(d) = (b - a).normalized() else { return Ok((Vec::new(), (f64::NEG_INFINITY, f64::INFINITY))) }; 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. @@ -3471,7 +3485,74 @@ fn kept_past_edge(piece: &[Vec2], a: Vec2, b: Vec2, cut_at: (bool, bool), work: if hi.is_finite() { kept.push(clip(&strip, a + d * hi, d)); } - Ok(kept) + Ok((kept, (lo, hi))) +} + +/// How far (mm) outside the edge's line the strip a trim takes reaches round an end of the edge +/// beyond which nothing stays, and how far (per mm of outline) what an exact trim leaves may +/// differ from the trimmed polyline: an outline's curves lie up to 0.001 mm off its chords, +/// and the arcs that follow a free-form curve as far again. +const TRIM_PAD: f64 = 2e-3; + +/// The strip that trimming the edge a→b back by `by` takes of piece `k` of `pieces` (the base +/// past the trimmed line, as polygons), as a polygon to cut out of the outline as drawn: from +/// the edge's line to the trimmed line, over `between` (the stretch [`kept_past_edge`] keeps +/// nothing of, as distances from `a`), square at a finite end. Toward an end beyond which +/// nothing stays it runs [`TRIM_PAD`] outside the edge's line and 1 mm past the piece, or +/// half-way to the next piece along the line (tabs side by side), so that it crosses the +/// outline at the trimmed line only. None where another piece lies beside this one across the +/// line. +fn taken_strip(pieces: &[Vec], k: usize, a: Vec2, b: Vec2, by: f64, (lo, hi): (f64, f64)) -> Option> { + let d = (b - a).normalized()?; + let inward = Vec2::new(-d.y, d.x); + let at = |u: f64, w: f64| a + d * u + inward * w; + let span = |p: &Vec| p.iter().map(|q| (*q - a).dot(d)).fold((f64::MAX, f64::MIN), |(u0, u1), u| (u0.min(u), u1.max(u))); + let (p0, p1) = span(pieces.get(k)?); + let (mut u0, mut u1) = (p0 - 1.0, p1 + 1.0); + for (j, q) in pieces.iter().enumerate() { + let (q0, q1) = span(q); + if j == k { + continue; + } else if q1 < p0 - TRIM_PAD { + u0 = u0.max((q1 + p0) / 2.0); + } else if q0 > p1 + TRIM_PAD { + u1 = u1.min((q0 + p1) / 2.0); + } else { + return None; + } + } + if !lo.is_finite() && !hi.is_finite() { + return Some(vec![at(u0, -TRIM_PAD), at(u1, -TRIM_PAD), at(u1, by), at(u0, by)]); + } + // Along the trimmed line, back down the far end, along the edge's line (stepping outside it + // past an end beyond which nothing stays), and up again. + let len = a.dist(b); + let mut strip = vec![at(if lo.is_finite() { lo } else { u0 }, by), at(if hi.is_finite() { hi } else { u1 }, by)]; + if hi.is_finite() { + strip.push(at(hi, 0.0)); + } else { + strip.extend([at(u1, -TRIM_PAD), at(len, -TRIM_PAD), at(len, 0.0)]); + } + if lo.is_finite() { + strip.push(at(lo, 0.0)); + } else { + strip.extend([at(0.0, 0.0), at(0.0, -TRIM_PAD), at(u0, -TRIM_PAD)]); + } + Some(strip) +} + +/// The outline `outer` (counter-clockwise) less the `strips` a trim takes ([`taken_strip`]), cut +/// as drawn: an arc stays an arc, ending where the trimmed line crosses it, and a free-form +/// curve that is cut is followed by tangent arcs. `cut` is what the same trim leaves of the +/// outline's polyline. None unless that leaves one piece holding the polygon's material to +/// within the chords (the trim keeps the polygon then). +fn trimmed_exactly(outer: &Loop2, strips: &[Vec], cut: &[Vec2]) -> Option { + let strips: Vec = strips.iter().map(|s| Region2 { outer: Loop2::polygon(s).ccw(), holes: Vec::new() }).collect(); + let base = Region2 { outer: outer.clone(), holes: Vec::new() }; + let mut left = material_boolean(&[base], &strips, kernel::BoolOp::Cut, &mut FlatWork::new()).ok()?; + let exact = left.pop().filter(|r| left.is_empty() && r.holes.is_empty())?.outer.ccw(); + let outline: f64 = outer.segs.iter().map(Seg2::length).sum(); + ((exact.signed_area() - signed_area(cut).abs()).abs() <= TRIM_PAD * outline).then_some(exact) } /// Do two polygons' edges cross? @@ -5030,6 +5111,283 @@ mod tests { } } + /// A sheet (2 mm, R 1, K 0.5) on an outline of lines, arcs and curves (counter-clockwise). + fn outlined(segs: Vec) -> SheetBody { + base_flange("Body1", "AL2", &alu(), Plane::XY, Region2 { outer: Loop2 { segs }, holes: vec![] }) + } + + /// The same sheet on its outline's 0.001 mm polyline: the base a trim used to leave. + fn chorded(s: &SheetBody) -> SheetBody { + let mut c = s.clone(); + c.base = Region2 { outer: Loop2::polygon(&s.base.outer.polyline(1e-3)), holes: vec![] }; + c + } + + fn straight(x0: f64, y0: f64, x1: f64, y1: f64) -> Seg2 { + Seg2::Line { a: Vec2::new(x0, y0), b: Vec2::new(x1, y1) } + } + + /// A quarter turn of radius 8 about (x, y), starting `from` quarter turns round. + fn round8(x: f64, y: f64, from: f64) -> Seg2 { + let quarter = std::f64::consts::FRAC_PI_2; + Seg2::Arc { center: Vec2::new(x, y), radius: 8.0, start: from * quarter, sweep: quarter } + } + + /// A 200 x 100 plate with its corners rounded 8. + fn rounded_plate() -> SheetBody { + outlined(vec![ + straight(8.0, 0.0, 192.0, 0.0), + round8(192.0, 8.0, -1.0), + straight(200.0, 8.0, 200.0, 92.0), + round8(192.0, 92.0, 0.0), + straight(192.0, 100.0, 8.0, 100.0), + round8(8.0, 92.0, 1.0), + straight(0.0, 92.0, 0.0, 8.0), + round8(8.0, 8.0, 2.0), + ]) + } + + /// An outline's arcs of radius 8, its edges, its length and its shortest edge. + fn arcs_and_edges(l: &Loop2) -> (usize, usize, f64, f64) { + let arcs = l.segs.iter().filter(|s| matches!(s, Seg2::Arc { radius, .. } if (*radius - 8.0).abs() < 1e-9)).count(); + (arcs, l.segs.len(), l.segs.iter().map(Seg2::length).sum(), l.segs.iter().map(Seg2::length).fold(f64::MAX, f64::min)) + } + + /// ∫ √(64 − z²): the area under an arc of radius 8, from its centre's height on. + fn under8(z: f64) -> f64 { + z / 2.0 * (64.0 - z * z).sqrt() + 32.0 * (z / 8.0).asin() + } + + #[test] + fn a_base_trimmed_for_a_flange_keeps_its_arcs() { + // A 200 x 100 plate with its corners rounded 8 and a 90° flange 40 high on its y = 0 edge + // (setback 3 inside, half of it in the middle, none outside; BA = π; panel 37). Bent + // inside or in the middle, the base is cut back through the two lower arcs. It used to + // become the outline's 0.001 mm polyline then: about 150 chords of a quarter millimetre, + // and no arc. + let (ba, panel, quarter) = (std::f64::consts::PI, 37.0, std::f64::consts::FRAC_PI_2); + let whole = 20_000.0 - (4.0 - std::f64::consts::PI) * 64.0; + // The plate below the height y: ∫ 184 + 2·√(64 − (8 − y)²). + let below = |y: f64| 184.0 * y + 2.0 * (under8(8.0) - under8(8.0 - y)); + for (position, line) in [("outside", 0.0f64), ("middle", 3.0 / 2.0), ("inside", 3.0)] { + let mut s = rounded_plate(); + let i = s.add_flange(Vec3::new(100.0, 0.0, 2.0), 40.0, quarter, 1.0, position, false).unwrap(); + // The trimmed line runs from arc to arc, `reach` past the ends of the edge as drawn. + let reach = (64.0 - (8.0 - line) * (8.0 - line)).sqrt(); + let f = s.flanges[i].clone(); + assert!(f.p.dist(Vec2::new(8.0 - reach, line)) < 1e-9 && (f.len - (184.0 + 2.0 * reach)).abs() < 1e-9, "{position}: {f:?}"); + // The eight edges as drawn: the upper arcs whole, the lower ones from the plate's + // sides to the trimmed line, an angle asin((8 − line)/8) each. + let (arcs, edges, length, shortest) = arcs_and_edges(&s.base.outer); + assert!((arcs, edges) == (4, 8) && shortest > 2.0, "{position}: {arcs} arcs of {edges} edges, shortest {shortest:e}"); + let outline = f.len + 2.0 * 84.0 + 184.0 + 8.0 * std::f64::consts::PI + 16.0 * ((8.0 - line) / 8.0).asin(); + assert!((length - outline).abs() < 1e-9, "{position}: {length} vs {outline}"); + assert!((s.base.area() - (whole - below(line))).abs() < 1e-9, "{position}: {} vs {}", s.base.area(), whole - below(line)); + // The flat pattern: the base and f.len × (BA + 37) of flange, its outline the base's + // with the flange's two sides (bend and panel, a line each) in place of nothing. + let regions = s.flat_regions().unwrap(); + let (arcs, edges, length, shortest) = arcs_and_edges(®ions[0].outer); + assert!(regions[0].outer.is_line_arc() && (arcs, edges) == (4, 12) && shortest > 2.0, "{position}: {:?}", regions[0].outer); + assert!((length - (outline + 2.0 * (ba + panel))).abs() < 1e-9, "{position}: {length}"); + let flat = whole - below(line) + f.len * (ba + panel); + assert!((regions[0].area() - flat).abs() < 1e-9, "{position}: {} vs {flat}", regions[0].area()); + holds_the_flat_material(&s, position); + // It is the trim the outline's polyline got, to within its chords: 50 to a quarter + // turn, each 0.001 mm inside the arc, so that the trimmed line ends up to + // 0.001/sin 36° = 0.0017 short of the arc where it crosses it at that angle. + let mut c = chorded(&rounded_plate()); + c.add_flange(Vec3::new(100.0, 0.0, 2.0), 40.0, quarter, 1.0, position, false).unwrap(); + assert!(c.base.outer.segs.len() > 100 && c.base.area() < s.base.area() && s.base.area() - c.base.area() < 0.05, "{position}"); + assert!(c.flanges[0].p.dist(f.p) < 2e-3 && (c.flanges[0].len - f.len).abs() < 4e-3, "{position}: {:?} vs {f:?}", c.flanges[0]); + } + // Flanges inside on two adjacent edges: each trim passes through the arc of the corner + // between them, which goes. The bends meet at the sharp corner that leaves, (197, 3), + // and close there as on a square plate; the other three arcs stay. + let root = 39f64.sqrt(); + let picks = [Vec3::new(100.0, 0.0, 2.0), Vec3::new(200.0, 50.0, 2.0)]; + let mut s = rounded_plate(); + let (made, notes) = s.add_flanges_noted(&picks, 40.0, quarter, 1.0, "inside", false).unwrap(); + assert!(notes.is_empty() && s.corners(&made).len() == 1, "{notes:?}"); + let (arcs, edges, _, shortest) = arcs_and_edges(&s.base.outer); + assert!((arcs, edges) == (3, 7) && shortest > 2.0, "{:?}", s.base.outer); + assert!(corners_of(&s.base).iter().any(|q| q.dist(Vec2::new(197.0, 3.0)) < 1e-9), "{:?}", s.base.outer); + // The plate less the strip under either edge, and the part of the corner both strips + // hold counted once: x 197…200 below y = 3, inside the corner's arc about (192, 8). + let right = 84.0 * 3.0 + 2.0 * (under8(8.0) - under8(5.0)); + let shared = under8(root) - under8(5.0) - 5.0 * (root - 5.0); + let base = whole - below(3.0) - right + shared; + assert!((s.base.area() - base).abs() < 1e-9, "{} vs {base}", s.base.area()); + let (bottom, side) = (&s.flanges[made[0]], &s.flanges[made[1]]); + assert!(bottom.p.dist(Vec2::new(8.0 - root, 3.0)) < 1e-9 && (bottom.len - (189.0 + root)).abs() < 1e-9, "{bottom:?}"); + assert!(side.p.dist(Vec2::new(197.0, 3.0)) < 1e-9 && (side.len - (89.0 + root)).abs() < 1e-9, "{side:?}"); + // The square plate: the same corner, the base 197 × 97, either flange 8 − √39 longer. + let mut square = plate_with_cuts(&[]); + let (corners, _) = square.add_flanges_noted(&picks, 40.0, quarter, 1.0, "inside", false).unwrap(); + let near = |x: (f64, f64), y: (f64, f64)| (x.0 - y.0).abs() < 1e-9 && (x.1 - y.1).abs() < 1e-9; + for (x, y) in made.iter().zip(&corners).map(|(&x, &y)| (&s.flanges[x], &square.flanges[y])) { + assert!(near(x.ext, y.ext) && near(x.slope, y.slope), "{x:?} vs {y:?}"); + } + let want = flat_area(&square) - (197.0 * 97.0 - base) - 2.0 * (8.0 - root) * (ba + panel); + assert!((flat_area(&s) - want).abs() < 1e-6, "{} vs {want}", flat_area(&s)); + holds_the_flat_material(&s, "two edges"); + } + + #[test] + fn a_trim_that_stops_at_a_leg_or_a_cut_out_keeps_the_arc_at_its_other_end() { + let (ba, quarter, root) = (std::f64::consts::PI, std::f64::consts::FRAC_PI_2, 39f64.sqrt()); + // A 200 x 40 bar with a 30 x 20 leg on its upper edge at the left and its upper right + // corner rounded 8, and a flange 15 high inside on the upper edge beside the leg. The + // trim stops at the leg, where the rule's bend relief (2 wide, 1 deep) is cut, and runs + // through the arc at the other end, to x = 192 + √39 on the bend line y = 37. + let hook = || { + outlined(vec![ + straight(0.0, 0.0, 200.0, 0.0), + straight(200.0, 0.0, 200.0, 32.0), + round8(192.0, 32.0, 0.0), + straight(192.0, 40.0, 30.0, 40.0), + straight(30.0, 40.0, 30.0, 60.0), + straight(30.0, 60.0, 0.0, 60.0), + straight(0.0, 60.0, 0.0, 0.0), + ]) + }; + let mut s = hook(); + let (made, notes) = s.add_flanges_noted(&[Vec3::new(110.0, 40.0, 2.0)], 15.0, quarter, 1.0, "inside", false).unwrap(); + assert!(notes.is_empty(), "{notes:?}"); + let f = s.flanges[made[0]].clone(); + assert!(f.p.dist(Vec2::new(192.0 + root, 37.0)) < 1e-9 && (f.len - (162.0 + root)).abs() < 1e-9, "{f:?}"); + // The arc from the bar's side to the bend line, the bend line, the relief's four edges + // in the leg, and the six edges the trim left alone. + let (arcs, edges, _, shortest) = arcs_and_edges(&s.base.outer); + assert!((arcs, edges) == (1, 11) && (shortest - 1.0).abs() < 1e-9, "{:?}", s.base.outer); + for p in [(30.0, 37.0), (30.0, 36.0), (28.0, 36.0), (28.0, 51.0 + ba), (30.0, 51.0 + ba), (30.0, 60.0)] { + assert!(corners_of(&s.base).iter().any(|q| q.dist(Vec2::new(p.0, p.1)) < 1e-9), "{p:?} not in {:?}", s.base.outer); + } + // By hand: the bar and the leg less the rounded corner (64 − 16π); the strip, 162 × 3 + // and what the corner holds above y = 37; and the relief, 2 wide from 1 below the bend + // line to 2 past the flange's flat length: 2 × (1 + BA + 12 + 2), up to y = 51 + BA. + let base = 8600.0 - (64.0 - 16.0 * ba) - (486.0 + under8(8.0) - under8(5.0)) - 2.0 * (ba + 15.0); + assert!((s.base.area() - base).abs() < 1e-9, "{} vs {base}", s.base.area()); + assert!((flat_area(&s) - (base + f.len * (ba + 12.0))).abs() < 1e-6, "{}", flat_area(&s)); + holds_the_flat_material(&s, "hook"); + let mut c = chorded(&hook()); + c.add_flange(Vec3::new(110.0, 40.0, 2.0), 15.0, quarter, 1.0, "inside", false).unwrap(); + assert!(c.base.area() < s.base.area() && s.base.area() - c.base.area() < 0.05, "{} vs {}", s.base.area(), c.base.area()); + assert!(c.flanges[0].p.dist(f.p) < 2e-3 && (c.flanges[0].len - f.len).abs() < 2e-3, "{:?} vs {f:?}", c.flanges[0]); + // The rounded plate with a notch 40 wide and 30 deep in the middle of its y = 0 edge, + // and a flange 40 high inside on what the notch leaves at the right: the trim starts + // square at the notch (which frees the bend's end: no relief) and runs through the lower + // right arc. The base under the notch and on its other side keeps its edge and its arc. + let mut s = rounded_plate(); + s.holes.push(block(80.0, 120.0, -10.0, 30.0)); + let (made, notes) = s.add_flanges_noted(&[Vec3::new(160.0, 0.0, 2.0)], 40.0, quarter, 1.0, "inside", false).unwrap(); + assert!(notes.is_empty(), "{notes:?}"); + let f = s.flanges[made[0]].clone(); + assert!(f.p.dist(Vec2::new(120.0, 3.0)) < 1e-9 && (f.len - (72.0 + root)).abs() < 1e-9, "{f:?}"); + // The edge as far as the notch's end, the step, the bend line, and the seven edges on. + let (arcs, edges, _, shortest) = arcs_and_edges(&s.base.outer); + assert!((arcs, edges) == (4, 10) && (shortest - 3.0).abs() < 1e-9, "{:?}", s.base.outer); + assert!(s.base.contains(Vec2::new(40.0, 1.0)) && s.base.contains(Vec2::new(100.0, 1.0)) && !s.base.contains(Vec2::new(160.0, 1.0))); + let whole = 20_000.0 - (4.0 - std::f64::consts::PI) * 64.0; + let base = whole - (72.0 * 3.0 + under8(8.0) - under8(5.0)); + assert!((s.base.area() - base).abs() < 1e-9, "{} vs {base}", s.base.area()); + assert!((flat_area(&s) - (base - 40.0 * 30.0 + f.len * (ba + 37.0))).abs() < 1e-6, "{}", flat_area(&s)); + holds_the_flat_material(&s, "notch"); + } + + #[test] + fn a_tab_trimmed_for_a_flange_leaves_the_arcs_of_the_tab_beside_it_whole() { + // A 200 x 60 plate with two tabs 40 wide and 25 long on its y = 0 edge (x 30…70 and + // 130…170), their outer corners rounded 8, and a flange 15 high inside on the end of the + // first. The second tab's end lies on the same line: the strip that goes ends in the gap + // between the two, so the second keeps its end and both its arcs whole. + let (ba, quarter, root) = (std::f64::consts::PI, std::f64::consts::FRAC_PI_2, 39f64.sqrt()); + let tab = |x: f64| { + [ + straight(x, 0.0, x, -17.0), + round8(x + 8.0, -17.0, 2.0), + straight(x + 8.0, -25.0, x + 32.0, -25.0), + round8(x + 32.0, -17.0, 3.0), + straight(x + 40.0, -17.0, x + 40.0, 0.0), + ] + }; + let mut segs = vec![straight(0.0, 0.0, 30.0, 0.0)]; + segs.extend(tab(30.0)); + segs.push(straight(70.0, 0.0, 130.0, 0.0)); + segs.extend(tab(130.0)); + segs.extend([ + straight(170.0, 0.0, 200.0, 0.0), + straight(200.0, 0.0, 200.0, 60.0), + straight(200.0, 60.0, 0.0, 60.0), + straight(0.0, 60.0, 0.0, 0.0), + ]); + let mut s = outlined(segs); + // The plate and two tabs, each less its two rounded corners (64 − 16π). + let whole = 12_000.0 + 2.0 * (1000.0 - 2.0 * (64.0 - 16.0 * ba)); + assert!((s.base.area() - whole).abs() < 1e-9, "{}", s.base.area()); + let (made, notes) = s.add_flanges_noted(&[Vec3::new(50.0, -25.0, 2.0)], 15.0, quarter, 1.0, "inside", false).unwrap(); + assert!(notes.is_empty(), "{notes:?}"); + let f = s.flanges[made[0]].clone(); + assert!(f.p.dist(Vec2::new(38.0 - root, -22.0)) < 1e-9 && (f.len - (24.0 + 2.0 * root)).abs() < 1e-9, "{f:?}"); + // The sixteen edges as drawn: the first tab's arcs end on the bend line, the second's + // are the quarter turns they were. + let (arcs, edges, _, shortest) = arcs_and_edges(&s.base.outer); + assert!((arcs, edges) == (4, 16) && shortest > 2.0, "{:?}", s.base.outer); + let quarters = |x0: f64| { + let turn = + |e: &&Seg2| matches!(e, Seg2::Arc { center, sweep, .. } if center.x > x0 && center.x < x0 + 40.0 && (sweep - quarter).abs() < 1e-12); + s.base.outer.segs.iter().filter(turn).count() + }; + assert_eq!((quarters(30.0), quarters(130.0)), (0, 2), "{:?}", s.base.outer); + // The first tab loses 24 × 3 and what its two corners hold below the bend line. + let base = whole - (72.0 + 2.0 * (under8(8.0) - under8(5.0))); + assert!((s.base.area() - base).abs() < 1e-9, "{} vs {base}", s.base.area()); + assert!((flat_area(&s) - (base + f.len * (ba + 12.0))).abs() < 1e-6, "{}", flat_area(&s)); + holds_the_flat_material(&s, "tabs"); + } + + #[test] + fn a_base_trimmed_through_a_free_form_curve_follows_it_with_arcs() { + // A 200 x 60 plate under a parabola 40 high: y = 60 + 160·(x/200)·(1 − x/200), a conic + // of weight 1. A flange 40 high inside on the right edge trims the base to x = 197, + // through the curve's end: 183.564 goes (∫ y from 197 to 200), and the bend runs up the + // line x = 197 to the curve, at y = 62.364. The planar booleans follow a curve they + // cut with tangent arcs within 0.001 mm: over the parabola's 220 mm that is at most + // 0.22 mm² of the base, and 0.001/cos 38° = 0.0013 of the bend where it meets the curve. + // It used to become about 200 chords. + let (ba, quarter) = (std::f64::consts::PI, std::f64::consts::FRAC_PI_2); + let crowned = || { + outlined(vec![ + straight(0.0, 0.0, 200.0, 0.0), + straight(200.0, 0.0, 200.0, 60.0), + Seg2::Conic { a: Vec2::new(200.0, 60.0), apex: Vec2::new(100.0, 140.0), b: Vec2::new(0.0, 60.0), w: 1.0 }, + straight(0.0, 60.0, 0.0, 0.0), + ]) + }; + let whole = 200.0 * 60.0 + 200.0 * 40.0 * 2.0 / 3.0; + assert!((crowned().base.area() - whole).abs() < 1e-9); + // (the pick, the strip that goes, where the bend starts, how long it is) + let cases = [ + ("right", Vec3::new(200.0, 30.0, 2.0), 183.564, Vec2::new(197.0, 0.0), 62.364), + // On the bottom edge the trim leaves the curve alone: 200 × 3 goes. + ("bottom", Vec3::new(100.0, 0.0, 2.0), 600.0, Vec2::new(0.0, 3.0), 200.0), + ]; + for (what, pick, strip, start, len) in cases { + let mut s = crowned(); + let i = s.add_flange(pick, 40.0, quarter, 1.0, "inside", false).unwrap(); + let outer = &s.base.outer; + let arcs = outer.segs.iter().filter(|e| matches!(e, Seg2::Arc { .. })).count(); + assert!(outer.is_line_arc() && arcs > 1 && outer.segs.len() == arcs + 3 && arcs < 64, "{what}: {arcs} arcs of {}", outer.segs.len()); + assert!((s.base.area() - (whole - strip)).abs() < 0.22, "{what}: {} vs {}", s.base.area(), whole - strip); + let f = s.flanges[i].clone(); + assert!(f.p.dist(start) < 1e-9 && (f.len - len).abs() < 2e-3, "{what}: {f:?}"); + assert!((flat_area(&s) - (s.base.area() + f.len * (ba + 37.0))).abs() < 1e-6, "{what}: {}", flat_area(&s)); + holds_the_flat_material(&s, what); + let mut c = chorded(&crowned()); + c.add_flange(pick, 40.0, quarter, 1.0, "inside", false).unwrap(); + assert!(c.base.outer.segs.len() > 150 && (c.base.area() - s.base.area()).abs() < 0.22, "{what}: {}", c.base.outer.segs.len()); + } + } + /// 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 1de97f55..eb46b666 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -1175,6 +1175,59 @@ fn an_edge_flange_on_a_flange_runs_along_what_a_flat_cut_left_of_its_far_edge() assert!(contains(&s, [130.0, 6.0, 42.0]) && !contains(&s, [30.0, 6.0, 42.0])); } +#[test] +fn a_flange_bent_inside_keeps_the_base_sketchs_arcs_in_the_flat_pattern_and_the_dxf() { + // A 200 x 100 plate with its corners rounded 8, drawn as arcs, and a 40 high flange on its + // bottom edge (2 mm, R 1, K 0.5: setback 3 inside, half of it in the middle; BA π, panel + // 37). With the bend inside or in the middle the base is cut back through the two lower + // arcs: the DXF's outline was about 150 LINEs then, every arc as chords a quarter + // millimetre long, where the bend outside gives 8 LINEs and 4 ARCs. + let h = 8.0 * (1.0 - 0.5f64.sqrt()); + // ∫ √(64 − z²): the area under an arc of radius 8, from its centre's height on. + let under = |z: f64| z / 2.0 * (64.0 - z * z).sqrt() + 32.0 * (z / 8.0).asin(); + for (position, setback) in [("outside", 0.0f64), ("middle", 3.0 / 2.0), ("inside", 3.0)] { + let mut s = Session::default(); + run(&mut s, "sheet.manage_rules", json!({"name": "R", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"})); + run(&mut s, "sketch.line", json!({"points": [[8, 0], [192, 0]]})); + run(&mut s, "sketch.arc.three_point", json!({"start": [192, 0], "end": [200, 8], "through": [200.0 - h, h]})); + run(&mut s, "sketch.line", json!({"points": [[200, 8], [200, 92]]})); + run(&mut s, "sketch.arc.three_point", json!({"start": [200, 92], "end": [192, 100], "through": [200.0 - h, 100.0 - h]})); + run(&mut s, "sketch.line", json!({"points": [[192, 100], [8, 100]]})); + run(&mut s, "sketch.arc.three_point", json!({"start": [8, 100], "end": [0, 92], "through": [h, 100.0 - h]})); + run(&mut s, "sketch.line", json!({"points": [[0, 92], [0, 8]]})); + run(&mut s, "sketch.arc.three_point", json!({"start": [0, 8], "end": [8, 0], "through": [h, h]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sheet.flange", json!({"sketch": "S"})); + run(&mut s, "sheet.flange", json!({"edges": [[100, 0, 2]], "height": 40, "angle": 90, "position": position})); + assert!(no_errors_or_warnings(&s), "{position}: {:?}", s.model.results); + let text = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string(); + let shapes = solidcraft_io::read_dxf(&text).unwrap(); + let lines = shapes.iter().filter(|g| matches!(g, solidcraft_io::Geom2::Line(..))).count(); + let radii: Vec = + shapes.iter().filter_map(|g| if let solidcraft_io::Geom2::Arc { c, a, .. } = g { Some(c.dist(*a)) } else { None }).collect(); + // Eight lines of outline and the bend's centre line; the four corners. + assert_eq!((lines, radii.len(), shapes.len()), (9, 4, 13), "{position}"); + assert!(radii.iter().all(|r| (r - 8.0).abs() < 1e-9), "{position}: {radii:?}"); + // The plate less what lies below the bend line, ∫ 184 + 2·√(64 − (8 − y)²) from 0 to the + // setback, and the flange along the trimmed edge, which runs from arc to arc. + let below = 184.0 * setback + 2.0 * (under(8.0) - under(8.0 - setback)); + let edge = 184.0 + 2.0 * (64.0 - (8.0 - setback) * (8.0 - setback)).sqrt(); + let want = 20_000.0 - (4.0 - PI) * 64.0 - below + edge * (PI + 37.0); + let regions = s.model.state().sheets[0].flat_regions().unwrap(); + assert!(regions.len() == 1 && (regions[0].area() - want).abs() < 1e-6, "{position}: {} vs {want}", regions[0].area()); + let f = flat(&mut s); + assert!((f["bends"][0]["length"].as_f64().unwrap_or(f64::NAN) - edge).abs() < 1e-9, "{position}: {}", f["bends"]); + // Folded it holds the same (K 0.5), and so it does unfolded and folded again. + for command in [None, Some("sheet.unfold"), Some("sheet.refold")] { + if let Some(command) = command { + run(&mut s, command, json!({})); + } + assert!(rel(volume(&mut s), want * 2.0) < 1e-3, "{position} {command:?}: {} vs {}", volume(&mut s), want * 2.0); + } + } +} + #[test] fn bend_table_exports_shop_dimensions_and_tracks_parameter_changes() { let mut s = Session::default(); diff --git a/docs/book/src/guide/sheet-metal.md b/docs/book/src/guide/sheet-metal.md index 38f231b3..028bd117 100644 --- a/docs/book/src/guide/sheet-metal.md +++ b/docs/book/src/guide/sheet-metal.md @@ -32,6 +32,11 @@ side of a notch keeps its edge, and a pick there, in the same feature or a later a flange of its own. Where base is still left beside the bend's end at such a cut-out, it gets the bend relief as above. An edge that cut-outs have taken whole is refused. +The setback is cut out of the base's outline as it was drawn: where the bend line crosses an +arc of the outline (a plate with rounded corners, bent inside or in the middle), the arc stays +an arc and ends there, in the flat pattern and the DXF. A free-form curve (an ellipse, a +spline) in an outline that a setback cuts is followed by tangent arcs within 0.001 mm. + 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, which