diff --git a/src/bonsai/bonsai/bim/module/clip_box/__init__.py b/src/bonsai/bonsai/bim/module/clip_box/__init__.py index baebdd48f1..db3e3cfb4b 100644 --- a/src/bonsai/bonsai/bim/module/clip_box/__init__.py +++ b/src/bonsai/bonsai/bim/module/clip_box/__init__.py @@ -23,16 +23,24 @@ from bpy.app.handlers import persistent import bonsai.tool as tool -from . import operator, prop, ui +from . import face_quad, gizmos, operator, prop, ui classes = ( operator.BIM_OT_add_clip_box, + operator.BIM_OT_add_clip_box_for_source, + operator.BIM_OT_align_view_to_clip_face, operator.BIM_OT_duplicate_clip_box, operator.BIM_OT_remove_clip_box, operator.BIM_OT_set_active_clip_box, operator.BIM_OT_toggle_clip_box_enabled, prop.BIMClipBoxProperties, prop.BIMSceneClipBoxProperties, + face_quad.BIM_GT_box_face_quad, + face_quad.BIM_GT_box_face_outline, + gizmos.OBJECT_GGT_bim_clip_box, + ui.BIM_MT_clip_box_add_for_source, + ui.BIM_MT_clip_box_info, + ui.BIM_MT_clip_box_settings, ui.BIM_UL_clip_box, ui.BIM_PT_clip_box, ) diff --git a/src/bonsai/bonsai/bim/module/clip_box/data.py b/src/bonsai/bonsai/bim/module/clip_box/data.py new file mode 100644 index 0000000000..7d4d515959 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/clip_box/data.py @@ -0,0 +1,212 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""EnumProperty ``items=`` callbacks for the source-based clip-box picker. + +Each callback returns ``[(id_str, label, description)]`` where ``id_str`` is +an IFC entity id stringified for entity-driven kinds, an IFC class name for +``CLASS``, or a fixed status name for ``STATUS``. The clip-box operator +turns the picked id into a ``matrix_world`` via the source-preset helper. +""" + +from __future__ import annotations + +import bonsai.tool as tool + +EnumItems = list[tuple[str, str, str]] + +# Module-level cache. Blender's EnumProperty stores raw char pointers from the +# tuples a callback returns, so the Python strings must outlive the draw call. +# Stashing the latest result per kind keeps them alive across callback firings. +_items_cache: dict[str, EnumItems] = {} + +# Sentinel id used for the "no options available" placeholder. The operator +# treats this as an invalid pick and surfaces an ERROR. +NO_OPTIONS_ID = "__none__" + + +def _cache(kind: str, items: EnumItems) -> EnumItems: + _items_cache[kind] = items + return items + + +def _no_options(label: str) -> EnumItems: + # Blender refuses to draw an EnumProperty with zero entries — show a + # placeholder so the dialog renders and the user sees the empty state. + return [(NO_OPTIONS_ID, label, "")] + + +def _label(entity, ifc_class: str | None = None) -> str: + name = (getattr(entity, "Name", None) or "Unnamed").strip() or "Unnamed" + return f"{ifc_class}: {name}" if ifc_class else name + + +def _build_items(kind: str, empty_label: str, build_fn) -> EnumItems: + """Shared shape for the IFC-driven enum callbacks. + + Returns the no-IFC placeholder if no file is loaded, then runs + ``build_fn(ifc_file)``, sorts the result alphabetically by label, and + returns the empty-result placeholder if nothing matched. The output is + always routed through the module cache. + """ + ifc = tool.Ifc.get() + if ifc is None: + return _cache(kind, _no_options("No IFC loaded")) + items = build_fn(ifc) + items.sort(key=lambda t: t[1].lower()) + if not items: + return _cache(kind, _no_options(empty_label)) + return _cache(kind, items) + + +# Top-down spatial hierarchy so the picker reads in the order an architect +# already thinks in, rather than a flat alphabetical mix. IfcSpace is excluded +# — spaces are typically empty volumes used for room metadata, so clipping to +# one rarely matches the user intent of "show me what's in this container". +SPATIAL_CLASSES: tuple[str, ...] = ( + "IfcProject", + "IfcSite", + "IfcBuilding", + "IfcBuildingStorey", +) + + +def spatial_items(self, context) -> EnumItems: + # Special-case: per-class sort within the hierarchy order rather than a + # flat alphabetical sort, so the dropdown reads project → site → building. + ifc = tool.Ifc.get() + if ifc is None: + return _cache("SPATIAL", _no_options("No IFC loaded")) + items: EnumItems = [] + for ifc_class in SPATIAL_CLASSES: + try: + entities = ifc.by_type(ifc_class, include_subtypes=False) + except RuntimeError: + continue + for entity in sorted(entities, key=lambda e: (e.Name or "").lower()): + items.append((str(entity.id()), _label(entity, ifc_class), "")) + if not items: + return _cache("SPATIAL", _no_options("No spatial containers")) + return _cache("SPATIAL", items) + + +def class_items(self, context) -> EnumItems: + # Special-case: the picker value IS the IFC class name, not an entity id, + # so the build shape differs from the other entity-driven callbacks. + ifc = tool.Ifc.get() + if ifc is None: + return _cache("CLASS", _no_options("No IFC loaded")) + # List only IFC classes ACTUALLY present in the file (not the whole + # schema), so the user picks from classes that can produce a non-empty + # clip volume. ``e.is_a()`` returns the most specific class per element. + present = sorted({e.is_a() for e in ifc.by_type("IfcProduct")}) + if not present: + return _cache("CLASS", _no_options("No products")) + return _cache("CLASS", [(cls, cls, "") for cls in present]) + + +def type_items(self, context) -> EnumItems: + return _build_items( + "TYPE", "No types defined", + lambda ifc: [(str(e.id()), _label(e, e.is_a()), "") for e in ifc.by_type("IfcTypeProduct")], + ) + + +def material_items(self, context) -> EnumItems: + return _build_items( + "MATERIAL", "No materials defined", + lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcMaterial")], + ) + + +def profile_items(self, context) -> EnumItems: + # ProfileName is optional. Skip unnamed profiles — they can't be + # meaningfully picked from a flat list. + return _build_items( + "PROFILE", "No named profiles", + lambda ifc: [ + (str(e.id()), f"{e.is_a()}: {e.ProfileName}", "") + for e in ifc.by_type("IfcProfileDef") + if getattr(e, "ProfileName", None) + ], + ) + + +def drawing_items(self, context) -> EnumItems: + return _build_items( + "DRAWING", "No drawings defined", + lambda ifc: [ + (str(e.id()), _label(e), "") + for e in ifc.by_type("IfcAnnotation") + if e.ObjectType == "DRAWING" + ], + ) + + +# Display labels for each status value. The id strings on the left are the +# canonical Pset_*Common.Status enum values accepted by Bonsai's status query. +STATUS_LABELS: tuple[tuple[str, str], ...] = ( + ("No Status", "No Status"), + ("NEW", "New"), + ("EXISTING", "Existing"), + ("DEMOLISH", "Demolish"), + ("TEMPORARY", "Temporary"), + ("OTHER", "Other"), + ("NOTKNOWN", "Not Known"), + ("UNSET", "Unset"), +) + + +def status_items(self, context) -> EnumItems: + # Fixed enum; no IFC needed. Still routed through the cache to share the + # same string-lifetime guarantee as the other callbacks. + return _cache("STATUS", [(value, label, "") for value, label in STATUS_LABELS]) + + +def system_items(self, context) -> EnumItems: + # IfcStructuralAnalysisModel is a structural-grouping container, not a + # distribution system — excluded to match Bonsai's other system pickers. + return _build_items( + "SYSTEM", "No systems defined", + lambda ifc: [ + (str(e.id()), _label(e, e.is_a()), "") + for e in ifc.by_type("IfcSystem") + if not e.is_a("IfcStructuralAnalysisModel") + ], + ) + + +def group_items(self, context) -> EnumItems: + # include_subtypes=False so IfcSystem and IfcZone instances don't appear + # under Group as well — those get their own picker entries. + return _build_items( + "GROUP", "No groups defined", + lambda ifc: [ + (str(e.id()), _label(e), "") + for e in ifc.by_type("IfcGroup", include_subtypes=False) + ], + ) + + +def zone_items(self, context) -> EnumItems: + return _build_items( + "ZONE", "No zones defined", + lambda ifc: [(str(e.id()), _label(e), "") for e in ifc.by_type("IfcZone")], + ) diff --git a/src/bonsai/bonsai/bim/module/clip_box/face_quad.py b/src/bonsai/bonsai/bim/module/clip_box/face_quad.py new file mode 100644 index 0000000000..0e27d6eed0 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/clip_box/face_quad.py @@ -0,0 +1,921 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Generic face-quad resize gizmos for any axis-aligned local box. + +This module contains the box-agnostic core of the interactive +face-resize gizmos: two Gizmo classes (a near-invisible click target +welded to each face, and a thin colored edge outline), a per-redraw +orchestrator that places six of each on a box, and the pure one-sided +resize arithmetic. None of it knows about IFC, clip boxes, or +``BIMSceneClipBoxProperties`` — a future camera-view-box adapter can +reuse the same classes and helpers. + +Consumer contract — the adapter group must: + +1. Create six ``BIM_GT_box_face_quad`` and six ``BIM_GT_box_face_outline`` + instances at ``setup()`` time, in :data:`FACE_ROUTES` order, and bind + each quad's ``move_get_cb`` / ``move_set_cb`` to closures that read + and mutate the box's host (e.g. an Empty's ``location`` / ``scale``). +2. Call :func:`apply_face_quad_layout` from ``refresh()`` / + ``draw_prepare()`` with the box's local-frame ``bmin`` / ``bmax``, + the host's ``matrix_world``, the OBB rotation as a 4x4 + (``Matrix.Identity(4)`` when the rotation rides in ``matrix_world``), + and the current ``region`` / ``rv3d``. +3. Implement ``_lock_for(active_gz)`` / ``_unlock_all()`` on the group + for drag mutual exclusion; the quad's ``invoke`` / ``exit`` call them. + +The resize arithmetic in :func:`compute_face_resize` is pure: feed it +the modal scalar plus drag-start snapshots and it returns the host's +new scale-on-axis and new origin location. +""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from typing import Any + +import bpy +from bpy_extras.view3d_utils import location_3d_to_region_2d, region_2d_to_location_3d +from mathutils import Matrix, Vector + + +# --------------------------------------------------------------------------- +# Public iteration order +# --------------------------------------------------------------------------- + +# (axis, is_max) pairs. The adapter group's ``setup()`` MUST create its +# six face-quad gizmos in this order so positional indexing into the +# layout helper stays correct. +FACE_ROUTES: tuple[tuple[int, bool], ...] = ( + (0, False), + (0, True), + (1, False), + (1, True), + (2, False), + (2, True), +) + + +# --------------------------------------------------------------------------- +# Public visual constants (adapter reads these in setup()) +# --------------------------------------------------------------------------- + +# Standard XYZ axis colors (Blender convention). +AXIS_COLOR: dict[int, tuple[float, float, float]] = { + 0: (1.0, 0.2, 0.2), + 1: (0.2, 1.0, 0.2), + 2: (0.2, 0.4, 1.0), +} + +# Documented "selectable but unpainted" trick: the GPU still writes the +# selection buffer at this alpha so clicks register, but no visible +# pixels are produced. +FACE_QUAD_ALPHA: float = 0.001 + +# Very faint hover tint — just enough to confirm "you're aiming at this +# face" without painting visibly over geometry behind it. +FACE_QUAD_ALPHA_HIGHLIGHT: float = 0.04 + +# Setup-time default for ``select_bias``; the layout helper overwrites +# it per frame to the front-facing or halo value below. Kept below the +# canonical arrow bias so a bailed frame can't let a front quad steal +# clicks meant for a hidden control. +FACE_QUAD_SELECT_BIAS: float = 0.5 + + +# --------------------------------------------------------------------------- +# Internal constants +# --------------------------------------------------------------------------- + +# Unit quad in the local XY plane spanning [-0.5, 0.5]^2 at z=0. Two +# CCW triangles viewed from +Z. matrix_basis stretches it onto the +# face's perpendicular extents. +_QUAD_TRIS: list[tuple[float, float, float]] = [ + (-0.5, -0.5, 0.0), + (0.5, -0.5, 0.0), + (0.5, 0.5, 0.0), + (-0.5, -0.5, 0.0), + (0.5, 0.5, 0.0), + (-0.5, 0.5, 0.0), +] + +# Unit-quad outline as 4 line segments in the local XY plane at z=0. +_QUAD_OUTLINE_LINES: list[tuple[float, float, float]] = [ + (-0.5, -0.5, 0.0), + (0.5, -0.5, 0.0), + (0.5, -0.5, 0.0), + (0.5, 0.5, 0.0), + (0.5, 0.5, 0.0), + (-0.5, 0.5, 0.0), + (-0.5, 0.5, 0.0), + (-0.5, -0.5, 0.0), +] + +# Degenerate zero-area triangle for hidden back-facing quads with no +# visible-adjacent neighbours (rare orientation). Blender tolerates +# this; the gizmo is hidden anyway so nothing renders. +_EMPTY_TRIS: list[tuple[float, float, float]] = [ + (0.0, 0.0, 0.0), + (0.0, 0.0, 0.0), + (0.0, 0.0, 0.0), +] + +# Rotates the gizmo's local +Z onto the outward face normal in the +# box's local frame. Right-hand rotation around the named axis. +_AXIS_ORIENT: dict[tuple[int, bool], Matrix] = { + (0, False): Matrix.Rotation(-math.pi / 2, 4, "Y"), + (0, True): Matrix.Rotation(math.pi / 2, 4, "Y"), + (1, False): Matrix.Rotation(math.pi / 2, 4, "X"), + (1, True): Matrix.Rotation(-math.pi / 2, 4, "X"), + (2, False): Matrix.Rotation(math.pi, 4, "X"), + (2, True): Matrix.Identity(4), +} + +# Per-face mapping from face-quad local axes to local box axes for the +# perpendicular-extent scale. ``(w_axis, h_axis)`` — the box-local axis +# indices the quad's local X and Y span after the orientation rotation. +_QUAD_PERP_AXES: dict[tuple[int, bool], tuple[int, int]] = { + (0, False): (2, 1), + (0, True): (2, 1), + (1, False): (0, 2), + (1, True): (0, 2), + (2, False): (0, 1), + (2, True): (0, 1), +} + +# Front-facing quad sits ABOVE the halo strips so the cursor on the +# visible face area always grabs the visible face, never accidentally +# routes to a back-face halo strip in an adjacent screen region. +_FACE_QUAD_FRONT_FACING_SELECT_BIAS: float = 1.5 +_FACE_QUAD_HALO_FRAME_SELECT_BIAS: float = 1.0 + +# Target halo-strip thickness in screen pixels. The world-space margin +# is recomputed per frame so the rim stays a roughly constant on-screen +# size regardless of viewport zoom. +_FACE_QUAD_HALO_TARGET_PIXELS: float = 20.0 + +# Minimum world half-extent a face resize may shrink to. Stops a drag +# from collapsing the host to zero or negative scale. +_MIN_HALF_EXTENT: float = 1e-4 + + +# --------------------------------------------------------------------------- +# Pure predicates (testable without Blender) +# --------------------------------------------------------------------------- + +Vec3 = tuple[float, float, float] + + +def face_outward_axis_local(axis: int, is_max: bool) -> Vec3: + """Un-rotated outward face normal in the box's local AABB coords. + + For ``(axis=0, is_max=True)`` returns ``(+1, 0, 0)``; for the −X + face ``(-1, 0, 0)``; etc. The rotated world normal is obtained by + applying the host's rotation and the OBB rotation: + ``mw_rot @ cage_rotation @ this``. + """ + sign = 1.0 if is_max else -1.0 + out = [0.0, 0.0, 0.0] + out[axis] = sign + return (out[0], out[1], out[2]) + + +def front_facing_face_mask( + face_normals_world: Sequence[Vec3], + view_dir_world: Vec3, + eps: float = 1e-6, +) -> tuple[bool, ...]: + """Which of the 6 box faces point toward the camera. + + A face is front-facing iff its outward normal points AGAINST the + view direction (``dot(normal, view_dir) < -eps``). The ``-eps`` + margin prevents flicker at grazing angles. + + ``face_normals_world`` must be in :data:`FACE_ROUTES` order; returns + a 6-tuple of bool parallel to that order. + """ + if len(face_normals_world) != 6: + msg = f"expected 6 face normals, got {len(face_normals_world)}" + raise ValueError(msg) + vx, vy, vz = view_dir_world + return tuple((n[0] * vx + n[1] * vy + n[2] * vz) < -eps for n in face_normals_world) + + +def view_axis_parallel_face_mask( + face_normals_world: Sequence[Vec3], + view_dir_world: Vec3, + threshold: float = 0.95, +) -> tuple[bool, ...]: + """Which faces have normals (anti-)parallel to the view direction. + + True iff ``abs(dot(normal, view_dir)) >= threshold`` — i.e. the + face is nearly perpendicular to the screen plane. Provided as a + pure predicate for callers that want to detect degenerate-drag + conditions; the layout helper itself no longer gates on it. + """ + if len(face_normals_world) != 6: + msg = f"expected 6 face normals, got {len(face_normals_world)}" + raise ValueError(msg) + vx, vy, vz = view_dir_world + return tuple( + abs(n[0] * vx + n[1] * vy + n[2] * vz) >= threshold for n in face_normals_world + ) + + +# --------------------------------------------------------------------------- +# Pure resize arithmetic +# --------------------------------------------------------------------------- + + +def compute_face_resize( + *, + value: float, + init_world_half: float, + init_location: tuple[float, float, float], + world_axis: tuple[float, float, float], + display_size: float, +) -> tuple[float, tuple[float, float, float]]: + """Pure one-sided face-resize arithmetic. + + Returns ``(new_scale_axis, new_location)`` — the host's new scale + on the dragged axis and its new world origin — such that the + dragged face moves by the modal's outward delta while the OPPOSITE + face stays put. + + ``value`` is ``init + delta``, where ``init`` is the unsigned + drag-start world half-extent and ``delta`` is the cursor projection + onto the face's OUTWARD world normal. Realized half-extent is + clamped to a small floor; the location shift uses the realized + (post-clamp) delta so the opposite face stays fixed even at the + clamp. + """ + face_delta = value - init_world_half + new_world_half = init_world_half + 0.5 * face_delta + if new_world_half < _MIN_HALF_EXTENT: + new_world_half = _MIN_HALF_EXTENT + realized_delta = 2.0 * (new_world_half - init_world_half) + + ds = display_size if display_size != 0.0 else 1.0 + new_scale_axis = new_world_half / ds + shift = 0.5 * realized_delta + new_location = ( + init_location[0] + shift * world_axis[0], + init_location[1] + shift * world_axis[1], + init_location[2] + shift * world_axis[2], + ) + return new_scale_axis, new_location + + +# --------------------------------------------------------------------------- +# Internal geometry helpers +# --------------------------------------------------------------------------- + + +def _compute_face_quad_scale( + bmin: Any, bmax: Any, axis: int, is_max: bool +) -> tuple[float, float]: + """Return ``(w, h)`` for the face quad's scale matrix.""" + w_axis, h_axis = _QUAD_PERP_AXES[(axis, is_max)] + w = float(bmax[w_axis] - bmin[w_axis]) + h = float(bmax[h_axis] - bmin[h_axis]) + return w, h + + +def _shared_edge_corner_keys( + axis_a: int, is_max_a: bool, axis_b: int, is_max_b: bool +) -> tuple[tuple[int, int, int], tuple[int, int, int]] | None: + """Return the 2 corner-bit triples shared by two adjacent faces. + + Corner keys are 3-tuples of bits (0 = bmin, 1 = bmax). The two + returned corners are ordered with the free-axis bit ascending. + """ + if axis_a == axis_b: + return None + free_axis = 3 - axis_a - axis_b + bit_a = 1 if is_max_a else 0 + bit_b = 1 if is_max_b else 0 + corner_lo = [0, 0, 0] + corner_hi = [0, 0, 0] + corner_lo[axis_a] = bit_a + corner_hi[axis_a] = bit_a + corner_lo[axis_b] = bit_b + corner_hi[axis_b] = bit_b + corner_lo[free_axis] = 0 + corner_hi[free_axis] = 1 + return ( + (corner_lo[0], corner_lo[1], corner_lo[2]), + (corner_hi[0], corner_hi[1], corner_hi[2]), + ) + + +def _face_corner_keys( + axis: int, is_max: bool +) -> tuple[ + tuple[int, int, int], + tuple[int, int, int], + tuple[int, int, int], + tuple[int, int, int], +]: + """Return the 4 corner-bit triples of a face in CCW order. + + Triangulation as ``[(0,1,2), (0,2,3)]`` covers the whole face with + two non-overlapping triangles. + """ + fixed_bit = 1 if is_max else 0 + free_axes = [a for a in (0, 1, 2) if a != axis] + fa0, fa1 = free_axes + corners = [] + for ka, kb in ((0, 0), (1, 0), (1, 1), (0, 1)): + key = [0, 0, 0] + key[axis] = fixed_bit + key[fa0] = ka + key[fa1] = kb + corners.append((key[0], key[1], key[2])) + return (corners[0], corners[1], corners[2], corners[3]) + + +def _build_strip_tris_relative( + edge_p0_local: tuple[float, float, float], + edge_p1_local: tuple[float, float, float], + extrusion_local: tuple[float, float, float], +) -> list[tuple[float, float, float]]: + """Build two CCW triangles (6 vertices) for a thin halo strip. + + All inputs are in coords relative to the gizmo's ``matrix_basis`` + anchor. The strip runs along ``[edge_p0_local, edge_p1_local]`` and + extrudes by ``extrusion_local`` perpendicular to the edge. + """ + p0x, p0y, p0z = edge_p0_local + p1x, p1y, p1z = edge_p1_local + ex, ey, ez = extrusion_local + p0e = (p0x + ex, p0y + ey, p0z + ez) + p1e = (p1x + ex, p1y + ey, p1z + ez) + return [ + (p0x, p0y, p0z), + p0e, + p1e, + (p0x, p0y, p0z), + p1e, + (p1x, p1y, p1z), + ] + + +def _strips_geometry_changed(quad_gz, face_quad_local, all_tris) -> bool: + """True if the back-face quad's geometry differs from the cached upload. + + Pure orbit/pan doesn't change either the box pose or the cage + rotation, so the computed strip vertices are byte-identical to the + previous frame's. Hitting the cache lets the back-facing branch + skip ``new_custom_shape`` and the GPU upload. + """ + cached = getattr(quad_gz, "_strips_cache_key", None) + last_state = getattr(quad_gz, "_last_geometry_state", None) + key = (face_quad_local, all_tris) + if cached is None or last_state != "strips" or cached != key: + quad_gz._strips_cache_key = key + quad_gz._last_geometry_state = "strips" + return True + return False + + +def _compute_face_basis( + mw: Any, + mw_rot: Any, + cage_rotation: Any, + pivot_local: Any, + face_local: Any, + orient: Any, +) -> tuple[Any, Any]: + """World-space (translation, outward-normal-direction) for one face.""" + rotated_face_local = ( + cage_rotation.to_3x3() @ (face_local - pivot_local) + pivot_local + ) + face_world = mw @ rotated_face_local + world_axis = ( + mw_rot @ cage_rotation.to_3x3() @ (orient.to_3x3() @ Vector((0.0, 0.0, 1.0))) + ).normalized() + return face_world, world_axis + + +def _compose_face_matrix_basis( + face_world: Any, + mw_rot_scale: Any, + cage_rotation: Any, + orient: Any, + w: float, + h: float, +) -> Any: + """Compose the 5-term ``matrix_basis`` for a face-plane gizmo. + + Returns ``Translation @ mw_rot_scale @ cage_rotation @ orient @ + Diagonal((w, h, 1, 1))`` — maps a unit-square local quad onto the + world-space face rectangle, including the host's scale. + """ + quad_scale = Matrix.Diagonal((w, h, 1.0, 1.0)) + return ( + Matrix.Translation(face_world) + @ mw_rot_scale.to_4x4() + @ cage_rotation + @ orient + @ quad_scale + ) + + +def _compute_box_corners_world( + bmin: Any, + bmax: Any, + pivot_local: Any, + cage_rotation_3x3: Any, + mw: Any, +) -> dict[tuple[int, int, int], Any]: + """Return the 8 OBB corners in world space, keyed by bit-triple.""" + corners: dict[tuple[int, int, int], Any] = {} + for ix in (0, 1): + for iy in (0, 1): + for iz in (0, 1): + local = Vector( + ( + float(bmax.x if ix else bmin.x), + float(bmax.y if iy else bmin.y), + float(bmax.z if iz else bmin.z), + ) + ) + rotated = cage_rotation_3x3 @ (local - pivot_local) + pivot_local + corners[(ix, iy, iz)] = mw @ rotated + return corners + + +def _abs_scale_matrix(mw: Any) -> Any: + """Return a copy of ``mw`` with all scale components ``abs()``-ed. + + Without this, a negative-scale host produces a visible/clickable + face inversion: ``mw @ local_vec`` flips the +axis face onto the + -axis world side, while the rotation-only normal stays pointing + in the +axis direction — so the gizmo for "the +X face" sits at + world -X but reports its outward normal as +X. + """ + loc, rot, scale = mw.decompose() + abs_scale = Vector((abs(scale.x), abs(scale.y), abs(scale.z))) + return Matrix.LocRotScale(loc, rot, abs_scale) + + +def _world_radius_to_screen_pixels( + region: Any, + rv3d: Any, + center_world: Vector, + world_radius: float, + *, + min_pixels: float = 0.0, +) -> float: + """Return the on-screen pixel radius of a world-space circle. + + Projects ``center_world`` and a sample point offset by + ``world_radius`` along the camera's view-aligned right axis to + region pixels, and returns the screen-pixel distance between them. + Falls back to ``min_pixels`` if either projection fails. + """ + try: + view_inv = rv3d.view_matrix.inverted() + right = Vector((view_inv[0][0], view_inv[0][1], view_inv[0][2])).normalized() + except (AttributeError, ValueError): + right = Vector((1.0, 0.0, 0.0)) + sample_world = center_world + right * world_radius + return _world_segment_to_screen_pixels( + region, rv3d, center_world, sample_world, min_pixels=min_pixels + ) + + +def _world_segment_to_screen_pixels( + region: Any, + rv3d: Any, + p0_world: Vector, + p1_world: Vector, + *, + min_pixels: float = 0.0, +) -> float: + """Return the on-screen pixel length of an arbitrary world segment. + + Unlike :func:`_world_radius_to_screen_pixels`, this measures the + ACTUAL projected length of the segment — foreshortening included. + Use this when the segment direction is known to be oblique to the + screen plane (e.g. a back face's outward normal): a perpendicular + radius measurement overestimates the on-screen length, leaving + halo strips visually narrower than the requested pixel target. + """ + p0 = location_3d_to_region_2d(region, rv3d, p0_world) + p1 = location_3d_to_region_2d(region, rv3d, p1_world) + if not p0 or not p1: + return min_pixels + dx = float(p1[0]) - float(p0[0]) + dy = float(p1[1]) - float(p0[1]) + return max(min_pixels, (dx * dx + dy * dy) ** 0.5) + + +# --------------------------------------------------------------------------- +# Gizmo classes +# --------------------------------------------------------------------------- + + +class BIM_GT_box_face_quad(bpy.types.Gizmo): # noqa: N801 — Blender bl_idname convention + """Near-invisible face-quad click target with drag-to-resize modal. + + Geometry: a unit quad in the local XY plane at z=0. The adapter + group's layout helper rotates and scales it onto the face plane; + the quad is welded to the world face (``use_draw_scale = False``). + """ + + bl_idname = "BIM_GT_box_face_quad" + bl_target_properties = ({"id": "offset", "type": "FLOAT", "array_length": 1},) + + __slots__ = ( + "custom_shape", + "custom_shape_select", + "init_value", + "move_get_cb", + "move_set_cb", + "axis", + "start_location", + "depth_point", + "callback", + "ctrl_click_cb", + "_group", + "_face_axis", + "is_max", + "_drag_snapshot", + "_last_geometry_state", + "_strips_cache_key", + ) + + def draw(self, context: Any) -> None: + self.draw_custom_shape(self.custom_shape) + + def draw_select(self, context: Any, select_id: int) -> None: + # Back-facing quads bind ``custom_shape_select`` to the halo-strip + # TRIS so clicks OUTSIDE the box silhouette catch the back face. + # Front-facing quads leave it None and reuse ``custom_shape``. + shape = getattr(self, "custom_shape_select", None) or self.custom_shape + self.draw_custom_shape(shape, select_id=select_id) + + def setup(self) -> None: + if not hasattr(self, "custom_shape_"): + self.custom_shape = self.new_custom_shape("TRIS", _QUAD_TRIS) + self.custom_shape_select = None + # Quad welded to world geometry — clicks must align with the + # visible face, not a screen-size widget. Disables Blender's + # per-frame pixel-constant autoscale. + self.use_draw_scale = False + + # ---- modal ------------------------------------------------------------- + + def invoke(self, context: Any, event: Any) -> set[str]: + # CTRL+click handoff: dispatch a host-defined callback (e.g. + # align-view) instead of starting a drag. + if event.ctrl and getattr(self, "ctrl_click_cb", None) is not None: + self.ctrl_click_cb(context, event) + return {"FINISHED"} + + region = context.region + rv3d = context.region_data + if region is None or rv3d is None: + return {"CANCELLED"} + self.init_value = self.move_get_cb() + # Freeze the projection plane at invoke — projection-plane + # drift on tilted axes causes exponential delta runaway. + self.depth_point = self.matrix_basis.translation.copy() + self.start_location = region_2d_to_location_3d( + region, rv3d, (event.mouse_x, event.mouse_y), self.depth_point + ) + + if getattr(self, "_group", None) is not None: + self._group._lock_for(self) + return {"RUNNING_MODAL"} + + def exit(self, context: Any, cancel: bool) -> None: + try: + if context.area: + context.area.header_text_set(None) + if cancel: + self.move_set_cb(self.init_value) + if hasattr(self, "callback"): + self.callback(self.move_get_cb()) + finally: + self._drag_snapshot = None + if getattr(self, "_group", None) is not None: + self._group._unlock_all() + + def modal(self, context: Any, event: Any, tweak: set[str]) -> set[str]: + if event.type == "ESC": + return {"CANCELLED"} + region = context.region + rv3d = context.region_data + if region is None or rv3d is None: + return {"CANCELLED"} + end_location = region_2d_to_location_3d( + region, rv3d, (event.mouse_x, event.mouse_y), self.depth_point + ) + delta = (end_location - self.start_location).dot(self.axis) + if "SNAP" in tweak: + delta = round(delta, 1) + if "PRECISE" in tweak: + delta /= 10.0 + self.move_set_cb(self.init_value + delta) + if context.area: + context.area.header_text_set( + f"Value: {self.move_get_cb():.3f} ({delta:.3f})" + ) + return {"RUNNING_MODAL"} + + +class BIM_GT_box_face_outline(bpy.types.Gizmo): # noqa: N801 — Blender bl_idname convention + """Thin non-interactive colored edge outline for one face. + + Drawn as 4 line segments in the face plane. The layout helper + toggles its ``alpha`` between near-zero and ``1.0`` based on the + sibling face-quad's ``is_highlight`` state — so hovering the quad + lights up the matching outline. ``hide_select = True`` keeps the + outline out of the GPU selection buffer. + """ + + bl_idname = "BIM_GT_box_face_outline" + bl_target_properties = () + + __slots__ = ( + "custom_shape", + "_face_axis", + "is_max", + "_last_outline_state", + ) + + def draw(self, context: Any) -> None: + self.draw_custom_shape(self.custom_shape) + + def draw_select(self, context: Any, select_id: int) -> None: + return None + + def setup(self) -> None: + if not hasattr(self, "custom_shape_"): + self.custom_shape = self.new_custom_shape("LINES", _QUAD_OUTLINE_LINES) + self.use_draw_scale = False + self.hide_select = True + self._last_outline_state = "unit" + + +# --------------------------------------------------------------------------- +# Per-redraw orchestrator +# --------------------------------------------------------------------------- + + +def apply_face_quad_layout( + *, + quad_gizmos, + outline_gizmos, + bmin: Any, + bmax: Any, + matrix_world: Any, + cage_rotation: Any, + region: Any, + rv3d: Any, + locked: bool, +) -> None: + """Lay out 6 face quads + 6 outlines on the box for this redraw. + + ``quad_gizmos`` / ``outline_gizmos`` are length-6 sequences in + :data:`FACE_ROUTES` order. ``bmin`` / ``bmax`` are the box corners + in the host's local frame; ``matrix_world`` is the host's world + matrix; ``cage_rotation`` is the OBB rotation as a 4x4 (use + ``Matrix.Identity(4)`` when rotation rides in ``matrix_world``). + ``region`` / ``rv3d`` drive the view-dependent front/back split and + the screen-constant halo margin; passing ``rv3d = None`` bails. + + Negative scale on the host is normalized to positive internally so + the visible cube and the clickable face gizmos stay aligned — + callers don't need to pre-process ``matrix_world``. + + When ``locked`` (a drag is active), ``hide`` / ``select_bias`` + writes are skipped — the active quad's geometry is still refreshed + so it tracks the moving box. + """ + if rv3d is None or getattr(rv3d, "view_rotation", None) is None: + return + if len(quad_gizmos) != 6 or len(outline_gizmos) != 6: + return + + mw = _abs_scale_matrix(matrix_world) + mw_rot = mw.to_quaternion().to_matrix() + mw_rot_scale = mw.to_3x3() + cage_rotation_3x3 = cage_rotation.to_3x3() + pivot_local = (bmin + bmax) * 0.5 + box_center_local = pivot_local + face_midpoints_local = { + (0, False): Vector((float(bmin.x), box_center_local.y, box_center_local.z)), + (0, True): Vector((float(bmax.x), box_center_local.y, box_center_local.z)), + (1, False): Vector((box_center_local.x, float(bmin.y), box_center_local.z)), + (1, True): Vector((box_center_local.x, float(bmax.y), box_center_local.z)), + (2, False): Vector((box_center_local.x, box_center_local.y, float(bmin.z))), + (2, True): Vector((box_center_local.x, box_center_local.y, float(bmax.z))), + } + + view_dir = (rv3d.view_rotation @ Vector((0.0, 0.0, -1.0))).normalized() + view_dir_tuple = (float(view_dir.x), float(view_dir.y), float(view_dir.z)) + face_normals_world = [] + for route_axis, route_is_max in FACE_ROUTES: + axis_local = Vector(face_outward_axis_local(route_axis, route_is_max)) + n_world = (mw_rot @ cage_rotation_3x3 @ axis_local).normalized() + face_normals_world.append( + (float(n_world.x), float(n_world.y), float(n_world.z)) + ) + front = front_facing_face_mask(tuple(face_normals_world), view_dir_tuple) + + box_center_world = mw @ pivot_local + corners_world = _compute_box_corners_world( + bmin, bmax, pivot_local, cage_rotation_3x3, mw + ) + route_to_index = {route: i for i, route in enumerate(FACE_ROUTES)} + + for i, route in enumerate(FACE_ROUTES): + quad_gz = quad_gizmos[i] + is_front = front[i] + axis_b, is_max_b = route + + # Place the colored OUTLINE on every face using the same composed + # face matrix the front-facing solid quad uses. Hidden/shown via + # alpha at the end of the pass. + outline_orient = _AXIS_ORIENT[route] + outline_face_world, _outline_axis = _compute_face_basis( + mw, + mw_rot, + cage_rotation, + pivot_local, + face_midpoints_local[route], + outline_orient, + ) + ow, oh = _compute_face_quad_scale(bmin, bmax, axis_b, is_max_b) + outline_gizmos[i].matrix_basis = _compose_face_matrix_basis( + outline_face_world, mw_rot_scale, cage_rotation, outline_orient, ow, oh + ) + + if is_front: + if not locked: + quad_gz.hide = False + quad_gz.select_bias = _FACE_QUAD_FRONT_FACING_SELECT_BIAS + orient = _AXIS_ORIENT[route] + face_world, world_axis = _compute_face_basis( + mw, + mw_rot, + cage_rotation, + pivot_local, + face_midpoints_local[route], + orient, + ) + w, h = _compute_face_quad_scale(bmin, bmax, axis_b, is_max_b) + quad_gz.matrix_basis = _compose_face_matrix_basis( + face_world, mw_rot_scale, cage_rotation, orient, w, h + ) + quad_gz.axis = world_axis + if getattr(quad_gz, "_last_geometry_state", None) != "solid": + quad_gz.custom_shape = quad_gz.new_custom_shape("TRIS", _QUAD_TRIS) + quad_gz.custom_shape_select = None + quad_gz._last_geometry_state = "solid" + continue + + # Back-facing: anchor at the back face centre; build halo strips + # in the planes of the adjacent FRONT faces, extruded outside + # the silhouette toward this face's outward normal. + face_world = mw @ ( + cage_rotation_3x3 @ (face_midpoints_local[route] - pivot_local) + + pivot_local + ) + quad_gz.matrix_basis = Matrix.Translation(face_world) + quad_gz.axis = ( + mw_rot + @ cage_rotation_3x3 + @ Vector(face_outward_axis_local(axis_b, is_max_b)) + ).normalized() + + adjacent_front_routes = [ + (axis_a, is_max_a) + for axis_a in range(3) + if axis_a != axis_b + for is_max_a in (False, True) + if front[route_to_index[(axis_a, is_max_a)]] + ] + # Per-face world margin: measure the screen-projected length of + # ONE world unit along THIS face's outward normal. The world + # margin that yields ~N pixels on screen is then ``N / length``. + # Foreshortening on oblique faces shortens the projected step, + # so the world step must grow to keep the strip the same width + # on screen. + face_world_margin = 0.0 + if region is not None: + sample_end = box_center_world + quad_gz.axis * 1.0 + screen_step = _world_segment_to_screen_pixels( + region, rv3d, box_center_world, sample_end, min_pixels=0.0 + ) + if screen_step > 0.0: + face_world_margin = _FACE_QUAD_HALO_TARGET_PIXELS / screen_step + if face_world_margin <= 0.0 or not adjacent_front_routes: + if not locked: + quad_gz.hide = True + quad_gz.select_bias = _FACE_QUAD_HALO_FRAME_SELECT_BIAS + if getattr(quad_gz, "_last_geometry_state", None) != "empty": + quad_gz.custom_shape = quad_gz.new_custom_shape("TRIS", _EMPTY_TRIS) + quad_gz.custom_shape_select = None + quad_gz._last_geometry_state = "empty" + continue + + extrusion_world = quad_gz.axis * face_world_margin + extrusion_local = ( + float(extrusion_world.x), + float(extrusion_world.y), + float(extrusion_world.z), + ) + all_tris: list[tuple[float, float, float]] = [] + for axis_a, is_max_a in adjacent_front_routes: + edge_keys = _shared_edge_corner_keys(axis_a, is_max_a, axis_b, is_max_b) + if edge_keys is None: + continue + key0, key1 = edge_keys + wp0 = corners_world[key0] + wp1 = corners_world[key1] + local_p0 = ( + float(wp0.x - face_world.x), + float(wp0.y - face_world.y), + float(wp0.z - face_world.z), + ) + local_p1 = ( + float(wp1.x - face_world.x), + float(wp1.y - face_world.y), + float(wp1.z - face_world.z), + ) + all_tris.extend( + _build_strip_tris_relative(local_p0, local_p1, extrusion_local) + ) + + if not locked: + quad_gz.hide = False + quad_gz.select_bias = _FACE_QUAD_HALO_FRAME_SELECT_BIAS + + corner_keys = _face_corner_keys(axis_b, is_max_b) + wc_local = [ + ( + float(corners_world[k].x - face_world.x), + float(corners_world[k].y - face_world.y), + float(corners_world[k].z - face_world.z), + ) + for k in corner_keys + ] + face_quad_local = [ + wc_local[0], + wc_local[1], + wc_local[2], + wc_local[0], + wc_local[2], + wc_local[3], + ] + if _strips_geometry_changed(quad_gz, tuple(face_quad_local), tuple(all_tris)): + quad_gz.custom_shape = quad_gz.new_custom_shape("TRIS", face_quad_local) + quad_gz.custom_shape_select = quad_gz.new_custom_shape("TRIS", all_tris) + quad_gz._last_geometry_state = "strips" + + # Outline alpha follows ONLY the hovered quad's own state — light + # the outline of the face under the cursor, nothing else. + if not locked: + for outline_gz, quad_gz in zip(outline_gizmos, quad_gizmos, strict=True): + lit = bool(getattr(quad_gz, "is_highlight", False)) + outline_gz.alpha = 1.0 if lit else 0.0 + outline_gz.alpha_highlight = 1.0 if lit else 0.0 + + +__all__ = [ + "AXIS_COLOR", + "BIM_GT_box_face_outline", + "BIM_GT_box_face_quad", + "FACE_QUAD_ALPHA", + "FACE_QUAD_ALPHA_HIGHLIGHT", + "FACE_QUAD_SELECT_BIAS", + "FACE_ROUTES", + "apply_face_quad_layout", + "compute_face_resize", + "face_outward_axis_local", + "front_facing_face_mask", + "view_axis_parallel_face_mask", +] diff --git a/src/bonsai/bonsai/bim/module/clip_box/gizmos.py b/src/bonsai/bonsai/bim/module/clip_box/gizmos.py new file mode 100644 index 0000000000..752d151bb2 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/clip_box/gizmos.py @@ -0,0 +1,323 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Interactive face-quad resize gizmos for the active clip box. + +Adapter group that binds the generic :mod:`face_quad` core to a Bonsai +clip-box Empty: six near-invisible click quads + six edge outlines on +the cube's faces. Dragging a face does a ONE-SIDED resize — the dragged +face moves along its outward world normal while the opposite face stays +put — by writing the empty's ``location`` and ``scale``. Bonsai's +depsgraph handler then re-arms the clip planes from the new matrix. +""" + +from __future__ import annotations + +import contextlib +from typing import Any + +import bpy +from mathutils import Matrix, Vector + +import bonsai.tool as tool + +from . import face_quad + + +# Local-frame bounds of the empty's CUBE display. The display spans +# ``[-empty_display_size, +empty_display_size]^3``; Bonsai always sets +# ``empty_display_size = 1.0`` on clip-box hosts, so the local box is +# the unit cube. The empty's per-axis scale + rotation + translation +# ride in ``matrix_world``, which the layout helper applies. +_LOCAL_BMIN = Vector((-1.0, -1.0, -1.0)) +_LOCAL_BMAX = Vector((1.0, 1.0, 1.0)) + + +def _world_axis(empty: bpy.types.Object, axis: int, is_max: bool) -> Vector: + """Outward world-space unit normal of the ``(axis, is_max)`` face. + + Uses the rotation-only matrix so a negative-scale empty doesn't + flip the resulting direction — the visible "+X face" then stays + associated with world +X (transformed through rotation). + """ + rot_mat = empty.matrix_world.to_quaternion().to_matrix() + n = Vector(rot_mat.col[axis]) + if n.length <= 0.0: + return Vector((0.0, 0.0, 0.0)) + n.normalize() + return n if is_max else -n + + +def _world_half_extent(empty: bpy.types.Object, axis: int) -> float: + """The empty's box half-extent along local ``axis`` in WORLD units. + + A CUBE empty's local cube is ``±empty_display_size``; ``matrix_world`` + stretches it by the column length on ``axis``. So the world + half-extent is ``|column[axis]| * empty_display_size``. + """ + col_len = empty.matrix_world.to_3x3().col[axis].length + display_size = abs(float(getattr(empty, "empty_display_size", 1.0) or 1.0)) + return float(col_len) * display_size + + +def _make_face_get_cb(gz: Any, group: Any, axis: int, is_max: bool): + """Closure returning the world half-extent at drag start and + snapshotting the empty's full transform on the gizmo instance. + + The snapshot lives on the gizmo (not the group) so a PERSISTENT + group servicing multiple clip boxes can't bleed one drag's state + onto another. Cleared on ``exit`` by the shared face-quad hook. + """ + + def getter() -> float: + empty = group._empty + if empty is None: + return 0.0 + existing = getattr(gz, "_drag_snapshot", None) + if existing is not None and existing.get("empty_name") == getattr( + empty, "name", None + ): + return float(existing["world_half"]) + + world_half = _world_half_extent(empty, axis) + display_size = abs(float(getattr(empty, "empty_display_size", 1.0) or 1.0)) + gz._drag_snapshot = { + "empty_name": getattr(empty, "name", None), + "world_half": world_half, + "location": tuple(float(v) for v in empty.location), + "scale": tuple(float(v) for v in empty.scale), + "display_size": display_size if display_size != 0.0 else 1.0, + "world_axis": tuple(_world_axis(empty, axis, is_max)), + } + return float(world_half) + + return getter + + +def _make_ctrl_click_cb(axis: int, is_max: bool): + """Closure that dispatches CTRL+click on a face to the align-view operator. + + Routing through an operator (rather than mutating ``rv3d`` here) + keeps the action F3-searchable and undoable. + """ + + def _callback(_context: Any, _event: Any) -> None: + bpy.ops.bim.align_view_to_clip_face( + "INVOKE_DEFAULT", axis=axis, is_max=is_max + ) + + return _callback + + +def _make_face_set_cb(gz: Any, group: Any, axis: int, is_max: bool): + """Closure that applies a one-sided face resize by writing the + empty's ``location`` + ``scale``. + + The modal calls this with ``value = init + delta`` where ``delta`` + is the cursor's projection onto the face's OUTWARD world normal. + Both reads come from ``gz._drag_snapshot`` so every frame is + relative to drag start, never compounding. + """ + del is_max # snapshot's world_axis carries the direction + + def setter(value: float) -> None: + empty = group._empty + if empty is None: + return + snap = getattr(gz, "_drag_snapshot", None) + if snap is None or snap.get("empty_name") != getattr(empty, "name", None): + return + + new_scale_axis, new_location = face_quad.compute_face_resize( + value=value, + init_world_half=snap["world_half"], + init_location=snap["location"], + world_axis=snap["world_axis"], + display_size=snap["display_size"], + ) + new_scale = list(snap["scale"]) + # Preserve the sign of the original scale so a user-flipped empty + # stays flipped after the resize — compute_face_resize returns a + # positive magnitude, the sign is the user's intent to keep. + sign = -1.0 if snap["scale"][axis] < 0.0 else 1.0 + new_scale[axis] = sign * new_scale_axis + + empty.scale = new_scale + empty.location = Vector(new_location) + + return setter + + +class OBJECT_GGT_bim_clip_box(bpy.types.GizmoGroup): # noqa: N801 — Blender bl_idname convention + """Face-quad resize handles on the active clip box. + + Renders six near-invisible click-target quads and six colored edge + outlines on the active clip-box empty whenever clipping is enabled. + Click-and-drag a face to resize one-sided; the opposite face stays + put. CTRL+click and plain click fall through to selection. + """ + + bl_idname = "OBJECT_GGT_bim_clip_box" + bl_label = "Bonsai Clip Box Faces" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT", "SHOW_MODAL_ALL"} + + @classmethod + def poll(cls, context: Any) -> bool: + scene = getattr(context, "scene", None) + if scene is None: + return False + scene_props = tool.ClipBox.get_scene_props(scene) + if not scene_props.enabled or not scene_props.enable_gizmos: + return False + active_clip_box = tool.ClipBox.get_active_clip_box(scene) + if active_clip_box is None: + return False + # Only render when the user has the active clip box itself + # selected — otherwise the face handles would intercept clicks + # meant for the geometry behind them. + return getattr(context, "active_object", None) is active_clip_box + + @classmethod + def setup_keymap(cls, keyconfig): + # Bind CLICK_DRAG so plain LEFTMOUSE PRESS passes through to + # selection — the user can still click through a near-invisible + # face quad to pick a mesh behind it. + km = keyconfig.keymaps.new( + name=cls.bl_idname, + space_type=cls.bl_space_type, + region_type=cls.bl_region_type, + ) + km.keymap_items.new( + "gizmogroup.gizmo_tweak", type="LEFTMOUSE", value="CLICK_DRAG" + ) + km.keymap_items.new( + "gizmogroup.gizmo_tweak", type="LEFTMOUSE", value="PRESS", ctrl=True + ) + return km + + def setup(self, context: Any) -> None: + # ``_empty`` is resolved each refresh so the PERSISTENT group + # follows whichever clip box is active in the scene PG. + self._empty: bpy.types.Object | None = None + self._locked = False + self._face_routes: list[tuple[int, bool]] = [] + + for axis, is_max in face_quad.FACE_ROUTES: + gz = self.gizmos.new(face_quad.BIM_GT_box_face_quad.bl_idname) + gz._group = self + gz._face_axis = axis + gz.is_max = is_max + gz._drag_snapshot = None + gz._last_geometry_state = "solid" + gz._strips_cache_key = None + gz.color = face_quad.AXIS_COLOR[axis] + gz.color_highlight = tuple( + min(1.0, c + 0.3) for c in face_quad.AXIS_COLOR[axis] + ) + gz.alpha = face_quad.FACE_QUAD_ALPHA + gz.alpha_highlight = face_quad.FACE_QUAD_ALPHA_HIGHLIGHT + gz.use_draw_modal = True + gz.scale_basis = 1.0 + gz.select_bias = face_quad.FACE_QUAD_SELECT_BIAS + gz.move_get_cb = _make_face_get_cb(gz, self, axis, is_max) + gz.move_set_cb = _make_face_set_cb(gz, self, axis, is_max) + # CTRL+click on a face aligns the viewport to look at it. + gz.ctrl_click_cb = _make_ctrl_click_cb(axis, is_max) + self._face_routes.append((axis, is_max)) + + # Outlines added last so they composite on top of the quad + # fills (Blender draws gizmos in creation order). + for axis, is_max in face_quad.FACE_ROUTES: + ol = self.gizmos.new(face_quad.BIM_GT_box_face_outline.bl_idname) + ol._face_axis = axis + ol.is_max = is_max + ol.color = face_quad.AXIS_COLOR[axis] + ol.color_highlight = face_quad.AXIS_COLOR[axis] + ol.alpha = 0.0 + ol.alpha_highlight = 0.0 + ol.line_width = 2.5 + + def _quad_gizmos(self): + return self.gizmos[: len(self._face_routes)] + + def _outline_gizmos(self): + n = len(self._face_routes) + return self.gizmos[n : 2 * n] + + def refresh(self, context: Any) -> None: + """State-change path: resolve the active empty, then run the + shared face-quad layout so the quads aren't stale for a frame + after a selection or active-index change.""" + empty = tool.ClipBox.get_active_clip_box(context.scene) + self._empty = empty + if empty is None: + for gz in self.gizmos: + gz.hide = True + return + self._layout(context, empty) + + def draw_prepare(self, context: Any) -> None: + """Per-redraw — fires on orbit — re-run the layout so the + front/back split, halo strips, and outline highlights track + the camera and any live G/R/S on the empty.""" + empty = self._empty + if empty is None: + return + self._layout(context, empty) + + def _layout(self, context: Any, empty: bpy.types.Object) -> None: + face_quad.apply_face_quad_layout( + quad_gizmos=self._quad_gizmos(), + outline_gizmos=self._outline_gizmos(), + bmin=_LOCAL_BMIN, + bmax=_LOCAL_BMAX, + matrix_world=empty.matrix_world, + # The empty's rotation rides in matrix_world, so the + # box-local OBB rotation is identity. + cage_rotation=Matrix.Identity(4), + region=getattr(context, "region", None), + rv3d=getattr(context, "region_data", None), + locked=self._locked, + ) + + # ---- mutual exclusion (lock siblings during a drag) ------------------ + + def _lock_for(self, active_gizmo) -> None: + self._locked = True + for gz in self.gizmos: + if gz is not active_gizmo: + with contextlib.suppress(ReferenceError, RuntimeError): + gz.hide = True + + def _unlock_all(self) -> None: + self._locked = False + for gz in self.gizmos: + with contextlib.suppress(ReferenceError, RuntimeError): + gz.hide = False + # Rebuild caps synchronously so the cross-section overlay + # re-forms the instant the user releases the handle, rather + # than waiting for the depsgraph's debounced rebuild path. + with contextlib.suppress(RuntimeError, ReferenceError): + tool.ClipBox.rebuild_caps_now() + # Push an undo step so the user can revert a face drag with Ctrl+Z. + with contextlib.suppress(RuntimeError): + bpy.ops.ed.undo_push(message="Resize Clip Box") diff --git a/src/bonsai/bonsai/bim/module/clip_box/operator.py b/src/bonsai/bonsai/bim/module/clip_box/operator.py index b080daa3de..e81b1e3f09 100644 --- a/src/bonsai/bonsai/bim/module/clip_box/operator.py +++ b/src/bonsai/bonsai/bim/module/clip_box/operator.py @@ -18,14 +18,132 @@ # # This file was generated with the assistance of an AI coding tool. -from __future__ import annotations - import bpy +from mathutils import Matrix, Vector import bonsai.tool as tool +from bonsai.bim.helper import prop_with_search + +from . import data + +# NOTE: do NOT add ``from __future__ import annotations`` to this module. +# PEP 563 stringifies the operator's EnumProperty class annotations, which +# breaks any introspection that reads ``cls.__annotations__[name].keywords`` +# — including the enum-search helper that draws the search-button icon. CLIP_BOX_NAME = "ClipBox" -CLIP_BOX_COLLECTION = "BBIM_ClipBoxes" + +# Display labels for the source-based picker, used for the menu entries and +# the dialog title. The dict keys are the canonical source-kind identifiers. +SOURCE_KIND_LABELS: dict[str, str] = { + "SPATIAL": "Spatial Element", + "CLASS": "Class", + "TYPE": "Type", + "MATERIAL": "Material", + "PROFILE": "Profile", + "DRAWING": "Drawing", + "STATUS": "Status", + "SYSTEM": "System", + "GROUP": "Group", + "ZONE": "Zone", +} + + +_SOURCE_ID_DISPATCH = { + "SPATIAL": data.spatial_items, + "CLASS": data.class_items, + "TYPE": data.type_items, + "MATERIAL": data.material_items, + "PROFILE": data.profile_items, + "DRAWING": data.drawing_items, + "STATUS": data.status_items, + "SYSTEM": data.system_items, + "GROUP": data.group_items, + "ZONE": data.zone_items, +} + + +def _source_id_items(self, context): + """Dispatch the ``source_id`` enum items based on the picked ``source_kind``.""" + fn = _SOURCE_ID_DISPATCH.get(self.source_kind) + if fn is None: + return [(data.NO_OPTIONS_ID, "No options", "")] + return fn(self, context) + + +def _source_display_name(kind, source_id): + """Human-readable name of the picked source, used in the clip-box name.""" + if kind == "STATUS": + return next((label for value, label in data.STATUS_LABELS if value == source_id), source_id) + if kind == "CLASS": + # source_id IS the human-readable IFC class name. + return source_id + ifc = tool.Ifc.get() + if ifc is None: + return source_id + try: + entity = ifc.by_id(int(source_id)) + except (TypeError, ValueError, RuntimeError): + return source_id + return (getattr(entity, "Name", None) or "Unnamed").strip() or "Unnamed" + + +class BIM_OT_align_view_to_clip_face(bpy.types.Operator): + bl_idname = "bim.align_view_to_clip_face" + bl_label = "Align View to Clip Box Face" + bl_description = "Orient the 3D viewport to look directly at the picked clip-box face" + bl_options = {"REGISTER"} + + axis: bpy.props.IntProperty(default=0, options={"SKIP_SAVE"}) + is_max: bpy.props.BoolProperty(default=True, options={"SKIP_SAVE"}) + + def execute(self, context): + rv3d = getattr(context, "region_data", None) + if rv3d is None: + return {"CANCELLED"} + clip_box = tool.ClipBox.get_active_clip_box(context.scene) + if clip_box is None: + return {"CANCELLED"} + rot_mat = clip_box.matrix_world.to_quaternion().to_matrix() + outward_local = Vector((0.0, 0.0, 0.0)) + outward_local[self.axis] = 1.0 if self.is_max else -1.0 + outward = (rot_mat @ outward_local).normalized() + if outward.length == 0.0: + return {"CANCELLED"} + up_world = (rot_mat @ _local_up_for_face(self.axis, self.is_max)).normalized() + rv3d.view_rotation = _view_rotation_from_forward_and_up(-outward, up_world) + return {"FINISHED"} + + +def _local_up_for_face(axis: int, is_max: bool) -> Vector: + """Box-local up direction for a face, following Blender numpad conventions. + + Side faces (local ±X / ±Y normal) → local +Z is up. Top face (local +Z + normal) → local +Y is up; bottom face (local -Z normal) → local -Y is + up. The caller rotates this through the empty's matrix so the + resulting world up axis tracks the box's orientation. + """ + if axis == 2: + return Vector((0.0, 1.0, 0.0)) if is_max else Vector((0.0, -1.0, 0.0)) + return Vector((0.0, 0.0, 1.0)) + + +def _view_rotation_from_forward_and_up(forward: Vector, up_hint: Vector) -> "bpy.types.Quaternion": + """Build a camera ``view_rotation`` that looks along ``forward`` with + ``up_hint`` projected to the camera's local +Y.""" + back = -forward.normalized() + right = up_hint.cross(back) + if right.length < 1e-6: + right = Vector((1.0, 0.0, 0.0)) + right.normalize() + up = back.cross(right).normalized() + return Matrix( + ( + (right.x, up.x, back.x), + (right.y, up.y, back.y), + (right.z, up.z, back.z), + ) + ).to_quaternion() class BIM_OT_add_clip_box(bpy.types.Operator): @@ -38,39 +156,61 @@ class BIM_OT_add_clip_box(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - scene_props = tool.ClipBox.get_scene_props(context.scene) - - obj = bpy.data.objects.new(CLIP_BOX_NAME, None) - obj.empty_display_type = "CUBE" - obj.empty_display_size = 1.0 - obj.location = context.scene.cursor.location.copy() # Default to a 20m cube (scale 10 around [-1, +1] local cube) so # the volume covers a typical building storey or two rather than # the meaningless 2m unit cube. The user resizes with S. - obj.scale = (10.0, 10.0, 10.0) - obj.show_in_front = True + matrix = Matrix.Translation(context.scene.cursor.location.copy()) @ Matrix.Diagonal( + (10.0, 10.0, 10.0, 1.0) + ) + tool.ClipBox.create_clip_box_empty(context, matrix, name=CLIP_BOX_NAME) + return {"FINISHED"} - collection = tool.Blender.get_or_create_collection(context.scene, CLIP_BOX_COLLECTION) - collection.objects.link(obj) - obj_props = tool.ClipBox.get_object_props(obj) - obj_props.is_clip_box = True +class BIM_OT_add_clip_box_for_source(bpy.types.Operator): + bl_idname = "bim.add_clip_box_for_source" + bl_label = "Add Clip Box From Source" + bl_description = ( + "Create a clip box sized to a chosen source: a spatial container, IFC type, material, " + "profile, drawing camera frustum, element status, system, group, or zone" + ) + bl_options = {"REGISTER", "UNDO"} - entry = scene_props.clip_boxes.add() - entry.obj = obj - scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1 + source_kind: bpy.props.EnumProperty( + name="Source Kind", + items=[(kind, label, "") for kind, label in SOURCE_KIND_LABELS.items()], + default="SPATIAL", + options={"SKIP_SAVE"}, + ) + source_id: bpy.props.EnumProperty( + name="Source", + items=_source_id_items, + options={"SKIP_SAVE"}, + ) - # Adding a new clip box arms clipping so the user sees the cut - # immediately. Without this they'd have to find the panel - # toggle to discover the feature actually works. - scene_props.enabled = True + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) - tool.Blender.set_active_object(obj) - tool.ClipBox.refresh(context.scene) - # Persist to the project pset so the box round-trips through IFC - # save/load. A project-level pset avoids the IfcRoot scale lock / - # strip that a per-entity placement would trigger on export. - tool.ClipBox.save_to_project_pset(context.scene) + def draw(self, context): + layout = self.layout + label = f"Clip {SOURCE_KIND_LABELS.get(self.source_kind, 'Source')}" + # Search button appears once the enum exceeds the helper's threshold, + # giving the user a popup picker instead of a plain dropdown. + prop_with_search(layout, self, "source_id", text=label) + + def execute(self, context): + if not self.source_id or self.source_id == data.NO_OPTIONS_ID: + self.report({"ERROR"}, "No source selected.") + return {"CANCELLED"} + matrix = tool.ClipBox.compute_matrix_for_source(self.source_kind, self.source_id) + if matrix is None: + kind_label = SOURCE_KIND_LABELS.get(self.source_kind, self.source_kind) + self.report( + {"ERROR"}, + f"No elements found for {kind_label} '{_source_display_name(self.source_kind, self.source_id)}'.", + ) + return {"CANCELLED"} + name = f"ClipBox.{SOURCE_KIND_LABELS.get(self.source_kind, self.source_kind)}.{_source_display_name(self.source_kind, self.source_id)}" + tool.ClipBox.create_clip_box_empty(context, matrix, name=name) return {"FINISHED"} @@ -148,23 +288,9 @@ class BIM_OT_duplicate_clip_box(bpy.types.Operator): if source is None: return {"CANCELLED"} - copy = bpy.data.objects.new(source.name, None) + copy = tool.ClipBox.create_clip_box_empty(context, source.matrix_world.copy(), name=source.name) + # Preserve the source's display attrs so the duplicate matches. copy.empty_display_type = source.empty_display_type copy.empty_display_size = source.empty_display_size copy.show_in_front = source.show_in_front - copy.matrix_world = source.matrix_world.copy() - - collection = tool.Blender.get_or_create_collection(context.scene, CLIP_BOX_COLLECTION) - collection.objects.link(copy) - - tool.ClipBox.get_object_props(copy).is_clip_box = True - - entry = scene_props.clip_boxes.add() - entry.obj = copy - scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1 - scene_props.enabled = True - - tool.Blender.set_active_object(copy) - tool.ClipBox.refresh(context.scene) - tool.ClipBox.save_to_project_pset(context.scene) return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/clip_box/prop.py b/src/bonsai/bonsai/bim/module/clip_box/prop.py index f503d8781f..b65cf8a0ca 100644 --- a/src/bonsai/bonsai/bim/module/clip_box/prop.py +++ b/src/bonsai/bonsai/bim/module/clip_box/prop.py @@ -53,16 +53,29 @@ class BIMClipBoxProperties(PropertyGroup): def update_active_clip_box_index(self, context): tool.ClipBox.schedule_refresh() tool.ClipBox.select_active_clip_box(context) + # Rebuild caps for the new active box's clip volume. + tool.ClipBox.invalidate_cap_cache(immediate=True) def update_show_caps(self, context): tool.ClipBox.schedule_refresh() + # Off → on must trigger a rebuild so caps reappear immediately rather + # than wait for the next depsgraph tick. The rebuild is a no-op when + # show_caps is now False (it clears and returns), so this is safe in + # both directions. + tool.ClipBox.invalidate_cap_cache() def update_enabled(self, context): tool.ClipBox.schedule_refresh() +def update_clip_only_ifc_products(self, context): + # The eligibility set for capping changed — drop the cache and let the + # debounced rebuild pick up the new objects on the next idle tick. + tool.ClipBox.invalidate_cap_cache() + + class BIMSceneClipBoxProperties(PropertyGroup): """Scene-level registry of clip boxes in this file. @@ -100,8 +113,33 @@ class BIMSceneClipBoxProperties(PropertyGroup): "very heavy scenes" ), ) + # Stored on the Scene PG so Blender persists it in the .blend; deliberately + # NOT written to the project pset so the IFC stays portable across users + # who may have different Blender-side reference geometry to clip. + clip_only_ifc_products: bpy.props.BoolProperty( + name="Only IFC Products", + default=True, + update=update_clip_only_ifc_products, + description=( + "When enabled, only IFC element geometry gets cross-section caps. " + "Disable to also cap Blender-side reference meshes (sketches, " + "imported obj, primitive cubes, …)" + ), + ) + # Also Scene-only — gizmo visibility is a per-user editing preference, + # not a portable IFC property. + enable_gizmos: bpy.props.BoolProperty( + name="Show Face Handles", + default=True, + description=( + "Show interactive face-resize handles on the active clip box. " + "Disable to fall back to plain G/R/S transforms on the empty" + ), + ) if TYPE_CHECKING: active_clip_box_index: int enabled: bool show_caps: bool + clip_only_ifc_products: bool + enable_gizmos: bool diff --git a/src/bonsai/bonsai/bim/module/clip_box/ui.py b/src/bonsai/bonsai/bim/module/clip_box/ui.py index b5ae95d9e2..39e786f113 100644 --- a/src/bonsai/bonsai/bim/module/clip_box/ui.py +++ b/src/bonsai/bonsai/bim/module/clip_box/ui.py @@ -20,18 +20,73 @@ from __future__ import annotations -from bpy.types import Panel, UIList +from bpy.types import Menu, Panel, UIList import bonsai.tool as tool +# Per-kind icon for the source-picker menu. Picked from Blender's built-in +# icon set; semantically close to the kind so users can scan the menu visually. +_SOURCE_MENU_ENTRIES: tuple[tuple[str, str, str], ...] = ( + ("SPATIAL", "Clip Spatial Element", "OUTLINER_COLLECTION"), + ("CLASS", "Clip by Class", "BLANK1"), + ("TYPE", "Clip Type", "FILE_3D"), + ("MATERIAL", "Clip Material", "MATERIAL"), + ("PROFILE", "Clip Profile", "MESH_CIRCLE"), + ("DRAWING", "Clip Drawing Extents", "CAMERA_DATA"), + ("STATUS", "Clip by Status", "INFO"), + ("SYSTEM", "Clip by System", "MOD_FLUID"), + ("GROUP", "Clip by Group", "OUTLINER_OB_GROUP_INSTANCE"), + ("ZONE", "Clip by Zone", "MOD_LATTICE"), +) + + +class BIM_MT_clip_box_add_for_source(Menu): + bl_idname = "BIM_MT_clip_box_add_for_source" + bl_label = "Add Clip Box From Source" + + def draw(self, context): + layout = self.layout + for kind, label, icon in _SOURCE_MENU_ENTRIES: + op = layout.operator("bim.add_clip_box_for_source", text=label, icon=icon) + op.source_kind = kind + + +class BIM_MT_clip_box_settings(Menu): + bl_idname = "BIM_MT_clip_box_settings" + bl_label = "Clip Box Settings" + + def draw(self, context): + scene_props = tool.ClipBox.get_scene_props(context.scene) + self.layout.prop(scene_props, "clip_only_ifc_products") + self.layout.prop(scene_props, "enable_gizmos") + + +class BIM_MT_clip_box_info(Menu): + bl_idname = "BIM_MT_clip_box_info" + bl_label = "Clip Box Face Handles" + + def draw(self, context): + layout = self.layout + layout.label(text="Face Handles", icon="INFO") + layout.separator() + layout.label(text="Drag a face to resize the clip box on that axis.") + layout.label(text="The opposite face stays fixed (one-sided resize).") + layout.label(text="Ctrl+Click a face to align the viewport to it.") + layout.separator() + layout.label(text="Toggle handles from the Settings (gear) menu.") + + class BIM_UL_clip_box(UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname, index, flt_flag): obj = item.obj - if obj is None: - layout.label(text="(missing)", icon="ERROR") - return row = layout.row(align=True) + if obj is None: + # Host empty was deleted from outliner; still expose the + # remove button so the orphan entry isn't permanent. + row.label(text="(missing)", icon="ERROR") + row.operator("bim.remove_clip_box", text="", icon="X", emboss=False).index = index + return row.prop(obj, "name", text="", emboss=False, icon="MESH_CUBE") row.operator("bim.duplicate_clip_box", text="", icon="DUPLICATE", emboss=False).index = index row.operator("bim.remove_clip_box", text="", icon="X", emboss=False).index = index @@ -60,9 +115,13 @@ class BIM_PT_clip_box(Panel): toggle=True, ) toggles.prop(scene_props, "show_caps", text="Show Caps", icon="MOD_SOLIDIFY", toggle=True) + toggles.menu("BIM_MT_clip_box_settings", icon="PREFERENCES", text="") + toggles.menu("BIM_MT_clip_box_info", icon="INFO", text="") layout.separator() - layout.operator("bim.add_clip_box", icon="ADD", text="Add Clip Box") + row = layout.row(align=True) + row.operator("bim.add_clip_box", icon="ADD", text="Add Clip Box") + row.menu("BIM_MT_clip_box_add_for_source", icon="DOWNARROW_HLT", text="") layout.template_list( "BIM_UL_clip_box", diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 8af70e54c9..0212d82c38 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -1950,6 +1950,13 @@ class BIM_PT_decorators_overlay(Panel): row = col.row(align=True) row.prop(model_props, "show_cut_decorator", text="Cut Decorator") row.prop(model_props, "show_cut_decorator_fill", text="Fill Cut Decorator") + clip_box_props = tool.ClipBox.get_scene_props(context.scene) + row = col.row(align=True) + # Grey out the toggles when there is no clip box to act on, so the + # user can see the controls but can't flip a switch that does nothing. + row.enabled = bool(clip_box_props.clip_boxes) + row.prop(clip_box_props, "enabled", text="Enable Clipping") + row.prop(clip_box_props, "show_caps", text="Show Caps") class BIM_PT_snappping(Panel): diff --git a/src/bonsai/bonsai/tool/clip_box.py b/src/bonsai/bonsai/tool/clip_box.py index c477e32ad9..9573a3f5e8 100644 --- a/src/bonsai/bonsai/tool/clip_box.py +++ b/src/bonsai/bonsai/tool/clip_box.py @@ -21,7 +21,7 @@ from __future__ import annotations import contextlib -from collections.abc import Callable, Iterator +from collections.abc import Callable, Iterable, Iterator from typing import TYPE_CHECKING, Any, Optional import bpy @@ -38,6 +38,21 @@ if TYPE_CHECKING: PlaneTuple = tuple[float, float, float, float] PlaneSet = tuple[PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple] +# Stable contract of Pset_*Common.Status values plus the "absent" entry. +# Duplicated locally rather than imported so this module has no load-order +# dependency on the sequence layer that hosts the matching query helper. +SOURCE_STATUS_VALUES: tuple[str, ...] = ( + "No Status", + "NEW", + "EXISTING", + "DEMOLISH", + "TEMPORARY", + "OTHER", + "NOTKNOWN", + "UNSET", +) + + # Outward margins so the empty's CUBE display edges sit safely INSIDE # the clip volume. A fixed absolute margin fails under rotation: the # float error in computing each column's length and in per-vertex dot @@ -187,12 +202,38 @@ class ClipBox: keeps the bbox aligned with the view the user is actually at. """ for area, region, region_3d in tool.Blender.iter_view3d_regions(): + # Skip collapsed / initializing regions wholesale: arming one + # CTDs Blender (see _region_is_renderable), and recording an + # arm signature for a region we didn't actually arm would make + # the next view-change comparison spurious. + if not cls._region_is_renderable(region, region_3d): + continue key = region.as_pointer() cls._owned.add(key) cls._region_by_key[key] = (area, region) cls._arm_region(area, region, region_3d, planes) cls._view_matrix_at_arm[key] = tuple(tuple(row) for row in region_3d.view_matrix) + @classmethod + def _region_is_renderable(cls, region: Any, region_3d: Any) -> bool: + """True iff ``region`` is safe to arm clip planes against. + + A collapsed / still-initializing region (``width`` or ``height`` + == 0, or no readable ``view_matrix``) has no live view-matrix + state. Calling ``region_3d.update()`` against it drives + ``ED_view3d_update_viewmat -> GPU_matrix_ortho_set`` into a null + deref and HARD-CRASHES Blender (CTD, not a catchable exception) — + observed when a 3D viewport is split/collapsed while the clip box + re-arms from a timer. Skipping such regions is the load-bearing + guard; they get armed on the next refresh once they have a size. + """ + try: + if int(getattr(region, "width", 0)) <= 0 or int(getattr(region, "height", 0)) <= 0: + return False + return getattr(region_3d, "view_matrix", None) is not None + except (ReferenceError, AttributeError, TypeError): + return False + @classmethod def _arm_region(cls, area: Any, region: Any, region_3d: Any, planes: PlaneSet) -> None: """Initialize the region's clip machinery and write ``planes``. @@ -201,12 +242,25 @@ class ClipBox: without leaving the C-side ``clipbb`` degenerate (which would break edit-mode click-select). Caller must guarantee a context in which operators are legal (not a draw handler / depsgraph callback). + + The ``_region_is_renderable`` guard is the real crash fix — arming a + collapsed / initializing region drives ``region_3d.update()`` into a + native null deref inside ``GPU_matrix_ortho_set`` that NO Python + ``try``/``except`` can catch (it's a CTD, not an exception). The + ``suppress`` around ``update()`` is unrelated to that: it only + swallows the *catchable* ``RuntimeError`` ("context is incorrect") + / ``ReferenceError`` (region freed mid-call) that the override path + can still surface — it does NOT and CANNOT make ``update()`` + crash-safe. """ + if not cls._region_is_renderable(region, region_3d): + return with bpy.context.temp_override(area=area, region=region): bpy.ops.view3d.clip_border(xmin=0, ymin=0, xmax=region.width, ymax=region.height) region_3d.clip_planes = planes region_3d.use_clip_planes = True - region_3d.update() + with contextlib.suppress(RuntimeError, ReferenceError): + region_3d.update() @classmethod def clear_clip_planes(cls) -> None: @@ -466,10 +520,15 @@ class ClipBox: for area, region, region_3d in tool.Blender.iter_view3d_regions(): if not region_3d.use_clip_planes: continue + # A collapsed / initializing region CTDs inside update() — same + # null deref as the operator arm path (see _region_is_renderable). + if not cls._region_is_renderable(region, region_3d): + continue key = region.as_pointer() cls._region_by_key[key] = (area, region) region_3d.clip_planes = planes - region_3d.update() + with contextlib.suppress(RuntimeError, ReferenceError): + region_3d.update() region.tag_redraw() @classmethod @@ -615,7 +674,12 @@ class ClipBox: except (AttributeError, RuntimeError, ReferenceError): return region_3d.clip_planes = cls.compute_planes_from_matrix(matrix) - region_3d.update() + # PRE_VIEW runs for the region being drawn this frame (always sized + # and renderable), so the collapsed-region CTD can't occur here. + # The suppress only mops up a catchable RuntimeError / ReferenceError + # from a region freed mid-draw — same as the arm paths. + with contextlib.suppress(RuntimeError, ReferenceError): + region_3d.update() # clip_bb captured by view3d.clip_border is view-aligned, so an # orbit/pan/zoom leaves the picker testing against the old # frustum even after clip_planes refresh. Re-arm so the picker @@ -628,6 +692,219 @@ class ClipBox: if prev_view is not None and prev_view != current_view: cls.schedule_refresh() + @classmethod + def create_clip_box_empty( + cls, + context: bpy.types.Context, + matrix: Any, + name: str = "ClipBox", + ) -> bpy.types.Object: + """Create + register a clip-box empty whose ``matrix_world`` is ``matrix``. + + Single entry point for any operator that needs to materialise a + clip box: handles the host collection, the per-object + ``is_clip_box`` flag, the scene-list entry, auto-enable, viewport + re-arm, and project-pset persistence. Returns the new empty. + """ + scene_props = cls.get_scene_props(context.scene) + + obj = bpy.data.objects.new(name, None) + obj.empty_display_type = "CUBE" + obj.empty_display_size = 1.0 + obj.show_in_front = True + obj.matrix_world = matrix + + collection = tool.Blender.get_or_create_collection(context.scene, cls.COLLECTION_NAME) + collection.objects.link(obj) + + cls.get_object_props(obj).is_clip_box = True + + entry = scene_props.clip_boxes.add() + entry.obj = obj + scene_props.active_clip_box_index = len(scene_props.clip_boxes) - 1 + # Auto-enable so the user sees the cut immediately rather than + # having to find the panel toggle after the add. + scene_props.enabled = True + + tool.Blender.set_active_object(obj) + cls.refresh(context.scene) + # Project-level pset rather than a per-entity placement so IfcRoot's + # scale-strip-on-export can't lose the box's dimensions. + cls.save_to_project_pset(context.scene) + return obj + + # ------------------------------------------------------------------ + # Source-based presets + # + # Build the host empty's ``matrix_world`` from a chosen IFC source + # (a spatial container, a type, a material, a drawing, …) so the + # user gets a clip box pre-sized to the AABB of the matched + # elements instead of having to drag a default cube into position. + # ------------------------------------------------------------------ + + @classmethod + def iter_elements_for_source(cls, kind: str, source_id: str) -> list[Any]: + """Resolve the IFC products matching ``(kind, source_id)``. + + ``kind`` selects the IFC-graph walk; ``source_id`` is the picker + value: an IFC entity id (stringified) for the entity-driven + kinds, or one of :data:`SOURCE_STATUS_VALUES` for ``"STATUS"``. + + Empty list when the IFC file is absent, ``source_id`` does not + resolve, or the walk has no matches. ``"DRAWING"`` returns the + single drawing entity so callers can introspect it; the actual + clip volume for that kind is built from the camera frustum, not + an AABB of decomposed elements. + """ + import ifcopenshell.util.element + + ifc_file = tool.Ifc.get() + if ifc_file is None: + return [] + + if kind == "STATUS": + if source_id not in SOURCE_STATUS_VALUES: + return [] + return list(tool.Sequence.get_elements_by_status(source_id)) + + if kind == "CLASS": + # source_id is an IFC class name (e.g. "IfcWall"). by_type with + # include_subtypes=True (default) so "IfcWall" matches + # IfcWallStandardCase etc., matching "all walls" in user terms. + try: + return list(ifc_file.by_type(source_id)) + except RuntimeError: + return [] + + try: + entity_id = int(source_id) + except (TypeError, ValueError): + return [] + try: + entity = ifc_file.by_id(entity_id) + except RuntimeError: + return [] + if entity is None: + return [] + + if kind == "SPATIAL": + return list(ifcopenshell.util.element.get_decomposition(entity, is_recursive=True)) + if kind == "TYPE": + return list(ifcopenshell.util.element.get_types(entity)) + if kind == "MATERIAL": + return list(ifcopenshell.util.element.get_elements_by_material(ifc_file, entity)) + if kind == "PROFILE": + return list(ifcopenshell.util.element.get_elements_by_profile(entity)) + if kind in ("SYSTEM", "GROUP", "ZONE"): + return list(ifcopenshell.util.element.get_grouped_by(entity, is_recursive=True)) + if kind == "DRAWING": + return [entity] + return [] + + @classmethod + def compute_matrix_for_source(cls, kind: str, source_id: str) -> Optional[Any]: + """Build the empty's ``matrix_world`` for ``(kind, source_id)``. + + Returns ``None`` when nothing matches — the operator turns that + into an ERROR report + ``CANCELLED``. + + ``"DRAWING"`` returns a rotated matrix aligned to the camera and + sized to ``clip_start..clip_end`` × the drawing's in-plane + extents. All other kinds return an axis-aligned matrix sized to + the world AABB of the matched elements' Blender objects. + """ + if kind == "DRAWING": + ifc_file = tool.Ifc.get() + if ifc_file is None: + return None + try: + entity = ifc_file.by_id(int(source_id)) + except (TypeError, ValueError, RuntimeError): + return None + camera_obj = tool.Ifc.get_object(entity) + if camera_obj is None or camera_obj.type != "CAMERA": + return None + return cls._camera_frustum_matrix(camera_obj) + + elements = cls.iter_elements_for_source(kind, source_id) + return cls._world_bbox_matrix_for_elements(elements) + + @classmethod + def _world_bbox_matrix_for_elements(cls, elements: Iterable[Any]) -> Optional[Any]: + """World-AABB matrix of the Blender objects backing ``elements``. + + ``matrix_world = Translation(center) @ Diagonal(half_extents)`` + so the CUBE empty's local ``[-1, +1]^3`` lands on the AABB + corners. Filters out elements without a Blender object and + elements whose object has a zero-volume bound box (typical for + empties used as containers). Returns ``None`` when nothing + survives the filter so the caller can ERROR instead of creating + a degenerate clip box. + + Half-extents are floored at :data:`_CLIP_EXPAND_ABS` so a single + point or flat slab still produces an invertible matrix. + """ + from mathutils import Matrix + + min_x = min_y = min_z = float("inf") + max_x = max_y = max_z = float("-inf") + found = False + for element in elements: + obj = tool.Ifc.get_object(element) + if obj is None: + continue + bbox = tool.Blender.get_object_world_bounding_box(obj) + if bbox["dimensions"] == (0.0, 0.0, 0.0): + continue + min_x = min(min_x, bbox["min_x"]) + min_y = min(min_y, bbox["min_y"]) + min_z = min(min_z, bbox["min_z"]) + max_x = max(max_x, bbox["max_x"]) + max_y = max(max_y, bbox["max_y"]) + max_z = max(max_z, bbox["max_z"]) + found = True + if not found: + return None + cx, cy, cz = (min_x + max_x) / 2, (min_y + max_y) / 2, (min_z + max_z) / 2 + hx = max((max_x - min_x) / 2, _CLIP_EXPAND_ABS) + hy = max((max_y - min_y) / 2, _CLIP_EXPAND_ABS) + hz = max((max_z - min_z) / 2, _CLIP_EXPAND_ABS) + return Matrix.Translation((cx, cy, cz)) @ Matrix.Diagonal((hx, hy, hz, 1.0)) + + @classmethod + def _camera_frustum_matrix(cls, camera_obj: bpy.types.Object) -> Optional[Any]: + """Rotated matrix matching the camera frustum ``clip_start..clip_end``. + + Bonsai's drawing module parameterises a drawing camera's frustum + via ``BIMCameraProperties.width`` and ``.height`` (the printed + extents in world units). The CUBE empty inherits the camera's + rotation; depth spans ``[clip_start, clip_end]`` along the + camera's local −Z (Blender cameras look down −Z). + + Returns ``None`` when the camera has no usable drawing extents — + the operator surfaces that as an ERROR + ``CANCELLED``. + """ + from mathutils import Matrix + + cam_data = camera_obj.data + cam_props = getattr(cam_data, "BIMCameraProperties", None) + if cam_props is None: + return None + width = float(getattr(cam_props, "width", 0.0) or 0.0) + height = float(getattr(cam_props, "height", 0.0) or 0.0) + if width <= 0.0 or height <= 0.0: + return None + + clip_start = float(getattr(cam_data, "clip_start", 0.0)) + clip_end = float(getattr(cam_data, "clip_end", 1.0)) + + x_half = max(width / 2.0, _CLIP_EXPAND_ABS) + y_half = max(height / 2.0, _CLIP_EXPAND_ABS) + z_half = max((clip_end - clip_start) / 2.0, _CLIP_EXPAND_ABS) + z_center = -(clip_start + clip_end) / 2.0 + offset = Matrix.Translation((0.0, 0.0, z_center)) @ Matrix.Diagonal((x_half, y_half, z_half, 1.0)) + return camera_obj.matrix_world @ offset + # ------------------------------------------------------------------ # Cross-section caps # @@ -709,27 +986,64 @@ class ClipBox: @classmethod def _iter_capable_objects(cls, scene: bpy.types.Scene) -> Iterator[bpy.types.Object]: - """Yield mesh objects eligible for capping: visible ``IfcElement``s. + """Yield mesh objects eligible for capping. - Limits to ``IfcElement`` (walls, slabs, doors, windows, …) so - spatial structure (``IfcSpace``, ``IfcBuildingStorey``, - ``IfcSite``) and annotations / grids never get capped — they're - non-physical containers / overlays that shouldn't sprout solid - fill polygons at clip boundaries. + When ``clip_only_ifc_products`` is set (default), limits to + ``IfcElement`` (walls, slabs, doors, windows, …) so spatial + structure (``IfcSpace``, ``IfcBuildingStorey``, ``IfcSite``) and + annotations / grids never get capped — they're non-physical + containers / overlays that shouldn't sprout solid fill polygons + at clip boundaries. + + When unset, any visible mesh in the scene is eligible regardless + of IFC association — useful for clipping Blender-side reference + geometry alongside a loaded IFC. """ - ifc_file = tool.Ifc.get() - if ifc_file is None: + scene_props = cls.get_scene_props(scene) + only_ifc = scene_props.clip_only_ifc_products + if only_ifc and tool.Ifc.get() is None: return for obj in scene.objects: if obj.type != "MESH" or obj.data is None: continue if not obj.visible_get(): continue - entity = tool.Ifc.get_entity(obj) - if entity is None or not entity.is_a("IfcElement"): - continue + if only_ifc: + entity = tool.Ifc.get_entity(obj) + if entity is None or not entity.is_a("IfcElement"): + continue yield obj + @classmethod + def invalidate_cap_cache(cls, *, immediate: bool = False) -> None: + """Drop the cap cache and schedule a fresh rebuild. + + Public entry point for property-update callbacks (or any external + change to eligibility / clip-box selection) so callers don't reach + into the private cache state directly. Pass ``immediate=True`` for + UI-driven changes that should rebuild on the next idle tick + without waiting for the depsgraph-debounce window. + """ + cls._cap_cache.clear() + cls._last_cap_clip_box_hash = 0 + cls._schedule_cap_rebuild(interval=0.0 if immediate else None) + + @classmethod + def rebuild_caps_now(cls, scene: Optional[bpy.types.Scene] = None) -> None: + """Drop the cap cache and rebuild SYNCHRONOUSLY, then redraw. + + Public entry point for interactive end-of-drag handlers (e.g. the + face-resize gizmo group) where the user expects the caps to + re-form the instant they release the handle — without the + debounce window the depsgraph path inserts. + """ + cls._cancel_pending_cap_rebuild() + cls._cap_cache.clear() + cls._last_cap_clip_box_hash = 0 + cls.rebuild_cap_cache(scene) + for _area, region, _region_3d in tool.Blender.iter_view3d_regions(): + region.tag_redraw() + @classmethod def rebuild_cap_cache( cls, @@ -929,14 +1243,17 @@ class ClipBox: return relevant @classmethod - def _schedule_cap_rebuild(cls) -> None: + def _schedule_cap_rebuild(cls, *, interval: Optional[float] = None) -> None: """(Re)schedule the deferred cap rebuild. Each call cancels any pending timer and registers a fresh one so a burst of updates collapses to a single rebuild once the - debounce window of quiet elapses. + debounce window of quiet elapses. Pass ``interval=0.0`` for + next-tick rebuild without debounce (UI-driven changes that + only fire on explicit user action, not depsgraph bursts). """ cls._cancel_pending_cap_rebuild() + delay = interval if interval is not None else cls._CAP_REBUILD_DEBOUNCE_SECONDS def _do_rebuild() -> None: cls._pending_cap_rebuild = None @@ -958,7 +1275,7 @@ class ClipBox: region.tag_redraw() return None - bpy.app.timers.register(_do_rebuild, first_interval=cls._CAP_REBUILD_DEBOUNCE_SECONDS) + bpy.app.timers.register(_do_rebuild, first_interval=delay) cls._pending_cap_rebuild = _do_rebuild @classmethod diff --git a/src/bonsai/test/bim/module/clip_box/test_add_for_source.py b/src/bonsai/test/bim/module/clip_box/test_add_for_source.py new file mode 100644 index 0000000000..947e2ffdfe --- /dev/null +++ b/src/bonsai/test/bim/module/clip_box/test_add_for_source.py @@ -0,0 +1,134 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +import bpy +import ifcopenshell +import ifcopenshell.api.spatial +import pytest + +import bonsai.tool as tool +from test.bim.bootstrap import NewFile + +pytestmark = pytest.mark.clip_box + + +def _make_ifc_cube(ifc, ifc_class, location=(0.0, 0.0, 0.0), size=2.0): + bpy.ops.mesh.primitive_cube_add(size=size, location=location) + obj = bpy.context.active_object + entity = ifc.create_entity(ifc_class) + tool.Ifc.link(entity, obj) + return entity, obj + + +class TestAddClipBoxForSourceSpatial(NewFile): + def test_spatial_creates_clip_box_sized_to_contained_walls(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + storey = ifc.create_entity("IfcBuildingStorey") + wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0) + wall_b, _ = _make_ifc_cube(ifc, "IfcWall", location=(4.0, 0.0, 0.0), size=2.0) + ifcopenshell.api.spatial.assign_container( + ifc, products=[wall_a, wall_b], relating_structure=storey + ) + + result = bpy.ops.bim.add_clip_box_for_source( + source_kind="SPATIAL", source_id=str(storey.id()) + ) + + assert result == {"FINISHED"} + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 1 + host = scene_props.clip_boxes[0].obj + assert tool.ClipBox.get_object_props(host).is_clip_box is True + translation, _, scale = host.matrix_world.decompose() + assert translation.x == pytest.approx(2.0) + assert scale.x == pytest.approx(3.0) + + +class TestAddClipBoxForSourceClass(NewFile): + def test_class_creates_clip_box_for_all_walls(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + # Two walls + one window; the IfcWall pick should cover only the walls. + _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0) + _make_ifc_cube(ifc, "IfcWall", location=(4.0, 0.0, 0.0), size=2.0) + _make_ifc_cube(ifc, "IfcWindow", location=(20.0, 0.0, 0.0), size=2.0) + + result = bpy.ops.bim.add_clip_box_for_source( + source_kind="CLASS", source_id="IfcWall" + ) + + assert result == {"FINISHED"} + scene_props = tool.ClipBox.get_scene_props() + host = scene_props.clip_boxes[0].obj + translation, _, scale = host.matrix_world.decompose() + # AABB of the two walls only (x in [-1, 5]); window at x=20 must not contribute. + assert translation.x == pytest.approx(2.0) + assert scale.x == pytest.approx(3.0) + + +class TestAddClipBoxForSourceEmpty(NewFile): + def test_no_matching_elements_reports_error(self): + # bpy.ops.* raises RuntimeError when an operator reports {"ERROR"}, + # so the assertion is on the raised message rather than the return code. + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + walltype = ifc.create_entity("IfcWallType") + # No occurrences linked — TYPE source resolves to 0 elements. + with pytest.raises(RuntimeError, match="No elements found"): + bpy.ops.bim.add_clip_box_for_source( + source_kind="TYPE", source_id=str(walltype.id()) + ) + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 0 + + def test_placeholder_source_id_reports_error(self): + # With no IFC file loaded, data.py callbacks return the NO_OPTIONS_ID + # sentinel. Submitting that sentinel as the picked source must ERROR. + from bonsai.bim.module.clip_box import data as clip_data + + with pytest.raises(RuntimeError, match="No source selected"): + bpy.ops.bim.add_clip_box_for_source( + source_kind="SPATIAL", source_id=clip_data.NO_OPTIONS_ID + ) + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 0 + + +class TestRemoveClipBoxOrphan(NewFile): + def test_remove_orphan_entry_when_host_object_deleted(self): + # The remove operator must work on an orphan entry — i.e. one whose + # host empty was deleted out from under it via the outliner. + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + assert len(scene_props.clip_boxes) == 1 + host = scene_props.clip_boxes[0].obj + assert host is not None + + bpy.data.objects.remove(host, do_unlink=True) + + # Entry survives but its `obj` pointer is now None. + assert len(scene_props.clip_boxes) == 1 + assert scene_props.clip_boxes[0].obj is None + + result = bpy.ops.bim.remove_clip_box(index=0) + + assert result == {"FINISHED"} + assert len(scene_props.clip_boxes) == 0 diff --git a/src/bonsai/test/bim/module/clip_box/test_clip_only_ifc_products.py b/src/bonsai/test/bim/module/clip_box/test_clip_only_ifc_products.py new file mode 100644 index 0000000000..12883a23aa --- /dev/null +++ b/src/bonsai/test/bim/module/clip_box/test_clip_only_ifc_products.py @@ -0,0 +1,273 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Pins the ``clip_only_ifc_products`` toggle contract. + +The toggle gates the cap-eligibility filter (IFC-only vs. all visible meshes) +and lives only on the Blender Scene PG — the project pset must never carry it. +""" + +import math + +import bpy +import ifcopenshell +import pytest +from mathutils import Matrix, Vector + +import bonsai.tool as tool +from test.bim.bootstrap import NewFile + +pytestmark = pytest.mark.clip_box + + +def _make_ifc_wall(ifc, location=(0.0, 0.0, 0.0)): + bpy.ops.mesh.primitive_cube_add(size=2.0, location=location) + obj = bpy.context.active_object + entity = ifc.create_entity("IfcWall") + tool.Ifc.link(entity, obj) + return entity, obj + + +def _make_blender_cube(location=(0.0, 0.0, 0.0)): + bpy.ops.mesh.primitive_cube_add(size=2.0, location=location) + return bpy.context.active_object + + +class TestDefaultIsTrue(NewFile): + def test_clip_only_ifc_products_defaults_to_true(self): + scene_props = tool.ClipBox.get_scene_props() + assert scene_props.clip_only_ifc_products is True + + +class TestCapEligibilityHonorsToggle(NewFile): + def test_only_ifc_true_excludes_non_ifc_mesh(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + _, wall = _make_ifc_wall(ifc, location=(0.0, 0.0, 0.0)) + cube = _make_blender_cube(location=(4.0, 0.0, 0.0)) + scene_props = tool.ClipBox.get_scene_props() + scene_props.clip_only_ifc_products = True + + eligible = set(tool.ClipBox._iter_capable_objects(bpy.context.scene)) + + assert wall in eligible + assert cube not in eligible + + def test_only_ifc_false_includes_non_ifc_mesh(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + _, wall = _make_ifc_wall(ifc, location=(0.0, 0.0, 0.0)) + cube = _make_blender_cube(location=(4.0, 0.0, 0.0)) + scene_props = tool.ClipBox.get_scene_props() + scene_props.clip_only_ifc_products = False + + eligible = set(tool.ClipBox._iter_capable_objects(bpy.context.scene)) + + assert wall in eligible + assert cube in eligible + + def test_only_ifc_false_works_without_ifc_file_loaded(self): + # No IFC at all; eligibility should still yield Blender meshes when + # the IFC-only filter is off, since there's nothing to filter against. + cube = _make_blender_cube(location=(0.0, 0.0, 0.0)) + scene_props = tool.ClipBox.get_scene_props() + scene_props.clip_only_ifc_products = False + + eligible = set(tool.ClipBox._iter_capable_objects(bpy.context.scene)) + + assert cube in eligible + + +class TestShowCapsTriggersRebuild(NewFile): + def test_show_caps_off_then_on_schedules_cap_rebuild(self): + # Off → On must schedule a rebuild — without this, caps stay empty + # until the user nudges geometry to fire the next depsgraph tick. + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + scene_props.show_caps = False + tool.ClipBox._cancel_pending_cap_rebuild() + assert tool.ClipBox._pending_cap_rebuild is None + + scene_props.show_caps = True + + assert tool.ClipBox._pending_cap_rebuild is not None + tool.ClipBox._cancel_pending_cap_rebuild() + + +class TestRebuildCapsNow(NewFile): + def test_rebuild_caps_now_cancels_any_pending_debounce(self): + # Synchronous path must wipe the debounced timer — otherwise the + # rebuild fires twice when the gizmo unlock interleaves with a + # depsgraph tick. + bpy.ops.bim.add_clip_box() + tool.ClipBox._schedule_cap_rebuild() + assert tool.ClipBox._pending_cap_rebuild is not None + + tool.ClipBox.rebuild_caps_now() + + assert tool.ClipBox._pending_cap_rebuild is None + + +class TestActiveClipBoxIndexRebuildsCaps(NewFile): + def test_index_change_schedules_cap_rebuild(self): + # UI-list click changes active_clip_box_index — the cap cache + # belongs to the previous box's clip volume, so a rebuild must + # be scheduled so the overlay matches the newly-active box. + bpy.ops.bim.add_clip_box() + bpy.ops.bim.add_clip_box() + scene_props = tool.ClipBox.get_scene_props() + tool.ClipBox._cancel_pending_cap_rebuild() + assert tool.ClipBox._pending_cap_rebuild is None + + scene_props.active_clip_box_index = 0 + + assert tool.ClipBox._pending_cap_rebuild is not None + tool.ClipBox._cancel_pending_cap_rebuild() + + +def _exec_align_view(axis: int, is_max: bool): + """Run ``bim.align_view_to_clip_face`` against the first VIEW_3D area + and return its ``rv3d``. Skips if no viewport is available in the + test session.""" + for area in bpy.context.window.screen.areas: + if area.type != "VIEW_3D": + continue + region = next((r for r in area.regions if r.type == "WINDOW"), None) + if region is None: + continue + with bpy.context.temp_override(area=area, region=region): + result = bpy.ops.bim.align_view_to_clip_face( + "EXEC_DEFAULT", axis=axis, is_max=is_max + ) + assert result == {"FINISHED"} + return bpy.context.space_data.region_3d + pytest.skip("No VIEW_3D area available") + + +class TestAlignViewToClipFace(NewFile): + def test_align_view_sets_rv3d_rotation_to_face_normal(self): + # The operator must reorient the viewport so its forward axis + # points AGAINST the picked face's outward normal (so the user + # sees the face from outside). + bpy.ops.bim.add_clip_box() + clip_box = tool.ClipBox.get_active_clip_box() + # Rotate the empty so the +X face's outward world normal isn't + # axis-aligned — proves the operator handles arbitrary rotation. + clip_box.matrix_world = Matrix.Rotation(math.radians(30), 4, "Z") @ clip_box.matrix_world + + rv3d = _exec_align_view(axis=0, is_max=True) + + outward = clip_box.matrix_world.to_3x3().col[0].normalized() + forward = rv3d.view_rotation @ Vector((0.0, 0.0, -1.0)) + assert (forward - (-outward)).length < 1e-4 + + def test_align_view_uses_box_local_z_up_for_side_face(self): + # Side faces (±X, ±Y local normals) follow Blender's numpad 1 / 3 + # convention but in the BOX'S local frame: local +Z is the + # screen-up axis, transformed through the empty's rotation. + bpy.ops.bim.add_clip_box() + clip_box = tool.ClipBox.get_active_clip_box() + clip_box.matrix_world = Matrix.Rotation(math.radians(45), 4, "Z") @ clip_box.matrix_world + + rv3d = _exec_align_view(axis=0, is_max=True) + + expected_up = (clip_box.matrix_world.to_quaternion() @ Vector((0.0, 0.0, 1.0))).normalized() + up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0)) + assert (up_world - expected_up).length < 1e-3, ( + f"Side-face view must have box-local +Z as up; expected {tuple(expected_up)}, got {tuple(up_world)}" + ) + + def test_align_view_keeps_box_local_z_up_for_negative_y_face(self): + # Clicking the -Y face used to put world +Z at the BOTTOM of the + # screen. With box-local convention it stays at the top. + bpy.ops.bim.add_clip_box() + + rv3d = _exec_align_view(axis=1, is_max=False) + + up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0)) + assert up_world.z > 0.99, f"-Y face view must keep box-local +Z as up, got {tuple(up_world)}" + + def test_align_view_respects_box_local_axes_when_box_x_rotated(self): + # Rotating around X moves box-local +Z away from world +Z; the + # up axis must follow the BOX, otherwise the box edges no longer + # appear horizontal/vertical when aligned to a face — the bug + # users hit on rotated boxes. + bpy.ops.bim.add_clip_box() + clip_box = tool.ClipBox.get_active_clip_box() + clip_box.matrix_world = Matrix.Rotation(math.radians(30), 4, "X") @ clip_box.matrix_world + + rv3d = _exec_align_view(axis=0, is_max=True) + + expected_up = (clip_box.matrix_world.to_quaternion() @ Vector((0.0, 0.0, 1.0))).normalized() + up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0)) + assert (up_world - expected_up).length < 1e-3, ( + f"X-rotated box must use box-local Z; expected {tuple(expected_up)}, got {tuple(up_world)}" + ) + + def test_align_view_uses_box_local_y_up_for_top_face(self): + # Top face (local +Z outward) follows Blender's numpad-7 + # convention applied in the box's local frame: local +Y is up. + bpy.ops.bim.add_clip_box() + + rv3d = _exec_align_view(axis=2, is_max=True) + + up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0)) + assert up_world.y > 0.99, f"Top-face view must have box-local +Y as up, got {tuple(up_world)}" + + def test_align_view_uses_box_local_negative_y_up_for_bottom_face(self): + # Bottom face (local -Z outward) follows ctrl-numpad-7: box-local + # -Y is up. + bpy.ops.bim.add_clip_box() + + rv3d = _exec_align_view(axis=2, is_max=False) + + up_world = rv3d.view_rotation @ Vector((0.0, 1.0, 0.0)) + assert up_world.y < -0.99, f"Bottom-face view must have box-local -Y as up, got {tuple(up_world)}" + + +class TestNotPersistedToProjectPset(NewFile): + def test_pset_does_not_carry_clip_only_ifc_products(self): + bpy.ops.bim.create_project() + scene_props = tool.ClipBox.get_scene_props() + # Flip to a non-default value, then trigger a pset write. + scene_props.clip_only_ifc_products = False + bpy.ops.bim.add_clip_box() # writes the pset + + import ifcopenshell.util.element + + project = tool.Ifc.get().by_type("IfcProject")[0] + pset = ifcopenshell.util.element.get_psets(project).get(tool.ClipBox.PSET_NAME, {}) + + # Whatever the pset stores, it must not carry this scene-only toggle. + for key in pset: + assert "clip_only_ifc" not in key.lower(), ( + f"Project pset unexpectedly carries the scene-only toggle (key {key!r})" + ) + + def test_load_from_pset_does_not_touch_clip_only_ifc_products(self): + # Round-trip: set the toggle on the Scene, simulate a pset load, and + # confirm the loader didn't overwrite the user's Scene-level choice. + bpy.ops.bim.create_project() + scene_props = tool.ClipBox.get_scene_props() + scene_props.clip_only_ifc_products = False + + tool.ClipBox.load_from_project_pset() + + assert scene_props.clip_only_ifc_products is False diff --git a/src/bonsai/test/bim/module/clip_box/test_face_quad.py b/src/bonsai/test/bim/module/clip_box/test_face_quad.py new file mode 100644 index 0000000000..2c838151fe --- /dev/null +++ b/src/bonsai/test/bim/module/clip_box/test_face_quad.py @@ -0,0 +1,298 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Tests for the generic face-quad gizmo core. + +Pins the three contracts the layout helper depends on: + +* ``compute_face_resize`` — pure one-sided resize arithmetic. +* ``front_facing_face_mask`` — view-aware face visibility predicate. +* ``apply_face_quad_layout`` — front-facing faces upload the solid + unit quad ("solid" state); back-facing faces upload the halo strips + ("strips" state). +""" + +import pytest +from mathutils import Matrix, Vector + +from bonsai.bim.module.clip_box import face_quad + +pytestmark = pytest.mark.clip_box + + +# ---------------------------------------------------------------- compute_face_resize --- + + +class TestComputeFaceResize: + def test_outward_drag_on_max_face_grows_half_extent_and_shifts_origin(self): + # Pulling the +X face outward by 2.0 world units must: + # - grow the world half by half the cursor delta (one-sided); + # - shift the empty's origin so the opposite (-X) face stays put. + new_scale, new_loc = face_quad.compute_face_resize( + value=10.0 + 2.0, # init + delta + init_world_half=10.0, + init_location=(0.0, 0.0, 0.0), + world_axis=(1.0, 0.0, 0.0), + display_size=1.0, + ) + # half-extent: 10 + 2/2 = 11 + assert new_scale == pytest.approx(11.0) + # origin shifts by half the realized delta (= 1.0) along +X + assert new_loc[0] == pytest.approx(1.0) + assert new_loc[1] == pytest.approx(0.0) + assert new_loc[2] == pytest.approx(0.0) + + def test_inward_drag_clamps_at_minimum_half_extent(self): + # Pulling the face inward by more than the current half collapses + # to a tiny floor instead of going negative. The realized delta + # (post-clamp) drives the location shift so the opposite face + # stays fixed even at the clamp. + new_scale, new_loc = face_quad.compute_face_resize( + value=0.0, # delta = -1.0 + init_world_half=1.0, + init_location=(5.0, 0.0, 0.0), + world_axis=(1.0, 0.0, 0.0), + display_size=1.0, + ) + assert new_scale > 0.0 + assert new_scale < 1.0 + # New origin sits between init (5.0) and -X face (which is at 4.0 + # = init.x - init_world_half). Since the clamp limited shrinkage, + # the new origin is just slightly less than init.x. + assert 4.0 < new_loc[0] < 5.0 + + def test_drag_on_min_face_via_negative_world_axis_grows_outward(self): + # On the -X face, ``world_axis`` is (-1, 0, 0). A positive + # ``delta`` (outward on this face) must still grow the half + # extent and shift the origin in the -X direction. + new_scale, new_loc = face_quad.compute_face_resize( + value=10.0 + 2.0, + init_world_half=10.0, + init_location=(0.0, 0.0, 0.0), + world_axis=(-1.0, 0.0, 0.0), + display_size=1.0, + ) + assert new_scale == pytest.approx(11.0) + # Origin shifts toward -X. + assert new_loc[0] == pytest.approx(-1.0) + + def test_display_size_scales_the_resulting_scale_axis(self): + # The returned scale is half_extent / display_size — so a + # display_size of 2.0 halves the scale relative to display_size + # of 1.0 for the same world half-extent. + new_scale_1, _ = face_quad.compute_face_resize( + value=10.0, + init_world_half=10.0, + init_location=(0.0, 0.0, 0.0), + world_axis=(1.0, 0.0, 0.0), + display_size=1.0, + ) + new_scale_2, _ = face_quad.compute_face_resize( + value=10.0, + init_world_half=10.0, + init_location=(0.0, 0.0, 0.0), + world_axis=(1.0, 0.0, 0.0), + display_size=2.0, + ) + assert new_scale_1 == pytest.approx(10.0) + assert new_scale_2 == pytest.approx(5.0) + + +# ----------------------------------------------------------- front_facing_face_mask --- + + +class TestFrontFacingFaceMask: + def test_view_along_neg_z_lights_up_only_pos_z_face(self): + # Camera looking down -Z (typical default front view): only the + # +Z face (last entry) faces the camera. + normals = ( + (-1.0, 0.0, 0.0), # -X face + (1.0, 0.0, 0.0), # +X face + (0.0, -1.0, 0.0), # -Y face + (0.0, 1.0, 0.0), # +Y face + (0.0, 0.0, -1.0), # -Z face + (0.0, 0.0, 1.0), # +Z face + ) + view_dir = (0.0, 0.0, -1.0) + + mask = face_quad.front_facing_face_mask(normals, view_dir) + + assert mask == (False, False, False, False, False, True) + + def test_view_along_pos_x_lights_up_neg_x_face(self): + # Camera looking along +X (front of the -X face). + normals = ( + (-1.0, 0.0, 0.0), + (1.0, 0.0, 0.0), + (0.0, -1.0, 0.0), + (0.0, 1.0, 0.0), + (0.0, 0.0, -1.0), + (0.0, 0.0, 1.0), + ) + view_dir = (1.0, 0.0, 0.0) + + mask = face_quad.front_facing_face_mask(normals, view_dir) + + assert mask == (True, False, False, False, False, False) + + def test_wrong_length_raises(self): + with pytest.raises(ValueError, match="expected 6 face normals"): + face_quad.front_facing_face_mask( + [(1.0, 0.0, 0.0), (-1.0, 0.0, 0.0)], (0.0, 0.0, -1.0) + ) + + +# ----------------------------------------------------- apply_face_quad_layout (front/back) --- + + +class _FakeQuad: + """Stand-in for ``BIM_GT_box_face_quad`` — only the slots the layout helper writes.""" + + def __init__(self): + self.matrix_basis = Matrix.Identity(4) + self.axis = Vector((0.0, 0.0, 0.0)) + self.hide = False + self.select_bias = 0.0 + self.is_highlight = False + self.custom_shape = None + self.custom_shape_select = None + self._last_geometry_state = None + self._strips_cache_key = None + + def new_custom_shape(self, kind, verts): + # Layout helper only stores the result; nothing further is asked of it. + return (kind, tuple(tuple(v) for v in verts)) + + +class _FakeOutline: + def __init__(self): + self.matrix_basis = Matrix.Identity(4) + self.alpha = 0.0 + self.alpha_highlight = 0.0 + + +class _FakeRV3D: + def __init__(self, view_rotation, view_matrix): + self.view_rotation = view_rotation + self.view_matrix = view_matrix + # Blender's location_3d_to_region_2d reads perspective_matrix to + # project world points; a simple ortho-projection matrix is enough + # for the layout helper's halo-strip pixel measurement. + self.perspective_matrix = view_matrix + self.is_perspective = False + + +class _FakeRegion: + width = 800 + height = 600 + + +def _run_layout(view_dir: Vector) -> tuple[str, ...]: + """Apply the layout helper for a unit cube at the origin with a + given world-space view direction; return each route's + ``_last_geometry_state`` in :data:`FACE_ROUTES` order.""" + quads = [_FakeQuad() for _ in range(6)] + outlines = [_FakeOutline() for _ in range(6)] + # view_rotation is the quaternion that rotates the camera's local + # forward (-Z) onto the desired world view direction. + view_rotation = Vector((0.0, 0.0, -1.0)).rotation_difference(view_dir.normalized()) + rv3d = _FakeRV3D(view_rotation, Matrix.Identity(4)) + face_quad.apply_face_quad_layout( + quad_gizmos=quads, + outline_gizmos=outlines, + bmin=Vector((-1.0, -1.0, -1.0)), + bmax=Vector((1.0, 1.0, 1.0)), + matrix_world=Matrix.Identity(4), + cage_rotation=Matrix.Identity(4), + region=_FakeRegion(), + rv3d=rv3d, + locked=False, + ) + return tuple(getattr(q, "_last_geometry_state", None) for q in quads) + + +class TestApplyFaceQuadLayout: + def test_oblique_view_yields_solid_fronts_and_strips_or_empty_backs(self): + # Oblique view direction (1, 1, -1) hits the box from the +X, +Y, + # +Z octant. Faces facing toward the camera (-X, -Y, +Z) must + # render as "solid"; faces facing away (+X, +Y, -Z) must render + # as back-facing — either "strips" (when adjacent front faces + # give halo edges) or "empty" (when no front-facing neighbour). + states = _run_layout(view_dir=Vector((1.0, 1.0, -1.0))) + + # FACE_ROUTES order: (-X, +X, -Y, +Y, -Z, +Z) + # Front-facing routes (against the view direction): -X, -Y, +Z + assert states[0] == "solid" # -X + assert states[2] == "solid" # -Y + assert states[5] == "solid" # +Z + # Back-facing routes (with the view direction): +X, +Y, -Z + for back_idx in (1, 3, 4): + assert states[back_idx] in ("strips", "empty") + + def test_negative_scale_host_does_not_invert_front_back_split(self): + # User-reported bug: when the host empty has scale=-1 on an axis, + # the visible +X side of the cube sits on world +X (negative-scale + # flips the local +X vertex onto world -X but the local -X vertex + # onto world +X — same set of points). The OLD layout used the + # signed matrix for positions while rotation-only for normals, + # which placed the "+X face" gizmo on world -X. After the + # ``_abs_scale_matrix`` fix the gizmo for the +X face must sit + # at world +X for an outward-X-facing view to register it as + # front-facing. + quads = [_FakeQuad() for _ in range(6)] + outlines = [_FakeOutline() for _ in range(6)] + # View toward +X: the +X face is at world +X for a standard box. + view_rotation = Vector((0.0, 0.0, -1.0)).rotation_difference( + Vector((-1.0, 0.0, 0.0)) + ) + rv3d = _FakeRV3D(view_rotation, Matrix.Identity(4)) + # Negative X scale (mirroring the cube along world X). + mw = Matrix.Diagonal((-1.0, 1.0, 1.0, 1.0)) + + face_quad.apply_face_quad_layout( + quad_gizmos=quads, + outline_gizmos=outlines, + bmin=Vector((-1.0, -1.0, -1.0)), + bmax=Vector((1.0, 1.0, 1.0)), + matrix_world=mw, + cage_rotation=Matrix.Identity(4), + region=_FakeRegion(), + rv3d=rv3d, + locked=False, + ) + + # Route 1 = (axis=0, is_max=True) = the +X face. Must be solid + # (front-facing) for a +X-facing view, regardless of sign-of-scale. + assert quads[1]._last_geometry_state == "solid" + + def test_view_parallel_front_face_remains_interactive(self): + # Looking dead-on at +Z (view_dir = -Z): the +Z face sits + # antiparallel to the view direction, so it's still the + # front-facing face. It must render solid (clickable for both + # the resize drag and the CTRL+click align-view dispatch), + # never hidden — the older "lockout" treatment removed + # CTRL+click access on the very face users most want to click. + states = _run_layout(view_dir=Vector((0.0, 0.0, -1.0))) + + assert states[5] == "solid" # +Z face (front-facing) stays interactive. + # -Z face has no adjacent front-facing neighbours in this view, + # so its halo strip degenerates to empty — but that's the + # back-face path, not a deliberate lockout. + assert states[4] == "empty" diff --git a/src/bonsai/test/bim/module/clip_box/test_source_kind_forward_compat.py b/src/bonsai/test/bim/module/clip_box/test_source_kind_forward_compat.py new file mode 100644 index 0000000000..84514ebd4b --- /dev/null +++ b/src/bonsai/test/bim/module/clip_box/test_source_kind_forward_compat.py @@ -0,0 +1,76 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Forward-compat guards for the source-based clip-box wiring. + +Adding a new source kind requires matching entries across four sites — the +label dict, the dispatch table, a callback in ``data.py``, and the menu entry. +Missing one path silently degrades the dialog to "No options" with no error. +These tests pin the four-way integrity. +""" + +import pytest + +from bonsai.bim.module.clip_box import data, operator, ui + +pytestmark = pytest.mark.clip_box + + +def test_every_label_has_a_dispatch_entry(): + missing = set(operator.SOURCE_KIND_LABELS) - set(operator._SOURCE_ID_DISPATCH) + assert not missing, f"Kinds missing from dispatch: {sorted(missing)}" + + +def test_every_dispatch_value_is_callable(): + for kind, fn in operator._SOURCE_ID_DISPATCH.items(): + assert callable(fn), f"Dispatch entry for {kind} is not callable" + + +def test_every_dispatch_target_lives_in_data_module(): + # Each callback must be a real attribute of the data module; protects + # against typos in the dispatch table that would otherwise only surface + # at the first dialog open. + for kind, fn in operator._SOURCE_ID_DISPATCH.items(): + assert getattr(data, fn.__name__, None) is fn, ( + f"Dispatch target for {kind} ({fn.__name__}) is not exported from data.py" + ) + + +def test_every_menu_entry_is_a_known_kind(): + for kind, label, icon in ui._SOURCE_MENU_ENTRIES: + assert kind in operator.SOURCE_KIND_LABELS, ( + f"Menu kind {kind!r} (label={label!r}) is not in SOURCE_KIND_LABELS" + ) + + +def test_every_label_has_a_menu_entry(): + menu_kinds = {kind for kind, _label, _icon in ui._SOURCE_MENU_ENTRIES} + missing = set(operator.SOURCE_KIND_LABELS) - menu_kinds + assert not missing, f"Kinds missing from menu: {sorted(missing)}" + + +def test_status_values_match_between_tool_and_data(): + # The status picker labels in data.STATUS_LABELS and the tool-layer + # validation list must agree — the dispatcher rejects any status value + # missing from the latter. + from bonsai.tool.clip_box import SOURCE_STATUS_VALUES + + data_values = tuple(value for value, _label in data.STATUS_LABELS) + assert data_values == SOURCE_STATUS_VALUES diff --git a/src/bonsai/test/tool/test_clip_box_for_source.py b/src/bonsai/test/tool/test_clip_box_for_source.py new file mode 100644 index 0000000000..78643e3f4d --- /dev/null +++ b/src/bonsai/test/tool/test_clip_box_for_source.py @@ -0,0 +1,375 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +import math + +import bpy +import ifcopenshell +import ifcopenshell.api.spatial +import ifcopenshell.api.type +import pytest +from mathutils import Vector + +import bonsai.tool as tool +from test.bim.bootstrap import NewFile + +pytestmark = pytest.mark.clip_box + + +def _make_ifc_cube(ifc, ifc_class, location=(0.0, 0.0, 0.0), size=2.0): + """Real bpy cube + ifc entity, linked. ``size`` is the cube edge length.""" + bpy.ops.mesh.primitive_cube_add(size=size, location=location) + obj = bpy.context.active_object + entity = ifc.create_entity(ifc_class) + tool.Ifc.link(entity, obj) + return entity, obj + + +class TestWorldBboxMatrix(NewFile): + def test_two_cubes_returns_centred_aabb_matrix(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0) + wall_b, _ = _make_ifc_cube(ifc, "IfcWall", location=(4.0, 0.0, 0.0), size=2.0) + + matrix = tool.ClipBox._world_bbox_matrix_for_elements([wall_a, wall_b]) + + assert matrix is not None + translation, _, scale = matrix.decompose() + # World AABB: x in [-1, 5], y/z in [-1, 1] -> center (2, 0, 0), half (3, 1, 1). + assert translation.x == pytest.approx(2.0) + assert translation.y == pytest.approx(0.0) + assert translation.z == pytest.approx(0.0) + assert scale.x == pytest.approx(3.0) + assert scale.y == pytest.approx(1.0) + assert scale.z == pytest.approx(1.0) + + def test_empty_iterable_returns_none(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + assert tool.ClipBox._world_bbox_matrix_for_elements([]) is None + + def test_element_without_blender_object_is_skipped(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0) + unbound = ifc.create_entity("IfcWall") + + matrix = tool.ClipBox._world_bbox_matrix_for_elements([wall_a, unbound]) + + assert matrix is not None + translation, _, scale = matrix.decompose() + assert translation.x == pytest.approx(0.0) + assert translation.y == pytest.approx(0.0) + assert translation.z == pytest.approx(0.0) + assert scale.x == pytest.approx(1.0) + + def test_all_filtered_returns_none(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + unbound_a = ifc.create_entity("IfcWall") + unbound_b = ifc.create_entity("IfcWall") + assert tool.ClipBox._world_bbox_matrix_for_elements([unbound_a, unbound_b]) is None + + def test_coincident_cubes_return_invertible_matrix(self): + # Two cubes at the same location collapse to a zero-volume AABB. + # The half-extent floor must keep the matrix invertible so downstream + # clip-plane math doesn't divide through a singular transform. + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=0.0001) + wall_b, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=0.0001) + + matrix = tool.ClipBox._world_bbox_matrix_for_elements([wall_a, wall_b]) + + assert matrix is not None + # A zero determinant means the matrix would map every point onto a + # subspace — the floor must prevent that. + assert matrix.determinant() != 0.0 + + +class TestCameraFrustumMatrix(NewFile): + def _make_camera(self, location=(0.0, 0.0, 0.0), rotation=None): + cam_data = bpy.data.cameras.new("DrawingCam") + cam_data.type = "ORTHO" + obj = bpy.data.objects.new("DrawingCam", cam_data) + bpy.context.scene.collection.objects.link(obj) + obj.location = location + if rotation is not None: + obj.rotation_euler = rotation + bpy.context.view_layer.update() + return obj + + def test_identity_camera_width_height_drive_in_plane_extents(self): + obj = self._make_camera() + cam = obj.data + cam.clip_start = 0.0 + cam.clip_end = 10.0 + cam.BIMCameraProperties.width = 8.0 + cam.BIMCameraProperties.height = 6.0 + + matrix = tool.ClipBox._camera_frustum_matrix(obj) + + translation, _, scale = matrix.decompose() + # Identity rotation: box centre at (0, 0, -5) in world (cameras look down -Z). + assert translation.x == pytest.approx(0.0) + assert translation.y == pytest.approx(0.0) + assert translation.z == pytest.approx(-5.0) + # Half-extents: width/2, height/2, (clip_end - clip_start) / 2. + assert scale.x == pytest.approx(4.0) + assert scale.y == pytest.approx(3.0) + assert scale.z == pytest.approx(5.0) + + def test_rotated_camera_preserves_rotation_in_matrix(self): + obj = self._make_camera(rotation=(0.0, math.radians(90), 0.0)) + cam = obj.data + cam.clip_start = 0.0 + cam.clip_end = 4.0 + cam.BIMCameraProperties.width = 2.0 + cam.BIMCameraProperties.height = 2.0 + + matrix = tool.ClipBox._camera_frustum_matrix(obj) + + _, rotation, scale = matrix.decompose() + # Scale is rotation-invariant. + assert scale.x == pytest.approx(1.0) + assert scale.y == pytest.approx(1.0) + assert scale.z == pytest.approx(2.0) + # The rotation component matches the camera's own rotation; quaternion + # dot product near unit magnitude means the orientations agree. + cam_rot = obj.matrix_world.decompose()[1] + assert abs(cam_rot.dot(rotation)) > 0.999 + + def test_returns_none_when_width_height_zero(self): + # A camera without usable drawing extents (width or height ≤ 0) + # cannot define a clip volume — caller surfaces ERROR + CANCELLED. + obj = self._make_camera() + cam = obj.data + cam.clip_start = 0.0 + cam.clip_end = 10.0 + cam.BIMCameraProperties.width = 8.0 + # height stays at the BIMCameraProperties default (50). We can't set + # height=0 here because the update callback divides width/height. + # Set width=0 directly via the underlying ID property instead, which + # bypasses the registered FloatProperty update path. + cam.BIMCameraProperties["width"] = 0.0 + + assert tool.ClipBox._camera_frustum_matrix(obj) is None + + +class TestIterElementsForSource(NewFile): + def test_no_ifc_file_returns_empty(self): + # NewFile leaves IfcStore purged; tool.Ifc.get() is None here. + assert tool.ClipBox.iter_elements_for_source("SPATIAL", "1") == [] + + def test_unknown_kind_returns_empty(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + wall = ifc.create_entity("IfcWall") + assert tool.ClipBox.iter_elements_for_source("UNKNOWN_KIND", str(wall.id())) == [] + + def test_non_integer_source_id_returns_empty(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + assert tool.ClipBox.iter_elements_for_source("SPATIAL", "not_an_int") == [] + + def test_unresolved_source_id_returns_empty(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + assert tool.ClipBox.iter_elements_for_source("SPATIAL", "999999") == [] + + def test_spatial_returns_decomposition(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + storey = ifc.create_entity("IfcBuildingStorey") + wall_a = ifc.create_entity("IfcWall") + wall_b = ifc.create_entity("IfcWall") + ifcopenshell.api.spatial.assign_container( + ifc, products=[wall_a, wall_b], relating_structure=storey + ) + + result = tool.ClipBox.iter_elements_for_source("SPATIAL", str(storey.id())) + + assert set(result) == {wall_a, wall_b} + + def test_type_returns_occurrences(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + wall_type = ifc.create_entity("IfcWallType") + wall_a = ifc.create_entity("IfcWall") + wall_b = ifc.create_entity("IfcWall") + ifcopenshell.api.type.assign_type( + ifc, related_objects=[wall_a, wall_b], relating_type=wall_type + ) + + result = tool.ClipBox.iter_elements_for_source("TYPE", str(wall_type.id())) + + assert set(result) == {wall_a, wall_b} + + def test_drawing_returns_drawing_entity(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + drawing = ifc.create_entity("IfcAnnotation", ObjectType="DRAWING") + + result = tool.ClipBox.iter_elements_for_source("DRAWING", str(drawing.id())) + + assert result == [drawing] + + def test_status_invalid_value_returns_empty(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + assert tool.ClipBox.iter_elements_for_source("STATUS", "MADE_UP_STATUS") == [] + + def test_class_returns_all_instances_of_ifc_class(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + wall_a = ifc.create_entity("IfcWall") + wall_b = ifc.create_entity("IfcWall") + window = ifc.create_entity("IfcWindow") + + result = tool.ClipBox.iter_elements_for_source("CLASS", "IfcWall") + + assert set(result) == {wall_a, wall_b} + assert window not in result + + def test_class_unknown_ifc_class_returns_empty(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + ifc.create_entity("IfcWall") + assert tool.ClipBox.iter_elements_for_source("CLASS", "IfcNotARealClass") == [] + + +class TestComputeMatrixForSource(NewFile): + def test_spatial_aggregates_contained_elements(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + storey = ifc.create_entity("IfcBuildingStorey") + wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0) + wall_b, _ = _make_ifc_cube(ifc, "IfcWall", location=(4.0, 0.0, 0.0), size=2.0) + ifcopenshell.api.spatial.assign_container( + ifc, products=[wall_a, wall_b], relating_structure=storey + ) + + matrix = tool.ClipBox.compute_matrix_for_source("SPATIAL", str(storey.id())) + + assert matrix is not None + translation, _, scale = matrix.decompose() + assert translation.x == pytest.approx(2.0) + assert scale.x == pytest.approx(3.0) + + def test_no_match_returns_none(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + wall_type = ifc.create_entity("IfcWallType") + # No occurrences linked. + assert tool.ClipBox.compute_matrix_for_source("TYPE", str(wall_type.id())) is None + + def test_drawing_uses_camera_frustum(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + drawing = ifc.create_entity("IfcAnnotation", ObjectType="DRAWING") + cam_data = bpy.data.cameras.new("Cam") + cam_data.type = "ORTHO" + cam_data.clip_start = 0.0 + cam_data.clip_end = 10.0 + cam_data.BIMCameraProperties.width = 4.0 + cam_data.BIMCameraProperties.height = 4.0 + obj = bpy.data.objects.new("Cam", cam_data) + bpy.context.scene.collection.objects.link(obj) + tool.Ifc.link(drawing, obj) + + matrix = tool.ClipBox.compute_matrix_for_source("DRAWING", str(drawing.id())) + + assert matrix is not None + _, _, scale = matrix.decompose() + assert scale.x == pytest.approx(2.0) + assert scale.y == pytest.approx(2.0) + assert scale.z == pytest.approx(5.0) + + def test_drawing_with_non_camera_returns_none(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + drawing = ifc.create_entity("IfcAnnotation", ObjectType="DRAWING") + obj = bpy.data.objects.new("NotACamera", None) + bpy.context.scene.collection.objects.link(obj) + tool.Ifc.link(drawing, obj) + + assert tool.ClipBox.compute_matrix_for_source("DRAWING", str(drawing.id())) is None + + def test_status_with_no_matching_elements_returns_none(self): + # STATUS pick with a valid status value but no element carrying that + # status — the dispatcher must surface "nothing matched" the same way + # an empty TYPE / MATERIAL pick does. + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + # Create a wall but never assign its Pset_WallCommon.Status — so a + # STATUS=NEW query finds 0 elements. + _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0) + + assert tool.ClipBox.compute_matrix_for_source("STATUS", "NEW") is None + + +class _FakeRegion: + def __init__(self, width, height): + self.width = width + self.height = height + + +class _FakeRV3D: + def __init__(self, view_matrix=()): # () is a truthy-enough non-None stand-in + self.view_matrix = view_matrix + self.updated = False + self.use_clip_planes = False + self.clip_planes = None + + def update(self): + self.updated = True + + +class TestRegionIsRenderable: + """``_region_is_renderable`` gates the clip-plane arm against collapsed / + initializing regions whose ``region_3d.update()`` would CTD Blender inside + ``GPU_matrix_ortho_set`` (the timer-arm crash this guard fixes).""" + + def test_sized_region_with_view_matrix_is_renderable(self): + assert tool.ClipBox._region_is_renderable(_FakeRegion(800, 600), _FakeRV3D()) is True + + def test_zero_width_is_not_renderable(self): + assert tool.ClipBox._region_is_renderable(_FakeRegion(0, 600), _FakeRV3D()) is False + + def test_zero_height_is_not_renderable(self): + assert tool.ClipBox._region_is_renderable(_FakeRegion(800, 0), _FakeRV3D()) is False + + def test_missing_view_matrix_is_not_renderable(self): + rv3d = _FakeRV3D() + rv3d.view_matrix = None + assert tool.ClipBox._region_is_renderable(_FakeRegion(800, 600), rv3d) is False + + def test_arm_region_early_returns_on_zero_size(self): + # A collapsed region must never reach temp_override / clip_border / + # update() — _arm_region short-circuits at the size guard. Positively + # assert update() was NOT called and no clip state was written, so a + # regression that drops the guard fails here rather than passing on + # "didn't crash". + rv3d = _FakeRV3D() + tool.ClipBox._arm_region(object(), _FakeRegion(0, 0), rv3d, ()) + assert rv3d.updated is False + assert rv3d.use_clip_planes is False + assert rv3d.clip_planes is None