Fix clip-box edit-mode picker + rotation margin

Edit-mode click-select rejected verts inside the clip volume
because clip_bb stayed at the first-arm view; the depsgraph and
PRE_VIEW handlers updated clip_planes but skipped the
view3d.clip_border call that refreshes clip_bb. Schedule a full
re-arm at transform-commit, IFC reload, and view drift.

The empty's wireframe was clipped by its own planes when rotated
at non-trivial scale because the 1e-6 absolute margin can't
absorb float-precision drift that scales with the box's world
half-extent. Add a 1e-5 relative expand.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-18 17:35:56 +02:00
parent 260a387069
commit 104eeaf0cd
2 changed files with 151 additions and 10 deletions
+43 -10
View File
@@ -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
@@ -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