mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
Hide parametric gizmos during transform modal
Parametric gizmos (wall/door/window/stair/roof/array/MEP) recompute matrix_basis every frame from obj.matrix_world. While Blender's transform modal (G/R/S and the Bonsai macro overrides) drags the matrix, the gizmos slide off-cursor and fight the transform overlay. Detect via context.window.modal_operators (Blender 4.2+) — the collection of running modal operators. Gate poll() (forward-compat) and draw_prepare() (production path: gizmo.hide=True preserves the GizmoGroup across the drag instead of destroying it). Cover the Bonsai macro override for G key (and Shift/Alt/Ctrl+Shift+D) by matching the BIM_OT_* macro idnames that surface in modal_operators. Forward-compat test walks every parametric-edit module for GizmoGroup subclasses and asserts poll returns False with the detector mocked, so new gizmo groups inherit the hide automatically. Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -159,6 +159,51 @@ _SPECIAL = {"=", " "} # Formula prefix, spaces
|
||||
NUMERIC_INPUT_CHARS = _DIGITS | _OPERATORS | _METRIC_UNITS | _IMPERIAL_UNITS | _SPECIAL
|
||||
|
||||
|
||||
_BONSAI_TRANSFORM_MACROS = frozenset(
|
||||
{
|
||||
# Bonsai overrides Blender's default move/duplicate keymaps with
|
||||
# macros that wrap TRANSFORM_OT_translate. While a macro is the outer
|
||||
# modal entry, the inner TRANSFORM_OT_translate does not surface in
|
||||
# window.modal_operators — the macro's own idname does. The
|
||||
# ``BIM_OT_`` prefix is what Blender returns from ``bl_idname`` at
|
||||
# runtime (the class declaration uses the dotted ``bim.`` form).
|
||||
"BIM_OT_override_move_macro", # G key
|
||||
"BIM_OT_override_object_duplicate_move_macro", # Shift+D
|
||||
"BIM_OT_override_object_duplicate_move_linked_macro", # Alt+D
|
||||
"BIM_OT_object_duplicate_move_linked_aggregate_macro",# Ctrl+Shift+D
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_transform_modal_active(context) -> bool:
|
||||
"""True iff a Blender transform modal (G/R/S and siblings, including
|
||||
Bonsai's macro overrides) is currently driving per-frame ``matrix_world``
|
||||
updates. Reads ``window.modal_operators`` — the Blender 4.2+ collection of
|
||||
running modal operators. Parametric gizmo groups gate poll + draw_prepare
|
||||
on this so they hide for the duration of the drag instead of sliding
|
||||
off-cursor as the matrix updates each frame."""
|
||||
window = getattr(context, "window", None)
|
||||
if window is None:
|
||||
return False
|
||||
modal_ops = getattr(window, "modal_operators", None)
|
||||
if not modal_ops:
|
||||
return False
|
||||
for op in modal_ops:
|
||||
idname = op.bl_idname
|
||||
if idname.startswith("TRANSFORM_OT_") or idname in _BONSAI_TRANSFORM_MACROS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _hide_all_non_modal_gizmos(group) -> None:
|
||||
"""Set ``hide = True`` on every gizmo in ``group`` whose own ``is_modal``
|
||||
is False. Used by parametric ``draw_prepare`` to suppress visible
|
||||
re-positioning while a transform modal is dragging ``matrix_world``."""
|
||||
for gz in group.gizmos:
|
||||
if not getattr(gz, "is_modal", False):
|
||||
gz.hide = True
|
||||
|
||||
|
||||
class GizmoColor(Enum):
|
||||
"""Color identifiers for dimension gizmos.
|
||||
|
||||
@@ -4998,6 +5043,9 @@ class BillboardingGizmoGroupMixin:
|
||||
self.position_gizmos(context)
|
||||
|
||||
def draw_prepare(self, context: bpy.types.Context) -> None:
|
||||
if _is_transform_modal_active(context):
|
||||
_hide_all_non_modal_gizmos(self)
|
||||
return
|
||||
self.position_gizmos(context)
|
||||
|
||||
def setup_icon_gizmo(
|
||||
@@ -5652,6 +5700,8 @@ class BaseParametricGizmoGroup:
|
||||
|
||||
if preview_base.any_preview_active(context):
|
||||
return False
|
||||
if _is_transform_modal_active(context):
|
||||
return False
|
||||
if cls.gizmo_pref_name:
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
if not getattr(prefs.gizmos, cls.gizmo_pref_name, True):
|
||||
@@ -6416,6 +6466,9 @@ class BaseParametricGizmoGroup:
|
||||
"""
|
||||
if not self.is_setup_complete():
|
||||
return
|
||||
if _is_transform_modal_active(context):
|
||||
_hide_all_non_modal_gizmos(self)
|
||||
return
|
||||
obj = context.active_object
|
||||
if not obj:
|
||||
return
|
||||
@@ -6622,6 +6675,9 @@ class BaseSchematicGizmoGroup(BaseParametricGizmoGroup):
|
||||
def draw_prepare(self, context: bpy.types.Context) -> None:
|
||||
if not self.is_setup_complete():
|
||||
return
|
||||
if _is_transform_modal_active(context):
|
||||
_hide_all_non_modal_gizmos(self)
|
||||
return
|
||||
obj = context.active_object
|
||||
if not obj:
|
||||
return
|
||||
@@ -7028,6 +7084,8 @@ class BaseIconActionGroup(BillboardingGizmoGroupMixin):
|
||||
return False
|
||||
if not tool.Blender.are_viewport_gizmos_enabled():
|
||||
return False
|
||||
if _is_transform_modal_active(context):
|
||||
return False
|
||||
return cls.is_eligible_object(obj)
|
||||
|
||||
def setup(self, context: bpy.types.Context) -> None:
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""Behaviour contract: every parametric gizmo group hides while a Blender
|
||||
transform modal (G/R/S and siblings) is dragging ``matrix_world``.
|
||||
|
||||
Discovery walks each parametric-edit module rather than naming gizmo groups —
|
||||
adding a new group automatically joins the test. The test exercises the
|
||||
BEHAVIOUR (poll returns False / draw_prepare early-returns when a transform
|
||||
modal is active) without pinning the name of the helper used internally."""
|
||||
|
||||
import importlib
|
||||
import types
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
PARAMETRIC_MODULES = (
|
||||
"bonsai.bim.module.model.array",
|
||||
"bonsai.bim.module.model.door",
|
||||
"bonsai.bim.module.model.host_add_opening_gizmo",
|
||||
"bonsai.bim.module.model.roof",
|
||||
"bonsai.bim.module.model.stair",
|
||||
"bonsai.bim.module.model.wall",
|
||||
"bonsai.bim.module.model.window",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _require_real_bpy():
|
||||
if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
|
||||
pytest.skip("requires real Blender (bpy is mocked or absent)")
|
||||
|
||||
|
||||
def _discover_parametric_gizmo_groups():
|
||||
"""Walk each parametric-edit module for ``bpy.types.GizmoGroup`` subclasses
|
||||
defined locally. Preview-owning gizmo groups (bl_idname contains 'preview')
|
||||
are excluded from the poll-level test: their poll legitimately fires while
|
||||
the preview is active, and the transform-modal hide for them lives in
|
||||
``draw_prepare`` via ``BillboardingGizmoGroupMixin``."""
|
||||
out = []
|
||||
for mod_path in PARAMETRIC_MODULES:
|
||||
mod = importlib.import_module(mod_path)
|
||||
for name in dir(mod):
|
||||
obj = getattr(mod, name)
|
||||
if not isinstance(obj, type):
|
||||
continue
|
||||
if not issubclass(obj, bpy.types.GizmoGroup) or obj is bpy.types.GizmoGroup:
|
||||
continue
|
||||
if obj.__module__ != mod.__name__:
|
||||
continue
|
||||
bl_idname = (getattr(obj, "bl_idname", "") or "").lower()
|
||||
if "preview" in bl_idname:
|
||||
continue
|
||||
out.append((f"{mod_path.rsplit('.', 1)[-1]}.{name}", obj))
|
||||
return out
|
||||
|
||||
|
||||
class TestDiscoveryFindsParametricGizmoGroups:
|
||||
def test_at_least_one_group_per_canonical_module(self):
|
||||
"""If discovery returns zero groups for a module the walk has drifted —
|
||||
likely the gizmo group moved to a different file. Surface the drift
|
||||
with the module name in the diagnostic."""
|
||||
per_module: dict[str, int] = {}
|
||||
for fq_name, _cls in _discover_parametric_gizmo_groups():
|
||||
mod_short = fq_name.split(".", 1)[0]
|
||||
per_module[mod_short] = per_module.get(mod_short, 0) + 1
|
||||
empty = [m.rsplit(".", 1)[-1] for m in PARAMETRIC_MODULES if per_module.get(m.rsplit(".", 1)[-1], 0) == 0]
|
||||
assert not empty, (
|
||||
f"Parametric modules with zero GizmoGroup subclasses (discovery walk drifted?): {empty}. "
|
||||
"Update PARAMETRIC_MODULES or check whether the gizmo groups moved to a new file."
|
||||
)
|
||||
|
||||
|
||||
class TestParametricGizmoPollsHideDuringTransformModal:
|
||||
"""For each discovered parametric gizmo group, mock the transform-modal
|
||||
detector to True and call ``poll(bpy.context)``. Every poll must return
|
||||
False — any True is a poll that wouldn't hide during a G/R/S drag, leaving
|
||||
the gizmos jittering against the dragging matrix."""
|
||||
|
||||
def test_every_group_poll_returns_false_when_transform_modal_active(self):
|
||||
groups = _discover_parametric_gizmo_groups()
|
||||
offenders = []
|
||||
with patch(
|
||||
"bonsai.bim.module.drawing.gizmos._is_transform_modal_active",
|
||||
return_value=True,
|
||||
):
|
||||
for name, cls in groups:
|
||||
poll = getattr(cls, "poll", None)
|
||||
if poll is None:
|
||||
continue
|
||||
try:
|
||||
result = poll(bpy.context)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}"))
|
||||
continue
|
||||
if result:
|
||||
offenders.append((name, "poll returned True with transform modal active"))
|
||||
|
||||
assert not offenders, (
|
||||
"Parametric gizmo polls that don't gate on the transform-modal detector "
|
||||
"(or raise instead of returning False): "
|
||||
+ ", ".join(f"{n} — {why}" for n, why in offenders)
|
||||
+ ". Hide parametric gizmos while Blender's transform modal is dragging "
|
||||
"matrix_world so they don't jitter off-cursor. The conventional path is to "
|
||||
"early-return from poll when _is_transform_modal_active(context) is True."
|
||||
)
|
||||
|
||||
|
||||
class TestBaseParametricPollHidesDuringTransformModal:
|
||||
"""Cross-feature base poll: door / window / stair / roof / railing / array
|
||||
all inherit ``BaseParametricGizmoGroup``. Its poll must short-circuit on
|
||||
the transform-modal detector so every inheriting feature behaves uniformly."""
|
||||
|
||||
def test_base_parametric_poll_returns_false(self):
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
|
||||
with patch("bonsai.tool.Blender.get_active_object", return_value=object()):
|
||||
with patch("bonsai.tool.Blender.are_viewport_gizmos_enabled", return_value=True):
|
||||
with patch(
|
||||
"bonsai.bim.module.model.preview_base.any_preview_active",
|
||||
return_value=False,
|
||||
):
|
||||
with patch(
|
||||
"bonsai.bim.module.drawing.gizmos._is_transform_modal_active",
|
||||
return_value=True,
|
||||
):
|
||||
assert BaseParametricGizmoGroup.poll(bpy.context) is False
|
||||
|
||||
|
||||
class TestBaseIconActionPollHidesDuringTransformModal:
|
||||
"""``BaseIconActionGroup`` is the parent of the simple icon-row gizmo
|
||||
groups; its poll mirrors the base parametric gate for forward-compat
|
||||
symmetry. Pinning here ensures a future icon-row group authored via this
|
||||
base inherits the transform-modal hide for free."""
|
||||
|
||||
def test_base_icon_action_poll_returns_false(self):
|
||||
from bonsai.bim.module.drawing.gizmos import BaseIconActionGroup
|
||||
|
||||
with patch("bonsai.tool.Blender.get_active_object", return_value=object()):
|
||||
with patch("bonsai.tool.Blender.are_viewport_gizmos_enabled", return_value=True):
|
||||
with patch(
|
||||
"bonsai.bim.module.drawing.gizmos._is_transform_modal_active",
|
||||
return_value=True,
|
||||
):
|
||||
assert BaseIconActionGroup.poll(bpy.context) is False
|
||||
|
||||
|
||||
class TestHelperReadsWindowModalOperators:
|
||||
"""Pin the public contract of ``_is_transform_modal_active``: it reads
|
||||
``context.window.modal_operators`` (Blender 4.2+) and returns True iff any
|
||||
operator's ``bl_idname`` starts with ``TRANSFORM_OT_``. The check itself
|
||||
is dependency-free and worth pinning so a future refactor that swaps the
|
||||
detection mechanism either keeps the contract or updates the test."""
|
||||
|
||||
def test_returns_true_for_transform_translate(self):
|
||||
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
|
||||
|
||||
fake_op = MagicMock()
|
||||
fake_op.bl_idname = "TRANSFORM_OT_translate"
|
||||
fake_context = MagicMock()
|
||||
fake_context.window.modal_operators = [fake_op]
|
||||
assert _is_transform_modal_active(fake_context) is True
|
||||
|
||||
def test_returns_true_for_transform_rotate_and_resize(self):
|
||||
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
|
||||
|
||||
for idname in ("TRANSFORM_OT_rotate", "TRANSFORM_OT_resize", "TRANSFORM_OT_shear"):
|
||||
fake_op = MagicMock()
|
||||
fake_op.bl_idname = idname
|
||||
fake_context = MagicMock()
|
||||
fake_context.window.modal_operators = [fake_op]
|
||||
assert _is_transform_modal_active(fake_context) is True, f"missed {idname}"
|
||||
|
||||
def test_returns_false_for_non_transform_modal(self):
|
||||
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
|
||||
|
||||
fake_op = MagicMock()
|
||||
fake_op.bl_idname = "VIEW3D_OT_select_box"
|
||||
fake_context = MagicMock()
|
||||
fake_context.window.modal_operators = [fake_op]
|
||||
assert _is_transform_modal_active(fake_context) is False
|
||||
|
||||
def test_returns_true_for_bonsai_move_macro(self):
|
||||
"""Bonsai overrides the G key with a macro that wraps
|
||||
``TRANSFORM_OT_translate``. While the macro is the outer modal entry
|
||||
the inner transform does not surface in ``modal_operators``; matching
|
||||
the macro idname covers the gap. Note Blender exposes ``bl_idname``
|
||||
at runtime in the ``BIM_OT_<verb_noun>`` form, not the ``bim.<verb_noun>``
|
||||
form used in the class declaration — verified via real-Blender modal
|
||||
introspection during grab."""
|
||||
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
|
||||
|
||||
macros = (
|
||||
"BIM_OT_override_move_macro",
|
||||
"BIM_OT_override_object_duplicate_move_macro",
|
||||
"BIM_OT_override_object_duplicate_move_linked_macro",
|
||||
"BIM_OT_object_duplicate_move_linked_aggregate_macro",
|
||||
)
|
||||
for idname in macros:
|
||||
fake_op = MagicMock()
|
||||
fake_op.bl_idname = idname
|
||||
fake_context = MagicMock()
|
||||
fake_context.window.modal_operators = [fake_op]
|
||||
assert _is_transform_modal_active(fake_context) is True, f"missed {idname}"
|
||||
|
||||
def test_returns_false_for_empty_modal_stack(self):
|
||||
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
|
||||
|
||||
fake_context = MagicMock()
|
||||
fake_context.window.modal_operators = []
|
||||
assert _is_transform_modal_active(fake_context) is False
|
||||
|
||||
def test_returns_false_when_window_is_none(self):
|
||||
from bonsai.bim.module.drawing.gizmos import _is_transform_modal_active
|
||||
|
||||
fake_context = MagicMock()
|
||||
fake_context.window = None
|
||||
assert _is_transform_modal_active(fake_context) is False
|
||||
Reference in New Issue
Block a user