mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-18 11:20:21 +00:00
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:
@@ -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
|
||||
Reference in New Issue
Block a user