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
@@ -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