Extend clip box with face handles and presets

Add source-based clip box presets — a dropdown menu next to the Add
Clip Box button lets the user pre-size a clip box to the bounding box
of a chosen IFC source: a spatial element, IFC class, type, material,
profile, drawing camera frustum, status, system, group, or zone. The
picker dialog uses prop_with_search so files with hundreds of materials
or types remain browsable.

Add interactive face resize handles — six near-invisible click-target
quads render on the active clip box when its empty is the active
object. Dragging a face grows or shrinks the box one-sided on that
axis; the opposite face stays fixed. Ctrl+Click on a face aligns the
viewport to look at that face, following Blender's numpad-view
convention applied to the box's local axes so rotated boxes align
orthogonally to the screen. The gizmos honour negative-scale empties
so the visible cube and the clickable handles stay aligned.

Add settings and info menus — a gear-icon menu next to the Enable
Clipping / Show Caps toggles exposes per-file preferences (cap only
IFC products, show face handles); an info-icon menu adjacent documents
the gizmo gestures. A quick-access toggle row also appears in the
viewport Overlay popover, greyed out when no clip box exists, and
orphaned clip-box list entries now expose an X button so users can
recover from external host-empty deletions.

Plumbing: cap rebuild fires synchronously on gizmo release and
clip-box selection change, so the cross-section overlay re-forms
without waiting for the depsgraph debounce; cap eligibility honours
the "Only IFC Products" toggle. Includes 121 tests covering source
resolution, drag math, face visibility, gizmo registration, and the
view-alignment up-axis convention.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-22 10:35:55 +02:00
parent 312be203c9
commit 3c20c27794
14 changed files with 3235 additions and 68 deletions
@@ -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,
)
@@ -0,0 +1,212 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""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")],
)
@@ -0,0 +1,921 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""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",
]
@@ -0,0 +1,323 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""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")
+170 -44
View File
@@ -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"}
@@ -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
+64 -5
View File
@@ -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",
+7
View File
@@ -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):
+335 -18
View File
@@ -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
@@ -0,0 +1,134 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
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
@@ -0,0 +1,273 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""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
@@ -0,0 +1,298 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""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"
@@ -0,0 +1,76 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""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
@@ -0,0 +1,375 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
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