diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index ac343153..47cb2342 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -1261,27 +1261,38 @@ impl SheetBody { Ok(loops) } - /// The bends: angle, radius, direction, allowance and centre line (flat coordinates). + /// What is cut out of the flat material: the cut-outs, and the pockets that together go + /// through the whole thickness. + fn cut_outs(&self) -> Vec { + let mut cuts = self.holes.clone(); + cuts.extend(self.through_pocket_regions(&mut FlatWork::new()).unwrap_or_default()); + cuts + } + + /// The bends: angle, radius, direction, allowance and centre line (flat coordinates). A + /// bend is listed where its centre line has material: one entry for every stretch that the + /// cut-outs leave of it, all with the bend's `index`, and none for a bend they take whole. pub fn bends(&self) -> Vec { - self.flanges - .iter() - .enumerate() - .map(|(i, f)| { - let ba = self.allowance(f); - let (u0, u1) = self.zone_span(f); - let a = f.p + f.n * (ba / 2.0) + f.d * u0; - BendInfo { + let cuts = self.cut_outs(); + let mut out = Vec::new(); + for (i, f) in self.flanges.iter().enumerate() { + let ba = self.allowance(f); + let (u0, u1) = self.zone_span(f); + let a = f.p + f.n * (ba / 2.0) + f.d * u0; + for (s0, s1) in clear_of(a, f.d, u1 - u0, &cuts) { + out.push(BendInfo { index: i + 1, angle_deg: f.angle.abs().to_degrees(), radius: f.radius, up: f.angle >= 0.0, allowance: ba, - length: u1 - u0, - start: a, - end: a + f.d * (u1 - u0), - } - }) - .collect() + length: s1 - s0, + start: if s0 > 0.0 { a + f.d * s0 } else { a }, + end: a + f.d * s1, + }); + } + } + out } /// Topology counted as Fusion does: a round hole is one face bounded by two closed circular @@ -2460,6 +2471,85 @@ fn signed_area(p: &[Vec2]) -> f64 { (0..m).filter_map(|i| Some(p.get(i)?.cross(*p.get((i + 1) % m)?))).sum::() / 2.0 } +/// Where the line from `a` along the unit vector `d` meets a boundary segment, as distances from +/// `a`: lines and arcs exactly, free-form curves on their 0.0001 mm chords. +fn line_hits(a: Vec2, d: Vec2, e: &Seg2, out: &mut Vec) { + match *e { + Seg2::Line { a: p, b: q } => { + let r = q - p; + let den = d.cross(r); + if den.abs() > 1e-12 * r.len() { + let t = (p - a).cross(d) / den; + if (-1e-9..=1.0 + 1e-9).contains(&t) { + out.push((p - a).cross(r) / den); + } + } + } + Seg2::Arc { center, radius, start, sweep } => { + let (along, off) = ((center - a).dot(d), (center - a).cross(d)); + if off.abs() <= radius { + let half = ((radius - off) * (radius + off)).max(0.0).sqrt(); + for s in [along - half, along + half] { + let angle = (a + d * s - center).angle(); + let turned = if sweep >= 0.0 { angle - start } else { start - angle }.rem_euclid(std::f64::consts::TAU); + if turned <= sweep.abs() + 1e-9 || turned >= std::f64::consts::TAU - 1e-9 { + out.push(s); + } + } + } + } + _ => { + for w in e.polyline(1e-4).windows(2) { + if let [p, q] = *w { + line_hits(a, d, &Seg2::Line { a: p, b: q }, out); + } + } + } + } +} + +/// The stretches of the line from `a` along the unit vector `d`, `len` long, that lie in none of +/// the `cuts` (flat regions), as distances from `a` in order. A line that no cut reaches comes +/// back whole, as (0, `len`); stretches under a thousandth of a micron are no material. +fn clear_of(a: Vec2, d: Vec2, len: f64, cuts: &[Region2]) -> Vec<(f64, f64)> { + if !len.is_finite() || len <= 0.0 { + return Vec::new(); + } + let b = a + d * len; + let (lo, hi) = (Vec2::new(a.x.min(b.x) - 1e-6, a.y.min(b.y) - 1e-6), Vec2::new(a.x.max(b.x) + 1e-6, a.y.max(b.y) + 1e-6)); + // Only cut-outs whose box the line enters can hold or cross it. + let near: Vec<&Region2> = cuts + .iter() + .filter(|r| { + let pts = r.outer.polyline(1e-3); + let (mut p0, mut p1) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN)); + for p in &pts { + (p0, p1) = (Vec2::new(p0.x.min(p.x), p0.y.min(p.y)), Vec2::new(p1.x.max(p.x), p1.y.max(p.y))); + } + p0.x <= hi.x && lo.x <= p1.x && p0.y <= hi.y && lo.y <= p1.y + }) + .collect(); + let mut at = vec![0.0, len]; + for e in near.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).flat_map(|l| &l.segs) { + line_hits(a, d, e, &mut at); + } + at.retain(|s| *s >= 0.0 && *s <= len); + at.sort_by(f64::total_cmp); + let mut out: Vec<(f64, f64)> = Vec::new(); + for w in at.windows(2) { + let [s0, s1] = *w else { continue }; + if s1 - s0 < 1e-9 || near.iter().any(|r| r.contains(a + d * ((s0 + s1) / 2.0))) { + continue; + } + match out.last_mut() { + Some(last) if s0 - last.1 < 1e-9 => last.1 = s1, + _ => out.push((s0, s1)), + } + } + out.retain(|(s0, s1)| s1 - s0 > 1e-6); + out +} + /// 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())?)) }; @@ -2711,6 +2801,67 @@ mod tests { assert!((s.flat_size().unwrap().1 - 73.685298).abs() < 1e-5); } + #[test] + fn bends_are_listed_where_their_centre_lines_have_material() { + // A 200 × 100 plate with a 40 mm flange on its y = 0 edge: the bend zone lies below + // y = 0, BA wide, its centre line along y = −BA / 2 from x = 0 to 200. + let mut s = plate(200.0, 100.0); + s.add_flange(Vec3::new(100.0, 0.0, 2.5), 40.0, std::f64::consts::FRAC_PI_2, 2.5, "outside", false).unwrap(); + let ba = s.allowance(&s.flanges[0]); + assert!((ba - std::f64::consts::FRAC_PI_2 * 3.6).abs() < 1e-12); + let y = -ba / 2.0; + let whole = s.bends(); + assert_eq!(whole.len(), 1); + assert!(whole[0].length == 200.0 && whole[0].start == Vec2::new(0.0, y) && whole[0].end == Vec2::new(200.0, y), "{whole:?}"); + let spans = |s: &SheetBody| -> Vec<(usize, f64, f64)> { + let b = s.bends(); + assert!(b.iter().all(|b| (b.start.y - y).abs() < 1e-12 && (b.end.y - y).abs() < 1e-12 && (b.length - b.start.dist(b.end)).abs() < 1e-12)); + assert!(b.iter().all(|b| b.up && b.allowance == ba && b.radius == 2.5 && (b.angle_deg - 90.0).abs() < 1e-12)); + b.iter().map(|b| (b.index, b.start.x, b.end.x)).collect() + }; + let close = |got: &[(usize, f64, f64)], want: &[(usize, f64, f64)]| { + got.len() == want.len() && got.iter().zip(want).all(|(g, w)| g.0 == w.0 && (g.1 - w.1).abs() < 1e-9 && (g.2 - w.2).abs() < 1e-9) + }; + let block = |x0: f64, x1: f64, y0: f64, y1: f64| rect(Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)); + let disc = |x: f64, y: f64, r: f64| Region2 { outer: Loop2::circle(Vec2::new(x, y), r), holes: vec![] }; + let with = |cuts: Vec| { + let mut c = s.clone(); + c.holes.extend(cuts); + c + }; + // A cut that takes the flange's left half, bend and all (and a strip of the base). + let half = with(vec![block(-10.0, 100.0, -60.0, 10.0)]); + assert!(close(&spans(&half), &[(1, 100.0, 200.0)]), "{:?}", half.bends()); + // A slot across the bend: two stretches, both the same bend. + let slot = with(vec![block(90.0, 110.0, -20.0, 10.0)]); + assert!(close(&spans(&slot), &[(1, 0.0, 90.0), (1, 110.0, 200.0)]), "{:?}", slot.bends()); + // A round hole on the centre line takes its diameter; one in the zone beside the line + // (0.5 across, 2 mm off it) takes nothing. + let hole = with(vec![disc(50.0, y, 1.0), disc(150.0, y + 2.0, 0.25)]); + assert!(close(&spans(&hole), &[(1, 0.0, 49.0), (1, 51.0, 200.0)]), "{:?}", hole.bends()); + // A hole crossed off its centre takes its chord there: 2·√(3² − 1²). + let chord = with(vec![disc(50.0, y + 1.0, 3.0)]); + let c = 8f64.sqrt(); + assert!(close(&spans(&chord), &[(1, 0.0, 50.0 - c), (1, 50.0 + c, 200.0)]), "{:?}", chord.bends()); + // A ring leaves the island inside it its stretch of the bend. + let ring = with(vec![Region2 { outer: Loop2::circle(Vec2::new(150.0, y), 10.0), holes: vec![Loop2::circle(Vec2::new(150.0, y), 4.0)] }]); + assert!(close(&spans(&ring), &[(1, 0.0, 140.0), (1, 146.0, 154.0), (1, 160.0, 200.0)]), "{:?}", ring.bends()); + // A cut over the whole bend zone leaves no bend, and cuts elsewhere leave it whole. + assert!(with(vec![block(-1.0, 201.0, -ba - 0.5, 0.5)]).bends().is_empty()); + assert_eq!(with(vec![disc(50.0, 50.0, 10.0), block(20.0, 40.0, -30.0, -10.0)]).bends(), whole); + // Pockets count where together they go through the sheet. + let mut pocketed = s.clone(); + pocketed.record_pocket(block(90.0, 110.0, -20.0, 10.0), 0.0, 1.0, &mut FlatWork::new()).unwrap(); + assert_eq!(pocketed.bends(), whole); + pocketed.record_pocket(block(80.0, 105.0, -20.0, 10.0), 1.0, 2.5, &mut FlatWork::new()).unwrap(); + assert!(close(&spans(&pocketed), &[(1, 0.0, 90.0), (1, 105.0, 200.0)]), "{:?}", pocketed.bends()); + // A second flange keeps its own number. + let mut two = half.clone(); + two.add_flange(Vec3::new(100.0, 100.0, 2.5), 40.0, std::f64::consts::FRAC_PI_2, 2.5, "outside", false).unwrap(); + let b = two.bends(); + assert!(b.len() == 2 && b[0].index == 1 && (b[0].length - 100.0).abs() < 1e-9 && b[1].index == 2 && b[1].length == 200.0, "{b:?}"); + } + #[test] fn flat_faces_of_a_sheet_are_found_and_named_by_panel() { use std::f64::consts::FRAC_PI_2; diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 1d9e799e..6425c981 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -54,7 +54,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("body?, path? → DXF (mm): OUTLINE, BEND_UP and BEND_DOWN (centre lines) layers"), CommandSpec::new("sheet.export_bend_table", "Export Bend Table", export_bend_table) .noundo() - .params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates"), + .params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates; a bend that cut-outs split has a row for each piece"), ]; fn points(p: &Value, k: &str, cmd: &str) -> Result> { diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index 38259ed4..3369efc5 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -954,6 +954,81 @@ fn tray_shrunk_by_a_parameter_unfolds_like_one_built_at_that_size() { } } +#[test] +fn the_bend_table_and_the_dxf_follow_what_flat_cuts_leave_of_a_bend() { + // A 200 x 100 plate with a 40 mm flange along y = 0: the bend's centre line runs at y = -pi/2 + // (R 1, T 2, K 0.5: a bend allowance of pi) from x = 0 to 200. + 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); + run(&mut s, "sheet.flange", json!({"edges": [[100, 0, 2]], "height": 40, "angle": 90, "radius": 1, "position": "outside"})); + // (bend number, length, start x, end x) of every row; all on the centre line. + let rows = |s: &mut Session| -> Vec<(u64, f64, f64, f64)> { + let f = flat(s); + let num = |v: &Value| v.as_f64().unwrap_or(f64::NAN); + f["bends"] + .as_array() + .cloned() + .unwrap_or_default() + .iter() + .map(|b| { + assert!((num(&b["start"][1]) + FRAC_PI_2).abs() < 1e-9 && (num(&b["end"][1]) + FRAC_PI_2).abs() < 1e-9, "{b}"); + (b["bend"].as_u64().unwrap_or(0), num(&b["length"]), num(&b["start"][0]), num(&b["end"][0])) + }) + .collect() + }; + let close = |got: &[(u64, f64, f64, f64)], want: &[(u64, f64, f64, f64)]| { + got.len() == want.len() + && got.iter().zip(want).all(|(g, w)| g.0 == w.0 && (g.1 - w.1).abs() < 1e-9 && (g.2 - w.2).abs() < 1e-9 && (g.3 - w.3).abs() < 1e-9) + }; + // The DXF's bend lines: both ends' x of every LINE along the centre line, left to right; the + // BEND_UP layer holds nothing else. + let bend_lines = |s: &mut Session| -> Vec<(f64, f64)> { + let text = run(s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string(); + let on_layer = text.lines().collect::>().windows(2).filter(|w| w[0].trim() == "8" && w[1].trim() == "BEND_UP").count(); + let mut found: Vec<(f64, f64)> = solidcraft_io::read_dxf(&text) + .unwrap() + .iter() + .filter_map(|g| match g { + solidcraft_io::Geom2::Line(a, b) if (a.y + FRAC_PI_2).abs() < 1e-6 && (b.y + FRAC_PI_2).abs() < 1e-6 => { + Some((a.x.min(b.x), a.x.max(b.x))) + } + _ => None, + }) + .collect(); + found.sort_by(|a, b| a.0.total_cmp(&b.0)); + assert_eq!(found.len(), on_layer, "{text}"); + found + }; + assert!(close(&rows(&mut s), &[(1, 200.0, 0.0, 200.0)]), "{:?}", rows(&mut s)); + assert_eq!(bend_lines(&mut s).len(), 1); + // A flat cut takes the flange's left half, its bend and a strip of the base. + run(&mut s, "sheet.unfold", json!({})); + rectangle_tool(&mut s, "Half", [-10.0, -60.0], [100.0, 10.0]); + run(&mut s, "solid.extrude", json!({"sketch": "Half", "through_all": true, "direction": "symmetric", "operation": "cut"})); + run(&mut s, "sheet.refold", json!({})); + assert!(close(&rows(&mut s), &[(1, 100.0, 100.0, 200.0)]), "{:?}", rows(&mut s)); + let lines = bend_lines(&mut s); + assert!(lines.len() == 1 && (lines[0].0 - 100.0).abs() < 1e-6 && (lines[0].1 - 200.0).abs() < 1e-6, "{lines:?}"); + let csv = run(&mut s, "sheet.export_bend_table", json!({})); + let text = csv["text"].as_str().unwrap_or_default(); + assert_eq!((text.lines().count(), csv["bends"].as_u64()), (2, Some(1)), "{text}"); + assert!(text.lines().nth(1).is_some_and(|l| l.contains(",1,90.000000000,up,1.000000000,3.141592654,100.000000000,100.000000000,")), "{text}"); + // A slot across what is left splits the bend: a row and a line for each piece, both bend 1. + run(&mut s, "sheet.unfold", json!({})); + rectangle_tool(&mut s, "Slot", [140.0, -20.0], [160.0, 10.0]); + run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "direction": "symmetric", "operation": "cut"})); + run(&mut s, "sheet.refold", json!({})); + assert!(close(&rows(&mut s), &[(1, 40.0, 100.0, 140.0), (1, 40.0, 160.0, 200.0)]), "{:?}", rows(&mut s)); + let lines = bend_lines(&mut s); + assert!(lines.len() == 2 && (lines[0].1 - 140.0).abs() < 1e-6 && (lines[1].0 - 160.0).abs() < 1e-6, "{lines:?}"); + let csv = run(&mut s, "sheet.export_bend_table", json!({})); + assert_eq!((csv["text"].as_str().unwrap_or_default().lines().count(), csv["bends"].as_u64()), (3, Some(2))); + // Unfolded, the flat pattern lists the same pieces. + run(&mut s, "sheet.unfold", json!({})); + assert!(close(&rows(&mut s), &[(1, 40.0, 100.0, 140.0), (1, 40.0, 160.0, 200.0)]), "{:?}", rows(&mut s)); +} + #[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 f6f1f007..1ca81011 100644 --- a/docs/book/src/guide/sheet-metal.md +++ b/docs/book/src/guide/sheet-metal.md @@ -64,7 +64,10 @@ per edge with an angle and a height box; an empty box takes the value above. For a shop drawing or spreadsheet, `sheet.export_bend_table` returns CSV by default; choose `format: "json"` for structured data. It includes the sheet rule, thickness, bend angles, radii, allowances and flat centre-line coordinates. Lengths use millimetres and angles use -degrees. Supply `path` to save it, or omit `path` to receive the content through the command +degrees. A bend is listed where its centre line has material: one that a cut-out has shortened +gives the length and ends it has left, and one that cut-outs split gives a row for every piece, +all with the bend's number (the flat pattern and the DXF draw the same pieces as bend lines). +Supply `path` to save it, or omit `path` to receive the content through the command response. `body` defaults to the last sheet body. Exporting does not add a timeline entry or consume an undo step.