diff --git a/src/bonsai/bonsai/tool/clip_box.py b/src/bonsai/bonsai/tool/clip_box.py index 1b30328a1d..c477e32ad9 100644 --- a/src/bonsai/bonsai/tool/clip_box.py +++ b/src/bonsai/bonsai/tool/clip_box.py @@ -38,15 +38,16 @@ if TYPE_CHECKING: PlaneTuple = tuple[float, float, float, float] PlaneSet = tuple[PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple, PlaneTuple] -# Outward margin (world units) so the empty's CUBE display edges sit -# safely INSIDE the clip volume. Absolute (not relative-to-scale) -# because a relative multiplier balloons with scale and produces a -# visibly-wrong gap between the wireframe and the clipped geometry. -# Sub-mesh-precision value: visually invisible at any reasonable IFC -# scale yet large enough to keep the empty's own wireframe edges off -# the clip planes when float-precision accumulation pushes a corner -# a fractional epsilon outward. +# Outward margins so the empty's CUBE display edges sit safely INSIDE +# the clip volume. A fixed absolute margin fails under rotation: the +# float error in computing each column's length and in per-vertex dot +# products at GPU rasterisation scales with the axis's world +# half-extent, so once the box is spawned at any non-trivial scale it +# can exceed an absolute floor. The relative term tracks that drift; +# the absolute term catches sub-unit boxes where the relative term +# shrinks below float precision. _CLIP_EXPAND_ABS = 1e-6 +_CLIP_EXPAND_REL = 1e-5 class ClipBox: @@ -63,6 +64,11 @@ class ClipBox: _owned: set[int] = set() _region_by_key: dict[int, tuple[Any, Any]] = {} + # View matrix per region at last clip_border arm. PRE_VIEW only + # updates clip_planes; without a snapshot, the C-side clip_bb stays + # aligned to the prior view and the edit-mode picker rejects verts + # inside the current clip_planes after orbit/pan/zoom. + _view_matrix_at_arm: dict[int, tuple] = {} _refresh_pending: bool = False _last_seen_ifc_id: int = 0 # Tracks the last matrix we persisted to the pset, keyed by Blender @@ -152,7 +158,9 @@ class ClipBox: prevents the cube's own wireframe from being clipped by its own planes. """ - return tool.Cad.obb_clip_planes_from_matrix(obj.matrix_world, expand=_CLIP_EXPAND_ABS) + return tool.Cad.obb_clip_planes_from_matrix( + obj.matrix_world, expand=_CLIP_EXPAND_ABS, expand_rel=_CLIP_EXPAND_REL + ) @classmethod def compute_planes_from_matrix(cls, matrix: Any) -> PlaneSet: @@ -163,7 +171,7 @@ class ClipBox: transform offset, while ``obj.matrix_world`` stays at the pre-transform value until the operator commits on release. """ - return tool.Cad.obb_clip_planes_from_matrix(matrix, expand=_CLIP_EXPAND_ABS) + return tool.Cad.obb_clip_planes_from_matrix(matrix, expand=_CLIP_EXPAND_ABS, expand_rel=_CLIP_EXPAND_REL) @classmethod def apply_clip_planes(cls, planes: PlaneSet) -> None: @@ -183,6 +191,7 @@ class ClipBox: cls._owned.add(key) cls._region_by_key[key] = (area, region) cls._arm_region(area, region, region_3d, planes) + cls._view_matrix_at_arm[key] = tuple(tuple(row) for row in region_3d.view_matrix) @classmethod def _arm_region(cls, area: Any, region: Any, region_3d: Any, planes: PlaneSet) -> None: @@ -271,6 +280,7 @@ class ClipBox: """Drop the ownership table without touching any region. Used on register/reload.""" cls._owned.clear() cls._region_by_key.clear() + cls._view_matrix_at_arm.clear() PSET_NAME = "BBIM_ClipBoxes" COLLECTION_NAME = "BBIM_ClipBoxes" @@ -533,10 +543,16 @@ class ClipBox: cls._last_seen_ifc_id = ifc_id cls._owned.clear() cls._region_by_key.clear() + cls._view_matrix_at_arm.clear() cls._persisted_matrices.clear() cls._last_seen_object_matrices.clear() if ifc_file is not None: cls.load_from_project_pset(scene) + # .blend carries use_clip_planes / clip_bb forward; the + # C-side picker is armed for the prior session's view. + # Re-arm against the current view so click-select matches + # what the user sees. + cls.schedule_refresh() # Orphan-empty adoption is deferred while a transform modal is # dragging so the active-index change on adoption can't disrupt # the move. @@ -557,6 +573,12 @@ class ClipBox: # one save on release, not N saves per frame. if ifc_file is not None and prev_matrix is not None: cls.mark_dirty_for_save(obj.name) + # clip_bb is stale once the box settles elsewhere. The modal + # gate suppresses per-tick re-arms during a live drag and + # fires once on release (or on external sets — Python, undo, + # constraint). + if prev_matrix is not None and not tool.Blender.is_transform_modal_active(bpy.context): + cls.schedule_refresh() cls.flush_pending_saves(scene) try: @@ -594,6 +616,17 @@ class ClipBox: return region_3d.clip_planes = cls.compute_planes_from_matrix(matrix) region_3d.update() + # clip_bb captured by view3d.clip_border is view-aligned, so an + # orbit/pan/zoom leaves the picker testing against the old + # frustum even after clip_planes refresh. Re-arm so the picker + # matches the current view. + region = getattr(bpy.context, "region", None) + if region is not None: + key = region.as_pointer() + prev_view = cls._view_matrix_at_arm.get(key) + current_view = tuple(tuple(row) for row in region_3d.view_matrix) + if prev_view is not None and prev_view != current_view: + cls.schedule_refresh() # ------------------------------------------------------------------ # Cross-section caps diff --git a/src/bonsai/test/bim/module/clip_box/test_clip_box.py b/src/bonsai/test/bim/module/clip_box/test_clip_box.py index 92a51e6390..2bcef8ec8c 100644 --- a/src/bonsai/test/bim/module/clip_box/test_clip_box.py +++ b/src/bonsai/test/bim/module/clip_box/test_clip_box.py @@ -114,6 +114,33 @@ class TestComputePlanes(NewFile): planes = tool.ClipBox.compute_planes(host) assert tool.Cad.point_is_inside_clip_planes(planes, Vector((5, 7, 0))) + def test_margin_grows_with_scale(self): + # The empty's CUBE display lives at local +-1; the GPU dot + # product that tests each wireframe vertex against the clip + # planes has float error that scales with the axis's world + # half-extent. A fixed absolute margin gets eaten by that drift + # once the box is spawned at non-trivial scale, so the half- + # extent the planes encode must include a relative term — the + # margin between the wireframe edge and the plane must grow + # with the scale. + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + + host.matrix_world = Matrix.Identity(4) + planes_unit = tool.ClipBox.compute_planes(host) + # +X plane: normal (-1, 0, 0), d = half_x. Read half from d. + half_unit = planes_unit[0][3] + margin_unit = half_unit - 1.0 + + host.matrix_world = Matrix.Diagonal((100.0, 100.0, 100.0, 1.0)) + planes_scaled = tool.ClipBox.compute_planes(host) + half_scaled = planes_scaled[0][3] + margin_scaled = half_scaled - 100.0 + + assert margin_scaled > margin_unit * 10, ( + f"margin must scale with extent: unit={margin_unit:g}, " f"scale-100={margin_scaled:g}" + ) + class TestToggleEnabled(NewFile): def test_flips_scene_enabled_flag(self): @@ -690,3 +717,84 @@ class TestCapRebuildDebounce(NewFile): ): tool.ClipBox.on_depsgraph_update_caps(bpy.context.scene, None) mock_handle.assert_not_called() + + +class TestClipBbReArmTriggers(NewFile): + """The C-side clip_bb captured by view3d.clip_border for edit-mode + click-select is view-aligned and tied to the box pose at arm time, + so it goes stale on either a clip-box transform commit, an external + matrix mutation, or an IFC reload that rehydrates from pset. The + depsgraph handler schedules a full re-arm at those events; the + modal gate suppresses per-tick re-arms during a live drag. + """ + + def setup_method(self): + tool.ClipBox._persisted_matrices.clear() + tool.ClipBox._last_seen_ifc_id = 0 + tool.ClipBox._refresh_pending = False + + def teardown_method(self): + tool.ClipBox._persisted_matrices.clear() + tool.ClipBox._last_seen_ifc_id = 0 + tool.ClipBox._refresh_pending = False + + def test_matrix_change_outside_modal_re_arms(self): + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + # Seed a stale baseline so prev_matrix != current_matrix. + stale = tuple(tuple(row) for row in Matrix.Translation((-99.0, 0.0, 0.0))) + tool.ClipBox._persisted_matrices[host.name] = stale + + with ( + patch.object(tool.Blender, "is_transform_modal_active", return_value=False), + patch.object(tool.ClipBox, "schedule_refresh") as mock_refresh, + ): + tool.ClipBox.on_depsgraph_update(bpy.context.scene, bpy.context.evaluated_depsgraph_get()) + mock_refresh.assert_called_once() + + def test_matrix_change_during_modal_skips_re_arm(self): + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + stale = tuple(tuple(row) for row in Matrix.Translation((-99.0, 0.0, 0.0))) + tool.ClipBox._persisted_matrices[host.name] = stale + + with ( + patch.object(tool.Blender, "is_transform_modal_active", return_value=True), + patch.object(tool.ClipBox, "schedule_refresh") as mock_refresh, + ): + tool.ClipBox.on_depsgraph_update(bpy.context.scene, bpy.context.evaluated_depsgraph_get()) + mock_refresh.assert_not_called() + + def test_first_matrix_sighting_does_not_re_arm(self): + # No prior persisted-matrix entry: the branch records the + # baseline and exits without re-arming. The add path already + # armed once; a per-tick re-arm on first sight would double-arm. + bpy.ops.bim.add_clip_box() + host = tool.ClipBox.get_active_clip_box() + tool.ClipBox._persisted_matrices.pop(host.name, None) + + with ( + patch.object(tool.Blender, "is_transform_modal_active", return_value=False), + patch.object(tool.ClipBox, "schedule_refresh") as mock_refresh, + ): + tool.ClipBox.on_depsgraph_update(bpy.context.scene, bpy.context.evaluated_depsgraph_get()) + mock_refresh.assert_not_called() + + def test_ifc_reload_re_arms(self): + bpy.ops.bim.create_project() + bpy.ops.bim.add_clip_box() + # Force an ifc-id mismatch so the rehydrate-from-pset branch + # fires. The .blend carries the prior session's clip_bb forward; + # the picker is armed for the OLD view until this re-arms. + tool.ClipBox._last_seen_ifc_id = 0 + + with ( + patch.object(tool.Blender, "is_transform_modal_active", return_value=False), + patch.object(tool.ClipBox, "schedule_refresh") as mock_refresh, + ): + tool.ClipBox.on_depsgraph_update(bpy.context.scene, bpy.context.evaluated_depsgraph_get()) + # IFC-load triggers a re-arm. (Reading the show_caps pset entry + # writes scene_props.show_caps via its update callback, which + # is a separate pre-existing re-arm path; the test pins the + # invariant "ifc-load arms at least once".) + assert mock_refresh.call_count >= 1