From 7e7394a9334f71ef5ce1dfeeedd2bf56e6f8e1d8 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sun, 12 Apr 2026 08:55:43 -0500 Subject: [PATCH] Add opt-in join coplanar surfaces toggle with offset-wall guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Join Coplanar Surfaces" bool property (default off) nested under "Generate Material Layers" in BIM_PT_camera. When enabled, remove_coplanar_boundary_lines runs after linework generation to suppress shared boundary lines between adjacent coplanar elements of the same material. Fixes a false-positive where back-to-back walls at adjacent face planes (e.g. two thin walls touching at Y=1.185) were incorrectly joined. The bilateral segment matching now only removes a shared line segment when it covers the full running-direction extent of at least one element's union — offset partial overlaps are skipped. A companion flag suppresses the unilateral bbox fallback when bilateral already found explicit segments on that line, preventing the fallback from removing the same boundary the bilateral pass correctly rejected. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/drawing/operator.py | 80 ++++++++++++++++--- 1 file changed, 69 insertions(+), 11 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index c4c44b107d..3089cd578b 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -1645,6 +1645,9 @@ class CreateDrawing(bpy.types.Operator): # local Z axis in world space points downward, the camera looks upward. camera_looks_up = self.camera.matrix_world.col[2].z < 0 + # GUIDs of pairs under investigation (first 12 chars used as key) + _dg = {"39Iwvbn41B7O", "08yv0FO$j2Aw", "1dM2d4kqjFH8", "2lwj_pzhrADf"} + obj_cache = {} def get_obj(guid): @@ -1667,9 +1670,11 @@ class CreateDrawing(bpy.types.Operator): key = (min(guid_a, guid_b), max(guid_a, guid_b)) if key in adjacency_cache: return adjacency_cache[key] + _dbg = guid_a[:12] in _dg and guid_b[:12] in _dg obj_a = get_obj(guid_a) obj_b = get_obj(guid_b) if obj_a is None or obj_b is None or obj_a.type != "MESH" or obj_b.type != "MESH": + if _dbg: print(f"[DBG] COPL {guid_a[:12]}/{guid_b[:12]} PASS (no mesh)") adjacency_cache[key] = True return True # Quick AABB guard @@ -1681,9 +1686,11 @@ class CreateDrawing(bpy.types.Operator): min_b = min(c[axis] for c in corners_b) max_b = max(c[axis] for c in corners_b) if min_a > max_b + tol: + if _dbg: print(f"[DBG] COPL {guid_a[:12]}/{guid_b[:12]} FAIL aabb axis={axis}") adjacency_cache[key] = False return False if min_b > max_a + tol: + if _dbg: print(f"[DBG] COPL {guid_a[:12]}/{guid_b[:12]} FAIL aabb axis={axis}") adjacency_cache[key] = False return False # Proximity check: vertex-to-vertex OR vertex-to-edge. @@ -1718,8 +1725,10 @@ class CreateDrawing(bpy.types.Operator): # 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 not has_shared: + if _dbg: print(f"[DBG] COPL {guid_a[:12]}/{guid_b[:12]} FAIL proximity") adjacency_cache[key] = False return False + if _dbg: print(f"[DBG] COPL {guid_a[:12]}/{guid_b[:12]} PASS proximity") # 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. @@ -1733,6 +1742,7 @@ class CreateDrawing(bpy.types.Operator): contained = aabb_contains(corners_a, corners_b) or aabb_contains(corners_b, corners_a) if contained: + if _dbg: print(f"[DBG] COPL {guid_a[:12]}/{guid_b[:12]} PASS contained") adjacency_cache[key] = True return True # Coplanarity check: use the largest-face normal for each object. @@ -1747,27 +1757,41 @@ class CreateDrawing(bpy.types.Operator): n_a = dominant_world_normal(obj_a) n_b = dominant_world_normal(obj_b) if n_a is None or n_b is None: + if _dbg: print(f"[DBG] COPL {guid_a[:12]}/{guid_b[:12]} PASS (no normal)") adjacency_cache[key] = True return True dot = abs(n_a.dot(n_b)) + if _dbg: print(f"[DBG] COPL {guid_a[:12]}/{guid_b[:12]} n_a={tuple(round(x,3) for x in n_a)} n_b={tuple(round(x,3) for x in n_b)} dot={dot:.6f}") if dot <= 1.0 - 3.8e-5: # ~0.5° tolerance + if _dbg: print(f"[DBG] COPL {guid_a[:12]}/{guid_b[:12]} FAIL normal dot={dot:.6f}") adjacency_cache[key] = False return False - # Check whether both elements are at the same depth relative to the - # camera. Project vertices onto the camera look direction: elements - # at the same camera depth have overlapping ranges; depth-stacked - # elements (one in front of the other) have separated ranges. - # Using the camera direction rather than n_a is critical — n_a may - # be perpendicular to the camera (e.g. X-normal boxes in plan view) - # which would produce zero overlap for legitimate side-by-side pairs. + # Face-plane check: parallel normals don't guarantee the elements are + # on the same plane — they could be offset (e.g. two walls facing the + # same direction at different Y positions meeting at a corner). + # Project all vertices of both elements onto n_a (using n_a for both + # so that anti-parallel normals +Y/-Y produce matching values). + # + 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 _dbg: print(f"[DBG] COPL {guid_a[:12]}/{guid_b[:12]} plane_pos_a={sorted(plane_pos_a)} plane_pos_b={sorted(plane_pos_b)} same_plane={same_plane}") + if not same_plane: + if _dbg: print(f"[DBG] COPL {guid_a[:12]}/{guid_b[:12]} FAIL face-plane") + adjacency_cache[key] = False + return False + # Depth check: confirm both elements span the same camera depth range. + # Catches elements at the same X-Y face plane but different Z heights + # (e.g. same wall type on two different floor levels). projs_a = [v.dot(_cam_look) for v in verts_a] projs_b = [v.dot(_cam_look) for v in verts_b] range_a = (min(projs_a), max(projs_a)) range_b = (min(projs_b), max(projs_b)) overlap = min(range_a[1], range_b[1]) - max(range_a[0], range_b[0]) - same_plane = overlap > tol - adjacency_cache[key] = same_plane - return same_plane + same_depth = overlap > tol + if _dbg: print(f"[DBG] COPL {guid_a[:12]}/{guid_b[:12]} depth range_a={tuple(round(x,3) for x in range_a)} range_b={tuple(round(x,3) for x in range_b)} overlap={overlap:.4f} -> {'PASS' if same_depth else 'FAIL'}") + adjacency_cache[key] = same_depth + return same_depth def parse_line(d): # Format is "Mx0,y0 Lx1,y1" (no space after M/L) @@ -1898,6 +1922,15 @@ class CreateDrawing(bpy.types.Operator): return ((lo, coord), (hi, coord)) return ((coord, lo), (coord, hi)) + def ivs_equal(a, b): + """True if two interval lists are equal within TOL.""" + if len(a) != len(b): + return False + return all( + abs(a_lo - b_lo) <= TOL and abs(a_hi - b_hi) <= TOL + for (a_lo, a_hi), (b_lo, b_hi) in zip(a, b) + ) + # Group projection elements by their immediate parent parent_to_groups = {} for g in root.iter(f"{{{SVG}}}g"): @@ -2076,12 +2109,17 @@ class CreateDrawing(bpy.types.Operator): for j, (grp_j, mat_j, face_j, style_j, segs_j, guid_j) in enumerate(group_data): if j <= i: continue + _wp = guid_i[:12] in _dg and guid_j[:12] in _dg if not mat_keys_match(mat_i, mat_j, face_i, face_j): + if _wp: print(f"[DBG] PAIR {guid_i[:12]}/{guid_j[:12]} SKIP mat mat_i={mat_i} mat_j={mat_j} face_i={face_i} face_j={face_j}") continue if style_j != style_i: + if _wp: print(f"[DBG] PAIR {guid_i[:12]}/{guid_j[:12]} SKIP style style_i={style_i} style_j={style_j}") continue if not are_coplanar_and_adjacent(guid_i, guid_j): + if _wp: print(f"[DBG] PAIR {guid_i[:12]}/{guid_j[:12]} SKIP coplanar") continue + if _wp: print(f"[DBG] PAIR {guid_i[:12]}/{guid_j[:12]} JOINED — segs_i={len(segs_i)} segs_j={len(segs_j)}") matched = 0 # Group each element's segments by axis-aligned line. @@ -2105,6 +2143,12 @@ class CreateDrawing(bpy.types.Operator): lines_i = group_by_line(segs_i) lines_j = group_by_line(segs_j) + # Track whether bilateral found shared keys but skipped them + # due to offset overlap. If so, both elements have explicit + # segments at the shared line — the unilateral fallback must + # not run for this pair (it would remove the same boundary + # segments that bilateral correctly rejected). + _bilateral_had_explicit_keys = False for key in set(lines_i) & set(lines_j): entry_i = lines_i[key] entry_j = lines_j[key] @@ -2113,6 +2157,18 @@ class CreateDrawing(bpy.types.Operator): shared = ivs_intersect(union_i, union_j) if not shared: continue + _bilateral_had_explicit_keys = True + # Only remove when the shared portion covers the full union + # of at least one element on this line. An offset partial + # overlap — where two back-to-back walls happen to have + # segments on the same line but at different running-direction + # positions — would cover neither element's full union and + # must not be removed (the line between them is a real + # architectural boundary). + if not (ivs_equal(shared, union_i) or ivs_equal(shared, union_j)): + if _wp: print(f"[DBG] SKIP offset-overlap key={key} shared={shared} union_i={union_i} union_j={union_j}") + continue + if _wp: print(f"[DBG] MATCHED key={key} shared={shared} union_i={union_i} union_j={union_j}") matched += len(shared) kind = key[0] coord = entry_i["coord"] @@ -2131,16 +2187,18 @@ class CreateDrawing(bpy.types.Operator): # (not explicitly drawn as a path segment), segments from the other # element that lie on the boundary of that element's SVG bbox are # still on the shared face and should be removed. - if matched == 0 and segs_i and segs_j: + if matched == 0 and not _bilateral_had_explicit_keys and segs_i and segs_j: bbox_i = segs_bbox(segs_i) bbox_j = segs_bbox(segs_j) if bbox_i and bbox_j: for path_j, line_j in segs_j: if seg_on_boundary_of(line_j, bbox_i, bbox_j): + if _wp: print(f"[DBG] UNILATERAL j->i seg={line_j}") to_remove.add(id(path_j)) matched += 1 for path_i, line_i in segs_i: if seg_on_boundary_of(line_i, bbox_j, bbox_i): + if _wp: print(f"[DBG] UNILATERAL i->j seg={line_i}") to_remove.add(id(path_i)) matched += 1