Add partial edge splitting for coplanar boundary removal

When two coplanar same-material elements share only part of an
edge (e.g. an L-shaped element abutting a rectangle along a
notch), the previous bilateral match removed whole segments and
left gaps or over-removed lines.

The new approach groups all segments per element by their
axis-aligned line, merges them into interval unions, computes the
shared intersection, removes all original segments on that line,
and re-adds the non-shared remainders as new path elements. This
correctly handles any number of sub-segments on the same line.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Ryan Schultz
2026-04-06 07:56:59 -05:00
parent e6235d0cb6
commit 9041b2e249
+117 -41
View File
@@ -1817,43 +1817,73 @@ class CreateDrawing(bpy.types.Operator):
return min(x_hi, max_xs) - max(x_lo, min_xs) > TOL
return False
def lines_match(a, b):
"""Return True if segments a and b represent the same physical edge.
def seg_line_key(seg):
"""Return a bucketed key for the axis-aligned line containing seg.
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.
Returns ('h', int_key) for horizontal or ('v', int_key) for vertical.
int_key is round(coord / TOL) so segments within TOL of the same
line map to the same bucket. Returns None for diagonal segments.
"""
(x0a, y0a), (x1a, y1a) = a
(x0b, y0b), (x1b, y1b) = b
# 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
(x0, y0), (x1, y1) = seg
if abs(y0 - y1) < TOL:
return ("h", round((y0 + y1) / 2 / TOL))
if abs(x0 - x1) < TOL:
return ("v", round((x0 + x1) / 2 / TOL))
return None
def seg_interval(seg):
"""Return the (lo, hi) 1-D interval of an axis-aligned segment."""
(x0, y0), (x1, y1) = seg
if abs(y0 - y1) < TOL:
return (min(x0, x1), max(x0, x1))
return (min(y0, y1), max(y0, y1))
def seg_axis_coord(seg, kind):
"""Return the fixed coordinate (y for 'h', x for 'v') of a segment."""
(x0, y0), (x1, y1) = seg
return (y0 + y1) / 2 if kind == "h" else (x0 + x1) / 2
def ivs_union(ivs):
"""Merge a list of (lo, hi) intervals into a non-overlapping sorted list."""
merged = []
for lo, hi in sorted(ivs):
if merged and lo <= merged[-1][1] + TOL:
merged[-1] = (merged[-1][0], max(merged[-1][1], hi))
else:
merged.append([lo, hi])
return [(lo, hi) for lo, hi in merged]
def ivs_intersect(a_ivs, b_ivs):
"""Return the intersection of two sets of intervals."""
result = []
for a_lo, a_hi in a_ivs:
for b_lo, b_hi in b_ivs:
lo, hi = max(a_lo, b_lo), min(a_hi, b_hi)
if hi > lo + TOL:
result.append((lo, hi))
return result
def ivs_subtract(ivs, sub):
"""Subtract interval list sub from interval list ivs."""
result = list(ivs)
for s_lo, s_hi in sub:
new_result = []
for r_lo, r_hi in result:
if s_hi <= r_lo + TOL or s_lo >= r_hi - TOL:
new_result.append((r_lo, r_hi))
else:
if r_lo < s_lo - TOL:
new_result.append((r_lo, s_lo))
if r_hi > s_hi + TOL:
new_result.append((s_hi, r_hi))
result = new_result
return result
def make_seg(kind, coord, lo, hi):
"""Reconstruct a segment tuple from a line kind, fixed coord, and interval."""
if kind == "h":
return ((lo, coord), (hi, coord))
return ((coord, lo), (coord, hi))
# Group projection <g> elements by their immediate parent
parent_to_groups = {}
@@ -1948,6 +1978,9 @@ class CreateDrawing(bpy.types.Operator):
group_data.append((grp, mat_key, style_key, segs, guid))
to_remove = set()
# New path segments to add back: list of (grp_element, seg) pairs
to_add = []
for i, (grp_i, mat_i, style_i, segs_i, guid_i) in enumerate(group_data):
if mat_i is None:
continue
@@ -1961,12 +1994,50 @@ class CreateDrawing(bpy.types.Operator):
if not are_coplanar_and_adjacent(guid_i, guid_j):
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):
to_remove.add(id(path_i))
to_remove.add(id(path_j))
matched += 1
# Group each element's segments by axis-aligned line.
# All segments on the same line (within TOL) are merged into a
# single interval union before computing the shared portion.
# This correctly handles cases where one element has a single
# long edge while the other has multiple shorter segments on
# the same line (e.g. an L-shaped element).
def group_by_line(segs):
d = {}
for path_el, seg in segs:
key = seg_line_key(seg)
if key is None:
continue
if key not in d:
d[key] = {"paths": [], "ivs": [], "coord": seg_axis_coord(seg, key[0])}
d[key]["paths"].append(path_el)
d[key]["ivs"].append(seg_interval(seg))
return d
lines_i = group_by_line(segs_i)
lines_j = group_by_line(segs_j)
for key in set(lines_i) & set(lines_j):
entry_i = lines_i[key]
entry_j = lines_j[key]
union_i = ivs_union(entry_i["ivs"])
union_j = ivs_union(entry_j["ivs"])
shared = ivs_intersect(union_i, union_j)
if not shared:
continue
matched += len(shared)
kind = key[0]
coord = entry_i["coord"]
for path_el in entry_i["paths"]:
to_remove.add(id(path_el))
for path_el in entry_j["paths"]:
to_remove.add(id(path_el))
rem_i = ivs_subtract(union_i, shared)
rem_j = ivs_subtract(union_j, shared)
for lo, hi in rem_i:
to_add.append((grp_i, make_seg(kind, coord, lo, hi)))
for lo, hi in rem_j:
to_add.append((grp_j, make_seg(kind, coord, lo, hi)))
# Unilateral fallback: when one element's shared edge is implicit
# (not explicitly drawn as a path segment), segments from the other
# element that lie on the boundary of that element's SVG bbox are
@@ -1990,6 +2061,11 @@ class CreateDrawing(bpy.types.Operator):
if id(path_el) in to_remove:
grp.remove(path_el)
for grp_el, seg in to_add:
path_el = etree.SubElement(grp_el, f"{{{SVG}}}path")
(x0, y0), (x1, y1) = seg
path_el.set("d", f"M{x0},{y0} L{x1},{y1}")
def drawing_to_model_co(self, x: float, y: float) -> Vector:
camera_xy = np.array((x, -y)) / self.scale / 1000
camera_xy += np.array((self.cprops.width / -2, self.cprops.height / 2)) # top left offset