mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-11 06:18:09 +00:00
Add viewport clip-box feature
A clip box hides everything outside a user-controllable oriented bounding box, with cross-section caps drawn where IFC product geometry intersects the planes. The box is hosted on a Blender empty (CUBE display); its matrix_world is the single source of truth — G/R/S edits the empty and the viewport clip planes track. State persists through IFC save/load via a project-level pset (IfcProject.BBIM_ClipBoxes) so the boxes survive without binding to any IfcRoot entity (avoids the IFC scale-lock / strip). UI: BIM_PT_clip_box under the Sandbox tab. Prominent Enable Clipping + Show Caps toggles at top, then Add, then a UIList with per-row duplicate / remove icons. Scene-level enabled / show_caps so the "hide everything outside" intent applies file-wide; enabled is intentionally not persisted to the pset so reopening an IFC never silently hides geometry. Adding a clip box arms clipping so the user immediately sees the cut. Default spawn at the 3D cursor with scale 10 (a 20 m cube) so the volume covers a typical building storey or two rather than the meaningless 2 m unit cube. Modal-aware: depsgraph + draw-handler paths gate per-frame side effects on tool.Blender.is_transform_modal_active so dragging G/R/S on the box only writes the pset once on commit, not per frame. Shift+D / Alt+D / Ctrl+Shift+D on a clip box gets adopted as a first-class entry via the collection-to-list sync. Cap eligibility is gated on IfcElement (walls, slabs, doors, …) so spatial structure (IfcSpace, IfcBuildingStorey, IfcSite) and annotations / grids never sprout solid fills at clip boundaries. Cap rebuild is debounced behind a 1 s quiet window so external gizmo drags (and any other burst of non-Bonsai depsgraph updates) collapse to one rebuild on release. Bonsai's own G/R/S keeps the snappy on-release feel via a modal-end fast-path. The relevance filter compares a per-Object matrix hash against a baseline so a plain selection click — which Blender quirkily flags as a transform update — doesn't churn the cache or flash the caps off. Edit mode short-circuits both the rebuild scheduler and the draw handler entirely. Caps use the evaluated mesh (modifier stack applied) and a session/matrix/clip-box-hash cache so a typical scene only re-bisects meshes whose geometry actually changed. Performance: every per-frame poller (refresh, depsgraph handlers, draw handlers) short-circuits on the cheapest available check first — cap_cache emptiness for the post-view draw handler, scene_props.enabled for the rest — so a session with clipping disabled pays only one boolean read per tick. Known v1 limitations documented in tests / docstrings: hollow profiles cap as solid discs (single-ring tessellation only), non-watertight inputs may produce degenerate caps, quad-view untested, Cycles / EEVEE render not supported (GPU-overlay only). Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -0,0 +1,692 @@
|
||||
# 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
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from test.bim.bootstrap import NewFile
|
||||
|
||||
pytestmark = pytest.mark.clip_box
|
||||
|
||||
|
||||
class TestAddClipBox(NewFile):
|
||||
def test_creates_empty_and_registers_entry(self):
|
||||
result = bpy.ops.bim.add_clip_box()
|
||||
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 host is not None
|
||||
assert host.empty_display_type == "CUBE"
|
||||
obj_props = tool.ClipBox.get_object_props(host)
|
||||
assert obj_props.is_clip_box is True
|
||||
|
||||
def test_spawns_at_3d_cursor(self):
|
||||
bpy.context.scene.cursor.location = (4.0, 0.0, 2.0)
|
||||
bpy.ops.bim.add_clip_box()
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
assert host is not None
|
||||
assert host.matrix_world.translation.x == pytest.approx(4.0)
|
||||
assert host.matrix_world.translation.z == pytest.approx(2.0)
|
||||
|
||||
def test_spawns_in_clip_boxes_collection(self):
|
||||
bpy.ops.bim.add_clip_box()
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
collection_names = [c.name for c in host.users_collection]
|
||||
assert "BBIM_ClipBoxes" in collection_names
|
||||
|
||||
|
||||
class TestActiveClipBoxResolution(NewFile):
|
||||
def test_no_box_returns_none(self):
|
||||
assert tool.ClipBox.get_active_clip_box() is None
|
||||
|
||||
def test_active_index_out_of_range_returns_none(self):
|
||||
bpy.ops.bim.add_clip_box()
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
scene_props.active_clip_box_index = 99
|
||||
assert tool.ClipBox.get_active_clip_box() is None
|
||||
|
||||
|
||||
class TestComputePlanes(NewFile):
|
||||
def test_planes_match_unit_box_at_origin(self):
|
||||
bpy.context.scene.cursor.location = (0.0, 0.0, 0.0)
|
||||
bpy.ops.bim.add_clip_box()
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
host.matrix_world = Matrix.Identity(4)
|
||||
|
||||
planes = tool.ClipBox.compute_planes(host)
|
||||
assert tool.Cad.point_is_inside_clip_planes(planes, Vector((0, 0, 0)))
|
||||
assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((2, 0, 0)))
|
||||
assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((0, 0, -2)))
|
||||
|
||||
def test_scaled_host_grows_clip_region(self):
|
||||
bpy.context.scene.cursor.location = (0.0, 0.0, 0.0)
|
||||
bpy.ops.bim.add_clip_box()
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
host.matrix_world = Matrix.Diagonal((3.0, 1.0, 1.0, 1.0))
|
||||
|
||||
# Test points well clear of any reasonable expand margin so the
|
||||
# assertion pins the OBB scaling behaviour, not the margin value.
|
||||
planes = tool.ClipBox.compute_planes(host)
|
||||
assert tool.Cad.point_is_inside_clip_planes(planes, Vector((2.5, 0, 0)))
|
||||
assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((4.0, 0, 0)))
|
||||
|
||||
def test_rotated_host_rotates_clip_region(self):
|
||||
bpy.context.scene.cursor.location = (0.0, 0.0, 0.0)
|
||||
bpy.ops.bim.add_clip_box()
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
host.matrix_world = Matrix.Rotation(math.radians(45), 4, "Z")
|
||||
|
||||
# Test points well clear of the expand margin so the assertion
|
||||
# pins rotation, not the margin value.
|
||||
planes = tool.ClipBox.compute_planes(host)
|
||||
assert tool.Cad.point_is_inside_clip_planes(planes, Vector((0.5, 0, 0)))
|
||||
assert not tool.Cad.point_is_inside_clip_planes(planes, Vector((2.0, 0, 0)))
|
||||
|
||||
def test_translated_and_rotated_host_keeps_centre_inside(self):
|
||||
bpy.context.scene.cursor.location = (5.0, 7.0, 0.0)
|
||||
bpy.ops.bim.add_clip_box()
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
host.matrix_world = Matrix.Translation((5.0, 7.0, 0.0)) @ Matrix.Rotation(math.radians(30), 4, "Z")
|
||||
|
||||
planes = tool.ClipBox.compute_planes(host)
|
||||
assert tool.Cad.point_is_inside_clip_planes(planes, Vector((5, 7, 0)))
|
||||
|
||||
|
||||
class TestToggleEnabled(NewFile):
|
||||
def test_flips_scene_enabled_flag(self):
|
||||
bpy.ops.bim.add_clip_box()
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
original = scene_props.enabled
|
||||
bpy.ops.bim.toggle_clip_box_enabled()
|
||||
assert scene_props.enabled is (not original)
|
||||
|
||||
|
||||
class TestSetActiveClipBox(NewFile):
|
||||
def test_switches_active_index(self):
|
||||
bpy.ops.bim.add_clip_box()
|
||||
bpy.ops.bim.add_clip_box()
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
assert scene_props.active_clip_box_index == 1
|
||||
bpy.ops.bim.set_active_clip_box(index=0)
|
||||
assert scene_props.active_clip_box_index == 0
|
||||
|
||||
def test_invalid_index_cancels(self):
|
||||
bpy.ops.bim.add_clip_box()
|
||||
result = bpy.ops.bim.set_active_clip_box(index=99)
|
||||
assert result == {"CANCELLED"}
|
||||
|
||||
|
||||
class TestRemoveClipBox(NewFile):
|
||||
def test_drops_active_entry_when_no_index(self):
|
||||
bpy.ops.bim.add_clip_box()
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
host_name = host.name
|
||||
bpy.ops.bim.remove_clip_box(delete_object=True)
|
||||
assert host_name not in bpy.data.objects
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
assert len(scene_props.clip_boxes) == 0
|
||||
|
||||
def test_drops_specified_index(self):
|
||||
# Per-row UIList button passes index explicitly; the user can
|
||||
# click X on any row without first selecting it as active.
|
||||
bpy.ops.bim.add_clip_box()
|
||||
bpy.ops.bim.add_clip_box()
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
first_name = scene_props.clip_boxes[0].obj.name
|
||||
bpy.ops.bim.remove_clip_box(index=0)
|
||||
assert first_name not in bpy.data.objects
|
||||
assert len(scene_props.clip_boxes) == 1
|
||||
|
||||
def test_no_active_box_cancels(self):
|
||||
result = bpy.ops.bim.remove_clip_box()
|
||||
assert result == {"CANCELLED"}
|
||||
|
||||
def test_out_of_range_index_cancels(self):
|
||||
bpy.ops.bim.add_clip_box()
|
||||
result = bpy.ops.bim.remove_clip_box(index=99)
|
||||
assert result == {"CANCELLED"}
|
||||
|
||||
|
||||
class TestPsetPersistence(NewFile):
|
||||
def test_add_clip_box_writes_project_pset(self):
|
||||
import ifcopenshell.util.element
|
||||
|
||||
bpy.ops.bim.create_project()
|
||||
bpy.ops.bim.add_clip_box()
|
||||
project = tool.Ifc.get().by_type("IfcProject")[0]
|
||||
psets = ifcopenshell.util.element.get_psets(project)
|
||||
assert tool.ClipBox.PSET_NAME in psets
|
||||
assert psets[tool.ClipBox.PSET_NAME]["Count"] == 1
|
||||
|
||||
def test_round_trip_via_pset_restores_matrix(self):
|
||||
bpy.ops.bim.create_project()
|
||||
bpy.ops.bim.add_clip_box()
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
host.matrix_world = Matrix.Translation((5.0, 7.0, 3.0)) @ Matrix.Diagonal((2.0, 1.5, 0.5, 1.0))
|
||||
tool.ClipBox.save_to_project_pset()
|
||||
|
||||
# Simulate a fresh-load state: clear scene list AND delete the
|
||||
# Blender empty so load has to recreate it.
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
scene_props.clip_boxes.clear()
|
||||
bpy.data.objects.remove(host, do_unlink=True)
|
||||
|
||||
tool.ClipBox.load_from_project_pset()
|
||||
assert len(scene_props.clip_boxes) == 1
|
||||
rehydrated = scene_props.clip_boxes[0].obj
|
||||
assert rehydrated is not None
|
||||
for r in range(4):
|
||||
for c in range(4):
|
||||
expected = (Matrix.Translation((5.0, 7.0, 3.0)) @ Matrix.Diagonal((2.0, 1.5, 0.5, 1.0)))[r][c]
|
||||
assert rehydrated.matrix_world[r][c] == pytest.approx(expected, abs=1e-6)
|
||||
|
||||
def test_load_from_pset_is_idempotent(self):
|
||||
bpy.ops.bim.create_project()
|
||||
bpy.ops.bim.add_clip_box()
|
||||
tool.ClipBox.load_from_project_pset()
|
||||
tool.ClipBox.load_from_project_pset()
|
||||
assert len(tool.ClipBox.get_scene_props().clip_boxes) == 1
|
||||
|
||||
def test_load_from_pset_does_not_touch_enabled(self):
|
||||
# ``enabled`` is intentionally not persisted to the pset — the
|
||||
# default is False (fresh .blend) and Blender's own .blend
|
||||
# session save carries the user's saved value through reload.
|
||||
# ``load_from_project_pset`` must not stomp either.
|
||||
bpy.ops.bim.create_project()
|
||||
bpy.ops.bim.add_clip_box()
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
scene_props.enabled = True
|
||||
tool.ClipBox.save_to_project_pset()
|
||||
|
||||
# Simulate the depsgraph IFC-reload branch: load runs without
|
||||
# touching enabled; the prior True value must survive.
|
||||
tool.ClipBox.load_from_project_pset()
|
||||
assert scene_props.enabled is True
|
||||
|
||||
# And the opposite: load when False must not flip it True.
|
||||
scene_props.enabled = False
|
||||
tool.ClipBox.load_from_project_pset()
|
||||
assert scene_props.enabled is False
|
||||
|
||||
def test_add_clip_box_creates_no_ifc_entity(self):
|
||||
# The clip box is project-pset persisted; there must be no
|
||||
# IfcRoot entity attached to the empty (which would lock its
|
||||
# scale and strip it on export).
|
||||
bpy.ops.bim.create_project()
|
||||
bpy.ops.bim.add_clip_box()
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
assert tool.Ifc.get_entity(host) is None
|
||||
|
||||
def test_show_caps_round_trips_via_pset(self):
|
||||
# show_caps is a scene-level toggle persisted in the project pset.
|
||||
# Per-mesh cap cost dominates the bisect, which doesn't scale with
|
||||
# clip-box extent, so the toggle applies file-wide.
|
||||
bpy.ops.bim.create_project()
|
||||
bpy.ops.bim.add_clip_box()
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
scene_props.show_caps = False
|
||||
tool.ClipBox.save_to_project_pset()
|
||||
|
||||
scene_props.clip_boxes.clear()
|
||||
scene_props.show_caps = True # default; load_from_project_pset must flip back to False
|
||||
bpy.data.objects.remove(host, do_unlink=True)
|
||||
|
||||
tool.ClipBox.load_from_project_pset()
|
||||
assert scene_props.show_caps is False
|
||||
|
||||
|
||||
class TestCapGeneration(NewFile):
|
||||
def test_cap_for_box_straddling_clip_plane_produces_triangles(self):
|
||||
# A 2x2x2 cube centred at the origin, clipped by a unit-radius clip
|
||||
# box also at the origin: the four faces of the cube that pierce
|
||||
# the +/- x box faces should yield cap polygons on the two faces.
|
||||
bpy.ops.bim.add_clip_box()
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
host.matrix_world = Matrix.Identity(4) # unit box at origin
|
||||
|
||||
bpy.ops.mesh.primitive_cube_add(size=4.0, location=(0.0, 0.0, 0.0))
|
||||
cube = bpy.context.active_object
|
||||
|
||||
world_planes = tool.Cad.obb_clip_planes_from_matrix(host.matrix_world)
|
||||
verts = tool.ClipBox._compute_caps_for_object(cube, world_planes)
|
||||
|
||||
# Every cap is at least one triangle (3 verts each), and we expect
|
||||
# 6 cap polygons (one per box face) → at minimum 18 verts.
|
||||
assert len(verts) >= 18
|
||||
assert len(verts) % 3 == 0
|
||||
|
||||
def test_cap_for_mesh_entirely_outside_box_produces_nothing(self):
|
||||
bpy.ops.bim.add_clip_box()
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
host.matrix_world = Matrix.Identity(4)
|
||||
|
||||
bpy.ops.mesh.primitive_cube_add(size=1.0, location=(10.0, 0.0, 0.0))
|
||||
cube = bpy.context.active_object
|
||||
|
||||
world_planes = tool.Cad.obb_clip_planes_from_matrix(host.matrix_world)
|
||||
verts = tool.ClipBox._compute_caps_for_object(cube, world_planes)
|
||||
assert verts == []
|
||||
|
||||
def test_cap_for_mesh_entirely_inside_box_produces_nothing(self):
|
||||
bpy.ops.bim.add_clip_box()
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
host.matrix_world = Matrix.Identity(4)
|
||||
|
||||
bpy.ops.mesh.primitive_cube_add(size=0.5, location=(0.0, 0.0, 0.0))
|
||||
cube = bpy.context.active_object
|
||||
|
||||
world_planes = tool.Cad.obb_clip_planes_from_matrix(host.matrix_world)
|
||||
verts = tool.ClipBox._compute_caps_for_object(cube, world_planes)
|
||||
assert verts == []
|
||||
|
||||
def test_non_watertight_mesh_does_not_crash(self):
|
||||
# The cap pipeline assumes watertight input; non-watertight
|
||||
# meshes (terrain, single-shell surfaces) may produce degenerate
|
||||
# caps but must not raise. The user is responsible for input
|
||||
# quality.
|
||||
bpy.ops.bim.add_clip_box()
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
host.matrix_world = Matrix.Identity(4)
|
||||
|
||||
bpy.ops.mesh.primitive_grid_add(size=4.0, location=(0.0, 0.0, 0.0))
|
||||
grid = bpy.context.active_object
|
||||
|
||||
world_planes = tool.Cad.obb_clip_planes_from_matrix(host.matrix_world)
|
||||
verts = tool.ClipBox._compute_caps_for_object(grid, world_planes)
|
||||
assert isinstance(verts, list)
|
||||
|
||||
def test_show_caps_defaults_on_and_toggles(self):
|
||||
bpy.ops.bim.add_clip_box()
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
assert scene_props.show_caps is True
|
||||
scene_props.show_caps = False
|
||||
assert scene_props.show_caps is False
|
||||
|
||||
|
||||
class TestCapEligibility(NewFile):
|
||||
def test_ifc_space_is_not_capped(self):
|
||||
# IfcSpace is an IfcProduct (spatial structure) but should never
|
||||
# cap — spaces are non-physical containers; capping them sprouts
|
||||
# solid fills where the room boundary crosses the clip plane.
|
||||
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
|
||||
bpy.ops.bim.create_project()
|
||||
bpy.ops.bim.add_clip_box()
|
||||
|
||||
bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0))
|
||||
space_obj = bpy.context.active_object
|
||||
tool.Root.get_root_props().ifc_product = "IfcSpatialElement"
|
||||
bpy.ops.bim.assign_class(ifc_class="IfcSpace")
|
||||
|
||||
capable = list(tool.ClipBox._iter_capable_objects(bpy.context.scene))
|
||||
assert space_obj not in capable
|
||||
|
||||
def test_ifc_wall_is_capped(self):
|
||||
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
|
||||
bpy.ops.bim.create_project()
|
||||
bpy.ops.bim.add_clip_box()
|
||||
|
||||
bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0))
|
||||
wall_obj = bpy.context.active_object
|
||||
tool.Root.get_root_props().ifc_product = "IfcElement"
|
||||
bpy.ops.bim.assign_class(ifc_class="IfcWall")
|
||||
|
||||
capable = list(tool.ClipBox._iter_capable_objects(bpy.context.scene))
|
||||
assert wall_obj in capable
|
||||
|
||||
def test_pure_blender_mesh_is_not_capped(self):
|
||||
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
|
||||
bpy.ops.bim.create_project()
|
||||
bpy.ops.bim.add_clip_box()
|
||||
|
||||
bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0))
|
||||
cube = bpy.context.active_object
|
||||
# No assign_class — pure Blender mesh, no IFC entity attached.
|
||||
capable = list(tool.ClipBox._iter_capable_objects(bpy.context.scene))
|
||||
assert cube not in capable
|
||||
|
||||
|
||||
class TestDuplicateClipBox(NewFile):
|
||||
def test_duplicates_active_when_no_index(self):
|
||||
bpy.ops.bim.add_clip_box()
|
||||
source = tool.ClipBox.get_active_clip_box()
|
||||
source.matrix_world = Matrix.Translation((3.0, 4.0, 5.0)) @ Matrix.Diagonal((2.0, 1.0, 1.0, 1.0))
|
||||
|
||||
bpy.ops.bim.duplicate_clip_box()
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
assert len(scene_props.clip_boxes) == 2
|
||||
copy = tool.ClipBox.get_active_clip_box()
|
||||
assert copy is not source
|
||||
for r in range(4):
|
||||
for c in range(4):
|
||||
assert copy.matrix_world[r][c] == pytest.approx(source.matrix_world[r][c])
|
||||
assert tool.ClipBox.get_object_props(copy).is_clip_box is True
|
||||
|
||||
def test_duplicates_specified_index(self):
|
||||
bpy.ops.bim.add_clip_box()
|
||||
first = tool.ClipBox.get_active_clip_box()
|
||||
bpy.ops.bim.add_clip_box()
|
||||
# Active is now index 1; duplicate index 0 explicitly.
|
||||
bpy.ops.bim.duplicate_clip_box(index=0)
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
assert len(scene_props.clip_boxes) == 3
|
||||
copy = tool.ClipBox.get_active_clip_box()
|
||||
for r in range(4):
|
||||
for c in range(4):
|
||||
assert copy.matrix_world[r][c] == pytest.approx(first.matrix_world[r][c])
|
||||
|
||||
def test_no_active_box_cancels(self):
|
||||
result = bpy.ops.bim.duplicate_clip_box()
|
||||
assert result == {"CANCELLED"}
|
||||
|
||||
def test_out_of_range_index_cancels(self):
|
||||
bpy.ops.bim.add_clip_box()
|
||||
result = bpy.ops.bim.duplicate_clip_box(index=99)
|
||||
assert result == {"CANCELLED"}
|
||||
|
||||
def test_duplicate_arms_clipping(self):
|
||||
bpy.ops.bim.add_clip_box() # arms
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
scene_props.enabled = False # user disables
|
||||
bpy.ops.bim.duplicate_clip_box() # re-arms
|
||||
assert scene_props.enabled is True
|
||||
|
||||
|
||||
class TestCollectionSync(NewFile):
|
||||
def test_sync_adopts_orphan_clip_box_empty(self):
|
||||
# Simulates Bonsai's Shift+D duplicate: an empty with is_clip_box=True
|
||||
# exists in the BBIM_ClipBoxes collection but no scene-list entry
|
||||
# points at it. The sync must adopt it as a first-class clip box.
|
||||
bpy.ops.bim.add_clip_box()
|
||||
source = tool.ClipBox.get_active_clip_box()
|
||||
|
||||
orphan = bpy.data.objects.new(source.name, None)
|
||||
orphan.empty_display_type = "CUBE"
|
||||
orphan.empty_display_size = 1.0
|
||||
orphan.matrix_world = source.matrix_world.copy()
|
||||
tool.ClipBox.get_object_props(orphan).is_clip_box = True
|
||||
collection = bpy.data.collections.get("BBIM_ClipBoxes")
|
||||
collection.objects.link(orphan)
|
||||
|
||||
tool.ClipBox._sync_collection_to_list(bpy.context.scene)
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
assert len(scene_props.clip_boxes) == 2
|
||||
assert scene_props.clip_boxes[-1].obj is orphan
|
||||
assert scene_props.active_clip_box_index == 1
|
||||
|
||||
def test_sync_skips_unflagged_empties(self):
|
||||
bpy.ops.bim.add_clip_box()
|
||||
collection = bpy.data.collections.get("BBIM_ClipBoxes")
|
||||
decoy = bpy.data.objects.new("Decoy", None)
|
||||
collection.objects.link(decoy)
|
||||
|
||||
tool.ClipBox._sync_collection_to_list(bpy.context.scene)
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
assert len(scene_props.clip_boxes) == 1
|
||||
|
||||
|
||||
class TestEnabledIsSceneLevel(NewFile):
|
||||
def test_default_is_false(self):
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
assert scene_props.enabled is False
|
||||
|
||||
def test_add_arms_clipping(self):
|
||||
# Adding any clip box flips enabled True so the user
|
||||
# immediately sees the cut and discovers the panel toggle
|
||||
# by association.
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
assert scene_props.enabled is False
|
||||
bpy.ops.bim.add_clip_box()
|
||||
assert scene_props.enabled is True
|
||||
|
||||
def test_subsequent_adds_re_arm_after_user_disables(self):
|
||||
bpy.ops.bim.add_clip_box() # arms
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
scene_props.enabled = False # user disables
|
||||
bpy.ops.bim.add_clip_box() # second add re-arms
|
||||
assert scene_props.enabled is True
|
||||
|
||||
def test_selecting_clip_box_does_not_arm(self):
|
||||
# Per design, selecting a clip box empty must NOT toggle the
|
||||
# scene-level enabled — activation is panel-only. Otherwise a
|
||||
# casual click in the outliner would silently hide geometry
|
||||
# with no obvious unarm path for a user who hasn't found the
|
||||
# panel yet.
|
||||
bpy.ops.bim.add_clip_box()
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
scene_props.enabled = False # disable after first-add auto-arm
|
||||
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
bpy.context.view_layer.objects.active = host
|
||||
tool.ClipBox.on_depsgraph_update(bpy.context.scene, None)
|
||||
assert scene_props.enabled is False
|
||||
|
||||
def test_toggle_operator_flips_scene_enabled(self):
|
||||
bpy.ops.bim.add_clip_box() # first-add arms it
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
assert scene_props.enabled is True
|
||||
bpy.ops.bim.toggle_clip_box_enabled()
|
||||
assert scene_props.enabled is False
|
||||
bpy.ops.bim.toggle_clip_box_enabled()
|
||||
assert scene_props.enabled is True
|
||||
|
||||
|
||||
class TestSpawnScale(NewFile):
|
||||
def test_default_scale_is_ten(self):
|
||||
# Default spawn scale is 10 (=20m cube) to cover a typical
|
||||
# storey, not the meaningless 1m unit cube.
|
||||
bpy.ops.bim.add_clip_box()
|
||||
host = tool.ClipBox.get_active_clip_box()
|
||||
assert tuple(host.scale) == pytest.approx((10.0, 10.0, 10.0))
|
||||
|
||||
|
||||
class TestCapRebuildDebounce(NewFile):
|
||||
"""The depsgraph handler debounces cap rebuilds so a burst of
|
||||
updates (e.g. an external-addon gizmo drag) collapses to one
|
||||
rebuild ~250 ms after the storm subsides. Bonsai's own transform
|
||||
modals get a fast path: an immediate rebuild on the True→False
|
||||
transition of ``is_transform_modal_active``.
|
||||
"""
|
||||
|
||||
def setup_method(self):
|
||||
bpy.ops.bim.add_clip_box()
|
||||
tool.ClipBox._cancel_pending_cap_rebuild()
|
||||
tool.ClipBox._last_modal_state = False
|
||||
tool.ClipBox._last_seen_object_matrices.clear()
|
||||
|
||||
def teardown_method(self):
|
||||
tool.ClipBox._cancel_pending_cap_rebuild()
|
||||
tool.ClipBox._last_modal_state = False
|
||||
tool.ClipBox._last_seen_object_matrices.clear()
|
||||
|
||||
def test_modal_end_triggers_immediate_rebuild(self):
|
||||
# Prime "previous tick had a modal active" then run a tick with
|
||||
# no modal → fast path fires rebuild_cap_cache synchronously,
|
||||
# bypassing the timer. Targets _handle_cap_tick directly to
|
||||
# bypass the screen guard that aborts in headless test runs.
|
||||
tool.ClipBox._last_modal_state = True
|
||||
with (
|
||||
patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
|
||||
patch.object(tool.ClipBox, "rebuild_cap_cache") as mock_rebuild,
|
||||
patch.object(tool.ClipBox, "_schedule_cap_rebuild") as mock_schedule,
|
||||
):
|
||||
tool.ClipBox._handle_cap_tick(bpy.context.scene, None)
|
||||
mock_rebuild.assert_called_once()
|
||||
mock_schedule.assert_not_called()
|
||||
|
||||
def test_burst_collapses_to_one_pending_timer(self):
|
||||
# 5 ticks with no modal active → schedule called 5 times; each
|
||||
# call cancels the previous pending timer and registers a fresh
|
||||
# one, so exactly one timer is pending at the end.
|
||||
with (
|
||||
patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
|
||||
patch.object(tool.ClipBox, "rebuild_cap_cache"),
|
||||
):
|
||||
for _ in range(5):
|
||||
tool.ClipBox._handle_cap_tick(bpy.context.scene, None)
|
||||
pending = tool.ClipBox._pending_cap_rebuild
|
||||
assert pending is not None
|
||||
assert bpy.app.timers.is_registered(pending)
|
||||
tool.ClipBox._cancel_pending_cap_rebuild()
|
||||
|
||||
def test_pending_rebuild_hides_caps(self):
|
||||
# While a debounce is in flight, on_post_view_caps must not
|
||||
# draw — the cached batches reflect an earlier frame and would
|
||||
# look stale against the geometry being mutated.
|
||||
scene_props = tool.ClipBox.get_scene_props()
|
||||
scene_props.enabled = True
|
||||
scene_props.show_caps = True
|
||||
|
||||
with (
|
||||
patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
|
||||
patch.object(tool.ClipBox, "rebuild_cap_cache"),
|
||||
):
|
||||
tool.ClipBox._handle_cap_tick(bpy.context.scene, None)
|
||||
assert tool.ClipBox._pending_cap_rebuild is not None
|
||||
|
||||
# Populate cap_cache to non-empty so the first-line gate
|
||||
# "if not cls._cap_cache: return" doesn't fire — the contract
|
||||
# we're pinning is the pending-rebuild gate specifically.
|
||||
tool.ClipBox._cap_cache["sentinel"] = ((), None)
|
||||
try:
|
||||
# If pending-rebuild gate works, on_post_view_caps exits
|
||||
# before importing gpu / building a shader. Patch
|
||||
# gpu.shader.from_builtin to fail loudly if drawing happens.
|
||||
with (
|
||||
patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
|
||||
patch("gpu.shader.from_builtin", side_effect=AssertionError("should be hidden")),
|
||||
):
|
||||
tool.ClipBox.on_post_view_caps()
|
||||
finally:
|
||||
tool.ClipBox._cap_cache.pop("sentinel", None)
|
||||
tool.ClipBox._cancel_pending_cap_rebuild()
|
||||
|
||||
def test_cancel_pending_drops_timer(self):
|
||||
with patch.object(tool.Blender, "is_transform_modal_active", return_value=False):
|
||||
tool.ClipBox._schedule_cap_rebuild()
|
||||
pending = tool.ClipBox._pending_cap_rebuild
|
||||
assert pending is not None and bpy.app.timers.is_registered(pending)
|
||||
tool.ClipBox._cancel_pending_cap_rebuild()
|
||||
assert tool.ClipBox._pending_cap_rebuild is None
|
||||
assert not bpy.app.timers.is_registered(pending)
|
||||
|
||||
def test_unregister_handler_cancels_pending(self):
|
||||
# The module's unregister() must drop any pending rebuild so a
|
||||
# timer can't fire against a freed addon. Exercise the helper
|
||||
# directly — full addon unregister would tear down too much for
|
||||
# a unit test.
|
||||
with patch.object(tool.Blender, "is_transform_modal_active", return_value=False):
|
||||
tool.ClipBox._schedule_cap_rebuild()
|
||||
assert tool.ClipBox._pending_cap_rebuild is not None
|
||||
tool.ClipBox._cancel_pending_cap_rebuild()
|
||||
assert tool.ClipBox._pending_cap_rebuild is None
|
||||
|
||||
def test_selection_only_tick_does_not_schedule(self):
|
||||
# Selecting an Object raises is_updated_transform=True on the
|
||||
# Object itself even though no actual matrix delta occurred
|
||||
# (Blender quirk). The matrix-hash baseline must filter that
|
||||
# out so the cache and hide-while-pending gate don't flash on
|
||||
# every click. Also covers the Scene/ViewLayer noise.
|
||||
bpy.ops.mesh.primitive_cube_add()
|
||||
cube = bpy.context.active_object
|
||||
|
||||
# Prime the baseline so cube's current matrix hash is "seen".
|
||||
tool.ClipBox._last_seen_object_matrices[cube.name] = tool.Blender.hash_matrix(cube.matrix_world)
|
||||
|
||||
class _SceneUpdate:
|
||||
id = bpy.context.scene
|
||||
is_updated_geometry = False
|
||||
is_updated_transform = True
|
||||
|
||||
class _CubeSelectionUpdate:
|
||||
id = cube
|
||||
is_updated_geometry = False
|
||||
is_updated_transform = True # quirk: matrix unchanged
|
||||
|
||||
class _FakeDepsgraph:
|
||||
updates = (_SceneUpdate(), _CubeSelectionUpdate())
|
||||
|
||||
with (
|
||||
patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
|
||||
patch.object(tool.ClipBox, "_schedule_cap_rebuild") as mock_schedule,
|
||||
):
|
||||
tool.ClipBox._handle_cap_tick(bpy.context.scene, _FakeDepsgraph())
|
||||
mock_schedule.assert_not_called()
|
||||
assert tool.ClipBox._pending_cap_rebuild is None
|
||||
|
||||
def test_real_transform_tick_does_schedule(self):
|
||||
bpy.ops.mesh.primitive_cube_add()
|
||||
cube = bpy.context.active_object
|
||||
# Baseline hash, then mutate the matrix so the filter sees a
|
||||
# true delta on the next tick.
|
||||
tool.ClipBox._last_seen_object_matrices[cube.name] = tool.Blender.hash_matrix(cube.matrix_world)
|
||||
cube.matrix_world = cube.matrix_world @ Matrix.Translation((1.0, 0, 0))
|
||||
|
||||
class _TransformUpdate:
|
||||
id = cube
|
||||
is_updated_geometry = False
|
||||
is_updated_transform = True
|
||||
|
||||
class _FakeDepsgraph:
|
||||
updates = (_TransformUpdate(),)
|
||||
|
||||
with (
|
||||
patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
|
||||
patch.object(tool.ClipBox, "_schedule_cap_rebuild") as mock_schedule,
|
||||
):
|
||||
tool.ClipBox._handle_cap_tick(bpy.context.scene, _FakeDepsgraph())
|
||||
mock_schedule.assert_called_once()
|
||||
|
||||
def test_geometry_update_tick_does_schedule(self):
|
||||
bpy.ops.mesh.primitive_cube_add()
|
||||
cube = bpy.context.active_object
|
||||
|
||||
class _GeometryUpdate:
|
||||
id = cube
|
||||
is_updated_geometry = True
|
||||
is_updated_transform = False
|
||||
|
||||
class _FakeDepsgraph:
|
||||
updates = (_GeometryUpdate(),)
|
||||
|
||||
with (
|
||||
patch.object(tool.Blender, "is_transform_modal_active", return_value=False),
|
||||
patch.object(tool.ClipBox, "_schedule_cap_rebuild") as mock_schedule,
|
||||
):
|
||||
tool.ClipBox._handle_cap_tick(bpy.context.scene, _FakeDepsgraph())
|
||||
mock_schedule.assert_called_once()
|
||||
|
||||
def test_edit_mode_skips_scheduling(self):
|
||||
# In any EDIT_* mode the depsgraph fires per vert/edge nudge;
|
||||
# the cap view isn't the focus and would flash off on every
|
||||
# tick. The entry-point gate must short-circuit before the
|
||||
# debounce scheduler runs.
|
||||
with (
|
||||
patch.object(tool.Blender, "is_in_edit_mode", return_value=True),
|
||||
patch.object(tool.ClipBox, "_handle_cap_tick") as mock_handle,
|
||||
):
|
||||
tool.ClipBox.on_depsgraph_update_caps(bpy.context.scene, None)
|
||||
mock_handle.assert_not_called()
|
||||
Reference in New Issue
Block a user