From 1b029256b7afaf03b6812fbcde0c54a29e38a425 Mon Sep 17 00:00:00 2001 From: blackboots <47943405+aka-blackboots@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:41:34 +0100 Subject: [PATCH] Split shallow crossings in planar region booleans segment_cross_t2 called two segments parallel when |cross(r, s)| < eps, which compares an area with a length. For 0.1 m edges that is every angle under 1e-4 rad. Two wall pieces that meet end to end at a 0.003 degree bend have mitre faces that cross at the junction about 5e-5 rad apart, so neither face was split at the crossing, one half of each face was classed as boundary and the other half was not, and the boundary walk failed with "Boolean regions produced an open boundary". floorkit unites same-material plan pieces with booleanRegions2D, so one such junction blanked a whole storey's plan. Segments are now parallel only when the ends of each spread across the other's line by at most eps: |cross(r, s)| <= eps * min(|r|, |s|). The Rust and node tests take the two regions floorkit sent for the failing storey. Both failed with OpenBoundary before this change. Signed-off-by: blackboots <47943405+aka-blackboots@users.noreply.github.com> --- .../tests/node/regions.test.mjs | 26 +++++++++++++++++++ main/opengeometry/src/geom2d/poly2d.rs | 2 +- .../src/geom2d/tests/boolean2d.rs | 25 ++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/main/opengeometry-three/tests/node/regions.test.mjs b/main/opengeometry-three/tests/node/regions.test.mjs index 9b713251..8f1d7619 100644 --- a/main/opengeometry-three/tests/node/regions.test.mjs +++ b/main/opengeometry-three/tests/node/regions.test.mjs @@ -126,6 +126,32 @@ test('a boundary that cannot be closed is InvalidTopology with the 2.0 text', () ); }); +test('two wall pieces whose mitre faces cross at a 0.003 degree bend unite to one region', () => { + const ring = (points) => points.map(([x, z]) => ({ x, y: 0, z })); + const first = { + outer: ring([ + [-7.915506291175921, -2.642064756497319], [-7.840493708824079, -2.575935243502681], + [-5.360466317231342, -5.389100659702814], [-5.435482107488363, -5.455226533879618], + [-7.915504659116948, -2.642066607788105], + ]), + holes: [], + }; + const second = { + outer: ring([ + [-8.973504659071258, -1.442066607747827], [-8.898495340928742, -1.375933392252173], + [-7.840495340928741, -2.575933392252173], [-7.915504659071259, -2.642066607747827], + [-7.915506291221608, -2.642064756537601], + ]), + holes: [], + }; + const united = OpenGeometry.booleanRegions2D([first], [second], 'union'); + const overlap = area(OpenGeometry.booleanRegions2D([first], [second], 'intersection')); + const apart = Math.abs(signedArea(first.outer)) + Math.abs(signedArea(second.outer)); + assert.equal(united.length, 1); + assert(overlap > 0 && overlap < 1e-7, `overlap ${overlap}`); + assert(Math.abs(area(united) + overlap - apart) < 1e-9); +}); + test('the curved boolean keeps an island in the hole of a frame apart in both operand orders', () => { const frame = curvedRegion(0, 0, 10, 10, [1, 1, 9, 9]); const island = curvedRegion(3, 3, 7, 7, [4, 4, 6, 6]); diff --git a/main/opengeometry/src/geom2d/poly2d.rs b/main/opengeometry/src/geom2d/poly2d.rs index ba2cefaa..065c1037 100644 --- a/main/opengeometry/src/geom2d/poly2d.rs +++ b/main/opengeometry/src/geom2d/poly2d.rs @@ -127,7 +127,7 @@ pub(super) fn segment_cross_t2( let r = Pt2::new(a2.x - a1.x, a2.z - a1.z); let s = Pt2::new(b2.x - b1.x, b2.z - b1.z); let denom = cross2(r, s); - if denom.abs() < eps { + if denom.abs() <= eps * r.x.hypot(r.z).min(s.x.hypot(s.z)) { return None; } let qp = Pt2::new(b1.x - a1.x, b1.z - a1.z); diff --git a/main/opengeometry/src/geom2d/tests/boolean2d.rs b/main/opengeometry/src/geom2d/tests/boolean2d.rs index 06109028..0bb34cd0 100644 --- a/main/opengeometry/src/geom2d/tests/boolean2d.rs +++ b/main/opengeometry/src/geom2d/tests/boolean2d.rs @@ -309,3 +309,28 @@ fn a_hole_that_no_region_contains_is_an_error() { Some(RegionBooleanError::OrphanHole) ); } + +#[test] +fn two_near_collinear_wall_pieces_unite_across_their_mitre() { + let first = vec![ + p(-7.915506291175921, -2.642064756497319), + p(-7.840493708824079, -2.575935243502681), + p(-5.360466317231342, -5.389100659702814), + p(-5.435482107488363, -5.455226533879618), + p(-7.915504659116948, -2.642066607788105), + ]; + let second = vec![ + p(-8.973504659071258, -1.442066607747827), + p(-8.898495340928742, -1.375933392252173), + p(-7.840495340928741, -2.575933392252173), + p(-7.915504659071259, -2.642066607747827), + p(-7.915506291221608, -2.642064756537601), + ]; + let (a, b) = (vec![first], vec![second]); + let [(united, union_area), (_, overlap), (_, remainder)] = areas_of_all_operations(&a, &b); + let (first_area, second_area) = (signed_area2(&a[0]).abs(), signed_area2(&b[0]).abs()); + assert_eq!(united, 1); + assert!(overlap > 0.0 && overlap < 1.0e-7, "overlap {overlap}"); + assert!((union_area + overlap - first_area - second_area).abs() < 1.0e-9); + assert!((remainder + overlap - first_area).abs() < 1.0e-9); +}