diff --git a/src/bonsai/bonsai/bim/handler.py b/src/bonsai/bonsai/bim/handler.py index dc4af08813..a7fc1dff67 100644 --- a/src/bonsai/bonsai/bim/handler.py +++ b/src/bonsai/bonsai/bim/handler.py @@ -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. diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index 6583144c89..d602c715a3 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -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) diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index 2d4b72c7a0..0a2c2678ac 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -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 diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index e2f8a2a9a8..e0b4952708 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -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 diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index ca1d176fda..8af70e54c9 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -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") diff --git a/src/bonsai/test/bim/module/model/test_connected_network_path_decorator.py b/src/bonsai/test/bim/module/model/test_connected_network_path_decorator.py new file mode 100644 index 0000000000..a956a8ecbe --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_connected_network_path_decorator.py @@ -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 . +# +# 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