Add opt-in join coplanar surfaces toggle with offset-wall guard

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.
This commit is contained in:
Ryan Schultz
2026-04-12 08:55:43 -05:00
parent 2b7bcb1b40
commit 7e7394a933
@@ -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 <g> 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