Skip to content

Commit 5436467

Browse files
committed
Whoops, this was supposed to be a PR...
Revert "Fix #3742: Remove coplanar boundary lines between adjacent same-material elements in Bonsai SVG drawings" This reverts commit 1c7e134.
1 parent 1c7e134 commit 5436467

File tree

1 file changed

+26
-217
lines changed

1 file changed

+26
-217
lines changed

src/bonsai/bonsai/bim/module/drawing/operator.py

Lines changed: 26 additions & 217 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,14 +1005,12 @@ def generate_linework(self, context: bpy.types.Context) -> Union[str, None]:
10051005
if self.cprops.generate_material_layers:
10061006
self.generate_material_layers(context, root)
10071007
self.merge_linework_and_add_metadata(root)
1008-
self.remove_coplanar_boundary_lines(root)
10091008
self.move_elements_to_top(root)
10101009
elif self.cprops.cut_mode == "OPENCASCADE":
10111010
self.move_projection_to_bottom(root)
10121011
if self.cprops.generate_material_layers:
10131012
self.generate_material_layers(context, root)
10141013
self.merge_linework_and_add_metadata(root)
1015-
self.remove_coplanar_boundary_lines(root)
10161014
self.move_elements_to_top(root)
10171015

10181016
if self.cprops.fill_mode == "SHAPELY":
@@ -1090,7 +1088,6 @@ def generate_linework(self, context: bpy.types.Context) -> Union[str, None]:
10901088
if self.cprops.fill_mode == "SVGFILL":
10911089
results = etree.tostring(root).decode("utf8")
10921090
svg_data_1 = results
1093-
from collections import defaultdict
10941091
from xml.dom.minidom import parseString
10951092

10961093
def yield_groups(n):
@@ -1105,31 +1102,19 @@ def yield_groups(n):
11051102

11061103
ls_groups = ifcopenshell.ifcopenshell_wrapper.svg_to_line_segments(results, "projection")
11071104

1108-
# Group projection elements by their parent section-view group so that all
1109-
# projection linework from the same view is merged in one cell decomposition.
1110-
# This enables coplanar surfaces from *different* elements to be joined.
1111-
groups_by_parent = defaultdict(list)
1112-
ls_by_parent = defaultdict(list)
1113-
for ls, g in zip(ls_groups, groups1):
1114-
pid = id(g.parentNode)
1115-
groups_by_parent[pid].append(g)
1116-
ls_by_parent[pid].extend(ls)
1117-
1118-
for pid, projection_groups in groups_by_parent.items():
1119-
section_parent = projection_groups[0].parentNode
1120-
combined_ls = ls_by_parent[pid]
1105+
for i, (ls, g1) in enumerate(zip(ls_groups, groups1)):
1106+
projection, g1 = g1, g1.parentNode
11211107

11221108
svgfill_context = ifcopenshell.ifcopenshell_wrapper.context(
11231109
ifcopenshell.ifcopenshell_wrapper.EXACT_CONSTRUCTIONS, 1.0e-3
11241110
)
11251111

11261112
# EXACT_CONSTRUCTIONS is significantly faster than FILTERED_CARTESIAN_QUOTIENT
11271113
# remove duplicates (without tolerance)
1128-
combined_ls = [l for l in map(tuple, set(map(frozenset, combined_ls))) if len(l) == 2 and l[0] != l[1]]
1129-
svgfill_context.add(combined_ls)
1114+
ls = [l for l in map(tuple, set(map(frozenset, ls))) if len(l) == 2 and l[0] != l[1]]
1115+
svgfill_context.add(ls)
11301116

1131-
num_passes = 1
1132-
g2 = None
1117+
num_passes = 0
11331118

11341119
for iteration in range(num_passes + 1):
11351120
# initialize empty group, note that in the current approach only one
@@ -1204,57 +1189,46 @@ def yield_groups(n):
12041189
if iteration != num_passes:
12051190
to_remove = []
12061191

1207-
material_cache = {}
12081192
for he_idx in range(0, len(pairs), 2):
1193+
# @todo instead of ray_distance, better do (x.point - y.point).dot(x.normal)
1194+
# to see if they're coplanar, because ray-distance will be different in case
1195+
# of element surfaces non-orthogonal to the view direction
1196+
12091197
def format(x):
12101198
if x is None:
12111199
return None
12121200
elif isinstance(x, tuple):
12131201
# found to be inside element using tree.select() no face or style info
12141202
return x
12151203
else:
1216-
return (x.instance, tuple(x.position), tuple(x.normal), x.style_index)
1204+
return (x.instance.is_a(), x.ray_distance, tuple(x.position))
12171205

12181206
pp = pairs[he_idx : he_idx + 2]
12191207
if pp == (-1, -1):
12201208
continue
12211209
data = list(map(format, map(semantics.__getitem__, pp)))
1222-
if None not in data and data[0][0].is_a() == data[1][0].is_a():
1223-
if len(data[0]) == 2 and len(data[1]) == 2:
1224-
# Both from tree.select() -> same element = same surface
1225-
if data[0][0] == data[1][0]:
1226-
to_remove.append(he_idx // 2)
1227-
elif len(data[0]) == 4 and len(data[1]) == 4:
1228-
# Both from tree.select_ray() -> coplanar + same style + same material
1229-
p1, n1, s1 = np.array(data[0][1]), np.array(data[0][2]), data[0][3]
1230-
p2, n2, s2 = np.array(data[1][1]), np.array(data[1][2]), data[1][3]
1231-
1232-
if s1 == s2:
1233-
if abs(1.0 - abs(np.dot(n1, n2))) < 1.0e-4:
1234-
if abs(np.dot(p1 - p2, n1)) < 1.0e-4:
1235-
def get_cached_material(inst):
1236-
id_ = inst.id()
1237-
if id_ not in material_cache:
1238-
mats = ifcopenshell.util.element.get_materials(inst)
1239-
material_cache[id_] = tuple(m.id() for m in mats) if mats else (-1,)
1240-
return material_cache[id_]
1241-
1242-
if get_cached_material(data[0][0]) == get_cached_material(data[1][0]):
1243-
to_remove.append(he_idx // 2)
1244-
# print(he_idx // 2, *data)
1210+
if None not in data and data[0][0] == data[1][0] and abs(data[0][1] - data[1][1]) < 1.0e-5:
1211+
to_remove.append(he_idx // 2)
1212+
# Print edge index and semantic data
1213+
# print(he_idx // 2, *data)
12451214

12461215
svgfill_context.merge(to_remove)
12471216

1248-
# Replace all per-element projection groups with one merged cell group.
1249-
# SVG draw order: projections must be below sections, so insert first.
1250-
for pg in projection_groups:
1251-
section_parent.removeChild(pg)
1217+
# Swap the XML nodes from the files
1218+
# Remove the original hidden line node we still have in the serializer output
1219+
g1.removeChild(projection)
12521220
g2.setAttribute("class", "projection")
1253-
children = [x for x in section_parent.childNodes if x.nodeType == x.ELEMENT_NODE]
1221+
# Find the children of the projection node parent
1222+
children = [x for x in g1.childNodes if x.nodeType == x.ELEMENT_NODE]
12541223
if children:
1255-
section_parent.insertBefore(g2, children[0])
1224+
# Insert the new semantically enriched cell-based projection node
1225+
# *before* the node with sections from the serializer. SVG derives
1226+
# draw order from node order in the DOM so sections are draw over
1227+
# the projections.
1228+
g1.insertBefore(g2, children[0])
12561229
else:
1257-
section_parent.appendChild(g2)
1230+
# This generally shouldn't happen
1231+
g1.appendChild(g2)
12581232

12591233
results = dom1.toxml()
12601234
results = results.encode("ascii", "xmlcharrefreplace")
@@ -1626,171 +1600,6 @@ def merge_linework_and_add_metadata(self, root):
16261600
g.set("class", " ".join(list(polygon_classes)))
16271601
group.append(g)
16281602

1629-
def remove_coplanar_boundary_lines(self, root):
1630-
"""Remove projection line segments shared between same-material elements.
1631-
1632-
After merge_linework_and_add_metadata() adds material-* CSS classes,
1633-
this scans all per-element projection <g> groups under each common
1634-
parent, finds path segments (M x0,y0 L x1,y1) that appear in two or
1635-
more groups that carry the same material-* class, and deletes them from
1636-
both groups so coplanar surfaces of the same material appear seamless.
1637-
"""
1638-
SVG = "http://www.w3.org/2000/svg"
1639-
TOL = 0.01 # SVG coordinate tolerance for matching line endpoints
1640-
1641-
obj_cache = {}
1642-
1643-
def get_obj(guid):
1644-
if guid in obj_cache:
1645-
return obj_cache[guid]
1646-
element = self.get_element_by_guid(guid)
1647-
obj = tool.Ifc.get_object(element) if element is not None else None
1648-
obj_cache[guid] = obj
1649-
return obj
1650-
1651-
adjacency_cache = {}
1652-
1653-
def are_coplanar_and_adjacent(guid_a, guid_b, tol=0.01):
1654-
"""True if the two meshes share a vertex AND have parallel face normals.
1655-
1656-
Sharing a vertex confirms physical adjacency (rules out depth-stacked elements
1657-
whose 2D projections accidentally overlap). Parallel normals confirms the
1658-
shared face is coplanar — elements meeting at a fold angle are rejected.
1659-
"""
1660-
key = (min(guid_a, guid_b), max(guid_a, guid_b))
1661-
if key in adjacency_cache:
1662-
return adjacency_cache[key]
1663-
obj_a = get_obj(guid_a)
1664-
obj_b = get_obj(guid_b)
1665-
if obj_a is None or obj_b is None or obj_a.type != "MESH" or obj_b.type != "MESH":
1666-
adjacency_cache[key] = True
1667-
return True
1668-
# Quick AABB guard
1669-
corners_a = [obj_a.matrix_world @ Vector(c) for c in obj_a.bound_box]
1670-
corners_b = [obj_b.matrix_world @ Vector(c) for c in obj_b.bound_box]
1671-
for axis in range(3):
1672-
if min(c[axis] for c in corners_a) > max(c[axis] for c in corners_b) + tol:
1673-
adjacency_cache[key] = False
1674-
return False
1675-
if min(c[axis] for c in corners_b) > max(c[axis] for c in corners_a) + tol:
1676-
adjacency_cache[key] = False
1677-
return False
1678-
# Shared vertex check
1679-
tol_sq = tol * tol
1680-
verts_a = [obj_a.matrix_world @ v.co for v in obj_a.data.vertices]
1681-
verts_b = [obj_b.matrix_world @ v.co for v in obj_b.data.vertices]
1682-
has_shared = any((va - vb).length_squared < tol_sq for va in verts_a for vb in verts_b)
1683-
if not has_shared:
1684-
adjacency_cache[key] = False
1685-
return False
1686-
# Coplanarity check: use the largest-face normal for each object.
1687-
# Area-weighted averages fail for slabs because top/bottom faces cancel.
1688-
def dominant_world_normal(obj):
1689-
mat3 = obj.matrix_world.to_3x3().normalized()
1690-
best = max(obj.data.polygons, key=lambda p: p.area, default=None)
1691-
if best is None or best.area < 1e-10:
1692-
return None
1693-
return (mat3 @ best.normal).normalized()
1694-
1695-
n_a = dominant_world_normal(obj_a)
1696-
n_b = dominant_world_normal(obj_b)
1697-
if n_a is None or n_b is None:
1698-
adjacency_cache[key] = True
1699-
return True
1700-
result = abs(n_a.dot(n_b)) > 1.0 - 1e-3
1701-
adjacency_cache[key] = result
1702-
return result
1703-
1704-
def parse_line(d):
1705-
# Format is "Mx0,y0 Lx1,y1" (no space after M/L)
1706-
parts = d.strip().split()
1707-
if len(parts) == 2 and parts[0].startswith("M") and parts[1].startswith("L"):
1708-
try:
1709-
x0, y0 = map(float, parts[0][1:].split(","))
1710-
x1, y1 = map(float, parts[1][1:].split(","))
1711-
return (x0, y0), (x1, y1)
1712-
except ValueError:
1713-
pass
1714-
return None
1715-
1716-
def lines_match(a, b):
1717-
(x0a, y0a), (x1a, y1a) = a
1718-
(x0b, y0b), (x1b, y1b) = b
1719-
return (
1720-
abs(x0a - x0b) < TOL and abs(y0a - y0b) < TOL and abs(x1a - x1b) < TOL and abs(y1a - y1b) < TOL
1721-
) or (
1722-
abs(x0a - x1b) < TOL and abs(y0a - y1b) < TOL and abs(x1a - x0b) < TOL and abs(y1a - y0b) < TOL
1723-
)
1724-
1725-
# Group projection <g> elements by their immediate parent
1726-
parent_to_groups = {}
1727-
for g in root.iter(f"{{{SVG}}}g"):
1728-
cls_list = g.get("class", "").split()
1729-
if "projection" not in cls_list:
1730-
continue
1731-
parent = g.getparent()
1732-
if parent is None:
1733-
continue
1734-
parent_to_groups.setdefault(id(parent), []).append(g)
1735-
1736-
for pid, proj_groups in parent_to_groups.items():
1737-
if len(proj_groups) < 2:
1738-
continue
1739-
1740-
def get_material_key(guid):
1741-
element = self.get_element_by_guid(guid)
1742-
if element is None:
1743-
return None
1744-
mats = ifcopenshell.util.element.get_materials(element)
1745-
return tuple(sorted(m.id() for m in mats)) if mats else ()
1746-
1747-
def get_style_key(guid):
1748-
"""IDs of IfcPresentationStyles directly on the element's geometry items."""
1749-
element = self.get_element_by_guid(guid)
1750-
if element is None or not getattr(element, "Representation", None):
1751-
return ()
1752-
style_ids = set()
1753-
for rep in element.Representation.Representations:
1754-
for item in rep.Items:
1755-
for si in getattr(item, "StyledByItem", ()):
1756-
for style in si.Styles:
1757-
style_ids.add(style.id())
1758-
return tuple(sorted(style_ids))
1759-
1760-
group_data = []
1761-
for grp in proj_groups:
1762-
guid = grp.get("{http://www.ifcopenshell.org/ns}guid", "")
1763-
mat_key = get_material_key(guid)
1764-
style_key = get_style_key(guid)
1765-
segs = []
1766-
for path_el in grp.findall(f"{{{SVG}}}path"):
1767-
line = parse_line(path_el.get("d", ""))
1768-
if line is not None:
1769-
segs.append((path_el, line))
1770-
group_data.append((grp, mat_key, style_key, segs, guid))
1771-
1772-
to_remove = set()
1773-
for i, (grp_i, mat_i, style_i, segs_i, guid_i) in enumerate(group_data):
1774-
if mat_i is None:
1775-
continue
1776-
for j, (grp_j, mat_j, style_j, segs_j, guid_j) in enumerate(group_data):
1777-
if j <= i:
1778-
continue
1779-
if mat_j != mat_i or style_j != style_i:
1780-
continue
1781-
if not are_coplanar_and_adjacent(guid_i, guid_j):
1782-
continue
1783-
for path_i, line_i in segs_i:
1784-
for path_j, line_j in segs_j:
1785-
if lines_match(line_i, line_j):
1786-
to_remove.add(id(path_i))
1787-
to_remove.add(id(path_j))
1788-
if to_remove:
1789-
for grp, mat, style, segs, guid in group_data:
1790-
for path_el, _ in segs:
1791-
if id(path_el) in to_remove:
1792-
grp.remove(path_el)
1793-
17941603
def drawing_to_model_co(self, x: float, y: float) -> Vector:
17951604
camera_xy = np.array((x, -y)) / self.scale / 1000
17961605
camera_xy += np.array((self.cprops.width / -2, self.cprops.height / 2)) # top left offset

0 commit comments

Comments
 (0)