From 363f65ff2c6684c3e610785ee41fda1d37593eab Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sun, 5 Apr 2026 09:37:39 -0500 Subject: [PATCH] Fix #3742: Improve coplanar boundary removal for complex wall configurations Extends the SVG projection line deduplication introduced earlier with several new cases: **Containment adjacency (inner element inside outer)** - The original shared-vertex check failed when an inner element's corners lie on edges of the outer element rather than at corner vertices. - Added vertex-to-edge proximity check using point-to-segment distance. - When one AABB is fully contained within another, skip the dominant-face normal check: a short/flat inner element's largest polygon is its top/bottom face, giving a spurious perpendicular normal. **Anti-parallel normals (mirrored wall faces)** - Two adjacent wall sections can have opposite face normals (+Y vs -Y) while still being on the same plane. The original `abs(dot)` check correctly identifies them as parallel, but projecting onto each object's own normal produced mirrored plane-position values that never matched. - Fixed by projecting both objects' vertices onto the same reference normal (n_a) when computing face-plane positions. **Parallel offset walls (false positive rejection)** - Two walls with identical orientations but on different parallel planes (e.g., offset walls meeting at a corner) were incorrectly accepted by the normal-direction check alone. - Added face-plane position check: after confirming normals are parallel, verify at least one face-plane position of A (projected onto the shared normal) matches one of B within tolerance. **L-shaped wall split segments (collinear overlap)** - An L-shaped wall's boundary at a shared interface can be split into multiple sub-segments (one per notch side), while the adjacent simple wall has a single full-length segment covering the same edge. - Exact endpoint matching always failed in this case. - Extended `lines_match` with a collinear-overlap check: axis-aligned segments on the same line with overlapping extents (beyond a trivial point touch) are now treated as the same physical edge. Fix #3742: Improve coplanar boundary removal for complex wall configurations Extends the SVG projection line deduplication introduced earlier with several new cases: **Containment adjacency (inner element inside outer)** - The original shared-vertex check failed when an inner element's corners lie on edges of the outer element rather than at corner vertices. - Added vertex-to-edge proximity check using point-to-segment distance. - When one AABB is fully contained within another, skip the dominant-face normal check: a short/flat inner element's largest polygon is its top/bottom face, giving a spurious perpendicular normal. **Anti-parallel normals (mirrored wall faces)** - Two adjacent wall sections can have opposite face normals (+Y vs -Y) while still being on the same plane. The original `abs(dot)` check correctly identifies them as parallel, but projecting onto each object's own normal produced mirrored plane-position values that never matched. - Fixed by projecting both objects' vertices onto the same reference normal (n_a) when computing face-plane positions. **Parallel offset walls (false positive rejection)** - Two walls with identical orientations but on different parallel planes (e.g., offset walls meeting at a corner) were incorrectly accepted by the normal-direction check alone. - Added face-plane position check: after confirming normals are parallel, verify at least one face-plane position of A (projected onto the shared normal) matches one of B within tolerance. **L-shaped wall split segments (collinear overlap)** - An L-shaped wall's boundary at a shared interface can be split into multiple sub-segments (one per notch side), while the adjacent simple wall has a single full-length segment covering the same edge. - Exact endpoint matching always failed in this case. - Extended `lines_match` with a collinear-overlap check: axis-aligned segments on the same line with overlapping extents (beyond a trivial point touch) are now treated as the same physical edge. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/drawing/operator.py | 154 ++++++++++++++++-- 1 file changed, 143 insertions(+), 11 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index ebb505a4fa..5ecd4fd445 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -1668,21 +1668,82 @@ class CreateDrawing(bpy.types.Operator): # Quick AABB guard corners_a = [obj_a.matrix_world @ Vector(c) for c in obj_a.bound_box] corners_b = [obj_b.matrix_world @ Vector(c) for c in obj_b.bound_box] + _verbose = {"1eW6OACmXD1hWJLDeB6erW", "2x0$XNlFH6FxlvS7eYJttx"} == {guid_a, guid_b} for axis in range(3): - if min(c[axis] for c in corners_a) > max(c[axis] for c in corners_b) + tol: + min_a = min(c[axis] for c in corners_a) + max_a = max(c[axis] for c in corners_a) + min_b = min(c[axis] for c in corners_b) + max_b = max(c[axis] for c in corners_b) + if _verbose: + print(f"[TARGET PAIR] axis={axis} {guid_a}=[{min_a:.4f},{max_a:.4f}] {guid_b}=[{min_b:.4f},{max_b:.4f}]") + if min_a > max_b + tol: + if _verbose: + print(f"[TARGET PAIR] AABB separated on axis {axis} (min_a={min_a:.4f} > max_b={max_b:.4f})") adjacency_cache[key] = False return False - if min(c[axis] for c in corners_b) > max(c[axis] for c in corners_a) + tol: + if min_b > max_a + tol: + if _verbose: + print(f"[TARGET PAIR] AABB separated on axis {axis} (min_b={min_b:.4f} > max_a={max_a:.4f})") adjacency_cache[key] = False return False - # Shared vertex check + # Proximity check: vertex-to-vertex OR vertex-to-edge. + # Vertex-to-vertex handles adjacent elements sharing a corner. + # Vertex-to-edge handles containment (inner element's corners lie + # on edges of outer element, never at its corner vertices). tol_sq = tol * tol + + def point_to_seg_dist_sq(p, a, b): + ab = b - a + len_sq = ab.length_squared + if len_sq < 1e-12: + return (p - a).length_squared + t = max(0.0, min(1.0, (p - a).dot(ab) / len_sq)) + return (p - (a + t * ab)).length_squared + + def vertex_near_edges(verts, obj, tol_sq): + mat = obj.matrix_world + for v in verts: + for edge in obj.data.edges: + v0 = mat @ obj.data.vertices[edge.vertices[0]].co + v1 = mat @ obj.data.vertices[edge.vertices[1]].co + if point_to_seg_dist_sq(v, v0, v1) < tol_sq: + return True + return False + verts_a = [obj_a.matrix_world @ v.co for v in obj_a.data.vertices] verts_b = [obj_b.matrix_world @ v.co for v in obj_b.data.vertices] + # Fast vertex-vertex check first has_shared = any((va - vb).length_squared < tol_sq for va in verts_a for vb in verts_b) + if _verbose and not has_shared: + # Find closest vertex pair for diagnostic output + closest = min(((va - vb).length, ia, ib) for ia, va in enumerate(verts_a) for ib, vb in enumerate(verts_b)) + print(f"[TARGET PAIR] no v-v match, closest vertex gap={closest[0]:.6f}m") + if not has_shared: + # Slower vertex-on-edge check for containment cases + has_shared = vertex_near_edges(verts_b, obj_a, tol_sq) or vertex_near_edges(verts_a, obj_b, tol_sq) + if _verbose: + print(f"[TARGET PAIR] vertex-on-edge result={has_shared}") + if _verbose: + print(f"[TARGET PAIR] proximity_check={has_shared}") if not has_shared: adjacency_cache[key] = False return False + # Containment check: if one AABB fully contains the other, the + # dominant-normal test is unreliable (a flat inner element's largest + # face is its top/bottom, not its side face). Skip normal check. + def aabb_contains(outer, inner): + for axis in range(3): + if min(c[axis] for c in inner) < min(c[axis] for c in outer) - tol: + return False + if max(c[axis] for c in inner) > max(c[axis] for c in outer) + tol: + return False + return True + + if aabb_contains(corners_a, corners_b) or aabb_contains(corners_b, corners_a): + if _verbose: + print(f"[TARGET PAIR] AABB containment detected, skipping normal check") + adjacency_cache[key] = True + return True # Coplanarity check: use the largest-face normal for each object. # Area-weighted averages fail for slabs because top/bottom faces cancel. def dominant_world_normal(obj): @@ -1697,9 +1758,24 @@ class CreateDrawing(bpy.types.Operator): if n_a is None or n_b is None: adjacency_cache[key] = True return True - result = abs(n_a.dot(n_b)) > 1.0 - 1e-3 - adjacency_cache[key] = result - return result + dot = abs(n_a.dot(n_b)) + if dot <= 1.0 - 1e-3: + if _verbose: + print(f"[TARGET PAIR] normal_dot={dot:.6f} coplanar=False (not parallel)") + adjacency_cache[key] = False + return False + # Normals are parallel — also verify the elements share a face plane. + # Two walls can have parallel normals but be on different parallel planes + # (e.g., offset walls meeting at a corner). Project all vertices of each + # object onto the same reference normal (n_a) so that anti-parallel + # normals (one face is flipped) don't produce mirrored projections. + plane_pos_a = {round(v.dot(n_a), 5) for v in verts_a} + plane_pos_b = {round(v.dot(n_a), 5) for v in verts_b} + same_plane = any(abs(pa - pb) < tol for pa in plane_pos_a for pb in plane_pos_b) + if _verbose: + print(f"[TARGET PAIR] normal_dot={dot:.6f} plane_pos_a={sorted(plane_pos_a)} plane_pos_b={sorted(plane_pos_b)} same_plane={same_plane}") + adjacency_cache[key] = same_plane + return same_plane def parse_line(d): # Format is "Mx0,y0 Lx1,y1" (no space after M/L) @@ -1714,13 +1790,42 @@ class CreateDrawing(bpy.types.Operator): return None def lines_match(a, b): + """Return True if segments a and b represent the same physical edge. + + Two cases are handled: + 1. Exact match (same endpoints within TOL, either order). + 2. Collinear overlap: both axis-aligned, on the same line, with + overlapping extents. This handles L-shaped walls whose boundary + is split into sub-segments that collectively cover the same edge + as a single segment in the adjacent element. + """ (x0a, y0a), (x1a, y1a) = a (x0b, y0b), (x1b, y1b) = b - return ( + # Case 1: exact endpoint match (either orientation) + if ( abs(x0a - x0b) < TOL and abs(y0a - y0b) < TOL and abs(x1a - x1b) < TOL and abs(y1a - y1b) < TOL ) or ( abs(x0a - x1b) < TOL and abs(y0a - y1b) < TOL and abs(x1a - x0b) < TOL and abs(y1a - y0b) < TOL - ) + ): + return True + # Case 2: collinear overlap for axis-aligned segments + is_horiz_a = abs(y0a - y1a) < TOL + is_horiz_b = abs(y0b - y1b) < TOL + if is_horiz_a and is_horiz_b: + # Both horizontal: same y, overlapping x ranges + if abs((y0a + y1a) / 2 - (y0b + y1b) / 2) < TOL: + min_xa, max_xa = min(x0a, x1a), max(x0a, x1a) + min_xb, max_xb = min(x0b, x1b), max(x0b, x1b) + return max(min_xa, min_xb) < min(max_xa, max_xb) - TOL + is_vert_a = abs(x0a - x1a) < TOL + is_vert_b = abs(x0b - x1b) < TOL + if is_vert_a and is_vert_b: + # Both vertical: same x, overlapping y ranges + if abs((x0a + x1a) / 2 - (x0b + x1b) / 2) < TOL: + min_ya, max_ya = min(y0a, y1a), max(y0a, y1a) + min_yb, max_yb = min(y0b, y1b), max(y0b, y1b) + return max(min_ya, min_yb) < min(max_ya, max_yb) - TOL + return False # Group projection elements by their immediate parent parent_to_groups = {} @@ -1760,10 +1865,12 @@ class CreateDrawing(bpy.types.Operator): group_data = [] for grp in proj_groups: guid = grp.get("{http://www.ifcopenshell.org/ns}guid", "") + cls = grp.get("class", "") mat_key = get_material_key(guid) style_key = get_style_key(guid) segs = [] - for path_el in grp.findall(f"{{{SVG}}}path"): + all_paths = grp.findall(f"{{{SVG}}}path") + for path_el in all_paths: line = parse_line(path_el.get("d", "")) if line is not None: segs.append((path_el, line)) @@ -1776,15 +1883,40 @@ class CreateDrawing(bpy.types.Operator): for j, (grp_j, mat_j, style_j, segs_j, guid_j) in enumerate(group_data): if j <= i: continue - if mat_j != mat_i or style_j != style_i: + _target = {"1eW6OACmXD1hWJLDeB6erW", "2x0$XNlFH6FxlvS7eYJttx"} == {guid_i, guid_j} + if mat_j != mat_i: + if _target: + print(f"[TARGET PAIR] SKIPPED: different mat {mat_i!r} vs {mat_j!r}") continue - if not are_coplanar_and_adjacent(guid_i, guid_j): + if style_j != style_i: + if _target: + print(f"[TARGET PAIR] SKIPPED: different style {style_i!r} vs {style_j!r}") continue + adj = are_coplanar_and_adjacent(guid_i, guid_j) + if not adj: + continue + matched = 0 for path_i, line_i in segs_i: for path_j, line_j in segs_j: if lines_match(line_i, line_j): + if _target: + print(f"[TARGET PAIR] MATCH: {line_i} == {line_j}") to_remove.add(id(path_i)) to_remove.add(id(path_j)) + matched += 1 + if _target: + print(f"[TARGET PAIR] adj=True, matched={matched} segment pair(s)") + if matched == 0: + print(f"[TARGET PAIR] segs_i ({guid_i}, {len(segs_i)} segs):") + for _, li in segs_i: + print(f" {li}") + print(f"[TARGET PAIR] segs_j ({guid_j}, {len(segs_j)} segs):") + for _, lj in segs_j: + print(f" {lj}") + elif matched: + print(f"[MERGED] {guid_i} vs {guid_j}: {matched} segment(s) removed") + + print(f"[COPLANAR DEBUG] Total paths to remove: {len(to_remove)}") if to_remove: for grp, mat, style, segs, guid in group_data: for path_el, _ in segs: