diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index ca4167ad..dfb118a5 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -584,6 +584,11 @@ fn pieces_overlap(a: &[Vec2], b: &[Vec2]) -> bool { convex_overlap(a, b) > FLAT_SLIVER * perimeter(a).max(perimeter(b)).max(1.0) } +/// How far inside a panel (mm) its edge is tested for what cut-outs have left of it: a cut-out +/// whose own edge lies along the panel's takes the edge there, and one that only touches it from +/// outside does not. +const EDGE_INSET: f64 = 1e-6; + /// Whether `p` lies in the triangle (a, b, c), its edges included (within 1e-6 mm). fn in_triangle(p: Vec2, a: Vec2, b: Vec2, c: Vec2) -> bool { let side = |u: Vec2, v: Vec2| { @@ -1447,8 +1452,28 @@ impl SheetBody { best } - /// Trim a panel's edge a→b back by `by` (toward the panel interior). - fn trim(&mut self, panel: Option, a: Vec2, b: Vec2, by: f64) -> Result<()> { + /// What `cuts` (the sheet's cut-outs) leave of a panel's edge a→b: the stretch nearest `pick` + /// (a world point), as distances from `a` along the edge; of two as near, the first. `None` + /// where they have taken the edge whole. + fn edge_left(&self, panel: Option, a: Vec2, b: Vec2, pick: Vec3, cuts: &[Region2]) -> Option<(f64, f64)> { + let d = (b - a).normalized()?; + if cuts.is_empty() { + return Some((0.0, a.dist(b))); + } + let left = clear_of(a + Vec2::new(-d.y, d.x) * EDGE_INSET, d, a.dist(b), cuts); + let at = mat_inverse(&self.world(panel)).map_or(0.0, |inv| { + let q = apply_point(&inv, pick); + (Vec2::new(q.x, q.y) - a).dot(d) + }); + let away = |s: &(f64, f64)| (s.0 - at).max(at - s.1).max(0.0); + left.into_iter().min_by(|x, y| away(x).total_cmp(&away(y))) + } + + /// Trim a panel's edge a→b back by `by` (toward the panel interior). Where a→b is what a + /// cut-out left of a longer edge, `cut_at` says at which of its ends (start, end) the + /// cut-out is: the base keeps its edge beyond such an end. A flange panel's far edge moves + /// whole. + fn trim(&mut self, panel: Option, a: Vec2, b: Vec2, by: f64, cut_at: (bool, bool)) -> Result<()> { if by.abs() < 1e-12 { return Ok(()); } @@ -1477,7 +1502,7 @@ impl SheetBody { let mut keep = Vec::new(); for p in off { if touches(&p) { - keep.extend(kept_past_edge(&p, a, b, &mut work)?); + keep.extend(kept_past_edge(&p, a, b, cut_at, &mut work)?); } else { keep.push(p); } @@ -1546,7 +1571,9 @@ impl SheetBody { /// 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`). A bend on /// the base that ends beside more of the base gets the rule's bend relief there - /// (`relieve_bend_ends`). + /// (`relieve_bend_ends`). Where cut-outs have taken part of a picked edge away, the flange + /// runs along the stretch they leave nearest the pick and takes its setback off that stretch + /// only; an edge they took whole is refused. 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())); @@ -1572,21 +1599,30 @@ impl SheetBody { } sizes.push((setback, leg)); } - // Find every edge before anything moves. + // Find every edge before anything moves: of an edge that cut-outs have taken part of, the + // stretch they leave nearest the pick, and at which of its ends a cut-out is. + let cuts = self.cut_outs(); let mut found = Vec::new(); - for (e, &(setback, leg)) in edges.iter().zip(&sizes) { + for (k, (e, &(setback, leg))) in edges.iter().zip(&sizes).enumerate() { let (panel, a, b, top, dist) = self.edge_at(e.pick).ok_or_else(|| DocError::Invalid("no sheet edge there".into()))?; if dist > self.t * 2.0 + 1e-3 { return Err(DocError::Invalid("pick an edge of the sheet's top or bottom face".into())); } let d = (b - a).normalized().ok_or_else(|| DocError::Invalid("edge".into()))?; - found.push((panel, a, b, d, top, setback, leg, e.angle)); + let (s0, s1) = self + .edge_left(panel, a, b, e.pick, &cuts) + .ok_or_else(|| DocError::Invalid(format!("{}a cut-out has taken that edge away; pick an edge the sheet still has", on(k))))?; + let cut = (s0 > 1e-9, s1 < a.dist(b) - 1e-9); + let (a, b) = (if cut.0 { a + d * s0 } else { a }, if cut.1 { a + d * s1 } else { b }); + found.push((panel, a, b, d, top, setback, leg, e.angle, cut)); } - for (panel, a, b, _, _, setback, _, _) in &found { - self.trim(*panel, *a, *b, *setback)?; + for (panel, a, b, _, _, setback, _, _, cut) in &found { + self.trim(*panel, *a, *b, *setback, *cut)?; } let mut made = Vec::new(); - for (panel, a, b, d, top, setback, leg, angle) in found { + // Whether a cut-out has freed each new bend's ends (start, end) of the base beside them. + let mut freed = Vec::new(); + for (panel, a, b, d, top, setback, leg, angle, cut) in found { let outward = Vec2::new(d.y, -d.x); let line_o = a - outward * setback; // The trimmed edge: along d, on the moved line. @@ -1628,6 +1664,21 @@ impl SheetBody { } } }; + // Where a cut-out ends the edge, the flange ends with it. + let (start, len) = if cut.0 || cut.1 { + let u = (start - a).dot(d); + let (u0, u1) = (if cut.0 { u.max(0.0) } else { u }, if cut.1 { (u + len).min(a.dist(b)) } else { u + len }); + if !(u1 - u0 > 1e-9) { + return Err(DocError::Invalid("the flange's edge was trimmed away".into())); + } + (start + d * (u0 - u), u1 - u0) + } else { + (start, len) + }; + // Beside such an end the base keeps the strip between its edge and the bend line: the + // bend is free of it where the cut-outs took all of that strip there. + let taken = |from: Vec2| clear_of(from, outward * -1.0, setback, &cuts).is_empty(); + freed.push((cut.0 && taken(a - d * 1e-4), cut.1 && taken(b + d * 1e-4))); let up = top != flip; self.flanges.push(SheetFlange { parent: panel, @@ -1646,8 +1697,8 @@ impl SheetBody { made.push(self.flanges.len() - 1); } let mut notes = Vec::new(); - for &i in &made { - notes.extend(self.relieve_bend_ends(i)); + for (&i, &free) in made.iter().zip(&freed) { + notes.extend(self.relieve_bend_ends(i, free)); } notes.extend(self.close_corners(&made)); notes.extend(self.close_return_corners(&made)); @@ -1658,9 +1709,10 @@ impl SheetBody { /// 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 { + /// so that the bend and the flange come free of the base, folded and flat. An end that a + /// cut-out has freed already (`free`: start, end) gets none. Returns a note for every other + /// such end left as it is. + fn relieve_bend_ends(&mut self, i: usize, free: (bool, bool)) -> 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(); @@ -1669,10 +1721,10 @@ impl SheetBody { 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)] { + for (u, s, free) in [(u0, -1.0, free.0), (u1, 1.0, free.1)] { // `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)) { + if free || !self.base.contains(at(1e-4, 1e-4)) { continue; } let cut = if !(width >= MIN_BEND_RELIEF) { @@ -2660,13 +2712,16 @@ impl SheetBody { self.edge_name_of(panel, a, b, top) } - /// A world point on the sheet edge with this name (the one nearest `near` if several). + /// A world point on the sheet edge with this name (the one nearest `near` if several): the + /// point of that edge nearest `near`, off its very ends, so that what is made on the edge + /// knows where along it the pick was. pub fn edge_by_name(&self, name: &str, near: Vec3) -> Option { let mut best: Option<(f64, Vec3)> = None; for (pi, r) in self.panels() { let pts = r.outer.ccw().polyline(1e-3); let m = pts.len(); let w = self.world(pi); + let local = mat_inverse(&w).map(|inv| apply_point(&inv, near)); for i in 0..m { let (Some(&a), Some(&b)) = (pts.get(i), pts.get((i + 1) % m)) else { continue }; if a.dist(b) < 1e-9 { @@ -2677,7 +2732,9 @@ impl SheetBody { let mid = (a + b) * 0.5; let q = apply_point(&w, Vec3::new(mid.x, mid.y, z)); if best.is_none_or(|(d, _)| q.dist(near) < d) { - best = Some((q.dist(near), q)); + let along = local.map(|p| (Vec2::new(p.x, p.y) - a).dot(b - a) / (b - a).len2()).filter(|t| t.is_finite()); + let on = a + (b - a) * along.map_or(0.5, |t| t.clamp(1e-6, 1.0 - 1e-6)); + best = Some((q.dist(near), apply_point(&w, Vec3::new(on.x, on.y, z)))); } } } @@ -3178,14 +3235,15 @@ fn clear_of(a: Vec2, d: Vec2, len: f64, cuts: &[Region2]) -> Vec<(f64, f64)> { /// 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>> { +/// 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()) }; 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); + let (mut lo, mut hi) = (if cut_at.0 { 0.0 } else { f64::NEG_INFINITY }, if cut_at.1 { a.dist(b) } else { f64::INFINITY }); for q in &kept { let m = q.len(); for i in 0..m { @@ -4524,6 +4582,151 @@ mod tests { } } + /// A 200 x 100 plate (2 mm, R 1, K 0.5) with these cut-outs through it. + fn plate_with_cuts(cuts: &[Region2]) -> SheetBody { + let outline = rect(Vec2::new(0.0, 0.0), Vec2::new(200.0, 0.0), Vec2::new(200.0, 100.0), Vec2::new(0.0, 100.0)); + let mut s = base_flange("Body1", "AL2", &alu(), Plane::XY, outline); + s.holes.extend(cuts.iter().cloned()); + s + } + + fn block(x0: f64, x1: f64, y0: f64, y1: f64) -> Region2 { + rect(Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)) + } + + #[test] + fn an_edge_flange_runs_along_what_a_cut_out_leaves_of_its_edge() { + // A cut has taken the plate's bottom left 100 x 60, so its y = 0 edge is left from x = 100 + // to 200. A 90° flange 40 high picked there (setback 3 inside, half of it in the middle, + // none outside; BA = π; panel 37) spans that stretch and takes its setback off it alone. + // It used to span x 0…200, half of it a strip standing beside nothing. + let (ba, panel, quarter) = (std::f64::consts::PI, 37.0, std::f64::consts::FRAC_PI_2); + let corner = [block(-10.0, 100.0, -10.0, 60.0)]; + for (position, line) in [("inside", 3.0), ("middle", 3.0 / 2.0), ("outside", 0.0)] { + let mut s = plate_with_cuts(&corner); + let (made, notes) = s.add_flanges_noted(&[Vec3::new(150.0, 0.0, 2.0)], 40.0, quarter, 1.0, position, false).unwrap(); + assert!(notes.is_empty(), "{position}: {notes:?}"); + let f = s.flanges[made[0]].clone(); + assert!(f.p.dist(Vec2::new(100.0, line)) < 1e-9 && (f.len - 100.0).abs() < 1e-9 && f.d.dist(Vec2::X) < 1e-12, "{position}: {f:?}"); + let bends = s.bends(); + assert!(bends.len() == 1 && (bends[0].length - 100.0).abs() < 1e-9 && (bends[0].start.x - 100.0).abs() < 1e-9, "{position}: {bends:?}"); + // The base keeps its edge where the cut was: the step at x = 100 lies inside the cut-out. + let step = [(0.0, 0.0), (100.0, 0.0), (100.0, line), (200.0, line), (200.0, 100.0), (0.0, 100.0)]; + let whole = [(0.0, 0.0), (200.0, 0.0), (200.0, 100.0), (0.0, 100.0)]; + base_is(&s, if line > 0.0 { &step[..] } else { &whole[..] }, position); + // Flat: the plate less the cut and the setback under the flange, and 100 × (BA + 37). + assert!((flat_area(&s) - (14_000.0 - 100.0 * line + 100.0 * (ba + panel))).abs() < 1e-6, "{position}: {}", flat_area(&s)); + let regions = s.flat_regions().unwrap(); + let y = line - ba - 20.0; + assert!(regions[0].contains(Vec2::new(150.0, y)) && !regions[0].contains(Vec2::new(50.0, y)), "{position}"); + holds_the_flat_material(&s, position); + s.flat = true; + assert!(s.solid().is_ok_and(|b| b.validity().is_empty()), "{position}: unfolded"); + } + // A pick on the part the cut took gets the nearest stretch that is left. + let mut s = plate_with_cuts(&corner); + let i = s.add_flange(Vec3::new(30.0, 0.0, 2.0), 40.0, quarter, 1.0, "outside", false).unwrap(); + assert!(s.flanges[i].p.dist(Vec2::new(100.0, 0.0)) < 1e-9 && (s.flanges[i].len - 100.0).abs() < 1e-9, "{:?}", s.flanges[i]); + // Cut-outs that leave the edge alone leave the flange alone: a hole in the plate, and a + // cut that only touches the edge's line from outside. + let mut plain = plate_with_cuts(&[]); + plain.add_flange(Vec3::new(100.0, 0.0, 2.0), 40.0, quarter, 1.0, "inside", false).unwrap(); + let mut s = plate_with_cuts(&[Region2 { outer: Loop2::circle(Vec2::new(100.0, 50.0), 10.0), holes: vec![] }, block(20.0, 60.0, -30.0, 0.0)]); + s.add_flange(Vec3::new(100.0, 0.0, 2.0), 40.0, quarter, 1.0, "inside", false).unwrap(); + assert_eq!((&s.flanges, &s.base), (&plain.flanges, &plain.base)); + // An edge the cut-outs took whole has nothing to bend, and the sheet stays as it was. + let mut s = plate_with_cuts(&[block(-10.0, 210.0, -10.0, 30.0)]); + let before = s.clone(); + let picks = [Vec3::new(200.0, 60.0, 2.0), Vec3::new(100.0, 0.0, 2.0)]; + let e = s.add_flanges(&picks, 40.0, quarter, 1.0, "inside", false).unwrap_err().to_string(); + assert!(e.contains("edge 2: a cut-out has taken that edge away") && s == before, "{e}"); + } + + #[test] + fn edge_flanges_on_either_side_of_a_notch_each_take_their_own_stretch_of_the_edge() { + // A notch 40 wide and 30 deep in the middle of the plate's y = 0 edge leaves x 0…80 and + // 120…200 of it. A flange picked on one side spans that side and takes its setback (3) + // off it alone, the other side keeping its edge; picked on both sides in one call, each + // flange has its own stretch. The notch is deeper than the bend line, so it frees the + // bends' ends and no bend relief is cut. + let (ba, panel, quarter) = (std::f64::consts::PI, 37.0, std::f64::consts::FRAC_PI_2); + let left = [(0.0, 3.0), (80.0, 3.0), (80.0, 0.0)]; + let right = [(120.0, 0.0), (120.0, 3.0), (200.0, 3.0)]; + let (plain_left, plain_right, top) = ([(0.0, 0.0)], [(200.0, 0.0)], [(200.0, 100.0), (0.0, 100.0)]); + // (picks' x, the base's corners along that edge, where each flange starts) + let cases: [(&[f64], Vec<(f64, f64)>, &[f64]); 4] = [ + (&[40.0], [&left[..], &plain_right[..]].concat(), &[0.0]), + (&[150.0], [&plain_left[..], &right[..]].concat(), &[120.0]), + (&[40.0, 150.0], [&left[..], &right[..]].concat(), &[0.0, 120.0]), + // A pick in the notch gets the side nearer to it. + (&[110.0], [&plain_left[..], &right[..]].concat(), &[120.0]), + ]; + for (xs, bottom, starts) in cases { + let what = format!("picks at x {xs:?}"); + let mut s = plate_with_cuts(&[block(80.0, 120.0, -10.0, 30.0)]); + let picks: Vec = xs.iter().map(|&x| Vec3::new(x, 0.0, 2.0)).collect(); + let (made, notes) = s.add_flanges_noted(&picks, 40.0, quarter, 1.0, "inside", false).unwrap(); + assert!(notes.is_empty() && s.corners(&made).is_empty(), "{what}: {notes:?}"); + let got: Vec<(f64, f64)> = made.iter().map(|&i| (s.flanges[i].p.x, s.flanges[i].len)).collect(); + assert!( + got.len() == starts.len() && got.iter().zip(starts).all(|(g, x)| (g.0 - x).abs() < 1e-9 && (g.1 - 80.0).abs() < 1e-9), + "{what}: {got:?}" + ); + assert!(made.iter().all(|&i| (s.flanges[i].p.y - 3.0).abs() < 1e-9), "{what}: {:?}", s.flanges); + base_is(&s, &[&bottom[..], &top[..]].concat(), &what); + // Flat: the plate less the notch and 80 × 3 of setback per flange, and 80 × (BA + 37) + // of each flange. + let n = made.len() as f64; + assert!((flat_area(&s) - (20_000.0 - 40.0 * 30.0 + n * 80.0 * (ba + panel - 3.0))).abs() < 1e-6, "{what}: {}", flat_area(&s)); + holds_the_flat_material(&s, &what); + } + } + + #[test] + fn an_edge_flange_beside_base_that_a_cut_out_left_gets_its_bend_relief() { + // A cut 2 deep along x 0…100 of the y = 0 edge is shallower than the flange's setback (3): + // beside the bend's end at x = 100 it leaves 1 mm of the base before the bend line. The + // flange picked on x 100…200 takes its setback off that stretch alone, and the rule's + // relief (2 wide, 1 deep) frees its bend there as it does beside a leg of the base's own + // outline: x 98…100, up to 1 past the bend line. + let (ba, panel) = (std::f64::consts::PI, 37.0); + let mut s = plate_with_cuts(&[block(-10.0, 100.0, -10.0, 2.0)]); + let (made, notes) = s.add_flanges_noted(&[Vec3::new(150.0, 0.0, 2.0)], 40.0, std::f64::consts::FRAC_PI_2, 1.0, "inside", false).unwrap(); + assert!(notes.is_empty(), "{notes:?}"); + base_is(&s, &[(0.0, 0.0), (98.0, 0.0), (98.0, 4.0), (100.0, 4.0), (100.0, 3.0), (200.0, 3.0), (200.0, 100.0), (0.0, 100.0)], "step"); + let f = &s.flanges[made[0]]; + assert!(f.p.dist(Vec2::new(100.0, 3.0)) < 1e-9 && (f.len - 100.0).abs() < 1e-9, "{f:?}"); + // Flat: the plate less the cut (100 × 2), the setback (100 × 3) and the 2 × 2 the relief + // takes of what the cut left, and 100 × (BA + 37) of flange. + assert!((flat_area(&s) - (20_000.0 - 200.0 - 300.0 - 4.0 + 100.0 * (ba + panel))).abs() < 1e-6, "{}", flat_area(&s)); + holds_the_flat_material(&s, "step"); + } + + #[test] + fn a_flange_on_a_flange_runs_along_what_a_cut_out_leaves_of_its_far_edge() { + // A 40 high flange on the plate's y = 0 edge (outside): its panel's far edge lies at + // y = −(BA + 37) in the flat. A cut takes x 0…60 of that edge, 12 into the panel, and a + // 15 high flange picked on the rest of it spans x 60…200. Set inside, it moves the far + // edge back by its setback (3) end to end: a flange's panel stays a rectangle. + let (ba, quarter) = (std::f64::consts::PI, std::f64::consts::FRAC_PI_2); + let far = -(ba + 37.0); + let mut wall = plate_with_cuts(&[]); + wall.add_flange(Vec3::new(100.0, 0.0, 2.0), 40.0, quarter, 1.0, "outside", false).unwrap(); + wall.holes.push(block(-10.0, 60.0, far - 5.0, far + 12.0)); + let pick = apply_point(&wall.world(Some(0)), Vec3::new(150.0, far, 2.0)); + for (position, setback) in [("outside", 0.0), ("inside", 3.0)] { + let mut s = wall.clone(); + let i = s.add_flange(pick, 15.0, quarter, 1.0, position, false).unwrap(); + let f = &s.flanges[i]; + assert!(f.parent == Some(0) && f.p.dist(Vec2::new(60.0, far + setback)) < 1e-9 && (f.len - 140.0).abs() < 1e-9, "{position}: {f:?}"); + // Flat: the plate, 200 × (BA + 37 − setback) of wall less the cut's 60 × (12 − setback), + // and 140 × (BA + 12) of the flange on it. + let want = 20_000.0 + 200.0 * (ba + 37.0 - setback) - 60.0 * (12.0 - setback) + 140.0 * (ba + 12.0); + assert!((flat_area(&s) - want).abs() < 1e-6, "{position}: {} vs {want}", flat_area(&s)); + holds_the_flat_material(&s, position); + } + } + /// 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 63fb4772..30783b34 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -1029,6 +1029,152 @@ fn the_bend_table_and_the_dxf_follow_what_flat_cuts_leave_of_a_bend() { assert!(close(&rows(&mut s), &[(1, 40.0, 100.0, 140.0), (1, 40.0, 160.0, 200.0)]), "{:?}", rows(&mut s)); } +/// The flat pattern's area: its outline loops less its cut-outs. +fn flat_area(f: &Value) -> f64 { + let loops = |k: &str| -> f64 { + let all = f[k].as_array().cloned().unwrap_or_default(); + all.iter() + .map(|l| { + let pts: Vec<(f64, f64)> = l + .as_array() + .cloned() + .unwrap_or_default() + .iter() + .map(|q| (q[0].as_f64().unwrap_or(f64::NAN), q[1].as_f64().unwrap_or(f64::NAN))) + .collect(); + polygon_area(&pts) + }) + .sum() + }; + loops("outline") - loops("cutouts") +} + +#[test] +fn an_edge_flange_made_after_a_cut_runs_along_the_edge_the_cut_left() { + // A 200 x 100 plate, its bottom left 100 x 60 cut away, then a flange picked on what is left of + // the bottom edge (x 100 … 200): it used to span the whole original edge, x 0 … 200, its left + // half standing beside nothing. + let mut s = Session::default(); + run(&mut s, "sheet.manage_rules", json!({"name": "R", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "2 mm", "active": true})); + base(&mut s, 200.0, 100.0); + rectangle_tool(&mut s, "Corner", [-10.0, -10.0], [100.0, 60.0]); + run(&mut s, "solid.extrude", json!({"sketch": "Corner", "through_all": true, "direction": "symmetric", "operation": "cut"})); + assert!((volume(&mut s) - 14_000.0 * 2.0).abs() < 1e-6); + run(&mut s, "sheet.flange", json!({"edges": [[150, 0, 2]], "height": 40, "angle": 90, "radius": 1, "position": "outside"})); + assert!(no_errors_or_warnings(&s), "{:?}", s.model.results); + let f = flat(&mut s); + let bends = f["bends"].as_array().cloned().unwrap_or_default(); + assert_eq!(bends.len(), 1, "{f}"); + let x = |k: &str| bends[0][k][0].as_f64().unwrap_or(f64::NAN); + assert!( + (x("start") - 100.0).abs() < 1e-9 && (x("end") - 200.0).abs() < 1e-9 && (bends[0]["length"].as_f64().unwrap_or(0.0) - 100.0).abs() < 1e-9, + "{f}" + ); + assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{f}"); + // The flat pattern: the plate less the cut, and the flange (π of bend, 37 of panel) on 100 mm. + assert!((flat_area(&f) - (14_000.0 + 100.0 * (PI + 37.0))).abs() < 1e-6, "{}", flat_area(&f)); + // Folded: the bend keeps its volume (K 0.5); the flange stands on x 100 … 200 only. + let want = (14_000.0 + 100.0 * (PI + 37.0)) * 2.0; + assert!((volume(&mut s) - want).abs() < 1e-3 * want, "{} vs {want}", volume(&mut s)); + assert!(contains(&s, [150.0, -2.0, 20.0]) && !contains(&s, [50.0, -2.0, 20.0])); + let state = s.model.state(); + let body = &state.bodies[0].body; + assert!(body.validity().is_empty() && body.shell_count() == 1); + for command in ["sheet.unfold", "sheet.refold"] { + run(&mut s, command, json!({})); + assert!((volume(&mut s) - want).abs() < 1e-3 * want, "{command}: {}", volume(&mut s)); + } +} + +/// A 200 x 100 plate (2 mm, K 0.5) with `cut`, a rectangle between two corners, cut through it. +fn plate_with_a_cut(s: &mut Session, cut: [[f64; 2]; 2]) { + run(s, "sheet.manage_rules", json!({"name": "R", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true})); + base(s, 200.0, 100.0); + rectangle_tool(s, "Cut", cut[0], cut[1]); + run(s, "solid.extrude", json!({"sketch": "Cut", "through_all": true, "direction": "symmetric", "operation": "cut"})); +} + +#[test] +fn edge_flanges_on_either_side_of_a_notch_span_the_side_they_were_picked_on() { + // A notch 40 wide and 30 deep in the middle of the bottom edge leaves x 0 … 80 and 120 … 200 of + // it. A 90° flange 40 high set inside (setback 3, BA π, panel 37) spans the side it was picked + // on and takes its setback off that side alone; picked on both sides, each has its own. + for (edges, sides) in + [(json!([[160, 0, 2]]), [false, true]), (json!([[40, 0, 2]]), [true, false]), (json!([[40, 0, 2], [160, 0, 2]]), [true, true])] + { + let mut s = Session::default(); + plate_with_a_cut(&mut s, [[80.0, -10.0], [120.0, 30.0]]); + run(&mut s, "sheet.flange", json!({"edges": edges, "height": 40, "angle": 90})); + assert!(no_errors_or_warnings(&s), "{edges}: {:?}", s.model.results); + let f = flat(&mut s); + let want: Vec<(f64, f64)> = [(0.0, 80.0), (120.0, 200.0)].into_iter().zip(sides).filter_map(|(span, on)| on.then_some(span)).collect(); + let bends: Vec<(f64, f64)> = f["bends"] + .as_array() + .cloned() + .unwrap_or_default() + .iter() + .map(|b| (b["start"][0].as_f64().unwrap_or(f64::NAN), b["end"][0].as_f64().unwrap_or(f64::NAN))) + .collect(); + assert!( + bends.len() == want.len() && bends.iter().zip(&want).all(|(b, w)| (b.0 - w.0).abs() < 1e-9 && (b.1 - w.1).abs() < 1e-9), + "{edges}: {f}" + ); + assert_eq!(f["outline"].as_array().map(Vec::len), Some(1), "{edges}: {f}"); + // The plate less the notch, and for each flange 80 × (π + 37) less the 80 × 3 it set back. + let area = 20_000.0 - 40.0 * 30.0 + want.len() as f64 * 80.0 * (PI + 37.0 - 3.0); + assert!((flat_area(&f) - area).abs() < 1e-6, "{edges}: {}", flat_area(&f)); + assert!(rel(volume(&mut s), area * 2.0) < 1e-4, "{edges}: {} vs {}", volume(&mut s), area * 2.0); + // A flange stands with its outer face on the edge (y 0 … 2); a side without one keeps the + // base up to the edge, which the bend's round outer corner leaves free. + for (x, on) in [40.0, 160.0].into_iter().zip(sides) { + assert_eq!(contains(&s, [x, 1.0, 20.0]), on, "{edges}: a flange at x {x}"); + assert_eq!(contains(&s, [x, 0.2, 0.2]), !on, "{edges}: the base's own edge at x {x}"); + } + let state = s.model.state(); + let body = &state.bodies[0].body; + assert!(body.validity().is_empty() && body.shell_count() == 1, "{edges}"); + for command in ["sheet.unfold", "sheet.refold"] { + run(&mut s, command, json!({})); + assert!(rel(volume(&mut s), area * 2.0) < 1e-4, "{edges}, {command}: {}", volume(&mut s)); + } + } + // An edge that a cut has taken whole is refused. + let mut s = Session::default(); + plate_with_a_cut(&mut s, [[-10.0, -10.0], [210.0, 30.0]]); + let e = s.execute("sheet.flange", &json!({"edges": [[100, 0, 2]], "height": 40})).map(|_| ()).unwrap_err().to_string(); + assert!(e.contains("a cut-out has taken that edge away"), "{e}"); +} + +#[test] +fn an_edge_flange_on_a_flange_runs_along_what_a_flat_cut_left_of_its_far_edge() { + // A 40 high wall on the plate's bottom edge (outside: BA π, panel 37), its far edge at + // y = -(π + 37) in the flat. Unfolded, a cut takes x 0 … 60 of that edge, up to y = -30. A + // 15 high flange picked on the rest of the edge (the wall's inner face is y = -1, its top + // z = 40) spans x 60 … 200. + 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})); + base(&mut s, 200.0, 100.0); + run(&mut s, "sheet.flange", json!({"edges": [[100, 0, 2]], "height": 40, "angle": 90, "position": "outside"})); + run(&mut s, "sheet.unfold", json!({})); + rectangle_tool(&mut s, "Corner", [-10.0, -50.0], [60.0, -30.0]); + run(&mut s, "solid.extrude", json!({"sketch": "Corner", "through_all": true, "direction": "symmetric", "operation": "cut"})); + run(&mut s, "sheet.refold", json!({})); + run(&mut s, "sheet.flange", json!({"edges": [[130, -1, 40]], "height": 15, "angle": 90, "position": "outside"})); + assert!(no_errors_or_warnings(&s), "{:?}", s.model.results); + let f = flat(&mut s); + let bends = f["bends"].as_array().cloned().unwrap_or_default(); + let spans: Vec<(f64, f64)> = + bends.iter().map(|b| (b["start"][0].as_f64().unwrap_or(f64::NAN), b["end"][0].as_f64().unwrap_or(f64::NAN))).collect(); + let close = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9; + assert!(spans.len() == 2 && close(spans[0], (0.0, 200.0)) && close(spans[1], (60.0, 200.0)), "{f}"); + // The plate, the wall less the cut's 60 × (π + 7), and 140 × (π + 12) of the flange on it. + let area = 20_000.0 + 200.0 * (PI + 37.0) - 60.0 * (PI + 7.0) + 140.0 * (PI + 12.0); + assert!((flat_area(&f) - area).abs() < 1e-6, "{}", flat_area(&f)); + assert!(rel(volume(&mut s), area * 2.0) < 1e-4, "{} vs {}", volume(&mut s), area * 2.0); + // It lies over the plate from the wall (z 41 … 43, y 0 … 12), on x 60 … 200 only. + assert!(contains(&s, [130.0, 6.0, 42.0]) && !contains(&s, [30.0, 6.0, 42.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 a0da2967..904ab104 100644 --- a/docs/book/src/guide/sheet-metal.md +++ b/docs/book/src/guide/sheet-metal.md @@ -24,6 +24,13 @@ flat. A relief that can't be cut (a width under 0.01 mm, one as wide as the base bend, or a slot that would cut the base apart) is left out, and the flange feature reports a warning saying where. +Where a cut-out has taken part of an edge away (a corner cut off the plate, a notch in the +middle of an edge), an edge flange picked on that edge runs along the stretch the cut-outs +leave nearest the pick, and takes its setback off that stretch only. The base on the other +side of a notch keeps its edge, and a pick there, in the same feature or a later one, makes +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. + 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`