mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user