mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-18 22:33:33 +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,
|
BoundingBoxDecorator,
|
||||||
DoorSwingReadonlyDecorator,
|
DoorSwingReadonlyDecorator,
|
||||||
MEPSegmentExtendPreviewDecorator,
|
MEPSegmentExtendPreviewDecorator,
|
||||||
|
MEPSystemPathDecorator,
|
||||||
SlabDirectionDecorator,
|
SlabDirectionDecorator,
|
||||||
WallAxisDecorator,
|
WallAxisDecorator,
|
||||||
WallFilletPreviewDecorator,
|
WallFilletPreviewDecorator,
|
||||||
|
WallSystemPathDecorator,
|
||||||
)
|
)
|
||||||
from bonsai.bim.module.model.wall import WallGizmoPreviewDecorator
|
from bonsai.bim.module.model.wall import WallGizmoPreviewDecorator
|
||||||
from bonsai.bim.module.nest.decorator import NestDecorator
|
from bonsai.bim.module.nest.decorator import NestDecorator
|
||||||
@@ -513,6 +515,8 @@ def _install_viewport_overlays() -> None:
|
|||||||
NestDecorator.uninstall()
|
NestDecorator.uninstall()
|
||||||
WallAxisDecorator.uninstall()
|
WallAxisDecorator.uninstall()
|
||||||
SlabDirectionDecorator.uninstall()
|
SlabDirectionDecorator.uninstall()
|
||||||
|
MEPSystemPathDecorator.uninstall()
|
||||||
|
WallSystemPathDecorator.uninstall()
|
||||||
WallFilletPreviewDecorator.uninstall()
|
WallFilletPreviewDecorator.uninstall()
|
||||||
BendPreviewDecorator.uninstall()
|
BendPreviewDecorator.uninstall()
|
||||||
MEPSegmentExtendPreviewDecorator.uninstall()
|
MEPSegmentExtendPreviewDecorator.uninstall()
|
||||||
@@ -532,6 +536,9 @@ def _install_viewport_overlays() -> None:
|
|||||||
WallAxisDecorator.install(bpy.context)
|
WallAxisDecorator.install(bpy.context)
|
||||||
if model_props.show_slab_direction:
|
if model_props.show_slab_direction:
|
||||||
SlabDirectionDecorator.install(bpy.context)
|
SlabDirectionDecorator.install(bpy.context)
|
||||||
|
if model_props.show_paths:
|
||||||
|
MEPSystemPathDecorator.install(bpy.context)
|
||||||
|
WallSystemPathDecorator.install(bpy.context)
|
||||||
if model_props.show_bounding_box:
|
if model_props.show_bounding_box:
|
||||||
BoundingBoxDecorator.install(bpy.context)
|
BoundingBoxDecorator.install(bpy.context)
|
||||||
# Always-installed: draw() self-polls on Scene.BIMPreviewProperties.
|
# Always-installed: draw() self-polls on Scene.BIMPreviewProperties.
|
||||||
|
|||||||
@@ -378,6 +378,11 @@ def unregister():
|
|||||||
# half-unloaded module state.
|
# half-unloaded module state.
|
||||||
opening.DecorationsHandler.uninstall()
|
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:
|
if not bpy.app.background:
|
||||||
for tool_data in reversed(tools):
|
for tool_data in reversed(tools):
|
||||||
bpy.utils.unregister_tool(tool_data.tool)
|
bpy.utils.unregister_tool(tool_data.tool)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
from collections.abc import Sequence
|
||||||
from math import cos, pi, radians, sin, tan
|
from math import cos, pi, radians, sin, tan
|
||||||
from typing import Any, Literal, NamedTuple
|
from typing import Any, Literal, NamedTuple
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ from mathutils import Matrix, Quaternion, Vector
|
|||||||
|
|
||||||
import bonsai.core.geometry
|
import bonsai.core.geometry
|
||||||
import bonsai.tool as tool
|
import bonsai.tool as tool
|
||||||
|
from bonsai.bim.decorator_cache import TokenCache
|
||||||
from bonsai.bim.module.drawing.gizmos import (
|
from bonsai.bim.module.drawing.gizmos import (
|
||||||
ARC_SEGMENTS,
|
ARC_SEGMENTS,
|
||||||
DOOR_SWING_ANGLE_MAX,
|
DOOR_SWING_ANGLE_MAX,
|
||||||
@@ -62,6 +64,41 @@ def highlight_color(color, alpha=0.1):
|
|||||||
return color
|
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:
|
class ProfileDecorator:
|
||||||
installed = None
|
installed = None
|
||||||
|
|
||||||
@@ -2529,3 +2566,461 @@ def draw_polyline_segments(
|
|||||||
|
|
||||||
_BBOX_HIGHLIGHT_LINE_WIDTH = 1.8
|
_BBOX_HIGHLIGHT_LINE_WIDTH = 1.8
|
||||||
_BBOX_HIGHLIGHT_LINE_ALPHA = 0.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.data import AuthoringData
|
||||||
from bonsai.bim.module.model.decorator import (
|
from bonsai.bim.module.model.decorator import (
|
||||||
BoundingBoxDecorator,
|
BoundingBoxDecorator,
|
||||||
|
MEPSystemPathDecorator,
|
||||||
SlabDirectionDecorator,
|
SlabDirectionDecorator,
|
||||||
WallAxisDecorator,
|
WallAxisDecorator,
|
||||||
|
WallSystemPathDecorator,
|
||||||
)
|
)
|
||||||
from bonsai.bim.module.model.door import update_door_modifier_bmesh
|
from bonsai.bim.module.model.door import update_door_modifier_bmesh
|
||||||
from bonsai.bim.module.model.window import update_window_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()
|
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:
|
def update_measure_xyz(self: "BIMModelProperties", context: bpy.types.Context) -> None:
|
||||||
if self.show_bounding_box:
|
if self.show_bounding_box:
|
||||||
BoundingBoxDecorator.install(context)
|
BoundingBoxDecorator.install(context)
|
||||||
@@ -354,6 +369,19 @@ class BIMModelProperties(PropertyGroup):
|
|||||||
default=False,
|
default=False,
|
||||||
update=update_slab_direction_decorator,
|
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_transform_orientation_slot_type: bpy.props.StringProperty(name="Previous Gizmo Orientation Type")
|
||||||
prev_show_gizmo_object_translate: bpy.props.BoolProperty(name="Previous Gizmo Translate")
|
prev_show_gizmo_object_translate: bpy.props.BoolProperty(name="Previous Gizmo Translate")
|
||||||
@@ -401,6 +429,7 @@ class BIMModelProperties(PropertyGroup):
|
|||||||
offset: float
|
offset: float
|
||||||
show_wall_axis: bool
|
show_wall_axis: bool
|
||||||
show_slab_direction: bool
|
show_slab_direction: bool
|
||||||
|
show_paths: bool
|
||||||
|
|
||||||
prev_transform_orientation_slot_type: str
|
prev_transform_orientation_slot_type: str
|
||||||
prev_show_gizmo_object_translate: bool
|
prev_show_gizmo_object_translate: bool
|
||||||
|
|||||||
@@ -1925,6 +1925,7 @@ class BIM_PT_decorators_overlay(Panel):
|
|||||||
aggregate_props = tool.Aggregate.get_aggregate_props()
|
aggregate_props = tool.Aggregate.get_aggregate_props()
|
||||||
nest_props = tool.Nest.get_nest_props()
|
nest_props = tool.Nest.get_nest_props()
|
||||||
model_props = tool.Model.get_model_props()
|
model_props = tool.Model.get_model_props()
|
||||||
|
system_props = tool.System.get_system_props()
|
||||||
display_all = overlay.show_overlays
|
display_all = overlay.show_overlays
|
||||||
|
|
||||||
col = layout.column()
|
col = layout.column()
|
||||||
@@ -1942,6 +1943,9 @@ class BIM_PT_decorators_overlay(Panel):
|
|||||||
row = col.row(align=True)
|
row = col.row(align=True)
|
||||||
row.prop(model_props, "show_slab_direction", text="Slab Direction")
|
row.prop(model_props, "show_slab_direction", text="Slab Direction")
|
||||||
row = col.row(align=True)
|
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.prop(model_props, "show_bounding_box", text="Bounding Box Dimensions")
|
||||||
row = col.row(align=True)
|
row = col.row(align=True)
|
||||||
row.prop(model_props, "show_cut_decorator", text="Cut Decorator")
|
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