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.
This commit is contained in:
NOWN-DEV authored and GitHub committed 2026-10-13 16:33:35 -04:00
1 parent c3e0ea498a
commit 8319c35a32
3 files changed
+424 -8

No files matched your search

+366 -8
View File
@@ -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::<Option<Vec<_>>>().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<Vec<Vec<Vec2>>> {
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<Vec<Vec2>>, (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<Vec2>], k: usize, a: Vec2, b: Vec2, by: f64, (lo, hi): (f64, f64)) -> Option<Vec<Vec2>> {
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<Vec2>| 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<Vec2>], cut: &[Vec2]) -> Option<Loop2> {
let strips: Vec<Region2> = 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<Seg2>) -> 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(&regions[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<String>) {
+53
View File
@@ -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<f64> =
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();
+5
View File
@@ -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