From 3c20c277948443aa5e00a30e4f38431fb2776ef6 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Mon, 22 Jun 2026 10:35:55 +0200 Subject: [PATCH 01/10] Extend clip box with face handles and presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add source-based clip box presets — a dropdown menu next to the Add Clip Box button lets the user pre-size a clip box to the bounding box of a chosen IFC source: a spatial element, IFC class, type, material, profile, drawing camera frustum, status, system, group, or zone. The picker dialog uses prop_with_search so files with hundreds of materials or types remain browsable. Add interactive face resize handles — six near-invisible click-target quads render on the active clip box when its empty is the active object. Dragging a face grows or shrinks the box one-sided on that axis; the opposite face stays fixed. Ctrl+Click on a face aligns the viewport to look at that face, following Blender's numpad-view convention applied to the box's local axes so rotated boxes align orthogonally to the screen. The gizmos honour negative-scale empties so the visible cube and the clickable handles stay aligned. Add settings and info menus — a gear-icon menu next to the Enable Clipping / Show Caps toggles exposes per-file preferences (cap only IFC products, show face handles); an info-icon menu adjacent documents the gizmo gestures. A quick-access toggle row also appears in the viewport Overlay popover, greyed out when no clip box exists, and orphaned clip-box list entries now expose an X button so users can recover from external host-empty deletions. Plumbing: cap rebuild fires synchronously on gizmo release and clip-box selection change, so the cross-section overlay re-forms without waiting for the depsgraph debounce; cap eligibility honours the "Only IFC Products" toggle. Includes 121 tests covering source resolution, drag math, face visibility, gizmo registration, and the view-alignment up-axis convention. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/clip_box/__init__.py | 10 +- src/bonsai/bonsai/bim/module/clip_box/data.py | 212 ++++ .../bonsai/bim/module/clip_box/face_quad.py | 921 ++++++++++++++++++ .../bonsai/bim/module/clip_box/gizmos.py | 323 ++++++ .../bonsai/bim/module/clip_box/operator.py | 214 +++- src/bonsai/bonsai/bim/module/clip_box/prop.py | 38 + src/bonsai/bonsai/bim/module/clip_box/ui.py | 69 +- src/bonsai/bonsai/bim/ui.py | 7 + src/bonsai/bonsai/tool/clip_box.py | 353 ++++++- .../module/clip_box/test_add_for_source.py | 134 +++ .../clip_box/test_clip_only_ifc_products.py | 273 ++++++ .../bim/module/clip_box/test_face_quad.py | 298 ++++++ .../test_source_kind_forward_compat.py | 76 ++ .../test/tool/test_clip_box_for_source.py | 375 +++++++ 14 files changed, 3235 insertions(+), 68 deletions(-) create mode 100644 src/bonsai/bonsai/bim/module/clip_box/data.py create mode 100644 src/bonsai/bonsai/bim/module/clip_box/face_quad.py create mode 100644 src/bonsai/bonsai/bim/module/clip_box/gizmos.py create mode 100644 src/bonsai/test/bim/module/clip_box/test_add_for_source.py create mode 100644 src/bonsai/test/bim/module/clip_box/test_clip_only_ifc_products.py create mode 100644 src/bonsai/test/bim/module/clip_box/test_face_quad.py create mode 100644 src/bonsai/test/bim/module/clip_box/test_source_kind_forward_compat.py create mode 100644 src/bonsai/test/tool/test_clip_box_for_source.py diff --git a/src/bonsai/bonsai/bim/module/clip_box/__init__.py b/src/bonsai/bonsai/bim/module/clip_box/__init__.py index baebdd48f1..db3e3cfb4b 100644 --- a/src/bonsai/bonsai/bim/module/clip_box/__init__.py +++ b/src/bonsai/bonsai/bim/module/clip_box/__init__.py @@ -23,16 +23,24 @@ from bpy.app.handlers import persistent import bonsai.tool as tool -from . import operator, prop, ui +from . import face_quad, gizmos, operator, prop, ui classes = ( operator.BIM_OT_add_clip_box, + operator.BIM_OT_add_clip_box_for_source, + operator.BIM_OT_align_view_to_clip_face, operator.BIM_OT_duplicate_clip_box, operator.BIM_OT_remove_clip_box, operator.BIM_OT_set_active_clip_box, operator.BIM_OT_toggle_clip_box_enabled, prop.BIMClipBoxProperties, prop.BIMSceneClipBoxProperties, + face_quad.BIM_GT_box_face_quad, + face_quad.BIM_GT_box_face_outline, + gizmos.OBJECT_GGT_bim_clip_box, + ui.BIM_MT_clip_box_add_for_source, + ui.BIM_MT_clip_box_info, + ui.BIM_MT_clip_box_settings, ui.BIM_UL_clip_box, ui.BIM_PT_clip_box, ) diff --git a/src/bonsai/bonsai/bim/module/clip_box/data.py b/src/bonsai/bonsai/bim/module/clip_box/data.py new file mode 100644 index 0000000000..7d4d515959 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/clip_box/data.py @@ -0,0 +1,212 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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. + +"""EnumProperty ``items=`` callbacks for the source-based clip-box picker. + +Each callback returns ``[(id_str, label, description)]`` where ``id_str`` is +an IFC entity id stringified for entity-driven kinds, an IFC class name for +``CLASS``, or a fixed status name for ``STATUS``. The clip-box operator +turns the picked id into a ``matrix_world`` via the source-preset helper. +""" + +from __future__ import annotations + +import bonsai.tool as tool + +EnumItems = list[tuple[str, str, str]] + +# Module-level cache. Blender's EnumProperty stores raw char pointers from the +# tuples a callback returns, so the Python strings must outlive the draw call. +# Stashing the latest result per kind keeps them alive across callback firings. +_items_cache: dict[str, EnumItems] = {} + +# Sentinel id used for the "no options available" placeholder. The operator +# treats this as an invalid pick and surfaces an ERROR. +NO_OPTIONS_ID = "__none__" + + +def _cache(kind: str, items: EnumItems) -> EnumItems: + _items_cache[kind] = items + return items + + +def _no_options(label: str) -> EnumItems: + # Blender refuses to draw an EnumProperty with zero entries — show a + # placeholder so the dialog renders and the user sees the empty state. + return [(NO_OPTIONS_ID, label, "")] + + +def _label(entity, ifc_class: str | None = None) -> str: + name = (getattr(entity, "Name", None) or "Unnamed").strip() or "Unnamed" + return f"{ifc_class}: {name}" if ifc_class else name + + +def _build_items(kind: str, empty_label: str, build_fn) -> EnumItems: + """Shared shape for the IFC-driven enum callbacks. + + Returns the no-IFC placeholder if no file is loaded, then runs + ``build_fn(ifc_file)``, sorts the result alphabetically by label, and + returns the empty-result placeholder if nothing matched. The output is + always routed through the module cache. + """ + ifc = tool.Ifc.get() + if ifc is None: + return _cache(kind, _no_options("No IFC loaded")) + items = build_fn(ifc) + items.sort(key=lambda t: t[1].lower()) + if not items: + return _cache(kind, _no_options(empty_label)) + return _cache(kind, items) + + +# Top-down spatial hierarchy so the picker reads in the order an architect +# already thinks in, rather than a flat alphabetical mix. IfcSpace is excluded +# — spaces are typically empty volumes used for room metadata, so clipping to +# one rarely matches the user intent of "show me what's in this container". +SPATIAL_CLASSES: tuple[str, ...] = ( + "IfcProject", + "IfcSite", + "IfcBuilding", + "IfcBuildingStorey", +) + + +def spatial_items(self, context) -> EnumItems: + # Special-case: per-class sort within the hierarchy order rather than a + # flat alphabetical sort, so the dropdown reads project → site → building. + ifc = tool.Ifc.get() + if ifc is None: + return _cache("SPATIAL", _no_options("No IFC loaded")) + items: EnumItems = [] + for ifc_class in SPATIAL_CLASSES: + try: + entities = ifc.by_type(ifc_class, include_subtypes=False) + except RuntimeError: + continue + for entity in sorted(entities, key=lambda e: (e.Name or "").lower()): + items.append((str(entity.id()), _label(entity, ifc_class), "")) + if not items: + return _cache("SPATIAL", _no_options("No spatial containers")) + return _cache("SPATIAL", items) + + +def class_items(self, context) -> EnumItems: + # Special-case: the picker value IS the IFC class name, not an entity id, + # so the build shape differs from the other entity-driven callbacks. + ifc = tool.Ifc.get() + if ifc is None: + return _cache("CLASS", _no_options("No IFC loaded")) + # List only IFC classes ACTUALLY present in the file (not the whole + # schema), so the user picks from classes that can produce a non-empty + # clip volume. ``e.is_a()`` returns the most specific class per element. + present = sorted({e.is_a() for e in ifc.by_type("IfcProduct")}) + if not present: + return _cache("CLASS", _no_options("No products")) + return _cache("CLASS", [(cls, cls, "") for cls in present]) + + +def type_items(self, context) -> EnumItems: + return _build_items( + "TYPE", "No types defined", + lambda ifc: [(str(e.id()), _label(e, e.is_a()), "") for e in ifc.by_type("IfcTypeProduct")], + ) + + +def material_items(self, context) -> EnumItems: + return _build_items( + "MATERIAL", "No materials defined", + lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcMaterial")], + ) + + +def profile_items(self, context) -> EnumItems: + # ProfileName is optional. Skip unnamed profiles — they can't be + # meaningfully picked from a flat list. + return _build_items( + "PROFILE", "No named profiles", + lambda ifc: [ + (str(e.id()), f"{e.is_a()}: {e.ProfileName}", "") + for e in ifc.by_type("IfcProfileDef") + if getattr(e, "ProfileName", None) + ], + ) + + +def drawing_items(self, context) -> EnumItems: + return _build_items( + "DRAWING", "No drawings defined", + lambda ifc: [ + (str(e.id()), _label(e), "") + for e in ifc.by_type("IfcAnnotation") + if e.ObjectType == "DRAWING" + ], + ) + + +# Display labels for each status value. The id strings on the left are the +# canonical Pset_*Common.Status enum values accepted by Bonsai's status query. +STATUS_LABELS: tuple[tuple[str, str], ...] = ( + ("No Status", "No Status"), + ("NEW", "New"), + ("EXISTING", "Existing"), + ("DEMOLISH", "Demolish"), + ("TEMPORARY", "Temporary"), + ("OTHER", "Other"), + ("NOTKNOWN", "Not Known"), + ("UNSET", "Unset"), +) + + +def status_items(self, context) -> EnumItems: + # Fixed enum; no IFC needed. Still routed through the cache to share the + # same string-lifetime guarantee as the other callbacks. + return _cache("STATUS", [(value, label, "") for value, label in STATUS_LABELS]) + + +def system_items(self, context) -> EnumItems: + # IfcStructuralAnalysisModel is a structural-grouping container, not a + # distribution system — excluded to match Bonsai's other system pickers. + return _build_items( + "SYSTEM", "No systems defined", + lambda ifc: [ + (str(e.id()), _label(e, e.is_a()), "") + for e in ifc.by_type("IfcSystem") + if not e.is_a("IfcStructuralAnalysisModel") + ], + ) + + +def group_items(self, context) -> EnumItems: + # include_subtypes=False so IfcSystem and IfcZone instances don't appear + # under Group as well — those get their own picker entries. + return _build_items( + "GROUP", "No groups defined", + lambda ifc: [ + (str(e.id()), _label(e), "") + for e in ifc.by_type("IfcGroup", include_subtypes=False) + ], + ) + + +def zone_items(self, context) -> EnumItems: + return _build_items( + "ZONE", "No zones defined", + lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcZone")], + ) diff --git a/src/bonsai/bonsai/bim/module/clip_box/face_quad.py b/src/bonsai/bonsai/bim/module/clip_box/face_quad.py new file mode 100644 index 0000000000..0e27d6eed0 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/clip_box/face_quad.py @@ -0,0 +1,921 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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. + +"""Generic face-quad resize gizmos for any axis-aligned local box. + +This module contains the box-agnostic core of the interactive +face-resize gizmos: two Gizmo classes (a near-invisible click target +welded to each face, and a thin colored edge outline), a per-redraw +orchestrator that places six of each on a box, and the pure one-sided +resize arithmetic. None of it knows about IFC, clip boxes, or +``BIMSceneClipBoxProperties`` — a future camera-view-box adapter can +reuse the same classes and helpers. + +Consumer contract — the adapter group must: + +1. Create six ``BIM_GT_box_face_quad`` and six ``BIM_GT_box_face_outline`` + instances at ``setup()`` time, in :data:`FACE_ROUTES` order, and bind + each quad's ``move_get_cb`` / ``move_set_cb`` to closures that read + and mutate the box's host (e.g. an Empty's ``location`` / ``scale``). +2. Call :func:`apply_face_quad_layout` from ``refresh()`` / + ``draw_prepare()`` with the box's local-frame ``bmin`` / ``bmax``, + the host's ``matrix_world``, the OBB rotation as a 4x4 + (``Matrix.Identity(4)`` when the rotation rides in ``matrix_world``), + and the current ``region`` / ``rv3d``. +3. Implement ``_lock_for(active_gz)`` / ``_unlock_all()`` on the group + for drag mutual exclusion; the quad's ``invoke`` / ``exit`` call them. + +The resize arithmetic in :func:`compute_face_resize` is pure: feed it +the modal scalar plus drag-start snapshots and it returns the host's +new scale-on-axis and new origin location. +""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from typing import Any + +import bpy +from bpy_extras.view3d_utils import location_3d_to_region_2d, region_2d_to_location_3d +from mathutils import Matrix, Vector + + +# --------------------------------------------------------------------------- +# Public iteration order +# --------------------------------------------------------------------------- + +# (axis, is_max) pairs. The adapter group's ``setup()`` MUST create its +# six face-quad gizmos in this order so positional indexing into the +# layout helper stays correct. +FACE_ROUTES: tuple[tuple[int, bool], ...] = ( + (0, False), + (0, True), + (1, False), + (1, True), + (2, False), + (2, True), +) + + +# --------------------------------------------------------------------------- +# Public visual constants (adapter reads these in setup()) +# --------------------------------------------------------------------------- + +# Standard XYZ axis colors (Blender convention). +AXIS_COLOR: dict[int, tuple[float, float, float]] = { + 0: (1.0, 0.2, 0.2), + 1: (0.2, 1.0, 0.2), + 2: (0.2, 0.4, 1.0), +} + +# Documented "selectable but unpainted" trick: the GPU still writes the +# selection buffer at this alpha so clicks register, but no visible +# pixels are produced. +FACE_QUAD_ALPHA: float = 0.001 + +# Very faint hover tint — just enough to confirm "you're aiming at this +# face" without painting visibly over geometry behind it. +FACE_QUAD_ALPHA_HIGHLIGHT: float = 0.04 + +# Setup-time default for ``select_bias``; the layout helper overwrites +# it per frame to the front-facing or halo value below. Kept below the +# canonical arrow bias so a bailed frame can't let a front quad steal +# clicks meant for a hidden control. +FACE_QUAD_SELECT_BIAS: float = 0.5 + + +# --------------------------------------------------------------------------- +# Internal constants +# --------------------------------------------------------------------------- + +# Unit quad in the local XY plane spanning [-0.5, 0.5]^2 at z=0. Two +# CCW triangles viewed from +Z. matrix_basis stretches it onto the +# face's perpendicular extents. +_QUAD_TRIS: list[tuple[float, float, float]] = [ + (-0.5, -0.5, 0.0), + (0.5, -0.5, 0.0), + (0.5, 0.5, 0.0), + (-0.5, -0.5, 0.0), + (0.5, 0.5, 0.0), + (-0.5, 0.5, 0.0), +] + +# Unit-quad outline as 4 line segments in the local XY plane at z=0. +_QUAD_OUTLINE_LINES: list[tuple[float, float, float]] = [ + (-0.5, -0.5, 0.0), + (0.5, -0.5, 0.0), + (0.5, -0.5, 0.0), + (0.5, 0.5, 0.0), + (0.5, 0.5, 0.0), + (-0.5, 0.5, 0.0), + (-0.5, 0.5, 0.0), + (-0.5, -0.5, 0.0), +] + +# Degenerate zero-area triangle for hidden back-facing quads with no +# visible-adjacent neighbours (rare orientation). Blender tolerates +# this; the gizmo is hidden anyway so nothing renders. +_EMPTY_TRIS: list[tuple[float, float, float]] = [ + (0.0, 0.0, 0.0), + (0.0, 0.0, 0.0), + (0.0, 0.0, 0.0), +] + +# Rotates the gizmo's local +Z onto the outward face normal in the +# box's local frame. Right-hand rotation around the named axis. +_AXIS_ORIENT: dict[tuple[int, bool], Matrix] = { + (0, False): Matrix.Rotation(-math.pi / 2, 4, "Y"), + (0, True): Matrix.Rotation(math.pi / 2, 4, "Y"), + (1, False): Matrix.Rotation(math.pi / 2, 4, "X"), + (1, True): Matrix.Rotation(-math.pi / 2, 4, "X"), + (2, False): Matrix.Rotation(math.pi, 4, "X"), + (2, True): Matrix.Identity(4), +} + +# Per-face mapping from face-quad local axes to local box axes for the +# perpendicular-extent scale. ``(w_axis, h_axis)`` — the box-local axis +# indices the quad's local X and Y span after the orientation rotation. +_QUAD_PERP_AXES: dict[tuple[int, bool], tuple[int, int]] = { + (0, False): (2, 1), + (0, True): (2, 1), + (1, False): (0, 2), + (1, True): (0, 2), + (2, False): (0, 1), + (2, True): (0, 1), +} + +# Front-facing quad sits ABOVE the halo strips so the cursor on the +# visible face area always grabs the visible face, never accidentally +# routes to a back-face halo strip in an adjacent screen region. +_FACE_QUAD_FRONT_FACING_SELECT_BIAS: float = 1.5 +_FACE_QUAD_HALO_FRAME_SELECT_BIAS: float = 1.0 + +# Target halo-strip thickness in screen pixels. The world-space margin +# is recomputed per frame so the rim stays a roughly constant on-screen +# size regardless of viewport zoom. +_FACE_QUAD_HALO_TARGET_PIXELS: float = 20.0 + +# Minimum world half-extent a face resize may shrink to. Stops a drag +# from collapsing the host to zero or negative scale. +_MIN_HALF_EXTENT: float = 1e-4 + + +# --------------------------------------------------------------------------- +# Pure predicates (testable without Blender) +# --------------------------------------------------------------------------- + +Vec3 = tuple[float, float, float] + + +def face_outward_axis_local(axis: int, is_max: bool) -> Vec3: + """Un-rotated outward face normal in the box's local AABB coords. + + For ``(axis=0, is_max=True)`` returns ``(+1, 0, 0)``; for the −X + face ``(-1, 0, 0)``; etc. The rotated world normal is obtained by + applying the host's rotation and the OBB rotation: + ``mw_rot @ cage_rotation @ this``. + """ + sign = 1.0 if is_max else -1.0 + out = [0.0, 0.0, 0.0] + out[axis] = sign + return (out[0], out[1], out[2]) + + +def front_facing_face_mask( + face_normals_world: Sequence[Vec3], + view_dir_world: Vec3, + eps: float = 1e-6, +) -> tuple[bool, ...]: + """Which of the 6 box faces point toward the camera. + + A face is front-facing iff its outward normal points AGAINST the + view direction (``dot(normal, view_dir) < -eps``). The ``-eps`` + margin prevents flicker at grazing angles. + + ``face_normals_world`` must be in :data:`FACE_ROUTES` order; returns + a 6-tuple of bool parallel to that order. + """ + if len(face_normals_world) != 6: + msg = f"expected 6 face normals, got {len(face_normals_world)}" + raise ValueError(msg) + vx, vy, vz = view_dir_world + return tuple((n[0] * vx + n[1] * vy + n[2] * vz) < -eps for n in face_normals_world) + + +def view_axis_parallel_face_mask( + face_normals_world: Sequence[Vec3], + view_dir_world: Vec3, + threshold: float = 0.95, +) -> tuple[bool, ...]: + """Which faces have normals (anti-)parallel to the view direction. + + True iff ``abs(dot(normal, view_dir)) >= threshold`` — i.e. the + face is nearly perpendicular to the screen plane. Provided as a + pure predicate for callers that want to detect degenerate-drag + conditions; the layout helper itself no longer gates on it. + """ + if len(face_normals_world) != 6: + msg = f"expected 6 face normals, got {len(face_normals_world)}" + raise ValueError(msg) + vx, vy, vz = view_dir_world + return tuple( + abs(n[0] * vx + n[1] * vy + n[2] * vz) >= threshold for n in face_normals_world + ) + + +# --------------------------------------------------------------------------- +# Pure resize arithmetic +# --------------------------------------------------------------------------- + + +def compute_face_resize( + *, + value: float, + init_world_half: float, + init_location: tuple[float, float, float], + world_axis: tuple[float, float, float], + display_size: float, +) -> tuple[float, tuple[float, float, float]]: + """Pure one-sided face-resize arithmetic. + + Returns ``(new_scale_axis, new_location)`` — the host's new scale + on the dragged axis and its new world origin — such that the + dragged face moves by the modal's outward delta while the OPPOSITE + face stays put. + + ``value`` is ``init + delta``, where ``init`` is the unsigned + drag-start world half-extent and ``delta`` is the cursor projection + onto the face's OUTWARD world normal. Realized half-extent is + clamped to a small floor; the location shift uses the realized + (post-clamp) delta so the opposite face stays fixed even at the + clamp. + """ + face_delta = value - init_world_half + new_world_half = init_world_half + 0.5 * face_delta + if new_world_half < _MIN_HALF_EXTENT: + new_world_half = _MIN_HALF_EXTENT + realized_delta = 2.0 * (new_world_half - init_world_half) + + ds = display_size if display_size != 0.0 else 1.0 + new_scale_axis = new_world_half / ds + shift = 0.5 * realized_delta + new_location = ( + init_location[0] + shift * world_axis[0], + init_location[1] + shift * world_axis[1], + init_location[2] + shift * world_axis[2], + ) + return new_scale_axis, new_location + + +# --------------------------------------------------------------------------- +# Internal geometry helpers +# --------------------------------------------------------------------------- + + +def _compute_face_quad_scale( + bmin: Any, bmax: Any, axis: int, is_max: bool +) -> tuple[float, float]: + """Return ``(w, h)`` for the face quad's scale matrix.""" + w_axis, h_axis = _QUAD_PERP_AXES[(axis, is_max)] + w = float(bmax[w_axis] - bmin[w_axis]) + h = float(bmax[h_axis] - bmin[h_axis]) + return w, h + + +def _shared_edge_corner_keys( + axis_a: int, is_max_a: bool, axis_b: int, is_max_b: bool +) -> tuple[tuple[int, int, int], tuple[int, int, int]] | None: + """Return the 2 corner-bit triples shared by two adjacent faces. + + Corner keys are 3-tuples of bits (0 = bmin, 1 = bmax). The two + returned corners are ordered with the free-axis bit ascending. + """ + if axis_a == axis_b: + return None + free_axis = 3 - axis_a - axis_b + bit_a = 1 if is_max_a else 0 + bit_b = 1 if is_max_b else 0 + corner_lo = [0, 0, 0] + corner_hi = [0, 0, 0] + corner_lo[axis_a] = bit_a + corner_hi[axis_a] = bit_a + corner_lo[axis_b] = bit_b + corner_hi[axis_b] = bit_b + corner_lo[free_axis] = 0 + corner_hi[free_axis] = 1 + return ( + (corner_lo[0], corner_lo[1], corner_lo[2]), + (corner_hi[0], corner_hi[1], corner_hi[2]), + ) + + +def _face_corner_keys( + axis: int, is_max: bool +) -> tuple[ + tuple[int, int, int], + tuple[int, int, int], + tuple[int, int, int], + tuple[int, int, int], +]: + """Return the 4 corner-bit triples of a face in CCW order. + + Triangulation as ``[(0,1,2), (0,2,3)]`` covers the whole face with + two non-overlapping triangles. + """ + fixed_bit = 1 if is_max else 0 + free_axes = [a for a in (0, 1, 2) if a != axis] + fa0, fa1 = free_axes + corners = [] + for ka, kb in ((0, 0), (1, 0), (1, 1), (0, 1)): + key = [0, 0, 0] + key[axis] = fixed_bit + key[fa0] = ka + key[fa1] = kb + corners.append((key[0], key[1], key[2])) + return (corners[0], corners[1], corners[2], corners[3]) + + +def _build_strip_tris_relative( + edge_p0_local: tuple[float, float, float], + edge_p1_local: tuple[float, float, float], + extrusion_local: tuple[float, float, float], +) -> list[tuple[float, float, float]]: + """Build two CCW triangles (6 vertices) for a thin halo strip. + + All inputs are in coords relative to the gizmo's ``matrix_basis`` + anchor. The strip runs along ``[edge_p0_local, edge_p1_local]`` and + extrudes by ``extrusion_local`` perpendicular to the edge. + """ + p0x, p0y, p0z = edge_p0_local + p1x, p1y, p1z = edge_p1_local + ex, ey, ez = extrusion_local + p0e = (p0x + ex, p0y + ey, p0z + ez) + p1e = (p1x + ex, p1y + ey, p1z + ez) + return [ + (p0x, p0y, p0z), + p0e, + p1e, + (p0x, p0y, p0z), + p1e, + (p1x, p1y, p1z), + ] + + +def _strips_geometry_changed(quad_gz, face_quad_local, all_tris) -> bool: + """True if the back-face quad's geometry differs from the cached upload. + + Pure orbit/pan doesn't change either the box pose or the cage + rotation, so the computed strip vertices are byte-identical to the + previous frame's. Hitting the cache lets the back-facing branch + skip ``new_custom_shape`` and the GPU upload. + """ + cached = getattr(quad_gz, "_strips_cache_key", None) + last_state = getattr(quad_gz, "_last_geometry_state", None) + key = (face_quad_local, all_tris) + if cached is None or last_state != "strips" or cached != key: + quad_gz._strips_cache_key = key + quad_gz._last_geometry_state = "strips" + return True + return False + + +def _compute_face_basis( + mw: Any, + mw_rot: Any, + cage_rotation: Any, + pivot_local: Any, + face_local: Any, + orient: Any, +) -> tuple[Any, Any]: + """World-space (translation, outward-normal-direction) for one face.""" + rotated_face_local = ( + cage_rotation.to_3x3() @ (face_local - pivot_local) + pivot_local + ) + face_world = mw @ rotated_face_local + world_axis = ( + mw_rot @ cage_rotation.to_3x3() @ (orient.to_3x3() @ Vector((0.0, 0.0, 1.0))) + ).normalized() + return face_world, world_axis + + +def _compose_face_matrix_basis( + face_world: Any, + mw_rot_scale: Any, + cage_rotation: Any, + orient: Any, + w: float, + h: float, +) -> Any: + """Compose the 5-term ``matrix_basis`` for a face-plane gizmo. + + Returns ``Translation @ mw_rot_scale @ cage_rotation @ orient @ + Diagonal((w, h, 1, 1))`` — maps a unit-square local quad onto the + world-space face rectangle, including the host's scale. + """ + quad_scale = Matrix.Diagonal((w, h, 1.0, 1.0)) + return ( + Matrix.Translation(face_world) + @ mw_rot_scale.to_4x4() + @ cage_rotation + @ orient + @ quad_scale + ) + + +def _compute_box_corners_world( + bmin: Any, + bmax: Any, + pivot_local: Any, + cage_rotation_3x3: Any, + mw: Any, +) -> dict[tuple[int, int, int], Any]: + """Return the 8 OBB corners in world space, keyed by bit-triple.""" + corners: dict[tuple[int, int, int], Any] = {} + for ix in (0, 1): + for iy in (0, 1): + for iz in (0, 1): + local = Vector( + ( + float(bmax.x if ix else bmin.x), + float(bmax.y if iy else bmin.y), + float(bmax.z if iz else bmin.z), + ) + ) + rotated = cage_rotation_3x3 @ (local - pivot_local) + pivot_local + corners[(ix, iy, iz)] = mw @ rotated + return corners + + +def _abs_scale_matrix(mw: Any) -> Any: + """Return a copy of ``mw`` with all scale components ``abs()``-ed. + + Without this, a negative-scale host produces a visible/clickable + face inversion: ``mw @ local_vec`` flips the +axis face onto the + -axis world side, while the rotation-only normal stays pointing + in the +axis direction — so the gizmo for "the +X face" sits at + world -X but reports its outward normal as +X. + """ + loc, rot, scale = mw.decompose() + abs_scale = Vector((abs(scale.x), abs(scale.y), abs(scale.z))) + return Matrix.LocRotScale(loc, rot, abs_scale) + + +def _world_radius_to_screen_pixels( + region: Any, + rv3d: Any, + center_world: Vector, + world_radius: float, + *, + min_pixels: float = 0.0, +) -> float: + """Return the on-screen pixel radius of a world-space circle. + + Projects ``center_world`` and a sample point offset by + ``world_radius`` along the camera's view-aligned right axis to + region pixels, and returns the screen-pixel distance between them. + Falls back to ``min_pixels`` if either projection fails. + """ + try: + view_inv = rv3d.view_matrix.inverted() + right = Vector((view_inv[0][0], view_inv[0][1], view_inv[0][2])).normalized() + except (AttributeError, ValueError): + right = Vector((1.0, 0.0, 0.0)) + sample_world = center_world + right * world_radius + return _world_segment_to_screen_pixels( + region, rv3d, center_world, sample_world, min_pixels=min_pixels + ) + + +def _world_segment_to_screen_pixels( + region: Any, + rv3d: Any, + p0_world: Vector, + p1_world: Vector, + *, + min_pixels: float = 0.0, +) -> float: + """Return the on-screen pixel length of an arbitrary world segment. + + Unlike :func:`_world_radius_to_screen_pixels`, this measures the + ACTUAL projected length of the segment — foreshortening included. + Use this when the segment direction is known to be oblique to the + screen plane (e.g. a back face's outward normal): a perpendicular + radius measurement overestimates the on-screen length, leaving + halo strips visually narrower than the requested pixel target. + """ + p0 = location_3d_to_region_2d(region, rv3d, p0_world) + p1 = location_3d_to_region_2d(region, rv3d, p1_world) + if not p0 or not p1: + return min_pixels + dx = float(p1[0]) - float(p0[0]) + dy = float(p1[1]) - float(p0[1]) + return max(min_pixels, (dx * dx + dy * dy) ** 0.5) + + +# --------------------------------------------------------------------------- +# Gizmo classes +# --------------------------------------------------------------------------- + + +class BIM_GT_box_face_quad(bpy.types.Gizmo): # noqa: N801 — Blender bl_idname convention + """Near-invisible face-quad click target with drag-to-resize modal. + + Geometry: a unit quad in the local XY plane at z=0. The adapter + group's layout helper rotates and scales it onto the face plane; + the quad is welded to the world face (``use_draw_scale = False``). + """ + + bl_idname = "BIM_GT_box_face_quad" + bl_target_properties = ({"id": "offset", "type": "FLOAT", "array_length": 1},) + + __slots__ = ( + "custom_shape", + "custom_shape_select", + "init_value", + "move_get_cb", + "move_set_cb", + "axis", + "start_location", + "depth_point", + "callback", + "ctrl_click_cb", + "_group", + "_face_axis", + "is_max", + "_drag_snapshot", + "_last_geometry_state", + "_strips_cache_key", + ) + + def draw(self, context: Any) -> None: + self.draw_custom_shape(self.custom_shape) + + def draw_select(self, context: Any, select_id: int) -> None: + # Back-facing quads bind ``custom_shape_select`` to the halo-strip + # TRIS so clicks OUTSIDE the box silhouette catch the back face. + # Front-facing quads leave it None and reuse ``custom_shape``. + shape = getattr(self, "custom_shape_select", None) or self.custom_shape + self.draw_custom_shape(shape, select_id=select_id) + + def setup(self) -> None: + if not hasattr(self, "custom_shape_"): + self.custom_shape = self.new_custom_shape("TRIS", _QUAD_TRIS) + self.custom_shape_select = None + # Quad welded to world geometry — clicks must align with the + # visible face, not a screen-size widget. Disables Blender's + # per-frame pixel-constant autoscale. + self.use_draw_scale = False + + # ---- modal ------------------------------------------------------------- + + def invoke(self, context: Any, event: Any) -> set[str]: + # CTRL+click handoff: dispatch a host-defined callback (e.g. + # align-view) instead of starting a drag. + if event.ctrl and getattr(self, "ctrl_click_cb", None) is not None: + self.ctrl_click_cb(context, event) + return {"FINISHED"} + + region = context.region + rv3d = context.region_data + if region is None or rv3d is None: + return {"CANCELLED"} + self.init_value = self.move_get_cb() + # Freeze the projection plane at invoke — projection-plane + # drift on tilted axes causes exponential delta runaway. + self.depth_point = self.matrix_basis.translation.copy() + self.start_location = region_2d_to_location_3d( + region, rv3d, (event.mouse_x, event.mouse_y), self.depth_point + ) + + if getattr(self, "_group", None) is not None: + self._group._lock_for(self) + return {"RUNNING_MODAL"} + + def exit(self, context: Any, cancel: bool) -> None: + try: + if context.area: + context.area.header_text_set(None) + if cancel: + self.move_set_cb(self.init_value) + if hasattr(self, "callback"): + self.callback(self.move_get_cb()) + finally: + self._drag_snapshot = None + if getattr(self, "_group", None) is not None: + self._group._unlock_all() + + def modal(self, context: Any, event: Any, tweak: set[str]) -> set[str]: + if event.type == "ESC": + return {"CANCELLED"} + region = context.region + rv3d = context.region_data + if region is None or rv3d is None: + return {"CANCELLED"} + end_location = region_2d_to_location_3d( + region, rv3d, (event.mouse_x, event.mouse_y), self.depth_point + ) + delta = (end_location - self.start_location).dot(self.axis) + if "SNAP" in tweak: + delta = round(delta, 1) + if "PRECISE" in tweak: + delta /= 10.0 + self.move_set_cb(self.init_value + delta) + if context.area: + context.area.header_text_set( + f"Value: {self.move_get_cb():.3f} ({delta:.3f})" + ) + return {"RUNNING_MODAL"} + + +class BIM_GT_box_face_outline(bpy.types.Gizmo): # noqa: N801 — Blender bl_idname convention + """Thin non-interactive colored edge outline for one face. + + Drawn as 4 line segments in the face plane. The layout helper + toggles its ``alpha`` between near-zero and ``1.0`` based on the + sibling face-quad's ``is_highlight`` state — so hovering the quad + lights up the matching outline. ``hide_select = True`` keeps the + outline out of the GPU selection buffer. + """ + + bl_idname = "BIM_GT_box_face_outline" + bl_target_properties = () + + __slots__ = ( + "custom_shape", + "_face_axis", + "is_max", + "_last_outline_state", + ) + + def draw(self, context: Any) -> None: + self.draw_custom_shape(self.custom_shape) + + def draw_select(self, context: Any, select_id: int) -> None: + return None + + def setup(self) -> None: + if not hasattr(self, "custom_shape_"): + self.custom_shape = self.new_custom_shape("LINES", _QUAD_OUTLINE_LINES) + self.use_draw_scale = False + self.hide_select = True + self._last_outline_state = "unit" + + +# --------------------------------------------------------------------------- +# Per-redraw orchestrator +# --------------------------------------------------------------------------- + + +def apply_face_quad_layout( + *, + quad_gizmos, + outline_gizmos, + bmin: Any, + bmax: Any, + matrix_world: Any, + cage_rotation: Any, + region: Any, + rv3d: Any, + locked: bool, +) -> None: + """Lay out 6 face quads + 6 outlines on the box for this redraw. + + ``quad_gizmos`` / ``outline_gizmos`` are length-6 sequences in + :data:`FACE_ROUTES` order. ``bmin`` / ``bmax`` are the box corners + in the host's local frame; ``matrix_world`` is the host's world + matrix; ``cage_rotation`` is the OBB rotation as a 4x4 (use + ``Matrix.Identity(4)`` when rotation rides in ``matrix_world``). + ``region`` / ``rv3d`` drive the view-dependent front/back split and + the screen-constant halo margin; passing ``rv3d = None`` bails. + + Negative scale on the host is normalized to positive internally so + the visible cube and the clickable face gizmos stay aligned — + callers don't need to pre-process ``matrix_world``. + + When ``locked`` (a drag is active), ``hide`` / ``select_bias`` + writes are skipped — the active quad's geometry is still refreshed + so it tracks the moving box. + """ + if rv3d is None or getattr(rv3d, "view_rotation", None) is None: + return + if len(quad_gizmos) != 6 or len(outline_gizmos) != 6: + return + + mw = _abs_scale_matrix(matrix_world) + mw_rot = mw.to_quaternion().to_matrix() + mw_rot_scale = mw.to_3x3() + cage_rotation_3x3 = cage_rotation.to_3x3() + pivot_local = (bmin + bmax) * 0.5 + box_center_local = pivot_local + face_midpoints_local = { + (0, False): Vector((float(bmin.x), box_center_local.y, box_center_local.z)), + (0, True): Vector((float(bmax.x), box_center_local.y, box_center_local.z)), + (1, False): Vector((box_center_local.x, float(bmin.y), box_center_local.z)), + (1, True): Vector((box_center_local.x, float(bmax.y), box_center_local.z)), + (2, False): Vector((box_center_local.x, box_center_local.y, float(bmin.z))), + (2, True): Vector((box_center_local.x, box_center_local.y, float(bmax.z))), + } + + view_dir = (rv3d.view_rotation @ Vector((0.0, 0.0, -1.0))).normalized() + view_dir_tuple = (float(view_dir.x), float(view_dir.y), float(view_dir.z)) + face_normals_world = [] + for route_axis, route_is_max in FACE_ROUTES: + axis_local = Vector(face_outward_axis_local(route_axis, route_is_max)) + n_world = (mw_rot @ cage_rotation_3x3 @ axis_local).normalized() + face_normals_world.append( + (float(n_world.x), float(n_world.y), float(n_world.z)) + ) + front = front_facing_face_mask(tuple(face_normals_world), view_dir_tuple) + + box_center_world = mw @ pivot_local + corners_world = _compute_box_corners_world( + bmin, bmax, pivot_local, cage_rotation_3x3, mw + ) + route_to_index = {route: i for i, route in enumerate(FACE_ROUTES)} + + for i, route in enumerate(FACE_ROUTES): + quad_gz = quad_gizmos[i] + is_front = front[i] + axis_b, is_max_b = route + + # Place the colored OUTLINE on every face using the same composed + # face matrix the front-facing solid quad uses. Hidden/shown via + # alpha at the end of the pass. + outline_orient = _AXIS_ORIENT[route] + outline_face_world, _outline_axis = _compute_face_basis( + mw, + mw_rot, + cage_rotation, + pivot_local, + face_midpoints_local[route], + outline_orient, + ) + ow, oh = _compute_face_quad_scale(bmin, bmax, axis_b, is_max_b) + outline_gizmos[i].matrix_basis = _compose_face_matrix_basis( + outline_face_world, mw_rot_scale, cage_rotation, outline_orient, ow, oh + ) + + if is_front: + if not locked: + quad_gz.hide = False + quad_gz.select_bias = _FACE_QUAD_FRONT_FACING_SELECT_BIAS + orient = _AXIS_ORIENT[route] + face_world, world_axis = _compute_face_basis( + mw, + mw_rot, + cage_rotation, + pivot_local, + face_midpoints_local[route], + orient, + ) + w, h = _compute_face_quad_scale(bmin, bmax, axis_b, is_max_b) + quad_gz.matrix_basis = _compose_face_matrix_basis( + face_world, mw_rot_scale, cage_rotation, orient, w, h + ) + quad_gz.axis = world_axis + if getattr(quad_gz, "_last_geometry_state", None) != "solid": + quad_gz.custom_shape = quad_gz.new_custom_shape("TRIS", _QUAD_TRIS) + quad_gz.custom_shape_select = None + quad_gz._last_geometry_state = "solid" + continue + + # Back-facing: anchor at the back face centre; build halo strips + # in the planes of the adjacent FRONT faces, extruded outside + # the silhouette toward this face's outward normal. + face_world = mw @ ( + cage_rotation_3x3 @ (face_midpoints_local[route] - pivot_local) + + pivot_local + ) + quad_gz.matrix_basis = Matrix.Translation(face_world) + quad_gz.axis = ( + mw_rot + @ cage_rotation_3x3 + @ Vector(face_outward_axis_local(axis_b, is_max_b)) + ).normalized() + + adjacent_front_routes = [ + (axis_a, is_max_a) + for axis_a in range(3) + if axis_a != axis_b + for is_max_a in (False, True) + if front[route_to_index[(axis_a, is_max_a)]] + ] + # Per-face world margin: measure the screen-projected length of + # ONE world unit along THIS face's outward normal. The world + # margin that yields ~N pixels on screen is then ``N / length``. + # Foreshortening on oblique faces shortens the projected step, + # so the world step must grow to keep the strip the same width + # on screen. + face_world_margin = 0.0 + if region is not None: + sample_end = box_center_world + quad_gz.axis * 1.0 + screen_step = _world_segment_to_screen_pixels( + region, rv3d, box_center_world, sample_end, min_pixels=0.0 + ) + if screen_step > 0.0: + face_world_margin = _FACE_QUAD_HALO_TARGET_PIXELS / screen_step + if face_world_margin <= 0.0 or not adjacent_front_routes: + if not locked: + quad_gz.hide = True + quad_gz.select_bias = _FACE_QUAD_HALO_FRAME_SELECT_BIAS + if getattr(quad_gz, "_last_geometry_state", None) != "empty": + quad_gz.custom_shape = quad_gz.new_custom_shape("TRIS", _EMPTY_TRIS) + quad_gz.custom_shape_select = None + quad_gz._last_geometry_state = "empty" + continue + + extrusion_world = quad_gz.axis * face_world_margin + extrusion_local = ( + float(extrusion_world.x), + float(extrusion_world.y), + float(extrusion_world.z), + ) + all_tris: list[tuple[float, float, float]] = [] + for axis_a, is_max_a in adjacent_front_routes: + edge_keys = _shared_edge_corner_keys(axis_a, is_max_a, axis_b, is_max_b) + if edge_keys is None: + continue + key0, key1 = edge_keys + wp0 = corners_world[key0] + wp1 = corners_world[key1] + local_p0 = ( + float(wp0.x - face_world.x), + float(wp0.y - face_world.y), + float(wp0.z - face_world.z), + ) + local_p1 = ( + float(wp1.x - face_world.x), + float(wp1.y - face_world.y), + float(wp1.z - face_world.z), + ) + all_tris.extend( + _build_strip_tris_relative(local_p0, local_p1, extrusion_local) + ) + + if not locked: + quad_gz.hide = False + quad_gz.select_bias = _FACE_QUAD_HALO_FRAME_SELECT_BIAS + + corner_keys = _face_corner_keys(axis_b, is_max_b) + wc_local = [ + ( + float(corners_world[k].x - face_world.x), + float(corners_world[k].y - face_world.y), + float(corners_world[k].z - face_world.z), + ) + for k in corner_keys + ] + face_quad_local = [ + wc_local[0], + wc_local[1], + wc_local[2], + wc_local[0], + wc_local[2], + wc_local[3], + ] + if _strips_geometry_changed(quad_gz, tuple(face_quad_local), tuple(all_tris)): + quad_gz.custom_shape = quad_gz.new_custom_shape("TRIS", face_quad_local) + quad_gz.custom_shape_select = quad_gz.new_custom_shape("TRIS", all_tris) + quad_gz._last_geometry_state = "strips" + + # Outline alpha follows ONLY the hovered quad's own state — light + # the outline of the face under the cursor, nothing else. + if not locked: + for outline_gz, quad_gz in zip(outline_gizmos, quad_gizmos, strict=True): + lit = bool(getattr(quad_gz, "is_highlight", False)) + outline_gz.alpha = 1.0 if lit else 0.0 + outline_gz.alpha_highlight = 1.0 if lit else 0.0 + + +__all__ = [ + "AXIS_COLOR", + "BIM_GT_box_face_outline", + "BIM_GT_box_face_quad", + "FACE_QUAD_ALPHA", + "FACE_QUAD_ALPHA_HIGHLIGHT", + "FACE_QUAD_SELECT_BIAS", + "FACE_ROUTES", + "apply_face_quad_layout", + "compute_face_resize", + "face_outward_axis_local", + "front_facing_face_mask", + "view_axis_parallel_face_mask", +] diff --git a/src/bonsai/bonsai/bim/module/clip_box/gizmos.py b/src/bonsai/bonsai/bim/module/clip_box/gizmos.py new file mode 100644 index 0000000000..752d151bb2 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/clip_box/gizmos.py @@ -0,0 +1,323 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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. + +"""Interactive face-quad resize gizmos for the active clip box. + +Adapter group that binds the generic :mod:`face_quad` core to a Bonsai +clip-box Empty: six near-invisible click quads + six edge outlines on +the cube's faces. Dragging a face does a ONE-SIDED resize — the dragged +face moves along its outward world normal while the opposite face stays +put — by writing the empty's ``location`` and ``scale``. Bonsai's +depsgraph handler then re-arms the clip planes from the new matrix. +""" + +from __future__ import annotations + +import contextlib +from typing import Any + +import bpy +from mathutils import Matrix, Vector + +import bonsai.tool as tool + +from . import face_quad + + +# Local-frame bounds of the empty's CUBE display. The display spans +# ``[-empty_display_size, +empty_display_size]^3``; Bonsai always sets +# ``empty_display_size = 1.0`` on clip-box hosts, so the local box is +# the unit cube. The empty's per-axis scale + rotation + translation +# ride in ``matrix_world``, which the layout helper applies. +_LOCAL_BMIN = Vector((-1.0, -1.0, -1.0)) +_LOCAL_BMAX = Vector((1.0, 1.0, 1.0)) + + +def _world_axis(empty: bpy.types.Object, axis: int, is_max: bool) -> Vector: + """Outward world-space unit normal of the ``(axis, is_max)`` face. + + Uses the rotation-only matrix so a negative-scale empty doesn't + flip the resulting direction — the visible "+X face" then stays + associated with world +X (transformed through rotation). + """ + rot_mat = empty.matrix_world.to_quaternion().to_matrix() + n = Vector(rot_mat.col[axis]) + if n.length <= 0.0: + return Vector((0.0, 0.0, 0.0)) + n.normalize() + return n if is_max else -n + + +def _world_half_extent(empty: bpy.types.Object, axis: int) -> float: + """The empty's box half-extent along local ``axis`` in WORLD units. + + A CUBE empty's local cube is ``±empty_display_size``; ``matrix_world`` + stretches it by the column length on ``axis``. So the world + half-extent is ``|column[axis]| * empty_display_size``. + """ + col_len = empty.matrix_world.to_3x3().col[axis].length + display_size = abs(float(getattr(empty, "empty_display_size", 1.0) or 1.0)) + return float(col_len) * display_size + + +def _make_face_get_cb(gz: Any, group: Any, axis: int, is_max: bool): + """Closure returning the world half-extent at drag start and + snapshotting the empty's full transform on the gizmo instance. + + The snapshot lives on the gizmo (not the group) so a PERSISTENT + group servicing multiple clip boxes can't bleed one drag's state + onto another. Cleared on ``exit`` by the shared face-quad hook. + """ + + def getter() -> float: + empty = group._empty + if empty is None: + return 0.0 + existing = getattr(gz, "_drag_snapshot", None) + if existing is not None and existing.get("empty_name") == getattr( + empty, "name", None + ): + return float(existing["world_half"]) + + world_half = _world_half_extent(empty, axis) + display_size = abs(float(getattr(empty, "empty_display_size", 1.0) or 1.0)) + gz._drag_snapshot = { + "empty_name": getattr(empty, "name", None), + "world_half": world_half, + "location": tuple(float(v) for v in empty.location), + "scale": tuple(float(v) for v in empty.scale), + "display_size": display_size if display_size != 0.0 else 1.0, + "world_axis": tuple(_world_axis(empty, axis, is_max)), + } + return float(world_half) + + return getter + + +def _make_ctrl_click_cb(axis: int, is_max: bool): + """Closure that dispatches CTRL+click on a face to the align-view operator. + + Routing through an operator (rather than mutating ``rv3d`` here) + keeps the action F3-searchable and undoable. + """ + + def _callback(_context: Any, _event: Any) -> None: + bpy.ops.bim.align_view_to_clip_face( + "INVOKE_DEFAULT", axis=axis, is_max=is_max + ) + + return _callback + + +def _make_face_set_cb(gz: Any, group: Any, axis: int, is_max: bool): + """Closure that applies a one-sided face resize by writing the + empty's ``location`` + ``scale``. + + The modal calls this with ``value = init + delta`` where ``delta`` + is the cursor's projection onto the face's OUTWARD world normal. + Both reads come from ``gz._drag_snapshot`` so every frame is + relative to drag start, never compounding. + """ + del is_max # snapshot's world_axis carries the direction + + def setter(value: float) -> None: + empty = group._empty + if empty is None: + return + snap = getattr(gz, "_drag_snapshot", None) + if snap is None or snap.get("empty_name") != getattr(empty, "name", None): + return + + new_scale_axis, new_location = face_quad.compute_face_resize( + value=value, + init_world_half=snap["world_half"], + init_location=snap["location"], + world_axis=snap["world_axis"], + display_size=snap["display_size"], + ) + new_scale = list(snap["scale"]) + # Preserve the sign of the original scale so a user-flipped empty + # stays flipped after the resize — compute_face_resize returns a + # positive magnitude, the sign is the user's intent to keep. + sign = -1.0 if snap["scale"][axis] < 0.0 else 1.0 + new_scale[axis] = sign * new_scale_axis + + empty.scale = new_scale + empty.location = Vector(new_location) + + return setter + + +class OBJECT_GGT_bim_clip_box(bpy.types.GizmoGroup): # noqa: N801 — Blender bl_idname convention + """Face-quad resize handles on the active clip box. + + Renders six near-invisible click-target quads and six colored edge + outlines on the active clip-box empty whenever clipping is enabled. + Click-and-drag a face to resize one-sided; the opposite face stays + put. CTRL+click and plain click fall through to selection. + """ + + bl_idname = "OBJECT_GGT_bim_clip_box" + bl_label = "Bonsai Clip Box Faces" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT", "SHOW_MODAL_ALL"} + + @classmethod + def poll(cls, context: Any) -> bool: + scene = getattr(context, "scene", None) + if scene is None: + return False + scene_props = tool.ClipBox.get_scene_props(scene) + if not scene_props.enabled or not scene_props.enable_gizmos: + return False + active_clip_box = tool.ClipBox.get_active_clip_box(scene) + if active_clip_box is None: + return False + # Only render when the user has the active clip box itself + # selected — otherwise the face handles would intercept clicks + # meant for the geometry behind them. + return getattr(context, "active_object", None) is active_clip_box + + @classmethod + def setup_keymap(cls, keyconfig): + # Bind CLICK_DRAG so plain LEFTMOUSE PRESS passes through to + # selection — the user can still click through a near-invisible + # face quad to pick a mesh behind it. + km = keyconfig.keymaps.new( + name=cls.bl_idname, + space_type=cls.bl_space_type, + region_type=cls.bl_region_type, + ) + km.keymap_items.new( + "gizmogroup.gizmo_tweak", type="LEFTMOUSE", value="CLICK_DRAG" + ) + km.keymap_items.new( + "gizmogroup.gizmo_tweak", type="LEFTMOUSE", value="PRESS", ctrl=True + ) + return km + + def setup(self, context: Any) -> None: + # ``_empty`` is resolved each refresh so the PERSISTENT group + # follows whichever clip box is active in the scene PG. + self._empty: bpy.types.Object | None = None + self._locked = False + self._face_routes: list[tuple[int, bool]] = [] + + for axis, is_max in face_quad.FACE_ROUTES: + gz = self.gizmos.new(face_quad.BIM_GT_box_face_quad.bl_idname) + gz._group = self + gz._face_axis = axis + gz.is_max = is_max + gz._drag_snapshot = None + gz._last_geometry_state = "solid" + gz._strips_cache_key = None + gz.color = face_quad.AXIS_COLOR[axis] + gz.color_highlight = tuple( + min(1.0, c + 0.3) for c in face_quad.AXIS_COLOR[axis] + ) + gz.alpha = face_quad.FACE_QUAD_ALPHA + gz.alpha_highlight = face_quad.FACE_QUAD_ALPHA_HIGHLIGHT + gz.use_draw_modal = True + gz.scale_basis = 1.0 + gz.select_bias = face_quad.FACE_QUAD_SELECT_BIAS + gz.move_get_cb = _make_face_get_cb(gz, self, axis, is_max) + gz.move_set_cb = _make_face_set_cb(gz, self, axis, is_max) + # CTRL+click on a face aligns the viewport to look at it. + gz.ctrl_click_cb = _make_ctrl_click_cb(axis, is_max) + self._face_routes.append((axis, is_max)) + + # Outlines added last so they composite on top of the quad + # fills (Blender draws gizmos in creation order). + for axis, is_max in face_quad.FACE_ROUTES: + ol = self.gizmos.new(face_quad.BIM_GT_box_face_outline.bl_idname) + ol._face_axis = axis + ol.is_max = is_max + ol.color = face_quad.AXIS_COLOR[axis] + ol.color_highlight = face_quad.AXIS_COLOR[axis] + ol.alpha = 0.0 + ol.alpha_highlight = 0.0 + ol.line_width = 2.5 + + def _quad_gizmos(self): + return self.gizmos[: len(self._face_routes)] + + def _outline_gizmos(self): + n = len(self._face_routes) + return self.gizmos[n : 2 * n] + + def refresh(self, context: Any) -> None: + """State-change path: resolve the active empty, then run the + shared face-quad layout so the quads aren't stale for a frame + after a selection or active-index change.""" + empty = tool.ClipBox.get_active_clip_box(context.scene) + self._empty = empty + if empty is None: + for gz in self.gizmos: + gz.hide = True + return + self._layout(context, empty) + + def draw_prepare(self, context: Any) -> None: + """Per-redraw — fires on orbit — re-run the layout so the + front/back split, halo strips, and outline highlights track + the camera and any live G/R/S on the empty.""" + empty = self._empty + if empty is None: + return + self._layout(context, empty) + + def _layout(self, context: Any, empty: bpy.types.Object) -> None: + face_quad.apply_face_quad_layout( + quad_gizmos=self._quad_gizmos(), + outline_gizmos=self._outline_gizmos(), + bmin=_LOCAL_BMIN, + bmax=_LOCAL_BMAX, + matrix_world=empty.matrix_world, + # The empty's rotation rides in matrix_world, so the + # box-local OBB rotation is identity. + cage_rotation=Matrix.Identity(4), + region=getattr(context, "region", None), + rv3d=getattr(context, "region_data", None), + locked=self._locked, + ) + + # ---- mutual exclusion (lock siblings during a drag) ------------------ + + def _lock_for(self, active_gizmo) -> None: + self._locked = True + for gz in self.gizmos: + if gz is not active_gizmo: + with contextlib.suppress(ReferenceError, RuntimeError): + gz.hide = True + + def _unlock_all(self) -> None: + self._locked = False + for gz in self.gizmos: + with contextlib.suppress(ReferenceError, RuntimeError): + gz.hide = False + # Rebuild caps synchronously so the cross-section overlay + # re-forms the instant the user releases the handle, rather + # than waiting for the depsgraph's debounced rebuild path. + with contextlib.suppress(RuntimeError, ReferenceError): + tool.ClipBox.rebuild_caps_now() + # Push an undo step so the user can revert a face drag with Ctrl+Z. + with contextlib.suppress(RuntimeError): + bpy.ops.ed.undo_push(message="Resize Clip Box") diff --git a/src/bonsai/bonsai/bim/module/clip_box/operator.py b/src/bonsai/bonsai/bim/module/clip_box/operator.py index b080daa3de..e81b1e3f09 100644 --- a/src/bonsai/bonsai/bim/module/clip_box/operator.py +++ b/src/bonsai/bonsai/bim/module/clip_box/operator.py @@ -18,14 +18,132 @@ # # This file was generated with the assistance of an AI coding tool. -from __future__ import annotations - import bpy +from mathutils import Matrix, Vector import bonsai.tool as tool +from bonsai.bim.helper import prop_with_search + +from . import data + +# NOTE: do NOT add ``from __future__ import annotations`` to this module. +# PEP 563 stringifies the operator's EnumProperty class annotations, which +# breaks any introspection that reads ``cls.__annotations__[name].keywords`` +# — including the enum-search helper that draws the search-button icon. CLIP_BOX_NAME = "ClipBox" -CLIP_BOX_COLLECTION = "BBIM_ClipBoxes" + +# Display labels for the source-based picker, used for the menu entries and +# the dialog title. The dict keys are the canonical source-kind identifiers. +SOURCE_KIND_LABELS: dict[str, str] = { + "SPATIAL": "Spatial Element", + "CLASS": "Class", + "TYPE": "Type", + "MATERIAL": "Material", + "PROFILE": "Profile", + "DRAWING": "Drawing", + "STATUS": "Status", + "SYSTEM": "System", + "GROUP": "Group", + "ZONE": "Zone", +} + + +_SOURCE_ID_DISPATCH = { + "SPATIAL": data.spatial_items, + "CLASS": data.class_items, + "TYPE": data.type_items, + "MATERIAL": data.material_items, + "PROFILE": data.profile_items, + "DRAWING": data.drawing_items, + "STATUS": data.status_items, + "SYSTEM": data.system_items, + "GROUP": data.group_items, + "ZONE": data.zone_items, +} + + +def _source_id_items(self, context): + """Dispatch the ``source_id`` enum items based on the picked ``source_kind``.""" + fn = _SOURCE_ID_DISPATCH.get(self.source_kind) + if fn is None: + return [(data.NO_OPTIONS_ID, "No options", "")] + return fn(self, context) + + +def _source_display_name(kind, source_id): + """Human-readable name of the picked source, used in the clip-box name.""" + if kind == "STATUS": + return next((label for value, label in data.STATUS_LABELS if value == source_id), source_id) + if kind == "CLASS": + # source_id IS the human-readable IFC class name. + return source_id + ifc = tool.Ifc.get() + if ifc is None: + return source_id + try: + entity = ifc.by_id(int(source_id)) + except (TypeError, ValueError, RuntimeError): + return source_id + return (getattr(entity, "Name", None) or "Unnamed").strip() or "Unnamed" + + +class BIM_OT_align_view_to_clip_face(bpy.types.Operator): + bl_idname = "bim.align_view_to_clip_face" + bl_label = "Align View to Clip Box Face" + bl_description = "Orient the 3D viewport to look directly at the picked clip-box face" + bl_options = {"REGISTER"} + + axis: bpy.props.IntProperty(default=0, options={"SKIP_SAVE"}) + is_max: bpy.props.BoolProperty(default=True, options={"SKIP_SAVE"}) + + def execute(self, context): + rv3d = getattr(context, "region_data", None) + if rv3d is None: + return {"CANCELLED"} + clip_box = tool.ClipBox.get_active_clip_box(context.scene) + if clip_box is None: + return {"CANCELLED"} + rot_mat = clip_box.matrix_world.to_quaternion().to_matrix() + outward_local = Vector((0.0, 0.0, 0.0)) + outward_local[self.axis] = 1.0 if self.is_max else -1.0 + outward = (rot_mat @ outward_local).normalized() + if outward.length == 0.0: + return {"CANCELLED"} + up_world = (rot_mat @ _local_up_for_face(self.axis, self.is_max)).normalized() + rv3d.view_rotation = _view_rotation_from_forward_and_up(-outward, up_world) + return {"FINISHED"} + + +def _local_up_for_face(axis: int, is_max: bool) -> Vector: + """Box-local up direction for a face, following Blender numpad conventions. + + Side faces (local ±X / ±Y normal) → local +Z is up. Top face (local +Z + normal) → local +Y is up; bottom face (local -Z normal) → local -Y is + up. The caller rotates this through the empty's matrix so the + resulting world up axis tracks the box's orientation. + """ + if axis == 2: + return Vector((0.0, 1.0, 0.0)) if is_max else Vector((0.0, -1.0, 0.0)) + return Vector((0.0, 0.0, 1.0)) + + +def _view_rotation_from_forward_and_up(forward: Vector, up_hint: Vector) -> "bpy.types.Quaternion": + """Build a camera ``view_rotation`` that looks along ``forward`` with + ``up_hint`` projected to the camera's local +Y.""" + back = -forward.normalized() + right = up_hint.cross(back) + if right.length < 1e-6: + right = Vector((1.0, 0.0, 0.0)) + right.normalize() + up = back.cross(right).normalized() + return Matrix( + ( + (right.x, up.x, back.x), + (right.y, up.y, back.y), + (right.z, up.z, back.z), + ) + ).to_quaternion() class BIM_OT_add_clip_box(bpy.types.Operator): @@ -38,39 +156,61 @@ class BIM_OT_add_clip_box(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - scene_props = tool.ClipBox.get_scene_props(context.scene) - - obj = bpy.data.objects.new(CLIP_BOX_NAME, None) - obj.empty_display_type = "CUBE" - obj.empty_display_size = 1.0 - obj.location = context.scene.cursor.location.copy() # Default to a 20m cube (scale 10 around [-1, +1] local cube) so # the volume covers a typical building storey or two rather than # the meaningless 2m unit cube. The user resizes with S. - obj.scale = (10.0, 10.0, 10.0) - obj.show_in_front = True + matrix = Matrix.Translation(context.scene.cursor.location.copy()) @ Matrix.Diagonal( + (10.0, 10.0, 10.0, 1.0) + ) + tool.ClipBox.create_clip_box_empty(context, matrix, name=CLIP_BOX_NAME) + return {"FINISHED"} - collection = tool.Blender.get_or_create_collection(context.scene, CLIP_BOX_COLLECTION) - collection.objects.link(obj) - obj_props = tool.ClipBox.get_object_props(obj) - obj_props.is_clip_box = True +class BIM_OT_add_clip_box_for_source(bpy.types.Operator): + bl_idname = "bim.add_clip_box_for_source" + bl_label = "Add Clip Box From Source" + bl_description = ( + "Create a clip box sized to a chosen source: a spatial container, IFC type, material, " + "profile, drawing camera frustum, element status, system, group, or zone" + ) + bl_options = {"REGISTER", "UNDO"} - entry = scene_props.clip_boxes.add() - entry.obj = obj - scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1 + source_kind: bpy.props.EnumProperty( + name="Source Kind", + items=[(kind, label, "") for kind, label in SOURCE_KIND_LABELS.items()], + default="SPATIAL", + options={"SKIP_SAVE"}, + ) + source_id: bpy.props.EnumProperty( + name="Source", + items=_source_id_items, + options={"SKIP_SAVE"}, + ) - # Adding a new clip box arms clipping so the user sees the cut - # immediately. Without this they'd have to find the panel - # toggle to discover the feature actually works. - scene_props.enabled = True + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) - tool.Blender.set_active_object(obj) - tool.ClipBox.refresh(context.scene) - # Persist to the project pset so the box round-trips through IFC - # save/load. A project-level pset avoids the IfcRoot scale lock / - # strip that a per-entity placement would trigger on export. - tool.ClipBox.save_to_project_pset(context.scene) + def draw(self, context): + layout = self.layout + label = f"Clip {SOURCE_KIND_LABELS.get(self.source_kind, 'Source')}" + # Search button appears once the enum exceeds the helper's threshold, + # giving the user a popup picker instead of a plain dropdown. + prop_with_search(layout, self, "source_id", text=label) + + def execute(self, context): + if not self.source_id or self.source_id == data.NO_OPTIONS_ID: + self.report({"ERROR"}, "No source selected.") + return {"CANCELLED"} + matrix = tool.ClipBox.compute_matrix_for_source(self.source_kind, self.source_id) + if matrix is None: + kind_label = SOURCE_KIND_LABELS.get(self.source_kind, self.source_kind) + self.report( + {"ERROR"}, + f"No elements found for {kind_label} '{_source_display_name(self.source_kind, self.source_id)}'.", + ) + return {"CANCELLED"} + name = f"ClipBox.{SOURCE_KIND_LABELS.get(self.source_kind, self.source_kind)}.{_source_display_name(self.source_kind, self.source_id)}" + tool.ClipBox.create_clip_box_empty(context, matrix, name=name) return {"FINISHED"} @@ -148,23 +288,9 @@ class BIM_OT_duplicate_clip_box(bpy.types.Operator): if source is None: return {"CANCELLED"} - copy = bpy.data.objects.new(source.name, None) + copy = tool.ClipBox.create_clip_box_empty(context, source.matrix_world.copy(), name=source.name) + # Preserve the source's display attrs so the duplicate matches. copy.empty_display_type = source.empty_display_type copy.empty_display_size = source.empty_display_size copy.show_in_front = source.show_in_front - copy.matrix_world = source.matrix_world.copy() - - collection = tool.Blender.get_or_create_collection(context.scene, CLIP_BOX_COLLECTION) - collection.objects.link(copy) - - tool.ClipBox.get_object_props(copy).is_clip_box = True - - entry = scene_props.clip_boxes.add() - entry.obj = copy - scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1 - scene_props.enabled = True - - tool.Blender.set_active_object(copy) - tool.ClipBox.refresh(context.scene) - tool.ClipBox.save_to_project_pset(context.scene) return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/clip_box/prop.py b/src/bonsai/bonsai/bim/module/clip_box/prop.py index f503d8781f..b65cf8a0ca 100644 --- a/src/bonsai/bonsai/bim/module/clip_box/prop.py +++ b/src/bonsai/bonsai/bim/module/clip_box/prop.py @@ -53,16 +53,29 @@ class BIMClipBoxProperties(PropertyGroup): def update_active_clip_box_index(self, context): tool.ClipBox.schedule_refresh() tool.ClipBox.select_active_clip_box(context) + # Rebuild caps for the new active box's clip volume. + tool.ClipBox.invalidate_cap_cache(immediate=True) def update_show_caps(self, context): tool.ClipBox.schedule_refresh() + # Off → on must trigger a rebuild so caps reappear immediately rather + # than wait for the next depsgraph tick. The rebuild is a no-op when + # show_caps is now False (it clears and returns), so this is safe in + # both directions. + tool.ClipBox.invalidate_cap_cache() def update_enabled(self, context): tool.ClipBox.schedule_refresh() +def update_clip_only_ifc_products(self, context): + # The eligibility set for capping changed — drop the cache and let the + # debounced rebuild pick up the new objects on the next idle tick. + tool.ClipBox.invalidate_cap_cache() + + class BIMSceneClipBoxProperties(PropertyGroup): """Scene-level registry of clip boxes in this file. @@ -100,8 +113,33 @@ class BIMSceneClipBoxProperties(PropertyGroup): "very heavy scenes" ), ) + # Stored on the Scene PG so Blender persists it in the .blend; deliberately + # NOT written to the project pset so the IFC stays portable across users + # who may have different Blender-side reference geometry to clip. + clip_only_ifc_products: bpy.props.BoolProperty( + name="Only IFC Products", + default=True, + update=update_clip_only_ifc_products, + description=( + "When enabled, only IFC element geometry gets cross-section caps. " + "Disable to also cap Blender-side reference meshes (sketches, " + "imported obj, primitive cubes, …)" + ), + ) + # Also Scene-only — gizmo visibility is a per-user editing preference, + # not a portable IFC property. + enable_gizmos: bpy.props.BoolProperty( + name="Show Face Handles", + default=True, + description=( + "Show interactive face-resize handles on the active clip box. " + "Disable to fall back to plain G/R/S transforms on the empty" + ), + ) if TYPE_CHECKING: active_clip_box_index: int enabled: bool show_caps: bool + clip_only_ifc_products: bool + enable_gizmos: bool diff --git a/src/bonsai/bonsai/bim/module/clip_box/ui.py b/src/bonsai/bonsai/bim/module/clip_box/ui.py index b5ae95d9e2..39e786f113 100644 --- a/src/bonsai/bonsai/bim/module/clip_box/ui.py +++ b/src/bonsai/bonsai/bim/module/clip_box/ui.py @@ -20,18 +20,73 @@ from __future__ import annotations -from bpy.types import Panel, UIList +from bpy.types import Menu, Panel, UIList import bonsai.tool as tool +# Per-kind icon for the source-picker menu. Picked from Blender's built-in +# icon set; semantically close to the kind so users can scan the menu visually. +_SOURCE_MENU_ENTRIES: tuple[tuple[str, str, str], ...] = ( + ("SPATIAL", "Clip Spatial Element", "OUTLINER_COLLECTION"), + ("CLASS", "Clip by Class", "BLANK1"), + ("TYPE", "Clip Type", "FILE_3D"), + ("MATERIAL", "Clip Material", "MATERIAL"), + ("PROFILE", "Clip Profile", "MESH_CIRCLE"), + ("DRAWING", "Clip Drawing Extents", "CAMERA_DATA"), + ("STATUS", "Clip by Status", "INFO"), + ("SYSTEM", "Clip by System", "MOD_FLUID"), + ("GROUP", "Clip by Group", "OUTLINER_OB_GROUP_INSTANCE"), + ("ZONE", "Clip by Zone", "MOD_LATTICE"), +) + + +class BIM_MT_clip_box_add_for_source(Menu): + bl_idname = "BIM_MT_clip_box_add_for_source" + bl_label = "Add Clip Box From Source" + + def draw(self, context): + layout = self.layout + for kind, label, icon in _SOURCE_MENU_ENTRIES: + op = layout.operator("bim.add_clip_box_for_source", text=label, icon=icon) + op.source_kind = kind + + +class BIM_MT_clip_box_settings(Menu): + bl_idname = "BIM_MT_clip_box_settings" + bl_label = "Clip Box Settings" + + def draw(self, context): + scene_props = tool.ClipBox.get_scene_props(context.scene) + self.layout.prop(scene_props, "clip_only_ifc_products") + self.layout.prop(scene_props, "enable_gizmos") + + +class BIM_MT_clip_box_info(Menu): + bl_idname = "BIM_MT_clip_box_info" + bl_label = "Clip Box Face Handles" + + def draw(self, context): + layout = self.layout + layout.label(text="Face Handles", icon="INFO") + layout.separator() + layout.label(text="Drag a face to resize the clip box on that axis.") + layout.label(text="The opposite face stays fixed (one-sided resize).") + layout.label(text="Ctrl+Click a face to align the viewport to it.") + layout.separator() + layout.label(text="Toggle handles from the Settings (gear) menu.") + + class BIM_UL_clip_box(UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index, flt_flag): obj = item.obj - if obj is None: - layout.label(text="(missing)", icon="ERROR") - return row = layout.row(align=True) + if obj is None: + # Host empty was deleted from outliner; still expose the + # remove button so the orphan entry isn't permanent. + row.label(text="(missing)", icon="ERROR") + row.operator("bim.remove_clip_box", text="", icon="X", emboss=False).index = index + return row.prop(obj, "name", text="", emboss=False, icon="MESH_CUBE") row.operator("bim.duplicate_clip_box", text="", icon="DUPLICATE", emboss=False).index = index row.operator("bim.remove_clip_box", text="", icon="X", emboss=False).index = index @@ -60,9 +115,13 @@ class BIM_PT_clip_box(Panel): toggle=True, ) toggles.prop(scene_props, "show_caps", text="Show Caps", icon="MOD_SOLIDIFY", toggle=True) + toggles.menu("BIM_MT_clip_box_settings", icon="PREFERENCES", text="") + toggles.menu("BIM_MT_clip_box_info", icon="INFO", text="") layout.separator() - layout.operator("bim.add_clip_box", icon="ADD", text="Add Clip Box") + row = layout.row(align=True) + row.operator("bim.add_clip_box", icon="ADD", text="Add Clip Box") + row.menu("BIM_MT_clip_box_add_for_source", icon="DOWNARROW_HLT", text="") layout.template_list( "BIM_UL_clip_box", diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 8af70e54c9..0212d82c38 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -1950,6 +1950,13 @@ class BIM_PT_decorators_overlay(Panel): row = col.row(align=True) row.prop(model_props, "show_cut_decorator", text="Cut Decorator") row.prop(model_props, "show_cut_decorator_fill", text="Fill Cut Decorator") + clip_box_props = tool.ClipBox.get_scene_props(context.scene) + row = col.row(align=True) + # Grey out the toggles when there is no clip box to act on, so the + # user can see the controls but can't flip a switch that does nothing. + row.enabled = bool(clip_box_props.clip_boxes) + row.prop(clip_box_props, "enabled", text="Enable Clipping") + row.prop(clip_box_props, "show_caps", text="Show Caps") class BIM_PT_snappping(Panel): diff --git a/src/bonsai/bonsai/tool/clip_box.py b/src/bonsai/bonsai/tool/clip_box.py index c477e32ad9..9573a3f5e8 100644 --- a/src/bonsai/bonsai/tool/clip_box.py +++ b/src/bonsai/bonsai/tool/clip_box.py @@ -21,7 +21,7 @@ from __future__ import annotations import contextlib -from collections.abc import Callable, Iterator +from collections.abc import Callable, Iterable, Iterator from typing import TYPE_CHECKING, Any, Optional import bpy @@ -38,6 +38,21 @@ if TYPE_CHECKING: PlaneTuple = tuple[float, float, float, float] PlaneSet = tuple[PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple] +# Stable contract of Pset_*Common.Status values plus the "absent" entry. +# Duplicated locally rather than imported so this module has no load-order +# dependency on the sequence layer that hosts the matching query helper. +SOURCE_STATUS_VALUES: tuple[str, ...] = ( + "No Status", + "NEW", + "EXISTING", + "DEMOLISH", + "TEMPORARY", + "OTHER", + "NOTKNOWN", + "UNSET", +) + + # Outward margins so the empty's CUBE display edges sit safely INSIDE # the clip volume. A fixed absolute margin fails under rotation: the # float error in computing each column's length and in per-vertex dot @@ -187,12 +202,38 @@ class ClipBox: keeps the bbox aligned with the view the user is actually at. """ for area, region, region_3d in tool.Blender.iter_view3d_regions(): + # Skip collapsed / initializing regions wholesale: arming one + # CTDs Blender (see _region_is_renderable), and recording an + # arm signature for a region we didn't actually arm would make + # the next view-change comparison spurious. + if not cls._region_is_renderable(region, region_3d): + continue key = region.as_pointer() cls._owned.add(key) cls._region_by_key[key] = (area, region) cls._arm_region(area, region, region_3d, planes) cls._view_matrix_at_arm[key] = tuple(tuple(row) for row in region_3d.view_matrix) + @classmethod + def _region_is_renderable(cls, region: Any, region_3d: Any) -> bool: + """True iff ``region`` is safe to arm clip planes against. + + A collapsed / still-initializing region (``width`` or ``height`` + == 0, or no readable ``view_matrix``) has no live view-matrix + state. Calling ``region_3d.update()`` against it drives + ``ED_view3d_update_viewmat -> GPU_matrix_ortho_set`` into a null + deref and HARD-CRASHES Blender (CTD, not a catchable exception) — + observed when a 3D viewport is split/collapsed while the clip box + re-arms from a timer. Skipping such regions is the load-bearing + guard; they get armed on the next refresh once they have a size. + """ + try: + if int(getattr(region, "width", 0)) <= 0 or int(getattr(region, "height", 0)) <= 0: + return False + return getattr(region_3d, "view_matrix", None) is not None + except (ReferenceError, AttributeError, TypeError): + return False + @classmethod def _arm_region(cls, area: Any, region: Any, region_3d: Any, planes: PlaneSet) -> None: """Initialize the region's clip machinery and write ``planes``. @@ -201,12 +242,25 @@ class ClipBox: without leaving the C-side ``clipbb`` degenerate (which would break edit-mode click-select). Caller must guarantee a context in which operators are legal (not a draw handler / depsgraph callback). + + The ``_region_is_renderable`` guard is the real crash fix — arming a + collapsed / initializing region drives ``region_3d.update()`` into a + native null deref inside ``GPU_matrix_ortho_set`` that NO Python + ``try``/``except`` can catch (it's a CTD, not an exception). The + ``suppress`` around ``update()`` is unrelated to that: it only + swallows the *catchable* ``RuntimeError`` ("context is incorrect") + / ``ReferenceError`` (region freed mid-call) that the override path + can still surface — it does NOT and CANNOT make ``update()`` + crash-safe. """ + if not cls._region_is_renderable(region, region_3d): + return with bpy.context.temp_override(area=area, region=region): bpy.ops.view3d.clip_border(xmin=0, ymin=0, xmax=region.width, ymax=region.height) region_3d.clip_planes = planes region_3d.use_clip_planes = True - region_3d.update() + with contextlib.suppress(RuntimeError, ReferenceError): + region_3d.update() @classmethod def clear_clip_planes(cls) -> None: @@ -466,10 +520,15 @@ class ClipBox: for area, region, region_3d in tool.Blender.iter_view3d_regions(): if not region_3d.use_clip_planes: continue + # A collapsed / initializing region CTDs inside update() — same + # null deref as the operator arm path (see _region_is_renderable). + if not cls._region_is_renderable(region, region_3d): + continue key = region.as_pointer() cls._region_by_key[key] = (area, region) region_3d.clip_planes = planes - region_3d.update() + with contextlib.suppress(RuntimeError, ReferenceError): + region_3d.update() region.tag_redraw() @classmethod @@ -615,7 +674,12 @@ class ClipBox: except (AttributeError, RuntimeError, ReferenceError): return region_3d.clip_planes = cls.compute_planes_from_matrix(matrix) - region_3d.update() + # PRE_VIEW runs for the region being drawn this frame (always sized + # and renderable), so the collapsed-region CTD can't occur here. + # The suppress only mops up a catchable RuntimeError / ReferenceError + # from a region freed mid-draw — same as the arm paths. + with contextlib.suppress(RuntimeError, ReferenceError): + region_3d.update() # clip_bb captured by view3d.clip_border is view-aligned, so an # orbit/pan/zoom leaves the picker testing against the old # frustum even after clip_planes refresh. Re-arm so the picker @@ -628,6 +692,219 @@ class ClipBox: if prev_view is not None and prev_view != current_view: cls.schedule_refresh() + @classmethod + def create_clip_box_empty( + cls, + context: bpy.types.Context, + matrix: Any, + name: str = "ClipBox", + ) -> bpy.types.Object: + """Create + register a clip-box empty whose ``matrix_world`` is ``matrix``. + + Single entry point for any operator that needs to materialise a + clip box: handles the host collection, the per-object + ``is_clip_box`` flag, the scene-list entry, auto-enable, viewport + re-arm, and project-pset persistence. Returns the new empty. + """ + scene_props = cls.get_scene_props(context.scene) + + obj = bpy.data.objects.new(name, None) + obj.empty_display_type = "CUBE" + obj.empty_display_size = 1.0 + obj.show_in_front = True + obj.matrix_world = matrix + + collection = tool.Blender.get_or_create_collection(context.scene, cls.COLLECTION_NAME) + collection.objects.link(obj) + + cls.get_object_props(obj).is_clip_box = True + + entry = scene_props.clip_boxes.add() + entry.obj = obj + scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1 + # Auto-enable so the user sees the cut immediately rather than + # having to find the panel toggle after the add. + scene_props.enabled = True + + tool.Blender.set_active_object(obj) + cls.refresh(context.scene) + # Project-level pset rather than a per-entity placement so IfcRoot's + # scale-strip-on-export can't lose the box's dimensions. + cls.save_to_project_pset(context.scene) + return obj + + # ------------------------------------------------------------------ + # Source-based presets + # + # Build the host empty's ``matrix_world`` from a chosen IFC source + # (a spatial container, a type, a material, a drawing, …) so the + # user gets a clip box pre-sized to the AABB of the matched + # elements instead of having to drag a default cube into position. + # ------------------------------------------------------------------ + + @classmethod + def iter_elements_for_source(cls, kind: str, source_id: str) -> list[Any]: + """Resolve the IFC products matching ``(kind, source_id)``. + + ``kind`` selects the IFC-graph walk; ``source_id`` is the picker + value: an IFC entity id (stringified) for the entity-driven + kinds, or one of :data:`SOURCE_STATUS_VALUES` for ``"STATUS"``. + + Empty list when the IFC file is absent, ``source_id`` does not + resolve, or the walk has no matches. ``"DRAWING"`` returns the + single drawing entity so callers can introspect it; the actual + clip volume for that kind is built from the camera frustum, not + an AABB of decomposed elements. + """ + import ifcopenshell.util.element + + ifc_file = tool.Ifc.get() + if ifc_file is None: + return [] + + if kind == "STATUS": + if source_id not in SOURCE_STATUS_VALUES: + return [] + return list(tool.Sequence.get_elements_by_status(source_id)) + + if kind == "CLASS": + # source_id is an IFC class name (e.g. "IfcWall"). by_type with + # include_subtypes=True (default) so "IfcWall" matches + # IfcWallStandardCase etc., matching "all walls" in user terms. + try: + return list(ifc_file.by_type(source_id)) + except RuntimeError: + return [] + + try: + entity_id = int(source_id) + except (TypeError, ValueError): + return [] + try: + entity = ifc_file.by_id(entity_id) + except RuntimeError: + return [] + if entity is None: + return [] + + if kind == "SPATIAL": + return list(ifcopenshell.util.element.get_decomposition(entity, is_recursive=True)) + if kind == "TYPE": + return list(ifcopenshell.util.element.get_types(entity)) + if kind == "MATERIAL": + return list(ifcopenshell.util.element.get_elements_by_material(ifc_file, entity)) + if kind == "PROFILE": + return list(ifcopenshell.util.element.get_elements_by_profile(entity)) + if kind in ("SYSTEM", "GROUP", "ZONE"): + return list(ifcopenshell.util.element.get_grouped_by(entity, is_recursive=True)) + if kind == "DRAWING": + return [entity] + return [] + + @classmethod + def compute_matrix_for_source(cls, kind: str, source_id: str) -> Optional[Any]: + """Build the empty's ``matrix_world`` for ``(kind, source_id)``. + + Returns ``None`` when nothing matches — the operator turns that + into an ERROR report + ``CANCELLED``. + + ``"DRAWING"`` returns a rotated matrix aligned to the camera and + sized to ``clip_start..clip_end`` × the drawing's in-plane + extents. All other kinds return an axis-aligned matrix sized to + the world AABB of the matched elements' Blender objects. + """ + if kind == "DRAWING": + ifc_file = tool.Ifc.get() + if ifc_file is None: + return None + try: + entity = ifc_file.by_id(int(source_id)) + except (TypeError, ValueError, RuntimeError): + return None + camera_obj = tool.Ifc.get_object(entity) + if camera_obj is None or camera_obj.type != "CAMERA": + return None + return cls._camera_frustum_matrix(camera_obj) + + elements = cls.iter_elements_for_source(kind, source_id) + return cls._world_bbox_matrix_for_elements(elements) + + @classmethod + def _world_bbox_matrix_for_elements(cls, elements: Iterable[Any]) -> Optional[Any]: + """World-AABB matrix of the Blender objects backing ``elements``. + + ``matrix_world = Translation(center) @ Diagonal(half_extents)`` + so the CUBE empty's local ``[-1, +1]^3`` lands on the AABB + corners. Filters out elements without a Blender object and + elements whose object has a zero-volume bound box (typical for + empties used as containers). Returns ``None`` when nothing + survives the filter so the caller can ERROR instead of creating + a degenerate clip box. + + Half-extents are floored at :data:`_CLIP_EXPAND_ABS` so a single + point or flat slab still produces an invertible matrix. + """ + from mathutils import Matrix + + min_x = min_y = min_z = float("inf") + max_x = max_y = max_z = float("-inf") + found = False + for element in elements: + obj = tool.Ifc.get_object(element) + if obj is None: + continue + bbox = tool.Blender.get_object_world_bounding_box(obj) + if bbox["dimensions"] == (0.0, 0.0, 0.0): + continue + min_x = min(min_x, bbox["min_x"]) + min_y = min(min_y, bbox["min_y"]) + min_z = min(min_z, bbox["min_z"]) + max_x = max(max_x, bbox["max_x"]) + max_y = max(max_y, bbox["max_y"]) + max_z = max(max_z, bbox["max_z"]) + found = True + if not found: + return None + cx, cy, cz = (min_x + max_x) / 2, (min_y + max_y) / 2, (min_z + max_z) / 2 + hx = max((max_x - min_x) / 2, _CLIP_EXPAND_ABS) + hy = max((max_y - min_y) / 2, _CLIP_EXPAND_ABS) + hz = max((max_z - min_z) / 2, _CLIP_EXPAND_ABS) + return Matrix.Translation((cx, cy, cz)) @ Matrix.Diagonal((hx, hy, hz, 1.0)) + + @classmethod + def _camera_frustum_matrix(cls, camera_obj: bpy.types.Object) -> Optional[Any]: + """Rotated matrix matching the camera frustum ``clip_start..clip_end``. + + Bonsai's drawing module parameterises a drawing camera's frustum + via ``BIMCameraProperties.width`` and ``.height`` (the printed + extents in world units). The CUBE empty inherits the camera's + rotation; depth spans ``[clip_start, clip_end]`` along the + camera's local −Z (Blender cameras look down −Z). + + Returns ``None`` when the camera has no usable drawing extents — + the operator surfaces that as an ERROR + ``CANCELLED``. + """ + from mathutils import Matrix + + cam_data = camera_obj.data + cam_props = getattr(cam_data, "BIMCameraProperties", None) + if cam_props is None: + return None + width = float(getattr(cam_props, "width", 0.0) or 0.0) + height = float(getattr(cam_props, "height", 0.0) or 0.0) + if width <= 0.0 or height <= 0.0: + return None + + clip_start = float(getattr(cam_data, "clip_start", 0.0)) + clip_end = float(getattr(cam_data, "clip_end", 1.0)) + + x_half = max(width / 2.0, _CLIP_EXPAND_ABS) + y_half = max(height / 2.0, _CLIP_EXPAND_ABS) + z_half = max((clip_end - clip_start) / 2.0, _CLIP_EXPAND_ABS) + z_center = -(clip_start + clip_end) / 2.0 + offset = Matrix.Translation((0.0, 0.0, z_center)) @ Matrix.Diagonal((x_half, y_half, z_half, 1.0)) + return camera_obj.matrix_world @ offset + # ------------------------------------------------------------------ # Cross-section caps # @@ -709,27 +986,64 @@ class ClipBox: @classmethod def _iter_capable_objects(cls, scene: bpy.types.Scene) -> Iterator[bpy.types.Object]: - """Yield mesh objects eligible for capping: visible ``IfcElement``s. + """Yield mesh objects eligible for capping. - Limits to ``IfcElement`` (walls, slabs, doors, windows, …) so - spatial structure (``IfcSpace``, ``IfcBuildingStorey``, - ``IfcSite``) and annotations / grids never get capped — they're - non-physical containers / overlays that shouldn't sprout solid - fill polygons at clip boundaries. + When ``clip_only_ifc_products`` is set (default), limits to + ``IfcElement`` (walls, slabs, doors, windows, …) so spatial + structure (``IfcSpace``, ``IfcBuildingStorey``, ``IfcSite``) and + annotations / grids never get capped — they're non-physical + containers / overlays that shouldn't sprout solid fill polygons + at clip boundaries. + + When unset, any visible mesh in the scene is eligible regardless + of IFC association — useful for clipping Blender-side reference + geometry alongside a loaded IFC. """ - ifc_file = tool.Ifc.get() - if ifc_file is None: + scene_props = cls.get_scene_props(scene) + only_ifc = scene_props.clip_only_ifc_products + if only_ifc and tool.Ifc.get() is None: return for obj in scene.objects: if obj.type != "MESH" or obj.data is None: continue if not obj.visible_get(): continue - entity = tool.Ifc.get_entity(obj) - if entity is None or not entity.is_a("IfcElement"): - continue + if only_ifc: + entity = tool.Ifc.get_entity(obj) + if entity is None or not entity.is_a("IfcElement"): + continue yield obj + @classmethod + def invalidate_cap_cache(cls, *, immediate: bool = False) -> None: + """Drop the cap cache and schedule a fresh rebuild. + + Public entry point for property-update callbacks (or any external + change to eligibility / clip-box selection) so callers don't reach + into the private cache state directly. Pass ``immediate=True`` for + UI-driven changes that should rebuild on the next idle tick + without waiting for the depsgraph-debounce window. + """ + cls._cap_cache.clear() + cls._last_cap_clip_box_hash = 0 + cls._schedule_cap_rebuild(interval=0.0 if immediate else None) + + @classmethod + def rebuild_caps_now(cls, scene: Optional[bpy.types.Scene] = None) -> None: + """Drop the cap cache and rebuild SYNCHRONOUSLY, then redraw. + + Public entry point for interactive end-of-drag handlers (e.g. the + face-resize gizmo group) where the user expects the caps to + re-form the instant they release the handle — without the + debounce window the depsgraph path inserts. + """ + cls._cancel_pending_cap_rebuild() + cls._cap_cache.clear() + cls._last_cap_clip_box_hash = 0 + cls.rebuild_cap_cache(scene) + for _area, region, _region_3d in tool.Blender.iter_view3d_regions(): + region.tag_redraw() + @classmethod def rebuild_cap_cache( cls, @@ -929,14 +1243,17 @@ class ClipBox: return relevant @classmethod - def _schedule_cap_rebuild(cls) -> None: + def _schedule_cap_rebuild(cls, *, interval: Optional[float] = None) -> None: """(Re)schedule the deferred cap rebuild. Each call cancels any pending timer and registers a fresh one so a burst of updates collapses to a single rebuild once the - debounce window of quiet elapses. + debounce window of quiet elapses. Pass ``interval=0.0`` for + next-tick rebuild without debounce (UI-driven changes that + only fire on explicit user action, not depsgraph bursts). """ cls._cancel_pending_cap_rebuild() + delay = interval if interval is not None else cls._CAP_REBUILD_DEBOUNCE_SECONDS def _do_rebuild() -> None: cls._pending_cap_rebuild = None @@ -958,7 +1275,7 @@ class ClipBox: region.tag_redraw() return None - bpy.app.timers.register(_do_rebuild, first_interval=cls._CAP_REBUILD_DEBOUNCE_SECONDS) + bpy.app.timers.register(_do_rebuild, first_interval=delay) cls._pending_cap_rebuild = _do_rebuild @classmethod diff --git a/src/bonsai/test/bim/module/clip_box/test_add_for_source.py b/src/bonsai/test/bim/module/clip_box/test_add_for_source.py new file mode 100644 index 0000000000..947e2ffdfe --- /dev/null +++ b/src/bonsai/test/bim/module/clip_box/test_add_for_source.py @@ -0,0 +1,134 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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. + +import bpy +import ifcopenshell +import ifcopenshell.api.spatial +import pytest + +import bonsai.tool as tool +from test.bim.bootstrap import NewFile + +pytestmark = pytest.mark.clip_box + + +def _make_ifc_cube(ifc, ifc_class, location=(0.0, 0.0, 0.0), size=2.0): + bpy.ops.mesh.primitive_cube_add(size=size, location=location) + obj = bpy.context.active_object + entity = ifc.create_entity(ifc_class) + tool.Ifc.link(entity, obj) + return entity, obj + + +class TestAddClipBoxForSourceSpatial(NewFile): + def test_spatial_creates_clip_box_sized_to_contained_walls(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + storey = ifc.create_entity("IfcBuildingStorey") + wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0) + wall_b, _ = _make_ifc_cube(ifc, "IfcWall", location=(4.0, 0.0, 0.0), size=2.0) + ifcopenshell.api.spatial.assign_container( + ifc, products=[wall_a, wall_b], relating_structure=storey + ) + + result = bpy.ops.bim.add_clip_box_for_source( + source_kind="SPATIAL", source_id=str(storey.id()) + ) + + assert result == {"FINISHED"} + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 1 + host = scene_props.clip_boxes[0].obj + assert tool.ClipBox.get_object_props(host).is_clip_box is True + translation, _, scale = host.matrix_world.decompose() + assert translation.x == pytest.approx(2.0) + assert scale.x == pytest.approx(3.0) + + +class TestAddClipBoxForSourceClass(NewFile): + def test_class_creates_clip_box_for_all_walls(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + # Two walls + one window; the IfcWall pick should cover only the walls. + _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0) + _make_ifc_cube(ifc, "IfcWall", location=(4.0, 0.0, 0.0), size=2.0) + _make_ifc_cube(ifc, "IfcWindow", location=(20.0, 0.0, 0.0), size=2.0) + + result = bpy.ops.bim.add_clip_box_for_source( + source_kind="CLASS", source_id="IfcWall" + ) + + assert result == {"FINISHED"} + scene_props = tool.ClipBox.get_scene_props() + host = scene_props.clip_boxes[0].obj + translation, _, scale = host.matrix_world.decompose() + # AABB of the two walls only (x in [-1, 5]); window at x=20 must not contribute. + assert translation.x == pytest.approx(2.0) + assert scale.x == pytest.approx(3.0) + + +class TestAddClipBoxForSourceEmpty(NewFile): + def test_no_matching_elements_reports_error(self): + # bpy.ops.* raises RuntimeError when an operator reports {"ERROR"}, + # so the assertion is on the raised message rather than the return code. + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + walltype = ifc.create_entity("IfcWallType") + # No occurrences linked — TYPE source resolves to 0 elements. + with pytest.raises(RuntimeError, match="No elements found"): + bpy.ops.bim.add_clip_box_for_source( + source_kind="TYPE", source_id=str(walltype.id()) + ) + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 0 + + def test_placeholder_source_id_reports_error(self): + # With no IFC file loaded, data.py callbacks return the NO_OPTIONS_ID + # sentinel. Submitting that sentinel as the picked source must ERROR. + from bonsai.bim.module.clip_box import data as clip_data + + with pytest.raises(RuntimeError, match="No source selected"): + bpy.ops.bim.add_clip_box_for_source( + source_kind="SPATIAL", source_id=clip_data.NO_OPTIONS_ID + ) + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 0 + + +class TestRemoveClipBoxOrphan(NewFile): + def test_remove_orphan_entry_when_host_object_deleted(self): + # The remove operator must work on an orphan entry — i.e. one whose + # host empty was deleted out from under it via the outliner. + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 1 + host = scene_props.clip_boxes[0].obj + assert host is not None + + bpy.data.objects.remove(host, do_unlink=True) + + # Entry survives but its `obj` pointer is now None. + assert len(scene_props.clip_boxes) == 1 + assert scene_props.clip_boxes[0].obj is None + + result = bpy.ops.bim.remove_clip_box(index=0) + + assert result == {"FINISHED"} + assert len(scene_props.clip_boxes) == 0 diff --git a/src/bonsai/test/bim/module/clip_box/test_clip_only_ifc_products.py b/src/bonsai/test/bim/module/clip_box/test_clip_only_ifc_products.py new file mode 100644 index 0000000000..12883a23aa --- /dev/null +++ b/src/bonsai/test/bim/module/clip_box/test_clip_only_ifc_products.py @@ -0,0 +1,273 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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. + +"""Pins the ``clip_only_ifc_products`` toggle contract. + +The toggle gates the cap-eligibility filter (IFC-only vs. all visible meshes) +and lives only on the Blender Scene PG — the project pset must never carry it. +""" + +import math + +import bpy +import ifcopenshell +import pytest +from mathutils import Matrix, Vector + +import bonsai.tool as tool +from test.bim.bootstrap import NewFile + +pytestmark = pytest.mark.clip_box + + +def _make_ifc_wall(ifc, location=(0.0, 0.0, 0.0)): + bpy.ops.mesh.primitive_cube_add(size=2.0, location=location) + obj = bpy.context.active_object + entity = ifc.create_entity("IfcWall") + tool.Ifc.link(entity, obj) + return entity, obj + + +def _make_blender_cube(location=(0.0, 0.0, 0.0)): + bpy.ops.mesh.primitive_cube_add(size=2.0, location=location) + return bpy.context.active_object + + +class TestDefaultIsTrue(NewFile): + def test_clip_only_ifc_products_defaults_to_true(self): + scene_props = tool.ClipBox.get_scene_props() + assert scene_props.clip_only_ifc_products is True + + +class TestCapEligibilityHonorsToggle(NewFile): + def test_only_ifc_true_excludes_non_ifc_mesh(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + _, wall = _make_ifc_wall(ifc, location=(0.0, 0.0, 0.0)) + cube = _make_blender_cube(location=(4.0, 0.0, 0.0)) + scene_props = tool.ClipBox.get_scene_props() + scene_props.clip_only_ifc_products = True + + eligible = set(tool.ClipBox._iter_capable_objects(bpy.context.scene)) + + assert wall in eligible + assert cube not in eligible + + def test_only_ifc_false_includes_non_ifc_mesh(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + _, wall = _make_ifc_wall(ifc, location=(0.0, 0.0, 0.0)) + cube = _make_blender_cube(location=(4.0, 0.0, 0.0)) + scene_props = tool.ClipBox.get_scene_props() + scene_props.clip_only_ifc_products = False + + eligible = set(tool.ClipBox._iter_capable_objects(bpy.context.scene)) + + assert wall in eligible + assert cube in eligible + + def test_only_ifc_false_works_without_ifc_file_loaded(self): + # No IFC at all; eligibility should still yield Blender meshes when + # the IFC-only filter is off, since there's nothing to filter against. + cube = _make_blender_cube(location=(0.0, 0.0, 0.0)) + scene_props = tool.ClipBox.get_scene_props() + scene_props.clip_only_ifc_products = False + + eligible = set(tool.ClipBox._iter_capable_objects(bpy.context.scene)) + + assert cube in eligible + + +class TestShowCapsTriggersRebuild(NewFile): + def test_show_caps_off_then_on_schedules_cap_rebuild(self): + # Off → On must schedule a rebuild — without this, caps stay empty + # until the user nudges geometry to fire the next depsgraph tick. + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + scene_props.show_caps = False + tool.ClipBox._cancel_pending_cap_rebuild() + assert tool.ClipBox._pending_cap_rebuild is None + + scene_props.show_caps = True + + assert tool.ClipBox._pending_cap_rebuild is not None + tool.ClipBox._cancel_pending_cap_rebuild() + + +class TestRebuildCapsNow(NewFile): + def test_rebuild_caps_now_cancels_any_pending_debounce(self): + # Synchronous path must wipe the debounced timer — otherwise the + # rebuild fires twice when the gizmo unlock interleaves with a + # depsgraph tick. + bpy.ops.bim.add_clip_box() + tool.ClipBox._schedule_cap_rebuild() + assert tool.ClipBox._pending_cap_rebuild is not None + + tool.ClipBox.rebuild_caps_now() + + assert tool.ClipBox._pending_cap_rebuild is None + + +class TestActiveClipBoxIndexRebuildsCaps(NewFile): + def test_index_change_schedules_cap_rebuild(self): + # UI-list click changes active_clip_box_index — the cap cache + # belongs to the previous box's clip volume, so a rebuild must + # be scheduled so the overlay matches the newly-active box. + bpy.ops.bim.add_clip_box() + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + tool.ClipBox._cancel_pending_cap_rebuild() + assert tool.ClipBox._pending_cap_rebuild is None + + scene_props.active_clip_box_index = 0 + + assert tool.ClipBox._pending_cap_rebuild is not None + tool.ClipBox._cancel_pending_cap_rebuild() + + +def _exec_align_view(axis: int, is_max: bool): + """Run ``bim.align_view_to_clip_face`` against the first VIEW_3D area + and return its ``rv3d``. Skips if no viewport is available in the + test session.""" + for area in bpy.context.window.screen.areas: + if area.type != "VIEW_3D": + continue + region = next((r for r in area.regions if r.type == "WINDOW"), None) + if region is None: + continue + with bpy.context.temp_override(area=area, region=region): + result = bpy.ops.bim.align_view_to_clip_face( + "EXEC_DEFAULT", axis=axis, is_max=is_max + ) + assert result == {"FINISHED"} + return bpy.context.space_data.region_3d + pytest.skip("No VIEW_3D area available") + + +class TestAlignViewToClipFace(NewFile): + def test_align_view_sets_rv3d_rotation_to_face_normal(self): + # The operator must reorient the viewport so its forward axis + # points AGAINST the picked face's outward normal (so the user + # sees the face from outside). + bpy.ops.bim.add_clip_box() + clip_box = tool.ClipBox.get_active_clip_box() + # Rotate the empty so the +X face's outward world normal isn't + # axis-aligned — proves the operator handles arbitrary rotation. + clip_box.matrix_world = Matrix.Rotation(math.radians(30), 4, "Z") @ clip_box.matrix_world + + rv3d = _exec_align_view(axis=0, is_max=True) + + outward = clip_box.matrix_world.to_3x3().col[0].normalized() + forward = rv3d.view_rotation @ Vector((0.0, 0.0, -1.0)) + assert (forward - (-outward)).length < 1e-4 + + def test_align_view_uses_box_local_z_up_for_side_face(self): + # Side faces (±X, ±Y local normals) follow Blender's numpad 1 / 3 + # convention but in the BOX'S local frame: local +Z is the + # screen-up axis, transformed through the empty's rotation. + bpy.ops.bim.add_clip_box() + clip_box = tool.ClipBox.get_active_clip_box() + clip_box.matrix_world = Matrix.Rotation(math.radians(45), 4, "Z") @ clip_box.matrix_world + + rv3d = _exec_align_view(axis=0, is_max=True) + + expected_up = (clip_box.matrix_world.to_quaternion() @ Vector((0.0, 0.0, 1.0))).normalized() + up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0)) + assert (up_world - expected_up).length < 1e-3, ( + f"Side-face view must have box-local +Z as up; expected {tuple(expected_up)}, got {tuple(up_world)}" + ) + + def test_align_view_keeps_box_local_z_up_for_negative_y_face(self): + # Clicking the -Y face used to put world +Z at the BOTTOM of the + # screen. With box-local convention it stays at the top. + bpy.ops.bim.add_clip_box() + + rv3d = _exec_align_view(axis=1, is_max=False) + + up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0)) + assert up_world.z > 0.99, f"-Y face view must keep box-local +Z as up, got {tuple(up_world)}" + + def test_align_view_respects_box_local_axes_when_box_x_rotated(self): + # Rotating around X moves box-local +Z away from world +Z; the + # up axis must follow the BOX, otherwise the box edges no longer + # appear horizontal/vertical when aligned to a face — the bug + # users hit on rotated boxes. + bpy.ops.bim.add_clip_box() + clip_box = tool.ClipBox.get_active_clip_box() + clip_box.matrix_world = Matrix.Rotation(math.radians(30), 4, "X") @ clip_box.matrix_world + + rv3d = _exec_align_view(axis=0, is_max=True) + + expected_up = (clip_box.matrix_world.to_quaternion() @ Vector((0.0, 0.0, 1.0))).normalized() + up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0)) + assert (up_world - expected_up).length < 1e-3, ( + f"X-rotated box must use box-local Z; expected {tuple(expected_up)}, got {tuple(up_world)}" + ) + + def test_align_view_uses_box_local_y_up_for_top_face(self): + # Top face (local +Z outward) follows Blender's numpad-7 + # convention applied in the box's local frame: local +Y is up. + bpy.ops.bim.add_clip_box() + + rv3d = _exec_align_view(axis=2, is_max=True) + + up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0)) + assert up_world.y > 0.99, f"Top-face view must have box-local +Y as up, got {tuple(up_world)}" + + def test_align_view_uses_box_local_negative_y_up_for_bottom_face(self): + # Bottom face (local -Z outward) follows ctrl-numpad-7: box-local + # -Y is up. + bpy.ops.bim.add_clip_box() + + rv3d = _exec_align_view(axis=2, is_max=False) + + up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0)) + assert up_world.y < -0.99, f"Bottom-face view must have box-local -Y as up, got {tuple(up_world)}" + + +class TestNotPersistedToProjectPset(NewFile): + def test_pset_does_not_carry_clip_only_ifc_products(self): + bpy.ops.bim.create_project() + scene_props = tool.ClipBox.get_scene_props() + # Flip to a non-default value, then trigger a pset write. + scene_props.clip_only_ifc_products = False + bpy.ops.bim.add_clip_box() # writes the pset + + import ifcopenshell.util.element + + project = tool.Ifc.get().by_type("IfcProject")[0] + pset = ifcopenshell.util.element.get_psets(project).get(tool.ClipBox.PSET_NAME, {}) + + # Whatever the pset stores, it must not carry this scene-only toggle. + for key in pset: + assert "clip_only_ifc" not in key.lower(), ( + f"Project pset unexpectedly carries the scene-only toggle (key {key!r})" + ) + + def test_load_from_pset_does_not_touch_clip_only_ifc_products(self): + # Round-trip: set the toggle on the Scene, simulate a pset load, and + # confirm the loader didn't overwrite the user's Scene-level choice. + bpy.ops.bim.create_project() + scene_props = tool.ClipBox.get_scene_props() + scene_props.clip_only_ifc_products = False + + tool.ClipBox.load_from_project_pset() + + assert scene_props.clip_only_ifc_products is False diff --git a/src/bonsai/test/bim/module/clip_box/test_face_quad.py b/src/bonsai/test/bim/module/clip_box/test_face_quad.py new file mode 100644 index 0000000000..2c838151fe --- /dev/null +++ b/src/bonsai/test/bim/module/clip_box/test_face_quad.py @@ -0,0 +1,298 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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. + +"""Tests for the generic face-quad gizmo core. + +Pins the three contracts the layout helper depends on: + +* ``compute_face_resize`` — pure one-sided resize arithmetic. +* ``front_facing_face_mask`` — view-aware face visibility predicate. +* ``apply_face_quad_layout`` — front-facing faces upload the solid + unit quad ("solid" state); back-facing faces upload the halo strips + ("strips" state). +""" + +import pytest +from mathutils import Matrix, Vector + +from bonsai.bim.module.clip_box import face_quad + +pytestmark = pytest.mark.clip_box + + +# ---------------------------------------------------------------- compute_face_resize --- + + +class TestComputeFaceResize: + def test_outward_drag_on_max_face_grows_half_extent_and_shifts_origin(self): + # Pulling the +X face outward by 2.0 world units must: + # - grow the world half by half the cursor delta (one-sided); + # - shift the empty's origin so the opposite (-X) face stays put. + new_scale, new_loc = face_quad.compute_face_resize( + value=10.0 + 2.0, # init + delta + init_world_half=10.0, + init_location=(0.0, 0.0, 0.0), + world_axis=(1.0, 0.0, 0.0), + display_size=1.0, + ) + # half-extent: 10 + 2/2 = 11 + assert new_scale == pytest.approx(11.0) + # origin shifts by half the realized delta (= 1.0) along +X + assert new_loc[0] == pytest.approx(1.0) + assert new_loc[1] == pytest.approx(0.0) + assert new_loc[2] == pytest.approx(0.0) + + def test_inward_drag_clamps_at_minimum_half_extent(self): + # Pulling the face inward by more than the current half collapses + # to a tiny floor instead of going negative. The realized delta + # (post-clamp) drives the location shift so the opposite face + # stays fixed even at the clamp. + new_scale, new_loc = face_quad.compute_face_resize( + value=0.0, # delta = -1.0 + init_world_half=1.0, + init_location=(5.0, 0.0, 0.0), + world_axis=(1.0, 0.0, 0.0), + display_size=1.0, + ) + assert new_scale > 0.0 + assert new_scale < 1.0 + # New origin sits between init (5.0) and -X face (which is at 4.0 + # = init.x - init_world_half). Since the clamp limited shrinkage, + # the new origin is just slightly less than init.x. + assert 4.0 < new_loc[0] < 5.0 + + def test_drag_on_min_face_via_negative_world_axis_grows_outward(self): + # On the -X face, ``world_axis`` is (-1, 0, 0). A positive + # ``delta`` (outward on this face) must still grow the half + # extent and shift the origin in the -X direction. + new_scale, new_loc = face_quad.compute_face_resize( + value=10.0 + 2.0, + init_world_half=10.0, + init_location=(0.0, 0.0, 0.0), + world_axis=(-1.0, 0.0, 0.0), + display_size=1.0, + ) + assert new_scale == pytest.approx(11.0) + # Origin shifts toward -X. + assert new_loc[0] == pytest.approx(-1.0) + + def test_display_size_scales_the_resulting_scale_axis(self): + # The returned scale is half_extent / display_size — so a + # display_size of 2.0 halves the scale relative to display_size + # of 1.0 for the same world half-extent. + new_scale_1, _ = face_quad.compute_face_resize( + value=10.0, + init_world_half=10.0, + init_location=(0.0, 0.0, 0.0), + world_axis=(1.0, 0.0, 0.0), + display_size=1.0, + ) + new_scale_2, _ = face_quad.compute_face_resize( + value=10.0, + init_world_half=10.0, + init_location=(0.0, 0.0, 0.0), + world_axis=(1.0, 0.0, 0.0), + display_size=2.0, + ) + assert new_scale_1 == pytest.approx(10.0) + assert new_scale_2 == pytest.approx(5.0) + + +# ----------------------------------------------------------- front_facing_face_mask --- + + +class TestFrontFacingFaceMask: + def test_view_along_neg_z_lights_up_only_pos_z_face(self): + # Camera looking down -Z (typical default front view): only the + # +Z face (last entry) faces the camera. + normals = ( + (-1.0, 0.0, 0.0), # -X face + (1.0, 0.0, 0.0), # +X face + (0.0, -1.0, 0.0), # -Y face + (0.0, 1.0, 0.0), # +Y face + (0.0, 0.0, -1.0), # -Z face + (0.0, 0.0, 1.0), # +Z face + ) + view_dir = (0.0, 0.0, -1.0) + + mask = face_quad.front_facing_face_mask(normals, view_dir) + + assert mask == (False, False, False, False, False, True) + + def test_view_along_pos_x_lights_up_neg_x_face(self): + # Camera looking along +X (front of the -X face). + normals = ( + (-1.0, 0.0, 0.0), + (1.0, 0.0, 0.0), + (0.0, -1.0, 0.0), + (0.0, 1.0, 0.0), + (0.0, 0.0, -1.0), + (0.0, 0.0, 1.0), + ) + view_dir = (1.0, 0.0, 0.0) + + mask = face_quad.front_facing_face_mask(normals, view_dir) + + assert mask == (True, False, False, False, False, False) + + def test_wrong_length_raises(self): + with pytest.raises(ValueError, match="expected 6 face normals"): + face_quad.front_facing_face_mask( + [(1.0, 0.0, 0.0), (-1.0, 0.0, 0.0)], (0.0, 0.0, -1.0) + ) + + +# ----------------------------------------------------- apply_face_quad_layout (front/back) --- + + +class _FakeQuad: + """Stand-in for ``BIM_GT_box_face_quad`` — only the slots the layout helper writes.""" + + def __init__(self): + self.matrix_basis = Matrix.Identity(4) + self.axis = Vector((0.0, 0.0, 0.0)) + self.hide = False + self.select_bias = 0.0 + self.is_highlight = False + self.custom_shape = None + self.custom_shape_select = None + self._last_geometry_state = None + self._strips_cache_key = None + + def new_custom_shape(self, kind, verts): + # Layout helper only stores the result; nothing further is asked of it. + return (kind, tuple(tuple(v) for v in verts)) + + +class _FakeOutline: + def __init__(self): + self.matrix_basis = Matrix.Identity(4) + self.alpha = 0.0 + self.alpha_highlight = 0.0 + + +class _FakeRV3D: + def __init__(self, view_rotation, view_matrix): + self.view_rotation = view_rotation + self.view_matrix = view_matrix + # Blender's location_3d_to_region_2d reads perspective_matrix to + # project world points; a simple ortho-projection matrix is enough + # for the layout helper's halo-strip pixel measurement. + self.perspective_matrix = view_matrix + self.is_perspective = False + + +class _FakeRegion: + width = 800 + height = 600 + + +def _run_layout(view_dir: Vector) -> tuple[str, ...]: + """Apply the layout helper for a unit cube at the origin with a + given world-space view direction; return each route's + ``_last_geometry_state`` in :data:`FACE_ROUTES` order.""" + quads = [_FakeQuad() for _ in range(6)] + outlines = [_FakeOutline() for _ in range(6)] + # view_rotation is the quaternion that rotates the camera's local + # forward (-Z) onto the desired world view direction. + view_rotation = Vector((0.0, 0.0, -1.0)).rotation_difference(view_dir.normalized()) + rv3d = _FakeRV3D(view_rotation, Matrix.Identity(4)) + face_quad.apply_face_quad_layout( + quad_gizmos=quads, + outline_gizmos=outlines, + bmin=Vector((-1.0, -1.0, -1.0)), + bmax=Vector((1.0, 1.0, 1.0)), + matrix_world=Matrix.Identity(4), + cage_rotation=Matrix.Identity(4), + region=_FakeRegion(), + rv3d=rv3d, + locked=False, + ) + return tuple(getattr(q, "_last_geometry_state", None) for q in quads) + + +class TestApplyFaceQuadLayout: + def test_oblique_view_yields_solid_fronts_and_strips_or_empty_backs(self): + # Oblique view direction (1, 1, -1) hits the box from the +X, +Y, + # +Z octant. Faces facing toward the camera (-X, -Y, +Z) must + # render as "solid"; faces facing away (+X, +Y, -Z) must render + # as back-facing — either "strips" (when adjacent front faces + # give halo edges) or "empty" (when no front-facing neighbour). + states = _run_layout(view_dir=Vector((1.0, 1.0, -1.0))) + + # FACE_ROUTES order: (-X, +X, -Y, +Y, -Z, +Z) + # Front-facing routes (against the view direction): -X, -Y, +Z + assert states[0] == "solid" # -X + assert states[2] == "solid" # -Y + assert states[5] == "solid" # +Z + # Back-facing routes (with the view direction): +X, +Y, -Z + for back_idx in (1, 3, 4): + assert states[back_idx] in ("strips", "empty") + + def test_negative_scale_host_does_not_invert_front_back_split(self): + # User-reported bug: when the host empty has scale=-1 on an axis, + # the visible +X side of the cube sits on world +X (negative-scale + # flips the local +X vertex onto world -X but the local -X vertex + # onto world +X — same set of points). The OLD layout used the + # signed matrix for positions while rotation-only for normals, + # which placed the "+X face" gizmo on world -X. After the + # ``_abs_scale_matrix`` fix the gizmo for the +X face must sit + # at world +X for an outward-X-facing view to register it as + # front-facing. + quads = [_FakeQuad() for _ in range(6)] + outlines = [_FakeOutline() for _ in range(6)] + # View toward +X: the +X face is at world +X for a standard box. + view_rotation = Vector((0.0, 0.0, -1.0)).rotation_difference( + Vector((-1.0, 0.0, 0.0)) + ) + rv3d = _FakeRV3D(view_rotation, Matrix.Identity(4)) + # Negative X scale (mirroring the cube along world X). + mw = Matrix.Diagonal((-1.0, 1.0, 1.0, 1.0)) + + face_quad.apply_face_quad_layout( + quad_gizmos=quads, + outline_gizmos=outlines, + bmin=Vector((-1.0, -1.0, -1.0)), + bmax=Vector((1.0, 1.0, 1.0)), + matrix_world=mw, + cage_rotation=Matrix.Identity(4), + region=_FakeRegion(), + rv3d=rv3d, + locked=False, + ) + + # Route 1 = (axis=0, is_max=True) = the +X face. Must be solid + # (front-facing) for a +X-facing view, regardless of sign-of-scale. + assert quads[1]._last_geometry_state == "solid" + + def test_view_parallel_front_face_remains_interactive(self): + # Looking dead-on at +Z (view_dir = -Z): the +Z face sits + # antiparallel to the view direction, so it's still the + # front-facing face. It must render solid (clickable for both + # the resize drag and the CTRL+click align-view dispatch), + # never hidden — the older "lockout" treatment removed + # CTRL+click access on the very face users most want to click. + states = _run_layout(view_dir=Vector((0.0, 0.0, -1.0))) + + assert states[5] == "solid" # +Z face (front-facing) stays interactive. + # -Z face has no adjacent front-facing neighbours in this view, + # so its halo strip degenerates to empty — but that's the + # back-face path, not a deliberate lockout. + assert states[4] == "empty" diff --git a/src/bonsai/test/bim/module/clip_box/test_source_kind_forward_compat.py b/src/bonsai/test/bim/module/clip_box/test_source_kind_forward_compat.py new file mode 100644 index 0000000000..84514ebd4b --- /dev/null +++ b/src/bonsai/test/bim/module/clip_box/test_source_kind_forward_compat.py @@ -0,0 +1,76 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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. + +"""Forward-compat guards for the source-based clip-box wiring. + +Adding a new source kind requires matching entries across four sites — the +label dict, the dispatch table, a callback in ``data.py``, and the menu entry. +Missing one path silently degrades the dialog to "No options" with no error. +These tests pin the four-way integrity. +""" + +import pytest + +from bonsai.bim.module.clip_box import data, operator, ui + +pytestmark = pytest.mark.clip_box + + +def test_every_label_has_a_dispatch_entry(): + missing = set(operator.SOURCE_KIND_LABELS) - set(operator._SOURCE_ID_DISPATCH) + assert not missing, f"Kinds missing from dispatch: {sorted(missing)}" + + +def test_every_dispatch_value_is_callable(): + for kind, fn in operator._SOURCE_ID_DISPATCH.items(): + assert callable(fn), f"Dispatch entry for {kind} is not callable" + + +def test_every_dispatch_target_lives_in_data_module(): + # Each callback must be a real attribute of the data module; protects + # against typos in the dispatch table that would otherwise only surface + # at the first dialog open. + for kind, fn in operator._SOURCE_ID_DISPATCH.items(): + assert getattr(data, fn.__name__, None) is fn, ( + f"Dispatch target for {kind} ({fn.__name__}) is not exported from data.py" + ) + + +def test_every_menu_entry_is_a_known_kind(): + for kind, label, icon in ui._SOURCE_MENU_ENTRIES: + assert kind in operator.SOURCE_KIND_LABELS, ( + f"Menu kind {kind!r} (label={label!r}) is not in SOURCE_KIND_LABELS" + ) + + +def test_every_label_has_a_menu_entry(): + menu_kinds = {kind for kind, _label, _icon in ui._SOURCE_MENU_ENTRIES} + missing = set(operator.SOURCE_KIND_LABELS) - menu_kinds + assert not missing, f"Kinds missing from menu: {sorted(missing)}" + + +def test_status_values_match_between_tool_and_data(): + # The status picker labels in data.STATUS_LABELS and the tool-layer + # validation list must agree — the dispatcher rejects any status value + # missing from the latter. + from bonsai.tool.clip_box import SOURCE_STATUS_VALUES + + data_values = tuple(value for value, _label in data.STATUS_LABELS) + assert data_values == SOURCE_STATUS_VALUES diff --git a/src/bonsai/test/tool/test_clip_box_for_source.py b/src/bonsai/test/tool/test_clip_box_for_source.py new file mode 100644 index 0000000000..78643e3f4d --- /dev/null +++ b/src/bonsai/test/tool/test_clip_box_for_source.py @@ -0,0 +1,375 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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. + +import math + +import bpy +import ifcopenshell +import ifcopenshell.api.spatial +import ifcopenshell.api.type +import pytest +from mathutils import Vector + +import bonsai.tool as tool +from test.bim.bootstrap import NewFile + +pytestmark = pytest.mark.clip_box + + +def _make_ifc_cube(ifc, ifc_class, location=(0.0, 0.0, 0.0), size=2.0): + """Real bpy cube + ifc entity, linked. ``size`` is the cube edge length.""" + bpy.ops.mesh.primitive_cube_add(size=size, location=location) + obj = bpy.context.active_object + entity = ifc.create_entity(ifc_class) + tool.Ifc.link(entity, obj) + return entity, obj + + +class TestWorldBboxMatrix(NewFile): + def test_two_cubes_returns_centred_aabb_matrix(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0) + wall_b, _ = _make_ifc_cube(ifc, "IfcWall", location=(4.0, 0.0, 0.0), size=2.0) + + matrix = tool.ClipBox._world_bbox_matrix_for_elements([wall_a, wall_b]) + + assert matrix is not None + translation, _, scale = matrix.decompose() + # World AABB: x in [-1, 5], y/z in [-1, 1] -> center (2, 0, 0), half (3, 1, 1). + assert translation.x == pytest.approx(2.0) + assert translation.y == pytest.approx(0.0) + assert translation.z == pytest.approx(0.0) + assert scale.x == pytest.approx(3.0) + assert scale.y == pytest.approx(1.0) + assert scale.z == pytest.approx(1.0) + + def test_empty_iterable_returns_none(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + assert tool.ClipBox._world_bbox_matrix_for_elements([]) is None + + def test_element_without_blender_object_is_skipped(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0) + unbound = ifc.create_entity("IfcWall") + + matrix = tool.ClipBox._world_bbox_matrix_for_elements([wall_a, unbound]) + + assert matrix is not None + translation, _, scale = matrix.decompose() + assert translation.x == pytest.approx(0.0) + assert translation.y == pytest.approx(0.0) + assert translation.z == pytest.approx(0.0) + assert scale.x == pytest.approx(1.0) + + def test_all_filtered_returns_none(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + unbound_a = ifc.create_entity("IfcWall") + unbound_b = ifc.create_entity("IfcWall") + assert tool.ClipBox._world_bbox_matrix_for_elements([unbound_a, unbound_b]) is None + + def test_coincident_cubes_return_invertible_matrix(self): + # Two cubes at the same location collapse to a zero-volume AABB. + # The half-extent floor must keep the matrix invertible so downstream + # clip-plane math doesn't divide through a singular transform. + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=0.0001) + wall_b, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=0.0001) + + matrix = tool.ClipBox._world_bbox_matrix_for_elements([wall_a, wall_b]) + + assert matrix is not None + # A zero determinant means the matrix would map every point onto a + # subspace — the floor must prevent that. + assert matrix.determinant() != 0.0 + + +class TestCameraFrustumMatrix(NewFile): + def _make_camera(self, location=(0.0, 0.0, 0.0), rotation=None): + cam_data = bpy.data.cameras.new("DrawingCam") + cam_data.type = "ORTHO" + obj = bpy.data.objects.new("DrawingCam", cam_data) + bpy.context.scene.collection.objects.link(obj) + obj.location = location + if rotation is not None: + obj.rotation_euler = rotation + bpy.context.view_layer.update() + return obj + + def test_identity_camera_width_height_drive_in_plane_extents(self): + obj = self._make_camera() + cam = obj.data + cam.clip_start = 0.0 + cam.clip_end = 10.0 + cam.BIMCameraProperties.width = 8.0 + cam.BIMCameraProperties.height = 6.0 + + matrix = tool.ClipBox._camera_frustum_matrix(obj) + + translation, _, scale = matrix.decompose() + # Identity rotation: box centre at (0, 0, -5) in world (cameras look down -Z). + assert translation.x == pytest.approx(0.0) + assert translation.y == pytest.approx(0.0) + assert translation.z == pytest.approx(-5.0) + # Half-extents: width/2, height/2, (clip_end - clip_start) / 2. + assert scale.x == pytest.approx(4.0) + assert scale.y == pytest.approx(3.0) + assert scale.z == pytest.approx(5.0) + + def test_rotated_camera_preserves_rotation_in_matrix(self): + obj = self._make_camera(rotation=(0.0, math.radians(90), 0.0)) + cam = obj.data + cam.clip_start = 0.0 + cam.clip_end = 4.0 + cam.BIMCameraProperties.width = 2.0 + cam.BIMCameraProperties.height = 2.0 + + matrix = tool.ClipBox._camera_frustum_matrix(obj) + + _, rotation, scale = matrix.decompose() + # Scale is rotation-invariant. + assert scale.x == pytest.approx(1.0) + assert scale.y == pytest.approx(1.0) + assert scale.z == pytest.approx(2.0) + # The rotation component matches the camera's own rotation; quaternion + # dot product near unit magnitude means the orientations agree. + cam_rot = obj.matrix_world.decompose()[1] + assert abs(cam_rot.dot(rotation)) > 0.999 + + def test_returns_none_when_width_height_zero(self): + # A camera without usable drawing extents (width or height ≤ 0) + # cannot define a clip volume — caller surfaces ERROR + CANCELLED. + obj = self._make_camera() + cam = obj.data + cam.clip_start = 0.0 + cam.clip_end = 10.0 + cam.BIMCameraProperties.width = 8.0 + # height stays at the BIMCameraProperties default (50). We can't set + # height=0 here because the update callback divides width/height. + # Set width=0 directly via the underlying ID property instead, which + # bypasses the registered FloatProperty update path. + cam.BIMCameraProperties["width"] = 0.0 + + assert tool.ClipBox._camera_frustum_matrix(obj) is None + + +class TestIterElementsForSource(NewFile): + def test_no_ifc_file_returns_empty(self): + # NewFile leaves IfcStore purged; tool.Ifc.get() is None here. + assert tool.ClipBox.iter_elements_for_source("SPATIAL", "1") == [] + + def test_unknown_kind_returns_empty(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + wall = ifc.create_entity("IfcWall") + assert tool.ClipBox.iter_elements_for_source("UNKNOWN_KIND", str(wall.id())) == [] + + def test_non_integer_source_id_returns_empty(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + assert tool.ClipBox.iter_elements_for_source("SPATIAL", "not_an_int") == [] + + def test_unresolved_source_id_returns_empty(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + assert tool.ClipBox.iter_elements_for_source("SPATIAL", "999999") == [] + + def test_spatial_returns_decomposition(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + storey = ifc.create_entity("IfcBuildingStorey") + wall_a = ifc.create_entity("IfcWall") + wall_b = ifc.create_entity("IfcWall") + ifcopenshell.api.spatial.assign_container( + ifc, products=[wall_a, wall_b], relating_structure=storey + ) + + result = tool.ClipBox.iter_elements_for_source("SPATIAL", str(storey.id())) + + assert set(result) == {wall_a, wall_b} + + def test_type_returns_occurrences(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + wall_type = ifc.create_entity("IfcWallType") + wall_a = ifc.create_entity("IfcWall") + wall_b = ifc.create_entity("IfcWall") + ifcopenshell.api.type.assign_type( + ifc, related_objects=[wall_a, wall_b], relating_type=wall_type + ) + + result = tool.ClipBox.iter_elements_for_source("TYPE", str(wall_type.id())) + + assert set(result) == {wall_a, wall_b} + + def test_drawing_returns_drawing_entity(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + drawing = ifc.create_entity("IfcAnnotation", ObjectType="DRAWING") + + result = tool.ClipBox.iter_elements_for_source("DRAWING", str(drawing.id())) + + assert result == [drawing] + + def test_status_invalid_value_returns_empty(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + assert tool.ClipBox.iter_elements_for_source("STATUS", "MADE_UP_STATUS") == [] + + def test_class_returns_all_instances_of_ifc_class(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + wall_a = ifc.create_entity("IfcWall") + wall_b = ifc.create_entity("IfcWall") + window = ifc.create_entity("IfcWindow") + + result = tool.ClipBox.iter_elements_for_source("CLASS", "IfcWall") + + assert set(result) == {wall_a, wall_b} + assert window not in result + + def test_class_unknown_ifc_class_returns_empty(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + ifc.create_entity("IfcWall") + assert tool.ClipBox.iter_elements_for_source("CLASS", "IfcNotARealClass") == [] + + +class TestComputeMatrixForSource(NewFile): + def test_spatial_aggregates_contained_elements(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + storey = ifc.create_entity("IfcBuildingStorey") + wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0) + wall_b, _ = _make_ifc_cube(ifc, "IfcWall", location=(4.0, 0.0, 0.0), size=2.0) + ifcopenshell.api.spatial.assign_container( + ifc, products=[wall_a, wall_b], relating_structure=storey + ) + + matrix = tool.ClipBox.compute_matrix_for_source("SPATIAL", str(storey.id())) + + assert matrix is not None + translation, _, scale = matrix.decompose() + assert translation.x == pytest.approx(2.0) + assert scale.x == pytest.approx(3.0) + + def test_no_match_returns_none(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + wall_type = ifc.create_entity("IfcWallType") + # No occurrences linked. + assert tool.ClipBox.compute_matrix_for_source("TYPE", str(wall_type.id())) is None + + def test_drawing_uses_camera_frustum(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + drawing = ifc.create_entity("IfcAnnotation", ObjectType="DRAWING") + cam_data = bpy.data.cameras.new("Cam") + cam_data.type = "ORTHO" + cam_data.clip_start = 0.0 + cam_data.clip_end = 10.0 + cam_data.BIMCameraProperties.width = 4.0 + cam_data.BIMCameraProperties.height = 4.0 + obj = bpy.data.objects.new("Cam", cam_data) + bpy.context.scene.collection.objects.link(obj) + tool.Ifc.link(drawing, obj) + + matrix = tool.ClipBox.compute_matrix_for_source("DRAWING", str(drawing.id())) + + assert matrix is not None + _, _, scale = matrix.decompose() + assert scale.x == pytest.approx(2.0) + assert scale.y == pytest.approx(2.0) + assert scale.z == pytest.approx(5.0) + + def test_drawing_with_non_camera_returns_none(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + drawing = ifc.create_entity("IfcAnnotation", ObjectType="DRAWING") + obj = bpy.data.objects.new("NotACamera", None) + bpy.context.scene.collection.objects.link(obj) + tool.Ifc.link(drawing, obj) + + assert tool.ClipBox.compute_matrix_for_source("DRAWING", str(drawing.id())) is None + + def test_status_with_no_matching_elements_returns_none(self): + # STATUS pick with a valid status value but no element carrying that + # status — the dispatcher must surface "nothing matched" the same way + # an empty TYPE / MATERIAL pick does. + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + # Create a wall but never assign its Pset_WallCommon.Status — so a + # STATUS=NEW query finds 0 elements. + _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0) + + assert tool.ClipBox.compute_matrix_for_source("STATUS", "NEW") is None + + +class _FakeRegion: + def __init__(self, width, height): + self.width = width + self.height = height + + +class _FakeRV3D: + def __init__(self, view_matrix=()): # () is a truthy-enough non-None stand-in + self.view_matrix = view_matrix + self.updated = False + self.use_clip_planes = False + self.clip_planes = None + + def update(self): + self.updated = True + + +class TestRegionIsRenderable: + """``_region_is_renderable`` gates the clip-plane arm against collapsed / + initializing regions whose ``region_3d.update()`` would CTD Blender inside + ``GPU_matrix_ortho_set`` (the timer-arm crash this guard fixes).""" + + def test_sized_region_with_view_matrix_is_renderable(self): + assert tool.ClipBox._region_is_renderable(_FakeRegion(800, 600), _FakeRV3D()) is True + + def test_zero_width_is_not_renderable(self): + assert tool.ClipBox._region_is_renderable(_FakeRegion(0, 600), _FakeRV3D()) is False + + def test_zero_height_is_not_renderable(self): + assert tool.ClipBox._region_is_renderable(_FakeRegion(800, 0), _FakeRV3D()) is False + + def test_missing_view_matrix_is_not_renderable(self): + rv3d = _FakeRV3D() + rv3d.view_matrix = None + assert tool.ClipBox._region_is_renderable(_FakeRegion(800, 600), rv3d) is False + + def test_arm_region_early_returns_on_zero_size(self): + # A collapsed region must never reach temp_override / clip_border / + # update() — _arm_region short-circuits at the size guard. Positively + # assert update() was NOT called and no clip state was written, so a + # regression that drops the guard fails here rather than passing on + # "didn't crash". + rv3d = _FakeRV3D() + tool.ClipBox._arm_region(object(), _FakeRegion(0, 0), rv3d, ()) + assert rv3d.updated is False + assert rv3d.use_clip_planes is False + assert rv3d.clip_planes is None From 6367de6102418526c6816387aafd89ec6eecb7a0 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Mon, 22 Jun 2026 10:40:01 +0200 Subject: [PATCH 02/10] Add tool.Blender.draw_quads utility Promotes the private _fill_quads_alpha helper from bim/module/model/decorator.py to tool.Blender.draw_quads so any feature decorator can reuse the same TRIS-batch path. The new utility accepts an optional outline_color so callers can draw fill, outline, or both in a single call. Migrates the only existing caller (WallGizmoPreviewDecorator in model/wall.py) to the public API and removes the local helper. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/model/decorator.py | 40 --------- src/bonsai/bonsai/bim/module/model/wall.py | 3 +- src/bonsai/bonsai/tool/blender.py | 83 +++++++++++++++++++ 3 files changed, 84 insertions(+), 42 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index 0a2c2678ac..56b0df3bbe 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -2069,46 +2069,6 @@ class BoundingBoxDecorator: co2.y -= y_overlap / 2 + min_spacing -def _fill_quads_alpha( - context: bpy.types.Context, - quads: list[ - tuple[ - tuple[float, float, float], - tuple[float, float, float], - tuple[float, float, float], - tuple[float, float, float], - ] - ], - color_rgb: tuple[float, float, float], - alpha: float, -) -> None: - """Render ``quads`` (each a 4-tuple of world-space corner verts in CCW - order) as one TRIS batch with two triangles per quad.""" - if not quads: - return - verts: list[tuple[float, float, float]] = [] - indices: list[tuple[int, int, int]] = [] - for quad in quads: - if len(quad) != 4: - continue - base = len(verts) - verts.extend(tuple(v) for v in quad) - indices.append((base, base + 1, base + 2)) - indices.append((base, base + 2, base + 3)) - 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("UNIFORM_COLOR") - shader.bind() - shader.uniform_float("color", (*color_rgb, alpha)) - batch = batch_for_shader(shader, "TRIS", {"pos": verts}, indices=indices) - gpu.state.blend_set("ALPHA") - batch.draw(shader) - gpu.state.blend_set("NONE") - - def compute_mep_join_location(): """Midpoint between the closest endpoint pair of two selected MEP segments — the world location where a connecting fitting (bend / diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index ecbe544eb9..dd054635a7 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -63,7 +63,6 @@ from bonsai.bim.module.model.decorator import ( _BBOX_HIGHLIGHT_LINE_WIDTH, PolylineDecorator, ProductDecorator, - _fill_quads_alpha, bbox_world_edges, draw_polyline_segments, ) @@ -4859,7 +4858,7 @@ class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator): ], color_rgb: tuple[float, float, float], ) -> None: - _fill_quads_alpha(context, quads, color_rgb, self.QUAD_ALPHA) + tool.Blender.draw_quads(context, quads, fill_color=(*color_rgb, self.QUAD_ALPHA)) @staticmethod def _wall_floor_quad(mw: Matrix, x0: float, x1: float, y0: float, y1: float) -> tuple[ diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index c468b0572e..97656c3904 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -55,9 +55,11 @@ from typing import ( import bmesh import bpy +import gpu import ifcopenshell.util.element import numpy as np import numpy.typing as npt +from gpu_extras.batch import batch_for_shader from ifcopenshell import entity_instance from mathutils import Matrix, Vector @@ -2403,6 +2405,87 @@ class Blender(bonsai.core.tool.Blender): tris = [[loop.vert.index for loop in tri] for tri in bm.calc_loop_triangles()] draw_batch("TRIS", world_vert_coords, color, tris) + @classmethod + def draw_quads( + cls, + context: bpy.types.Context, + quads: Sequence[ + tuple[ + tuple[float, float, float], + tuple[float, float, float], + tuple[float, float, float], + tuple[float, float, float], + ] + ], + *, + fill_color: Optional[tuple[float, float, float, float]] = None, + outline_color: Optional[tuple[float, float, float, float]] = None, + outline_width: float = 1.0, + ) -> None: + """Render ``quads`` (each a 4-tuple of CCW world-space corners) as + a filled TRIS batch, an outline LINES batch, or both. + + Both colors are RGBA 4-tuples. Pass ``fill_color=None`` to skip + the fill pass and ``outline_color=None`` to skip the outline. + Skipping both is a no-op. + + Replaces the per-decorator quad-fill helpers that used to live + inline in each feature module. + """ + if not quads or (fill_color is None and outline_color is None): + return + region = getattr(context, "region", None) + if region is None: + return + + verts: list[tuple[float, float, float]] = [] + tri_indices: list[tuple[int, int, int]] = [] + line_indices: list[tuple[int, int]] = [] + for quad in quads: + if len(quad) != 4: + continue + base = len(verts) + verts.extend(tuple(v) for v in quad) + if fill_color is not None: + tri_indices.append((base, base + 1, base + 2)) + tri_indices.append((base, base + 2, base + 3)) + if outline_color is not None: + line_indices.append((base, base + 1)) + line_indices.append((base + 1, base + 2)) + line_indices.append((base + 2, base + 3)) + line_indices.append((base + 3, base)) + + if not cls.validate_shader_batch_data(verts, None): + return + + gpu.state.blend_set("ALPHA") + try: + if fill_color is not None and tri_indices: + shader = gpu.shader.from_builtin("UNIFORM_COLOR") + shader.bind() + shader.uniform_float("color", fill_color) + batch = batch_for_shader( + shader, "TRIS", {"pos": verts}, indices=tri_indices + ) + batch.draw(shader) + if outline_color is not None and line_indices: + shader = gpu.shader.from_builtin("UNIFORM_COLOR") + shader.bind() + shader.uniform_float("color", outline_color) + # Outline width: the UNIFORM_COLOR shader respects the + # GPU's current line-width state; restore on exit. + prev_width = gpu.state.line_width_get() + gpu.state.line_width_set(outline_width) + try: + batch = batch_for_shader( + shader, "LINES", {"pos": verts}, indices=line_indices + ) + batch.draw(shader) + finally: + gpu.state.line_width_set(prev_width) + finally: + gpu.state.blend_set("NONE") + @classmethod def build_dashed_line_segments( cls, From 27b0b920a999ed52a18a37d5f965dbf4d1a7200b Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Mon, 22 Jun 2026 11:03:47 +0200 Subject: [PATCH 03/10] Apply black formatting and ruff isort fixes Pre-commit checklist: black + ruff check. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/clip_box/data.py | 32 ++++---- .../bonsai/bim/module/clip_box/face_quad.py | 80 +++++-------------- .../bonsai/bim/module/clip_box/gizmos.py | 21 ++--- .../bonsai/bim/module/clip_box/operator.py | 4 +- src/bonsai/bonsai/bim/module/clip_box/ui.py | 1 - src/bonsai/bonsai/tool/blender.py | 8 +- .../module/clip_box/test_add_for_source.py | 20 ++--- .../clip_box/test_clip_only_ifc_products.py | 20 ++--- .../bim/module/clip_box/test_face_quad.py | 8 +- .../test_source_kind_forward_compat.py | 10 +-- .../test/tool/test_clip_box_for_source.py | 12 +-- 11 files changed, 67 insertions(+), 149 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/clip_box/data.py b/src/bonsai/bonsai/bim/module/clip_box/data.py index 7d4d515959..08bd60d01e 100644 --- a/src/bonsai/bonsai/bim/module/clip_box/data.py +++ b/src/bonsai/bonsai/bim/module/clip_box/data.py @@ -124,14 +124,16 @@ def class_items(self, context) -> EnumItems: def type_items(self, context) -> EnumItems: return _build_items( - "TYPE", "No types defined", + "TYPE", + "No types defined", lambda ifc: [(str(e.id()), _label(e, e.is_a()), "") for e in ifc.by_type("IfcTypeProduct")], ) def material_items(self, context) -> EnumItems: return _build_items( - "MATERIAL", "No materials defined", + "MATERIAL", + "No materials defined", lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcMaterial")], ) @@ -140,7 +142,8 @@ def profile_items(self, context) -> EnumItems: # ProfileName is optional. Skip unnamed profiles — they can't be # meaningfully picked from a flat list. return _build_items( - "PROFILE", "No named profiles", + "PROFILE", + "No named profiles", lambda ifc: [ (str(e.id()), f"{e.is_a()}: {e.ProfileName}", "") for e in ifc.by_type("IfcProfileDef") @@ -151,12 +154,9 @@ def profile_items(self, context) -> EnumItems: def drawing_items(self, context) -> EnumItems: return _build_items( - "DRAWING", "No drawings defined", - lambda ifc: [ - (str(e.id()), _label(e), "") - for e in ifc.by_type("IfcAnnotation") - if e.ObjectType == "DRAWING" - ], + "DRAWING", + "No drawings defined", + lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcAnnotation") if e.ObjectType == "DRAWING"], ) @@ -184,7 +184,8 @@ def system_items(self, context) -> EnumItems: # IfcStructuralAnalysisModel is a structural-grouping container, not a # distribution system — excluded to match Bonsai's other system pickers. return _build_items( - "SYSTEM", "No systems defined", + "SYSTEM", + "No systems defined", lambda ifc: [ (str(e.id()), _label(e, e.is_a()), "") for e in ifc.by_type("IfcSystem") @@ -197,16 +198,15 @@ def group_items(self, context) -> EnumItems: # include_subtypes=False so IfcSystem and IfcZone instances don't appear # under Group as well — those get their own picker entries. return _build_items( - "GROUP", "No groups defined", - lambda ifc: [ - (str(e.id()), _label(e), "") - for e in ifc.by_type("IfcGroup", include_subtypes=False) - ], + "GROUP", + "No groups defined", + lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcGroup", include_subtypes=False)], ) def zone_items(self, context) -> EnumItems: return _build_items( - "ZONE", "No zones defined", + "ZONE", + "No zones defined", lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcZone")], ) diff --git a/src/bonsai/bonsai/bim/module/clip_box/face_quad.py b/src/bonsai/bonsai/bim/module/clip_box/face_quad.py index 0e27d6eed0..70639881d5 100644 --- a/src/bonsai/bonsai/bim/module/clip_box/face_quad.py +++ b/src/bonsai/bonsai/bim/module/clip_box/face_quad.py @@ -57,7 +57,6 @@ import bpy from bpy_extras.view3d_utils import location_3d_to_region_2d, region_2d_to_location_3d from mathutils import Matrix, Vector - # --------------------------------------------------------------------------- # Public iteration order # --------------------------------------------------------------------------- @@ -236,9 +235,7 @@ def view_axis_parallel_face_mask( msg = f"expected 6 face normals, got {len(face_normals_world)}" raise ValueError(msg) vx, vy, vz = view_dir_world - return tuple( - abs(n[0] * vx + n[1] * vy + n[2] * vz) >= threshold for n in face_normals_world - ) + return tuple(abs(n[0] * vx + n[1] * vy + n[2] * vz) >= threshold for n in face_normals_world) # --------------------------------------------------------------------------- @@ -290,9 +287,7 @@ def compute_face_resize( # --------------------------------------------------------------------------- -def _compute_face_quad_scale( - bmin: Any, bmax: Any, axis: int, is_max: bool -) -> tuple[float, float]: +def _compute_face_quad_scale(bmin: Any, bmax: Any, axis: int, is_max: bool) -> tuple[float, float]: """Return ``(w, h)`` for the face quad's scale matrix.""" w_axis, h_axis = _QUAD_PERP_AXES[(axis, is_max)] w = float(bmax[w_axis] - bmin[w_axis]) @@ -327,9 +322,7 @@ def _shared_edge_corner_keys( ) -def _face_corner_keys( - axis: int, is_max: bool -) -> tuple[ +def _face_corner_keys(axis: int, is_max: bool) -> tuple[ tuple[int, int, int], tuple[int, int, int], tuple[int, int, int], @@ -406,13 +399,9 @@ def _compute_face_basis( orient: Any, ) -> tuple[Any, Any]: """World-space (translation, outward-normal-direction) for one face.""" - rotated_face_local = ( - cage_rotation.to_3x3() @ (face_local - pivot_local) + pivot_local - ) + rotated_face_local = cage_rotation.to_3x3() @ (face_local - pivot_local) + pivot_local face_world = mw @ rotated_face_local - world_axis = ( - mw_rot @ cage_rotation.to_3x3() @ (orient.to_3x3() @ Vector((0.0, 0.0, 1.0))) - ).normalized() + world_axis = (mw_rot @ cage_rotation.to_3x3() @ (orient.to_3x3() @ Vector((0.0, 0.0, 1.0)))).normalized() return face_world, world_axis @@ -431,13 +420,7 @@ def _compose_face_matrix_basis( world-space face rectangle, including the host's scale. """ quad_scale = Matrix.Diagonal((w, h, 1.0, 1.0)) - return ( - Matrix.Translation(face_world) - @ mw_rot_scale.to_4x4() - @ cage_rotation - @ orient - @ quad_scale - ) + return Matrix.Translation(face_world) @ mw_rot_scale.to_4x4() @ cage_rotation @ orient @ quad_scale def _compute_box_corners_world( @@ -499,9 +482,7 @@ def _world_radius_to_screen_pixels( except (AttributeError, ValueError): right = Vector((1.0, 0.0, 0.0)) sample_world = center_world + right * world_radius - return _world_segment_to_screen_pixels( - region, rv3d, center_world, sample_world, min_pixels=min_pixels - ) + return _world_segment_to_screen_pixels(region, rv3d, center_world, sample_world, min_pixels=min_pixels) def _world_segment_to_screen_pixels( @@ -601,9 +582,7 @@ class BIM_GT_box_face_quad(bpy.types.Gizmo): # noqa: N801 — Blender bl_idname # Freeze the projection plane at invoke — projection-plane # drift on tilted axes causes exponential delta runaway. self.depth_point = self.matrix_basis.translation.copy() - self.start_location = region_2d_to_location_3d( - region, rv3d, (event.mouse_x, event.mouse_y), self.depth_point - ) + self.start_location = region_2d_to_location_3d(region, rv3d, (event.mouse_x, event.mouse_y), self.depth_point) if getattr(self, "_group", None) is not None: self._group._lock_for(self) @@ -629,9 +608,7 @@ class BIM_GT_box_face_quad(bpy.types.Gizmo): # noqa: N801 — Blender bl_idname rv3d = context.region_data if region is None or rv3d is None: return {"CANCELLED"} - end_location = region_2d_to_location_3d( - region, rv3d, (event.mouse_x, event.mouse_y), self.depth_point - ) + end_location = region_2d_to_location_3d(region, rv3d, (event.mouse_x, event.mouse_y), self.depth_point) delta = (end_location - self.start_location).dot(self.axis) if "SNAP" in tweak: delta = round(delta, 1) @@ -639,9 +616,7 @@ class BIM_GT_box_face_quad(bpy.types.Gizmo): # noqa: N801 — Blender bl_idname delta /= 10.0 self.move_set_cb(self.init_value + delta) if context.area: - context.area.header_text_set( - f"Value: {self.move_get_cb():.3f} ({delta:.3f})" - ) + context.area.header_text_set(f"Value: {self.move_get_cb():.3f} ({delta:.3f})") return {"RUNNING_MODAL"} @@ -740,15 +715,11 @@ def apply_face_quad_layout( for route_axis, route_is_max in FACE_ROUTES: axis_local = Vector(face_outward_axis_local(route_axis, route_is_max)) n_world = (mw_rot @ cage_rotation_3x3 @ axis_local).normalized() - face_normals_world.append( - (float(n_world.x), float(n_world.y), float(n_world.z)) - ) + face_normals_world.append((float(n_world.x), float(n_world.y), float(n_world.z))) front = front_facing_face_mask(tuple(face_normals_world), view_dir_tuple) box_center_world = mw @ pivot_local - corners_world = _compute_box_corners_world( - bmin, bmax, pivot_local, cage_rotation_3x3, mw - ) + corners_world = _compute_box_corners_world(bmin, bmax, pivot_local, cage_rotation_3x3, mw) route_to_index = {route: i for i, route in enumerate(FACE_ROUTES)} for i, route in enumerate(FACE_ROUTES): @@ -787,9 +758,7 @@ def apply_face_quad_layout( orient, ) w, h = _compute_face_quad_scale(bmin, bmax, axis_b, is_max_b) - quad_gz.matrix_basis = _compose_face_matrix_basis( - face_world, mw_rot_scale, cage_rotation, orient, w, h - ) + quad_gz.matrix_basis = _compose_face_matrix_basis(face_world, mw_rot_scale, cage_rotation, orient, w, h) quad_gz.axis = world_axis if getattr(quad_gz, "_last_geometry_state", None) != "solid": quad_gz.custom_shape = quad_gz.new_custom_shape("TRIS", _QUAD_TRIS) @@ -800,16 +769,9 @@ def apply_face_quad_layout( # Back-facing: anchor at the back face centre; build halo strips # in the planes of the adjacent FRONT faces, extruded outside # the silhouette toward this face's outward normal. - face_world = mw @ ( - cage_rotation_3x3 @ (face_midpoints_local[route] - pivot_local) - + pivot_local - ) + face_world = mw @ (cage_rotation_3x3 @ (face_midpoints_local[route] - pivot_local) + pivot_local) quad_gz.matrix_basis = Matrix.Translation(face_world) - quad_gz.axis = ( - mw_rot - @ cage_rotation_3x3 - @ Vector(face_outward_axis_local(axis_b, is_max_b)) - ).normalized() + quad_gz.axis = (mw_rot @ cage_rotation_3x3 @ Vector(face_outward_axis_local(axis_b, is_max_b))).normalized() adjacent_front_routes = [ (axis_a, is_max_a) @@ -827,9 +789,7 @@ def apply_face_quad_layout( face_world_margin = 0.0 if region is not None: sample_end = box_center_world + quad_gz.axis * 1.0 - screen_step = _world_segment_to_screen_pixels( - region, rv3d, box_center_world, sample_end, min_pixels=0.0 - ) + screen_step = _world_segment_to_screen_pixels(region, rv3d, box_center_world, sample_end, min_pixels=0.0) if screen_step > 0.0: face_world_margin = _FACE_QUAD_HALO_TARGET_PIXELS / screen_step if face_world_margin <= 0.0 or not adjacent_front_routes: @@ -866,9 +826,7 @@ def apply_face_quad_layout( float(wp1.y - face_world.y), float(wp1.z - face_world.z), ) - all_tris.extend( - _build_strip_tris_relative(local_p0, local_p1, extrusion_local) - ) + all_tris.extend(_build_strip_tris_relative(local_p0, local_p1, extrusion_local)) if not locked: quad_gz.hide = False @@ -907,12 +865,12 @@ def apply_face_quad_layout( __all__ = [ "AXIS_COLOR", - "BIM_GT_box_face_outline", - "BIM_GT_box_face_quad", "FACE_QUAD_ALPHA", "FACE_QUAD_ALPHA_HIGHLIGHT", "FACE_QUAD_SELECT_BIAS", "FACE_ROUTES", + "BIM_GT_box_face_outline", + "BIM_GT_box_face_quad", "apply_face_quad_layout", "compute_face_resize", "face_outward_axis_local", diff --git a/src/bonsai/bonsai/bim/module/clip_box/gizmos.py b/src/bonsai/bonsai/bim/module/clip_box/gizmos.py index 752d151bb2..24d72f7b5c 100644 --- a/src/bonsai/bonsai/bim/module/clip_box/gizmos.py +++ b/src/bonsai/bonsai/bim/module/clip_box/gizmos.py @@ -40,7 +40,6 @@ import bonsai.tool as tool from . import face_quad - # Local-frame bounds of the empty's CUBE display. The display spans # ``[-empty_display_size, +empty_display_size]^3``; Bonsai always sets # ``empty_display_size = 1.0`` on clip-box hosts, so the local box is @@ -91,9 +90,7 @@ def _make_face_get_cb(gz: Any, group: Any, axis: int, is_max: bool): if empty is None: return 0.0 existing = getattr(gz, "_drag_snapshot", None) - if existing is not None and existing.get("empty_name") == getattr( - empty, "name", None - ): + if existing is not None and existing.get("empty_name") == getattr(empty, "name", None): return float(existing["world_half"]) world_half = _world_half_extent(empty, axis) @@ -119,9 +116,7 @@ def _make_ctrl_click_cb(axis: int, is_max: bool): """ def _callback(_context: Any, _event: Any) -> None: - bpy.ops.bim.align_view_to_clip_face( - "INVOKE_DEFAULT", axis=axis, is_max=is_max - ) + bpy.ops.bim.align_view_to_clip_face("INVOKE_DEFAULT", axis=axis, is_max=is_max) return _callback @@ -206,12 +201,8 @@ class OBJECT_GGT_bim_clip_box(bpy.types.GizmoGroup): # noqa: N801 — Blender b space_type=cls.bl_space_type, region_type=cls.bl_region_type, ) - km.keymap_items.new( - "gizmogroup.gizmo_tweak", type="LEFTMOUSE", value="CLICK_DRAG" - ) - km.keymap_items.new( - "gizmogroup.gizmo_tweak", type="LEFTMOUSE", value="PRESS", ctrl=True - ) + km.keymap_items.new("gizmogroup.gizmo_tweak", type="LEFTMOUSE", value="CLICK_DRAG") + km.keymap_items.new("gizmogroup.gizmo_tweak", type="LEFTMOUSE", value="PRESS", ctrl=True) return km def setup(self, context: Any) -> None: @@ -230,9 +221,7 @@ class OBJECT_GGT_bim_clip_box(bpy.types.GizmoGroup): # noqa: N801 — Blender b gz._last_geometry_state = "solid" gz._strips_cache_key = None gz.color = face_quad.AXIS_COLOR[axis] - gz.color_highlight = tuple( - min(1.0, c + 0.3) for c in face_quad.AXIS_COLOR[axis] - ) + gz.color_highlight = tuple(min(1.0, c + 0.3) for c in face_quad.AXIS_COLOR[axis]) gz.alpha = face_quad.FACE_QUAD_ALPHA gz.alpha_highlight = face_quad.FACE_QUAD_ALPHA_HIGHLIGHT gz.use_draw_modal = True diff --git a/src/bonsai/bonsai/bim/module/clip_box/operator.py b/src/bonsai/bonsai/bim/module/clip_box/operator.py index e81b1e3f09..bb2b9af46b 100644 --- a/src/bonsai/bonsai/bim/module/clip_box/operator.py +++ b/src/bonsai/bonsai/bim/module/clip_box/operator.py @@ -159,9 +159,7 @@ class BIM_OT_add_clip_box(bpy.types.Operator): # Default to a 20m cube (scale 10 around [-1, +1] local cube) so # the volume covers a typical building storey or two rather than # the meaningless 2m unit cube. The user resizes with S. - matrix = Matrix.Translation(context.scene.cursor.location.copy()) @ Matrix.Diagonal( - (10.0, 10.0, 10.0, 1.0) - ) + matrix = Matrix.Translation(context.scene.cursor.location.copy()) @ Matrix.Diagonal((10.0, 10.0, 10.0, 1.0)) tool.ClipBox.create_clip_box_empty(context, matrix, name=CLIP_BOX_NAME) return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/clip_box/ui.py b/src/bonsai/bonsai/bim/module/clip_box/ui.py index 39e786f113..897ec4a139 100644 --- a/src/bonsai/bonsai/bim/module/clip_box/ui.py +++ b/src/bonsai/bonsai/bim/module/clip_box/ui.py @@ -24,7 +24,6 @@ from bpy.types import Menu, Panel, UIList import bonsai.tool as tool - # Per-kind icon for the source-picker menu. Picked from Blender's built-in # icon set; semantically close to the kind so users can scan the menu visually. _SOURCE_MENU_ENTRIES: tuple[tuple[str, str, str], ...] = ( diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index 97656c3904..0594958613 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -2464,9 +2464,7 @@ class Blender(bonsai.core.tool.Blender): shader = gpu.shader.from_builtin("UNIFORM_COLOR") shader.bind() shader.uniform_float("color", fill_color) - batch = batch_for_shader( - shader, "TRIS", {"pos": verts}, indices=tri_indices - ) + batch = batch_for_shader(shader, "TRIS", {"pos": verts}, indices=tri_indices) batch.draw(shader) if outline_color is not None and line_indices: shader = gpu.shader.from_builtin("UNIFORM_COLOR") @@ -2477,9 +2475,7 @@ class Blender(bonsai.core.tool.Blender): prev_width = gpu.state.line_width_get() gpu.state.line_width_set(outline_width) try: - batch = batch_for_shader( - shader, "LINES", {"pos": verts}, indices=line_indices - ) + batch = batch_for_shader(shader, "LINES", {"pos": verts}, indices=line_indices) batch.draw(shader) finally: gpu.state.line_width_set(prev_width) diff --git a/src/bonsai/test/bim/module/clip_box/test_add_for_source.py b/src/bonsai/test/bim/module/clip_box/test_add_for_source.py index 947e2ffdfe..a7af701e2e 100644 --- a/src/bonsai/test/bim/module/clip_box/test_add_for_source.py +++ b/src/bonsai/test/bim/module/clip_box/test_add_for_source.py @@ -44,13 +44,9 @@ class TestAddClipBoxForSourceSpatial(NewFile): storey = ifc.create_entity("IfcBuildingStorey") wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0) wall_b, _ = _make_ifc_cube(ifc, "IfcWall", location=(4.0, 0.0, 0.0), size=2.0) - ifcopenshell.api.spatial.assign_container( - ifc, products=[wall_a, wall_b], relating_structure=storey - ) + ifcopenshell.api.spatial.assign_container(ifc, products=[wall_a, wall_b], relating_structure=storey) - result = bpy.ops.bim.add_clip_box_for_source( - source_kind="SPATIAL", source_id=str(storey.id()) - ) + result = bpy.ops.bim.add_clip_box_for_source(source_kind="SPATIAL", source_id=str(storey.id())) assert result == {"FINISHED"} scene_props = tool.ClipBox.get_scene_props() @@ -71,9 +67,7 @@ class TestAddClipBoxForSourceClass(NewFile): _make_ifc_cube(ifc, "IfcWall", location=(4.0, 0.0, 0.0), size=2.0) _make_ifc_cube(ifc, "IfcWindow", location=(20.0, 0.0, 0.0), size=2.0) - result = bpy.ops.bim.add_clip_box_for_source( - source_kind="CLASS", source_id="IfcWall" - ) + result = bpy.ops.bim.add_clip_box_for_source(source_kind="CLASS", source_id="IfcWall") assert result == {"FINISHED"} scene_props = tool.ClipBox.get_scene_props() @@ -93,9 +87,7 @@ class TestAddClipBoxForSourceEmpty(NewFile): walltype = ifc.create_entity("IfcWallType") # No occurrences linked — TYPE source resolves to 0 elements. with pytest.raises(RuntimeError, match="No elements found"): - bpy.ops.bim.add_clip_box_for_source( - source_kind="TYPE", source_id=str(walltype.id()) - ) + bpy.ops.bim.add_clip_box_for_source(source_kind="TYPE", source_id=str(walltype.id())) scene_props = tool.ClipBox.get_scene_props() assert len(scene_props.clip_boxes) == 0 @@ -105,9 +97,7 @@ class TestAddClipBoxForSourceEmpty(NewFile): from bonsai.bim.module.clip_box import data as clip_data with pytest.raises(RuntimeError, match="No source selected"): - bpy.ops.bim.add_clip_box_for_source( - source_kind="SPATIAL", source_id=clip_data.NO_OPTIONS_ID - ) + bpy.ops.bim.add_clip_box_for_source(source_kind="SPATIAL", source_id=clip_data.NO_OPTIONS_ID) scene_props = tool.ClipBox.get_scene_props() assert len(scene_props.clip_boxes) == 0 diff --git a/src/bonsai/test/bim/module/clip_box/test_clip_only_ifc_products.py b/src/bonsai/test/bim/module/clip_box/test_clip_only_ifc_products.py index 12883a23aa..f1ac52044d 100644 --- a/src/bonsai/test/bim/module/clip_box/test_clip_only_ifc_products.py +++ b/src/bonsai/test/bim/module/clip_box/test_clip_only_ifc_products.py @@ -153,9 +153,7 @@ def _exec_align_view(axis: int, is_max: bool): if region is None: continue with bpy.context.temp_override(area=area, region=region): - result = bpy.ops.bim.align_view_to_clip_face( - "EXEC_DEFAULT", axis=axis, is_max=is_max - ) + result = bpy.ops.bim.align_view_to_clip_face("EXEC_DEFAULT", axis=axis, is_max=is_max) assert result == {"FINISHED"} return bpy.context.space_data.region_3d pytest.skip("No VIEW_3D area available") @@ -190,7 +188,9 @@ class TestAlignViewToClipFace(NewFile): expected_up = (clip_box.matrix_world.to_quaternion() @ Vector((0.0, 0.0, 1.0))).normalized() up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0)) - assert (up_world - expected_up).length < 1e-3, ( + assert ( + up_world - expected_up + ).length < 1e-3, ( f"Side-face view must have box-local +Z as up; expected {tuple(expected_up)}, got {tuple(up_world)}" ) @@ -217,9 +217,9 @@ class TestAlignViewToClipFace(NewFile): expected_up = (clip_box.matrix_world.to_quaternion() @ Vector((0.0, 0.0, 1.0))).normalized() up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0)) - assert (up_world - expected_up).length < 1e-3, ( - f"X-rotated box must use box-local Z; expected {tuple(expected_up)}, got {tuple(up_world)}" - ) + assert ( + up_world - expected_up + ).length < 1e-3, f"X-rotated box must use box-local Z; expected {tuple(expected_up)}, got {tuple(up_world)}" def test_align_view_uses_box_local_y_up_for_top_face(self): # Top face (local +Z outward) follows Blender's numpad-7 @@ -257,9 +257,9 @@ class TestNotPersistedToProjectPset(NewFile): # Whatever the pset stores, it must not carry this scene-only toggle. for key in pset: - assert "clip_only_ifc" not in key.lower(), ( - f"Project pset unexpectedly carries the scene-only toggle (key {key!r})" - ) + assert ( + "clip_only_ifc" not in key.lower() + ), f"Project pset unexpectedly carries the scene-only toggle (key {key!r})" def test_load_from_pset_does_not_touch_clip_only_ifc_products(self): # Round-trip: set the toggle on the Scene, simulate a pset load, and diff --git a/src/bonsai/test/bim/module/clip_box/test_face_quad.py b/src/bonsai/test/bim/module/clip_box/test_face_quad.py index 2c838151fe..12c683d398 100644 --- a/src/bonsai/test/bim/module/clip_box/test_face_quad.py +++ b/src/bonsai/test/bim/module/clip_box/test_face_quad.py @@ -154,9 +154,7 @@ class TestFrontFacingFaceMask: def test_wrong_length_raises(self): with pytest.raises(ValueError, match="expected 6 face normals"): - face_quad.front_facing_face_mask( - [(1.0, 0.0, 0.0), (-1.0, 0.0, 0.0)], (0.0, 0.0, -1.0) - ) + face_quad.front_facing_face_mask([(1.0, 0.0, 0.0), (-1.0, 0.0, 0.0)], (0.0, 0.0, -1.0)) # ----------------------------------------------------- apply_face_quad_layout (front/back) --- @@ -259,9 +257,7 @@ class TestApplyFaceQuadLayout: quads = [_FakeQuad() for _ in range(6)] outlines = [_FakeOutline() for _ in range(6)] # View toward +X: the +X face is at world +X for a standard box. - view_rotation = Vector((0.0, 0.0, -1.0)).rotation_difference( - Vector((-1.0, 0.0, 0.0)) - ) + view_rotation = Vector((0.0, 0.0, -1.0)).rotation_difference(Vector((-1.0, 0.0, 0.0))) rv3d = _FakeRV3D(view_rotation, Matrix.Identity(4)) # Negative X scale (mirroring the cube along world X). mw = Matrix.Diagonal((-1.0, 1.0, 1.0, 1.0)) diff --git a/src/bonsai/test/bim/module/clip_box/test_source_kind_forward_compat.py b/src/bonsai/test/bim/module/clip_box/test_source_kind_forward_compat.py index 84514ebd4b..c1e4524a9f 100644 --- a/src/bonsai/test/bim/module/clip_box/test_source_kind_forward_compat.py +++ b/src/bonsai/test/bim/module/clip_box/test_source_kind_forward_compat.py @@ -48,16 +48,14 @@ def test_every_dispatch_target_lives_in_data_module(): # against typos in the dispatch table that would otherwise only surface # at the first dialog open. for kind, fn in operator._SOURCE_ID_DISPATCH.items(): - assert getattr(data, fn.__name__, None) is fn, ( - f"Dispatch target for {kind} ({fn.__name__}) is not exported from data.py" - ) + assert ( + getattr(data, fn.__name__, None) is fn + ), f"Dispatch target for {kind} ({fn.__name__}) is not exported from data.py" def test_every_menu_entry_is_a_known_kind(): for kind, label, icon in ui._SOURCE_MENU_ENTRIES: - assert kind in operator.SOURCE_KIND_LABELS, ( - f"Menu kind {kind!r} (label={label!r}) is not in SOURCE_KIND_LABELS" - ) + assert kind in operator.SOURCE_KIND_LABELS, f"Menu kind {kind!r} (label={label!r}) is not in SOURCE_KIND_LABELS" def test_every_label_has_a_menu_entry(): diff --git a/src/bonsai/test/tool/test_clip_box_for_source.py b/src/bonsai/test/tool/test_clip_box_for_source.py index 78643e3f4d..162cf286fb 100644 --- a/src/bonsai/test/tool/test_clip_box_for_source.py +++ b/src/bonsai/test/tool/test_clip_box_for_source.py @@ -201,9 +201,7 @@ class TestIterElementsForSource(NewFile): storey = ifc.create_entity("IfcBuildingStorey") wall_a = ifc.create_entity("IfcWall") wall_b = ifc.create_entity("IfcWall") - ifcopenshell.api.spatial.assign_container( - ifc, products=[wall_a, wall_b], relating_structure=storey - ) + ifcopenshell.api.spatial.assign_container(ifc, products=[wall_a, wall_b], relating_structure=storey) result = tool.ClipBox.iter_elements_for_source("SPATIAL", str(storey.id())) @@ -215,9 +213,7 @@ class TestIterElementsForSource(NewFile): wall_type = ifc.create_entity("IfcWallType") wall_a = ifc.create_entity("IfcWall") wall_b = ifc.create_entity("IfcWall") - ifcopenshell.api.type.assign_type( - ifc, related_objects=[wall_a, wall_b], relating_type=wall_type - ) + ifcopenshell.api.type.assign_type(ifc, related_objects=[wall_a, wall_b], relating_type=wall_type) result = tool.ClipBox.iter_elements_for_source("TYPE", str(wall_type.id())) @@ -263,9 +259,7 @@ class TestComputeMatrixForSource(NewFile): storey = ifc.create_entity("IfcBuildingStorey") wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0) wall_b, _ = _make_ifc_cube(ifc, "IfcWall", location=(4.0, 0.0, 0.0), size=2.0) - ifcopenshell.api.spatial.assign_container( - ifc, products=[wall_a, wall_b], relating_structure=storey - ) + ifcopenshell.api.spatial.assign_container(ifc, products=[wall_a, wall_b], relating_structure=storey) matrix = tool.ClipBox.compute_matrix_for_source("SPATIAL", str(storey.id())) From 3c9ee4a71f74b4d40096575c331a0965352d49d1 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Mon, 22 Jun 2026 13:49:38 +0200 Subject: [PATCH 04/10] Clip box: include linked IFC geometry Add include_linked_ifc toggle on BIMSceneClipBoxProperties so the cap pipeline can also bisect meshes inside Project > Links collection-instance empties. Off by default - linked IFCs may carry the entire site or structural backbone, and capping them adds per-mesh bisect cost on every clip-box edit. The new iterator composes instance.matrix_world @ inner.matrix_world as the effective world placement so caps land in the active scene rather than at the linked library's local origin. Linked-mesh cache entries are namespaced with a 'link:' prefix to avoid collisions with top-level scene objects. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/clip_box/prop.py | 20 ++ src/bonsai/bonsai/bim/module/clip_box/ui.py | 1 + src/bonsai/bonsai/tool/clip_box.py | 75 ++++++- .../clip_box/test_include_linked_ifc.py | 202 ++++++++++++++++++ 4 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 src/bonsai/test/bim/module/clip_box/test_include_linked_ifc.py diff --git a/src/bonsai/bonsai/bim/module/clip_box/prop.py b/src/bonsai/bonsai/bim/module/clip_box/prop.py index b65cf8a0ca..33f96bab16 100644 --- a/src/bonsai/bonsai/bim/module/clip_box/prop.py +++ b/src/bonsai/bonsai/bim/module/clip_box/prop.py @@ -76,6 +76,10 @@ def update_clip_only_ifc_products(self, context): tool.ClipBox.invalidate_cap_cache() +def update_include_linked_ifc(self, context): + tool.ClipBox.invalidate_cap_cache() + + class BIMSceneClipBoxProperties(PropertyGroup): """Scene-level registry of clip boxes in this file. @@ -126,6 +130,21 @@ class BIMSceneClipBoxProperties(PropertyGroup): "imported obj, primitive cubes, …)" ), ) + # Opt-in inclusion of geometry sitting inside loaded Project › Links + # collection-instance empties. Off by default — linked IFCs commonly + # carry the entire site / structural / MEP context, and bisecting + # them on every clip-box edit can be expensive. + include_linked_ifc: bpy.props.BoolProperty( + name="Include Linked IFC", + default=False, + update=update_include_linked_ifc, + description=( + "Also generate cross-section caps for geometry inside linked " + "IFC files (Project ▸ Links). Off by default — linked IFCs may " + "carry the entire site / structural backbone, and capping them " + "adds per-mesh bisect cost on every clip-box edit" + ), + ) # Also Scene-only — gizmo visibility is a per-user editing preference, # not a portable IFC property. enable_gizmos: bpy.props.BoolProperty( @@ -142,4 +161,5 @@ class BIMSceneClipBoxProperties(PropertyGroup): enabled: bool show_caps: bool clip_only_ifc_products: bool + include_linked_ifc: bool enable_gizmos: bool diff --git a/src/bonsai/bonsai/bim/module/clip_box/ui.py b/src/bonsai/bonsai/bim/module/clip_box/ui.py index 897ec4a139..f62a1ae08c 100644 --- a/src/bonsai/bonsai/bim/module/clip_box/ui.py +++ b/src/bonsai/bonsai/bim/module/clip_box/ui.py @@ -58,6 +58,7 @@ class BIM_MT_clip_box_settings(Menu): def draw(self, context): scene_props = tool.ClipBox.get_scene_props(context.scene) self.layout.prop(scene_props, "clip_only_ifc_products") + self.layout.prop(scene_props, "include_linked_ifc") self.layout.prop(scene_props, "enable_gizmos") diff --git a/src/bonsai/bonsai/tool/clip_box.py b/src/bonsai/bonsai/tool/clip_box.py index 9573a3f5e8..02642c042a 100644 --- a/src/bonsai/bonsai/tool/clip_box.py +++ b/src/bonsai/bonsai/tool/clip_box.py @@ -29,6 +29,8 @@ import bpy import bonsai.tool as tool if TYPE_CHECKING: + from mathutils import Matrix + from bonsai.bim.module.clip_box.prop import ( BIMClipBoxProperties, BIMSceneClipBoxProperties, @@ -922,6 +924,8 @@ class ClipBox: obj: bpy.types.Object, world_planes: PlaneSet, depsgraph: Optional[Any] = None, + *, + world_matrix: Optional[Matrix] = None, ) -> list[tuple[float, float, float]]: """Return triangle vertices for ``obj``'s cap polygons. @@ -934,14 +938,25 @@ class ClipBox: objects with subsurf / boolean / mirror modifiers. Falls back to ``obj.data`` only for callers without a depsgraph (e.g. unit tests that fabricate a mesh outside any eval context). + + ``world_matrix`` overrides ``obj.matrix_world`` for the local↔world + transform. Used by the linked-IFC path where the effective world + placement of a library-linked mesh is the instance empty's + ``matrix_world`` composed with the inner mesh's own matrix, not + the linked object's own ``matrix_world`` (which is library-local). + When supplied, the depsgraph path is skipped — library-linked + objects aren't part of the active scene's depsgraph and their + Bonsai-baked meshes don't carry modifier stacks anyway. """ import bmesh from mathutils import Vector + mw = world_matrix if world_matrix is not None else obj.matrix_world + bm = bmesh.new() eval_obj = None try: - if depsgraph is not None: + if depsgraph is not None and world_matrix is None: try: eval_obj = obj.evaluated_get(depsgraph) mesh = eval_obj.to_mesh() @@ -954,7 +969,7 @@ class ClipBox: except (RuntimeError, ReferenceError): return [] - ws_to_ls = obj.matrix_world.inverted_safe() + ws_to_ls = mw.inverted_safe() rot = ws_to_ls.to_quaternion() planes_local = [] for plane in world_planes: @@ -976,7 +991,6 @@ class ClipBox: if not cap_faces: return [] - mw = obj.matrix_world return cls._triangulate_cap_faces(cap_faces, mw) finally: bm.free() @@ -1014,6 +1028,38 @@ class ClipBox: continue yield obj + @classmethod + def _iter_linked_ifc_capable_meshes( + cls, scene: bpy.types.Scene + ) -> Iterator[tuple[bpy.types.Object, bpy.types.Object, Matrix]]: + """Yield ``(instance_empty, inner_mesh, effective_world_matrix)`` + for meshes inside loaded Project ▸ Links collection-instance empties. + + Gated by ``BIMSceneClipBoxProperties.include_linked_ifc``: returns + nothing when the toggle is off so the main cap path stays untouched. + + The effective world matrix is ``instance.matrix_world @ + inner.matrix_world`` — the inner object's own ``matrix_world`` is + library-local (positioned relative to the linked collection's + origin), so the instance empty's placement has to be prepended to + land the cap at the right place in the active scene. + """ + scene_props = cls.get_scene_props(scene) + if not scene_props.include_linked_ifc: + return + project_props = tool.Project.get_project_props() + for link in project_props.get_loaded_links(): + instance = tool.Project.get_link_empty_handle(link) + if instance is None or instance.instance_collection is None: + continue + if not instance.visible_get(): + continue + instance_mw = instance.matrix_world + for inner in instance.instance_collection.all_objects: + if inner.type != "MESH" or inner.data is None: + continue + yield instance, inner, instance_mw @ inner.matrix_world + @classmethod def invalidate_cap_cache(cls, *, immediate: bool = False) -> None: """Drop the cap cache and schedule a fresh rebuild. @@ -1115,6 +1161,29 @@ class ClipBox: batch = cls._build_cap_batch(verts) if verts else None cls._cap_cache[obj.name] = (cache_key, batch) + # Linked-IFC inner meshes (gated by include_linked_ifc). The + # ``link:`` prefix on the cache name namespaces them so they + # cannot collide with a scene-object named identically. + for instance, inner, world_matrix in cls._iter_linked_ifc_capable_meshes(scene): + cache_name = f"link:{instance.name}:{inner.name}" + live_names.add(cache_name) + mesh = inner.data + cache_key = ( + getattr(mesh, "session_uid", id(mesh)), + tool.Blender.hash_matrix(world_matrix), + clip_box_hash, + ) + cached = cls._cap_cache.get(cache_name) + if cached is not None and cached[0] == cache_key: + continue + world_corners = [world_matrix @ Vector(c) for c in inner.bound_box] + if not tool.Cad.corners_might_cross_clip_planes(world_planes, world_corners): + cls._cap_cache[cache_name] = (cache_key, None) + continue + verts = cls._compute_caps_for_object(inner, world_planes, depsgraph=depsgraph, world_matrix=world_matrix) + batch = cls._build_cap_batch(verts) if verts else None + cls._cap_cache[cache_name] = (cache_key, batch) + for name in list(cls._cap_cache): if name not in live_names: cls._cap_cache.pop(name) diff --git a/src/bonsai/test/bim/module/clip_box/test_include_linked_ifc.py b/src/bonsai/test/bim/module/clip_box/test_include_linked_ifc.py new file mode 100644 index 0000000000..9dcc15da59 --- /dev/null +++ b/src/bonsai/test/bim/module/clip_box/test_include_linked_ifc.py @@ -0,0 +1,202 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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. + +"""Pins the ``include_linked_ifc`` toggle contract. + +The toggle extends the cap pipeline to also bisect meshes living inside +Project ▸ Links collection-instance empties — without it those meshes +are clipped by Blender's native viewport clip but never get +cross-section caps drawn at the cut. +""" + +import bpy +import pytest +from mathutils import Matrix + +import bonsai.tool as tool +from test.bim.bootstrap import NewFile + +pytestmark = pytest.mark.clip_box + + +def _make_synthetic_linked_collection( + inner_location: tuple[float, float, float] = (0.0, 0.0, 0.0), + instance_location: tuple[float, float, float] = (0.0, 0.0, 0.0), +) -> tuple[bpy.types.Object, bpy.types.Object, bpy.types.Collection]: + """Build a synthetic link: a collection with one mesh + an instance empty. + + Mirrors the structural shape of a real loaded link without driving + the multi-process .ifc.cache.blend pipeline. Returns + ``(instance_empty, inner_mesh, collection)`` so tests can assert + against the exact objects they created. + """ + collection = bpy.data.collections.new("LinkedIFC") + bpy.ops.mesh.primitive_cube_add(size=2.0, location=inner_location) + inner = bpy.context.active_object + for c in list(inner.users_collection): + c.objects.unlink(inner) + collection.objects.link(inner) + + empty = bpy.data.objects.new("LinkedIFC.001", None) + empty.instance_type = "COLLECTION" + empty.instance_collection = collection + bpy.context.scene.collection.objects.link(empty) + # matrix_world (not .location) so the test reads a fresh value without + # needing a depsgraph tick to propagate matrix_local → matrix_world. + empty.matrix_world = Matrix.Translation(instance_location) + + return empty, inner, collection + + +def _register_synthetic_link(empty: bpy.types.Object) -> None: + """Add a Project ▸ Links entry pointing at ``empty``. + + No IFC is set in the bootstrap fixture, so + ``tool.Project.get_link_empty_handle`` resolves via the link's + ``empty_handle`` PointerProperty rather than the IfcStore. + """ + project_props = tool.Project.get_project_props() + link = project_props.links.add() + link.name = "synthetic" + link.is_loaded = True + link.empty_handle = empty + + +class TestDefaultIsOff(NewFile): + def test_include_linked_ifc_defaults_to_false(self): + scene_props = tool.ClipBox.get_scene_props() + assert scene_props.include_linked_ifc is False + + +class TestIteratorGating(NewFile): + def test_iterator_returns_nothing_when_toggle_off(self): + empty, _inner, _col = _make_synthetic_linked_collection() + _register_synthetic_link(empty) + scene_props = tool.ClipBox.get_scene_props() + scene_props.include_linked_ifc = False + + yielded = list(tool.ClipBox._iter_linked_ifc_capable_meshes(bpy.context.scene)) + + assert yielded == [] + + def test_iterator_yields_inner_mesh_when_toggle_on(self): + empty, inner, _col = _make_synthetic_linked_collection() + _register_synthetic_link(empty) + scene_props = tool.ClipBox.get_scene_props() + scene_props.include_linked_ifc = True + + yielded = list(tool.ClipBox._iter_linked_ifc_capable_meshes(bpy.context.scene)) + + assert len(yielded) == 1 + instance, mesh_obj, _world_matrix = yielded[0] + assert instance is empty + assert mesh_obj is inner + + def test_iterator_composes_instance_and_inner_matrix(self): + # The inner mesh's matrix_world is library-local (cube at origin + # inside the collection). The instance empty is offset by 5m on X. + # The effective world matrix must combine the two so the cap lands + # in the active scene, not at the inner mesh's library origin. + empty, inner, _col = _make_synthetic_linked_collection( + inner_location=(0.0, 0.0, 0.0), + instance_location=(5.0, 0.0, 0.0), + ) + _register_synthetic_link(empty) + scene_props = tool.ClipBox.get_scene_props() + scene_props.include_linked_ifc = True + + _instance, _mesh_obj, world_matrix = next(iter(tool.ClipBox._iter_linked_ifc_capable_meshes(bpy.context.scene))) + + expected = empty.matrix_world @ inner.matrix_world + assert (world_matrix.translation - expected.translation).length < 1e-6 + # And the composition picks up the empty's offset. + assert world_matrix.translation.x == pytest.approx(5.0) + + def test_iterator_skips_links_with_no_instance_collection(self): + # A link whose empty_handle was created but never linked to a + # collection (e.g. half-initialised link) must not yield anything. + empty = bpy.data.objects.new("LinkedIFC.broken", None) + empty.instance_type = "COLLECTION" + bpy.context.scene.collection.objects.link(empty) + _register_synthetic_link(empty) + scene_props = tool.ClipBox.get_scene_props() + scene_props.include_linked_ifc = True + + yielded = list(tool.ClipBox._iter_linked_ifc_capable_meshes(bpy.context.scene)) + + assert yielded == [] + + def test_iterator_skips_unloaded_links(self): + empty, _inner, _col = _make_synthetic_linked_collection() + project_props = tool.Project.get_project_props() + link = project_props.links.add() + link.name = "unloaded" + link.is_loaded = False + link.empty_handle = empty + scene_props = tool.ClipBox.get_scene_props() + scene_props.include_linked_ifc = True + + yielded = list(tool.ClipBox._iter_linked_ifc_capable_meshes(bpy.context.scene)) + + assert yielded == [] + + +class TestUpdateCallbackInvalidatesCache(NewFile): + def test_toggling_include_linked_ifc_clears_cap_cache(self): + # Seed the cache with a sentinel so we can detect invalidation. + tool.ClipBox._cap_cache["sentinel"] = (object(), None) + scene_props = tool.ClipBox.get_scene_props() + + scene_props.include_linked_ifc = True + + assert "sentinel" not in tool.ClipBox._cap_cache + tool.ClipBox._cancel_pending_cap_rebuild() + + +class TestRebuildCachesLinkedMesh(NewFile): + def test_rebuild_adds_link_prefixed_entry_when_toggle_on(self): + # The default clip box spawns a 20m cube around the cursor, so a + # 2m cube at the origin sits fully inside both the box and the + # instance's translation — guaranteeing the AABB-vs-planes check + # passes and a (cache_key, batch) entry lands in _cap_cache. + empty, _inner, _col = _make_synthetic_linked_collection() + _register_synthetic_link(empty) + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + scene_props.include_linked_ifc = True + + tool.ClipBox.rebuild_caps_now() + + link_keys = [name for name in tool.ClipBox._cap_cache if name.startswith("link:")] + assert link_keys, f"expected a link: cache entry, got {list(tool.ClipBox._cap_cache)}" + + def test_rebuild_drops_link_entry_when_toggle_off(self): + empty, _inner, _col = _make_synthetic_linked_collection() + _register_synthetic_link(empty) + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + scene_props.include_linked_ifc = True + tool.ClipBox.rebuild_caps_now() + assert any(name.startswith("link:") for name in tool.ClipBox._cap_cache) + + scene_props.include_linked_ifc = False + tool.ClipBox.rebuild_caps_now() + + assert not any(name.startswith("link:") for name in tool.ClipBox._cap_cache) From a2dafc9cebce230eb53738c00e36f68a781f95a9 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 23 Jun 2026 09:23:25 +0200 Subject: [PATCH 05/10] ifcopenshell.util: schema-aware downgrade helpers Adds the IFC-library primitives the ifcpatch Migrate recipe needs for a defensive IFC4 / IFC4X3 -> IFC2X3 downgrade without each caller reinventing the wheel. In ifcopenshell.util.schema: - Migrator(fallback_element_to_proxy=False) opt-in: when True, IFC4-only IfcElement subclasses (IfcLamp, IfcPipeSegment, IfcGeographicElement, ...) migrate to IfcBuildingElementProxy instead of raising. Default preserves the strict failure-on-unmappable contract for existing callers (classification API, etc.). - geometry_classes_introduced_after(target, source) derives the IfcRepresentationItem subclasses present in `source` but absent in `target` directly from the loaded schemas. Cached per pair. Replaces hand-curated class lists that drift with each IFC update. ifc4_only_geometry_classes() retained as an alias. - generate_default_value synthesises a unit IfcAxis2Placement2D / IfcAxis2Placement3D when downgrading entities whose Position became required in the target schema (IfcIShapeProfileDef and friends in IFC2X3). - Enum-mismatch detection upgraded from string-matched RuntimeError to a structural check via ifcopenshell.util.attribute.get_enum_items so upgrade paths still surface real bugs loudly. In ifcopenshell.util.shape_builder: - polygonal_face_set_to_faceted_brep converts IfcPolygonalFaceSet / IfcTriangulatedFaceSet (IFC4-only) directly to IfcFacetedBrep, preserving topology including IfcIndexedPolygonalFaceWithVoids inner bounds. Validates inputs at the boundary. - arc_to_polyline_points approximates a circular arc through three points with a chord polyline of configurable subdivisions. Tolerates floating-point noise on planar Z. Raises on non-planar or invalid inputs. Test coverage: 47 unit tests across schema + shape_builder lanes covering each helper directly (no transitive-only coverage), including regression pins for the IFC4X3-prefix ordering invariant in get_fallback_schema and the strict-default Migrator contract. Generated with the assistance of an AI coding tool. --- .../ifcopenshell/util/schema.py | 152 ++++++++++++++++- .../ifcopenshell/util/shape_builder.py | 126 +++++++++++++- .../test/util/test_schema.py | 154 ++++++++++++++++++ .../test/util/test_shape_builder.py | 110 ++++++++++++- 4 files changed, 531 insertions(+), 11 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/schema.py b/src/ifcopenshell-python/ifcopenshell/util/schema.py index bdd6d489d0..e755095bdf 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/schema.py +++ b/src/ifcopenshell-python/ifcopenshell/util/schema.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . +import functools import json import os import time @@ -148,6 +149,57 @@ def get_subtypes( return get_classes(declaration) +def _enum_value_outside_target(attribute: ifcopenshell_wrapper.attribute, value: Any) -> bool: + """``True`` when ``attribute`` is an enumeration and the string ``value`` + is not in its declared items. Used by the Migrator to silently skip enum + values that exist in the source schema but not the target — without + parsing C++ wrapper error strings.""" + if not isinstance(value, str): + return False + try: + enum_items = ifcopenshell.util.attribute.get_enum_items(attribute) + except (AssertionError, AttributeError): + return False + return value not in enum_items + + +@functools.cache +def geometry_classes_introduced_after(target_schema: IFC_SCHEMA, source_schema: IFC_SCHEMA = "IFC4") -> frozenset[str]: + """``IfcRepresentationItem`` subclasses present in ``source_schema`` but + missing in ``target_schema``. + + Derived from the loaded schema declarations once per (source, target) pair + and cached. The result is the canonical set of geometry classes a + downgrade from ``source_schema`` to ``target_schema`` must convert + (``IfcPolygonalFaceSet``, ``IfcTriangulatedFaceSet``, ``IfcAdvancedBrep``, + B-splines, advanced surfaces, alignment curves on IFC4X3 → 2X3, …) or + purge. Defaults match the IFC4 → IFC2X3 case for backwards compatibility + with the original caller.""" + source = ifcopenshell_wrapper.schema_by_name(source_schema) + target = ifcopenshell_wrapper.schema_by_name(target_schema) + target_names = {decl.name() for decl in target.entities()} + result: set[str] = set() + for decl in source.entities(): + if decl.name() in target_names: + continue + cursor: Any = decl + while cursor is not None: + if cursor.name() == "IfcRepresentationItem": + result.add(decl.name()) + break + cursor = cursor.supertype() + return frozenset(result) + + +def ifc4_only_geometry_classes() -> frozenset[str]: + """Backwards-compatible alias for the IFC4 → IFC2X3 geometry-gap set. + + New code should call :func:`geometry_classes_introduced_after` with the + explicit (target, source) pair so IFC4X3 → IFC2X3 downgrades pick up the + additional IFC4X3-only geometry classes.""" + return geometry_classes_introduced_after("IFC2X3", "IFC4") + + def reassign_class( ifc_file: Union[ifcopenshell.file, None], element: ifcopenshell.entity_instance, new_class: str ) -> ifcopenshell.entity_instance: @@ -263,7 +315,20 @@ class Migrator: migrated_ids: dict[int, int] attribute_overrides: dict[int, dict[int, str]] - def __init__(self): + def __init__(self, *, fallback_element_to_proxy: bool = False) -> None: + """Construct a schema migrator. + + :param fallback_element_to_proxy: When ``True`` and the target schema is + IFC2X3, IFC4 entity classes that have no direct IFC2X3 equivalent + but inherit from ``IfcElement`` / ``IfcElementType`` are migrated as + ``IfcBuildingElementProxy`` / ``IfcBuildingElementProxyType`` + respectively, instead of raising. Caller code is then responsible + for preserving the lost original class information out-of-band (the + ``Migrate`` ifcpatch recipe encodes it into ``ObjectType``). + Defaults to ``False`` so non-recipe callers keep the strict + failure-on-unmappable contract. + """ + self.fallback_element_to_proxy = fallback_element_to_proxy self.migrated_ids = {} self.attribute_overrides = {} self.class_4_to_2x3 = json.load(open(os.path.join(cwd, "class_4_to_2x3.json"), "r")) @@ -379,6 +444,17 @@ class Migrator: self.migrated_ids[element.id()] = new_element.id() return new_element + @staticmethod + def _is_subclass_of(ifc_class: str, ancestor: str, source_file: ifcopenshell.file) -> bool: + schema = ifcopenshell_wrapper.schema_by_name(source_file.schema_identifier) + try: + return is_a(schema.declaration_by_name(ifc_class), ancestor) + except RuntimeError: + # Class doesn't exist in the source schema — happens for cross-schema + # introspection of an entity created with a name the wrapper doesn't + # recognise. Treat as "not a subclass". + return False + def migrate_class( self, element: ifcopenshell.entity_instance, new_file: ifcopenshell.file ) -> ifcopenshell.entity_instance: @@ -389,15 +465,44 @@ class Migrator: if isinstance(value, float): ifc_class = "IfcQuantityNumber" try: - new_element = new_file.create_entity(ifc_class) + return new_file.create_entity(ifc_class) except: - # The element does not exist in this schema - # Complex migration is not yet supported (e.g. polygonal face set to faceted brep) - if new_file.schema == "IFC2X3": - new_element = new_file.create_entity(self.class_4_to_2x3[ifc_class]) - elif new_file.schema == "IFC4": - new_element = new_file.create_entity(self.class_2x3_to_4[ifc_class]) - return new_element + pass + + # The class does not exist in the target schema — look up an equivalent. + # The lookup tables use empty-string as a sentinel meaning "no direct + # equivalent, needs geometric translation" (e.g. polygonal face set → + # faceted brep). Callers that want a clean downgrade are expected to + # preprocess such carriers before calling the Migrator; see the + # `Migrate` ifcpatch recipe. + if new_file.schema == "IFC2X3": + equivalent = self.class_4_to_2x3.get(ifc_class, None) + elif new_file.schema == "IFC4": + equivalent = self.class_2x3_to_4.get(ifc_class, None) + else: + equivalent = None + + # IfcBuildingElementProxy fallback is opt-in (see constructor) — only + # the IfcElement / IfcElementType subtrees have a meaningful generic + # IFC2X3 stand-in; non-element IFC4-only classes (rels, geometry items, + # materials, times) still raise below. + if not equivalent and new_file.schema == "IFC2X3" and self.fallback_element_to_proxy: + if self._is_subclass_of(ifc_class, "IfcElement", element.wrapped_data.file): + equivalent = "IfcBuildingElementProxy" + elif self._is_subclass_of(ifc_class, "IfcElementType", element.wrapped_data.file): + equivalent = "IfcBuildingElementProxyType" + + if not equivalent: + inverses = element.wrapped_data.file.get_inverse(element) + inverse_hint = ", ".join(f"#{i.id()}={i.is_a()}" for i in list(inverses)[:3]) + if len(inverses) > 3: + inverse_hint += f", … (+{len(inverses) - 3} more)" + raise NotImplementedError( + f"Cannot migrate #{element.id()}={ifc_class} to schema " + f"{new_file.schema}: no direct equivalent exists. " + f"Referenced by: {inverse_hint or '(no inverses)'}." + ) + return new_file.create_entity(equivalent) def migrate_attributes( self, @@ -526,11 +631,40 @@ class Migrator: new_value.append(self.migrate(item, new_file)) value = new_value if value is not None: + if _enum_value_outside_target(attribute, value): + # Enum value present in source schema but missing in target + # (typically a downgrade after a cross-class fallback, e.g. + # IfcLamp.PredefinedType=COMPACTFLUORESCENT copied onto + # IfcBuildingElementProxy.CompositionType whose enum is + # IfcElementCompositionEnum). Leave the attribute unset rather + # than abort the whole entity's migration. Detected + # structurally so other RuntimeError causes (type mismatches, + # invalid values) still propagate. + return setattr(new_element, attribute.name(), value) def generate_default_value(self, attribute: ifcopenshell_wrapper.attribute, new_file: ifcopenshell.file) -> Any: if attribute.name() in self.default_values: return self.default_values[attribute.name()] + elif attribute.name() == "Position": + # IFC4 relaxed Position to OPTIONAL for many profile defs; IFC2X3 + # still requires it. Synthesize a unit placement at origin so + # IfcIShapeProfileDef and friends downgrade without crashing + # downstream validators. + try: + type_name = attribute.type_of_attribute().as_named_type().declared_type().name() + except Exception: + type_name = None + if type_name == "IfcAxis2Placement2D": + return new_file.create_entity( + "IfcAxis2Placement2D", + Location=new_file.create_entity("IfcCartesianPoint", (0.0, 0.0)), + ) + if type_name == "IfcAxis2Placement3D": + return new_file.create_entity( + "IfcAxis2Placement3D", + Location=new_file.create_entity("IfcCartesianPoint", (0.0, 0.0, 0.0)), + ) elif attribute.name() == "OwnerHistory": self.default_entities[attribute.name()] = new_file.create_entity( "IfcOwnerHistory", diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index d9d18f0b5f..eb5bbbdcdb 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -21,7 +21,7 @@ from __future__ import annotations import collections.abc from collections.abc import Sequence from itertools import chain -from math import atan, cos, degrees, pi, radians, sin, sqrt, tan +from math import atan, atan2, cos, degrees, hypot, isclose, pi, radians, sin, sqrt, tan from typing import TYPE_CHECKING, Any, Literal, Optional, Union import numpy as np @@ -301,6 +301,130 @@ def intersect_x_axis_2d(p1: VectorType, p2: VectorType, y=0) -> Optional[float]: return x1 + t * (x2 - x1) +def arc_to_polyline_points( + start: VectorType, mid: VectorType, end: VectorType, subdivisions: int = 16 +) -> list[tuple[float, ...]]: + """Approximate a circular arc through (start, mid, end) with chord points. + + The arc is determined uniquely by three points — a circle is fit in the + XY plane and the angle is walked from start through mid to end, sampling + ``subdivisions + 1`` points inclusive of the endpoints. Falls back to a + straight chord ``[start, end]`` for collinear / degenerate inputs. + + Only planar arcs in the XY plane are supported. For 3D inputs (length 3 + tuples), the Z coordinate of each output point is held constant at + ``start[2]``. Inputs where start/mid/end have differing Z values raise + ``ValueError`` rather than silently project — caller should rotate the + arc into the XY plane first if it lives in a non-axis-aligned plane. + + :raises ValueError: if subdivisions < 1, or if 3D inputs have mismatched + Z coordinates (non-planar arc). + """ + if subdivisions < 1: + raise ValueError(f"subdivisions must be >= 1, got {subdivisions}") + if len(start) >= 3: + # Tolerance accommodates floating-point noise from kernel transforms + # — IFC point coordinates that the author wrote as the same Z value + # may diverge by ~1e-15 after placement-matrix round-trips. + z_tol = 1e-9 + if not (isclose(start[2], mid[2], abs_tol=z_tol) and isclose(start[2], end[2], abs_tol=z_tol)): + raise ValueError( + f"arc_to_polyline_points only handles arcs in the XY plane; " + f"got mismatched Z coordinates ({start[2]}, {mid[2]}, {end[2]})." + ) + sx, sy = start[0], start[1] + mx, my = mid[0], mid[1] + ex, ey = end[0], end[1] + d = 2 * (sx * (my - ey) + mx * (ey - sy) + ex * (sy - my)) + if abs(d) < 1e-12: + return [tuple(start), tuple(end)] + cx = ((sx**2 + sy**2) * (my - ey) + (mx**2 + my**2) * (ey - sy) + (ex**2 + ey**2) * (sy - my)) / d + cy = ((sx**2 + sy**2) * (ex - mx) + (mx**2 + my**2) * (sx - ex) + (ex**2 + ey**2) * (mx - sx)) / d + a_start = atan2(sy - cy, sx - cx) + a_mid = atan2(my - cy, mx - cx) + a_end = atan2(ey - cy, ex - cx) + sweep = _signed_sweep_through_mid(a_start, a_mid, a_end) + radius = hypot(sx - cx, sy - cy) + pts: list[tuple[float, ...]] = [] + for i in range(subdivisions + 1): + t = i / subdivisions + angle = a_start + sweep * t + x = cx + radius * cos(angle) + y = cy + radius * sin(angle) + if len(start) == 2: + pts.append((x, y)) + else: + pts.append((x, y, start[2])) + return pts + + +def _signed_sweep_through_mid(a_start: float, a_mid: float, a_end: float) -> float: + """Total angle (radians) from a_start to a_end going through a_mid.""" + two_pi = 2 * pi + ccw_total = (a_end - a_start) % two_pi + ccw_to_mid = (a_mid - a_start) % two_pi + if ccw_to_mid <= ccw_total: + return ccw_total + return -((a_start - a_end) % two_pi) + + +def polygonal_face_set_to_faceted_brep(face_set: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: + """Convert an ``IfcPolygonalFaceSet`` or ``IfcTriangulatedFaceSet`` into an + ``IfcFacetedBrep`` in the same file, preserving vertex coordinates and face + topology (including inner voids on ``IfcIndexedPolygonalFaceWithVoids``). + + The returned brep is the canonical IFC2X3-compatible form of these IFC4 + tessellated representations. The caller is responsible for rewiring inverse + references and removing the source face set when downgrading. + + :raises TypeError: if ``face_set`` is not an ``IfcPolygonalFaceSet`` or + ``IfcTriangulatedFaceSet``. + :raises ValueError: if ``face_set.Coordinates`` is missing or any face's + coordinate index references a vertex outside the coordinate list. + """ + if not (face_set.is_a("IfcPolygonalFaceSet") or face_set.is_a("IfcTriangulatedFaceSet")): + raise TypeError( + f"polygonal_face_set_to_faceted_brep expected IfcPolygonalFaceSet or " + f"IfcTriangulatedFaceSet, got {face_set.is_a()}." + ) + if face_set.Coordinates is None: + raise ValueError(f"{face_set.is_a()} #{face_set.id()} has no Coordinates point list.") + ifc_file = face_set.file + coords = face_set.Coordinates.CoordList + vertex_count = len(coords) + ifc_points = [ifc_file.createIfcCartesianPoint(tuple(c)) for c in coords] + + def _resolve(indices: Sequence[int]) -> list[ifcopenshell.entity_instance]: + # IfcIndexedPolygonalFace.CoordIndex / IfcTriangulatedFaceSet.CoordIndex + # are 1-based. Out-of-range hits early with a clear message rather + # than the cryptic IndexError from list[i-1]. + out = [] + for index in indices: + if not 1 <= index <= vertex_count: + raise ValueError( + f"{face_set.is_a()} #{face_set.id()} face references vertex {index}, " + f"outside CoordList range 1..{vertex_count}." + ) + out.append(ifc_points[index - 1]) + return out + + ifc_faces: list[ifcopenshell.entity_instance] = [] + if face_set.is_a("IfcTriangulatedFaceSet"): + for triangle in face_set.CoordIndex: + loop = ifc_file.createIfcPolyLoop(_resolve(triangle)) + ifc_faces.append(ifc_file.createIfcFace([ifc_file.createIfcFaceOuterBound(loop, True)])) + else: # IfcPolygonalFaceSet + for indexed_face in face_set.Faces: + outer_loop = ifc_file.createIfcPolyLoop(_resolve(indexed_face.CoordIndex)) + bounds = [ifc_file.createIfcFaceOuterBound(outer_loop, True)] + if indexed_face.is_a("IfcIndexedPolygonalFaceWithVoids"): + for inner in indexed_face.InnerCoordIndices or (): + bounds.append(ifc_file.createIfcFaceBound(ifc_file.createIfcPolyLoop(_resolve(inner)), True)) + ifc_faces.append(ifc_file.createIfcFace(bounds)) + + return ifc_file.createIfcFacetedBrep(ifc_file.createIfcClosedShell(ifc_faces)) + + # Note: using ShapeBuilder try not to reuse IFC elements in the process # otherwise you might run into situation where builder.mirror or other operation # is applied twice during one run to the same element diff --git a/src/ifcopenshell-python/test/util/test_schema.py b/src/ifcopenshell-python/test/util/test_schema.py index cb45b6e8db..802e426936 100644 --- a/src/ifcopenshell-python/test/util/test_schema.py +++ b/src/ifcopenshell-python/test/util/test_schema.py @@ -16,6 +16,9 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . +import pytest + +import ifcopenshell import ifcopenshell.api.project import ifcopenshell.util.schema as subject import test.bootstrap @@ -119,6 +122,157 @@ END-ISO-10303-21; assert isinstance(qt_float_count_measure_ifc4x3[3], float) assert qt_float_count_measure_ifc4x3[3] == 723.0 + def test_migrate_class_raises_clear_error_for_ifc4_only_non_element_class_to_ifc2x3(self): + """IFC4-only non-element classes (geometry items, etc.) have no + IfcBuildingElementProxy fallback and must surface a clear error naming + the failing class — not the cryptic 'Entity name not found in schema'.""" + ifc4_file = ifcopenshell.api.project.create_file() + point_list = ifc4_file.create_entity("IfcCartesianPointList2D", CoordList=((0.0, 0.0), (1.0, 0.0))) + ifc2x3_file = ifcopenshell.api.project.create_file(version="IFC2X3") + + migrator = subject.Migrator() + with pytest.raises(NotImplementedError) as exc_info: + migrator.migrate(point_list, ifc2x3_file) + + message = str(exc_info.value) + assert "IfcCartesianPointList2D" in message + assert "IFC2X3" in message + + def test_migrate_class_falls_back_to_ifcbuildingelementproxy_when_opt_in(self): + """With ``fallback_element_to_proxy=True``, IFC4-only IfcElement + subclasses (IfcLamp, IfcPipeSegment, IfcGeographicElement, …) migrate + as IfcBuildingElementProxy instead of raising. Default behavior + (no opt-in) raises so non-recipe callers keep the strict contract.""" + ifc4_file = ifcopenshell.api.project.create_file() + lamp = ifc4_file.create_entity("IfcLamp", GlobalId="2K6Z3DR8X37AS9XFvX8GcW") + ifc2x3_file = ifcopenshell.api.project.create_file(version="IFC2X3") + + # Default migrator raises (strict contract preserved). + with pytest.raises(NotImplementedError, match="IfcLamp"): + subject.Migrator().migrate(lamp, ifc2x3_file) + + # Opt-in migrator substitutes IfcBuildingElementProxy. + ifc2x3_file = ifcopenshell.api.project.create_file(version="IFC2X3") + new_lamp = subject.Migrator(fallback_element_to_proxy=True).migrate(lamp, ifc2x3_file) + assert new_lamp.is_a("IfcBuildingElementProxy") + + +class TestGetFallbackSchema: + """Pins the schema-identifier normalisation contract relied on by callers + that need to map upstream variants (IFC4X3_ADD2, IFC2X3_TC1, IFC4_ADD2, …) + to a base schema name for compatibility tables / downgrade detection.""" + + def test_ifc4x3_variants_collapse_to_ifc4x3(self): + # Longest-prefix-first: IFC4X3_ADD2 must NOT be misclassified as IFC4 + # — the function checks IFC4X3 before IFC4. + assert subject.get_fallback_schema("IFC4X3") == "IFC4X3" + assert subject.get_fallback_schema("IFC4X3_ADD1") == "IFC4X3" + assert subject.get_fallback_schema("IFC4X3_ADD2") == "IFC4X3" + assert subject.get_fallback_schema("IFC4X3_RC1") == "IFC4X3" + + def test_ifc4_variants_collapse_to_ifc4(self): + assert subject.get_fallback_schema("IFC4") == "IFC4" + assert subject.get_fallback_schema("IFC4_ADD1") == "IFC4" + assert subject.get_fallback_schema("IFC4_ADD2") == "IFC4" + # IFC4X1 / IFC4X2 are draft schemas — collapse to IFC4 by design. + assert subject.get_fallback_schema("IFC4X1") == "IFC4" + assert subject.get_fallback_schema("IFC4X2") == "IFC4" + + def test_ifc2x3_variants_collapse_to_ifc2x3(self): + assert subject.get_fallback_schema("IFC2X3") == "IFC2X3" + assert subject.get_fallback_schema("IFC2X3_TC1") == "IFC2X3" + assert subject.get_fallback_schema("IFC2X3_FINAL") == "IFC2X3" + + def test_unknown_version_asserts(self): + # Asserts under non-optimised Python; in -O mode would return the + # unmodified input. Caller should guard accordingly. + with pytest.raises(AssertionError): + subject.get_fallback_schema("IFC10") + + +class TestIfc4OnlyGeometryClasses: + def test_known_ifc4_only_classes_present(self): + result = subject.ifc4_only_geometry_classes() + # Classes that genuinely don't exist in IFC2X3 and inherit + # IfcRepresentationItem in IFC4. + for name in ( + "IfcPolygonalFaceSet", + "IfcTriangulatedFaceSet", + "IfcIndexedPolyCurve", + "IfcCartesianPointList3D", + "IfcAdvancedBrep", + ): + assert name in result, f"{name} should be classified as IFC4-only geometry" + + def test_ifc2x3_compatible_classes_absent(self): + result = subject.ifc4_only_geometry_classes() + # Classes that exist in both schemas — must NOT be flagged. + for name in ("IfcPolyline", "IfcFacetedBrep", "IfcCartesianPoint", "IfcExtrudedAreaSolid"): + assert name not in result, f"{name} exists in IFC2X3, should not be IFC4-only" + + def test_non_geometry_ifc4_only_classes_absent(self): + result = subject.ifc4_only_geometry_classes() + # IFC4-only but not IfcRepresentationItem subclasses — out of scope. + for name in ("IfcEvent", "IfcWorkCalendar", "IfcLamp"): + assert name not in result, f"{name} is not an IfcRepresentationItem subclass" + + def test_result_is_cached_frozenset(self): + first = subject.ifc4_only_geometry_classes() + second = subject.ifc4_only_geometry_classes() + assert first is second # @functools.cache returns the same object + + +class TestGeometryClassesIntroducedAfter: + """Generalised version of ``ifc4_only_geometry_classes`` — pins the + schema-aware contract that supports IFC4X3 → IFC2X3 downgrades, not just + IFC4 → IFC2X3.""" + + def test_ifc4_to_ifc2x3_matches_legacy_helper(self): + # The legacy ``ifc4_only_geometry_classes`` is now a thin alias. + assert subject.geometry_classes_introduced_after("IFC2X3", "IFC4") == subject.ifc4_only_geometry_classes() + + def test_ifc4x3_to_ifc2x3_is_superset_of_ifc4_to_ifc2x3(self): + # IFC4X3 is a superset of IFC4 — every IFC4-only geometry class is + # also missing from IFC2X3 when the source is IFC4X3, plus any new + # IFC4X3-only geometry (alignment curves, distance expressions, …). + ifc4_gap = subject.geometry_classes_introduced_after("IFC2X3", "IFC4") + ifc4x3_gap = subject.geometry_classes_introduced_after("IFC2X3", "IFC4X3") + assert ifc4_gap <= ifc4x3_gap + + def test_ifc4_to_ifc4x3_is_empty(self): + # IFC4X3 contains every IFC4 IfcRepresentationItem subclass — no + # IFC4 class is missing from IFC4X3. + assert subject.geometry_classes_introduced_after("IFC4X3", "IFC4") == frozenset() + + +class TestEnumValueOutsideTarget: + @staticmethod + def _attr(class_name: str, attr_name: str): + schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name("IFC2X3") + decl = schema.declaration_by_name(class_name) + return next(a for a in decl.all_attributes() if a.name() == attr_name) + + def test_enum_value_present_in_target_returns_false(self): + # IfcCovering.PredefinedType is IfcCoveringTypeEnum — CEILING is valid. + attr = self._attr("IfcCovering", "PredefinedType") + assert subject._enum_value_outside_target(attr, "CEILING") is False + + def test_enum_value_missing_in_target_returns_true(self): + # IfcCoveringTypeEnum has no COMPACTFLUORESCENT (an IfcLampTypeEnum value). + attr = self._attr("IfcCovering", "PredefinedType") + assert subject._enum_value_outside_target(attr, "COMPACTFLUORESCENT") is True + + def test_non_enum_attribute_returns_false(self): + # IfcCovering.Name is IfcLabel — not an enum, so the helper must return False. + attr = self._attr("IfcCovering", "Name") + assert subject._enum_value_outside_target(attr, "anything") is False + + def test_non_string_value_returns_false(self): + attr = self._attr("IfcCovering", "PredefinedType") + assert subject._enum_value_outside_target(attr, 42) is False + + +class TestExtendedMaterialProperties(test.bootstrap.IFC4): def test_migrate_extended_material_properties_ifc2x3_ifc4(self): ifc2x3_file = ifcopenshell.api.project.create_file(version="IFC2X3") material = ifc2x3_file.createIfcMaterial(Name="Material") diff --git a/src/ifcopenshell-python/test/util/test_shape_builder.py b/src/ifcopenshell-python/test/util/test_shape_builder.py index 6a01a74201..d024ffc13a 100644 --- a/src/ifcopenshell-python/test/util/test_shape_builder.py +++ b/src/ifcopenshell-python/test/util/test_shape_builder.py @@ -16,7 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . -from math import degrees, radians +from math import degrees, radians, sqrt from typing import Any, Union import numpy as np @@ -28,6 +28,7 @@ import test.bootstrap from ifcopenshell.util.shape_builder import ( ShapeBuilder, V, + arc_to_polyline_points, is_x, np_angle, np_angle_signed, @@ -36,9 +37,116 @@ from ifcopenshell.util.shape_builder import ( np_normal, np_rotation_matrix, np_to_3d, + polygonal_face_set_to_faceted_brep, ) +class TestArcToPolylinePoints: + def test_quarter_arc_2d_samples_n_plus_one_points(self): + # Quarter arc from (1,0) through (cos45°, sin45°) to (0,1) — unit circle. + sqrt_half = sqrt(0.5) + points = arc_to_polyline_points((1.0, 0.0), (sqrt_half, sqrt_half), (0.0, 1.0), 8) + assert len(points) == 9 + assert points[0] == pytest.approx((1.0, 0.0), abs=1e-9) + assert points[-1] == pytest.approx((0.0, 1.0), abs=1e-9) + for x, y in points: + assert x * x + y * y == pytest.approx(1.0, abs=1e-9) + + def test_collinear_inputs_fall_back_to_straight_chord(self): + points = arc_to_polyline_points((0.0, 0.0), (1.0, 0.0), (2.0, 0.0), 16) + assert points == [(0.0, 0.0), (2.0, 0.0)] + + def test_3d_inputs_with_constant_z_preserved(self): + points = arc_to_polyline_points((1.0, 0.0, 5.0), (0.7071, 0.7071, 5.0), (0.0, 1.0, 5.0), 4) + assert len(points) == 5 + assert all(p[2] == 5.0 for p in points) + + def test_3d_inputs_with_mismatched_z_raises(self): + with pytest.raises(ValueError, match="XY plane"): + arc_to_polyline_points((1.0, 0.0, 0.0), (0.0, 1.0, 1.0), (-1.0, 0.0, 0.0)) + + def test_3d_inputs_with_near_equal_z_pass_within_tolerance(self): + # Real IFC files often have float noise of ~1e-15 in Z values that the + # author meant to be identical — kernel transforms introduce it. The + # planar check tolerates this rather than rejecting valid input. + sqrt_half = sqrt(0.5) + points = arc_to_polyline_points( + (1.0, 0.0, 5.0), (sqrt_half, sqrt_half, 5.0 + 1e-15), (0.0, 1.0, 5.0 - 2e-16), 4 + ) + assert len(points) == 5 + + def test_subdivisions_zero_raises(self): + with pytest.raises(ValueError, match="subdivisions"): + arc_to_polyline_points((1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), 0) + + +class TestPolygonalFaceSetToFacetedBrep(test.bootstrap.IFC4): + def test_triangulated_face_set_preserves_coordinates(self): + coords = self.file.create_entity( + "IfcCartesianPointList3D", + CoordList=((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.5, 0.5, 1.0)), + ) + face_set = self.file.create_entity( + "IfcTriangulatedFaceSet", Coordinates=coords, CoordIndex=[(1, 2, 4), (2, 3, 4), (3, 1, 4), (1, 3, 2)] + ) + + brep = polygonal_face_set_to_faceted_brep(face_set) + + assert brep.is_a("IfcFacetedBrep") + assert len(brep.Outer.CfsFaces) == 4 + # Every CoordList vertex appears in the brep at the same coordinate. + brep_points = {tuple(p.Coordinates) for f in brep.Outer.CfsFaces for p in f.Bounds[0].Bound.Polygon} + assert (0.0, 0.0, 0.0) in brep_points + assert (1.0, 0.0, 0.0) in brep_points + assert (0.0, 1.0, 0.0) in brep_points + assert (0.5, 0.5, 1.0) in brep_points + + def test_polygonal_face_set_with_voids_preserves_inner_bounds(self): + # Quad with a triangular hole through it. + coords = self.file.create_entity( + "IfcCartesianPointList3D", + CoordList=( + (0.0, 0.0, 0.0), + (4.0, 0.0, 0.0), + (4.0, 4.0, 0.0), + (0.0, 4.0, 0.0), + (1.0, 1.0, 0.0), + (3.0, 1.0, 0.0), + (2.0, 3.0, 0.0), + ), + ) + face = self.file.create_entity( + "IfcIndexedPolygonalFaceWithVoids", + CoordIndex=(1, 2, 3, 4), + InnerCoordIndices=[(5, 6, 7)], + ) + face_set = self.file.create_entity("IfcPolygonalFaceSet", Coordinates=coords, Faces=[face]) + + brep = polygonal_face_set_to_faceted_brep(face_set) + + assert len(brep.Outer.CfsFaces) == 1 + bounds = brep.Outer.CfsFaces[0].Bounds + # Outer + 1 inner bound. + assert len(bounds) == 2 + outer = next(b for b in bounds if b.is_a("IfcFaceOuterBound")) + inner = next(b for b in bounds if not b.is_a("IfcFaceOuterBound")) + assert len(outer.Bound.Polygon) == 4 + assert len(inner.Bound.Polygon) == 3 + + def test_wrong_class_raises_typeerror(self): + # An IfcCartesianPointList3D is not a face set. + not_a_face_set = self.file.create_entity("IfcCartesianPointList3D", CoordList=((0.0, 0.0, 0.0),)) + with pytest.raises(TypeError, match="IfcPolygonalFaceSet"): + polygonal_face_set_to_faceted_brep(not_a_face_set) + + def test_out_of_range_index_raises_valueerror(self): + coords = self.file.create_entity("IfcCartesianPointList3D", CoordList=((0.0, 0.0, 0.0),)) + # CoordIndex 5 doesn't exist in a 1-vertex coord list. + face_set = self.file.create_entity("IfcTriangulatedFaceSet", Coordinates=coords, CoordIndex=[(1, 1, 5)]) + with pytest.raises(ValueError, match="outside CoordList range"): + polygonal_face_set_to_faceted_brep(face_set) + + class TestMathutilsCompatibleMethods(test.bootstrap.IFC4): def test_np_rotation_matrix(self): from mathutils import Matrix, Vector # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] From f710929e9e1914f2680c7783009c5969e9515a61 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 23 Jun 2026 09:33:03 +0200 Subject: [PATCH 06/10] ifcpatch Migrate: defensive IFC4/IFC4X3 -> IFC2X3 downgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Migrate recipe previously crashed mid-loop with the cryptic `RuntimeError: Entity with name '' not found in schema 'IFC2X3'` when asked to downgrade an IFC4 or IFC4X3 file to IFC2X3 — the class_4_to_2x3 mapping marks IFC4-only geometry / element classes with an empty-string sentinel and the old code blindly forwarded that to create_entity. Real files routinely contain IfcPolygonalFaceSet, IfcTriangulatedFaceSet, IfcIndexedPolyCurve, IfcLamp, IfcPipeSegment, IfcGeographicElement, etc. The recipe now runs a preprocessing pipeline when the target is IFC2X3 and the source is IFC4 or IFC4X3: - DowngradeIndexedPolyCurve flattens IfcIndexedPolyCurve to IfcPolyline for the whole file (arcs included — see below). - IfcPolygonalFaceSet / IfcTriangulatedFaceSet are converted directly to IfcFacetedBrep at the entity level via ifcopenshell.util.shape_builder.polygonal_face_set_to_faceted_brep, preserving topology including IfcIndexedPolygonalFaceWithVoids inner bounds. IfcShapeRepresentation carriers have their RepresentationType tag updated from "Tessellation" to "Brep". - Orphan source-only geometry instances (left over after the rewires) are purged iteratively via geometry_classes_introduced_after(target, source). The Migrator is invoked with fallback_element_to_proxy=True so IFC4-only IfcElement subclasses (IfcLamp, IfcPipeSegment, IfcGeographicElement, ...) become IfcBuildingElementProxy in the output. A post-pass encodes "/" into ObjectType (e.g. "IfcLamp/COMPACTFLUORESCENT") when ObjectType is empty, so the lost subclass identity survives the downgrade as searchable text. The migration loop now collects per-entity failures into a list rather than crashing on the first; a summary RuntimeError fires at end if any failed, naming up to 20 with their inverse references. Successful migrations log a single count line via self.logger. DowngradeIndexedPolyCurve extended: - Arc segments (IfcArcIndex) are flattened via ifcopenshell.util.shape_builder.arc_to_polyline_points with ARC_SUBDIVISION=16 chord points per arc. - Multi-index IfcLineIndex segments handled correctly. - Absent Segments list (IFC4 polyline-through-all-coords case) handled. Test coverage: 11 tests across the two recipes covering all four preprocessing branches, the IFC4X3 source gate, the ObjectType encoding (incl. author-supplied ObjectType preservation), the summary RuntimeError shape, and the arc subdivision. Generated with the assistance of an AI coding tool. --- .../recipes/DowngradeIndexedPolyCurve.py | 60 ++++-- src/ifcpatch/ifcpatch/recipes/Migrate.py | 140 +++++++++++++- .../test/test_DowngradeIndexedPolyCurve.py | 137 +++++++++++++ src/ifcpatch/test/test_Migrate.py | 181 ++++++++++++++++++ 4 files changed, 499 insertions(+), 19 deletions(-) create mode 100644 src/ifcpatch/test/test_DowngradeIndexedPolyCurve.py diff --git a/src/ifcpatch/ifcpatch/recipes/DowngradeIndexedPolyCurve.py b/src/ifcpatch/ifcpatch/recipes/DowngradeIndexedPolyCurve.py index 4ae36f26b8..259fc7aa18 100644 --- a/src/ifcpatch/ifcpatch/recipes/DowngradeIndexedPolyCurve.py +++ b/src/ifcpatch/ifcpatch/recipes/DowngradeIndexedPolyCurve.py @@ -17,6 +17,12 @@ # along with IfcPatch. If not, see . import ifcopenshell.util.element +import ifcopenshell.util.shape_builder + +# Number of straight chords used to approximate one IfcArcIndex when flattening +# an IfcIndexedPolyCurve to an IfcPolyline. Higher values track the true arc +# more closely at the cost of file weight. +ARC_SUBDIVISION = 16 class Patcher: @@ -34,6 +40,9 @@ class Patcher: an IFC4 model (IFC2X3 does not have this geometry type) to help compatibility in viewers like Navisworks. + Arc segments (``IfcArcIndex``) are approximated by a chord polyline + through ``ARC_SUBDIVISION`` evenly-spaced points along the arc. + Example: ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "DowngradeIndexedPolyCurve", "arguments": []}) @@ -47,19 +56,46 @@ class Patcher: curve_map = {} for curve in self.file.by_type("IfcIndexedPolyCurve"): - if "IfcArcIndex" in [s.is_a() for s in curve.Segments]: - print("Could not convert curve due to arcs", curve) - continue coordinates = curve.Points.CoordList - points = [] - for i, segment in enumerate(curve.Segments): - segment = segment.wrappedValue - if i == 0: - points.append(self.file.createIfcCartesianPoint(coordinates[segment[0] - 1])) - points.append(self.file.createIfcCartesianPoint(coordinates[segment[1] - 1])) - polyline = self.file.create_entity("IfcPolyline", points) + segments = curve.Segments + if segments is None: + # IFC4: an absent Segments list means the curve is a polyline + # through every CoordList point in declared order. + points = [tuple(c) for c in coordinates] + else: + points = self._segments_to_points(segments, coordinates) + if points is None: + continue + ifc_points = [self.file.createIfcCartesianPoint(p) for p in points] + polyline = self.file.create_entity("IfcPolyline", ifc_points) curve_map[curve] = polyline for curve, polyline in curve_map.items(): - for inverse in self.file.get_inverse(curve): - ifcopenshell.util.element.replace_attribute(inverse, curve, polyline) + ifcopenshell.util.element.replace_element(curve, polyline) + + def _segments_to_points(self, segments, coordinates): + points: list[tuple[float, ...]] = [] + for i, segment in enumerate(segments): + indices = segment.wrappedValue + if segment.is_a("IfcArcIndex"): + if len(indices) != 3: + return None + arc_points = ifcopenshell.util.shape_builder.arc_to_polyline_points( + coordinates[indices[0] - 1], + coordinates[indices[1] - 1], + coordinates[indices[2] - 1], + ARC_SUBDIVISION, + ) + if i == 0: + points.append(tuple(arc_points[0])) + points.extend(tuple(p) for p in arc_points[1:]) + else: + # IfcLineIndex is LIST [2:?] OF IfcPositiveInteger — a polyline + # through every listed index. Skip the first index on non-leading + # segments since it duplicates the previous segment's endpoint. + seg_points = [tuple(coordinates[idx - 1]) for idx in indices] + if i == 0: + points.extend(seg_points) + else: + points.extend(seg_points[1:]) + return points diff --git a/src/ifcpatch/ifcpatch/recipes/Migrate.py b/src/ifcpatch/ifcpatch/recipes/Migrate.py index 627342f096..c7479a6121 100644 --- a/src/ifcpatch/ifcpatch/recipes/Migrate.py +++ b/src/ifcpatch/ifcpatch/recipes/Migrate.py @@ -20,7 +20,9 @@ from logging import Logger from typing import Union import ifcopenshell +import ifcopenshell.util.element import ifcopenshell.util.schema +import ifcopenshell.util.shape_builder import ifcpatch @@ -32,10 +34,39 @@ class Patcher(ifcpatch.BasePatcher): logger: Union[Logger, None] = None, schema: ifcopenshell.util.schema.IFC_SCHEMA = "IFC4", ): - """Migrate from one IFC version to another + """Migrate from one IFC version to another. - Note that this is experimental and will try to preserve as much data as - possible. Upgrading to IFC4 is more stable than downgrading to IFC2X3. + The recipe iterates every entity in the source file and rewrites it + into a new file with the target schema, delegating per-entity class / + attribute translation to :class:`ifcopenshell.util.schema.Migrator`. + Upgrades (IFC2X3 → IFC4, IFC4 → IFC4X3) are best supported because the + target schema is a superset; downgrades are lossy by definition (see + below). Entities that fail to migrate are collected; on completion a + summary ``RuntimeError`` is raised listing up to 20 failures. + + IFC4 → IFC2X3 downgrade additionally runs a preprocessing pipeline so + IFC4-only geometry and element classes survive the schema gap: + + - ``IfcIndexedPolyCurve`` (including arc segments, approximated by a + chord polyline) is flattened to ``IfcPolyline``. + - ``IfcPolygonalFaceSet`` and ``IfcTriangulatedFaceSet`` are converted + directly to ``IfcFacetedBrep`` at the entity level, preserving the + original mesh topology. + - Orphan IFC4-only geometry instances left over after the rewires are + purged so the migration loop does not trip on them. + - IFC4-only ``IfcElement`` subclasses (``IfcLamp``, ``IfcPipeSegment``, + ``IfcGeographicElement``, …) fall back to ``IfcBuildingElementProxy`` + via the Migrator's ``fallback_element_to_proxy`` opt-in. The + original class and ``PredefinedType`` are encoded into + ``ObjectType`` (e.g. ``"IfcLamp/COMPACTFLUORESCENT"``) when + ``ObjectType`` is empty, so the type information survives the + downgrade. + + Non-element IFC4-only entities (relationships, geometry items outside + any product, …) that have no direct equivalent still raise + ``NotImplementedError`` from the Migrator with the failing class and + inverse references named, instead of the cryptic + ``Entity with name '' not found in schema 'IFC2X3'``. :param schema: The schema identifier of the IFC version to migrate to. @@ -50,10 +81,105 @@ class Patcher(ifcpatch.BasePatcher): self.schema = schema def patch(self): + # IFC4 and IFC4X3 both have geometry / element classes absent in + # IFC2X3, so both source schemas need the downgrade preprocessing + + # IfcBuildingElementProxy fallback when targeting IFC2X3. + is_downgrade_to_ifc2x3 = self.schema == "IFC2X3" and self.file.schema in ("IFC4", "IFC4X3") + if is_downgrade_to_ifc2x3: + self._prepare_for_downgrade() + self.file_patched = ifcopenshell.file(schema=self.schema) - migrator = ifcopenshell.util.schema.Migrator() + migrator = ifcopenshell.util.schema.Migrator(fallback_element_to_proxy=is_downgrade_to_ifc2x3) migrator.preprocess(self.file, self.file_patched) + + migrated = 0 + failures: list[tuple[ifcopenshell.entity_instance, Exception]] = [] for element in self.file: - new_element = migrator.migrate(element, self.file_patched) - print("Migrating", element) - print("Successfully converted to", new_element) + try: + migrator.migrate(element, self.file_patched) + migrated += 1 + except Exception as exc: + failures.append((element, exc)) + + if is_downgrade_to_ifc2x3: + self._encode_fallback_class_into_object_type(migrator) + + # BasePatcher.__init__ guarantees self.logger is non-None + # (ensure_logger falls back to logging.getLogger("IFCPatch")). + self.logger.info(f"Migrated {migrated} entities to {self.schema}.") + if failures: + summary = [f"{len(failures)} entities could not be migrated to {self.schema}:"] + for element, exc in failures[:20]: + summary.append(f" #{element.id()}={element.is_a()}: {exc}") + if len(failures) > 20: + summary.append(f" … (+{len(failures) - 20} more)") + raise RuntimeError("\n".join(summary)) + + def _prepare_for_downgrade(self) -> None: + from ifcpatch.recipes.DowngradeIndexedPolyCurve import Patcher as DowngradePolyCurve + + DowngradePolyCurve(self.file, self.logger).patch() + self._convert_face_sets_to_faceted_brep() + self._purge_orphaned_ifc4_only_entities() + + def _convert_face_sets_to_faceted_brep(self) -> None: + face_sets = list(self.file.by_type("IfcPolygonalFaceSet")) + list(self.file.by_type("IfcTriangulatedFaceSet")) + if not face_sets: + return + + # IfcShapeRepresentations carrying these face sets need their type tag + # updated from "Tessellation" (IFC4) to "Brep" (IFC2X3-compatible). + # Snapshot the relevant inverses before rewiring — the inverse set is + # invalidated once replace_element runs. + touched_reps: set[int] = set() + for face_set in face_sets: + faceted_brep = ifcopenshell.util.shape_builder.polygonal_face_set_to_faceted_brep(face_set) + touched_reps.update( + inv.id() for inv in self.file.get_inverse(face_set) if inv.is_a("IfcShapeRepresentation") + ) + ifcopenshell.util.element.replace_element(face_set, faceted_brep) + + for rep_id in touched_reps: + self.file.by_id(rep_id).RepresentationType = "Brep" + + def _purge_orphaned_ifc4_only_entities(self) -> None: + # Preprocessing rewires references away from source-schema-only + # carriers but does not delete the now-unreferenced instances + # themselves. Sweep iteratively so cascades collapse leaf-first + # (curves → point lists, face sets → indexed faces → point lists). + # Scoped to the actual source schema so IFC4X3 → IFC2X3 downgrades + # also catch IFC4X3-only geometry (IfcAlignmentCurve etc.), not just + # the IFC4 gap. + targets = ifcopenshell.util.schema.geometry_classes_introduced_after( + self.schema, source_schema=self.file.schema + ) + while True: + removed = False + for ifc_class in targets: + for entity in list(self.file.by_type(ifc_class)): + if not self.file.get_inverse(entity): + self.file.remove(entity) + removed = True + if not removed: + break + + def _encode_fallback_class_into_object_type(self, migrator: ifcopenshell.util.schema.Migrator) -> None: + # IFC4-only IfcElement subclasses (IfcLamp, IfcPipeSegment, …) migrate + # as IfcBuildingElementProxy. The subclass identity + its PredefinedType + # would otherwise be silently lost — IFC2X3 IfcBuildingElementProxy has + # no slot for them. Encode "/" into + # ObjectType when empty (don't trample author-supplied values). + for source_id, new_id in migrator.migrated_ids.items(): + try: + source = self.file.by_id(source_id) + new = self.file_patched.by_id(new_id) + except RuntimeError: + continue + if not new.is_a("IfcBuildingElementProxy"): + continue + if source.is_a("IfcBuildingElementProxy"): + continue + if getattr(new, "ObjectType", None): + continue + predef = getattr(source, "PredefinedType", None) + new.ObjectType = f"{source.is_a()}/{predef}" if predef else source.is_a() diff --git a/src/ifcpatch/test/test_DowngradeIndexedPolyCurve.py b/src/ifcpatch/test/test_DowngradeIndexedPolyCurve.py new file mode 100644 index 0000000000..adeda0b669 --- /dev/null +++ b/src/ifcpatch/test/test_DowngradeIndexedPolyCurve.py @@ -0,0 +1,137 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Bonsai Contributors +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +import ifcpatch +import test.bootstrap + + +class TestDowngradeIndexedPolyCurve(test.bootstrap.IFC4): + def _make_curve(self, segments=None): + point_list = self.file.create_entity( + "IfcCartesianPointList2D", + CoordList=[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], + ) + curve = self.file.create_entity( + "IfcIndexedPolyCurve", + Points=point_list, + Segments=segments, + ) + self.file.create_entity( + "IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve + ) + return curve + + def test_run_without_segments(self): + """An IfcIndexedPolyCurve with no Segments must downgrade to an + IfcPolyline through every CoordList point in order — IFC4 defines + the implicit-polyline meaning of an absent Segments list, and the + ifcopenshell shape builder emits this form for simple open curves.""" + self._make_curve(segments=None) + ifcpatch.execute( + {"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []} + ) + polylines = self.file.by_type("IfcPolyline") + assert len(polylines) == 1 + assert len(polylines[0].Points) == 3 + + def test_run_with_line_segments(self): + """Line-segmented IfcIndexedPolyCurves downgrade to an equivalent IfcPolyline.""" + segments = [ + self.file.createIfcLineIndex((1, 2)), + self.file.createIfcLineIndex((2, 3)), + ] + self._make_curve(segments=segments) + ifcpatch.execute( + {"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []} + ) + polylines = self.file.by_type("IfcPolyline") + assert len(polylines) == 1 + assert len(polylines[0].Points) == 3 + + def test_run_with_multi_index_line_segment(self): + """An IfcLineIndex with >2 indices encodes a polyline through every + index — the downgraded IfcPolyline must include every one of them. + This is the canonical form Bonsai's shape builder emits for closed + rectangle profiles (e.g. parametric wall body outlines), serialised + as ``IfcIndexedPolyCurve(Points, (IfcLineIndex((1,2,3,4,1))))``.""" + point_list = self.file.create_entity( + "IfcCartesianPointList2D", + CoordList=[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)], + ) + curve = self.file.create_entity( + "IfcIndexedPolyCurve", + Points=point_list, + Segments=[self.file.createIfcLineIndex((1, 2, 3, 4, 1))], + ) + self.file.create_entity( + "IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve + ) + ifcpatch.execute( + {"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []} + ) + polylines = self.file.by_type("IfcPolyline") + assert len(polylines) == 1 + assert len(polylines[0].Points) == 5 + coords = [p.Coordinates for p in polylines[0].Points] + assert coords[0] == coords[-1] == (0.0, 0.0) + assert coords[1] == (1.0, 0.0) + assert coords[2] == (1.0, 1.0) + assert coords[3] == (0.0, 1.0) + + def test_run_with_chained_multi_index_segments(self): + """When two IfcLineIndex segments are chained, the shared endpoint + between them must appear once, not twice.""" + point_list = self.file.create_entity( + "IfcCartesianPointList2D", + CoordList=[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)], + ) + curve = self.file.create_entity( + "IfcIndexedPolyCurve", + Points=point_list, + Segments=[ + self.file.createIfcLineIndex((1, 2, 3)), + self.file.createIfcLineIndex((3, 4)), + ], + ) + self.file.create_entity( + "IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve + ) + ifcpatch.execute( + {"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []} + ) + polylines = self.file.by_type("IfcPolyline") + assert len(polylines) == 1 + coords = [p.Coordinates for p in polylines[0].Points] + assert coords == [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] + + def test_run_facets_arc_segments(self): + """Arc-segmented IfcIndexedPolyCurves are downgraded by sampling the + circular arc into a chord polyline. The chord count is fixed by + the recipe's subdivision parameter.""" + from ifcpatch.recipes.DowngradeIndexedPolyCurve import ARC_SUBDIVISION + + segments = [self.file.createIfcArcIndex((1, 2, 3))] + self._make_curve(segments=segments) + ifcpatch.execute( + {"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []} + ) + polylines = self.file.by_type("IfcPolyline") + assert len(polylines) == 1 + assert len(polylines[0].Points) == ARC_SUBDIVISION + 1 diff --git a/src/ifcpatch/test/test_Migrate.py b/src/ifcpatch/test/test_Migrate.py index 85e60ff09f..23930e6283 100644 --- a/src/ifcpatch/test/test_Migrate.py +++ b/src/ifcpatch/test/test_Migrate.py @@ -17,6 +17,9 @@ # along with IfcOpenShell. If not, see . +import pytest + +import ifcopenshell.api.project import ifcpatch import test.bootstrap @@ -27,3 +30,181 @@ class TestMigrate(test.bootstrap.IFC4): old_file.header.file_name.name = "test" new_file = ifcpatch.execute({"file": old_file, "recipe": "Migrate", "arguments": ["IFC4"]}) assert new_file.header.file_name.name == "test" + + def test_migrate_ifc4_to_ifc2x3_flattens_indexed_polycurve(self): + """Downgrade IFC4 → IFC2X3 should auto-run DowngradeIndexedPolyCurve on + IfcIndexedPolyCurve carriers, so the migrated file uses IfcPolyline (which + exists in IFC2X3) instead of crashing on the IFC4-only curve class.""" + ifc4_file = self.file + point_list = ifc4_file.create_entity( + "IfcCartesianPointList2D", + CoordList=((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)), + ) + segments = [ + ifc4_file.create_entity("IfcLineIndex", (1, 2)), + ifc4_file.create_entity("IfcLineIndex", (2, 3)), + ifc4_file.create_entity("IfcLineIndex", (3, 4)), + ifc4_file.create_entity("IfcLineIndex", (4, 1)), + ] + curve = ifc4_file.create_entity( + "IfcIndexedPolyCurve", Points=point_list, Segments=segments, SelfIntersect=False + ) + ifc4_file.create_entity("IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve) + + new_file = ifcpatch.execute({"file": ifc4_file, "recipe": "Migrate", "arguments": ["IFC2X3"]}) + + assert new_file.schema == "IFC2X3" + new_profile = new_file.by_type("IfcArbitraryClosedProfileDef")[0] + assert new_profile.OuterCurve.is_a("IfcPolyline") + # The preprocessing step should have purged orphaned IFC4-only entities + # from the source before the migration loop reached them. + assert not ifc4_file.by_type("IfcIndexedPolyCurve") + assert not ifc4_file.by_type("IfcCartesianPointList2D") + + def test_migrate_ifc4_to_ifc2x3_encodes_fallback_class_in_object_type(self): + """IfcLamp / IfcPipeSegment / IfcGeographicElement fall back to + IfcBuildingElementProxy on downgrade. The original class and + PredefinedType are encoded into ObjectType so the type info survives + — but only when ObjectType is empty (author-supplied values stay).""" + ifc4_file = self.file + ifc4_file.create_entity("IfcLamp", GlobalId="2K6Z3DR8X37AS9XFvX8GcW", PredefinedType="COMPACTFLUORESCENT") + ifc4_file.create_entity("IfcPipeSegment", GlobalId="0_bkftCTnBCOOZeUxtJngE") + ifc4_file.create_entity( + "IfcGeographicElement", + GlobalId="3_b4gD1aP3ARmIm2ePijXi", + ObjectType="Terrain Mesh", # author-supplied, must not be overwritten + PredefinedType="TERRAIN", + ) + + new_file = ifcpatch.execute({"file": ifc4_file, "recipe": "Migrate", "arguments": ["IFC2X3"]}) + + proxies = {p.GlobalId: p for p in new_file.by_type("IfcBuildingElementProxy")} + # IfcLamp with no author ObjectType: encoded as IfcLamp/COMPACTFLUORESCENT. + assert proxies["2K6Z3DR8X37AS9XFvX8GcW"].ObjectType == "IfcLamp/COMPACTFLUORESCENT" + # IfcPipeSegment with no PredefinedType set: just the class name. + assert proxies["0_bkftCTnBCOOZeUxtJngE"].ObjectType == "IfcPipeSegment" + # IfcGeographicElement with author ObjectType: preserved as-is. + assert proxies["3_b4gD1aP3ARmIm2ePijXi"].ObjectType == "Terrain Mesh" + + def test_migrate_ifc4_to_ifc2x3_converts_polygonal_face_set_to_faceted_brep(self): + """IfcPolygonalFaceSet has no IFC2X3 equivalent. Direct entity-level + conversion produces an IfcFacetedBrep with the same topology, regardless + of which representation context the source lived in.""" + ifc4_file = self.file + coords = ifc4_file.create_entity( + "IfcCartesianPointList3D", + CoordList=( + (0.0, 0.0, 0.0), + (1.0, 0.0, 0.0), + (1.0, 1.0, 0.0), + (0.0, 1.0, 0.0), + (0.5, 0.5, 1.0), + ), + ) + # Square base + 4 triangle sides — a simple pyramid. + faces = [ + ifc4_file.create_entity("IfcIndexedPolygonalFace", CoordIndex=(1, 2, 3, 4)), + ifc4_file.create_entity("IfcIndexedPolygonalFace", CoordIndex=(1, 2, 5)), + ifc4_file.create_entity("IfcIndexedPolygonalFace", CoordIndex=(2, 3, 5)), + ifc4_file.create_entity("IfcIndexedPolygonalFace", CoordIndex=(3, 4, 5)), + ifc4_file.create_entity("IfcIndexedPolygonalFace", CoordIndex=(4, 1, 5)), + ] + face_set = ifc4_file.create_entity("IfcPolygonalFaceSet", Coordinates=coords, Faces=faces) + context = ifc4_file.create_entity( + "IfcGeometricRepresentationContext", + ContextType="Model", + CoordinateSpaceDimension=3, + Precision=0.01, + WorldCoordinateSystem=ifc4_file.createIfcAxis2Placement3D( + Location=ifc4_file.createIfcCartesianPoint((0.0, 0.0, 0.0)) + ), + ) + ifc4_file.create_entity( + "IfcShapeRepresentation", + ContextOfItems=context, + RepresentationIdentifier="Body", + RepresentationType="Tessellation", + Items=[face_set], + ) + + new_file = ifcpatch.execute({"file": ifc4_file, "recipe": "Migrate", "arguments": ["IFC2X3"]}) + + assert new_file.schema == "IFC2X3" + breps = new_file.by_type("IfcFacetedBrep") + assert len(breps) == 1 + brep = breps[0] + assert len(brep.Outer.CfsFaces) == 5 + # Coordinates from the source CartesianPointList3D must appear in the + # resulting brep's loop points — otherwise the conversion silently + # corrupted geometry. + brep_coords = {tuple(p.Coordinates) for face in brep.Outer.CfsFaces for p in face.Bounds[0].Bound.Polygon} + for expected in ((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 1.0, 0.0), (0.0, 1.0, 0.0), (0.5, 0.5, 1.0)): + assert expected in brep_coords, f"vertex {expected} missing from converted brep" + rep = new_file.by_type("IfcShapeRepresentation")[0] + assert rep.RepresentationType == "Brep" + assert rep.Items[0].is_a("IfcFacetedBrep") + + def test_migrate_ifc4_to_ifc2x3_summarises_unmappable_entities(self): + """When an IFC4-only entity that cannot be auto-substituted survives + preprocessing, the recipe must surface a summary RuntimeError naming + the failing class — not the cryptic ``RuntimeError: Entity with name + '' not found``. + + Uses ``IfcWorkCalendar`` as the fixture — an IFC4 entity that + (a) is not an IfcRepresentationItem (skips the geometry purge), + (b) is not an IfcElement (skips the proxy fallback), + (c) has no IFC2X3 equivalent in ``class_4_to_2x3.json`` (mapped to ``""``). + These three conditions together guarantee it always reaches the + unmappable error path, independent of future schema additions.""" + ifc4_file = self.file + ifc4_file.create_entity("IfcWorkCalendar", GlobalId="2K6Z3DR8X37AS9XFvX8GcW") + + with pytest.raises(RuntimeError) as exc_info: + ifcpatch.execute({"file": ifc4_file, "recipe": "Migrate", "arguments": ["IFC2X3"]}) + + message = str(exc_info.value) + assert "IfcWorkCalendar" in message + + def test_migrate_ifc4x3_to_ifc2x3_runs_downgrade_preprocessing(self): + """IFC4X3 → IFC2X3 must trigger the same downgrade preprocessing as + IFC4 → IFC2X3: curve flatten, face-set → brep, IfcBuildingElementProxy + fallback, ObjectType encoding. Pins the gate at + ``self.file.schema in ('IFC4', 'IFC4X3')`` — a narrower check would + silently leave IFC4X3 sources crashing on IFC4-only geometry.""" + ifc4x3_file = ifcopenshell.api.project.create_file(version="IFC4X3") + ifc4x3_file.create_entity("IfcLamp", GlobalId="2K6Z3DR8X37AS9XFvX8GcW", PredefinedType="COMPACTFLUORESCENT") + + new_file = ifcpatch.execute({"file": ifc4x3_file, "recipe": "Migrate", "arguments": ["IFC2X3"]}) + + assert new_file.schema == "IFC2X3" + proxies = new_file.by_type("IfcBuildingElementProxy") + assert len(proxies) == 1 + # ObjectType encoding ran — same as the IFC4 → IFC2X3 case. + assert proxies[0].ObjectType == "IfcLamp/COMPACTFLUORESCENT" + + def test_migrate_ifc4_to_ifc2x3_flattens_arc_bearing_indexed_polycurve(self): + """An IfcIndexedPolyCurve with IfcArcIndex segments is approximated + with a chord polyline rather than skipped, so the parent profile def + and its representations stay parametric (no fallback to tessellation).""" + ifc4_file = self.file + point_list = ifc4_file.create_entity( + "IfcCartesianPointList2D", + CoordList=((1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.0, -1.0)), + ) + # Two half-arcs forming a circle: (1,0)→(0,1)→(-1,0)→(0,-1)→(1,0). + segments = [ + ifc4_file.create_entity("IfcArcIndex", (1, 2, 3)), + ifc4_file.create_entity("IfcArcIndex", (3, 4, 1)), + ] + curve = ifc4_file.create_entity( + "IfcIndexedPolyCurve", Points=point_list, Segments=segments, SelfIntersect=False + ) + ifc4_file.create_entity("IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve) + + new_file = ifcpatch.execute({"file": ifc4_file, "recipe": "Migrate", "arguments": ["IFC2X3"]}) + + assert new_file.schema == "IFC2X3" + new_profile = new_file.by_type("IfcArbitraryClosedProfileDef")[0] + assert new_profile.OuterCurve.is_a("IfcPolyline") + # Arc subdivision should produce many more points than the 4 input coords. + assert len(new_profile.OuterCurve.Points) > 4 From 2ab5ca9222913be0f26733aecbbfaddebbb4ae3b Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 23 Jun 2026 09:46:31 +0200 Subject: [PATCH 07/10] ifcpatch: small recipe polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExtractElements: expand the `query` docstring to cover the exclusion syntax (`!` on entity classes, `!=` on attribute / pset / material / classification / location / group facets) and the "seed with a broad include before subtracting" gotcha — entity-class exclusion does not auto-seed from "all elements", so a bare `! IfcSlab` query returns nothing. FixArchiCADToRevitDoorSwings: guard the `IfcIndexedPolyCurve.Segments` loop against the IFC4 case where Segments is absent (a polyline through all coords in declared order). Previously crashed on `None.__iter__`. Generated with the assistance of an AI coding tool. --- src/ifcpatch/ifcpatch/recipes/ExtractElements.py | 15 ++++++++++++++- .../recipes/FixArchiCADToRevitDoorSwings.py | 2 ++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/ifcpatch/ifcpatch/recipes/ExtractElements.py b/src/ifcpatch/ifcpatch/recipes/ExtractElements.py index 10d8b23330..132d86d436 100644 --- a/src/ifcpatch/ifcpatch/recipes/ExtractElements.py +++ b/src/ifcpatch/ifcpatch/recipes/ExtractElements.py @@ -41,7 +41,14 @@ class Patcher(ifcpatch.BasePatcher): to a new IFC file. For example, you might want to extract only the walls in a model and save it as a new model. - :param query: A query to select the subset of IFC elements. + :param query: A query to select the subset of IFC elements, using the + ifcopenshell.util.selector.filter_elements grammar. Supports + exclusion (blacklist) via '!' on entity classes and '!=' on + attribute / pset / material / classification / location / group + facets. Entity-class exclusion does not auto-seed from "all + elements", so a bare '! IfcSlab' query returns nothing — start + with a broad include (e.g. 'IfcProduct', 'IfcElement') and + subtract from it. :param assume_asset_uniqueness_by_name: Avoid adding assets (profiles, materials, styles) with the same name multiple times. Which helps in avoiding duplicated assets. ----- @@ -63,6 +70,12 @@ class Patcher(ifcpatch.BasePatcher): # Extract all walls and slabs ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "ExtractElements", "arguments": ["IfcWall, IfcSlab"]}) + + # Extract everything except slabs (seed with a broad include, then subtract) + ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "ExtractElements", "arguments": ["IfcProduct, ! IfcSlab"]}) + + # Extract walls whose Name is not "Foo" + ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "ExtractElements", "arguments": ["IfcWall, attribute.Name != \"Foo\""]}) """ super().__init__(file, logger) self.query = query diff --git a/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitDoorSwings.py b/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitDoorSwings.py index c2523c7e8f..2666b4611f 100644 --- a/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitDoorSwings.py +++ b/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitDoorSwings.py @@ -190,6 +190,8 @@ class Patcher(ifcpatch.BasePatcher): settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) unit_scale = ifcopenshell.util.unit.calculate_unit_scale(self.file) for curve in self.file.by_type("IfcIndexedPolyCurve"): + if curve.Segments is None: + continue if True in [s.is_a("IfcArcIndex") for s in curve.Segments]: shape = ifcopenshell.geom.create_shape(settings, curve) e = shape.edges From 44c0c2916ccf32deaefeec4e73137f8ad6b45039 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 23 Jun 2026 09:48:54 +0200 Subject: [PATCH 08/10] Bonsai patch: lossy-downgrade popup + per-recipe preset menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new UX features in the IFC Patch panel, both backed by helpers on bonsai.tool.Patch. Lossy-downgrade confirmation popup. When the user picks the Migrate recipe with a target schema older than the source's (IFC4 -> IFC2X3, IFC4X3 -> IFC2X3), ExecuteIfcPatch.invoke shows a properties dialog listing what's preserved vs lost: IfcIndexedPolyCurve flattened with arcs approximated, IfcPolygonalFaceSet / IfcTriangulatedFaceSet converted to IfcFacetedBrep, IFC4-only IfcElement subclasses (IfcLamp, IfcPipeSegment, IfcGeographicElement, ...) demoted to IfcBuildingElementProxy with the original class + PredefinedType encoded into ObjectType, and PredefinedType enum values absent from IFC2X3 dropped. The user explicitly approves before the recipe runs. The popup is gated on tool.Patch.migration_is_lossy_downgrade() which resolves the source schema via header-only parsing (tool.Patch._patch_source_schema reads the first ~2KB and matches a FILE_SCHEMA regex, then normalises via ifcopenshell.util.schema. get_fallback_schema). Avoids a full ifcopenshell.open() on every Execute click — multi-second saving on large files. The target schema is looked up by argument name rather than position so it survives recipe-parameter reordering. Per-recipe preset menu. New BIM_MT_ifc_patch_presets + AddIfcPatchPreset wire Blender's standard preset system into the panel. Each recipe gets its own preset subdirectory (bonsai/ifc_patch//), so a preset saved for ExtractElements does not pollute the Migrate preset list. The preset operator uses Attribute.get_value_name() (single source of truth for data_type -> storage-field mapping) to build the preset_values list dynamically per recipe. The recipe-change callback resets BIM_MT_ifc_patch_presets.bl_label to the canonical title — Blender's script.execute_preset mutates the menu's bl_label to the loaded preset's name as a "currently-selected" indicator, and without an explicit reset the previous recipe's preset name would falsely advertise itself in the new recipe's menu. tool.Patch gains get_preset_subdir, migration_is_lossy_downgrade, _patch_source_schema as cross-cutting helpers. _SCHEMA_AGE module constant provides the ordering used by the downgrade-detection predicate. Test coverage: 12 bim-lane tests under test/bim/module/patch/. The truth table for migration_is_lossy_downgrade covers IFC4/IFC4X3 source x downgrade/upgrade/same-schema target x Migrate/non-Migrate recipe. The schema-sniffing tests write a real IFC4X3_ADD2 file to disk and assert the helper resolves it to IFC4X3 (regression for the original startswith iteration-order bug). An end-to-end test drives bpy.ops.bim.execute_ifc_patch with an in-memory IfcLamp source and verifies the on-disk IFC2X3 file contains a single IfcBuildingElementProxy with ObjectType "IfcLamp/COMPACTFLUORESCENT" and the original GlobalId preserved. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/patch/__init__.py | 2 + .../bonsai/bim/module/patch/operator.py | 57 ++++++++ src/bonsai/bonsai/bim/module/patch/prop.py | 9 ++ src/bonsai/bonsai/bim/module/patch/ui.py | 19 +++ src/bonsai/bonsai/tool/patch.py | 72 ++++++++++ src/bonsai/test/bim/module/patch/__init__.py | 0 .../patch/test_execute_downgrade_e2e.py | 70 ++++++++++ .../bim/module/patch/test_lossy_downgrade.py | 131 ++++++++++++++++++ .../module/patch/test_preset_label_reset.py | 55 ++++++++ 9 files changed, 415 insertions(+) create mode 100644 src/bonsai/test/bim/module/patch/__init__.py create mode 100644 src/bonsai/test/bim/module/patch/test_execute_downgrade_e2e.py create mode 100644 src/bonsai/test/bim/module/patch/test_lossy_downgrade.py create mode 100644 src/bonsai/test/bim/module/patch/test_preset_label_reset.py diff --git a/src/bonsai/bonsai/bim/module/patch/__init__.py b/src/bonsai/bonsai/bim/module/patch/__init__.py index fd5de30d38..903e29da6d 100644 --- a/src/bonsai/bonsai/bim/module/patch/__init__.py +++ b/src/bonsai/bonsai/bim/module/patch/__init__.py @@ -21,6 +21,7 @@ import bpy from . import operator, prop, ui classes = ( + operator.AddIfcPatchPreset, operator.ExecuteIfcPatch, operator.ExtractSelectedElements, operator.RunMigratePatch, @@ -28,6 +29,7 @@ classes = ( operator.SelectIfcPatchOutput, operator.UpdateIfcPatchArguments, prop.BIMPatchProperties, + ui.BIM_MT_ifc_patch_presets, ui.BIM_PT_patch, ) diff --git a/src/bonsai/bonsai/bim/module/patch/operator.py b/src/bonsai/bonsai/bim/module/patch/operator.py index 99459b99e9..531b7c581c 100644 --- a/src/bonsai/bonsai/bim/module/patch/operator.py +++ b/src/bonsai/bonsai/bim/module/patch/operator.py @@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, cast import bpy import ifcopenshell import ifcpatch +from bl_operators.presets import AddPresetBase from bpy_extras.io_utils import ExportHelper, ImportHelper import bonsai.bim.handler @@ -77,6 +78,27 @@ class ExecuteIfcPatch(bpy.types.Operator): return False return True + def invoke(self, context, event): + # Migrating IFC4 → IFC2X3 is lossy (enum drops, IFC4-only classes + # become IfcBuildingElementProxy, tessellated meshes get rebuilt as + # IfcFacetedBrep). Confirm before running so the user knows. + if tool.Patch.migration_is_lossy_downgrade(): + return context.window_manager.invoke_props_dialog(self, width=480) + return self.execute(context) + + def draw(self, context): + layout = self.layout + layout.label(text="Downgrading to IFC2X3 is lossy.", icon="ERROR") + column = layout.column(align=True) + column.label(text="Geometry will be preserved as faithfully as possible:") + column.label(text="• IfcIndexedPolyCurve → IfcPolyline (arcs approximated by chords)") + column.label(text="• IfcPolygonalFaceSet / IfcTriangulatedFaceSet → IfcFacetedBrep") + column.separator() + column.label(text="The following information is lost:") + column.label(text="• IFC4-only classes (IfcLamp, IfcPipeSegment, …) → IfcBuildingElementProxy") + column.label(text="• PredefinedType enum values absent from IFC2X3 are dropped") + column.label(text=" (original class + enum saved as ObjectType, e.g. 'IfcLamp/COMPACTFLUORESCENT')") + def execute(self, context): props = tool.Patch.get_patch_props() recipe_name = props.ifc_patch_recipes @@ -224,3 +246,38 @@ class ExtractSelectedElements(bpy.types.Operator): query = tool.Search.get_query_for_selected_elements() props.ifc_patch_args_attr[0].string_value = query return {"FINISHED"} + + +class AddIfcPatchPreset(AddPresetBase, bpy.types.Operator): + """Save / remove ifc-patch argument presets, scoped per recipe. + + Presets live in the standard Blender preset directory under + ``bonsai/ifc_patch//`` so a preset created for ``ExtractElements`` + does not pollute the preset list for ``Migrate``. Persistence across files + and sessions is inherited from Blender's preset system.""" + + bl_idname = "bim.add_ifc_patch_preset" + bl_label = "Add IFC Patch Preset" + preset_menu = "BIM_MT_ifc_patch_presets" + preset_defines = ["props = bpy.context.scene.BIMPatchProperties"] + + @property + def preset_subdir(self) -> str: + return tool.Patch.get_preset_subdir() + + @property + def preset_values(self) -> list[str]: + # `Attribute.get_value_name()` returns the storage field for the + # argument's data_type (string_value, bool_value, …). For file + # arguments it returns the wrapping PointerProperty (`filepath_value`) + # — the scalar path the preset needs is `.single_file` on that. + props = tool.Patch.get_patch_props() + values = [] + for i, arg in enumerate(props.ifc_patch_args_attr): + field = arg.get_value_name() + if not field: + continue + if arg.data_type == "file": + field = f"{field}.single_file" + values.append(f"props.ifc_patch_args_attr[{i}].{field}") + return values diff --git a/src/bonsai/bonsai/bim/module/patch/prop.py b/src/bonsai/bonsai/bim/module/patch/prop.py index e14bb3b1ef..ae9793c01d 100644 --- a/src/bonsai/bonsai/bim/module/patch/prop.py +++ b/src/bonsai/bonsai/bim/module/patch/prop.py @@ -71,6 +71,15 @@ def get_ifcpatch_recipes(self: "BIMPatchProperties", context: bpy.types.Context) def update_ifc_patch_recipe(self: "BIMPatchProperties", context: bpy.types.Context) -> None: bpy.ops.bim.update_ifc_patch_arguments(recipe=self.ifc_patch_recipes) + # Blender's script.execute_preset mutates the menu class's bl_label to + # the loaded preset's display name (used as a "currently selected" + # indicator). The label persists across recipe changes — making the new + # recipe's menu falsely show the previous recipe's preset name. Reset + # the label to the menu's canonical title so it always matches the + # active recipe's preset list. + menu_cls = getattr(bpy.types, "BIM_MT_ifc_patch_presets", None) + if menu_cls is not None: + menu_cls.bl_label = "IFC Patch Presets" class BIMPatchProperties(PropertyGroup): diff --git a/src/bonsai/bonsai/bim/module/patch/ui.py b/src/bonsai/bonsai/bim/module/patch/ui.py index c3101b4705..98c262bb1b 100644 --- a/src/bonsai/bonsai/bim/module/patch/ui.py +++ b/src/bonsai/bonsai/bim/module/patch/ui.py @@ -29,6 +29,20 @@ if TYPE_CHECKING: from bonsai.bim.prop import Attribute +class BIM_MT_ifc_patch_presets(bpy.types.Menu): + """Lists ifc-patch presets for the currently selected recipe. + + ``preset_subdir`` is resolved per draw so switching recipes swaps the + preset list without re-registering the menu.""" + + bl_label = "IFC Patch Presets" + preset_operator = "script.execute_preset" + + def draw(self, context: bpy.types.Context) -> None: + self.preset_subdir = tool.Patch.get_preset_subdir() + bpy.types.Menu.draw_preset(self, context) + + class BIM_PT_patch(bpy.types.Panel): bl_label = "Patch" bl_idname = "BIM_PT_patch" @@ -66,6 +80,11 @@ class BIM_PT_patch(bpy.types.Panel): row.operator("bim.patch_query_from_selected", text="", icon="EYEDROPPER") if props.ifc_patch_args_attr: + preset_row = layout.row(heading="Preset", align=True) + preset_row.menu("BIM_MT_ifc_patch_presets", text=BIM_MT_ifc_patch_presets.bl_label) + preset_row.operator("bim.add_ifc_patch_preset", text="", icon="ADD") + preset_row.operator("bim.add_ifc_patch_preset", text="", icon="REMOVE").remove_active = True + draw_callback = draw_callback_ if props.ifc_patch_recipes == "ExtractElements" else None draw_attributes(props.ifc_patch_args_attr, layout, callback=draw_callback) diff --git a/src/bonsai/bonsai/tool/patch.py b/src/bonsai/bonsai/tool/patch.py index 6ae06b5e10..6d8ca11939 100644 --- a/src/bonsai/bonsai/tool/patch.py +++ b/src/bonsai/bonsai/tool/patch.py @@ -18,18 +18,33 @@ from __future__ import annotations +import re from typing import TYPE_CHECKING, Any import bpy import ifcopenshell +import ifcopenshell.util.schema import ifcpatch import bonsai.core.tool +import bonsai.tool if TYPE_CHECKING: from bonsai.bim.module.patch.prop import BIMPatchProperties +# Lower index = older schema. Used to detect downgrades vs upgrades. +_SCHEMA_AGE = {"IFC2X3": 0, "IFC4": 1, "IFC4X3": 2} + +# Pretty-printed argument name for the ``Migrate`` recipe's schema parameter +# (see UpdateIfcPatchArguments.pretty_arg_name in bim/module/patch/operator.py). +_MIGRATE_SCHEMA_ARG_NAME = "Schema" + +# Match a STEP-encoded FILE_SCHEMA header: ``FILE_SCHEMA(('IFC4'));`` and the +# IFC4X3_ADD2 / IFC2X3_TC1 variants. Captures the bare schema identifier. +_IFC_FILE_SCHEMA_RE = re.compile(r"FILE_SCHEMA\s*\(\s*\(\s*'([^']+)'", re.IGNORECASE) + + class Patch(bonsai.core.tool.Patch): @classmethod def get_patch_props(cls) -> BIMPatchProperties: @@ -54,6 +69,63 @@ class Patch(bonsai.core.tool.Patch): "SplitByBuildingStorey", ) + @classmethod + def get_preset_subdir(cls) -> str: + """Resolve the preset subdirectory for the currently selected recipe. + + Returns a stable string for the ``-`` placeholder so the menu and save + operator remain usable when no real recipe has been picked yet.""" + recipe = cls.get_patch_props().ifc_patch_recipes or "-" + return f"bonsai/ifc_patch/{recipe}" + + @classmethod + def migration_is_lossy_downgrade(cls) -> bool: + """``True`` when the currently configured patch is the ``Migrate`` + recipe targeting an older schema than the input file. Used to gate + the destructive-migration confirmation dialog.""" + props = cls.get_patch_props() + if props.ifc_patch_recipes != "Migrate": + return False + target_schema = next( + (arg.get_value() for arg in props.ifc_patch_args_attr if arg.name == _MIGRATE_SCHEMA_ARG_NAME), + None, + ) + if not target_schema: + return False + source_schema = cls._patch_source_schema() + if not source_schema: + return False + return _SCHEMA_AGE.get(target_schema, -1) < _SCHEMA_AGE.get(source_schema, -1) + + @classmethod + def _patch_source_schema(cls) -> str: + """Resolve the IFC schema of the configured input without parsing the + full file. For loaded-from-memory the schema is in the entity_instance + wrapper; for disk paths we read only the STEP file header (first ~2KB) + rather than ``ifcopenshell.open`` which parses the whole file.""" + props = cls.get_patch_props() + if props.should_load_from_memory: + ifc_file = bonsai.tool.Ifc.get() + return ifc_file.schema if ifc_file else "" + if not props.ifc_patch_input: + return "" + try: + with open(props.ifc_patch_input, "rb") as f: + header = f.read(2048).decode("utf-8", errors="ignore") + except OSError: + return "" + match = _IFC_FILE_SCHEMA_RE.search(header) + if not match: + return "" + # Collapse IFC4X3_ADD2 / IFC2X3_TC1 / IFC4_ADD2 / IFC4X1 etc. to their + # base via the canonical normaliser — handles longest-prefix-first + # ordering correctly (IFC4X3 before IFC4) so we don't misclassify + # IFC4X3 files as IFC4. + try: + return ifcopenshell.util.schema.get_fallback_schema(match.group(1).upper()) + except AssertionError: + return "" + @classmethod def post_process_patch_arguments(cls, recipe: str, args: list[Any]) -> list[Any]: if recipe == "ExtractElements": diff --git a/src/bonsai/test/bim/module/patch/__init__.py b/src/bonsai/test/bim/module/patch/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/bonsai/test/bim/module/patch/test_execute_downgrade_e2e.py b/src/bonsai/test/bim/module/patch/test_execute_downgrade_e2e.py new file mode 100644 index 0000000000..aa9600a045 --- /dev/null +++ b/src/bonsai/test/bim/module/patch/test_execute_downgrade_e2e.py @@ -0,0 +1,70 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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. + +import tempfile +from pathlib import Path + +import bpy +import ifcopenshell +import pytest + +import bonsai.tool as tool +from test.bim.bootstrap import NewFile + +pytestmark = pytest.mark.patch + + +class TestExecuteIfcPatchDowngradeEndToEnd(NewFile): + """Drives the full panel flow the user sees: load IFC4 in memory, pick + Migrate + IFC2X3, click Execute, get an IFC2X3 file on disk with the + expected IfcBuildingElementProxy fallback + ObjectType encoding. + + A regression here means a real user clicking Execute either crashes + Blender, produces a broken file, or silently drops type information + that the recipe is supposed to preserve via ObjectType.""" + + def test_ifc4_with_ifclamp_downgrades_to_ifc2x3_with_proxy_and_object_type(self): + ifc = ifcopenshell.file(schema="IFC4") + ifc.create_entity("IfcLamp", GlobalId="2K6Z3DR8X37AS9XFvX8GcW", PredefinedType="COMPACTFLUORESCENT") + tool.Ifc.set(ifc) + + props = tool.Patch.get_patch_props() + props.should_load_from_memory = True + props.ifc_patch_recipes = "Migrate" + next(a for a in props.ifc_patch_args_attr if a.name == "Schema").enum_value = "IFC2X3" + + with tempfile.TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / "downgraded.ifc" + props.ifc_patch_output = str(output_path) + + result = bpy.ops.bim.execute_ifc_patch() + + assert result == {"FINISHED"} + assert output_path.exists(), "Recipe ran but no output file was written" + + written = ifcopenshell.open(str(output_path)) + assert written.schema == "IFC2X3" + proxies = written.by_type("IfcBuildingElementProxy") + assert len(proxies) == 1, "IfcLamp should fall back to a single IfcBuildingElementProxy" + assert proxies[0].ObjectType == "IfcLamp/COMPACTFLUORESCENT", ( + "Original class + PredefinedType must be encoded into ObjectType " + "so the downgrade isn't a total information loss" + ) + assert proxies[0].GlobalId == "2K6Z3DR8X37AS9XFvX8GcW" diff --git a/src/bonsai/test/bim/module/patch/test_lossy_downgrade.py b/src/bonsai/test/bim/module/patch/test_lossy_downgrade.py new file mode 100644 index 0000000000..03150ac7f7 --- /dev/null +++ b/src/bonsai/test/bim/module/patch/test_lossy_downgrade.py @@ -0,0 +1,131 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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. + +import tempfile +from pathlib import Path + +import bpy +import ifcopenshell +import pytest + +import bonsai.tool as tool +from test.bim.bootstrap import NewFile + +pytestmark = pytest.mark.patch + + +def _set_patch_state(*, recipe: str, target_schema: str | None, source_ifc: ifcopenshell.file | None = None) -> None: + """Drive the BIMPatchProperties into the configuration that a user produces + by picking Recipe + Schema in the panel + checking "Load from memory". + Setting the recipe fires UpdateIfcPatchArguments which builds the dynamic + args collection — only then can we assign the schema arg's enum_value.""" + props = tool.Patch.get_patch_props() + if source_ifc is not None: + tool.Ifc.set(source_ifc) + props.should_load_from_memory = True + props.ifc_patch_recipes = recipe # update callback builds ifc_patch_args_attr + if target_schema is not None: + schema_arg = next(a for a in props.ifc_patch_args_attr if a.name == "Schema") + schema_arg.enum_value = target_schema + + +class TestMigrationIsLossyDowngrade(NewFile): + """Pins the predicate that gates ``ExecuteIfcPatch.invoke``'s + confirmation popup. Every row of the truth table corresponds to a real + user-facing flow — wrong answers either nag the user on safe migrations + or silently let lossy ones through with no warning.""" + + def test_ifc4_to_ifc2x3_in_memory_is_lossy(self): + ifc = ifcopenshell.file(schema="IFC4") + _set_patch_state(recipe="Migrate", target_schema="IFC2X3", source_ifc=ifc) + assert tool.Patch.migration_is_lossy_downgrade() is True + + def test_ifc4x3_to_ifc2x3_in_memory_is_lossy(self): + # Regression for the gate that originally only fired for self.file.schema == "IFC4", + # silently leaving IFC4X3 sources crashing on IFC4-only geometry. + ifc = ifcopenshell.file(schema="IFC4X3") + _set_patch_state(recipe="Migrate", target_schema="IFC2X3", source_ifc=ifc) + assert tool.Patch.migration_is_lossy_downgrade() is True + + def test_ifc2x3_to_ifc4_upgrade_is_not_lossy(self): + ifc = ifcopenshell.file(schema="IFC2X3") + _set_patch_state(recipe="Migrate", target_schema="IFC4", source_ifc=ifc) + assert tool.Patch.migration_is_lossy_downgrade() is False + + def test_ifc4_to_ifc4_same_schema_is_not_lossy(self): + ifc = ifcopenshell.file(schema="IFC4") + _set_patch_state(recipe="Migrate", target_schema="IFC4", source_ifc=ifc) + assert tool.Patch.migration_is_lossy_downgrade() is False + + def test_non_migrate_recipe_is_not_lossy(self): + # The popup only ever applies to the Migrate recipe — other recipes + # (ExtractElements, TessellateElements, …) handle their own warnings. + ifc = ifcopenshell.file(schema="IFC4") + _set_patch_state(recipe="ExtractElements", target_schema=None, source_ifc=ifc) + assert tool.Patch.migration_is_lossy_downgrade() is False + + def test_no_source_set_is_not_lossy(self): + # Without an input file or in-memory IFC, the predicate cannot tell + # what the source schema is — defaults to False so the popup doesn't + # block harmless cases where the user is still configuring the panel. + props = tool.Patch.get_patch_props() + props.ifc_patch_recipes = "Migrate" + schema_arg = next(a for a in props.ifc_patch_args_attr if a.name == "Schema") + schema_arg.enum_value = "IFC2X3" + assert tool.Patch.migration_is_lossy_downgrade() is False + + +class TestPatchSourceSchemaSniff(NewFile): + """End-to-end pin on the header-only schema parsing. The IFC4X3 misdetection + bug originally lived in this code path — a raw startswith(\"IFC4\") loop + matching IFC4X3_ADD2 before the IFC4X3 base check was reached.""" + + def test_in_memory_ifc4x3_source_resolves_to_ifc4x3(self): + ifc = ifcopenshell.file(schema="IFC4X3") + tool.Ifc.set(ifc) + props = tool.Patch.get_patch_props() + props.should_load_from_memory = True + assert tool.Patch._patch_source_schema() == "IFC4X3" + + def test_file_path_ifc4x3_add2_source_resolves_to_ifc4x3(self): + # Writes a real .ifc file with IFC4X3_ADD2 in the FILE_SCHEMA header + # and confirms the regex + get_fallback_schema normaliser correctly + # collapse it to IFC4X3, not IFC4. + with tempfile.TemporaryDirectory() as tmpdir: + ifc_path = Path(tmpdir) / "sample.ifc" + ifc_path.write_text( + "ISO-10303-21;\n" + "HEADER;\n" + "FILE_DESCRIPTION((''),'2;1');\n" + "FILE_NAME('','2026',(''),(''),'','','');\n" + "FILE_SCHEMA(('IFC4X3_ADD2'));\n" + "ENDSEC;\n" + "DATA;\nENDSEC;\nEND-ISO-10303-21;\n" + ) + props = tool.Patch.get_patch_props() + props.should_load_from_memory = False + props.ifc_patch_input = str(ifc_path) + assert tool.Patch._patch_source_schema() == "IFC4X3" + + def test_missing_input_returns_empty_string(self): + props = tool.Patch.get_patch_props() + props.should_load_from_memory = False + props.ifc_patch_input = "" + assert tool.Patch._patch_source_schema() == "" diff --git a/src/bonsai/test/bim/module/patch/test_preset_label_reset.py b/src/bonsai/test/bim/module/patch/test_preset_label_reset.py new file mode 100644 index 0000000000..3b900e8290 --- /dev/null +++ b/src/bonsai/test/bim/module/patch/test_preset_label_reset.py @@ -0,0 +1,55 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# 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. + +import bpy +import pytest + +import bonsai.tool as tool +from test.bim.bootstrap import NewFile + +pytestmark = pytest.mark.patch + + +class TestPresetMenuLabelResetsOnRecipeChange(NewFile): + """Blender's ``script.execute_preset`` mutates the menu class's bl_label + to the loaded preset's display name as a "currently-selected" indicator. + Without a recipe-change callback, that label persists into the next + recipe's menu — falsely advertising a preset that belongs to a + different recipe's subdir and isn't selectable from the new menu.""" + + def test_changing_recipe_restores_canonical_label(self): + # Simulate the state Blender leaves after the user picked a preset + # for the previous recipe. + menu_cls = bpy.types.BIM_MT_ifc_patch_presets + menu_cls.bl_label = "Structural" + + # Switching the recipe must fire update_ifc_patch_recipe, which + # resets the menu label. + props = tool.Patch.get_patch_props() + props.ifc_patch_recipes = "Migrate" + + assert menu_cls.bl_label == "IFC Patch Presets" + + def test_canonical_label_is_used_when_no_preset_was_loaded(self): + # Fresh state — label is the bl_label-default from the class declaration. + menu_cls = bpy.types.BIM_MT_ifc_patch_presets + props = tool.Patch.get_patch_props() + props.ifc_patch_recipes = "ExtractElements" + assert menu_cls.bl_label == "IFC Patch Presets" From 6fa984ce2bdb1cd5e54d0ea1f41d70ee0875e376 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 23 Jun 2026 14:16:33 +0200 Subject: [PATCH 09/10] Fix MEP pair-disconnect crash and bend re-edit pen icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three user-facing fixes for the MEP-system gizmo surface: 1. MEP pair-disconnect no longer crashes Blender. The MEPSystemPathDecorator cached entity_instance references in _cached_walk; deleting a bridging fitting via the gizmo left a freed SWIG handle in the list, and the next _build_geometry pass segfaulted on .is_a. The cache now stores STEP integer ids and re-resolves via ifc_file.by_id on each draw, plus folds tool.Parametric.get_geom_generation into the cache key — ifcopenshell.api mutations invalidate before the next frame regardless of how the deletion was routed. 2. Bend re-edit pen icon stays reachable. The bend creation path tessellates the swept-disk body (upstream geometry-kernel workaround), so tool.System.has_parametric_body correctly returns False for a freshly-committed bend. _active_is_bend_fitting and GizmoMEPActions.is_eligible_object now fall back to the type's BBIM_Fitting pset — the same source bim.enable_bend_preview_from_bend reads parameters from — keeping the pen icon eligible. 3. MEP pair / per-port unjoin icons unified through bim.disconnect_elements. The MEP gizmo group's three unjoin icons (pair, start, end) now share the wall-disconnect surface: same VIEW3D_GT_wall_link_toggle icon, same bim.disconnect_elements operator. tool.Connection.find_rels learned a new "mep-pair-fitting" kind that returns the bridging fitting as the disconnect target; core.connection.disconnect_rel grew the matching dispatch arm. The old MEPUnjoinAtPort and MEPUnjoinPair operators are removed. Also registered wall.GizmoPairDisconnect (previously declared but never in the classes tuple, so dead code) for the wall+slab pair-disconnect surface, and extracted MEP port-topology helpers (find_bridging_fitting, is_disconnectable_fitting, neighbours_at_ports) onto tool.System so the canonical walk has a single home. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/model/__init__.py | 3 +- .../bonsai/bim/module/model/decorator.py | 42 +++- src/bonsai/bonsai/bim/module/model/mep.py | 209 +++++++----------- src/bonsai/bonsai/core/connection.py | 30 ++- src/bonsai/bonsai/tool/connection.py | 48 ++-- src/bonsai/bonsai/tool/system.py | 63 ++++++ .../test_connected_network_path_decorator.py | 142 ++++++++++++ .../module/model/test_disconnect_elements.py | 139 ++++++++++++ .../module/model/test_mep_actions_cache.py | 18 +- .../model/test_mep_actions_visibility.py | 179 ++++++++++++--- .../model/test_mep_disconnect_integration.py | 139 ++++++++++++ .../module/model/test_mep_port_operators.py | 169 -------------- src/bonsai/test/core/test_connection.py | 192 +++++++++++++--- src/bonsai/test/tool/test_system.py | 127 +++++++++++ 14 files changed, 1102 insertions(+), 398 deletions(-) create mode 100644 src/bonsai/test/bim/module/model/test_mep_disconnect_integration.py diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index d602c715a3..3bfb1accea 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -110,6 +110,7 @@ classes = ( wall.GizmoWallFilletPreview, wall.GizmoWallFilletReedit, wall.GizmoWallFilletToggleOpenings, + wall.GizmoPairDisconnect, wall.GizmoSlabEdition, wall.GizmoSlabUnjoinWalls, wall.GizmoWallJoinIntersection, @@ -279,9 +280,7 @@ classes = ( mep.MEPAddObstruction, mep.MEPAddTransition, mep.MEPAddBend, - mep.MEPUnjoinAtPort, mep.MEPRemoveTerminalFitting, - mep.MEPUnjoinPair, mep.SelectMEPPathMembers, mep.MEPJoinSegments, mep_bend_preview.EnableBendPreview, diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index 56b0df3bbe..8ec53af5a8 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -2569,14 +2569,20 @@ class _ConnectedNetworkPathDecorator(tool.Blender.ViewportDecorator): 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 + # Walk cache keyed on (start_guid, ifc_file, geom_gen). Stores STEP + # integer ids rather than ``entity_instance`` references — re-resolved + # via ``ifc_file.by_id`` on each cache hit. Structurally rules out + # the dangling-SWIG-handle class of bug: an entity removed between + # frames either bumps geom_gen (cache miss → re-walk) or fails to + # re-resolve (handled below by re-walking). 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._cached_geom_gen: int = -1 + self._cached_walk_ids: list[int] = [] + # Geometry cache: shared TokenCache. Key folds in geom_gen so IFC + # mutations that don't surface via the depsgraph still flush the + # resolved world-space lines and dots. self._geom_cache: TokenCache[ tuple[ list[tuple[tuple[float, float, float], tuple[float, float, float]]], @@ -2739,9 +2745,22 @@ class _ConnectedNetworkPathDecorator(tool.Blender.ViewportDecorator): 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: + current_geom_gen = tool.Parametric.get_geom_generation() + connected: list[Any] | None = None + if ( + start_guid == self._cached_start_guid + and ifc_file is self._cached_ifc_file + and current_geom_gen == self._cached_geom_gen + and self._cached_walk_ids + ): + try: + connected = [ifc_file.by_id(eid) for eid in self._cached_walk_ids] + except RuntimeError: + # An entity was removed without bumping geom_gen — rare but + # possible from non-operator code paths. Force a re-walk + # rather than feeding a stale handle to _build_geometry. + connected = None + if connected is None: try: connected = self._walk(start_element) except Exception: @@ -2750,12 +2769,13 @@ class _ConnectedNetworkPathDecorator(tool.Blender.ViewportDecorator): traceback.print_exc() self._walk_failure_logged = True - self._cached_walk = [] + self._cached_walk_ids = [] self._failed_seed_guid = start_guid return self._cached_start_guid = start_guid self._cached_ifc_file = ifc_file - self._cached_walk = connected + self._cached_geom_gen = current_geom_gen + self._cached_walk_ids = [e.id() for e in connected] if not connected: return @@ -2770,7 +2790,7 @@ class _ConnectedNetworkPathDecorator(tool.Blender.ViewportDecorator): try: lines, free_points, connection_points = self._geom_cache.get_or_compute( - (start_guid, id(ifc_file)), + (start_guid, id(ifc_file), current_geom_gen), lambda: self._build_geometry(connected), ) except Exception: diff --git a/src/bonsai/bonsai/bim/module/model/mep.py b/src/bonsai/bonsai/bim/module/model/mep.py index dea4129e7d..26a5a53de0 100644 --- a/src/bonsai/bonsai/bim/module/model/mep.py +++ b/src/bonsai/bonsai/bim/module/model/mep.py @@ -693,27 +693,6 @@ def get_connected_element_at_segment_port(segment, at_segment_start): return tool.System.get_port_relating_element(connected_port) -def find_fitting_between_segments(segment_a, segment_b): - """Single IfcFlowFitting bridging segment_a and segment_b via ports, or - ``None`` if no fitting (or multiple fittings — only direct one-fitting - joins handled).""" - if not (segment_a.is_a("IfcFlowSegment") and segment_b.is_a("IfcFlowSegment")): - return None - b_ports_set = set(tool.System.get_ports(segment_b)) - for a_port in tool.System.get_ports(segment_a): - connected_port = tool.System.get_connected_port(a_port) - if connected_port is None: - continue - fitting = tool.System.get_port_relating_element(connected_port) - if fitting is None or not fitting.is_a("IfcFlowFitting"): - continue - for fitting_port in tool.System.get_ports(fitting): - other_port = tool.System.get_connected_port(fitting_port) - if other_port is not None and other_port in b_ports_set: - return fitting - return None - - def _resolve_active_mep_segment(operator, context): """Return the operator's target ``IfcFlowSegment`` or ``None`` after reporting. @@ -808,52 +787,6 @@ class MEPAddObstruction(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} -class MEPUnjoinAtPort(bpy.types.Operator, tool.Ifc.Operator): - """Delete the IfcFlowFitting that bridges a segment's port to a second element. - - Used when the connection at the port is in the JOINED state (the fitting - has at least one other port connecting to a different element). The - segment isn't resized — only the bridging fitting is removed. Refuses - to act on an OBSTRUCTION fitting (those are routed through - ``bim.mep_add_obstruction`` with mode=REMOVE which extends the segment - to absorb the freed length).""" - - bl_idname = "bim.mep_unjoin_at_port" - bl_label = "Unjoin MEP Segment at Port" - bl_description = "Disconnect the segment from the fitting at the named port (deletes the fitting)" - bl_options = {"REGISTER", "UNDO"} - segment_id: bpy.props.IntProperty(name="Segment Element ID", default=0) - position: bpy.props.EnumProperty( - name="Port", - items=[ - ("START", "At Start", "Operate on the segment's start port"), - ("END", "At End", "Operate on the segment's end port"), - ], - default="END", - ) - - def _execute(self, context): - resolved = _require_port_state(self, context, PORT_JOINED, "joining") - if resolved is None: - return {"CANCELLED"} - element, at_segment_start = resolved - - fitting = get_connected_element_at_segment_port(element, at_segment_start) - if fitting is None or not fitting.is_a("IfcFlowFitting"): - self.report({"ERROR"}, "Connected port does not lead to a fitting.") - return {"CANCELLED"} - if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION": - self.report({"ERROR"}, "Obstruction fittings are removed via bim.mep_add_obstruction (mode=REMOVE).") - return {"CANCELLED"} - - fitting_obj = tool.Ifc.get_object(fitting) - if fitting_obj is None: - self.report({"ERROR"}, "Fitting has no Blender object.") - return {"CANCELLED"} - tool.Geometry.delete_ifc_object(fitting_obj) - return {"FINISHED"} - - class MEPRemoveTerminalFitting(bpy.types.Operator, tool.Ifc.Operator): """Remove the terminal fitting at a segment's named port. @@ -906,44 +839,6 @@ class MEPRemoveTerminalFitting(bpy.types.Operator, tool.Ifc.Operator): return {"FINISHED"} -class MEPUnjoinPair(bpy.types.Operator, tool.Ifc.Operator): - """Delete the IfcFlowFitting joining two selected MEP segments. - - Removes the fitting; segments are left in place for the user to reposition.""" - - bl_idname = "bim.mep_unjoin_pair" - bl_label = "Unjoin MEP Segments" - bl_description = "Delete the fitting joining the two selected MEP segments" - bl_options = {"REGISTER", "UNDO"} - - @classmethod - def poll(cls, context): - if not _n_mep_selected(2): - cls.poll_message_set("Select exactly 2 MEP segments joined by a fitting.") - return False - return True - - def _execute(self, context): - selected_objs = tool.Blender.get_selected_objects() - elements = [tool.Ifc.get_entity(o) for o in selected_objs] - if any(e is None or not e.is_a("IfcFlowSegment") for e in elements): - self.report({"ERROR"}, "Both selected objects must be MEP segments.") - return {"CANCELLED"} - fitting = find_fitting_between_segments(elements[0], elements[1]) - if fitting is None: - self.report({"ERROR"}, "No single fitting joins the selected segments.") - return {"CANCELLED"} - if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION": - self.report({"ERROR"}, "Obstruction fittings are removed via bim.mep_add_obstruction (mode=REMOVE).") - return {"CANCELLED"} - fitting_obj = tool.Ifc.get_object(fitting) - if fitting_obj is None: - self.report({"ERROR"}, "Fitting has no Blender object.") - return {"CANCELLED"} - tool.Geometry.delete_ifc_object(fitting_obj) - return {"FINISHED"} - - class SelectMEPPathMembers(bpy.types.Operator): """Replace the selection with every MEP element reachable from the active one via IfcRelConnectsPorts — the entire connected distribution network.""" @@ -2677,10 +2572,22 @@ def _active_mep_has_connected_neighbor(obj: bpy.types.Object) -> bool: def _active_is_bend_fitting(obj: bpy.types.Object) -> bool: + """True iff the active object is a parametric BEND fitting eligible for + the bend-preview re-edit path. Re-edit reads parameters from the type's + ``BBIM_Fitting`` pset, so that pset's presence is the ground truth for + re-editability — not the body representation class. The bend creation + path tessellates the swept-disk body as an upstream-geometry-kernel + workaround, so a freshly-committed bend's body contains only an + ``IfcTriangulatedFaceSet`` and ``has_parametric_body`` correctly + returns False for it; the pset gate is what keeps the pen icon + eligible.""" element = tool.Ifc.get_entity(obj) if not _is_bend_fitting(element): return False - return tool.System.has_parametric_body(element) + element_type = ifcopenshell.util.element.get_type(element) + if element_type is None: + return False + return ifcopenshell.util.element.get_pset(element_type, "BBIM_Fitting") is not None class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup): @@ -2763,20 +2670,20 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup): ), IconActionConfig( name="unjoin_start", - icon="VIEW3D_GT_unjoin", - operator="bim.mep_unjoin_at_port", + icon="VIEW3D_GT_wall_link_toggle", + operator="bim.disconnect_elements", visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj), ), IconActionConfig( name="unjoin_end", - icon="VIEW3D_GT_unjoin", - operator="bim.mep_unjoin_at_port", + icon="VIEW3D_GT_wall_link_toggle", + operator="bim.disconnect_elements", visibility_condition=lambda obj: _selection_size() == 1 and _active_is_flow_segment(obj), ), IconActionConfig( name="unjoin_pair", - icon="VIEW3D_GT_unjoin", - operator="bim.mep_unjoin_pair", + icon="VIEW3D_GT_wall_link_toggle", + operator="bim.disconnect_elements", visibility_condition=lambda _active: _n_mep_selected(2), ), ] @@ -2794,7 +2701,17 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup): element = tool.Ifc.get_entity(obj) if element is None or not tool.System.is_mep_element(element): return False - return tool.System.has_parametric_body(element) + if tool.System.has_parametric_body(element): + return True + # Bend fittings carry their parametric definition in the type's + # ``BBIM_Fitting`` pset because the bend creation path tessellates + # the swept-disk body (upstream geometry-kernel workaround), so + # ``has_parametric_body`` returns False for them. Fall back to the + # pset gate so the pen icon (re_edit_bend) stays reachable. + element_type = ifcopenshell.util.element.get_type(element) + if element_type is None: + return False + return ifcopenshell.util.element.get_pset(element_type, "BBIM_Fitting") is not None def setup(self, context: bpy.types.Context) -> None: super().setup(context) @@ -2802,11 +2719,13 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup): @classmethod def _wire_anchored_icon_targets(cls, group) -> None: - """Pre-fill ``position`` (and ``mode`` for open-lock) on each anchored - icon so a click dispatches to the right port without a per-frame - property write; apply the warning-red hover colour to destructive - icons. Takes any object with ``action__gizmo`` attributes so - tests can exercise the wiring without instantiating the GizmoGroup.""" + """Pre-fill ``position`` (and ``mode`` for open-lock) on the lock + icons so a click dispatches to the right port without a per-frame + property write, and pre-bind the unified ``bim.disconnect_elements`` + operator on each unjoin icon so :py:meth:`position_gizmos` only has + to update the two GUIDs per frame. Takes any object with + ``action__gizmo`` attributes so tests can exercise the wiring + without instantiating the GizmoGroup.""" for config_name, (_icon, position_arg) in cls.LOCK_ICON_CONFIGS.items(): gz = getattr(group, f"action_{config_name}_gizmo", None) if gz is None: @@ -2820,19 +2739,12 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup): op_props = gz.target_set_operator("bim.mep_remove_terminal_fitting") op_props.position = position_arg - for config_name, position_arg in (("unjoin_start", "START"), ("unjoin_end", "END")): - gz = getattr(group, f"action_{config_name}_gizmo", None) - if gz is None: - continue - op_props = gz.target_set_operator("bim.mep_unjoin_at_port") - op_props.position = position_arg - - warning_color = gizmo.get_warning_color_from_prefs(tool.Blender.get_addon_preferences()) + group.unjoin_op_props = {} for config_name in cls.UNJOIN_CONFIGS: gz = getattr(group, f"action_{config_name}_gizmo", None) if gz is None: continue - gz.color_highlight = warning_color + group.unjoin_op_props[config_name] = gz.target_set_operator("bim.disconnect_elements") def position_gizmos(self, context: bpy.types.Context) -> None: """Lay out icons across three regions: row above bbox top, segment @@ -2899,6 +2811,10 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup): if not visible: gz.hide = True continue + if config.name.startswith("unjoin_"): + if not self._bind_unjoin_at_port(config.name, obj, endpoint_kind == "START"): + gz.hide = True + continue if segment_endpoints is None: segment_endpoints = tool.Model.get_flow_segment_axis(obj) start_world, end_world = segment_endpoints @@ -2910,7 +2826,7 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup): if len(selected) == 2: elements = [tool.Ifc.get_entity(o) for o in selected] if all(e is not None and e.is_a("IfcFlowSegment") for e in elements): - pair_fitting = find_fitting_between_segments(elements[0], elements[1]) or False + pair_fitting = tool.System.find_bridging_fitting(elements[0], elements[1]) or False else: pair_fitting = False else: @@ -2922,6 +2838,13 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup): gz.hide = True continue + if config.name == "unjoin_pair": + selected = tool.Blender.get_selected_objects() + pair_elements = [tool.Ifc.get_entity(o) for o in selected] + if not self._bind_unjoin_pair(pair_elements): + gz.hide = True + continue + if not bend_anchor_attempted: bend_anchor = compute_mep_join_location() bend_anchor_attempted = True @@ -2950,3 +2873,33 @@ class GizmoMEPActions(bpy.types.GizmoGroup, gizmo.BaseIconActionGroup): if name in self.ENDPOINT_CONFIGS: return self.ICON_SCALE * self.ENDPOINT_SCALE_RATIO return self.ICON_SCALE + + def _bind_unjoin_at_port(self, config_name: str, segment_obj: bpy.types.Object, at_segment_start: bool) -> bool: + """Resolve the fitting at the named port and bind both GUIDs on the + pre-wired ``bim.disconnect_elements`` op_props. Returns False when + the partner is unresolvable (port not joined to a disconnectable + fitting), and the caller hides the icon.""" + element = tool.Ifc.get_entity(segment_obj) + if element is None: + return False + fitting = get_connected_element_at_segment_port(element, at_segment_start) + if fitting is None or not fitting.is_a("IfcFlowFitting"): + return False + if getattr(fitting, "PredefinedType", None) == "OBSTRUCTION": + return False + op_props = self.unjoin_op_props[config_name] + op_props.element_a_guid = element.GlobalId + op_props.element_b_guid = fitting.GlobalId + return True + + def _bind_unjoin_pair(self, pair_elements: list[ifcopenshell.entity_instance | None]) -> bool: + """Bind both segment GUIDs on the pair-disconnect icon's pre-wired + ``bim.disconnect_elements`` op_props. Returns False when either side + is missing a GlobalId (e.g. selection lost an active object), and + the caller hides the icon.""" + if len(pair_elements) != 2 or any(e is None for e in pair_elements): + return False + op_props = self.unjoin_op_props["unjoin_pair"] + op_props.element_a_guid = pair_elements[0].GlobalId + op_props.element_b_guid = pair_elements[1].GlobalId + return True diff --git a/src/bonsai/bonsai/core/connection.py b/src/bonsai/bonsai/core/connection.py index 71a2bb871c..8b5d80d898 100644 --- a/src/bonsai/bonsai/core/connection.py +++ b/src/bonsai/bonsai/core/connection.py @@ -27,6 +27,12 @@ disconnect-on-delete). Each rel kind returned by :py:meth:`find_rels_for_element` maps to a single arm here, so adding a new rel kind means extending one dispatch table — both call sites benefit automatically and the AST forward-compat guard enforces coverage. + +For the ``"mep-pair-fitting"`` kind the ``rel`` slot carries the +``IfcFlowFitting`` itself rather than a relationship entity — the subject +whose removal disconnects the pair. The dispatch treats the slot as the +deletion target and routes through ``geometry.delete_ifc_object`` so the +cascade-on-delete contract still owns port-rel cleanup. """ from __future__ import annotations @@ -37,21 +43,20 @@ import bonsai.core.geometry from bonsai.core.model import regenerate_wall_to_underside if TYPE_CHECKING: - import bpy import ifcopenshell import bonsai.tool as tool def disconnect_rel( - ifc: "type[tool.Ifc]", - geometry: "type[tool.Geometry]", - model: "type[tool.Model]", - connection: "type[tool.Connection]", - rel: "ifcopenshell.entity_instance", + ifc: type[tool.Ifc], + geometry: type[tool.Geometry], + model: type[tool.Model], + connection: type[tool.Connection], + rel: ifcopenshell.entity_instance, kind: str, - elem: "ifcopenshell.entity_instance", - partner: "ifcopenshell.entity_instance", + elem: ifcopenshell.entity_instance, + partner: ifcopenshell.entity_instance, skip_elem_recreate: bool = False, skip_partner_recreate: bool = False, ) -> None: @@ -95,5 +100,14 @@ def disconnect_rel( relating_element=rel.RelatingElement, related_element=rel.RelatedElement, ) + elif kind == "mep-pair-fitting": + fitting = rel # slot semantics: the fitting whose removal disconnects the pair. + if skip_elem_recreate and fitting is elem: + return + if skip_partner_recreate and fitting is partner: + return + fitting_obj = ifc.get_object(fitting) + if fitting_obj is not None: + geometry.delete_ifc_object(fitting_obj) else: raise ValueError(f"Unknown rel kind: {kind!r}") diff --git a/src/bonsai/bonsai/tool/connection.py b/src/bonsai/bonsai/tool/connection.py index 4ec154b573..709f277cc5 100644 --- a/src/bonsai/bonsai/tool/connection.py +++ b/src/bonsai/bonsai/tool/connection.py @@ -29,6 +29,11 @@ operator dispatch the right post-disconnect cleanup: - ``"element-top"`` for ``IfcRelConnectsElements`` with ``Description=="TOP"`` (the rel kind ``extend_walls_to_underside`` creates) - ``"element"`` for any other ``IfcRelConnectsElements`` +- ``"mep-pair-fitting"`` for an MEP pair whose disconnect is effected by + removing a bridging ``IfcFlowFitting``. The ``rel`` slot for this kind + carries the fitting entity itself (not a relationship entity) — the + subject whose removal disconnects the pair. ``OBSTRUCTION`` fittings are + excluded; those are removed via ``bim.mep_add_obstruction(mode=REMOVE)``. Add new rel kinds by extending :py:meth:`Connection.find_rel`. The disconnect operator's cleanup switch maps each kind to the right post-mutation calls.""" @@ -37,6 +42,8 @@ from __future__ import annotations from typing import TYPE_CHECKING +import bonsai.tool as tool + if TYPE_CHECKING: import ifcopenshell @@ -45,9 +52,9 @@ class Connection: @classmethod def find_rels( cls, - elem_a: "ifcopenshell.entity_instance", - elem_b: "ifcopenshell.entity_instance", - ) -> "list[tuple[ifcopenshell.entity_instance, str]]": + elem_a: ifcopenshell.entity_instance, + elem_b: ifcopenshell.entity_instance, + ) -> list[tuple[ifcopenshell.entity_instance, str]]: """Return every supported rel linking ``elem_a`` to ``elem_b`` as a list of ``(rel, kind)`` tuples. Walks both ``ConnectedTo`` and ``ConnectedFrom`` because either side of the rel can be the relating @@ -79,14 +86,18 @@ class Connection: kind = "element-top" if getattr(rel, "Description", None) == "TOP" else "element" _record(rel, kind) + fitting = tool.System.find_bridging_fitting(elem_a, elem_b) + if fitting is not None: + _record(fitting, "mep-pair-fitting") + return rels @classmethod def find_rel( cls, - elem_a: "ifcopenshell.entity_instance", - elem_b: "ifcopenshell.entity_instance", - ) -> "tuple[ifcopenshell.entity_instance | None, str | None]": + elem_a: ifcopenshell.entity_instance, + elem_b: ifcopenshell.entity_instance, + ) -> tuple[ifcopenshell.entity_instance | None, str | None]: """Return the first ``(rel, kind)`` or ``(None, None)``. Cheaper than ``find_rels`` when callers only need to know whether a connection exists or what kind it is.""" @@ -96,17 +107,22 @@ class Connection: @classmethod def find_rels_for_element( cls, - elem: "ifcopenshell.entity_instance", - ) -> "list[tuple[ifcopenshell.entity_instance, str, ifcopenshell.entity_instance]]": + elem: ifcopenshell.entity_instance, + ) -> list[tuple[ifcopenshell.entity_instance, str, ifcopenshell.entity_instance]]: """Return every supported rel touching ``elem`` as ``(rel, kind, partner)`` triples. ``partner`` is the *other* element on the rel — the side cascade cleanup must operate on when ``elem`` is being deleted. - Mirrors :py:meth:`find_rels`'s kind taxonomy. The single-element entry - point lets the cascade-on-delete in ``tool.Geometry.delete_ifc_object`` - enumerate everything the disconnect operator would handle pairwise. + Mirrors :py:meth:`find_rels`'s relationship-kind taxonomy. Notably + does NOT emit ``"mep-pair-fitting"`` triples: ``IfcRelConnectsPorts`` + cleanup is owned by ``tool.Geometry.delete_ifc_object``'s + ``remove_port`` loop, which runs unconditionally on any IFC root + deletion. Including MEP here would cause the cascade to also remove + the bridging fitting when one of its connected segments is deleted — + a policy choice (fitting may still join other live segments) that's + better left to the user via the explicit disconnect operator. """ - result: list[tuple["ifcopenshell.entity_instance", str, "ifcopenshell.entity_instance"]] = [] + result: list[tuple[ifcopenshell.entity_instance, str, ifcopenshell.entity_instance]] = [] seen: set[int] = set() def _record(rel, kind, partner): @@ -133,10 +149,10 @@ class Connection: @classmethod def orient_element_top( cls, - rel: "ifcopenshell.entity_instance", - elem_a: "ifcopenshell.entity_instance", - elem_b: "ifcopenshell.entity_instance", - ) -> "tuple[ifcopenshell.entity_instance, ifcopenshell.entity_instance]": + rel: ifcopenshell.entity_instance, + elem_a: ifcopenshell.entity_instance, + elem_b: ifcopenshell.entity_instance, + ) -> tuple[ifcopenshell.entity_instance, ifcopenshell.entity_instance]: """Return ``(wall, slab)`` for an ``IfcRelConnectsElements(TOP)`` rel. The ``extend_walls_to_underside`` flow stores slab as the relating diff --git a/src/bonsai/bonsai/tool/system.py b/src/bonsai/bonsai/tool/system.py index 29b5223d9b..cf1ed3ab88 100644 --- a/src/bonsai/bonsai/tool/system.py +++ b/src/bonsai/bonsai/tool/system.py @@ -488,6 +488,69 @@ class System(bonsai.core.tool.System): def is_mep_element(cls, element: ifcopenshell.entity_instance) -> bool: return element.is_a("IfcFlowSegment") or element.is_a("IfcFlowFitting") + @classmethod + def is_disconnectable_fitting(cls, element: ifcopenshell.entity_instance) -> bool: + """A fitting whose deletion is the supported teardown for one of + its port connections. ``OBSTRUCTION`` fittings are excluded — they + have a dedicated grow/shrink flow (``bim.mep_add_obstruction`` + with ``mode=REMOVE``) that absorbs the freed segment length.""" + if not element.is_a("IfcFlowFitting"): + return False + return getattr(element, "PredefinedType", None) != "OBSTRUCTION" + + @classmethod + def neighbours_at_ports(cls, element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]: + """Entities reachable from ``element``'s ports via a single + ``IfcRelConnectsPorts`` hop, deduped by IFC id.""" + neighbours: list[ifcopenshell.entity_instance] = [] + seen: set[int] = set() + for port in cls.get_ports(element): + connected_port = cls.get_connected_port(port) + if connected_port is None: + continue + neighbour = ifcopenshell.util.system.get_port_element(connected_port) + if neighbour is None or neighbour.id() in seen: + continue + seen.add(neighbour.id()) + neighbours.append(neighbour) + return neighbours + + @classmethod + def find_bridging_fitting( + cls, + elem_a: ifcopenshell.entity_instance, + elem_b: ifcopenshell.entity_instance, + ) -> Union[ifcopenshell.entity_instance, None]: + """Return the disconnectable ``IfcFlowFitting`` whose removal + disconnects ``elem_a`` from ``elem_b``, or ``None``. + + Two topologies are handled. (1) Direct port-to-port between a + segment/fitting and a disconnectable fitting: the fitting endpoint + is returned. (2) Two segments joined by a single bridging + disconnectable fitting: the bridging fitting is returned. + ``OBSTRUCTION`` fittings short-circuit to ``None``.""" + if not (cls.is_mep_element(elem_a) and cls.is_mep_element(elem_b)): + return None + + a_neighbours = cls.neighbours_at_ports(elem_a) + b_neighbours = cls.neighbours_at_ports(elem_b) + elem_a_id = elem_a.id() + elem_b_id = elem_b.id() + + if cls.is_disconnectable_fitting(elem_a) and any(n.id() == elem_b_id for n in a_neighbours): + return elem_a + if cls.is_disconnectable_fitting(elem_b) and any(n.id() == elem_a_id for n in b_neighbours): + return elem_b + + a_fittings = [n for n in a_neighbours if cls.is_disconnectable_fitting(n)] + if not a_fittings: + return None + b_fitting_ids = {n.id() for n in b_neighbours if cls.is_disconnectable_fitting(n)} + for fitting in a_fittings: + if fitting.id() in b_fitting_ids: + return fitting + return None + @classmethod def has_parametric_body(cls, element: ifcopenshell.entity_instance) -> bool: """True when the MEP element's body representation is a profile sweep 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 index a956a8ecbe..0376fdabc5 100644 --- 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 @@ -63,6 +63,148 @@ def test_fully_overridden_subclass_is_accepted(): assert cls.__name__ == "DecoratorWithAllHooks" +# --------------------------------------------------------------------------- +# Cache invalidation — the load-bearing crash guard. +# +# Without geom-generation gating, the walk cache holds entity_instance +# references that outlive their backing IFC entities after an +# ifcopenshell.api mutation. The next _build_geometry pass calls .is_a +# on a freed SWIG handle and segfaults Blender. The gate must fire +# whenever tool.Parametric.get_geom_generation bumps — which is on +# every tool.Ifc.Operator commit (via refresh_post_commit), covering +# every disconnect path. + +from types import SimpleNamespace +from unittest.mock import Mock, patch + + +def _seed_cache(decorator, *, start_guid, ifc_file, geom_gen, walk_ids): + decorator._cached_start_guid = start_guid + decorator._cached_ifc_file = ifc_file + decorator._cached_geom_gen = geom_gen + decorator._cached_walk_ids = list(walk_ids) + + +def test_walk_cache_reuses_when_seed_file_and_geom_gen_unchanged(): + """Cache hit: same seed, same ifc_file, same geom_gen → reuse the + stored walk. Steady-state path while the IFC is idle.""" + cls = _build_subclass("DecoratorCacheReuse") + dec = cls() + + ifc_file = SimpleNamespace() + _seed_cache(dec, start_guid="GUID", ifc_file=ifc_file, geom_gen=5, walk_ids=[101, 102]) + + current_geom_gen = 5 + start_guid = "GUID" + hit = ( + start_guid == dec._cached_start_guid + and ifc_file is dec._cached_ifc_file + and current_geom_gen == dec._cached_geom_gen + and dec._cached_walk_ids + ) + assert hit, "Cache must hit when seed, file, and geom_gen are unchanged" + + +def test_walk_cache_invalidates_on_geom_generation_bump(): + """Cache must miss when geom_gen bumps so entities removed by an + ``ifcopenshell.api`` mutation never survive in the cached walk + list into the next draw pass.""" + cls = _build_subclass("DecoratorCacheGenInvalidates") + dec = cls() + + ifc_file = SimpleNamespace() + _seed_cache(dec, start_guid="GUID", ifc_file=ifc_file, geom_gen=5, walk_ids=[101]) + + current_geom_gen = 6 # IFC mutation has bumped the counter + start_guid = "GUID" + hit = ( + start_guid == dec._cached_start_guid + and ifc_file is dec._cached_ifc_file + and current_geom_gen == dec._cached_geom_gen + and dec._cached_walk_ids + ) + assert not hit, "Cache must miss when geom_gen bumps so the walk re-runs against live entities" + + +def test_walk_cache_invalidates_on_seed_change(): + """Selecting a different network seed forces a re-walk even if + geom_gen is unchanged.""" + cls = _build_subclass("DecoratorCacheSeedChange") + dec = cls() + + ifc_file = SimpleNamespace() + _seed_cache(dec, start_guid="OLD-GUID", ifc_file=ifc_file, geom_gen=5, walk_ids=[101]) + + hit = ( + "NEW-GUID" == dec._cached_start_guid + and ifc_file is dec._cached_ifc_file + and 5 == dec._cached_geom_gen + and dec._cached_walk_ids + ) + assert not hit + + +def test_walk_cache_invalidates_on_ifc_file_swap(): + """Loading a different IFC file must invalidate even if the new + seed happens to share the GUID (different IfcOpenShell file + objects → different identity).""" + cls = _build_subclass("DecoratorCacheFileSwap") + dec = cls() + + old_file = SimpleNamespace() + new_file = SimpleNamespace() + _seed_cache(dec, start_guid="GUID", ifc_file=old_file, geom_gen=5, walk_ids=[101]) + + hit = ( + "GUID" == dec._cached_start_guid + and new_file is dec._cached_ifc_file + and 5 == dec._cached_geom_gen + and dec._cached_walk_ids + ) + assert not hit + + +def test_walk_cache_stores_ids_not_entity_references(): + """Structural safety: the cache stores STEP integer ids, not raw + ``entity_instance`` references — re-resolved via ``ifc_file.by_id`` + on each cache hit. Eliminates the dangling-SWIG-handle class entirely: + even if geom_gen mistakenly fails to bump, a deleted entity's id won't + resolve, the cache-hit branch returns ``None``, and the next draw + re-walks against live entities.""" + cls = _build_subclass("DecoratorCacheStoresIds") + dec = cls() + _seed_cache(dec, start_guid="GUID", ifc_file=SimpleNamespace(), geom_gen=5, walk_ids=[42]) + assert dec._cached_walk_ids == [42] + assert all(isinstance(eid, int) for eid in dec._cached_walk_ids) + + +def test_geom_cache_key_includes_geom_generation(): + """``TokenCache.get_or_compute`` keys that include geom_gen flush + the cached world-space geometry on IFC mutations the depsgraph + token doesn't observe — without that key component, a re-walk + would feed a fresh list to the lambda while the cache still + returned the prior result.""" + import bonsai.bim.decorator_cache as decorator_cache + from bonsai.bim.module.model.decorator import MEPSystemPathDecorator + + decorator_cache.reset_for_test() + dec = MEPSystemPathDecorator() + + builds: list[int] = [] + + def _build(): + builds.append(1) + return ([], [], []) + + ifc_file = SimpleNamespace() + dec._geom_cache.get_or_compute(("GUID", id(ifc_file), 1), _build) + dec._geom_cache.get_or_compute(("GUID", id(ifc_file), 1), _build) + assert len(builds) == 1, "Same key (same gen) should reuse the cached value" + + dec._geom_cache.get_or_compute(("GUID", id(ifc_file), 2), _build) + assert len(builds) == 2, "Bumping geom_gen in the key must invalidate the cached value" + + # --------------------------------------------------------------------------- # Pure-geometry classifier contract. # diff --git a/src/bonsai/test/bim/module/model/test_disconnect_elements.py b/src/bonsai/test/bim/module/model/test_disconnect_elements.py index da668e604a..e39bf8af03 100644 --- a/src/bonsai/test/bim/module/model/test_disconnect_elements.py +++ b/src/bonsai/test/bim/module/model/test_disconnect_elements.py @@ -47,6 +47,11 @@ def _rel(klass: str, *, relating=None, related=None, description=None, rel_id: i def _elem(*, connected_to=(), connected_from=()): e = Mock() + # Default is_a to False so the MEP-pair-fitting branch of find_rels + # (which calls ``elem.is_a("IfcFlowSegment")``) early-outs on the + # generic _elem stubs used by the wall-side dispatch tests. Test + # cases that want is_a("IfcWall")-True explicitly override e.is_a. + e.is_a = lambda _c: False e.ConnectedTo = list(connected_to) e.ConnectedFrom = list(connected_from) e.GlobalId = "GUID" @@ -167,6 +172,140 @@ def test_find_rels_for_element_skips_rels_without_partner(): assert tool.Connection.find_rels_for_element(elem) == [] +# --------------------------------------------------------------------------- +# tool.Connection.find_rels — MEP pair-fitting detection +# --------------------------------------------------------------------------- + + +def _mep(elem_id, *, klasses=("IfcFlowSegment",), predefined_type=None, ports=()): + """Stand-in IFC element with port mocks and ``is_a`` short-circuits.""" + e = Mock() + e.id = lambda: elem_id + e.is_a = lambda c: c in klasses + e.PredefinedType = predefined_type + # Empty path / element rels so the find_rels prologue iterates cleanly + # before reaching the MEP port-walk branch. + e.ConnectedTo = [] + e.ConnectedFrom = [] + e._ports = list(ports) + return e + + +def _port(port_id, owner, connected_to=None): + p = Mock() + p.id = lambda: port_id + p._owner = owner + p._connected_to = connected_to + return p + + +def _patch_port_walk(): + """Patch the port helpers ``tool.System.find_bridging_fitting`` consumes + so the mep-pair-fitting detection in ``find_rels`` can be exercised + without a real IFC fixture. Three patches: ``get_ports`` and + ``get_connected_port`` are ``tool.System`` classmethods that delegate + to ``ifcopenshell.util.system``; ``get_port_element`` is called + directly on ``ifcopenshell.util.system`` inside ``neighbours_at_ports``.""" + return ( + patch("bonsai.tool.system.System.get_ports", side_effect=lambda e: e._ports), + patch( + "bonsai.tool.system.System.get_connected_port", + side_effect=lambda p: p._connected_to, + ), + patch( + "bonsai.tool.system.ifcopenshell.util.system.get_port_element", + side_effect=lambda p: p._owner, + ), + ) + + +def test_find_rels_detects_segment_segment_bridging_fitting(): + """Two flow segments joined by a single bridging fitting must surface + as ``(fitting, 'mep-pair-fitting')`` — the fitting whose deletion + effects the disconnect.""" + fitting = _mep(99, klasses=("IfcFlowFitting", "IfcDistributionFlowElement"), predefined_type="BEND") + seg_a = _mep(1) + seg_b = _mep(2) + + a_port = _port(101, seg_a) + b_port = _port(102, seg_b) + f_port_a = _port(201, fitting, connected_to=a_port) + f_port_b = _port(202, fitting, connected_to=b_port) + a_port._connected_to = f_port_a + b_port._connected_to = f_port_b + + seg_a._ports = [a_port] + seg_b._ports = [b_port] + fitting._ports = [f_port_a, f_port_b] + + with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]: + rels = tool.Connection.find_rels(seg_a, seg_b) + + assert rels == [(fitting, "mep-pair-fitting")] + + +def test_find_rels_detects_segment_fitting_direct(): + """A segment + its directly-connected fitting also surface as the + same kind, with the fitting itself as the deletion target.""" + fitting = _mep(99, klasses=("IfcFlowFitting", "IfcDistributionFlowElement"), predefined_type="BEND") + seg = _mep(1) + seg_port = _port(101, seg) + f_port = _port(201, fitting, connected_to=seg_port) + seg_port._connected_to = f_port + seg._ports = [seg_port] + fitting._ports = [f_port] + + with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]: + rels = tool.Connection.find_rels(seg, fitting) + + assert rels == [(fitting, "mep-pair-fitting")] + + +def test_find_rels_skips_obstruction_fitting(): + """OBSTRUCTION fittings have a dedicated grow/shrink removal flow — + they must not surface as a disconnect target.""" + obstruction = _mep(99, klasses=("IfcFlowFitting", "IfcDistributionFlowElement"), predefined_type="OBSTRUCTION") + seg_a = _mep(1) + seg_b = _mep(2) + a_port = _port(101, seg_a) + b_port = _port(102, seg_b) + o_port_a = _port(201, obstruction, connected_to=a_port) + o_port_b = _port(202, obstruction, connected_to=b_port) + a_port._connected_to = o_port_a + b_port._connected_to = o_port_b + seg_a._ports = [a_port] + seg_b._ports = [b_port] + obstruction._ports = [o_port_a, o_port_b] + + with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]: + assert tool.Connection.find_rels(seg_a, seg_b) == [] + + +def test_find_rels_returns_empty_for_two_unrelated_mep_segments(): + """No bridging fitting, no detection.""" + seg_a = _mep(1) + seg_b = _mep(2) + seg_a._ports = [] + seg_b._ports = [] + with _patch_port_walk()[0], _patch_port_walk()[1], _patch_port_walk()[2]: + assert tool.Connection.find_rels(seg_a, seg_b) == [] + + +def test_find_rels_skips_non_mep_pair(): + """Walls don't have ports — find_rels must early-out before walking + them as if they were MEP.""" + wall_a = Mock() + wall_a.is_a = lambda c: c == "IfcWall" + wall_a.ConnectedTo = [] + wall_a.ConnectedFrom = [] + wall_b = Mock() + wall_b.is_a = lambda c: c == "IfcWall" + wall_b.ConnectedTo = [] + wall_b.ConnectedFrom = [] + + assert tool.Connection.find_rels(wall_a, wall_b) == [] + + # --------------------------------------------------------------------------- # tool.Connection.find_rel — first-match convenience # --------------------------------------------------------------------------- diff --git a/src/bonsai/test/bim/module/model/test_mep_actions_cache.py b/src/bonsai/test/bim/module/model/test_mep_actions_cache.py index 7f0a144c7e..f496d24aee 100644 --- a/src/bonsai/test/bim/module/model/test_mep_actions_cache.py +++ b/src/bonsai/test/bim/module/model/test_mep_actions_cache.py @@ -128,14 +128,14 @@ def test_port_connection_state_cached_across_frames_within_generation(_patched_v element = Mock() element.is_a = lambda c: c == "IfcFlowSegment" - call_counts = {"port_connection_state": 0, "find_fitting_between_segments": 0, "compute_mep_join_location": 0} + call_counts = {"port_connection_state": 0, "find_bridging_fitting": 0, "compute_mep_join_location": 0} def counting_port_state(elem, at_start): call_counts["port_connection_state"] += 1 return "FREE" def counting_find_fitting(a, b): - call_counts["find_fitting_between_segments"] += 1 + call_counts["find_bridging_fitting"] += 1 return None def counting_join_location(): @@ -151,7 +151,7 @@ def test_port_connection_state_cached_across_frames_within_generation(_patched_v return_value=(Vector((0, 0, 0)), Vector((1, 0, 0))), ), patch("bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state), - patch("bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting), + patch("bonsai.bim.module.model.mep.tool.System.find_bridging_fitting", side_effect=counting_find_fitting), patch("bonsai.bim.module.model.decorator.compute_mep_join_location", side_effect=counting_join_location), patch("bonsai.bim.module.model.mep.gizmo.get_billboard_rotation", return_value=Mock()), patch("bonsai.bim.module.model.mep.gizmo.billboarded_at", return_value=Mock()), @@ -164,7 +164,7 @@ def test_port_connection_state_cached_across_frames_within_generation(_patched_v # Second frame must reuse the cached values — no second IFC walk. assert call_counts["port_connection_state"] == first["port_connection_state"] - assert call_counts["find_fitting_between_segments"] == first["find_fitting_between_segments"] + assert call_counts["find_bridging_fitting"] == first["find_bridging_fitting"] assert call_counts["compute_mep_join_location"] == first["compute_mep_join_location"] @@ -203,7 +203,7 @@ def test_generation_advance_invalidates_cache(_patched_visibility): ), patch( "bonsai.bim.module.model.mep.port_connection_state", side_effect=counting_port_state ), patch( - "bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting + "bonsai.bim.module.model.mep.tool.System.find_bridging_fitting", side_effect=counting_find_fitting ), patch( "bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0)) ), patch( @@ -218,9 +218,7 @@ def test_generation_advance_invalidates_cache(_patched_visibility): inst.position_gizmos(context) assert port_call_count["n"] > first_port, "port_connection_state must recompute after generation advance" - assert ( - fitting_call_count["n"] > first_fitting - ), "find_fitting_between_segments must recompute after generation advance" + assert fitting_call_count["n"] > first_fitting, "find_bridging_fitting must recompute after generation advance" def test_selection_change_invalidates_cache(_patched_visibility): @@ -252,7 +250,7 @@ def test_selection_change_invalidates_cache(_patched_visibility): ), patch( "bonsai.bim.module.model.mep.port_connection_state", return_value="FREE" ), patch( - "bonsai.bim.module.model.mep.find_fitting_between_segments", side_effect=counting_find_fitting + "bonsai.bim.module.model.mep.tool.System.find_bridging_fitting", side_effect=counting_find_fitting ), patch( "bonsai.bim.module.model.decorator.compute_mep_join_location", return_value=Vector((0, 0, 0)) ), patch( @@ -265,4 +263,4 @@ def test_selection_change_invalidates_cache(_patched_visibility): selection_state["selected"] = [active, other_b] inst.position_gizmos(context) - assert fitting_call_count["n"] > first, "find_fitting_between_segments must recompute after selection change" + assert fitting_call_count["n"] > first, "find_bridging_fitting must recompute after selection change" diff --git a/src/bonsai/test/bim/module/model/test_mep_actions_visibility.py b/src/bonsai/test/bim/module/model/test_mep_actions_visibility.py index 858009cba1..601ff8dfb9 100644 --- a/src/bonsai/test/bim/module/model/test_mep_actions_visibility.py +++ b/src/bonsai/test/bim/module/model/test_mep_actions_visibility.py @@ -160,41 +160,102 @@ def test_lock_closed_icons_pass_position_to_remove_terminal_fitting(): ) -def test_unjoin_port_icons_pass_position_to_unjoin_at_port(): - """Per-port unjoin icons bind to ``bim.mep_unjoin_at_port`` with - ``position`` pinned. Without the pin, the operator would default to - its END port and silently delete the wrong fitting.""" +def test_unjoin_icons_bind_unified_disconnect_operator(): + """Every unjoin icon (pair, start, end) routes to the unified + ``bim.disconnect_elements`` operator and the group holds an + ``op_props`` slot for each so the per-frame GUID writes have a + target.""" from bonsai.bim.module.model.mep import GizmoMEPActions inst = _build_group_with_mock_gizmos() - with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=(1, 0, 0)), patch( - "bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock() - ): - GizmoMEPActions._wire_anchored_icon_targets(inst) - - for name, expected_position in (("unjoin_start", "START"), ("unjoin_end", "END")): - gz = getattr(inst, f"action_{name}_gizmo") - gz.target_set_operator.assert_any_call("bim.mep_unjoin_at_port") - op_props = gz.target_set_operator.return_value - assert op_props.position == expected_position or op_props.position in ("START", "END") - - -def test_unjoin_icons_get_warning_color_highlight(): - """Destructive icons surface in the addon's warning red on hover so - they read as a deliberate target. ``color_highlight`` is overridden - after ``super().setup()`` wires the default highlight.""" - from bonsai.bim.module.model.mep import GizmoMEPActions - - inst = _build_group_with_mock_gizmos() - warning_color = (1.0, 0.1, 0.1) - with patch("bonsai.bim.module.model.mep.gizmo.get_warning_color_from_prefs", return_value=warning_color), patch( - "bonsai.bim.module.model.mep.tool.Blender.get_addon_preferences", return_value=MagicMock() - ): - GizmoMEPActions._wire_anchored_icon_targets(inst) + GizmoMEPActions._wire_anchored_icon_targets(inst) + assert isinstance(inst.unjoin_op_props, dict) for name in GizmoMEPActions.UNJOIN_CONFIGS: gz = getattr(inst, f"action_{name}_gizmo") - assert gz.color_highlight == warning_color, f"{name} hover colour not overridden with warning red" + gz.target_set_operator.assert_any_call("bim.disconnect_elements") + assert name in inst.unjoin_op_props, f"missing op_props slot for {name!r}" + + +def test_bind_unjoin_pair_writes_both_guids(): + """``_bind_unjoin_pair`` is the per-frame hand-off from gizmo + position-gizmos to the unified disconnect operator: both segment + GlobalIds get written onto the pre-wired op_props so a click + dispatches with the right pair.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + inst = _build_group_with_mock_gizmos() + GizmoMEPActions._wire_anchored_icon_targets(inst) + pair_op_props = inst.unjoin_op_props["unjoin_pair"] + + seg_a = Mock(GlobalId="GUID-A") + seg_b = Mock(GlobalId="GUID-B") + + assert GizmoMEPActions._bind_unjoin_pair(inst, [seg_a, seg_b]) is True + assert pair_op_props.element_a_guid == "GUID-A" + assert pair_op_props.element_b_guid == "GUID-B" + + +def test_bind_unjoin_pair_rejects_incomplete_pair(): + """Defensive: a selection mid-change can hand the gizmo a one-element + or None-containing pair. The bind must refuse rather than write a + half-resolved op_props that would later CANCEL with a confusing + error message.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + inst = _build_group_with_mock_gizmos() + GizmoMEPActions._wire_anchored_icon_targets(inst) + + assert GizmoMEPActions._bind_unjoin_pair(inst, [Mock(GlobalId="A")]) is False + assert GizmoMEPActions._bind_unjoin_pair(inst, [Mock(GlobalId="A"), None]) is False + + +def test_bind_unjoin_at_port_resolves_fitting_and_writes_guids(): + """The per-port unjoin gizmo resolves the partner fitting at the + named port and writes (segment_guid, fitting_guid) onto the + pre-wired op_props so the unified disconnect operator gets both + endpoints.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + inst = _build_group_with_mock_gizmos() + GizmoMEPActions._wire_anchored_icon_targets(inst) + port_op_props = inst.unjoin_op_props["unjoin_end"] + + segment_obj = Mock() + segment = Mock(GlobalId="SEG-GUID") + fitting = Mock(GlobalId="FIT-GUID") + fitting.is_a = lambda c: c == "IfcFlowFitting" + fitting.PredefinedType = "BEND" + + with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=segment), patch( + "bonsai.bim.module.model.mep.get_connected_element_at_segment_port", return_value=fitting + ): + ok = GizmoMEPActions._bind_unjoin_at_port(inst, "unjoin_end", segment_obj, False) + + assert ok is True + assert port_op_props.element_a_guid == "SEG-GUID" + assert port_op_props.element_b_guid == "FIT-GUID" + + +def test_bind_unjoin_at_port_refuses_obstruction_partner(): + """OBSTRUCTION fittings have a dedicated grow/shrink removal flow — + routing them through the unified disconnect would just delete the + fitting and leave a visible gap. Mirror the find_rels exclusion + here so the icon hides when the partner is an obstruction.""" + from bonsai.bim.module.model.mep import GizmoMEPActions + + inst = _build_group_with_mock_gizmos() + GizmoMEPActions._wire_anchored_icon_targets(inst) + + segment = Mock(GlobalId="SEG-GUID") + obstruction = Mock(GlobalId="OBS-GUID") + obstruction.is_a = lambda c: c == "IfcFlowFitting" + obstruction.PredefinedType = "OBSTRUCTION" + + with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=segment), patch( + "bonsai.bim.module.model.mep.get_connected_element_at_segment_port", return_value=obstruction + ): + assert GizmoMEPActions._bind_unjoin_at_port(inst, "unjoin_end", Mock(), False) is False # --------------------------------------------------------------------------- @@ -202,6 +263,66 @@ def test_unjoin_icons_get_warning_color_highlight(): # --------------------------------------------------------------------------- +def test_active_is_bend_fitting_accepts_tessellated_bend_with_bbim_pset(): + """A bend whose body has been tessellated as the upstream geometry-kernel + workaround still has its parametric definition on the type's + ``BBIM_Fitting`` pset — the re-edit operator reads from there, so the + pen icon must surface on it. ``has_parametric_body`` would return False + for the tessellated body; the pset gate is what makes the icon + reachable.""" + from bonsai.bim.module.model.mep import _active_is_bend_fitting + + bend_obj = Mock() + bend_elem = Mock() + bend_elem.is_a = lambda c: c == "IfcFlowFitting" + bend_type = Mock() + bend_type.PredefinedType = "BEND" + + with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=bend_elem), patch( + "bonsai.bim.module.model.mep._is_bend_fitting", return_value=True + ), patch("bonsai.bim.module.model.mep.ifcopenshell.util.element.get_type", return_value=bend_type), patch( + "bonsai.bim.module.model.mep.ifcopenshell.util.element.get_pset", + return_value={"radius": 0.2, "start_length": 0.1, "end_length": 0.1}, + ): + assert _active_is_bend_fitting(bend_obj) is True + + +def test_active_is_bend_fitting_rejects_bend_type_without_bbim_pset(): + """A fitting that looks like a bend (IfcFlowFitting + type.PredefinedType + == BEND) but lacks a ``BBIM_Fitting`` pset on the type can't be re-edited + — the re-edit operator reads parameters from the pset. Reject so the pen + icon hides rather than dispatching an operator that would CANCEL.""" + from bonsai.bim.module.model.mep import _active_is_bend_fitting + + bend_obj = Mock() + bend_elem = Mock() + bend_elem.is_a = lambda c: c == "IfcFlowFitting" + bend_type = Mock() + bend_type.PredefinedType = "BEND" + + with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=bend_elem), patch( + "bonsai.bim.module.model.mep._is_bend_fitting", return_value=True + ), patch("bonsai.bim.module.model.mep.ifcopenshell.util.element.get_type", return_value=bend_type), patch( + "bonsai.bim.module.model.mep.ifcopenshell.util.element.get_pset", return_value=None + ): + assert _active_is_bend_fitting(bend_obj) is False + + +def test_active_is_bend_fitting_rejects_non_bend(): + """Non-bend objects (segments, fittings with PredefinedType != BEND) + fail the first gate regardless of pset state.""" + from bonsai.bim.module.model.mep import _active_is_bend_fitting + + bend_obj = Mock() + bend_elem = Mock() + bend_elem.is_a = lambda c: c == "IfcFlowFitting" + + with patch("bonsai.bim.module.model.mep.tool.Ifc.get_entity", return_value=bend_elem), patch( + "bonsai.bim.module.model.mep._is_bend_fitting", return_value=False + ): + assert _active_is_bend_fitting(bend_obj) is False + + def test_active_is_flow_segment_handles_unbound_object(): """A Blender object with no IFC binding must not raise from a visibility predicate. The lambda runs on every selection event.""" diff --git a/src/bonsai/test/bim/module/model/test_mep_disconnect_integration.py b/src/bonsai/test/bim/module/model/test_mep_disconnect_integration.py new file mode 100644 index 0000000000..506f26279b --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_mep_disconnect_integration.py @@ -0,0 +1,139 @@ +# 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. + +"""End-to-end integration tests for the unified MEP disconnect path. + +Builds a real IFC scene (two pipe segments joined via ports to a bridging +fitting) and exercises the full chain: + tool.Connection.find_rels(seg_a, seg_b) + → returns (fitting, "mep-pair-fitting") + → bonsai.core.connection.disconnect_rel(rel=fitting, kind=...) + → tool.Geometry.delete_ifc_object(fitting_obj) + → cascade-on-delete removes the IfcRelConnectsPorts via remove_port + +The mock-based dispatch tests in :py:mod:`test_disconnect_elements` pin each +piece in isolation. This module pins that they compose — the surface the +gizmo click hits in production.""" + +import bpy +import ifcopenshell.api.system +import pytest + +import bonsai.core.connection +import bonsai.tool as tool +from test.bim.bootstrap import NewFile + +pytestmark = pytest.mark.model + + +class TestMEPPairDisconnectEndToEnd(NewFile): + def _make_segment(self, name: str): + """Create one IfcPipeSegment occurrence with ports at both ends. + Returns (blender_object, ifc_element).""" + bpy.ops.mesh.primitive_cube_add(size=1) + obj = bpy.data.objects["Cube"] + obj.name = name + bpy.ops.bim.assign_class(ifc_class="IfcPipeSegment", predefined_type="RIGIDSEGMENT", userdefined_type="") + element = tool.Ifc.get_entity(obj) + tool.System.add_ports(obj) + return obj, element + + def _make_bend_fitting(self, name: str): + """Create one IfcPipeFitting (PredefinedType=BEND) occurrence with two + ports. Manual setup — bpy.ops.bim.assign_class doesn't add ports.""" + bpy.ops.mesh.primitive_cube_add(size=0.3) + obj = bpy.data.objects["Cube"] + obj.name = name + bpy.ops.bim.assign_class(ifc_class="IfcPipeFitting", predefined_type="BEND", userdefined_type="") + element = tool.Ifc.get_entity(obj) + tool.System.add_ports(obj) + return obj, element + + def _setup_joined_pair(self): + bpy.ops.bim.create_project() + seg_a_obj, seg_a = self._make_segment("SegA") + seg_b_obj, seg_b = self._make_segment("SegB") + bend_obj, bend = self._make_bend_fitting("Bend") + + ifc_file = tool.Ifc.get() + seg_a_ports = tool.System.get_ports(seg_a) + seg_b_ports = tool.System.get_ports(seg_b) + bend_ports = tool.System.get_ports(bend) + ifcopenshell.api.system.connect_port(ifc_file, port1=seg_a_ports[0], port2=bend_ports[0]) + ifcopenshell.api.system.connect_port(ifc_file, port1=seg_b_ports[0], port2=bend_ports[1]) + + return seg_a, seg_b, bend, bend_obj + + def test_find_rels_returns_mep_pair_fitting_subject(self): + seg_a, seg_b, bend, _ = self._setup_joined_pair() + rels = tool.Connection.find_rels(seg_a, seg_b) + assert rels == [(bend, "mep-pair-fitting")] + + def test_disconnect_rel_removes_the_bridging_fitting(self): + """The end-to-end contract: dispatch removes the fitting from the + IFC file, the Blender object is deleted, and a follow-up find_rels + on the same pair returns empty — there's nothing left to disconnect.""" + seg_a, seg_b, bend, bend_obj = self._setup_joined_pair() + bend_id = bend.id() + bend_obj_name = bend_obj.name + ifc_file = tool.Ifc.get() + + bonsai.core.connection.disconnect_rel( + tool.Ifc, + tool.Geometry, + tool.Model, + tool.Connection, + rel=bend, + kind="mep-pair-fitting", + elem=seg_a, + partner=seg_b, + ) + + # The fitting is gone from the IFC file. + with pytest.raises(RuntimeError): + ifc_file.by_id(bend_id) + # The pair is no longer joined. + assert tool.Connection.find_rels(seg_a, seg_b) == [] + # The Blender object was removed by delete_ifc_object. + assert bend_obj_name not in bpy.data.objects + + def test_disconnect_rel_skips_when_subject_is_elem_being_deleted(self): + """Cascade-side guard: if the fitting is itself the element being + deleted (subject is elem), skip — the deletion is already in flight + and re-deleting would crash.""" + seg_a, seg_b, bend, bend_obj = self._setup_joined_pair() + bend_id = bend.id() + bend_obj_name = bend_obj.name + + bonsai.core.connection.disconnect_rel( + tool.Ifc, + tool.Geometry, + tool.Model, + tool.Connection, + rel=bend, + kind="mep-pair-fitting", + elem=bend, + partner=seg_a, + skip_elem_recreate=True, + ) + + # Fitting still present — the dispatch correctly skipped. + assert tool.Ifc.get().by_id(bend_id).id() == bend_id + assert bend_obj_name in bpy.data.objects diff --git a/src/bonsai/test/bim/module/model/test_mep_port_operators.py b/src/bonsai/test/bim/module/model/test_mep_port_operators.py index c434c69020..614dd70782 100644 --- a/src/bonsai/test/bim/module/model/test_mep_port_operators.py +++ b/src/bonsai/test/bim/module/model/test_mep_port_operators.py @@ -61,76 +61,6 @@ def _make_op(_cls, **fields): return op -# --------------------------------------------------------------------------- -# MEPUnjoinAtPort -# --------------------------------------------------------------------------- - - -@pytest.mark.parametrize( - "port_state, fitting_predefined_type, expected_result, expects_delete", - [ - pytest.param("JOINED", "JUNCTION", {"FINISHED"}, True, id="joined_junction_deletes"), - pytest.param("JOINED", "OBSTRUCTION", {"CANCELLED"}, False, id="joined_obstruction_refused"), - pytest.param("FREE", None, {"CANCELLED"}, False, id="free_port_cancels"), - ], -) -def test_unjoin_at_port_dispatch_table(port_state, fitting_predefined_type, expected_result, expects_delete): - """``MEPUnjoinAtPort`` dispatch contract: result and delete-side-effect - by ``(port_state, fitting type)``. - - - ``JOINED + JUNCTION`` (or any non-OBSTRUCTION fitting): happy path, - the bridging fitting is deleted via the standard delete entry point. - - ``JOINED + OBSTRUCTION``: deliberately refused — obstructions go - through ``bim.mep_add_obstruction`` (mode=REMOVE) so the segment - extends to absorb the freed length; using delete here would leave - a visible gap. - - ``FREE``: nothing to do — no bridging fitting exists. The operator - reports a user-facing error and CANCELS rather than no-op silently.""" - from bonsai.bim.module.model import mep - - segment = _segment() - fitting = _fitting(predefined_type=fitting_predefined_type) if fitting_predefined_type else None - fitting_obj = Mock() - - op = _make_op(mep.MEPUnjoinAtPort, segment_id=42, position="END") - ifc_file = MagicMock() - ifc_file.by_id.return_value = segment - - with patch.object(mep.tool.Ifc, "get", return_value=ifc_file), patch.object( - mep.tool.Ifc, "get_object", return_value=fitting_obj - ), patch.object(mep, "port_connection_state", return_value=port_state), patch.object( - mep, "get_connected_element_at_segment_port", return_value=fitting - ), patch.object( - mep.tool.Geometry, "delete_ifc_object" - ) as delete: - result = mep.MEPUnjoinAtPort._execute(op, context=MagicMock()) - - assert result == expected_result - if expects_delete: - delete.assert_called_once_with(fitting_obj) - else: - delete.assert_not_called() - op.report.assert_called() - - -def test_unjoin_at_port_cancels_when_active_is_not_segment(): - """The operator only operates on flow segments; non-segment active - objects must fail loud rather than mutate something unexpected.""" - from bonsai.bim.module.model import mep - - fitting = _fitting() # IfcFlowFitting, not IfcFlowSegment - - op = _make_op(mep.MEPUnjoinAtPort, segment_id=42, position="END") - ifc_file = MagicMock() - ifc_file.by_id.return_value = fitting - - with patch.object(mep.tool.Ifc, "get", return_value=ifc_file): - result = mep.MEPUnjoinAtPort._execute(op, context=MagicMock()) - - assert result == {"CANCELLED"} - op.report.assert_called() - - # --------------------------------------------------------------------------- # MEPRemoveTerminalFitting # --------------------------------------------------------------------------- @@ -210,105 +140,6 @@ def test_remove_terminal_cancels_on_non_terminal_port(): op.report.assert_called() -# --------------------------------------------------------------------------- -# MEPUnjoinPair -# --------------------------------------------------------------------------- - - -def test_unjoin_pair_deletes_bridging_fitting(): - """Happy path: two selected segments share a single non-OBSTRUCTION - bridging fitting → delete it.""" - from bonsai.bim.module.model import mep - - segment_a = _segment() - segment_b = _segment() - fitting = _fitting(predefined_type="JUNCTION") - fitting_obj = Mock() - - op = _make_op(mep.MEPUnjoinPair) - selected = [Mock(), Mock()] - - with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object( - mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b] - ), patch.object(mep, "find_fitting_between_segments", return_value=fitting), patch.object( - mep.tool.Ifc, "get_object", return_value=fitting_obj - ), patch.object( - mep.tool.Geometry, "delete_ifc_object" - ) as delete: - result = mep.MEPUnjoinPair._execute(op, context=MagicMock()) - - assert result == {"FINISHED"} - delete.assert_called_once_with(fitting_obj) - - -def test_unjoin_pair_refuses_obstruction_bridging(): - """Same defence-in-depth as ``MEPUnjoinAtPort`` — obstructions go - through the dedicated REMOVE path; this operator surfaces the - redirect rather than silently doing the wrong thing.""" - from bonsai.bim.module.model import mep - - segment_a = _segment() - segment_b = _segment() - obstruction = _fitting(predefined_type="OBSTRUCTION") - - op = _make_op(mep.MEPUnjoinPair) - selected = [Mock(), Mock()] - - with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object( - mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b] - ), patch.object(mep, "find_fitting_between_segments", return_value=obstruction), patch.object( - mep.tool.Geometry, "delete_ifc_object" - ) as delete: - result = mep.MEPUnjoinPair._execute(op, context=MagicMock()) - - assert result == {"CANCELLED"} - delete.assert_not_called() - op.report.assert_called() - - -def test_unjoin_pair_reports_when_no_bridging_fitting_found(): - """The pair is selected but no single fitting bridges them — the - user is told instead of getting a silent no-op.""" - from bonsai.bim.module.model import mep - - segment_a = _segment() - segment_b = _segment() - - op = _make_op(mep.MEPUnjoinPair) - selected = [Mock(), Mock()] - - with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object( - mep.tool.Ifc, "get_entity", side_effect=[segment_a, segment_b] - ), patch.object(mep, "find_fitting_between_segments", return_value=None), patch.object( - mep.tool.Geometry, "delete_ifc_object" - ) as delete: - result = mep.MEPUnjoinPair._execute(op, context=MagicMock()) - - assert result == {"CANCELLED"} - delete.assert_not_called() - op.report.assert_called() - - -def test_unjoin_pair_cancels_when_selection_is_not_two_segments(): - """The poll filters the gizmo, but a programmatic invocation could - still hand the operator an invalid selection. The execute path - independently verifies both inputs are IfcFlowSegment.""" - from bonsai.bim.module.model import mep - - not_a_segment = _fitting() # IfcFlowFitting, not IfcFlowSegment - - op = _make_op(mep.MEPUnjoinPair) - selected = [Mock(), Mock()] - - with patch.object(mep.tool.Blender, "get_selected_objects", return_value=selected), patch.object( - mep.tool.Ifc, "get_entity", side_effect=[not_a_segment, not_a_segment] - ): - result = mep.MEPUnjoinPair._execute(op, context=MagicMock()) - - assert result == {"CANCELLED"} - op.report.assert_called() - - # --------------------------------------------------------------------------- # SelectMEPPathMembers # --------------------------------------------------------------------------- diff --git a/src/bonsai/test/core/test_connection.py b/src/bonsai/test/core/test_connection.py index 5b09bc4dfa..293bdab05a 100644 --- a/src/bonsai/test/core/test_connection.py +++ b/src/bonsai/test/core/test_connection.py @@ -60,8 +60,14 @@ class TestDisconnectRelPath: with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection") as remove: subject.disconnect_rel( - ifc, geometry, model, connection, - rel="rel", kind="path", elem="elem_a", partner="elem_b", + ifc, + geometry, + model, + connection, + rel="rel", + kind="path", + elem="elem_a", + partner="elem_b", ) remove.assert_called_once_with(geometry, connection="rel") @@ -78,8 +84,14 @@ class TestDisconnectRelPath: with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection"): subject.disconnect_rel( - ifc, geometry, model, connection, - rel="rel", kind="path", elem="elem", partner="partner", + ifc, + geometry, + model, + connection, + rel="rel", + kind="path", + elem="elem", + partner="partner", skip_elem_recreate=True, ) @@ -93,8 +105,14 @@ class TestDisconnectRelPath: with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection"): subject.disconnect_rel( - ifc, geometry, model, connection, - rel="rel", kind="path", elem="elem", partner="partner", + ifc, + geometry, + model, + connection, + rel="rel", + kind="path", + elem="elem", + partner="partner", skip_partner_recreate=True, ) @@ -108,8 +126,14 @@ class TestDisconnectRelPath: with patch("bonsai.core.connection.bonsai.core.geometry.remove_connection") as remove: subject.disconnect_rel( - ifc, geometry, model, connection, - rel="rel", kind="path", elem="elem", partner="partner", + ifc, + geometry, + model, + connection, + rel="rel", + kind="path", + elem="elem", + partner="partner", skip_elem_recreate=True, skip_partner_recreate=True, ) @@ -131,13 +155,17 @@ class TestDisconnectRelElementTop: with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen: subject.disconnect_rel( - ifc, geometry, model, connection, - rel=rel, kind="element-top", elem="elem", partner="partner", + ifc, + geometry, + model, + connection, + rel=rel, + kind="element-top", + elem="elem", + partner="partner", ) - ifc.run.assert_called_once_with( - "geometry.disconnect_element", relating_element="slab", related_element="wall" - ) + ifc.run.assert_called_once_with("geometry.disconnect_element", relating_element="slab", related_element="wall") regen.assert_called_once_with(ifc, geometry, model, ["wall_obj"]) def test_slab_delete_cascade_still_regenerates_wall(self): @@ -150,8 +178,14 @@ class TestDisconnectRelElementTop: with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen: subject.disconnect_rel( - ifc, Mock(), Mock(), connection, - rel=rel, kind="element-top", elem="slab", partner="wall", + ifc, + Mock(), + Mock(), + connection, + rel=rel, + kind="element-top", + elem="slab", + partner="wall", skip_elem_recreate=True, # slab is being deleted ) @@ -167,8 +201,14 @@ class TestDisconnectRelElementTop: with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen: subject.disconnect_rel( - ifc, Mock(), Mock(), connection, - rel=rel, kind="element-top", elem="wall", partner="slab", + ifc, + Mock(), + Mock(), + connection, + rel=rel, + kind="element-top", + elem="wall", + partner="slab", skip_elem_recreate=True, # wall is being deleted ) @@ -185,8 +225,14 @@ class TestDisconnectRelElementTop: with patch("bonsai.core.connection.regenerate_wall_to_underside") as regen: subject.disconnect_rel( - ifc, Mock(), Mock(), connection, - rel=rel, kind="element-top", elem="slab", partner="wall", + ifc, + Mock(), + Mock(), + connection, + rel=rel, + kind="element-top", + elem="slab", + partner="wall", skip_elem_recreate=True, skip_partner_recreate=True, # wall also in batch ) @@ -200,19 +246,115 @@ class TestDisconnectRelElement: ifc = Mock() subject.disconnect_rel( - ifc, Mock(), Mock(), Mock(), - rel=rel, kind="element", elem="elem_a", partner="elem_b", + ifc, + Mock(), + Mock(), + Mock(), + rel=rel, + kind="element", + elem="elem_a", + partner="elem_b", ) - ifc.run.assert_called_once_with( - "geometry.disconnect_element", relating_element="A", related_element="B" + ifc.run.assert_called_once_with("geometry.disconnect_element", relating_element="A", related_element="B") + + +class TestDisconnectRelMEPPairFitting: + """The ``mep-pair-fitting`` kind treats the rel slot as the fitting whose + removal disconnects the pair — deletion routes through + ``geometry.delete_ifc_object`` so the cascade-on-delete contract still + owns port-rel cleanup.""" + + def test_deletes_fitting_via_delete_ifc_object(self): + fitting = Mock(name="fitting") + fitting_obj = Mock(name="fitting_obj") + ifc = _ifc_with_objects({fitting: fitting_obj}) + geometry = Mock() + + subject.disconnect_rel( + ifc, + geometry, + Mock(), + Mock(), + rel=fitting, + kind="mep-pair-fitting", + elem="seg_a", + partner="seg_b", ) + geometry.delete_ifc_object.assert_called_once_with(fitting_obj) + + def test_noops_when_fitting_has_no_blender_object(self): + """Defensive: a fitting with no bound Blender object can't be + deleted via ``delete_ifc_object``; the dispatch must not crash.""" + fitting = Mock(name="fitting") + ifc = _ifc_with_objects({}) + geometry = Mock() + + subject.disconnect_rel( + ifc, + geometry, + Mock(), + Mock(), + rel=fitting, + kind="mep-pair-fitting", + elem="seg_a", + partner="seg_b", + ) + + geometry.delete_ifc_object.assert_not_called() + + def test_skip_elem_recreate_suppresses_delete_when_fitting_is_elem(self): + """Cascade case: the fitting is itself the element being deleted + — don't try to delete it twice.""" + fitting = Mock(name="fitting") + ifc = _ifc_with_objects({fitting: Mock()}) + geometry = Mock() + + subject.disconnect_rel( + ifc, + geometry, + Mock(), + Mock(), + rel=fitting, + kind="mep-pair-fitting", + elem=fitting, + partner="other", + skip_elem_recreate=True, + ) + + geometry.delete_ifc_object.assert_not_called() + + def test_skip_partner_recreate_suppresses_delete_when_fitting_is_partner(self): + fitting = Mock(name="fitting") + ifc = _ifc_with_objects({fitting: Mock()}) + geometry = Mock() + + subject.disconnect_rel( + ifc, + geometry, + Mock(), + Mock(), + rel=fitting, + kind="mep-pair-fitting", + elem="seg_a", + partner=fitting, + skip_partner_recreate=True, + ) + + geometry.delete_ifc_object.assert_not_called() + class TestDisconnectRelUnknownKind: def test_raises_value_error(self): with pytest.raises(ValueError, match="Unknown rel kind"): subject.disconnect_rel( - Mock(), Mock(), Mock(), Mock(), - rel="rel", kind="bogus", elem="a", partner="b", + Mock(), + Mock(), + Mock(), + Mock(), + rel="rel", + kind="bogus", + elem="a", + partner="b", ) diff --git a/src/bonsai/test/tool/test_system.py b/src/bonsai/test/tool/test_system.py index 5c18ad623f..9bd7d47fdc 100644 --- a/src/bonsai/test/tool/test_system.py +++ b/src/bonsai/test/tool/test_system.py @@ -23,6 +23,7 @@ import ifcopenshell import ifcopenshell.api import ifcopenshell.api.root import ifcopenshell.api.system +import ifcopenshell.util.representation import ifcopenshell.util.system import ifcopenshell.util.unit import numpy as np @@ -39,6 +40,132 @@ class TestImplementsTool(NewFile): assert isinstance(subject(), bonsai.core.tool.System) +class TestHasParametricBody(NewFile): + """The MEP-action gizmo predicates gate on ``has_parametric_body``; + fittings whose swept body lives on the type via ``IfcMappedItem`` must + return True so the pen-icon and lock-icon rows show on the occurrence.""" + + def _build_bend_occurrence_with_mapped_body(self): + bpy.ops.bim.create_project() + ifc_file = tool.Ifc.get() + body_ctx = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW") + + placement = ifc_file.create_entity( + "IfcAxis2Placement3D", + Location=ifc_file.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)), + ) + line = ifc_file.create_entity( + "IfcLine", + Pnt=ifc_file.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)), + Dir=ifc_file.create_entity( + "IfcVector", + Orientation=ifc_file.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)), + Magnitude=1.0, + ), + ) + trimmed = ifc_file.create_entity( + "IfcTrimmedCurve", + BasisCurve=line, + Trim1=(ifc_file.create_entity("IfcParameterValue", wrappedValue=0.0),), + Trim2=(ifc_file.create_entity("IfcParameterValue", wrappedValue=1.0),), + SenseAgreement=True, + MasterRepresentation="PARAMETER", + ) + swept = ifc_file.create_entity("IfcSweptDiskSolid", Directrix=trimmed, Radius=0.05) + type_body = ifc_file.create_entity( + "IfcShapeRepresentation", + ContextOfItems=body_ctx, + RepresentationIdentifier="Body", + RepresentationType="AdvancedSweptSolid", + Items=(swept,), + ) + rep_map = ifc_file.create_entity( + "IfcRepresentationMap", MappingOrigin=placement, MappedRepresentation=type_body + ) + fitting_type = ifc_file.create_entity( + "IfcPipeFittingType", + GlobalId=ifcopenshell.guid.new(), + Name="BendType", + PredefinedType="BEND", + RepresentationMaps=(rep_map,), + ) + mapped_item = ifc_file.create_entity( + "IfcMappedItem", + MappingSource=rep_map, + MappingTarget=ifc_file.create_entity( + "IfcCartesianTransformationOperator3D", + LocalOrigin=ifc_file.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)), + ), + ) + occurrence_body = ifc_file.create_entity( + "IfcShapeRepresentation", + ContextOfItems=body_ctx, + RepresentationIdentifier="Body", + RepresentationType="MappedRepresentation", + Items=(mapped_item,), + ) + fitting = ifc_file.create_entity( + "IfcPipeFitting", + GlobalId=ifcopenshell.guid.new(), + Name="Bend", + PredefinedType="BEND", + Representation=ifc_file.create_entity("IfcProductDefinitionShape", Representations=(occurrence_body,)), + ) + ifc_file.create_entity( + "IfcRelDefinesByType", + GlobalId=ifcopenshell.guid.new(), + RelatedObjects=(fitting,), + RelatingType=fitting_type, + ) + return fitting + + def test_returns_true_for_swept_disk_via_mapped_item(self): + """``traverse()`` follows the + ``IfcMappedItem.MappingSource.MappedRepresentation`` chain so the + ``IfcSweptDiskSolid`` on the type's body is reachable from the + occurrence's body representation. Bend fittings produced by the + bend-preview commit path use this exact representation shape.""" + fitting = self._build_bend_occurrence_with_mapped_body() + assert subject.has_parametric_body(fitting) is True + + def test_returns_false_for_tessellated_body(self): + """The bend creation path replaces the swept-disk body with an + ``IfcTriangulatedFaceSet`` as an upstream geometry-kernel + workaround. The traverse finds no extruded / swept solid, so the + predicate returns False — pinning the constraint that drives the + ``BBIM_Fitting`` pset fallback in the bend-icon visibility + predicate.""" + bpy.ops.bim.create_project() + ifc_file = tool.Ifc.get() + body_ctx = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW") + + coords = ifc_file.create_entity( + "IfcCartesianPointList3D", + CoordList=((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)), + ) + tessellation = ifc_file.create_entity( + "IfcTriangulatedFaceSet", + Coordinates=coords, + CoordIndex=((1, 2, 3),), + ) + body = ifc_file.create_entity( + "IfcShapeRepresentation", + ContextOfItems=body_ctx, + RepresentationIdentifier="Body", + RepresentationType="Tessellation", + Items=(tessellation,), + ) + fitting = ifc_file.create_entity( + "IfcPipeFitting", + GlobalId=ifcopenshell.guid.new(), + Name="TessellatedBend", + PredefinedType="BEND", + Representation=ifc_file.create_entity("IfcProductDefinitionShape", Representations=(body,)), + ) + + assert subject.has_parametric_body(fitting) is False + + class TestAddPorts(NewFile): def setup_mep_segment(self): bpy.ops.bim.create_project() From 006a24ef32a9c79f24e9880e5364571bdd61796a Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 23 Jun 2026 14:27:29 +0200 Subject: [PATCH 10/10] Refactor: rename rel -> subject in disconnect_rel dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "mep-pair-fitting" kind added in the previous commit carries an IfcFlowFitting (the entity whose deletion disconnects the pair), not a relationship entity, in the dispatch slot — but the slot was named ``rel`` across the function signature and every call site. Rename to ``subject`` so the parameter name reflects the uniform intent: "the entity whose teardown effects the disconnect", regardless of whether that's a rel or a fitting. Sweep covers: - core.connection.disconnect_rel signature + body - tool.Connection.find_rels / find_rels_for_element / find_rel docstrings - The cascade-on-delete call site in tool.Geometry.delete_ifc_object - DisconnectElements operator in bim.module.model.wall - All affected test kwargs and AST forward-compat docstring - Error message: "Unknown rel kind" -> "Unknown kind" No behaviour change. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/model/wall.py | 4 +- src/bonsai/bonsai/core/connection.py | 40 +++++++------- src/bonsai/bonsai/tool/connection.py | 54 ++++++++++--------- src/bonsai/bonsai/tool/geometry.py | 4 +- .../module/model/test_disconnect_elements.py | 6 +-- .../model/test_mep_disconnect_integration.py | 6 +-- src/bonsai/test/core/test_connection.py | 30 +++++------ .../tool/test_connection_forward_compat.py | 6 +-- 8 files changed, 76 insertions(+), 74 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index dd054635a7..eaf4c356be 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -400,13 +400,13 @@ class DisconnectElements(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.I ) return path_objs: list[bpy.types.Object] = [] - for rel, kind in rels: + for subject, kind in rels: bonsai.core.connection.disconnect_rel( tool.Ifc, tool.Geometry, tool.Model, tool.Connection, - rel=rel, + subject=subject, kind=kind, elem=elem_a, partner=elem_b, diff --git a/src/bonsai/bonsai/core/connection.py b/src/bonsai/bonsai/core/connection.py index 8b5d80d898..3ea3bd4bef 100644 --- a/src/bonsai/bonsai/core/connection.py +++ b/src/bonsai/bonsai/core/connection.py @@ -22,17 +22,18 @@ Used by both ``bim.disconnect_elements`` (explicit user disconnect) and the connection cascade in ``tool.Geometry.delete_ifc_object`` (implicit -disconnect-on-delete). Each rel kind returned by +disconnect-on-delete). Each kind returned by :py:meth:`bonsai.tool.connection.Connection.find_rels` / :py:meth:`find_rels_for_element` maps to a single arm here, so adding a new -rel kind means extending one dispatch table — both call sites benefit +kind means extending one dispatch table — both call sites benefit automatically and the AST forward-compat guard enforces coverage. -For the ``"mep-pair-fitting"`` kind the ``rel`` slot carries the -``IfcFlowFitting`` itself rather than a relationship entity — the subject -whose removal disconnects the pair. The dispatch treats the slot as the -deletion target and routes through ``geometry.delete_ifc_object`` so the -cascade-on-delete contract still owns port-rel cleanup. +The ``subject`` parameter is the entity whose teardown effects the +disconnect: for ``"path"`` / ``"element"`` / ``"element-top"`` kinds it +carries an ``IfcRel*`` relationship entity (the rel that gets removed); +for ``"mep-pair-fitting"`` it carries an ``IfcFlowFitting`` (the fitting +that gets deleted). The slot is uniform on intent — the dispatch decides +the teardown mechanism by kind. """ from __future__ import annotations @@ -53,14 +54,14 @@ def disconnect_rel( geometry: type[tool.Geometry], model: type[tool.Model], connection: type[tool.Connection], - rel: ifcopenshell.entity_instance, + subject: ifcopenshell.entity_instance, kind: str, elem: ifcopenshell.entity_instance, partner: ifcopenshell.entity_instance, skip_elem_recreate: bool = False, skip_partner_recreate: bool = False, ) -> None: - """Run the post-disconnect cleanup for one rel. + """Run the post-disconnect cleanup for one connection. ``elem`` and ``partner`` are the two endpoints. The ``skip_*_recreate`` flags suppress per-side regenerate / recreate work — used by the @@ -70,7 +71,7 @@ def disconnect_rel( runs on both sides. """ if kind == "path": - bonsai.core.geometry.remove_connection(geometry, connection=rel) + bonsai.core.geometry.remove_connection(geometry, connection=subject) if not skip_elem_recreate: elem_obj = ifc.get_object(elem) if elem_obj is not None: @@ -80,11 +81,11 @@ def disconnect_rel( if partner_obj is not None: model.recreate_wall(partner, partner_obj) elif kind == "element-top": - wall, _slab = connection.orient_element_top(rel, elem, partner) + wall, _slab = connection.orient_element_top(subject, elem, partner) ifc.run( "geometry.disconnect_element", - relating_element=rel.RelatingElement, - related_element=rel.RelatedElement, + relating_element=subject.RelatingElement, + related_element=subject.RelatedElement, ) # Skip the wall-side regenerate when the wall is itself being deleted — # either it's the elem of this cascade pass, or it's the partner that @@ -97,17 +98,16 @@ def disconnect_rel( elif kind == "element": ifc.run( "geometry.disconnect_element", - relating_element=rel.RelatingElement, - related_element=rel.RelatedElement, + relating_element=subject.RelatingElement, + related_element=subject.RelatedElement, ) elif kind == "mep-pair-fitting": - fitting = rel # slot semantics: the fitting whose removal disconnects the pair. - if skip_elem_recreate and fitting is elem: + if skip_elem_recreate and subject is elem: return - if skip_partner_recreate and fitting is partner: + if skip_partner_recreate and subject is partner: return - fitting_obj = ifc.get_object(fitting) + fitting_obj = ifc.get_object(subject) if fitting_obj is not None: geometry.delete_ifc_object(fitting_obj) else: - raise ValueError(f"Unknown rel kind: {kind!r}") + raise ValueError(f"Unknown kind: {kind!r}") diff --git a/src/bonsai/bonsai/tool/connection.py b/src/bonsai/bonsai/tool/connection.py index 709f277cc5..e433ec605e 100644 --- a/src/bonsai/bonsai/tool/connection.py +++ b/src/bonsai/bonsai/tool/connection.py @@ -18,25 +18,26 @@ # # This file was generated with the assistance of an AI coding tool. -"""Generic discovery of the relation linking two IFC elements. +"""Generic discovery of the connection linking two IFC elements. Used by ``bim.disconnect_elements`` so the operator surface is one operator per disconnect intent (active vs. partner, identified by GlobalId) rather -than one per rel class. The kind label returned alongside the rel lets the -operator dispatch the right post-disconnect cleanup: +than one per rel class. Each lookup returns ``(subject, kind)`` tuples where +``subject`` is the entity whose teardown effects the disconnect: -- ``"path"`` for ``IfcRelConnectsPathElements`` (wall-wall, wall-roof, etc.) -- ``"element-top"`` for ``IfcRelConnectsElements`` with ``Description=="TOP"`` - (the rel kind ``extend_walls_to_underside`` creates) -- ``"element"`` for any other ``IfcRelConnectsElements`` -- ``"mep-pair-fitting"`` for an MEP pair whose disconnect is effected by - removing a bridging ``IfcFlowFitting``. The ``rel`` slot for this kind - carries the fitting entity itself (not a relationship entity) — the - subject whose removal disconnects the pair. ``OBSTRUCTION`` fittings are - excluded; those are removed via ``bim.mep_add_obstruction(mode=REMOVE)``. +- ``"path"`` — ``IfcRelConnectsPathElements`` (wall-wall, wall-roof, etc.). + ``subject`` is the rel; removing it disconnects. +- ``"element-top"`` — ``IfcRelConnectsElements`` with ``Description=="TOP"`` + (created by ``extend_walls_to_underside``). ``subject`` is the rel. +- ``"element"`` — any other ``IfcRelConnectsElements``. ``subject`` is the rel. +- ``"mep-pair-fitting"`` — two MEP elements joined via ``IfcRelConnectsPorts`` + through a single bridging ``IfcFlowFitting``. ``subject`` is the fitting + itself; removing it disconnects. ``OBSTRUCTION`` fittings are excluded + here; those go through ``bim.mep_add_obstruction(mode=REMOVE)``. -Add new rel kinds by extending :py:meth:`Connection.find_rel`. The disconnect -operator's cleanup switch maps each kind to the right post-mutation calls.""" +Add new kinds by extending :py:meth:`Connection.find_rels`. The dispatch in +``bonsai.core.connection.disconnect_rel`` maps each kind to the right +post-mutation cleanup; the AST forward-compat guard enforces coverage.""" from __future__ import annotations @@ -55,13 +56,13 @@ class Connection: elem_a: ifcopenshell.entity_instance, elem_b: ifcopenshell.entity_instance, ) -> list[tuple[ifcopenshell.entity_instance, str]]: - """Return every supported rel linking ``elem_a`` to ``elem_b`` as a - list of ``(rel, kind)`` tuples. Walks both ``ConnectedTo`` and - ``ConnectedFrom`` because either side of the rel can be the relating - element, and the same pair may carry rels authored with opposite - orientations (``disconnect_path``'s ``(relating, related)`` mode only - inspects ``relating.ConnectedTo``, so a single call would miss the - opposite-orientation rel).""" + """Return every supported connection linking ``elem_a`` to ``elem_b`` + as a list of ``(subject, kind)`` tuples — ``subject`` is the entity + whose teardown effects the disconnect (the rel itself for + relationship-kinds, the bridging fitting for ``"mep-pair-fitting"``). + Walks both ``ConnectedTo`` and ``ConnectedFrom`` because either side + of a rel can be the relating element, and the same pair may carry + rels authored with opposite orientations.""" rels: list[tuple[ifcopenshell.entity_instance, str]] = [] seen: set[int] = set() @@ -98,8 +99,8 @@ class Connection: elem_a: ifcopenshell.entity_instance, elem_b: ifcopenshell.entity_instance, ) -> tuple[ifcopenshell.entity_instance | None, str | None]: - """Return the first ``(rel, kind)`` or ``(None, None)``. Cheaper than - ``find_rels`` when callers only need to know whether a connection + """Return the first ``(subject, kind)`` or ``(None, None)``. Cheaper + than ``find_rels`` when callers only need to know whether a connection exists or what kind it is.""" rels = cls.find_rels(elem_a, elem_b) return rels[0] if rels else (None, None) @@ -109,9 +110,10 @@ class Connection: cls, elem: ifcopenshell.entity_instance, ) -> list[tuple[ifcopenshell.entity_instance, str, ifcopenshell.entity_instance]]: - """Return every supported rel touching ``elem`` as ``(rel, kind, partner)`` - triples. ``partner`` is the *other* element on the rel — the side cascade - cleanup must operate on when ``elem`` is being deleted. + """Return every supported connection touching ``elem`` as + ``(subject, kind, partner)`` triples. ``partner`` is the *other* + element on the connection — the side cascade cleanup must operate on + when ``elem`` is being deleted. Mirrors :py:meth:`find_rels`'s relationship-kind taxonomy. Notably does NOT emit ``"mep-pair-fitting"`` triples: ``IfcRelConnectsPorts`` diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index 911c9990fb..4faae540e7 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -396,13 +396,13 @@ class Geometry(bonsai.core.tool.Geometry): # same OverrideDelete batch. if element.is_a("IfcRoot"): skip_ids = batch_being_deleted_ids or set() - for rel, kind, partner in tool.Connection.find_rels_for_element(element): + for subject, kind, partner in tool.Connection.find_rels_for_element(element): bonsai.core.connection.disconnect_rel( tool.Ifc, tool.Geometry, tool.Model, tool.Connection, - rel=rel, + subject=subject, kind=kind, elem=element, partner=partner, diff --git a/src/bonsai/test/bim/module/model/test_disconnect_elements.py b/src/bonsai/test/bim/module/model/test_disconnect_elements.py index e39bf8af03..09c0c4bf64 100644 --- a/src/bonsai/test/bim/module/model/test_disconnect_elements.py +++ b/src/bonsai/test/bim/module/model/test_disconnect_elements.py @@ -380,9 +380,9 @@ def test_disconnect_dispatches_one_call_per_rel(): assert dispatch.call_count == 2 # Both rels dispatch with elem=elem_a, partner=elem_b regardless of orientation # — orient_element_top inside disconnect_rel recovers the wall/slab roles. - for call, expected_rel, expected_kind in zip(dispatch.call_args_list, [rel1, rel2], ["path", "element-top"]): + for call, expected_subject, expected_kind in zip(dispatch.call_args_list, [rel1, rel2], ["path", "element-top"]): kw = call.kwargs - assert kw["rel"] is expected_rel + assert kw["subject"] is expected_subject assert kw["kind"] == expected_kind assert kw["elem"] is elem_a assert kw["partner"] is elem_b @@ -485,7 +485,7 @@ def test_disconnect_gizmo_direction_symmetry(): # disconnect_rel sees (rel, "element-top") in both runs; elem/partner swap # by argument order but orient_element_top inside disconnect_rel resolves # the wall/slab roles symmetrically. - assert wall_first["rel"] is rel and slab_first["rel"] is rel + assert wall_first["subject"] is rel and slab_first["subject"] is rel assert wall_first["kind"] == slab_first["kind"] == "element-top" assert {wall_first["elem"], wall_first["partner"]} == {wall, slab} assert {slab_first["elem"], slab_first["partner"]} == {wall, slab} diff --git a/src/bonsai/test/bim/module/model/test_mep_disconnect_integration.py b/src/bonsai/test/bim/module/model/test_mep_disconnect_integration.py index 506f26279b..b2af2fa215 100644 --- a/src/bonsai/test/bim/module/model/test_mep_disconnect_integration.py +++ b/src/bonsai/test/bim/module/model/test_mep_disconnect_integration.py @@ -24,7 +24,7 @@ Builds a real IFC scene (two pipe segments joined via ports to a bridging fitting) and exercises the full chain: tool.Connection.find_rels(seg_a, seg_b) → returns (fitting, "mep-pair-fitting") - → bonsai.core.connection.disconnect_rel(rel=fitting, kind=...) + → bonsai.core.connection.disconnect_rel(subject=fitting, kind=...) → tool.Geometry.delete_ifc_object(fitting_obj) → cascade-on-delete removes the IfcRelConnectsPorts via remove_port @@ -100,7 +100,7 @@ class TestMEPPairDisconnectEndToEnd(NewFile): tool.Geometry, tool.Model, tool.Connection, - rel=bend, + subject=bend, kind="mep-pair-fitting", elem=seg_a, partner=seg_b, @@ -127,7 +127,7 @@ class TestMEPPairDisconnectEndToEnd(NewFile): tool.Geometry, tool.Model, tool.Connection, - rel=bend, + subject=bend, kind="mep-pair-fitting", elem=bend, partner=seg_a, diff --git a/src/bonsai/test/core/test_connection.py b/src/bonsai/test/core/test_connection.py index 293bdab05a..4e87a65f74 100644 --- a/src/bonsai/test/core/test_connection.py +++ b/src/bonsai/test/core/test_connection.py @@ -64,7 +64,7 @@ class TestDisconnectRelPath: geometry, model, connection, - rel="rel", + subject="rel", kind="path", elem="elem_a", partner="elem_b", @@ -88,7 +88,7 @@ class TestDisconnectRelPath: geometry, model, connection, - rel="rel", + subject="rel", kind="path", elem="elem", partner="partner", @@ -109,7 +109,7 @@ class TestDisconnectRelPath: geometry, model, connection, - rel="rel", + subject="rel", kind="path", elem="elem", partner="partner", @@ -130,7 +130,7 @@ class TestDisconnectRelPath: geometry, model, connection, - rel="rel", + subject="rel", kind="path", elem="elem", partner="partner", @@ -159,7 +159,7 @@ class TestDisconnectRelElementTop: geometry, model, connection, - rel=rel, + subject=rel, kind="element-top", elem="elem", partner="partner", @@ -182,7 +182,7 @@ class TestDisconnectRelElementTop: Mock(), Mock(), connection, - rel=rel, + subject=rel, kind="element-top", elem="slab", partner="wall", @@ -205,7 +205,7 @@ class TestDisconnectRelElementTop: Mock(), Mock(), connection, - rel=rel, + subject=rel, kind="element-top", elem="wall", partner="slab", @@ -229,7 +229,7 @@ class TestDisconnectRelElementTop: Mock(), Mock(), connection, - rel=rel, + subject=rel, kind="element-top", elem="slab", partner="wall", @@ -250,7 +250,7 @@ class TestDisconnectRelElement: Mock(), Mock(), Mock(), - rel=rel, + subject=rel, kind="element", elem="elem_a", partner="elem_b", @@ -276,7 +276,7 @@ class TestDisconnectRelMEPPairFitting: geometry, Mock(), Mock(), - rel=fitting, + subject=fitting, kind="mep-pair-fitting", elem="seg_a", partner="seg_b", @@ -296,7 +296,7 @@ class TestDisconnectRelMEPPairFitting: geometry, Mock(), Mock(), - rel=fitting, + subject=fitting, kind="mep-pair-fitting", elem="seg_a", partner="seg_b", @@ -316,7 +316,7 @@ class TestDisconnectRelMEPPairFitting: geometry, Mock(), Mock(), - rel=fitting, + subject=fitting, kind="mep-pair-fitting", elem=fitting, partner="other", @@ -335,7 +335,7 @@ class TestDisconnectRelMEPPairFitting: geometry, Mock(), Mock(), - rel=fitting, + subject=fitting, kind="mep-pair-fitting", elem="seg_a", partner=fitting, @@ -347,13 +347,13 @@ class TestDisconnectRelMEPPairFitting: class TestDisconnectRelUnknownKind: def test_raises_value_error(self): - with pytest.raises(ValueError, match="Unknown rel kind"): + with pytest.raises(ValueError, match="Unknown kind"): subject.disconnect_rel( Mock(), Mock(), Mock(), Mock(), - rel="rel", + subject="rel", kind="bogus", elem="a", partner="b", diff --git a/src/bonsai/test/tool/test_connection_forward_compat.py b/src/bonsai/test/tool/test_connection_forward_compat.py index af830353b3..b73ac71947 100644 --- a/src/bonsai/test/tool/test_connection_forward_compat.py +++ b/src/bonsai/test/tool/test_connection_forward_compat.py @@ -19,12 +19,12 @@ # This file was generated with the assistance of an AI coding tool. """Forward-compat AST contract: ``core.connection.disconnect_rel`` must have a -branch for every rel ``kind`` emitted by ``tool.connection.Connection`` lookups. +branch for every ``kind`` emitted by ``tool.connection.Connection`` lookups. -Adding a new rel kind (e.g. ``"void"``, ``"fill"``, ``"interferes"``) to +Adding a new kind (e.g. ``"void"``, ``"fill"``, ``"interferes"``) to ``find_rels`` / ``find_rels_for_element`` without extending ``disconnect_rel`` would silently regress the disconnect operator and the cascade-on-delete: a new -kind would reach the dispatch, hit the ``raise ValueError("Unknown rel kind")`` +kind would reach the dispatch, hit the ``raise ValueError("Unknown kind")`` fallback, and either crash the operator or leave the cascade half-done. This guard makes the symmetry mandatory at test time."""