test: failing regression test for #264: Sheet metal: collinear tabs cannot be folded or flanged together (#279)

* test: reproduce #264 (Sheet metal: collinear tabs cannot be folded or flanged together)

* fix(sheet): fold and flange tabs that sit side by side on one line (#264)

A fold's bend zone and a flange's setback were cut with an infinite line, so
a second tab on the same line blocked a fold ('slanted or curved edge') and
two tab-end flanges in one feature shared one trimmed edge (nonmanifold).
When the zone falls apart into pieces, fold only the pieces the fold points
span, each as its own bend; trim a flange's setback only from the material
along its own edge, and attach each flange to the edge it was picked on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: bherbruck <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-12 11:19:21 -04:00
1 parent 3b8028a040
commit 14163d0965
2 files changed
+216 -7

No files matched your search

+173 -7
View File
@@ -1134,7 +1134,29 @@ impl SheetBody {
match panel {
None => {
let pts = self.base.outer.ccw().polyline(1e-3);
let cut = clip(&pts, a + inward * by, inward);
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.
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<Vec2>| {
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)));
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())),
};
}
if cut.len() < 3 {
return Err(DocError::Invalid("the flange's setback removes the whole panel".into()));
}
@@ -1190,13 +1212,13 @@ impl SheetBody {
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, d, top));
found.push((panel, a, b, d, top));
}
for (panel, a, d, _) in &found {
self.trim(*panel, *a, *a + *d, setback)?;
for (panel, a, b, _, _) in &found {
self.trim(*panel, *a, *b, setback)?;
}
let mut made = Vec::new();
for (panel, a, d, top) in found {
for (panel, a, b, d, top) 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.
@@ -1204,14 +1226,21 @@ impl SheetBody {
None => {
let pts = self.base.outer.ccw().polyline(1e-3);
let m = pts.len();
(0..m)
let on_line: Vec<(Vec2, f64)> = (0..m)
.filter_map(|k| {
let (p0, p1) = (*pts.get(k)?, *pts.get((k + 1) % m)?);
let e = p1 - p0;
let on = (p0 - line_o).dot(outward).abs() < 1e-6 && (p1 - line_o).dot(outward).abs() < 1e-6;
(on && e.dot(d) > 0.0).then_some((p0, e.len()))
})
.next()
.collect();
// Of several edges on the line (tabs side by side), the one the pick was on.
let (ua, ub) = (a.dot(d), b.dot(d));
on_line
.iter()
.find(|(p0, len)| (p0.dot(d) + len).min(ub) - p0.dot(d).max(ua) > 1e-9)
.or_else(|| on_line.first())
.copied()
.ok_or_else(|| DocError::Invalid("the flange's edge was trimmed away".into()))?
}
Some(i) => {
@@ -1400,6 +1429,14 @@ impl SheetBody {
if signed_area(&fixed_part).abs() < 1e-9 || signed_area(&moving).abs() < 1e-9 {
return Err(DocError::Invalid("the fold line must cross the sheet with material on both sides of the bend".into()));
}
// Separate tabs on one line (the zone falls apart into several pieces): fold the ones
// the line's points span, each as its own bend.
let strips = merge_loops(std::slice::from_ref(&strip), &mut FlatWork::new())?;
if strips.len() > 1 {
let span = (a2.dot(d).min(b2.dot(d)), a2.dot(d).max(b2.dot(d)));
let angle = if flip { -angle } else { angle };
return self.fold_tabs(panel, &pts, (n, d), (s0, ba), span, (angle, radius), &strips);
}
// The bend zone must be a rectangle across the panel (a slanted or curved edge would
// run across the bend as a helix, which the sheet's faces can't carry yet).
let us: Vec<f64> = strip.iter().map(|q| q.dot(d)).collect();
@@ -1471,6 +1508,135 @@ impl SheetBody {
Ok(i)
}
/// A fold whose bend zone falls apart into several pieces (`strips`: tabs on one line). The
/// pieces the line's points span (`span`, along `d`) bend, each with the material beyond it;
/// the rest of the panel stays put. `n` points to the moving side, the zone is `s0..s0 + ba`.
#[allow(clippy::too_many_arguments)]
fn fold_tabs(
&mut self,
panel: Option<usize>,
pts: &[Vec2],
(n, d): (Vec2, Vec2),
(s0, ba): (f64, f64),
span: (f64, f64),
(angle, radius): (f64, f64),
strips: &[Vec<Vec2>],
) -> Result<usize> {
const EPS: f64 = 1e-6;
let urange = |p: &[Vec2]| p.iter().fold((f64::MAX, f64::MIN), |(lo, hi), q| (lo.min(q.dot(d)), hi.max(q.dot(d))));
let overlap = |a: (f64, f64), b: (f64, f64)| a.1.min(b.1) - a.0.max(b.0);
let mut work = FlatWork::new();
// The material beyond the zone, piece by piece, and the zone piece each one hangs from.
let beyond = merge_loops(&[clip(pts, n * (s0 + ba), n)], &mut work)?;
let far_edges = |p: &[Vec2]| -> Vec<(f64, f64)> {
let m = p.len();
(0..m)
.filter_map(|i| Some((*p.get(i)?, *p.get((i + 1) % m)?)))
.filter(|(a, b)| (a.dot(n) - s0 - ba).abs() < EPS && (b.dot(n) - s0 - ba).abs() < EPS)
.map(|(a, b)| (a.dot(d).min(b.dot(d)), a.dot(d).max(b.dot(d))))
.collect()
};
let strip_u: Vec<(f64, f64)> = strips.iter().map(|s| urange(s)).collect();
let mut hangs: Vec<Vec<usize>> = vec![Vec::new(); strips.len()];
for (k, part) in beyond.iter().enumerate() {
let edges = far_edges(part);
let on: Vec<usize> = (0..strips.len()).filter(|&i| strip_u.get(i).is_some_and(|&u| edges.iter().any(|&e| overlap(e, u) > EPS))).collect();
match on.as_slice() {
[i] => {
if let Some(h) = hangs.get_mut(*i) {
h.push(k);
}
}
_ => return Err(DocError::Invalid("not supported yet: folding tabs that join again beyond the bend".into())),
}
}
let chosen: Vec<usize> = (0..strips.len()).filter(|&i| strip_u.get(i).is_some_and(|&u| overlap(u, span) > EPS)).collect();
if chosen.is_empty() {
return Err(DocError::Invalid("the fold line must cross the sheet with material on both sides of the bend".into()));
}
// What stays: the panel short of the zone plus the zone pieces (and what hangs from
// them) that don't fold.
let mut keep = vec![clip(pts, n * s0, n * -1.0)];
for i in 0..strips.len() {
if chosen.contains(&i) {
continue;
}
keep.extend(strips.get(i).cloned());
keep.extend(hangs.get(i).into_iter().flatten().filter_map(|&k| beyond.get(k).cloned()));
}
let fixed = match merge_loops(&keep, &mut work)?.as_slice() {
[one] if signed_area(one) > 1e-9 => one.clone(),
_ => return Err(DocError::Invalid("not supported yet: a fold that splits the part that stays".into())),
};
let mut made = Vec::new();
for &i in &chosen {
let (Some(strip), Some(&(u0, u1))) = (strips.get(i), strip_u.get(i)) else { continue };
// Each piece of the zone must be a rectangle (see `add_fold`).
if ((u1 - u0) * ba - signed_area(strip).abs()).abs() > 1e-6 * (u1 - u0).max(1.0) * ba.max(1.0) {
return Err(DocError::Invalid("not supported yet: folds whose bend zone crosses a slanted or curved edge of the sheet".into()));
}
let moving = match hangs.get(i).map(Vec::as_slice) {
Some([k]) => beyond.get(*k).cloned().ok_or_else(|| DocError::Invalid("fold".into()))?,
_ => return Err(DocError::Invalid("the fold line must cross the sheet with material on both sides of the bend".into())),
};
let leg = moving.iter().map(|q| q.dot(n) - s0 - ba).fold(0.0, f64::max);
made.push((u0, u1, n * s0 + d * u0, leg, moving));
}
// Flanges already on this panel: those beyond a folding tab move with it.
let mut moves = Vec::new();
for k in 0..self.flanges.len() {
let Some(f) = self.flanges.get(k) else { continue };
if f.parent != panel {
continue;
}
let (e0, e1) = (f.p.dot(n) - s0, (f.p + f.d * f.len).dot(n) - s0);
let fu = (f.p.dot(d).min((f.p + f.d * f.len).dot(d)), f.p.dot(d).max((f.p + f.d * f.len).dot(d)));
let crosses = Err(DocError::Invalid("not supported yet: a fold through a flange's bend".into()));
match made.iter().position(|m| fu.0 >= m.0 - 1e-9 && fu.1 <= m.1 + 1e-9) {
Some(j) if e0.min(e1) >= ba - 1e-9 => moves.push((k, j)),
Some(_) if e0.max(e1) > 1e-9 => return crosses,
None if e0.max(e1) > 1e-9 && made.iter().any(|m| overlap((m.0, m.1), fu) > EPS) => return crosses,
_ => {}
}
}
let ccw = |mut v: Vec<Vec2>| {
if signed_area(&v) < 0.0 {
v.reverse();
}
v.into_iter().map(snap).collect::<Vec<_>>()
};
match panel {
None => self.base = Region2 { outer: Loop2::polygon(&fixed).ccw(), holes: self.base.holes.clone() },
Some(j) => {
if let Some(f) = self.flanges.get_mut(j) {
f.shape = Some(ccw(fixed));
}
}
}
let first = self.flanges.len();
for (u0, u1, p0, leg, moving) in made {
self.flanges.push(SheetFlange {
parent: panel,
p: snap(p0),
d,
len: u1 - u0,
n,
angle,
radius,
leg,
ext: (0.0, 0.0),
shape: Some(ccw(moving)),
tag: String::new(),
});
}
for (k, j) in moves {
if let Some(f) = self.flanges.get_mut(k) {
f.parent = Some(first + j);
}
}
Ok(first)
}
/// Name a panel edge (flat coordinates a→b on `panel`, top or bottom face): base edges by the
/// base sketch line they lie on, flange edges by the flange (its tag) and side.
fn edge_name_of(&self, panel: Option<usize>, a: Vec2, b: Vec2, top: bool) -> Option<String> {
+43
View File
@@ -1568,6 +1568,49 @@ fn outline_blank(s: &mut Session, outline: Value) {
run(s, "sheet.flange", json!({"sketch": "Blank"}));
}
/// A 100 x 40 blank with two 15 mm tabs on its right edge (y 5..12 and y 28..35), on one bend line.
fn two_tab_blank(s: &mut Session) {
outline_blank(
s,
json!([[0, 0], [100, 0], [100, 5], [115, 5], [115, 12], [100, 12], [100, 28], [115, 28], [115, 35], [100, 35], [100, 40], [0, 40]]),
);
}
/// Folding one tab must not be refused because another tab lies on the same line (#264).
#[test]
fn fold_one_of_two_collinear_tabs() {
let mut s = Session::default();
two_tab_blank(&mut s);
let flat = volume(&mut s);
run(&mut s, "sheet.fold", json!({"points": [[107, 5, 0.8], [107, 12, 0.8]], "fixed": [50, 20, 0.8]}));
assert!(rel(volume(&mut s), flat) < 1e-3, "a fold keeps the material");
}
/// One fold line across both tabs bends both (#264).
#[test]
fn fold_across_two_collinear_tabs() {
let mut s = Session::default();
two_tab_blank(&mut s);
let flat = volume(&mut s);
run(&mut s, "sheet.fold", json!({"points": [[107, 5, 0.8], [107, 35, 0.8]], "fixed": [50, 20, 0.8]}));
assert!(rel(volume(&mut s), flat) < 1e-3);
let bb = &run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"];
assert!(bb["max"][2].as_f64().unwrap() > 7.0, "both tabs stand up: {bb}");
}
/// Edge flanges on both tab ends in one feature (two separate features already work) (#264).
#[test]
fn edge_flanges_on_two_collinear_tab_ends_in_one_feature() {
let mut s = Session::default();
two_tab_blank(&mut s);
run(&mut s, "sheet.flange", json!({"edges": [[115, 8, 0.8], [115, 31, 0.8]], "height": "10"}));
let mut separate = Session::default();
two_tab_blank(&mut separate);
run(&mut separate, "sheet.flange", json!({"edges": [[115, 8, 0.8]], "height": "10"}));
run(&mut separate, "sheet.flange", json!({"edges": [[115, 31, 0.8]], "height": "10"}));
assert!(rel(volume(&mut s), volume(&mut separate)) < 1e-6);
}
/// `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() {