Extract transform-modal gate + viewport helpers to tool.Blender

The transform-modal active check (Bonsai keymap macros + Blender's
TRANSFORM_OT_* family) was a module-local helper in drawing/gizmos.py
used by per-gizmo poll callbacks. It needs to be shared with other
features that gate per-frame side effects on whether a drag is in
progress (clip box plane re-arming, future modal-aware decorators).

Move BONSAI_TRANSFORM_MACROS and the gate into tool.Blender as
is_transform_modal_active classmethod; widen its window scan to all
WM windows for callers without a window-bound context (depsgraph
callbacks). Leave a thin module-local alias in drawing/gizmos.py so
AST scans and existing call sites stay decoupled from the helper's
home module.

Also add generic Blender helpers needed by the clip-box feature
(reusable by any future feature):

- iter_view3d_regions: yield (area, region, region_3d) for every
  WINDOW region in every 3D viewport — for clip-plane / draw-handler
  fanout.
- get_or_create_collection: idempotent named-collection lookup +
  link to a scene.
- is_in_edit_mode: True iff the active object is in any EDIT_*
  mode — for features that need to suspend per-tick work during
  vert/edge/face manipulation.
- serialize_matrix / deserialize_matrix / hash_matrix: round-trip a
  4x4 matrix as a 16-float CSV string for IFC pset persistence + a
  matching hash for cache keys.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-16 13:23:46 +02:00
parent 074021de70
commit a56b5660d0
2 changed files with 129 additions and 34 deletions
+6 -33
View File
@@ -159,40 +159,13 @@ _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
"""Module-local alias for ``tool.Blender.is_transform_modal_active``.
Preserved as a name so AST scans and call sites in this file stay
decoupled from the helper's home module.
"""
return tool.Blender.is_transform_modal_active(context)
def _hide_all_non_modal_gizmos(group) -> None:
+123 -1
View File
@@ -30,7 +30,15 @@ import sys
import tempfile
import traceback
import types
from collections.abc import Callable, Generator, Iterable, Mapping, Sequence, Sized
from collections.abc import (
Callable,
Generator,
Iterable,
Iterator,
Mapping,
Sequence,
Sized,
)
from datetime import datetime
from functools import cache, lru_cache
from pathlib import Path
@@ -575,6 +583,120 @@ class Blender(bonsai.core.tool.Blender):
else:
decorator_cls.uninstall()
# 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).
BONSAI_TRANSFORM_MACROS: frozenset[str] = frozenset(
{
"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
}
)
@classmethod
def is_transform_modal_active(cls, context: bpy.types.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. Callers gate
per-frame side effects (gizmo positioning, IFC persistence, etc.)
on this so they don't fire during the drag.
Falls back to scanning every window in the window manager when
``context.window`` is ``None`` — depsgraph callbacks run with a
limited context where ``context.window`` is typically missing,
but the modal is still active on one of the WM's windows.
"""
window = getattr(context, "window", None)
if window is not None and getattr(window, "modal_operators", None):
windows = [window]
else:
wm = getattr(context, "window_manager", None) or bpy.context.window_manager
if wm is None:
return False
windows = list(wm.windows)
for w in windows:
modal_ops = getattr(w, "modal_operators", None)
if not modal_ops:
continue
for op in modal_ops:
idname = op.bl_idname
if idname.startswith("TRANSFORM_OT_") or idname in cls.BONSAI_TRANSFORM_MACROS:
return True
return False
@classmethod
def is_in_edit_mode(cls, context: Optional[bpy.types.Context] = None) -> bool:
"""True iff the active object is in any edit-style mode.
Catches every ``EDIT_*`` variant (mesh, curve, armature,
metaball, lattice, surface, text, grease pencil). Defaults to
``OBJECT`` when the mode attribute is missing so background-mode
callers (no UI context) don't false-positive.
"""
ctx = context if context is not None else bpy.context
mode = getattr(ctx, "mode", "OBJECT")
return mode.startswith("EDIT_")
@classmethod
def iter_view3d_regions(cls) -> Iterator[tuple[bpy.types.Area, bpy.types.Region, bpy.types.RegionView3D]]:
"""Yield ``(area, region, region_3d)`` for every WINDOW region in every 3D viewport.
Useful for features that need to act on every visible 3D viewport
(clip planes, draw handlers, region redraw fanout). Empty
generator when ``bpy.context.screen`` is unavailable (shutdown,
background mode without a screen).
"""
screen = getattr(getattr(bpy, "context", None), "screen", None)
if screen is None:
return
for area in screen.areas:
if area.type != "VIEW_3D":
continue
for region in area.regions:
if region.type != "WINDOW":
continue
region_3d = getattr(region, "data", None)
if region_3d is None:
continue
yield area, region, region_3d
@classmethod
def get_or_create_collection(cls, scene: bpy.types.Scene, name: str) -> bpy.types.Collection:
"""Return the named collection, creating + linking it to ``scene`` if absent."""
collection = bpy.data.collections.get(name)
if collection is None:
collection = bpy.data.collections.new(name)
scene.collection.children.link(collection)
return collection
@classmethod
def serialize_matrix(cls, matrix: Matrix) -> str:
"""Serialize a 4x4 matrix as a 16-float comma-separated string.
Round-trip pair with :meth:`deserialize_matrix`. Used for storing
a matrix in an IFC pset string property without losing precision
(``%.9g`` carries ~9 significant digits, enough for ``float32``
round-trip).
"""
return ",".join(f"{matrix[r][c]:.9g}" for r in range(4) for c in range(4))
@classmethod
def deserialize_matrix(cls, text: str) -> Matrix:
"""Inverse of :meth:`serialize_matrix`."""
floats = [float(v) for v in text.split(",")]
return Matrix([tuple(floats[r * 4 : r * 4 + 4]) for r in range(4)])
@classmethod
def hash_matrix(cls, matrix: Matrix) -> int:
"""Hash a 4x4 matrix by its 16 floats. Useful as a cache key."""
return hash(tuple(matrix[r][c] for r in range(4) for c in range(4)))
@classmethod
def is_view_top_down(cls, context: bpy.types.Context, threshold: float = 0.9659) -> bool:
"""True when the viewport camera is looking ~straight down (or up) the world Z axis.