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. # local Z axis in world space points downward, the camera looks upward.
camera_looks_up = self.camera.matrix_world.col[2].z < 0 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 = {} obj_cache = {}
def get_obj(guid): def get_obj(guid):
@@ -1667,9 +1670,11 @@ class CreateDrawing(bpy.types.Operator):
key = (min(guid_a, guid_b), max(guid_a, guid_b)) key = (min(guid_a, guid_b), max(guid_a, guid_b))
if key in adjacency_cache: if key in adjacency_cache:
return adjacency_cache[key] return adjacency_cache[key]
_dbg = guid_a[:12] in _dg and guid_b[:12] in _dg
obj_a = get_obj(guid_a) obj_a = get_obj(guid_a)
obj_b = get_obj(guid_b) 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 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 adjacency_cache[key] = True
return True return True
# Quick AABB guard # Quick AABB guard
@@ -1681,9 +1686,11 @@ class CreateDrawing(bpy.types.Operator):
min_b = min(c[axis] for c in corners_b) min_b = min(c[axis] for c in corners_b)
max_b = max(c[axis] for c in corners_b) max_b = max(c[axis] for c in corners_b)
if min_a > max_b + tol: 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 adjacency_cache[key] = False
return False return False
if min_b > max_a + tol: 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 adjacency_cache[key] = False
return False return False
# Proximity check: vertex-to-vertex OR vertex-to-edge. # 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 # 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) 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 not has_shared:
if _dbg: print(f"[DBG] COPL {guid_a[:12]}/{guid_b[:12]} FAIL proximity")
adjacency_cache[key] = False adjacency_cache[key] = False
return 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 # Containment check: if one AABB fully contains the other, the
# dominant-normal test is unreliable (a flat inner element's largest # dominant-normal test is unreliable (a flat inner element's largest
# face is its top/bottom, not its side face). Skip normal check. # 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) contained = aabb_contains(corners_a, corners_b) or aabb_contains(corners_b, corners_a)
if contained: if contained:
if _dbg: print(f"[DBG] COPL {guid_a[:12]}/{guid_b[:12]} PASS contained")
adjacency_cache[key] = True adjacency_cache[key] = True
return True return True
# Coplanarity check: use the largest-face normal for each object. # 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_a = dominant_world_normal(obj_a)
n_b = dominant_world_normal(obj_b) n_b = dominant_world_normal(obj_b)
if n_a is None or n_b is None: 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 adjacency_cache[key] = True
return True return True
dot = abs(n_a.dot(n_b)) 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 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 adjacency_cache[key] = False
return False return False
# Check whether both elements are at the same depth relative to the # Face-plane check: parallel normals don't guarantee the elements are
# camera. Project vertices onto the camera look direction: elements # on the same plane — they could be offset (e.g. two walls facing the
# at the same camera depth have overlapping ranges; depth-stacked # same direction at different Y positions meeting at a corner).
# elements (one in front of the other) have separated ranges. # Project all vertices of both elements onto n_a (using n_a for both
# Using the camera direction rather than n_a is critical — n_a may # so that anti-parallel normals +Y/-Y produce matching values).
# be perpendicular to the camera (e.g. X-normal boxes in plan view) #
# which would produce zero overlap for legitimate side-by-side pairs. 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_a = [v.dot(_cam_look) for v in verts_a]
projs_b = [v.dot(_cam_look) for v in verts_b] projs_b = [v.dot(_cam_look) for v in verts_b]
range_a = (min(projs_a), max(projs_a)) range_a = (min(projs_a), max(projs_a))
range_b = (min(projs_b), max(projs_b)) range_b = (min(projs_b), max(projs_b))
overlap = min(range_a[1], range_b[1]) - max(range_a[0], range_b[0]) overlap = min(range_a[1], range_b[1]) - max(range_a[0], range_b[0])
same_plane = overlap > tol same_depth = overlap > tol
adjacency_cache[key] = same_plane 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'}")
return same_plane adjacency_cache[key] = same_depth
return same_depth
def parse_line(d): def parse_line(d):
# Format is "Mx0,y0 Lx1,y1" (no space after M/L) # 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 ((lo, coord), (hi, coord))
return ((coord, lo), (coord, hi)) 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 # Group projection <g> elements by their immediate parent
parent_to_groups = {} parent_to_groups = {}
for g in root.iter(f"{{{SVG}}}g"): 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): for j, (grp_j, mat_j, face_j, style_j, segs_j, guid_j) in enumerate(group_data):
if j <= i: if j <= i:
continue 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 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 continue
if style_j != style_i: 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 continue
if not are_coplanar_and_adjacent(guid_i, guid_j): 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 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 matched = 0
# Group each element's segments by axis-aligned line. # 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_i = group_by_line(segs_i)
lines_j = group_by_line(segs_j) 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): for key in set(lines_i) & set(lines_j):
entry_i = lines_i[key] entry_i = lines_i[key]
entry_j = lines_j[key] entry_j = lines_j[key]
@@ -2113,6 +2157,18 @@ class CreateDrawing(bpy.types.Operator):
shared = ivs_intersect(union_i, union_j) shared = ivs_intersect(union_i, union_j)
if not shared: if not shared:
continue 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) matched += len(shared)
kind = key[0] kind = key[0]
coord = entry_i["coord"] coord = entry_i["coord"]
@@ -2131,16 +2187,18 @@ class CreateDrawing(bpy.types.Operator):
# (not explicitly drawn as a path segment), segments from the other # (not explicitly drawn as a path segment), segments from the other
# element that lie on the boundary of that element's SVG bbox are # element that lie on the boundary of that element's SVG bbox are
# still on the shared face and should be removed. # 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_i = segs_bbox(segs_i)
bbox_j = segs_bbox(segs_j) bbox_j = segs_bbox(segs_j)
if bbox_i and bbox_j: if bbox_i and bbox_j:
for path_j, line_j in segs_j: for path_j, line_j in segs_j:
if seg_on_boundary_of(line_j, bbox_i, bbox_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)) to_remove.add(id(path_j))
matched += 1 matched += 1
for path_i, line_i in segs_i: for path_i, line_i in segs_i:
if seg_on_boundary_of(line_i, bbox_j, bbox_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)) to_remove.add(id(path_i))
matched += 1 matched += 1