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:
Gorgious56
2026-06-08 13:33:08 +02:00
parent db7591a867
commit 0bf8e9283f
2 changed files with 297 additions and 0 deletions
@@ -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