Fix demo preset crash + scope header refresh

bpy.ops.bim.new_project(preset='demo') crashed in
refresh_bim_tool_headers: the post-commit hook fired for every
nested bpy.ops.bim.append_library_element during template
loading, and the operator context Blender hands to
programmatically-invoked nested operators is stripped of the
view-layer attributes the refresh reads.

Two changes resolve it.

Gate the header refresh in tool.Parametric.refresh_post_commit
on operator.bl_idname being one of the EDIT_TYPES finish_op
idnames. Only validate-gizmo commits (bim.finish_editing_<name>)
now trigger the refresh; demo-loader and other non-edit
operators skip it. Querying the registry directly is the
canonical signal — string-prefix matching would silently drift
if ParametricObject.finish_op changes derivation.

Harden tool.Blender.get_active_object so its view_layer fallback
also uses getattr; the 150+ callers routed through it now
tolerate stripped contexts. _resolve_bim_tool_context applies
the same defensive pattern to mode / workspace.

Tests:
- test_handler_restricted_context covers get_active_object's
  defensive path and the BimTool-family whitelist (excludes
  annotation, spatial, structural).
- test_handler_forward_compat AST-pins that the gate consults
  EDIT_TYPES (not a string prefix).
- test_wall_header_refresh rewritten — three tests cover the
  gated-by-registry contract: counter bumps for every commit,
  finish_op operators refresh headers, others don't.

Hotkey-driven in-place edits (S_E / C_E) no longer trigger the
refresh — they were caught by the pre-refactor "every commit"
design. Left out of scope; the new skip-non-finish test pins
this as intentional.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-04 10:42:58 +02:00
committed by Thomas Krijnen
parent 6f036edf08
commit 54c00f0306
8 changed files with 230 additions and 88 deletions
+14 -9
View File
@@ -179,27 +179,32 @@ def update_bim_tool_props():
def refresh_bim_tool_headers(): def refresh_bim_tool_headers():
"""Commit-driven refresh of BIM Tool header values (extrusion_depth, """Push the active IFC entity's current header float values
length, x_angle) from the active object's IFC geometry. Must not (extrusion_depth, length, x_angle) into ``BIMModelProperties``.
write user-intent enums — those encode 'what to build next' and Enum-safe: never writes user-intent enum slots, which are owned by
would silently reset on every IFC commit.""" the selection callback."""
ctx = _resolve_bim_tool_context() ctx = _resolve_bim_tool_context()
if ctx is None: if ctx is None:
return return
obj, current_tool, element = ctx obj, current_tool, element = ctx
if current_tool.idname == "bim.annotation_tool": if current_tool.idname not in tool.Blender.get_property_header_tools():
return return
_read_headers_into_props(obj, element) _read_headers_into_props(obj, element)
def _resolve_bim_tool_context(): def _resolve_bim_tool_context():
"""Return ``(obj, current_tool, element)`` when an active BIM workspace """Return ``(obj, current_tool, element)`` when an active BIM workspace
tool sees a resolvable IFC element; ``None`` otherwise.""" tool sees a resolvable IFC element; ``None`` otherwise. Defensive
obj = bpy.context.active_object against stripped operator contexts — a missing ``active_object`` /
``mode`` / ``workspace`` short-circuits to ``None`` instead of raising."""
obj = tool.Blender.get_active_object()
if not obj: if not obj:
return None return None
mode = bpy.context.mode mode = getattr(bpy.context, "mode", None)
current_tool = bpy.context.workspace.tools.from_space_view3d_mode(mode) workspace = getattr(bpy.context, "workspace", None)
if mode is None or workspace is None:
return None
current_tool = workspace.tools.from_space_view3d_mode(mode)
if not current_tool or current_tool.idname not in tool.Blender.get_list_of_tools(): if not current_tool or current_tool.idname not in tool.Blender.get_list_of_tools():
return None return None
element = tool.Ifc.get_entity(obj) element = tool.Ifc.get_entity(obj)
+1 -1
View File
@@ -439,7 +439,7 @@ class IfcStore:
BrickStore.end_transaction() BrickStore.end_transaction()
IfcStore.end_transaction(operator) IfcStore.end_transaction(operator)
bonsai.bim.handler.refresh_ui_data() bonsai.bim.handler.refresh_ui_data()
tool.Parametric.refresh_post_commit() tool.Parametric.refresh_post_commit(operator)
if method == "MODAL": if method == "MODAL":
cls.modal_in_progress = False cls.modal_in_progress = False
+1 -1
View File
@@ -793,7 +793,7 @@ class Profile:
@interface @interface
class Parametric: class Parametric:
def get_geom_generation(cls) -> int: pass def get_geom_generation(cls) -> int: pass
def refresh_post_commit(cls) -> None: pass def refresh_post_commit(cls, operator) -> None: pass
@interface @interface
+25 -6
View File
@@ -229,15 +229,22 @@ class Blender(bonsai.core.tool.Blender):
@classmethod @classmethod
def get_active_object(cls, is_selected: bool = False) -> Union[bpy.types.Object, None]: def get_active_object(cls, is_selected: bool = False) -> Union[bpy.types.Object, None]:
"""Gets the active object """Return the active object, or ``None`` when the current context
exposes neither ``active_object`` nor a ``view_layer`` (stripped
operator contexts).
:param is_selected: If true, the active object also needs to be selected. :param is_selected: If true, the active object also needs to be selected.
""" """
if obj := (getattr(bpy.context, "active_object", None) or bpy.context.view_layer.objects.active): obj = getattr(bpy.context, "active_object", None)
if not is_selected: if obj is None:
return obj view_layer = getattr(bpy.context, "view_layer", None)
if obj.select_get(): if view_layer is not None:
return obj obj = view_layer.objects.active
if obj is None:
return None
if is_selected and not obj.select_get():
return None
return obj
@classmethod @classmethod
def get_selected_objects(cls, include_active: bool = True) -> set[bpy.types.Object]: def get_selected_objects(cls, include_active: bool = True) -> set[bpy.types.Object]:
@@ -1978,6 +1985,18 @@ class Blender(bonsai.core.tool.Blender):
dct = {cls.bl_idname: cls.ifc_element_type for cls in (BimTool.__subclasses__())} dct = {cls.bl_idname: cls.ifc_element_type for cls in (BimTool.__subclasses__())}
return types.MappingProxyType(dct) return types.MappingProxyType(dct)
@classmethod
@lru_cache
def get_property_header_tools(cls) -> frozenset[str]:
"""``BimTool`` plus its parametric subclasses — the workspace
tools whose 3D-view / N-panel header surfaces BIM Tool property
floats (extrusion_depth, length, x_angle). ``AnnotationTool``
and the non-``BimTool`` workspace tools (spatial / structural /
cad / covering) are excluded by construction."""
from bonsai.bim.module.model.workspace import BimTool
return frozenset(cls.bl_idname for cls in (BimTool.__subclasses__() + [BimTool]))
@classmethod @classmethod
def get_object_constraint_props(cls, obj: bpy.types.Object) -> BIMObjectConstraintProperties: def get_object_constraint_props(cls, obj: bpy.types.Object) -> BIMObjectConstraintProperties:
return obj.BIMObjectConstraintProperties # pyright: ignore[reportAttributeAccessIssue] return obj.BIMObjectConstraintProperties # pyright: ignore[reportAttributeAccessIssue]
+15 -8
View File
@@ -179,17 +179,24 @@ class Parametric(bonsai.core.tool.Parametric):
return cls._geom_generation return cls._geom_generation
@classmethod @classmethod
def refresh_post_commit(cls) -> None: def refresh_post_commit(cls, operator: bpy.types.Operator) -> None:
"""Post-commit hook for ``tool.Ifc.Operator``: re-syncs scene-level """Post-commit hook for ``tool.Ifc.Operator``: bumps the geometry
workspace-tool header fields from current IFC state and bumps the generation counter so caches keyed off it drop stale entries on
geometry generation counter so caches keyed off it drop stale the next draw, and tags viewports for redraw.
entries on the next draw. Header-only user-intent enums are
re-targeted on selection change, not here."""
import bonsai.bim.handler # late import: bim.handler imports tool.*
Additionally refreshes the BIM Tool header floats for the
validate-gizmo path operators whose ``bl_idname`` is the
``finish_op`` of an entry in ``EDIT_TYPES``. That is the only
commit class where selection didn't change but the header
values displayed did. Other operators skip the refresh: they
don't target an active-object header edit, and their commit
context may lack the view-layer attributes the refresh reads."""
cls._geom_generation += 1 cls._geom_generation += 1
bonsai.bim.handler.refresh_bim_tool_headers()
tool.Blender.update_all_viewports() tool.Blender.update_all_viewports()
if operator.bl_idname in {feature.finish_op for feature in cls.EDIT_TYPES}:
import bonsai.bim.handler # late import: bim.handler imports tool.*
bonsai.bim.handler.refresh_bim_tool_headers()
@classmethod @classmethod
def find_by_name(cls, name: str) -> Optional[ParametricObject]: def find_by_name(cls, name: str) -> Optional[ParametricObject]:
@@ -18,21 +18,20 @@
# #
# This file was generated with the assistance of an AI coding tool. # This file was generated with the assistance of an AI coding tool.
"""Regression tests for the post-IFC-commit refresh path that re-syncs the """Regression tests for the post-IFC-commit refresh path.
workspace tool header (``BIMModelProperties``) and invalidates the per-wall
gizmo geometry cache.
Bug repro before the fix: hotkey operators that edited the active wall in Two invariants:
place (``bpy.ops.bim.hotkey(hotkey="S_E")`` / ``"C_E"``) mutated IFC but never
fired ``active_object_callback`` (no selection change), so the header H/L/A * Every commit bumps ``_geom_generation`` so caches keyed off it drop
fields and the gizmo cache both kept showing stale values. ``refresh_ui_data`` stale entries on the next read.
ran, but it never resynced ``BIMModelProperties`` and never invalidated the * The BIM Tool header float refresh (``refresh_bim_tool_headers``) fires
per-gizmo-group geometry cache. The fix wires both refreshes through only for commits whose operator is a parametric ``finish_op`` from
``tool.Parametric.refresh_post_commit`` and calls it from every ``tool.Parametric.EDIT_TYPES`` the validate-gizmo path. Other
``tool.Ifc.Operator`` epilogue.""" operators skip it; their commit context may lack the view-layer
attributes the refresh reads."""
import types import types
from unittest.mock import patch from unittest.mock import MagicMock, patch
import bpy import bpy
import pytest import pytest
@@ -46,17 +45,42 @@ def _require_real_bpy():
pytest.skip("requires real Blender (bpy is mocked or absent)") pytest.skip("requires real Blender (bpy is mocked or absent)")
def test_refresh_post_commit_bumps_generation_and_resyncs_header(): def test_refresh_post_commit_bumps_generation_for_every_operator():
"""``refresh_post_commit`` must bump the generation counter and call """The generation counter advances on every commit, regardless of
``update_bim_tool_props`` so the workspace tool header re-syncs from IFC.""" operator class it's the cache-invalidation signal for any code
import bonsai.bim.handler as handler keyed off ``tool.Parametric.get_geom_generation()``."""
from bonsai import tool from bonsai import tool
before = tool.Parametric.get_geom_generation() before = tool.Parametric.get_geom_generation()
with patch.object(handler, "update_bim_tool_props") as mock_resync: tool.Parametric.refresh_post_commit(MagicMock(bl_idname="bim.append_library_element"))
tool.Parametric.refresh_post_commit()
assert tool.Parametric.get_geom_generation() == before + 1 assert tool.Parametric.get_geom_generation() == before + 1
mock_resync.assert_called_once()
def test_refresh_post_commit_refreshes_headers_for_validate_gizmo_operators():
"""Operators whose ``bl_idname`` matches a ``ParametricObject.finish_op``
in ``EDIT_TYPES`` are the validate-gizmo path: selection didn't
change, but the IFC values backing the BIM Tool header did. The
commit hook must push the new IFC state into the header floats."""
import bonsai.bim.handler as handler
from bonsai import tool
finish_op_idname = tool.Parametric.EDIT_TYPES[0].finish_op
with patch.object(handler, "refresh_bim_tool_headers") as mock_refresh:
tool.Parametric.refresh_post_commit(MagicMock(bl_idname=finish_op_idname))
mock_refresh.assert_called_once()
def test_refresh_post_commit_skips_header_refresh_for_non_finish_operators():
"""Other operators must not trigger the header refresh. The refresh
reads ``bpy.context``; for commits invoked from a stripped operator
context (e.g. nested ``bpy.ops`` calls during project setup) this
would raise ``AttributeError`` and break the outer operator chain."""
import bonsai.bim.handler as handler
from bonsai import tool
with patch.object(handler, "refresh_bim_tool_headers") as mock_refresh:
tool.Parametric.refresh_post_commit(MagicMock(bl_idname="bim.append_library_element"))
mock_refresh.assert_not_called()
def test_geom_generation_invalidates_wall_geom_cache(): def test_geom_generation_invalidates_wall_geom_cache():
@@ -18,11 +18,12 @@
# #
# This file was generated with the assistance of an AI coding tool. # This file was generated with the assistance of an AI coding tool.
"""Forward-compat AST contracts for ``bonsai.bim.handler``. """Forward-compat AST contracts for the BIM Tool refresh path.
Pins structural invariants on the post-commit refresh path that no Pins structural invariants that no behavioural test can catch on its own:
behavioural test can catch on its own specifically, that the commit- the commit-driven header refresh fires only for the parametric validate-
driven refresh never writes user-intent enum slots.""" gizmo operators (``bim.finish_editing_<name>``), never universally and
the header writer never drifts into user-intent enum writes."""
import ast import ast
from pathlib import Path from pathlib import Path
@@ -33,17 +34,13 @@ pytestmark = pytest.mark.model
HANDLER_PATH = Path(__file__).parent.parent.parent / "bonsai" / "bim" / "handler.py" HANDLER_PATH = Path(__file__).parent.parent.parent / "bonsai" / "bim" / "handler.py"
PARAMETRIC_PATH = HANDLER_PATH.parent.parent / "tool" / "parametric.py"
# User-intent enums: encode the user's "what to build next" choice on the # User-intent enums encode the user's "what to build next" choice on the
# BIM Tool panel. Writing them from a commit-driven path silently resets # BIM Tool panel. The header-only writer must never drift into enum writes;
# the user's selection on every IFC mutation — selection-change is the # user-intent enums are owned by the selection-change path.
# only legitimate caller.
USER_INTENT_ENUM_ATTRS = frozenset({"ifc_class", "relating_type_id"}) USER_INTENT_ENUM_ATTRS = frozenset({"ifc_class", "relating_type_id"})
# Functions that must remain free of user-intent enum writes. Both are
# reachable from ``tool.Parametric.refresh_post_commit``.
ENUM_SAFE_FUNCTIONS = ("refresh_bim_tool_headers", "_read_headers_into_props")
def _function_node(tree: ast.Module, name: str) -> ast.FunctionDef: def _function_node(tree: ast.Module, name: str) -> ast.FunctionDef:
for node in ast.walk(tree): for node in ast.walk(tree):
@@ -57,14 +54,13 @@ def handler_tree() -> ast.Module:
return ast.parse(HANDLER_PATH.read_text(encoding="utf-8")) return ast.parse(HANDLER_PATH.read_text(encoding="utf-8"))
@pytest.mark.parametrize("fn_name", ENUM_SAFE_FUNCTIONS) def test_read_headers_into_props_writes_only_header_floats(handler_tree: ast.Module) -> None:
def test_commit_driven_function_does_not_write_user_intent_enums(handler_tree: ast.Module, fn_name: str) -> None: """``_read_headers_into_props`` is the header-only writer called from
"""The commit-driven refresh path must never assign to user-intent the selection-driven refresh. It must not assign to user-intent enum
enum slots (``ifc_class``, ``relating_type_id``). Re-targeting these slots (``ifc_class``, ``relating_type_id``); those are the
from the post-commit hook silently overwrites the user's BIM Tool 'what to build next' choice and have their own targeted writes
panel selection on every IFC mutation; only selection-change callers earlier in ``update_bim_tool_props``."""
may write them.""" fn = _function_node(handler_tree, "_read_headers_into_props")
fn = _function_node(handler_tree, fn_name)
offenders = [] offenders = []
for node in ast.walk(fn): for node in ast.walk(fn):
if not isinstance(node, ast.Assign): if not isinstance(node, ast.Assign):
@@ -75,32 +71,49 @@ def test_commit_driven_function_does_not_write_user_intent_enums(handler_tree: a
if offenders: if offenders:
msgs = ", ".join(f"{attr} at line {line}" for attr, line in offenders) msgs = ", ".join(f"{attr} at line {line}" for attr, line in offenders)
pytest.fail( pytest.fail(
f"{fn_name!r} assigns to user-intent enum slot(s): {msgs}. " f"_read_headers_into_props assigns to user-intent enum slot(s): {msgs}. "
f"Move this assignment to a selection-driven callback." f"Header refresh must not re-target the user's BIM Tool panel selection."
) )
def test_refresh_post_commit_calls_header_only_entrypoint(handler_tree: ast.Module) -> None: def test_refresh_post_commit_gates_header_refresh_on_edit_types_registry() -> None:
"""``tool.Parametric.refresh_post_commit`` must dispatch into """``tool.Parametric.refresh_post_commit`` fires for every IFC
``refresh_bim_tool_headers``, not ``update_bim_tool_props``. operator commit. Only operators whose ``bl_idname`` matches a
The latter re-targets user-intent enums; routing the post-commit ``ParametricObject.finish_op`` in ``EDIT_TYPES`` (the validate-
hook through it silently resets the user's BIM Tool selection on gizmo path) must trigger a BIM Tool header refresh selection
every IFC mutation and crashes on element types absent from the didn't change but the header values did. Other operators must
``ifc_class`` enum (e.g. ``IfcAnnotation``).""" skip the refresh: they don't target an active-object header edit,
parametric_path = HANDLER_PATH.parent.parent / "tool" / "parametric.py" and their commit context may lack the view-layer attributes the
parametric_tree = ast.parse(parametric_path.read_text(encoding="utf-8")) refresh reads.
The gate must consult the registry, not match a string prefix
``EDIT_TYPES`` is the canonical list of parametric features, and
querying it stays correct even if ``ParametricObject.finish_op``
changes its derivation rule."""
parametric_tree = ast.parse(PARAMETRIC_PATH.read_text(encoding="utf-8"))
fn = _function_node(parametric_tree, "refresh_post_commit") fn = _function_node(parametric_tree, "refresh_post_commit")
called_handler_attrs = { found_gated_call = False
node.func.attr for node in ast.walk(fn):
for node in ast.walk(fn) if not isinstance(node, ast.If):
if isinstance(node, ast.Call) continue
and isinstance(node.func, ast.Attribute) references_registry = any(
and isinstance(node.func.value, ast.Attribute) isinstance(sub, ast.Attribute) and sub.attr == "EDIT_TYPES" for sub in ast.walk(node.test)
and node.func.value.attr == "handler" )
} if not references_registry:
assert ( continue
"refresh_bim_tool_headers" in called_handler_attrs for body_node in ast.walk(node):
), "refresh_post_commit must call bonsai.bim.handler.refresh_bim_tool_headers" if (
assert "update_bim_tool_props" not in called_handler_attrs, ( isinstance(body_node, ast.Call)
"refresh_post_commit must not call update_bim_tool_props " "(re-targets user-intent enums on every commit)" and isinstance(body_node.func, ast.Attribute)
and body_node.func.attr == "refresh_bim_tool_headers"
):
found_gated_call = True
break
if found_gated_call:
break
assert found_gated_call, (
"tool.Parametric.refresh_post_commit must gate refresh_bim_tool_headers on an "
"If whose test references EDIT_TYPES (the parametric registry). An ungated call "
"fires the refresh for commits in contexts that strip view-layer attributes; "
"a missing call silently drops the validate-gizmo header refresh."
) )
@@ -0,0 +1,74 @@
# 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.
"""Restricted-context regression test for ``tool.Blender.get_active_object``.
Some Blender contexts (e.g. the C-side operator context handed to
programmatically-invoked nested ``bpy.ops`` calls) lack the view-layer
attributes a normal UI context exposes. The canonical accessor must
return ``None`` in that case rather than ``AttributeError`` otherwise
every caller routed through it inherits the same crash class that
originally broke ``bpy.ops.bim.new_project(preset='demo')``."""
from unittest.mock import patch
import pytest
pytestmark = pytest.mark.model
class _RestrictedContext:
"""Stand-in for a ``bpy.context`` stripped of view-layer attributes."""
def __getattr__(self, name):
raise AttributeError(name)
def test_get_active_object_returns_none_in_restricted_context():
"""``tool.Blender.get_active_object`` is the canonical defensive
accessor. Both the primary read (``bpy.context.active_object``) and
the fallback (``bpy.context.view_layer.objects.active``) must
tolerate a stripped context otherwise the 150+ callers in the
codebase that route through this helper inherit the crash."""
import bonsai.tool.blender as blender_tool
with patch.object(blender_tool, "bpy") as bpy_patch:
bpy_patch.context = _RestrictedContext()
assert blender_tool.Blender.get_active_object() is None
def test_property_header_tools_whitelists_bim_tool_family_only():
"""``tool.Blender.get_property_header_tools`` gates the validate-
gizmo header refresh. Parametric ``BimTool`` subclasses and the
base ``BimTool`` itself must be included; ``AnnotationTool`` and
workspace tools outside the ``BimTool`` family (spatial, structural,
etc.) must not they don't surface these header floats."""
import bonsai.tool as tool_
# Ensure the lru_cache picks up subclasses registered by the
# current Blender session (idempotent if already populated).
tool_.Blender.get_property_header_tools.cache_clear()
headers = tool_.Blender.get_property_header_tools()
assert "bim.bim_tool" in headers, "base BimTool must surface property headers"
assert "bim.wall_tool" in headers, "parametric BimTool subclass must surface property headers"
assert "bim.annotation_tool" not in headers, "AnnotationTool is not a BimTool subclass — no header surface"
assert "bim.spatial_tool" not in headers, "SpatialTool is not BimTool-derived"
assert "bim.structural_tool" not in headers, "StructuralTool is not BimTool-derived"