fix: the bend table and bend lines follow what cut-outs leave of a bend (#607)

The bend table, flat pattern and DXF list a bend only where its centre line has material: a bend that cut-outs (or pockets that together go through the sheet) shorten keeps what is left, and one they split gets a row per piece, all with the bend's number.

Co-authored-by: NOWN-DEV <[email protected]>
This commit is contained in:
NOWN-DEV authored and GitHub committed 2026-10-13 04:32:45 -04:00
1 parent a4ca1b68db
commit 9089edfb71
4 files changed
+246 -17

No files matched your search

+166 -15
View File
@@ -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<Region2> {
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<BendInfo> {
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::<f64>() / 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<f64>) {
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<Region2>| {
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;
+1 -1
View File
@@ -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<Vec<Vec3>> {
+75
View File
@@ -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::<Vec<_>>().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();
+4 -1
View File
@@ -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.