diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index 75e2b4a46b..15961c3c4c 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -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: diff --git a/src/bonsai/test/bim/module/model/test_transform_modal_gate.py b/src/bonsai/test/bim/module/model/test_transform_modal_gate.py new file mode 100644 index 0000000000..de4723b226 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_transform_modal_gate.py @@ -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 . +# +# 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_`` form, not the ``bim.`` + 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