From c3aae64a63f26892f074ce5e7541c5e5dba6d015 Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:59:07 +0200 Subject: [PATCH] Sheet metal: a cut-out with a corner a hair beside a bend line refolds (#1095) Edges shorter than the model tolerance are dropped from the flat cells, and the refold sews touching corners, so a cut-out across a bend line no longer fails with a degenerate edge. --- crates/doc/src/sheet.rs | 151 ++++++++++++++++++++++++- crates/engine/src/cmd/sheet_tests.rs | 133 ++++++++++++++++++++++ crates/kernel/src/lib.rs | 2 +- crates/kernel/src/sew.rs | 162 +++++++++++++++++++++++++-- 4 files changed, 433 insertions(+), 15 deletions(-) diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index 6c716803..aba96b19 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -1081,7 +1081,17 @@ impl SheetBody { if points > MAX_FLAT_POINTS || out.len() >= MAX_FLAT_PIECES { return Err(flat_budget_error()); } - out.push(Cell { piece, outer: region.outer.segs, holes: region.holes.into_iter().map(|l| l.segs).collect() }); + // A piece shorter than the model's tolerance is no edge: a cut-out's corner a hair + // beside a bend line comes out of the booleans as corners a hair apart, on the + // panel and on the bend, joined by such an edge or by a loop of them on its own. + // The sew welds the corners left on either side. + let hairless = |l: Loop2| -> Vec { l.segs.into_iter().filter(|e| e.length() >= LINEAR_TOL).collect() }; + let outer = hairless(region.outer); + if outer.is_empty() { + continue; + } + let holes = region.holes.into_iter().map(hairless).filter(|h| !h.is_empty()).collect(); + out.push(Cell { piece, outer, holes }); } } Ok(out) @@ -1264,7 +1274,7 @@ impl SheetBody { // origin. Fitting planes after a distant component transfer loses precision. let mut local = self.clone(); local.frame = Plane::XY; - let body = kernel::sew(&local.faces(&cells, 0.0, self.t, &mut work)?)?; + let body = kernel::sew_touching(&local.faces(&cells, 0.0, self.t, &mut work)?)?; let body = local.machine_pockets(body, &mut work)?; let body = local.machine_bores(body, &mut work)?; self.place_local_solid(body) @@ -1434,7 +1444,7 @@ impl SheetBody { })?; kernel::extrude_planar_regions(&self.frame, &[region], 0.0, self.t)? } else { - kernel::sew(&self.faces(std::slice::from_ref(&cell), 0.0, self.t, &mut work)?)? + kernel::sew_touching(&self.faces(std::slice::from_ref(&cell), 0.0, self.t, &mut work)?)? }; if kernel::boolean(&solid, &drill, kernel::BoolOp::Intersect)?.is_none() { continue; @@ -1538,7 +1548,7 @@ impl SheetBody { Piece::Panel(pi) => apply_vector(&self.world(pi), Vec3::Z).cross(plane.normal()).len() > 1e-6, }) .collect(); - cells.iter().map(|c| Ok(kernel::sew(&self.faces(std::slice::from_ref(c), 0.0, self.t, &mut work)?)?)).collect() + cells.iter().map(|c| Ok(kernel::sew_touching(&self.faces(std::slice::from_ref(c), 0.0, self.t, &mut work)?)?)).collect() } fn through_pocket_regions(&self, work: &mut FlatWork) -> Result> { @@ -4746,6 +4756,42 @@ mod tests { s.flat_regions().unwrap().iter().map(Region2::area).sum() } + /// A plate (0, 0) (100, 0) (85, 20) (0, 20) with a 30 mm inside flange on its slanted edge + /// (2 mm sheet, R 2, K 0.5: the folded sheet holds its flat area times the thickness), and a + /// 4 mm² triangle cut out of the flat across the bend line, with a corner on the line or a + /// hair (0.01 to 0.1 µm) to either side of it, in the middle of the bend and near its ends. + /// The planar booleans leave such a corner as corners a hair apart on the base and on the + /// bend, and the refold joined them by an edge as short ("sew: a degenerate edge"). + #[test] + fn a_cut_out_corner_a_hair_beside_a_bend_line_refolds() { + let rule = RuleValues { bend_radius: 2.0, ..alu() }; + let outline = [Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(85.0, 20.0), Vec2::new(0.0, 20.0)]; + for deg in [90.0f64, 120.0] { + let mut s = base_flange("Body1", "AL2", &rule, Plane::XY, Region2 { outer: Loop2::polygon(&outline).ccw(), holes: vec![] }); + s.add_flange(Vec3::new(92.5, 10.0, 2.0), 30.0, deg.to_radians(), 2.0, "inside", false).unwrap(); + let (a0, v0) = (flat_area(&s), vol(&s.solid().unwrap())); + if deg == 90.0 { + // The base less 4 mm along the edge's normal (5 mm along x): 1750; the bend + // 25 x 3π/2; the flange 25 x 26. + assert!((a0 - (2400.0 + 37.5 * std::f64::consts::PI)).abs() < 1e-9, "{a0}"); + } + // The measure follows the bend by facets. + assert!((v0 / (a0 * 2.0) - 1.0).abs() < 1e-4, "{deg}°: {v0} vs {}", a0 * 2.0); + let f = s.flanges[0].clone(); + for (along, off) in [(0.5, 0.0), (0.5, 1e-8), (0.5, 5e-8), (0.5, -5e-8), (0.5, 1e-7), (0.5, -1e-7), (0.16, 5e-8), (0.84, -5e-8)] { + let what = format!("{deg}°, corner {along} along the bend, {off} mm beside it"); + let c = f.p + f.d * (f.len * along) + f.n * off; + let triangle = Loop2::polygon(&[c, c - f.n * 2.8 - f.d * 0.4, c + f.n * 0.4 - f.d * 2.8]).ccw(); + let mut cut = s.clone(); + cut.holes.push(Region2 { outer: triangle, holes: vec![] }); + assert!((a0 - flat_area(&cut) - 4.0).abs() < 1e-6, "{what}: flat area {}", a0 - flat_area(&cut)); + let body = cut.solid().unwrap_or_else(|e| panic!("{what}: {e}")); + assert!(body.validity().is_empty(), "{what}: {:?}", body.validity()); + assert!((v0 - vol(&body) - 8.0).abs() < 0.01, "{what}: {} vs 8", v0 - vol(&body)); + } + } + } + /// Distance between two parallel 3D lines through a0→a1 and b0→b1 (None if not parallel). fn parallel_gap(a0: Vec3, a1: Vec3, b0: Vec3, b1: Vec3) -> Option { let (u, v) = ((a1 - a0).normalized()?, (b1 - b0).normalized()?); @@ -6904,6 +6950,103 @@ mod tests { } } + /// The plate (0, 0) (100, 0) (85, 20) (0, 20) with a 30 mm inside flange on its slanted edge + /// (2 mm sheet, R 2, K 0.5: the folded sheet holds its flat area times the thickness), and a + /// triangle cut out of the flat with one corner on the bend line: where the line meets the + /// plate's edge, at either end, the triangle on the bend or on the base; in the line's middle, + /// from either side; and on the plate's straight edge, from inside. At the plate's edge the + /// sheet is pinched (two wedges of material meet along the thickness); in the middle the + /// triangle's tip touches the next piece. The refold ran the face through that corner twice + /// ("This wire is not simple"). + #[test] + fn a_cut_out_with_a_corner_on_a_bend_line_or_the_sheets_edge_refolds() { + let rule = RuleValues { bend_radius: 2.0, ..alu() }; + let outline = [Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(85.0, 20.0), Vec2::new(0.0, 20.0)]; + for deg in [90.0f64, 120.0] { + let mut s = base_flange("Body1", "AL2", &rule, Plane::XY, Region2 { outer: Loop2::polygon(&outline).ccw(), holes: vec![] }); + s.add_flange(Vec3::new(92.5, 10.0, 2.0), 30.0, deg.to_radians(), 2.0, "inside", false).unwrap(); + let (a0, v0) = (flat_area(&s), vol(&s.solid().unwrap())); + let f = s.flanges[0].clone(); + let (start, end, middle) = (f.p, f.p + f.d * f.len, f.p + f.d * (f.len / 2.0)); + // The bend line runs from the plate's edge y = 0 to y = 20, the bend on its +n side, + // square across at its ends; the base's corner at either end is at least 53.13° + // wide, the edge running off at (0.6, -0.8) along and across the line. The triangles' + // other corners as steps along the line and across it. + let cases = [ + ("on the bend at the line's start", start, [(2.0, 1.0), (0.4, 2.2)], 2.0), + ("on the bend at the line's end", end, [(-2.0, 1.0), (-0.4, 2.2)], 2.0), + ("on the base at the line's start", start, [(4.0, -1.0), (2.0, -2.0)], 3.0), + ("on the base at the line's middle", middle, [(-1.0, -2.0), (1.0, -2.0)], 2.0), + ("on the bend at the line's middle", middle, [(-1.0, 2.0), (1.0, 2.0)], 2.0), + ]; + for (what, c, others, area) in cases { + let what = format!("{deg}°, {what}"); + let corners: Vec = std::iter::once(c).chain(others.iter().map(|(u, v)| c + f.d * *u + f.n * *v)).collect(); + let mut cut = s.clone(); + cut.holes.push(Region2 { outer: Loop2::polygon(&corners).ccw(), holes: vec![] }); + assert!((a0 - flat_area(&cut) - area).abs() < 1e-6, "{what}: flat area {}", a0 - flat_area(&cut)); + let body = cut.solid().unwrap_or_else(|e| panic!("{what}: {e}")); + assert!(body.validity().is_empty() && body.shell_count() == 1, "{what}: {:?}", body.validity()); + assert!((v0 - vol(&body) - area * 2.0).abs() < 0.01, "{what}: {} vs {}", v0 - vol(&body), area * 2.0); + } + // A 4 mm² triangle standing on the plate's edge y = 20 at x = 40. + let mut cut = s.clone(); + cut.holes + .push(Region2 { outer: Loop2::polygon(&[Vec2::new(40.0, 20.0), Vec2::new(38.0, 18.0), Vec2::new(42.0, 18.0)]).ccw(), holes: vec![] }); + assert!((a0 - flat_area(&cut) - 4.0).abs() < 1e-6, "{deg}°, on the edge: flat area {}", a0 - flat_area(&cut)); + let body = cut.solid().unwrap_or_else(|e| panic!("{deg}°, on the edge: {e}")); + assert!(body.validity().is_empty() && body.shell_count() == 1, "{deg}°, on the edge: {:?}", body.validity()); + assert!((v0 - vol(&body) - 8.0).abs() < 0.01, "{deg}°, on the edge: {} vs 8", v0 - vol(&body)); + } + } + + /// The plate (0, 0) (100, 0) (85, 20) (0, 20) with a 30 mm inside flange on its slanted edge + /// (2 mm sheet, R 2, K 0.5: the folded sheet holds its flat area times the thickness), and a + /// triangle cut out of the flat on one side of the bend line only, with a corner a hair (0.01 + /// or 0.05 µm) from the line, short of it or across: on the base, in the line's middle and + /// near its ends; and at either end of the line, where it meets the plate's edge, on the base + /// and on the bend. The planar booleans leave such a corner as corners a hair apart, on the + /// base and on the bend; joined, they are a corner where the cut-out touches the bend line or + /// the plate's edge. So the refold needs both: without the joining it failed with "sew: a + /// degenerate edge", without the touching sew with "This wire is not simple". + #[test] + fn a_cut_out_on_one_side_of_a_bend_line_with_a_corner_a_hair_from_it_refolds() { + let rule = RuleValues { bend_radius: 2.0, ..alu() }; + let outline = [Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(85.0, 20.0), Vec2::new(0.0, 20.0)]; + for deg in [90.0f64, 120.0] { + let mut s = base_flange("Body1", "AL2", &rule, Plane::XY, Region2 { outer: Loop2::polygon(&outline).ccw(), holes: vec![] }); + s.add_flange(Vec3::new(92.5, 10.0, 2.0), 30.0, deg.to_radians(), 2.0, "inside", false).unwrap(); + let (a0, v0) = (flat_area(&s), vol(&s.solid().unwrap())); + let f = s.flanges[0].clone(); + // The corner at a fraction of the line, the triangle's other corners as steps along the + // line and across it (the bend on its +n side; the base's corner at either end of the + // line is at least 53.13° wide), and the triangle's area. + let cases = [ + ("on the base in the line's middle", 0.5, [(-1.0, -2.0), (1.0, -2.0)], 2.0), + ("on the base near the line's start", 0.16, [(-1.0, -2.0), (1.0, -2.0)], 2.0), + ("on the base near the line's end", 0.84, [(-1.0, -2.0), (1.0, -2.0)], 2.0), + ("on the base at the line's start", 0.0, [(3.0, -2.0), (2.0, -2.0)], 1.0), + ("on the base at the line's end", 1.0, [(-2.0, -2.0), (-3.0, -2.0)], 1.0), + ("on the bend at the line's start", 0.0, [(1.25, 2.0), (0.25, 2.0)], 1.0), + ("on the bend at the line's end", 1.0, [(-0.25, 2.0), (-1.25, 2.0)], 1.0), + ]; + for (what, along, others, area) in cases { + // Across the line toward the bend. + for off in [-5e-8, -1e-8, 1e-8, 5e-8] { + let what = format!("{deg}°, {what}, {off} mm across the line"); + let c = f.p + f.d * (f.len * along) + f.n * off; + let corners: Vec = std::iter::once(c).chain(others.iter().map(|(u, v)| c + f.d * *u + f.n * *v)).collect(); + let mut cut = s.clone(); + cut.holes.push(Region2 { outer: Loop2::polygon(&corners).ccw(), holes: vec![] }); + assert!((a0 - flat_area(&cut) - area).abs() < 1e-5, "{what}: flat area {}", a0 - flat_area(&cut)); + let body = cut.solid().unwrap_or_else(|e| panic!("{what}: {e}")); + assert!(body.validity().is_empty() && body.shell_count() == 1, "{what}: {:?}", body.validity()); + assert!((v0 - vol(&body) - area * 2.0).abs() < 0.01, "{what}: {} vs {}", v0 - vol(&body), area * 2.0); + } + } + } + } + #[test] fn a_tear_holds_at_every_angle() { use std::f64::consts::{FRAC_PI_2, PI}; diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index fe9c31a5..cab51340 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -507,6 +507,139 @@ fn unfold_cut_refold_and_convert() { assert!(t.execute("sheet.convert", &json!({"body": "Block", "face": [50, 30, 2.5]})).is_err()); } +/// A plate (15, -20) (0, 0) (-100, 0) (-100, -20) with a 20 mm inside flange on its slanted edge +/// (2 mm sheet, R 2, K 0.5: the folded sheet holds its flat area times the thickness), unfolded, +/// and a triangle cut out with a corner on the bend line: where the line ends on the plate's +/// edge at (-5, 0), the triangle on the bend or on the base, or in the line's middle. The refold +/// failed ("sew: face 10: This wire is not simple."). +#[test] +fn a_cut_out_with_a_corner_on_a_bend_line_refolds() { + // The base (10, -20) (-5, 0) (-100, 0) (-100, -20): 2050; the bend 25 x 3π/2; the flange 25 x 16. + let area = 2450.0 + 37.5 * PI; + let cases = [ + ([[-5.0, 0.0], [-3.0, -1.0], [-3.0, 1.0]], 2.0), + ([[-5.0, 0.0], [-7.0, -1.0], [-6.0, -3.0]], 2.5), + ([[2.5, -10.0], [2.1, -12.8], [-0.3, -9.6]], 4.0), + ([[2.5, -10.0], [4.7, -9.6], [3.5, -8.0]], 2.0), + ]; + for (triangle, cut) in cases { + let mut s = Session::default(); + run(&mut s, "sheet.manage_rules", json!({"name": "Rule", "active": true, "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "2 mm"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Outline"})); + run(&mut s, "sketch.line", json!({"points": [[15, -20], [0, 0], [-100, 0], [-100, -20]], "closed": true})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sheet.flange", json!({"sketch": "Outline", "rule": "Rule"})); + run(&mut s, "sheet.flange", json!({"edges": [[7.5, -10, 2]], "height": 20, "radius": 2, "position": "inside"})); + // The measure follows the bend by facets. + let folded = volume(&mut s); + assert!(rel(folded, area * 2.0) < 1e-4, "{folded} vs {}", area * 2.0); + run(&mut s, "sheet.unfold", json!({})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cutout"})); + run(&mut s, "sketch.line", json!({"points": triangle, "closed": true})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Cutout", "through_all": true, "direction": "symmetric", "operation": "cut"})); + assert!(rel(volume(&mut s), (area - cut) * 2.0) < 1e-9, "{triangle:?} flat: {}", volume(&mut s)); + run(&mut s, "sheet.refold", json!({})); + let refolded = volume(&mut s); + assert!((folded - refolded - cut * 2.0).abs() < 0.01, "{triangle:?} refolded: {refolded}, {} less", folded - refolded); + } +} + +/// Plates with an inside flange on a slanted edge (2 mm sheet, R 2, K 0.5: the folded sheet +/// holds its flat area times the thickness), unfolded, and a triangle cut out across the bend +/// line with a corner 0.05 µm beside the line, or on it at x = 84.0764335, a border between +/// two cells of the 1e-6 mm grid the sew finds vertices on. The refold failed ("sew: a +/// degenerate edge", "sew: face 13: This wire is not closed."); it loses the triangle. +#[test] +fn a_cut_corner_a_hair_from_a_bend_line_refolds() { + let root820 = 820f64.sqrt(); + let cases = [ + // Base 1750, bend 25 x 3π/2, flange 25 x 26; a triangle of 4 mm². + ( + [[0.0, 0.0], [100.0, 0.0], [85.0, 20.0], [0.0, 20.0]], + [92.5, 10.0, 2.0], + 30.0, + [[87.49999996, 9.99999997], [85.49999996, 7.99999997], [89.49999996, 7.99999997]], + 2400.0 + 37.5 * PI, + 4.0, + ), + // The edge is √820 long; the base loses 4 mm along its normal: 2444 − 4√820; the bend + // √820 x 3π/2, the flange √820 x 36; a triangle of 3 mm². + ( + [[0.0, 0.0], [100.0, 0.0], [88.0, 26.0], [0.0, 26.0]], + [94.0, 13.0, 2.0], + 40.0, + [[84.0764335, 24.9558467078], [82.0764335, 22.9558467078], [86.0764335, 23.9558467078]], + 2444.0 + root820 * (32.0 + 3.0 * PI / 2.0), + 3.0, + ), + ]; + for (outline, edge, height, triangle, area, cut) in cases { + let mut s = Session::default(); + run(&mut s, "sheet.manage_rules", json!({"name": "Rule", "active": true, "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "2 mm"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Outline"})); + run(&mut s, "sketch.line", json!({"points": outline, "closed": true})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sheet.flange", json!({"sketch": "Outline", "rule": "Rule"})); + run(&mut s, "sheet.flange", json!({"edges": [edge], "height": height, "radius": 2, "position": "inside"})); + // The measure follows the bend by facets. + let folded = volume(&mut s); + assert!(rel(folded, area * 2.0) < 1e-4, "{folded} vs {}", area * 2.0); + run(&mut s, "sheet.unfold", json!({})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cutout"})); + run(&mut s, "sketch.line", json!({"points": triangle, "closed": true})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Cutout", "through_all": true, "direction": "symmetric", "operation": "cut"})); + assert!(rel(volume(&mut s), (area - cut) * 2.0) < 1e-9, "flat: {}", volume(&mut s)); + run(&mut s, "sheet.refold", json!({})); + let refolded = volume(&mut s); + assert!((folded - refolded - cut * 2.0).abs() < 0.01, "refolded: {refolded}, {} less", folded - refolded); + } +} + +/// A 100 x 60 plate (2 mm sheet, R 2, K 0.5) with a 42 mm flange (R 1) outside its bottom edge, +/// unfolded, and a notch cut off the flat flange's free corner: an arc from the side edge, +/// through (0.6, 0.6) from the corner, to the free edge, closed by lines through a point outside +/// the sheet. The arc leaves the side edge 1.7997505 mm from the corner, in the folded flange at +/// a height of 42 − 1.7997505 = 40.2002495 mm: a border between two cells of the 1e-6 mm grid the +/// sew finds vertices on, and the arc's end and the edge's, worked out apart, differ by 7e-13 +/// mm. The refold failed ("sew: face 5: This wire is not closed."); it loses the notch. +#[test] +fn a_notch_whose_arc_ends_on_a_grid_border_refolds() { + let t = 1.7997505; + // The flat flange ends at 39 + the bend allowance (π/2)(R + K T) = 39 + π below the plate. + let free = -(42.0 - 1.0 - 2.0 + FRAC_PI_2 * (1.0 + 0.5 * 2.0)); + let flat = 6000.0 + 100.0 * PI + 100.0 * 39.0; + // The notch is the triangle corner, end, end less the circle's segment over its chord; the + // circle's centre (a, a) from the corner is as far from (0.6, 0.6) as from the arc's ends. + let a = (t * t - 0.72) / (2.0 * t - 2.4); + let r2 = 2.0 * (a - 0.6) * (a - 0.6); + let angle = 2.0 * (t * 2f64.sqrt() / (2.0 * r2.sqrt())).asin(); + let notch = t * t / 2.0 - r2 / 2.0 * (angle - angle.sin()); + assert!((notch - 0.88421).abs() < 1e-5, "{notch}"); + let mut s = Session::default(); + run(&mut s, "sheet.manage_rules", json!({"name": "Rule", "active": true, "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "2 mm"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Outline"})); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [100, 0], [100, 60], [0, 60]], "closed": true})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sheet.flange", json!({"sketch": "Outline", "rule": "Rule"})); + run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2]], "height": 42, "radius": 1, "position": "outside"})); + // The measure follows the bend by facets. + let folded = volume(&mut s); + assert!(rel(folded, flat * 2.0) < 1e-4, "{folded} vs {}", flat * 2.0); + run(&mut s, "sheet.unfold", json!({})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cutout"})); + run(&mut s, "sketch.arc.three_point", json!({"start": [t, free], "through": [0.6, free + 0.6], "end": [0.0, free + t]})); + run(&mut s, "sketch.line", json!({"points": [[0.0, free + t], [-1.0, -44.0]]})); + run(&mut s, "sketch.line", json!({"points": [[-1.0, -44.0], [t, free]]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Cutout", "through_all": true, "direction": "symmetric", "operation": "cut"})); + assert!(rel(volume(&mut s), (flat - notch) * 2.0) < 1e-6, "flat: {}", volume(&mut s)); + run(&mut s, "sheet.refold", json!({})); + let refolded = volume(&mut s); + assert!((folded - refolded - notch * 2.0).abs() < 0.01, "refolded: {refolded}, {} less", folded - refolded); +} + /// A sketch line across a 100 x 60 plate's top face at x = 40. fn fold_line(s: &mut Session) { run(s, "sketch.create", json!({"plane": "XY", "offset": 2.5, "name": "FoldLine"})); diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index adae29be..8743ea4d 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -122,7 +122,7 @@ pub use polybool::{faceted_boolean, planar_boolean}; pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell}; pub use prism::{PLANAR_FIT_TOL, extrude_planar_regions, planar_regions_boolean}; pub use prism_measure::certified_prism_equivalent; -pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew}; +pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew, sew_touching}; pub use splitface::{SplitTool, split_body, split_faces, split_faces_with_map}; pub use step::step_export; pub use step_in::{ImportedBody, StepImport, StepNode, step_cache_alive, step_import, step_import_shared, step_orientation_errors, step_validate}; diff --git a/crates/kernel/src/sew.rs b/crates/kernel/src/sew.rs index d3c247f3..441e9543 100644 --- a/crates/kernel/src/sew.rs +++ b/crates/kernel/src/sew.rs @@ -47,6 +47,24 @@ impl EdgeSpec { } } + /// The way the edge leaves its start (`None` when that cannot be told). + fn start_dir(&self) -> Option { + let d = match self { + EdgeSpec::Line { a, b } => *b - *a, + // The circle through a, mid and b, inverted about a, is the line through the images + // of mid and b, which runs along the circle's tangent at a. + EdgeSpec::Arc { a, b, mid } => { + let (m, e) = (*mid - *a, *b - *a); + m * (1.0 / m.len2()) - e * (1.0 / e.len2()) + } + EdgeSpec::Chain { ctrl } => { + let first = *ctrl.first()?; + ctrl.iter().skip(1).map(|p| *p - first).find(|d| d.len() > 1e-12)? + } + }; + d.normalized() + } + /// The same edge, run the other way. pub fn reversed(&self) -> EdgeSpec { match self { @@ -108,6 +126,81 @@ fn key(p: Vec3) -> Key { (q(p.x), q(p.y), q(p.z)) } +/// How near (mm) two edge ends weld into one vertex wherever they fall on the grid of [`key`]. +/// The grid alone joins the ends in one of its cells, but not two ends a hair apart on either +/// side of a cell's border: a corner at x = 84.0764335 lies on a border, and the same corner +/// worked out again through a bend can land 1e-14 mm to the other side, leaving its face's loop +/// open. The planar booleans close loops over gaps up to 1e-6 mm, so the weld reaches as far. +const WELD: f64 = 1e-6; + +/// The faces with every edge end within [`WELD`] of an end met earlier in a neighbouring cell of +/// the grid moved onto that end, so that [`key`] finds one vertex for both; `None` when no end +/// moves. Ends in one cell stay as they are (the grid already joins them). +fn welded(faces: &[FaceSpec]) -> Option> { + // The end each cell's ends weld to: the first met in it, or a nearer one in a neighbour. + let mut at: HashMap = HashMap::new(); + let mut moves = false; + for f in faces { + // A ruled surface's chains end on the vertices of its boundary. + let ruled: Vec<&EdgeSpec> = match &f.surface { + SurfSpec::Ruled { a, b } => vec![a, b], + _ => Vec::new(), + }; + for e in f.loops.iter().flatten().chain(ruled) { + let (a, b) = e.ends(); + for p in [a, b] { + let k = key(p); + if !p.is_finite() || at.contains_key(&k) { + continue; + } + let mut near = None; + for (dx, dy, dz) in (-1i64..=1).flat_map(|x| (-1i64..=1).flat_map(move |y| (-1i64..=1).map(move |z| (x, y, z)))) { + let n = (k.0.saturating_add(dx), k.1.saturating_add(dy), k.2.saturating_add(dz)); + if n != k + && let Some(q) = at.get(&n).filter(|q| q.dist(p) < WELD) + { + near = Some(*q); + break; + } + } + moves |= near.is_some(); + at.insert(k, near.unwrap_or(p)); + } + } + } + if !moves { + return None; + } + let weld = |p: Vec3| if p.is_finite() { at.get(&key(p)).copied().filter(|q| key(*q) != key(p)).unwrap_or(p) } else { p }; + let edge = |e: &EdgeSpec| match e { + EdgeSpec::Line { a, b } => EdgeSpec::Line { a: weld(*a), b: weld(*b) }, + EdgeSpec::Arc { a, b, mid } => EdgeSpec::Arc { a: weld(*a), b: weld(*b), mid: *mid }, + EdgeSpec::Chain { ctrl } => { + let mut ctrl = ctrl.to_vec(); + let n = ctrl.len(); + for i in [0, n.saturating_sub(1)] { + if let Some(p) = ctrl.get_mut(i) { + *p = weld(*p); + } + } + EdgeSpec::Chain { ctrl: ctrl.into() } + } + }; + Some( + faces + .iter() + .map(|f| FaceSpec { + loops: f.loops.iter().map(|l| l.iter().map(edge).collect()).collect(), + surface: match &f.surface { + SurfSpec::Ruled { a, b } => SurfSpec::Ruled { a: edge(a), b: edge(b) }, + s => s.clone(), + }, + probe: f.probe, + }) + .collect(), + ) +} + #[derive(Default)] struct Cache { /// Vertices by point and fan (see [`corner_fans`]). @@ -172,6 +265,14 @@ pub fn sew(faces: &[FaceSpec]) -> Result { sew_impl(faces, false, None).map(|(body, _)| body) } +/// Sew the faces into a closed solid that may touch itself: where four faces meet along a +/// straight edge (a cut-out's corner on the outline of a plate), each two of them get an edge of +/// their own (see [`touching_sheets`]), and where faces meet at a point in fans joined by no edge +/// (a hole's corner on its face's outline), each fan gets a vertex of its own. +pub fn sew_touching(faces: &[FaceSpec]) -> Result { + sew_impl(faces, true, None).map(|(body, _)| body) +} + /// Separate boundary sheets at a shared geometric edge without moving it. /// Optional transient sources follow only unchanged produced face identities. /// Also reports the face each face of the body was built from (by its place in `faces`; `None` @@ -191,8 +292,8 @@ const SAME_WAY: f64 = 1e-6; /// Where more than two faces meet along a straight edge (bodies touching along a line, or a wall /// of no thickness), which faces bound each sheet of it (each sheet gets its own edge), and the -/// vertex fans that follow from that (see [`corner_fans`]). Only edges on planes and on the walls -/// of extruded arcs are looked at. +/// vertex fans that follow from that (see [`corner_fans`]). Only edges on planes, on the walls +/// of extruded arcs and on ruled walls are looked at. /// /// Each sheet bounds a wedge of material, so that pieces of material meeting along an edge stay /// apart, except where that would make a face touch itself: a hole touching the outline from @@ -204,7 +305,7 @@ const SAME_WAY: f64 = 1e-6; fn touching_sheets(faces: &[FaceSpec]) -> Result<(HashMap, HashMap)> { let mut uses: HashMap<(Key, Key), Vec<(EdgeUse, Vec3, Vec3)>> = HashMap::new(); for (fi, face) in faces.iter().enumerate() { - if !matches!(face.surface, SurfSpec::Plane | SurfSpec::Extruded { .. }) { + if !matches!(face.surface, SurfSpec::Plane | SurfSpec::Extruded { .. } | SurfSpec::Ruled { .. }) { continue; } for (li, lp) in face.loops.iter().enumerate() { @@ -289,7 +390,7 @@ fn edge_sheets(faces: &[FaceSpec], group: &[(EdgeUse, Vec3, Vec3)]) -> Result<[[ let mut reference = None; for (u, a, b) in group { let face = faces.get(u.0).ok_or_else(|| fail("edge contact"))?; - let (into, turn, ccw) = leaving(face, *a, *b, t).ok_or_else(|| fail("unsupported edge contact"))?; + let (into, turn, ccw) = leaving(face, *u, *a, *b, t).ok_or_else(|| fail("unsupported edge contact"))?; let r: Vec3 = *reference.get_or_insert(into); let angle = r.cross(into).dot(t).atan2(r.dot(into)).rem_euclid(std::f64::consts::TAU); rays.push((*u, key(*a), turn, ccw, if angle > std::f64::consts::TAU - SAME_WAY { 0.0 } else { angle })); @@ -328,12 +429,12 @@ fn edge_sheets(faces: &[FaceSpec], group: &[(EdgeUse, Vec3, Vec3)]) -> Result<[[ Ok([pairs(true)?, pairs(false)?]) } -/// How a face leaves the straight edge `a`→`b` it bounds, seen along the edge's direction `t`: -/// the way into the face square to the edge, how fast it turns from that way (its curvature, -/// positive counter-clockwise about `t`; 0 for a plane), and whether its material is on its -/// counter-clockwise side. None for other faces, and for an extruded wall the edge is not a -/// ruling of. -fn leaving(face: &FaceSpec, a: Vec3, b: Vec3, t: Vec3) -> Option<(Vec3, f64, bool)> { +/// How a face leaves the straight edge `a`→`b` it bounds (its use `u`), seen along the edge's +/// direction `t`: the way into the face square to the edge, how fast it turns from that way (its +/// curvature, positive counter-clockwise about `t`; 0 for a plane or a ruled wall), and whether +/// its material is on its counter-clockwise side. None for other faces, and for an extruded wall +/// the edge is not a ruling of. +fn leaving(face: &FaceSpec, u: EdgeUse, a: Vec3, b: Vec3, t: Vec3) -> Option<(Vec3, f64, bool)> { let along = (b - a).normalized()?; // The outward normal along the edge, and the centre of curvature (with its curvature). let (outward, centre) = match &face.surface { @@ -353,6 +454,15 @@ fn leaving(face: &FaceSpec, a: Vec3, b: Vec3, t: Vec3) -> Option<(Vec3, f64, boo let side = if radial(p).dot(out) < 0.0 { -1.0 } else { 1.0 }; (r.normalized()? * side, Some((centre, 1.0 / r.len()))) } + // A ruled wall (a cut-out's wall across a bend, ruled between its traces on the bend's + // two faces), standing on the edge: its normal there is square to the edge and to the way + // the next edge leaves the edge's end into the wall, as a wall's top or bottom edge leaves + // its upright one. + SurfSpec::Ruled { .. } => { + let lp = face.loops.get(u.1)?; + let v = lp.get((u.2 + 1) % lp.len())?.start_dir()?; + (along.cross(v - along * v.dot(along)).normalized()?, None) + } _ => return None, }; // A face's loops run counter-clockwise about its outward normal: it lies on their left. @@ -650,6 +760,12 @@ fn sew_impl(faces: &[FaceSpec], touching: bool, sources: Option<&[Option 500_000 { return Err(KernelError::Invalid("sew: edge budget exceeded".into())); } + } + } + let welded = welded(faces); + let faces = welded.as_deref().unwrap_or(faces); + for f in faces { + for l in &f.loops { for e in l { let (a, b) = e.ends(); // Ends that weld to one vertex would make truck panic on a zero-length edge (#268). @@ -937,6 +1053,32 @@ mod tests { assert!(sew(&faces[..5]).is_err()); } + /// A 2.0000005 x 3 x 4 box: x = 2.0000005 mm is a border between two cells of the 1e-6 mm + /// grid vertices are found on. The bottom face's edges meet at its corner (x, 3, 0) 2e-9 mm + /// to either side of the border, as an arc's end and the end of the line it meets can: the + /// ends weld and the box closes. Ends 3e-6 mm apart stay apart. + #[test] + fn edge_ends_a_hair_apart_across_a_grid_border_weld() { + let x = 2.0000005; + let p = |x: f64, y: f64, z: f64| Vec3::new(x, y, z); + let (a, b, c, d) = (p(0., 0., 0.), p(x, 0., 0.), p(x, 3., 0.), p(0., 3., 0.)); + let (e, f, g, h) = (p(0., 0., 4.), p(x, 0., 4.), p(x, 3., 4.), p(0., 3., 4.)); + let line = |a, b| EdgeSpec::Line { a, b }; + let faces = |into: Vec3, from: Vec3| { + let bottom = FaceSpec { loops: vec![vec![line(a, d), line(d, into), line(from, b), line(b, a)]], surface: SurfSpec::Plane, probe: None }; + vec![bottom, quad(e, f, g, h), quad(a, b, f, e), quad(b, c, g, f), quad(c, d, h, g), quad(d, a, e, h)] + }; + let (lo, hi) = (p(x - 2e-9, 3., 0.), p(x + 2e-9, 3., 0.)); + assert_ne!(key(lo), key(hi)); + for (into, from) in [(lo, hi), (hi, lo), (lo, c), (hi, c)] { + let body = sew(&faces(into, from)).unwrap_or_else(|e| panic!("{into:?} {from:?}: {e}")); + assert!(body.validity().is_empty(), "{:?}", body.validity()); + let v = crate::measure(&body).unwrap().volume; + assert!((v - x * 12.0).abs() < 1e-6, "{v}"); + } + assert!(sew(&faces(p(x - 2e-6, 3., 0.), p(x + 1e-6, 3., 0.))).is_err()); + } + /// A 4 × 3 × 2 box whose +x wall bulges out as the cubic (4,0) (5,0) (5,3) (4,3), given as a /// chain of its two halves at the bottom (`z = 0`) and the top, the wall ruled between them. fn bulged_box(bottom: EdgeSpec, top: EdgeSpec) -> Vec {