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..08bd60d01e --- /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..70639881d5 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/clip_box/face_quad.py @@ -0,0 +1,879 @@ +# 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", + "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", + "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..24d72f7b5c --- /dev/null +++ b/src/bonsai/bonsai/bim/module/clip_box/gizmos.py @@ -0,0 +1,312 @@ +# 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..bb2b9af46b 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,59 @@ 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 +286,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..33f96bab16 100644 --- a/src/bonsai/bonsai/bim/module/clip_box/prop.py +++ b/src/bonsai/bonsai/bim/module/clip_box/prop.py @@ -53,16 +53,33 @@ 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() + + +def update_include_linked_ifc(self, context): + tool.ClipBox.invalidate_cap_cache() + + class BIMSceneClipBoxProperties(PropertyGroup): """Scene-level registry of clip boxes in this file. @@ -100,8 +117,49 @@ 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, …)" + ), + ) + # 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( + 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 + 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 b5ae95d9e2..f62a1ae08c 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, "include_linked_ifc") + 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/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 0a2c2678ac..8ec53af5a8 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 / @@ -2609,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]]], @@ -2779,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: @@ -2790,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 @@ -2810,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/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index ecbe544eb9..eaf4c356be 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, ) @@ -401,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, @@ -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/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/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/core/connection.py b/src/bonsai/bonsai/core/connection.py index 71a2bb871c..3ea3bd4bef 100644 --- a/src/bonsai/bonsai/core/connection.py +++ b/src/bonsai/bonsai/core/connection.py @@ -22,11 +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. + +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 @@ -37,25 +44,24 @@ 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], + subject: 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: - """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 @@ -65,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: @@ -75,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 @@ -92,8 +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": + if skip_elem_recreate and subject is elem: + return + if skip_partner_recreate and subject is partner: + return + 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/blender.py b/src/bonsai/bonsai/tool/blender.py index c468b0572e..0594958613 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,83 @@ 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, diff --git a/src/bonsai/bonsai/tool/clip_box.py b/src/bonsai/bonsai/tool/clip_box.py index c477e32ad9..02642c042a 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 @@ -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, @@ -38,6 +40,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 +204,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 +244,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 +522,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 +676,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 +694,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 # @@ -645,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. @@ -657,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() @@ -677,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: @@ -699,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() @@ -709,27 +1000,96 @@ 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 _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. + + 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, @@ -801,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) @@ -929,14 +1312,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 +1344,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/bonsai/tool/connection.py b/src/bonsai/bonsai/tool/connection.py index 4ec154b573..e433ec605e 100644 --- a/src/bonsai/bonsai/tool/connection.py +++ b/src/bonsai/bonsai/tool/connection.py @@ -18,25 +18,33 @@ # # 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`` +- ``"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 from typing import TYPE_CHECKING +import bonsai.tool as tool + if TYPE_CHECKING: import ifcopenshell @@ -45,16 +53,16 @@ class Connection: @classmethod def find_rels( cls, - 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).""" + elem_a: ifcopenshell.entity_instance, + elem_b: ifcopenshell.entity_instance, + ) -> list[tuple[ifcopenshell.entity_instance, str]]: + """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() @@ -79,16 +87,20 @@ 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]": - """Return the first ``(rel, kind)`` or ``(None, None)``. Cheaper than - ``find_rels`` when callers only need to know whether a connection + elem_a: ifcopenshell.entity_instance, + elem_b: ifcopenshell.entity_instance, + ) -> tuple[ifcopenshell.entity_instance | None, str | None]: + """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) @@ -96,17 +108,23 @@ class Connection: @classmethod def find_rels_for_element( 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. + elem: ifcopenshell.entity_instance, + ) -> list[tuple[ifcopenshell.entity_instance, str, ifcopenshell.entity_instance]]: + """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 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 +151,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/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/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/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/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..a7af701e2e --- /dev/null +++ b/src/bonsai/test/bim/module/clip_box/test_add_for_source.py @@ -0,0 +1,124 @@ +# 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..f1ac52044d --- /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..12c683d398 --- /dev/null +++ b/src/bonsai/test/bim/module/clip_box/test_face_quad.py @@ -0,0 +1,294 @@ +# 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_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) 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..c1e4524a9f --- /dev/null +++ b/src/bonsai/test/bim/module/clip_box/test_source_kind_forward_compat.py @@ -0,0 +1,74 @@ +# 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/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..09c0c4bf64 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 # --------------------------------------------------------------------------- @@ -241,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 @@ -346,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_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..b2af2fa215 --- /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(subject=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, + subject=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, + subject=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/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" diff --git a/src/bonsai/test/core/test_connection.py b/src/bonsai/test/core/test_connection.py index 5b09bc4dfa..4e87a65f74 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, + subject="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, + subject="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, + subject="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, + subject="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, + subject=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, + subject=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, + subject=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, + subject=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(), + subject=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(), + subject=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(), + subject=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(), + subject=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(), + subject=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"): + with pytest.raises(ValueError, match="Unknown kind"): subject.disconnect_rel( - Mock(), Mock(), Mock(), Mock(), - rel="rel", kind="bogus", elem="a", partner="b", + Mock(), + Mock(), + Mock(), + Mock(), + subject="rel", + kind="bogus", + elem="a", + partner="b", ) 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..162cf286fb --- /dev/null +++ b/src/bonsai/test/tool/test_clip_box_for_source.py @@ -0,0 +1,369 @@ +# 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 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.""" 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() 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] 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/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 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