Add network path overlay for walls and MEP

Adds a viewport overlay that traces the connected element path from
the selected wall or MEP element. Walls follow IfcRelConnectsPathElements
and draw each connected wall's reference axis with endpoint dots; MEP
elements follow IfcRelConnectsPorts and draw each segment's axis plus
a port-to-port spider for each fitting.

The new BIMModelProperties.show_paths toggle (Element Paths in the
Bonsai Decorators group of Blender's viewport overlay popover) drives
install / uninstall of both decorators on flip and on file load,
mirroring the show_slab_direction wiring. The popover row also
surfaces the pre-existing BIMSystemProperties.should_draw_decorations
toggle (System Decorations) so both connectivity overlays sit
together.

Dot colors split free endpoints (decorator_color_special, blue by
default) from junction nodes (decorator_color_selected, green by
default) so dangling chain tips read apart from interior joins. Walls
classify endpoints by IFC topology: rels expose RelatingConnectionType /
RelatedConnectionType and ATPATH dots use tool.Wall.path_connection_location_world
for the canonical T-meets join. MEP keeps the geometric classifier
because port positions coincide exactly across fitting + segment.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-19 08:52:38 +02:00
parent 104eeaf0cd
commit e2a4e5692f
6 changed files with 802 additions and 0 deletions
+7
View File
@@ -51,9 +51,11 @@ from bonsai.bim.module.model.decorator import (
BoundingBoxDecorator,
DoorSwingReadonlyDecorator,
MEPSegmentExtendPreviewDecorator,
MEPSystemPathDecorator,
SlabDirectionDecorator,
WallAxisDecorator,
WallFilletPreviewDecorator,
WallSystemPathDecorator,
)
from bonsai.bim.module.model.wall import WallGizmoPreviewDecorator
from bonsai.bim.module.nest.decorator import NestDecorator
@@ -513,6 +515,8 @@ def _install_viewport_overlays() -> None:
NestDecorator.uninstall()
WallAxisDecorator.uninstall()
SlabDirectionDecorator.uninstall()
MEPSystemPathDecorator.uninstall()
WallSystemPathDecorator.uninstall()
WallFilletPreviewDecorator.uninstall()
BendPreviewDecorator.uninstall()
MEPSegmentExtendPreviewDecorator.uninstall()
@@ -532,6 +536,9 @@ def _install_viewport_overlays() -> None:
WallAxisDecorator.install(bpy.context)
if model_props.show_slab_direction:
SlabDirectionDecorator.install(bpy.context)
if model_props.show_paths:
MEPSystemPathDecorator.install(bpy.context)
WallSystemPathDecorator.install(bpy.context)
if model_props.show_bounding_box:
BoundingBoxDecorator.install(bpy.context)
# Always-installed: draw() self-polls on Scene.BIMPreviewProperties.
@@ -378,6 +378,11 @@ def unregister():
# half-unloaded module state.
opening.DecorationsHandler.uninstall()
# Network path overlays attach SpaceView3D draw handlers on toggle;
# uninstall here so addon disable / Blender shutdown doesn't leak them.
decorator.MEPSystemPathDecorator.uninstall()
decorator.WallSystemPathDecorator.uninstall()
if not bpy.app.background:
for tool_data in reversed(tools):
bpy.utils.unregister_tool(tool_data.tool)
@@ -21,6 +21,7 @@
from __future__ import annotations
import math
from collections.abc import Sequence
from math import cos, pi, radians, sin, tan
from typing import Any, Literal, NamedTuple
@@ -43,6 +44,7 @@ from mathutils import Matrix, Quaternion, Vector
import bonsai.core.geometry
import bonsai.tool as tool
from bonsai.bim.decorator_cache import TokenCache
from bonsai.bim.module.drawing.gizmos import (
ARC_SEGMENTS,
DOOR_SWING_ANGLE_MAX,
@@ -62,6 +64,41 @@ def highlight_color(color, alpha=0.1):
return color
def _stroke_lines_alpha(
context: bpy.types.Context,
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
color_rgb: tuple[float, float, float],
line_width: float,
line_alpha: float,
) -> None:
"""Render ``segments`` (a list of (start, end) tuples) as one anti-aliased
LINES batch in world space. Early-returns when ``context.region`` is
unavailable (e.g. when called from a ``_RestrictContext``)."""
if not segments:
return
verts: list[tuple[float, float, float]] = []
indices: list[tuple[int, int]] = []
for start, end in segments:
base = len(verts)
verts.append(tuple(start))
verts.append(tuple(end))
indices.append((base, base + 1))
if not tool.Blender.validate_shader_batch_data(verts, indices):
return
region = getattr(context, "region", None)
if region is None:
return
shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR")
shader.bind()
shader.uniform_float("viewportSize", (region.width, region.height))
shader.uniform_float("lineWidth", line_width)
shader.uniform_float("color", (*color_rgb, line_alpha))
batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=indices)
gpu.state.blend_set("ALPHA")
batch.draw(shader)
gpu.state.blend_set("NONE")
class ProfileDecorator:
installed = None
@@ -2529,3 +2566,461 @@ def draw_polyline_segments(
_BBOX_HIGHLIGHT_LINE_WIDTH = 1.8
_BBOX_HIGHLIGHT_LINE_ALPHA = 0.8
class _ConnectedNetworkPathDecorator(tool.Blender.ViewportDecorator):
"""Shared scaffolding for "BFS-walk a connected IFC network from a selected
seed and overlay its schematic path" viewport decorators.
Subclasses implement three hooks:
``_is_seed_element(element)``: True if ``element`` can seed a walk
``_walk(start_element)``: list of network elements reachable from the seed
``_build_geometry(connected)``: ``(lines, free_points, connection_points)``
for one walk pass; free dots render in the base selected color,
connection dots in the "special" slot so junctions stand out
Lifecycle each redraw: gate on ``BIMModelProperties.show_paths`` (the
shared toggle for all network-path overlays), find the first selected
seed element, walk the network (cached per seed-GUID per IFC file), and
render lines + connection-node dots. Geometry is memoised through a
``TokenCache`` keyed on the decorator-cache token, so depsgraph / undo /
redo / load all invalidate the resolved world-space pass without
re-walking.
Install / uninstall is driven by the central addon-load handler and
by the toggle's ``update`` callback, so flipping the property takes
effect immediately without a Blender restart."""
# Network-path lines + junction dots render in ``decorator_color_selected``
# (Bonsai's palette slot for "what the user is currently inspecting"); free
# endpoints (dangling chain tips) switch to ``decorator_color_special`` so
# the end of the line stands apart from interior junctions at a glance.
LINE_WIDTH = 1.3
LINE_ALPHA = 0.85
# Sized larger than LINE_WIDTH so connection nodes read as discrete
# points rather than line thickenings.
DOT_SIZE = 4.0
# Squared distance under which two emitted dots are treated as the same
# connection node. In Blender units (typically meters), 1e-4 m ≈ 0.1 mm
# — below the precision at which two IFC reference-line endpoints would
# ever be authored as "the same join" but not so tight that float drift
# from coordinate composition misses a real coincidence.
CONNECTION_EPS_SQ = 1e-4 * 1e-4
def __init__(self) -> None:
# Two-tier cache. Walk cache keyed on (start_guid, ifc_file): re-walk
# only on selection change or file reload. Compare ``ifc_file`` with
# ``is`` (not id()) so a GC-recycled id() can't produce a false hit.
self._cached_start_guid: str | None = None
self._cached_ifc_file: Any = None
self._cached_walk: list[Any] = []
# Geometry cache: shared TokenCache so resolved world-space lines +
# dots re-build on every depsgraph / undo / redo / load.
self._geom_cache: TokenCache[
tuple[
list[tuple[tuple[float, float, float], tuple[float, float, float]]],
list[tuple[float, float, float]],
list[tuple[float, float, float]],
]
] = TokenCache()
# One-shot guards so a corrupted walk or build surfaces in the console
# once per decorator instance instead of every redraw.
self._walk_failure_logged: bool = False
self._build_failure_logged: bool = False
# Short-circuit re-running a known-broken walk or build for the same
# seed every frame; cleared the moment the user picks a different seed.
self._failed_seed_guid: str | None = None
_ABSTRACT_HOOKS = ("_is_seed_element", "_walk", "_build_geometry")
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
# Pin the template-method contract at class-definition time, mirroring
# ViewportDecorator's draw_method check: a subclass that forgets to
# override one of the three hooks would otherwise pass class creation
# and only raise NotImplementedError on the first walk — deferred long
# past the offending declaration.
missing = [
name for name in cls._ABSTRACT_HOOKS if getattr(cls, name) is getattr(_ConnectedNetworkPathDecorator, name)
]
if missing:
raise TypeError(f"{cls.__name__}: must override abstract hook(s) {sorted(missing)}")
def _is_seed_element(self, element: Any) -> bool:
raise NotImplementedError
def _walk(self, start_element: Any) -> list[Any]:
raise NotImplementedError
def _build_geometry(
self,
connected: list[Any],
) -> tuple[
list[tuple[tuple[float, float, float], tuple[float, float, float]]],
list[tuple[float, float, float]],
list[tuple[float, float, float]],
]:
"""Resolve world-space line segments + dots for one walk pass. Returns
``(lines, free_points, connection_points)`` — free dots get the base
selected color, connection dots get the special color so junctions
between two consecutive elements pop out. Never raises; skips
degenerate elements."""
raise NotImplementedError
@classmethod
def _partition_points_by_coincidence(
cls,
points: list[tuple[float, float, float]],
lines: Sequence[tuple[tuple[float, float, float], tuple[float, float, float]]] = (),
) -> tuple[list[tuple[float, float, float]], list[tuple[float, float, float]]]:
"""Split ``points`` into ``(free, connection)``. A point is "connection"
when (a) at least one other point in the list lies within
``CONNECTION_EPS_SQ`` (corner / end-to-end joins), or (b) it lies within
``CONNECTION_EPS_SQ`` of the interior of any segment in ``lines``
(T-junctions / ATPATH joins, where one wall's end lands on another
wall's axis interior rather than its endpoint). Connection points
dedupe to one representative each so coincident dots don't stack the
same color."""
eps_sq = cls.CONNECTION_EPS_SQ
n = len(points)
shared = [False] * n
for i in range(n):
xi, yi, zi = points[i]
for j in range(i + 1, n):
xj, yj, zj = points[j]
dx, dy, dz = xi - xj, yi - yj, zi - zj
if dx * dx + dy * dy + dz * dz <= eps_sq:
shared[i] = True
shared[j] = True
for i, point in enumerate(points):
if shared[i]:
continue
if cls._point_touches_any_segment_interior(point, lines, eps_sq):
shared[i] = True
free: list[tuple[float, float, float]] = []
connection: list[tuple[float, float, float]] = []
seen_connection: list[tuple[float, float, float]] = []
for i, point in enumerate(points):
if not shared[i]:
free.append(point)
continue
for existing in seen_connection:
dx, dy, dz = point[0] - existing[0], point[1] - existing[1], point[2] - existing[2]
if dx * dx + dy * dy + dz * dz <= eps_sq:
break
else:
seen_connection.append(point)
connection.append(point)
return free, connection
@staticmethod
def _point_touches_any_segment_interior(
point: tuple[float, float, float],
lines: Sequence[tuple[tuple[float, float, float], tuple[float, float, float]]],
eps_sq: float,
) -> bool:
"""True iff ``point`` lies within ``sqrt(eps_sq)`` of the interior of
any segment in ``lines``. Endpoints are excluded so a point cannot
match its own owning segment via either of that segment's tips — the
endpoint-coincidence pass already handles those cases. The qualifying
projection must land strictly inside the segment (``0 < t < 1``) AND
sit further than ``eps`` from either tip, catching ATPATH/T-junction
joins without false-flagging walls that share a corner."""
px, py, pz = point
for (ax, ay, az), (bx, by, bz) in lines:
dxa, dya, dza = px - ax, py - ay, pz - az
if dxa * dxa + dya * dya + dza * dza <= eps_sq:
continue
dxb, dyb, dzb = px - bx, py - by, pz - bz
if dxb * dxb + dyb * dyb + dzb * dzb <= eps_sq:
continue
ex, ey, ez = bx - ax, by - ay, bz - az
seg_len_sq = ex * ex + ey * ey + ez * ez
if seg_len_sq <= eps_sq:
continue
t = (dxa * ex + dya * ey + dza * ez) / seg_len_sq
if t <= 0.0 or t >= 1.0:
continue
qx, qy, qz = ax + t * ex, ay + t * ey, az + t * ez
dx, dy, dz = px - qx, py - qy, pz - qz
if dx * dx + dy * dy + dz * dz <= eps_sq:
return True
return False
def draw(self, context: bpy.types.Context) -> None:
model_props = tool.Model.get_model_props()
if not getattr(model_props, "show_paths", False):
return
ifc_file = tool.Ifc.get()
if ifc_file is None:
return
start_element = None
active = context.active_object
if active is not None:
element = tool.Ifc.get_entity(active)
if element is not None and self._is_seed_element(element):
start_element = element
if start_element is None:
for obj in context.selected_objects or []:
if obj is active:
continue
element = tool.Ifc.get_entity(obj)
if element is None or not self._is_seed_element(element):
continue
start_element = element
break
if start_element is None:
self._cached_start_guid = None
self._cached_walk = []
return
start_guid = start_element.GlobalId
if start_guid == self._failed_seed_guid:
return
if start_guid == self._cached_start_guid and ifc_file is self._cached_ifc_file and self._cached_walk:
connected = self._cached_walk
else:
try:
connected = self._walk(start_element)
except Exception:
if not self._walk_failure_logged:
import traceback
traceback.print_exc()
self._walk_failure_logged = True
self._cached_walk = []
self._failed_seed_guid = start_guid
return
self._cached_start_guid = start_guid
self._cached_ifc_file = ifc_file
self._cached_walk = connected
if not connected:
return
prefs = tool.Blender.get_addon_preferences()
line_color = tuple(prefs.decorator_color_selected[:3])
# Junction dots get the "selected" palette slot (green by default) so
# they read as the currently-inspected network's spine; free endpoints
# get the "special" slot (blue by default) so dangling line ends stand
# apart from junctions at a glance.
connection_color = line_color
free_color = tuple(prefs.decorator_color_special[:3])
try:
lines, free_points, connection_points = self._geom_cache.get_or_compute(
(start_guid, id(ifc_file)),
lambda: self._build_geometry(connected),
)
except Exception:
if not self._build_failure_logged:
import traceback
traceback.print_exc()
self._build_failure_logged = True
self._failed_seed_guid = start_guid
return
if lines:
_stroke_lines_alpha(context, lines, line_color, self.LINE_WIDTH, self.LINE_ALPHA)
if free_points or connection_points:
# POINTS via UNIFORM_COLOR; point_size_set only affects the next batch.
point_shader = gpu.shader.from_builtin("UNIFORM_COLOR")
point_shader.bind()
gpu.state.point_size_set(self.DOT_SIZE)
gpu.state.blend_set("ALPHA")
if free_points:
point_shader.uniform_float("color", (*free_color, self.LINE_ALPHA))
batch = batch_for_shader(point_shader, "POINTS", {"pos": free_points})
batch.draw(point_shader)
if connection_points:
point_shader.uniform_float("color", (*connection_color, self.LINE_ALPHA))
batch = batch_for_shader(point_shader, "POINTS", {"pos": connection_points})
batch.draw(point_shader)
gpu.state.blend_set("NONE")
class MEPSystemPathDecorator(_ConnectedNetworkPathDecorator):
"""Schematic-path overlay for the selected MEP element's connected
distribution system.
Walk: BFS through ``IfcRelConnectsPorts`` from the first selected MEP
element. Segments render as one axis line + endpoint dots. Fittings
render as:
- 2-port (transition, coupler, bend): one line port-to-port, keeping
the schematic continuous through the fitting. The "spider from
origin" pattern produces V-shaped flares when the fitting's local
origin is offset from its ports.
- 3+-port (tee, cross, branching): spider from origin to each port.
Drawing all N*(N-1)/2 port pairs would clutter the view at high N
(N=4 → 6 lines); the spider gives one line per port.
- 0-port / 1-port: degenerate, no lines (dots still emit)."""
def _is_seed_element(self, element: Any) -> bool:
return tool.System.is_mep_element(element)
def _walk(self, start_element: Any) -> list[Any]:
return tool.System.walk_connected_mep_elements(start_element)
def _build_geometry(
self,
connected: list[Any],
) -> tuple[
list[tuple[tuple[float, float, float], tuple[float, float, float]]],
list[tuple[float, float, float]],
list[tuple[float, float, float]],
]:
lines: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
port_positions: list[tuple[float, float, float]] = []
for element in connected:
if element.is_a("IfcFlowSegment"):
if not tool.Geometry.has_axis_representation(element):
continue
obj = tool.Ifc.get_object(element)
if obj is None:
continue
start_world, end_world = tool.Model.get_flow_segment_axis(obj)
lines.append((tuple(start_world), tuple(end_world)))
# Segment ports sit at the two axis endpoints — emit dots so
# the connection node is visible whether the neighbour is a
# fitting (also emits) or another segment (doesn't).
port_positions.append(tuple(start_world))
port_positions.append(tuple(end_world))
elif element.is_a("IfcFlowFitting"):
obj = tool.Ifc.get_object(element)
if obj is None:
continue
ports = tool.System.get_ports(element)
port_world_positions = [tool.System.get_port_world_position(p) for p in ports]
if len(port_world_positions) == 2:
lines.append((tuple(port_world_positions[0]), tuple(port_world_positions[1])))
elif len(port_world_positions) >= 3:
origin = obj.matrix_world.translation
for port_pos in port_world_positions:
lines.append((tuple(origin), tuple(port_pos)))
for port_pos in port_world_positions:
port_positions.append(tuple(port_pos))
free_points, connection_points = self._partition_points_by_coincidence(port_positions)
return lines, free_points, connection_points
class WallSystemPathDecorator(_ConnectedNetworkPathDecorator):
"""Schematic-path overlay for the selected wall's connected wall network.
Walk: BFS through ``IfcRelConnectsPathElements`` from the first selected
wall. Each wall renders as one reference-line segment + a dot at each
axis endpoint. Endpoints are classified by IFC topology — every wall in
the walked set inspects its ``IfcRelConnectsPathElements`` rels filtered
to walls in the same set, and uses ``Relating*``/``Related*ConnectionType``
(ATSTART / ATEND / ATPATH) to decide which endpoint participates. ATPATH
rels also emit a connection dot at the canonical join location (a T-meets
point sits on the through-wall's interior, not at any endpoint). The
framework's geometric classifier is bypassed for walls because authoring
tolerance and post-edit float drift commonly exceed the 0.1 mm coincidence
threshold, so T-junctions otherwise fell into the free bucket."""
def _is_seed_element(self, element: Any) -> bool:
return element.is_a("IfcWall") and tool.Geometry.has_axis_representation(element)
def _walk(self, start_element: Any) -> list[Any]:
return tool.Wall.walk_connected_walls(start_element)
def _build_geometry(
self,
connected: list[Any],
) -> tuple[
list[tuple[tuple[float, float, float], tuple[float, float, float]]],
list[tuple[float, float, float]],
list[tuple[float, float, float]],
]:
lines: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
refs: dict[int, tuple[tuple[float, float, float], tuple[float, float, float]]] = {}
for element in connected:
obj = tool.Ifc.get_object(element)
if obj is None:
continue
ref = tool.Wall.get_world_reference_line(obj)
if ref is None:
continue
p1, p2 = tuple(ref[0]), tuple(ref[1])
refs[element.id()] = (p1, p2)
lines.append((p1, p2))
free_points, connection_points = self._classify_endpoints_from_rels(connected, refs)
connection_points = self._dedupe_close_points(connection_points, self.CONNECTION_EPS_SQ)
return lines, free_points, connection_points
@staticmethod
def _classify_endpoints_from_rels(
connected: Sequence[Any],
refs: dict[int, tuple[tuple[float, float, float], tuple[float, float, float]]],
) -> tuple[list[tuple[float, float, float]], list[tuple[float, float, float]]]:
"""For each wall in ``connected`` with a reference line in ``refs``,
classify its endpoints by walking its ``IfcRelConnectsPathElements``
rels filtered to walls also in ``refs``. ATSTART side present →
reference-line start is a connection; ATEND side present → reference-
line end is a connection; otherwise free. ATPATH side present → emit
an extra connection dot at the canonical join via
``tool.Wall.path_connection_location_world``. Returns
``(free, connection)`` un-deduped."""
free_points: list[tuple[float, float, float]] = []
connection_points: list[tuple[float, float, float]] = []
for element in connected:
self_seg = refs.get(element.id())
if self_seg is None:
continue
sides: set[str] = set()
atpath_dots: list[tuple[float, float, float]] = []
for rel in getattr(element, "ConnectedTo", []) or ():
if not rel.is_a("IfcRelConnectsPathElements"):
continue
other = rel.RelatedElement
other_seg = refs.get(other.id()) if other is not None else None
if other_seg is None:
continue
self_type = rel.RelatingConnectionType
other_type = rel.RelatedConnectionType
sides.add(self_type)
if self_type == "ATPATH":
join = tool.Wall.path_connection_location_world(self_seg, self_type, other_seg, other_type)
atpath_dots.append(tuple(join))
for rel in getattr(element, "ConnectedFrom", []) or ():
if not rel.is_a("IfcRelConnectsPathElements"):
continue
other = rel.RelatingElement
other_seg = refs.get(other.id()) if other is not None else None
if other_seg is None:
continue
self_type = rel.RelatedConnectionType
other_type = rel.RelatingConnectionType
sides.add(self_type)
if self_type == "ATPATH":
join = tool.Wall.path_connection_location_world(self_seg, self_type, other_seg, other_type)
atpath_dots.append(tuple(join))
p1, p2 = self_seg
(connection_points if "ATSTART" in sides else free_points).append(p1)
(connection_points if "ATEND" in sides else free_points).append(p2)
connection_points.extend(atpath_dots)
return free_points, connection_points
@staticmethod
def _dedupe_close_points(
points: Sequence[tuple[float, float, float]],
eps_sq: float,
) -> list[tuple[float, float, float]]:
"""Drop later occurrences of points within ``sqrt(eps_sq)`` of an
earlier one. Used to collapse overlapping connection dots so an ATPATH
join computed at the same point as a neighbour's wall endpoint
renders once."""
result: list[tuple[float, float, float]] = []
for point in points:
for existing in result:
dx, dy, dz = point[0] - existing[0], point[1] - existing[1], point[2] - existing[2]
if dx * dx + dy * dy + dz * dz <= eps_sq:
break
else:
result.append(point)
return result
@@ -33,8 +33,10 @@ from bonsai.bim.module.drawing.decoration import CutDecorator
from bonsai.bim.module.model.data import AuthoringData
from bonsai.bim.module.model.decorator import (
BoundingBoxDecorator,
MEPSystemPathDecorator,
SlabDirectionDecorator,
WallAxisDecorator,
WallSystemPathDecorator,
)
from bonsai.bim.module.model.door import update_door_modifier_bmesh
from bonsai.bim.module.model.window import update_window_modifier_bmesh
@@ -132,6 +134,19 @@ def update_slab_direction_decorator(self: "BIMModelProperties", context: bpy.typ
SlabDirectionDecorator.uninstall()
def update_paths_decorator(self: "BIMModelProperties", context: bpy.types.Context) -> None:
"""Unified toggle for connected-element path overlays. Drives both the
MEP and wall path decorators each decorator's ``draw`` short-circuits
when its kind of element isn't selected, so leaving both installed is
cheap and lets one toggle cover any connected-element family."""
if self.show_paths:
MEPSystemPathDecorator.install(bpy.context)
WallSystemPathDecorator.install(bpy.context)
else:
MEPSystemPathDecorator.uninstall()
WallSystemPathDecorator.uninstall()
def update_measure_xyz(self: "BIMModelProperties", context: bpy.types.Context) -> None:
if self.show_bounding_box:
BoundingBoxDecorator.install(context)
@@ -354,6 +369,19 @@ class BIMModelProperties(PropertyGroup):
default=False,
update=update_slab_direction_decorator,
)
show_paths: bpy.props.BoolProperty(
name="Show Paths",
default=False,
update=update_paths_decorator,
description=(
"Trace the connected element path from the selected element. For "
"walls, follows IfcRelConnectsPathElements and draws each "
"connected wall's reference axis with endpoint dots. For MEP "
"elements, follows IfcRelConnectsPorts and draws each segment's "
"axis plus a port-to-port spider for each fitting. Toggle off to "
"skip the BFS traversal entirely."
),
)
prev_transform_orientation_slot_type: bpy.props.StringProperty(name="Previous Gizmo Orientation Type")
prev_show_gizmo_object_translate: bpy.props.BoolProperty(name="Previous Gizmo Translate")
@@ -401,6 +429,7 @@ class BIMModelProperties(PropertyGroup):
offset: float
show_wall_axis: bool
show_slab_direction: bool
show_paths: bool
prev_transform_orientation_slot_type: str
prev_show_gizmo_object_translate: bool
+4
View File
@@ -1925,6 +1925,7 @@ class BIM_PT_decorators_overlay(Panel):
aggregate_props = tool.Aggregate.get_aggregate_props()
nest_props = tool.Nest.get_nest_props()
model_props = tool.Model.get_model_props()
system_props = tool.System.get_system_props()
display_all = overlay.show_overlays
col = layout.column()
@@ -1942,6 +1943,9 @@ class BIM_PT_decorators_overlay(Panel):
row = col.row(align=True)
row.prop(model_props, "show_slab_direction", text="Slab Direction")
row = col.row(align=True)
row.prop(model_props, "show_paths", text="Element Paths")
row.prop(system_props, "should_draw_decorations", text="System Decorations")
row = col.row(align=True)
row.prop(model_props, "show_bounding_box", text="Bounding Box Dimensions")
row = col.row(align=True)
row.prop(model_props, "show_cut_decorator", text="Cut Decorator")
@@ -0,0 +1,262 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Pin the template-method contract for the connected-network-path decorator
base. The base class defines three abstract hooks (`_is_seed_element`,
`_walk`, `_build_geometry`) and an `__init_subclass__` that rejects any
subclass which leaves a hook un-overridden. Without this guard, a forgotten
override would only surface as `NotImplementedError` on the first redraw
that hit the missing hook long after the class declaration."""
import pytest
pytestmark = pytest.mark.model
_GOOD_HOOKS = {
"_is_seed_element": lambda self, element: False,
"_walk": lambda self, start_element: [],
"_build_geometry": lambda self, connected: ([], [], []),
}
def _build_subclass(name, omit=()):
from bonsai.bim.module.model.decorator import _ConnectedNetworkPathDecorator
namespace = {name: fn for name, fn in _GOOD_HOOKS.items() if name not in omit}
return type(name, (_ConnectedNetworkPathDecorator,), namespace)
@pytest.mark.parametrize("missing_hook", sorted(_GOOD_HOOKS))
def test_subclass_missing_any_single_hook_raises(missing_hook):
with pytest.raises(TypeError, match="must override abstract hook"):
_build_subclass(f"DecoratorMissing_{missing_hook}", omit=(missing_hook,))
def test_subclass_missing_all_hooks_raises_naming_each():
with pytest.raises(TypeError) as excinfo:
_build_subclass("DecoratorMissingEverything", omit=tuple(_GOOD_HOOKS))
message = str(excinfo.value)
for hook in _GOOD_HOOKS:
assert hook in message, f"missing-hook error must name {hook!r}"
def test_fully_overridden_subclass_is_accepted():
cls = _build_subclass("DecoratorWithAllHooks")
assert cls.__name__ == "DecoratorWithAllHooks"
# ---------------------------------------------------------------------------
# Pure-geometry classifier contract.
#
# Pins the free/connection split that drives the dot colors. The classifier
# is plain Python (no bpy / no ifcopenshell), so it runs unconditionally —
# the autouse Blender skip in conftest still applies but doesn't bite here.
_EPS = 1e-5 # well under CONNECTION_EPS_SQ's sqrt (1e-4)
def _cls():
from bonsai.bim.module.model.decorator import _ConnectedNetworkPathDecorator
return _ConnectedNetworkPathDecorator
def test_classifier_empty_input_returns_two_empty_lists():
free, conn = _cls()._partition_points_by_coincidence([])
assert free == []
assert conn == []
def test_classifier_single_point_is_free():
p = (1.0, 2.0, 3.0)
free, conn = _cls()._partition_points_by_coincidence([p])
assert free == [p]
assert conn == []
def test_classifier_coincident_pair_dedupes_to_one_connection():
p = (1.0, 2.0, 3.0)
near = (1.0 + _EPS, 2.0, 3.0)
free, conn = _cls()._partition_points_by_coincidence([p, near])
assert free == []
assert len(conn) == 1
def test_classifier_far_points_stay_free():
p1 = (0.0, 0.0, 0.0)
p2 = (10.0, 0.0, 0.0)
free, conn = _cls()._partition_points_by_coincidence([p1, p2])
assert sorted(free) == sorted([p1, p2])
assert conn == []
def test_classifier_t_junction_point_on_segment_interior_is_connection():
a1, a2 = (0.0, 0.0, 0.0), (5.0, 0.0, 0.0) # wall A endpoints (own segment)
b1, b2 = (2.5, -2.0, 0.0), (2.5, 0.0, 0.0) # wall B: T-meets A's midpoint
points = [a1, a2, b1, b2]
lines = [(a1, a2), (b1, b2)]
free, conn = _cls()._partition_points_by_coincidence(points, lines)
assert b2 in conn, "T-junction interior touch must be flagged as a connection"
assert a1 in free and a2 in free, "wall A free endpoints must stay free"
assert b1 in free, "wall B's far endpoint must stay free"
def test_classifier_endpoint_of_own_segment_is_not_a_t_junction():
"""A free endpoint sits exactly on its own segment's tip; the interior
check must exclude segment endpoints, not just the line interior."""
a1, a2 = (0.0, 0.0, 0.0), (5.0, 0.0, 0.0)
free, conn = _cls()._partition_points_by_coincidence([a1, a2], [(a1, a2)])
assert conn == [], "own-segment endpoints must not self-classify as connection"
assert sorted(free) == sorted([a1, a2])
def test_classifier_zero_length_segment_does_not_match():
"""A segment whose two endpoints coincide has no interior; the interior
check must skip it rather than divide by a near-zero seg_len_sq."""
a = (0.0, 0.0, 0.0)
p_far = (1.0, 1.0, 1.0)
free, conn = _cls()._partition_points_by_coincidence([p_far], [(a, a)])
assert free == [p_far]
assert conn == []
# ---------------------------------------------------------------------------
# Wall topology classifier — IFC-rel-driven endpoint classification.
#
# Pins the rule "an endpoint is a connection iff an IfcRelConnectsPathElements
# rel says so", independent of geometric coincidence. Replaces the geometric
# classifier on the wall path because authoring tolerance routinely exceeds
# the 0.1 mm epsilon, leaving T-junction dots mis-coloured.
from unittest.mock import Mock, patch
def _stub_wall(wid, connected_to=(), connected_from=()):
e = Mock()
e.id.return_value = wid
e.is_a = lambda kind: kind == "IfcWall"
e.ConnectedTo = list(connected_to)
e.ConnectedFrom = list(connected_from)
return e
def _stub_rel(relating, related, relating_type, related_type):
r = Mock()
r.is_a = lambda kind: kind == "IfcRelConnectsPathElements"
r.RelatingElement = relating
r.RelatedElement = related
r.RelatingConnectionType = relating_type
r.RelatedConnectionType = related_type
return r
def _wall_cls():
from bonsai.bim.module.model.decorator import WallSystemPathDecorator
return WallSystemPathDecorator
def test_wall_topology_single_wall_no_rels_both_endpoints_free():
a = _stub_wall(1)
refs = {1: ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))}
free, conn = _wall_cls()._classify_endpoints_from_rels([a], refs)
assert sorted(free) == sorted([(0.0, 0.0, 0.0), (5.0, 0.0, 0.0)])
assert conn == []
def test_wall_topology_l_corner_atend_to_atstart_flags_both_endpoints():
"""Two walls meeting at a corner: A's ATEND joins B's ATSTART. Each wall's
join-side endpoint flips to connection; the far endpoints stay free."""
a = _stub_wall(1)
b = _stub_wall(2)
rel = _stub_rel(relating=a, related=b, relating_type="ATEND", related_type="ATSTART")
a.ConnectedTo = [rel]
b.ConnectedFrom = [rel]
refs = {
1: ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)),
2: ((5.0, 0.0, 0.0), (5.0, 5.0, 0.0)),
}
free, conn = _wall_cls()._classify_endpoints_from_rels([a, b], refs)
assert (5.0, 0.0, 0.0) in conn, "A's ATEND endpoint at the corner must be connection"
assert (5.0, 0.0, 0.0) in conn, "B's ATSTART endpoint at the corner must be connection"
assert (0.0, 0.0, 0.0) in free, "A's far end must stay free"
assert (5.0, 5.0, 0.0) in free, "B's far end must stay free"
def test_wall_topology_t_junction_atpath_emits_canonical_join_dot():
"""B's ATEND meets A's interior (ATPATH). A's two endpoints stay free,
B's ATSTART stays free, B's ATEND is connection, and an extra connection
dot is emitted at the T-meets point computed by
``tool.Wall.path_connection_location_world``."""
a = _stub_wall(1)
b = _stub_wall(2)
rel = _stub_rel(relating=b, related=a, relating_type="ATEND", related_type="ATPATH")
a.ConnectedFrom = [rel]
b.ConnectedTo = [rel]
refs = {
1: ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0)),
2: ((2.5, -2.0, 0.0), (2.5, 0.0, 0.0)),
}
t_meets = (2.5, 0.0, 0.0)
with patch("bonsai.tool.Wall.path_connection_location_world", return_value=t_meets):
free, conn = _wall_cls()._classify_endpoints_from_rels([a, b], refs)
assert t_meets in conn, "T-meets canonical join must be a connection dot"
assert (2.5, 0.0, 0.0) in conn, "B's ATEND at the junction must also be a connection"
assert (0.0, 0.0, 0.0) in free and (5.0, 0.0, 0.0) in free, "A's endpoints stay free"
assert (2.5, -2.0, 0.0) in free, "B's ATSTART (far end) stays free"
def test_wall_topology_rel_to_wall_outside_walked_set_is_ignored():
"""A rel pointing at a wall whose id is not in ``refs`` must not classify
the participating endpoint as connection only intra-set joins count."""
a = _stub_wall(1)
outside = _stub_wall(99)
rel = _stub_rel(relating=a, related=outside, relating_type="ATEND", related_type="ATSTART")
a.ConnectedTo = [rel]
refs = {1: ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))}
free, conn = _wall_cls()._classify_endpoints_from_rels([a], refs)
assert sorted(free) == sorted([(0.0, 0.0, 0.0), (5.0, 0.0, 0.0)])
assert conn == []
def test_wall_topology_non_path_rels_are_ignored():
"""``ConnectedTo`` can carry ``IfcRelConnectsElements`` (slab clip rels);
only ``IfcRelConnectsPathElements`` contribute to wall endpoint topology."""
a = _stub_wall(1)
non_path_rel = Mock()
non_path_rel.is_a = lambda kind: kind == "IfcRelConnectsElements"
a.ConnectedTo = [non_path_rel]
refs = {1: ((0.0, 0.0, 0.0), (5.0, 0.0, 0.0))}
free, conn = _wall_cls()._classify_endpoints_from_rels([a], refs)
assert sorted(free) == sorted([(0.0, 0.0, 0.0), (5.0, 0.0, 0.0)])
assert conn == []
def test_wall_topology_dedupe_collapses_overlapping_connection_dots():
"""Two connection dots at the same world point (within eps) collapse to
one used by ``_build_geometry`` to keep ATPATH joins from stacking on
neighbour-wall endpoints."""
p = (1.0, 2.0, 3.0)
near = (1.0 + 1e-6, 2.0, 3.0)
far = (10.0, 0.0, 0.0)
result = _wall_cls()._dedupe_close_points([p, near, far], 1e-4 * 1e-4)
assert len(result) == 2
assert p in result and far in result