diff --git a/crates/doc/src/sheet.rs b/crates/doc/src/sheet.rs index 5e7751e5..52e39a94 100644 --- a/crates/doc/src/sheet.rs +++ b/crates/doc/src/sheet.rs @@ -2250,4 +2250,37 @@ mod tests { s.pockets.push(pocket.clone()); assert!(s.record_pocket(pocket.region, 0.0, 0.5, &mut work).is_err()); } + + /// A sketch solved again after a parameter change leaves its corners off by ~1e-13 mm, so + /// its edges are not quite square to the axes. Such a tray (four 72° flanges, shrunk to + /// 400 x 300 from a larger size) failed to unfold in the planar arrangement while the same + /// tray drawn at that size unfolded. + #[test] + fn nearly_square_base_unfolds_like_the_exact_one() { + let rv = RuleValues { thickness: 2.0, k_factor: 0.5, bend_radius: 1.0, ..steel() }; + let tray = |[a, b, c, d]: [Vec2; 4]| { + let mut s = base_flange("Body1", "AL2", &rv, Plane::XY, rect(a, b, c, d)); + let picks = [Vec3::new(200.0, 0.0, 2.0), Vec3::new(400.0, 150.0, 2.0), Vec3::new(200.0, 300.0, 2.0), Vec3::new(0.0, 150.0, 2.0)]; + s.add_flanges(&picks, 38.6, 72f64.to_radians(), 1.0, "inside", false).unwrap(); + s + }; + let flat_area = |s: &SheetBody| s.flat_regions().unwrap().iter().map(|r| r.area().abs()).sum::(); + let square = [Vec2::new(0.0, 0.0), Vec2::new(400.0, 0.0), Vec2::new(400.0, 300.0), Vec2::new(0.0, 300.0)]; + let exact = flat_area(&tray(square)); + // The corners the solver left after the parameter change, and the rectangle turned by a hair. + let solved = [ + Vec2::new(0.0, 0.0), + Vec2::new(400.0000000000001, 4.369543929613394e-13), + Vec2::new(400.00000000000017, 300.0000000000008), + Vec2::new(0.0, 300.0000000000009), + ]; + let (c, s) = (1e-14f64.cos(), 1e-14f64.sin()); + let turned = square.map(|p| Vec2::new(p.x * c - p.y * s, p.x * s + p.y * c)); + for corners in [solved, turned] { + let mut sheet = tray(corners); + assert!((flat_area(&sheet) - exact).abs() < 1e-6, "{} {exact}", flat_area(&sheet)); + sheet.flat = true; + assert!((vol(&sheet.solid().unwrap()) - exact * 2.0).abs() < 1e-3); + } + } } diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index 7326203a..5b1ac063 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -702,6 +702,45 @@ fn fold_keeps_the_larger_side_either_way() { } } +/// A tray shrunk by a parameter change unfolds like the same tray built at that size. The +/// sketch solved again leaves its corners ~1e-13 mm off, and the flat pattern's planar +/// arrangement used to drop a bend zone through a gap that small: "planar region arrangement +/// failed", for W alone or both sizes, at 72° or 90°, while growing the tray worked. +#[test] +fn tray_shrunk_by_a_parameter_unfolds_like_one_built_at_that_size() { + for angle in [72.0, 90.0] { + let tray = |s: &mut Session, w: f64, h: f64| { + run(s, "parameters.add", json!({"name": "W", "expression": format!("{w} mm")})); + run(s, "parameters.add", json!({"name": "H", "expression": format!("{h} mm")})); + run(s, "sheet.manage_rules", json!({"name": "AL2", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true})); + run(s, "sketch.create", json!({"plane": "XY", "name": "Tray"})); + run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [w, h]})); + run(s, "sketch.constraint.fix", json!({"entities": ["p1"]})); + run(s, "sketch.dimension", json!({"entities": ["l1"], "value": "W"})); + run(s, "sketch.dimension", json!({"entities": ["l2"], "value": "H"})); + run(s, "sketch.finish", json!({})); + run(s, "sheet.flange", json!({"type": "base", "sketch": "Tray"})); + let edges = json!([[w / 2.0, 0, 2], [w, h / 2.0, 2], [w / 2.0, h, 2], [0, h / 2.0, 2]]); + run(s, "sheet.flange", json!({"type": "edge", "edges": edges, "height": 38.6, "angle": angle})); + run(s, "sheet.unfold", json!({})); + }; + let mut direct = Session::default(); + tray(&mut direct, 400.0, 300.0); + let want = flat(&mut direct)["flat_size_mm"].clone(); + let mut shrunk = Session::default(); + tray(&mut shrunk, 2000.0, 2500.0); + for (name, expr) in [("W", "400 mm"), ("H", "300 mm")] { + let r = run(&mut shrunk, "parameters.change", json!({"name": name, "expression": expr})); + assert_eq!(r["errors"], json!([]), "{angle}° {name} = {expr}: {r}"); + } + let got = flat(&mut shrunk)["flat_size_mm"].clone(); + for k in 0..3 { + assert!((got[k].as_f64().unwrap() - want[k].as_f64().unwrap()).abs() < 1e-9, "{angle}°: {got} {want}"); + } + assert!(rel(volume(&mut shrunk), volume(&mut direct)) < 1e-9, "{angle}°"); + } +} + #[test] fn bend_table_exports_shop_dimensions_and_tracks_parameter_changes() { let mut s = Session::default(); diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs index 9711cf0d..61c9b82a 100644 --- a/crates/kernel/src/prism.rs +++ b/crates/kernel/src/prism.rs @@ -737,9 +737,21 @@ fn crossings(s: &Seg2, t: &Seg2, tol: f64) -> Vec<(f64, f64)> { /// Ray parity against analytic lines/arcs. Fragment classification must not depend on a /// display chord tolerance: a tiny tool can sit between a circular boundary and its chords. +/// Loops are closed only within the chaining tolerance (an arrangement's own output joins its +/// pieces within `10 · tol`), so a gap where one line ends and the next begins is closed by a +/// straight connector: a ray through the gap would otherwise slip out of the loop without a +/// crossing and flip the parity of points level with the corner. Arcs decide their own ends +/// within an angular tolerance (below), so joints with an arc keep that rule. fn inside_loop(lp: &Loop2, p: Vec2) -> bool { let mut odd = false; - for seg in &lp.segs { + let nexts = lp.segs.iter().skip(1).chain(lp.segs.first()); + for (seg, next) in lp.segs.iter().zip(nexts) { + if let (Seg2::Line { b: a, .. }, Seg2::Line { a: b, .. }) = (*seg, *next) + && (a.y > p.y) != (b.y > p.y) + && a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y) > p.x + { + odd = !odd; + } match *seg { Seg2::Line { a, b } => { if (a.y > p.y) != (b.y > p.y) { @@ -2148,6 +2160,36 @@ mod planar_tests { assert!(!inside_loop(&lp, Vec2::new(-5.0, -10.0))); } } + + /// Loops are closed only within the chaining tolerance. A ray level with one end of such a + /// gap slipped through it without a crossing, so a point beside the loop counted as inside: + /// a sheet shrunk by a parameter change (sketch solver residuals of 1e-13 mm) lost a bend + /// zone that way and its unfold failed. + #[test] + fn ray_parity_closes_the_gaps_chaining_leaves() { + let gap = 1e-13; + let square = Loop2 { + segs: vec![ + Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(10.0, 0.0) }, + Seg2::Line { a: Vec2::new(10.0, 0.0), b: Vec2::new(10.0, 10.0) }, + Seg2::Line { a: Vec2::new(10.0, 10.0), b: Vec2::new(0.0, 10.0) }, + Seg2::Line { a: Vec2::new(0.0, 10.0), b: Vec2::new(0.0, 5.0 + gap) }, + Seg2::Line { a: Vec2::new(0.0, 5.0), b: Vec2::new(0.0, 0.0) }, + ], + }; + for lp in [square.clone(), square.reversed()] { + assert!(!inside_loop(&lp, Vec2::new(-5.0, 5.0))); + assert!(inside_loop(&lp, Vec2::new(5.0, 5.0))); + assert!(!inside_loop(&lp, Vec2::new(15.0, 5.0))); + } + // A neighbour with an edge level with the gap joins the square instead of being + // dropped as if it lay inside it (which left the outline open: no result at all). + let a = Region2 { outer: square, holes: vec![] }; + let b = rectangle(Vec2::new(-4.0, 2.0), Vec2::new(0.0, 5.0)); + let union = planar_regions_boolean(&[a], &[b], BoolOp::Union, 1e-7).unwrap(); + assert_eq!(union.len(), 1); + assert!((union[0].area().abs() - 112.0).abs() < 1e-9, "{}", union[0].area()); + } #[test] fn tiny_tool_between_circle_and_display_chord_is_not_lost() { let circle = Region2 { outer: Loop2::circle(Vec2::ZERO, 10.0), holes: vec![] };