Refactor tool.Parametric — feature registry + lifecycle hooks

tool.Parametric becomes the central registry for Bonsai's parametric
features (wall, slab, door, window, railing, roof, stair, plus
mep-segment variants). Each feature registers a ParametricObject spec
declaring its enable/finish/cancel op names, props accessor, regen
callback, and is_element_type predicate.

Public surface:

* tool.Parametric.WALL / SLAB / DOOR / WINDOW / RAILING / ROOF /
  STAIR / PIPE_SEGMENT / DUCT_SEGMENT — typed accessors per feature.
* tool.Parametric.is_wall / is_door / is_window / is_railing /
  is_roof / is_stair — element-type predicates that move off
  tool.Blender.Modifier into the parametric registry. The next
  commit adds backward-compat shims on tool.Blender.Modifier so
  v0.8.0 callers keep working.
* tool.Parametric.is_object_editing(obj) — returns the registered
  feature an object is currently editing, or None.
* tool.Parametric.run_bim_op(op_name) — invoke a parametric op by
  bl_idname.
* tool.Parametric.heal_stale_edit_flags — clear is_editing flags
  on file load so a saved-mid-edit project doesn't leave gizmos
  poll-locked.
* supports_build_edit_lifecycle field on ParametricObject — declares
  whether the feature implements the build/edit/cancel triad.

The previous bare `print(f"Bonsai: commit of {obj.name!r} via
{finish_op} failed: {e}")` exception-handler is replaced with
logger.warning(..., exc_info=True). Same channel (Bonsai configures
logging to the Blender console at WARNING level), strictly more
information (full traceback), correct idiom for an error-path
message. A second logger.warning is added for parametric predicate
failures, also exception-handler scope.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-05-27 09:21:39 +02:00
committed by Thomas Krijnen
parent 8cf6d5a001
commit 8c9fcfd5e4
+416 -271
View File
@@ -18,239 +18,89 @@
# #
# This file was generated with the assistance of an AI coding tool. # This file was generated with the assistance of an AI coding tool.
"""Registry + save-time auto-commit for parametric draft edits. """Registry and save-time auto-commit for parametric draft edits.
Single source of truth: adding a new parametric element type is one entry in The registry is consumed along two orthogonal axes:
`Parametric.EDIT_TYPES`. Every consumer — save-time auto-commit, the
finish/cancel chains in ``tool.Blender.Modifier``, the ``PointerProperty``
attachment in ``bim/module/model/__init__.py``, and the per-type
``GizmoPreferences<X>`` registration in ``bim/__init__.py`` — derives the
class names, operator ``bl_idname``s, and predicates from the registry entry's
short ``name`` token.
Lives in ``tool/`` so both ``tool/`` (e.g. ``tool/blender.py``) and ``bim/`` - **Predicate axis**: every entry carries an ``is_<name>`` total predicate. Used
modules can consume it without crossing the layer boundary. The orchestration by ``find_for_element``, save-flow auto-commit, and per-feature gizmo polls.
helpers (``commit_object_draft``, ``commit_pending_edits``) call - **Lifecycle axis**: a subset of entries flagged ``supports_build_edit_lifecycle=True``
``bpy.ops.bim.*`` operators by name, which is runtime dispatch through Blender share the ``Enable/Finish/CancelEditing<Type>`` operator shape and are wired
rather than a Python import of ``bim/``. through ``build_edit_lifecycle``. The remainder declare their edit operators
directly because their lifecycle (per-attribute diff dispatch, layer-stack
editing, mid-spline gizmo drag, …) does not fit the shared mixin contract.
---------------------------------------------------------------------- Adding a new parametric element type is a single entry in ``EDIT_TYPES``;
How to add a new parametric object flag ``supports_build_edit_lifecycle`` only if the type's edit lifecycle matches
---------------------------------------------------------------------- one of the shared mixins in ``bim/parametric_lifecycle.py``."""
End-to-end walkthrough for wiring a new IFC element type (e.g. ``IfcSlab``)
into the gizmo-driven parametric edit framework. Numbered steps are
**required** unless flagged OPTIONAL. Keep this section in sync with the
implementation files it references — if a step's example code stops matching
the real registration site, the step is out of date.
STEP 1 — Add the registry entry (this file)
Append to `Parametric.EDIT_TYPES`::
ParametricObject("slab", has_non_editable_path=False),
The ``name`` token drives every derived identifier:
``BIMSlabProperties``, ``bim.enable_editing_slab`` /
``bim.finish_editing_slab`` / ``bim.cancel_editing_slab``, and the
``slab`` field on ``GizmoPreferences``. Set ``has_non_editable_path=True``
if the modifier exposes no user-editable path (cf. door, window, stair).
STEP 2 — Define the ``PropertyGroup`` (``bim/module/model/prop.py``)
Class name **must** be ``BIM<Name>Properties`` — capitalisation matches
`ParametricObject.props_attr`::
class BIMSlabProperties(bpy.types.PropertyGroup):
is_editing: BoolProperty(...)
# ... per-type draft fields, snapshots, mesh_dirty, etc. ...
The ``is_editing`` flag is the single field every consumer of the registry
expects.
STEP 3 — Register the PropertyGroup class
Add it to the ``classes`` tuple in ``bim/module/model/__init__.py`` (near
the existing ``prop.BIM<X>Properties`` entries). The
``bpy.types.Object.BIMSlabProperties`` attachment is automatic —
`Parametric.register_object_properties` loops the registry.
STEP 4 — Implement the Enable / Finish / Cancel triad
In ``bim/module/model/slab.py``, define three ``bpy.types.Operator``
subclasses with the canonical ``bl_idname``\\s:
- ``EnableEditingSlab`` → ``bl_idname = "bim.enable_editing_slab"``
- ``FinishEditingSlab`` → ``bl_idname = "bim.finish_editing_slab"``
- ``CancelEditingSlab`` → ``bl_idname = "bim.cancel_editing_slab"``
**First, check if your new type fits one of the existing lifecycle
shapes** in `bonsai.bim.parametric_lifecycle`. If it does, inherit
the matching mixin and the triad collapses to ~25 lines total:
- ``FeatureModifierEditMixin`` — BBIM_<Type> pset with nested
``lining_properties`` / ``panel_properties``; Finish via
``update_<type>_modifier_representation`` →
``ifcopenshell.api.feature``; Cancel via
``switch_representation`` to the Body rep. Reference samples:
door (multi-object) and window (single-object).
- ``PathPreservingEditMixin`` — BBIM_<Type> pset whose ``path_data``
is preserved through edit; Finish via per-type
``update_bbim_<type>_pset`` + ``update_<type>_modifier_ifc_data``;
Cancel rebuilds the bmesh preview. Reference samples: railing, roof.
If neither shape fits (the type needs validation-first lifecycle, an
explicit snapshot, delegate-to-sub-operators Finish, or a unique
post-Finish step) implement the triad standalone — see ``wall.py``
(validation/snapshot/delegate) or ``stair.py`` (raw pset JSON +
``update_ifc_stair_props``) as references. Register all three in the
module's ``classes`` tuple.
STEP 5 — Implement the gizmo group (same file)
Subclass ``BaseParametricGizmoGroup`` from
``bim/module/drawing/gizmos.py``::
class GizmoSlabEdition(bpy.types.GizmoGroup, BaseParametricGizmoGroup):
bl_idname = "OBJECT_GGT_bim_slab_edition"
@classmethod
def is_element_type(cls, element):
return tool.Blender.Modifier.is_slab(element)
dimension_gizmo_props = [DimensionGizmoConfig(...)]
Register it in the ``classes`` tuple. The classmethod makes
``tool.Blender.Modifier.is_slab(element)`` testable via the gizmo's
``poll()``.
STEP 6 — Add the element-type predicate (``tool/blender.py``)
Inside the ``Blender.Modifier`` class, alongside ``is_door`` / ``is_wall``::
@classmethod
def is_slab(cls, element: entity_instance) -> bool:
return tool.Pset.get_element_pset(element, "BBIM_Slab")
The method name **must** be ``is_<name>`` to match
`ParametricObject.name` — `Parametric.find_for_element`
looks it up by string.
STEP 7 — OPTIONAL: typed property accessor (``tool/model.py``)
Convenience helper for call sites that statically know the IFC type::
@classmethod
def get_slab_props(cls, obj) -> BIMSlabProperties:
return obj.BIMSlabProperties
Call sites that work generically (registry-driven) can use
``getattr(obj, feature.props_attr)`` directly and skip this step.
STEP 8 — OPTIONAL: gizmo visibility preferences (``bim/ui.py``)
For per-gizmo show/hide toggles, define::
class GizmoPreferencesSlab(bpy.types.PropertyGroup):
length: BoolProperty(name="Length", default=True, ...)
# ... one BoolProperty per gizmo ...
Then add a matching field on ``GizmoPreferences``::
slab: bpy.props.PointerProperty(type=GizmoPreferencesSlab)
Do **not** add ``GizmoPreferencesSlab`` to the ``classes`` list in
``bim/__init__.py`` — the registry-driven discovery in this module finds
it by name (``GizmoPreferences`` + capitalised registry token) and
registers it automatically.
STEP 9 — OPTIONAL: pure geometry helpers (``core/model.py``)
Per-type math (collinearity checks, slope/displacement conversions,
intersection helpers) lives here. The hard rule: no ``bpy`` /
``ifcopenshell`` imports at module load — wrap them in
``if TYPE_CHECKING:`` blocks only. Lets the helpers be unit-tested
headless via ``pytest test/core/``.
STEP 10 — Verify
From ``src/bonsai/``::
ruff check .
black --check .
pytest test/core/ -x -q
blender -b -P runpytest.py -- test/bim/ -x -q -m model
The Blender-backed lane runs a registry smoke test that iterates the
EDIT_TYPES list and asserts each entry's enable/finish/cancel operator
resolves to a registered ``bpy.ops.bim.*``, that ``bpy.types.Object``
carries the matching ``BIM<Name>Properties`` attribute, and that the
``is_<name>`` predicate exists on ``tool.Blender.Modifier``. Forget any
of the steps above and that test fails with a precise pointer at
what's missing.
Then manually in Blender:
1. Enable Bonsai → create an instance of the new IFC type.
2. Run ``bim.enable_editing_<name>`` → confirm the gizmo group polls in
and the dimension handles appear.
3. Modify a draft field, save the file → confirm auto-commit fires
(watch the console for the ``parametric_commit`` log line).
4. Disable + re-enable the addon → no ``bpy_struct: unknown property
type`` errors in the console (validates the register/unregister
symmetry driven by the registry)."""
from __future__ import annotations from __future__ import annotations
import logging
import re import re
import traceback from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Any, ClassVar, Optional
import bpy import bpy
import bonsai.core.tool import bonsai.core.tool
import bonsai.tool as tool import bonsai.tool as tool
logger = logging.getLogger(__name__)
if TYPE_CHECKING: if TYPE_CHECKING:
from ifcopenshell import entity_instance from ifcopenshell import entity_instance
# ``name`` must be a single ASCII lowercase token starting with a letter: # Lowercase ASCII snake_case token; each segment a non-empty letter/digit
# ``str.capitalize()`` only handles single-word names cleanly, so a compound # sequence starting with a letter. ``"pipe_segment"`` → ``"BIMPipeSegmentProperties"``.
# token like ``"curtain_wall"`` would derive ``"BIMCurtain_wallProperties"`` — _VALID_NAME_RE = re.compile(r"^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$")
# off the Bonsai naming convention and silently broken.
_VALID_NAME_RE = re.compile(r"^[a-z][a-z0-9]*$")
def _camel_case(name: str) -> str:
return "".join(part.capitalize() for part in name.split("_"))
@dataclass(frozen=True) @dataclass(frozen=True)
class ParametricObject: class ParametricObject:
"""One parametric element type's draft + enable + finish + cancel triad. """One parametric element type's draft + enable + finish + cancel edit lifecycle.
The short ``name`` token ("door", "window", "stair", "railing", "roof", The ``name`` token drives every derived identifier: the
"wall", …) drives every derived identifier: the ``BIM<Name>Properties`` ``BIM<Name>Properties`` attribute on ``bpy.types.Object``, the
attribute on ``bpy.types.Object`` and the ``bim.enable_editing_<name>`` / ``bim.enable_editing_<name>`` / ``bim.finish_editing_<name>`` /
``bim.finish_editing_<name>`` / ``bim.cancel_editing_<name>`` operator ``bim.cancel_editing_<name>`` operator ``bl_idname``s, and the
``bl_idname``s. The ``name`` is validated at construction time — a ``tool.Parametric.is_<name>`` runtime predicate.
multi-word IFC type would silently mis-derive through
``str.capitalize()`` and breaks the single-token assumption.
``has_non_editable_path`` flags element types whose modifier exposes no The predicate is part of the contract and MUST be total — accept any IFC
user-editable path (door, window, stair). entity, return a bool, never raise. A raising predicate breaks the save
path for every parametric type, not just its own.
The paired runtime predicate ``tool.Blender.Modifier.is_<name>(element)`` ``supports_build_edit_lifecycle`` marks entries whose edit lifecycle fits the
is part of the registry contract: it MUST be **total** — accept any shared mixin contract (``_enable_targets`` / ``_finish_targets`` /
IFC entity and return a boolean, never raise. The registry iterates ``_cancel_targets``) and that therefore wire their operators through
every predicate against the active element on save; a raising predicate ``build_edit_lifecycle``. Entries with bespoke edit lifecycles (per-attribute
propagates upward and breaks the save path for *all* parametric types, diff dispatch, layer-stack editing, mid-spline gizmo drag) leave this
not just its own.""" False and declare their operator classes directly."""
name: str name: str
has_non_editable_path: bool = False has_non_editable_path: bool = False
supports_build_edit_lifecycle: bool = False
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not _VALID_NAME_RE.match(self.name): if not _VALID_NAME_RE.match(self.name):
raise ValueError( raise ValueError(
f"ParametricObject name {self.name!r} must be a single ASCII lowercase " f"ParametricObject name {self.name!r} must match "
f"token matching {_VALID_NAME_RE.pattern!r}. ``str.capitalize()`` only " f"{_VALID_NAME_RE.pattern!r} — lowercase letters / digits, "
f"handles single-word names — compound IFC types need an explicit " f"optionally split by single underscores (e.g. ``door`` or "
f"naming override (not yet supported)." f"``pipe_segment``). Leading / trailing underscores and "
f"consecutive underscores are rejected because they produce "
f"empty CamelCase segments in derived class names."
) )
@property @property
def props_attr(self) -> str: def props_attr(self) -> str:
return f"BIM{self.name.capitalize()}Properties" return f"BIM{_camel_case(self.name)}Properties"
@property @property
def enable_op(self) -> str: def enable_op(self) -> str:
@@ -270,15 +120,58 @@ class ParametricObject:
class Parametric(bonsai.core.tool.Parametric): class Parametric(bonsai.core.tool.Parametric):
class GenerationKeyedCache:
"""A dict-keyed cache stamped with the parametric generation counter
at fill time. Reads at a later generation drop the whole dict and
re-run the loader. Any IFC commit bumps the generation, invalidating
all entries en bloc.
``None`` values are stored verbatim; only "key not in dict" counts as
a miss."""
def __init__(self) -> None:
self._gen: int | None = None
self._data: dict = {}
def get_or_compute(self, key, loader):
current = Parametric.get_geom_generation()
if self._gen != current:
self._data.clear()
self._gen = current
if key not in self._data:
self._data[key] = loader()
return self._data[key]
def clear(self) -> None:
"""Explicit drop. Use from ``load_post`` so a fresh file starts clean."""
self._data.clear()
self._gen = None
EDIT_TYPES: list[ParametricObject] = [ EDIT_TYPES: list[ParametricObject] = [
ParametricObject("door", has_non_editable_path=True), ParametricObject("door", has_non_editable_path=True, supports_build_edit_lifecycle=True),
ParametricObject("window", has_non_editable_path=True), ParametricObject("window", has_non_editable_path=True, supports_build_edit_lifecycle=True),
ParametricObject("stair", has_non_editable_path=True), ParametricObject("stair", has_non_editable_path=True, supports_build_edit_lifecycle=True),
ParametricObject("railing"), ParametricObject("railing", supports_build_edit_lifecycle=True),
ParametricObject("roof"), ParametricObject("roof", supports_build_edit_lifecycle=True),
ParametricObject("wall"), ParametricObject("wall"),
ParametricObject("array", supports_build_edit_lifecycle=True),
ParametricObject("pipe_segment", has_non_editable_path=True, supports_build_edit_lifecycle=True),
ParametricObject("duct_segment", has_non_editable_path=True, supports_build_edit_lifecycle=True),
] ]
# Annotations for the uppercase constants populated from ``EDIT_TYPES`` by
# the binding loop at module bottom. Declared here so IDEs and type
# checkers see the attributes without running the loop.
DOOR: ClassVar[ParametricObject]
WINDOW: ClassVar[ParametricObject]
STAIR: ClassVar[ParametricObject]
RAILING: ClassVar[ParametricObject]
ROOF: ClassVar[ParametricObject]
WALL: ClassVar[ParametricObject]
ARRAY: ClassVar[ParametricObject]
PIPE_SEGMENT: ClassVar[ParametricObject]
DUCT_SEGMENT: ClassVar[ParametricObject]
_geom_generation: int = 0 _geom_generation: int = 0
@classmethod @classmethod
@@ -288,20 +181,9 @@ class Parametric(bonsai.core.tool.Parametric):
@classmethod @classmethod
def refresh_post_commit(cls) -> None: def refresh_post_commit(cls) -> None:
"""Post-commit hook for ``tool.Ifc.Operator``: re-syncs scene-level """Post-commit hook for ``tool.Ifc.Operator``: re-syncs scene-level
``BIMModelProperties`` (workspace tool header H/L/A fields) from current workspace-tool header fields from current IFC state and bumps the
IFC state and bumps the geometry generation counter so per-gizmo-group geometry generation counter so caches keyed off it drop stale
caches keyed off it drop their stale entries on the next draw. entries on the next draw."""
Why this exists: ``update_bim_tool_props`` was historically only wired
to the active-object msgbus, so in-place IFC mutations on the current
selection (S_E, C_E, change_extrusion_*, …) left the header showing
stale values until the user changed selection. Same shape of bug for
the wall gizmo cache: ``GizmoGroup.refresh()`` only fires on Blender's
own state-change events, not on every ``bpy.ops.bim.*`` mutation.
Cheap when nothing parametric is active — ``update_bim_tool_props``
early-returns when no Bonsai workspace tool is selected or the active
object isn't an IFC element."""
import bonsai.bim.handler # late import: bim.handler imports tool.* import bonsai.bim.handler # late import: bim.handler imports tool.*
cls._geom_generation += 1 cls._geom_generation += 1
@@ -317,51 +199,104 @@ class Parametric(bonsai.core.tool.Parametric):
return next((f for f in cls.EDIT_TYPES if f.name == name), None) return next((f for f in cls.EDIT_TYPES if f.name == name), None)
@classmethod @classmethod
def find_for_element(cls, element: entity_instance) -> Optional[ParametricObject]: def _safe_predicate(cls, feature: ParametricObject, element: entity_instance) -> bool:
"""Return the registry entry whose IFC type predicate matches ``element``. """Resolve and invoke ``is_<feature.name>`` defensively. The contract is
that predicates are total (see ``ParametricObject`` docstring); a
regression that turns one predicate raising would otherwise break the
save path for every parametric type, not just its own."""
predicate = getattr(cls, f"is_{feature.name}", None)
if predicate is None:
return False
try:
return bool(predicate(element))
except Exception:
logger.warning(
"parametric predicate is_%s raised on %r",
feature.name,
element,
exc_info=True,
)
return False
The per-type predicate lives at ``tool.Blender.Modifier.is_<name>``; @classmethod
resolved here by attribute lookup at call time, which avoids a def find_for_element(cls, element: entity_instance) -> Optional[ParametricObject]:
``tool.parametric`` ↔ ``tool.blender`` import cycle.""" """Return the registry entry whose IFC type predicate matches ``element``."""
for feature in cls.EDIT_TYPES: for feature in cls.EDIT_TYPES:
predicate = getattr(tool.Blender.Modifier, f"is_{feature.name}", None) if cls._safe_predicate(feature, element):
if predicate is not None and predicate(element):
return feature return feature
return None return None
@classmethod @classmethod
def is_object_editing(cls, obj: bpy.types.Object) -> Optional[ParametricObject]: def is_object_editing(cls, obj: bpy.types.Object, skip_name: Optional[str] = None) -> Optional[ParametricObject]:
"""Return the registry entry whose edit lifecycle is active on ``obj``, or None.
``skip_name`` excludes one entry from the scan, for callers that want
to know if a *different* type is editing."""
for feature in cls.EDIT_TYPES: for feature in cls.EDIT_TYPES:
if feature.name == skip_name:
continue
if feature.is_editing(obj): if feature.is_editing(obj):
return feature return feature
return None return None
@classmethod
def _validated_editing_feature(cls, obj: bpy.types.Object) -> Optional[ParametricObject]:
"""Return the active registry entry on ``obj``, validated against the
per-type predicate. Returns None when no ``is_editing`` flag is set
or when the flag is stale.
Self-heals: a predicate mismatch clears the flag in place so the
finish dispatch never re-picks up a phantom edit."""
feature = cls.is_object_editing(obj)
if feature is None:
return None
element = tool.Ifc.get_entity(obj)
if element is None or not cls._safe_predicate(feature, element):
getattr(obj, feature.props_attr).is_editing = False
return None
return feature
@classmethod
def heal_stale_edit_flags(cls) -> None:
"""Validate every scene object's ``is_editing`` flag against the
per-type predicate, clearing stale flags in place.
Run from ``load_post`` so a ``.blend`` saved with phantom flags
(e.g. a save that bypassed the auto-commit flush) is consistent the
moment it opens."""
for obj in bpy.data.objects:
cls._validated_editing_feature(obj)
@classmethod @classmethod
def get_pending_edits(cls) -> list[tuple[bpy.types.Object, str]]: def get_pending_edits(cls) -> list[tuple[bpy.types.Object, str]]:
"""``(object, finish_operator_bl_idname)`` pairs for every object with """``(object, finish_operator_bl_idname)`` pairs for every object
an in-progress parametric draft. The first registry match per object wins.""" with an in-progress parametric draft. Stale flags are cleared in
return [(obj, feature.finish_op) for obj in bpy.data.objects if (feature := cls.is_object_editing(obj))] place and excluded."""
pending: list[tuple[bpy.types.Object, str]] = []
for obj in bpy.data.objects:
feature = cls._validated_editing_feature(obj)
if feature is not None:
pending.append((obj, feature.finish_op))
return pending
@classmethod @classmethod
def run_bim_op(cls, bl_idname: str) -> None: def run_bim_op(cls, bl_idname: str) -> None:
"""Invoke a ``bim.*`` operator by its ``bl_idname``. """Invoke a ``bim.*`` operator by ``bl_idname``.
Constraint enforced via ``assert``: the operator MUST be a Asserts the operator is a ``tool.Ifc.Operator`` subclass — bypassing
``tool.Ifc.Operator`` subclass — its transaction wrap is what that wrap would mutate IFC outside Bonsai's transaction system."""
makes the IFC mutation undo-aware. Direct ``bpy.ops.bim.*`` invocation
of a non-``Ifc.Operator`` would mutate IFC outside Bonsai's
transaction system."""
verb = bl_idname.removeprefix("bim.") verb = bl_idname.removeprefix("bim.")
op_cls = getattr(bpy.types, f"BIM_OT_{verb}", None) op_cls = getattr(bpy.types, f"BIM_OT_{verb}", None)
assert op_cls is not None and issubclass( if op_cls is None or not issubclass(op_cls, tool.Ifc.Operator):
op_cls, tool.Ifc.Operator raise RuntimeError(
), f"{bl_idname!r} must be a registered tool.Ifc.Operator subclass for undo-safe IFC mutation" f"{bl_idname!r} must be a registered tool.Ifc.Operator subclass for undo-safe IFC mutation"
)
getattr(bpy.ops.bim, verb)() getattr(bpy.ops.bim, verb)()
@classmethod @classmethod
def commit_object_draft(cls, obj: bpy.types.Object, finish_op: str) -> bool: def commit_object_draft(cls, obj: bpy.types.Object, finish_op: str) -> bool:
"""Run ``finish_op`` scoped to ``obj`` alone. Returns True on success, False if """Run ``finish_op`` scoped to ``obj`` alone. Returns False (with
the operator raised (with traceback printed to the console). traceback printed) if the operator raised.
Both ``temp_override`` and ``view_layer.objects.active`` are set: Both ``temp_override`` and ``view_layer.objects.active`` are set:
``temp_override`` does not rebind ``objects.active``, and some finish ``temp_override`` does not rebind ``objects.active``, and some finish
@@ -374,9 +309,13 @@ class Parametric(bonsai.core.tool.Parametric):
try: try:
cls.run_bim_op(finish_op) cls.run_bim_op(finish_op)
return True return True
except Exception as e: except Exception:
print(f"Bonsai: commit of {obj.name!r} via {finish_op} failed: {e}") logger.warning(
traceback.print_exc() "commit of %r via %s failed",
obj.name,
finish_op,
exc_info=True,
)
return False return False
finally: finally:
view_layer.objects.active = original_active view_layer.objects.active = original_active
@@ -385,14 +324,9 @@ class Parametric(bonsai.core.tool.Parametric):
def commit_pending_edits(cls) -> tuple[int, list[bpy.types.Object]]: def commit_pending_edits(cls) -> tuple[int, list[bpy.types.Object]]:
"""Run each pending draft's finish operator scoped to its object. """Run each pending draft's finish operator scoped to its object.
A per-object failure does not abort the loop — remaining drafts still A per-object failure does not abort the loop — remaining drafts
flush, otherwise the auto-commit would ship the exact silent-desync still flush, otherwise the auto-commit would ship the exact silent
it exists to prevent. desync it exists to prevent."""
Each finish op wraps its own IFC transaction, so N pending drafts
produce N+1 undo entries (one per commit, plus the save). Ctrl+Z
walks back through commits individually — intentional, each commit
is reversible on its own."""
committed = 0 committed = 0
failed: list[bpy.types.Object] = [] failed: list[bpy.types.Object] = []
for obj, finish_op in cls.get_pending_edits(): for obj, finish_op in cls.get_pending_edits():
@@ -406,18 +340,12 @@ class Parametric(bonsai.core.tool.Parametric):
def commit_pending_edits_for_selection( def commit_pending_edits_for_selection(
cls, names: Optional[tuple[str, ...]] = None cls, names: Optional[tuple[str, ...]] = None
) -> tuple[int, list[bpy.types.Object]]: ) -> tuple[int, list[bpy.types.Object]]:
"""Selection-scoped variant of `commit_pending_edits`. ``names`` """Selection-scoped variant. ``names`` filters which registry entries
filters which registry entries to consider — e.g. ``("wall",)`` to commit to consider; ``None`` considers every type."""
only wall drafts among selected objects; ``None`` considers every type.
Used by multi-object operators (``bim.unjoin_walls``, ``bim.merge_wall``,
``bim.extend_walls_to_wall`` etc.) that must run against committed IFC
state — running them with a wall whose draft hasn't been flushed leaves
stale gizmos pointing at obsolete IFC numbers."""
committed = 0 committed = 0
failed: list[bpy.types.Object] = [] failed: list[bpy.types.Object] = []
for obj in tool.Blender.get_selected_objects(): for obj in tool.Blender.get_selected_objects():
feature = cls.is_object_editing(obj) feature = cls._validated_editing_feature(obj)
if feature is None: if feature is None:
continue continue
if names is not None and feature.name not in names: if names is not None and feature.name not in names:
@@ -428,11 +356,25 @@ class Parametric(bonsai.core.tool.Parametric):
failed.append(obj) failed.append(obj)
return committed, failed return committed, failed
@classmethod
def _assert_predicates_registered(cls) -> None:
"""Loud at addon-enable if any ``EDIT_TYPES`` entry has no matching
``is_<name>`` classmethod. Without this, a typo in the registry entry
produces a silent-False predicate that never matches — every
parametric draft of that type bypasses save-flow auto-commit."""
missing = [feature.name for feature in cls.EDIT_TYPES if not callable(getattr(cls, f"is_{feature.name}", None))]
if missing:
raise RuntimeError(
f"tool.Parametric.EDIT_TYPES has entries with no is_<name> predicate: {missing}. "
f"Add `is_<name>(cls, element) -> bool` classmethods on tool.Parametric, "
f"or remove the entries from EDIT_TYPES."
)
@classmethod @classmethod
def register_object_properties(cls, prop_module) -> None: def register_object_properties(cls, prop_module) -> None:
"""Attach ``bpy.types.Object.BIM<Name>Properties`` for every registered """Attach ``bpy.types.Object.BIM<Name>Properties`` for every registered
parametric type, looking up the matching ``PropertyGroup`` class on parametric type. Skips entries whose ``PropertyGroup`` is absent."""
``prop_module``. Skips entries whose ``PropertyGroup`` class is absent.""" cls._assert_predicates_registered()
for feature in cls.EDIT_TYPES: for feature in cls.EDIT_TYPES:
prop_cls = getattr(prop_module, feature.props_attr, None) prop_cls = getattr(prop_module, feature.props_attr, None)
if prop_cls is None: if prop_cls is None:
@@ -447,14 +389,217 @@ class Parametric(bonsai.core.tool.Parametric):
@classmethod @classmethod
def iter_gizmo_preference_classes(cls, ui_module) -> list[type]: def iter_gizmo_preference_classes(cls, ui_module) -> list[type]:
"""``GizmoPreferences<Name>`` classes that exist on ``ui_module`` for """Shared ``GizmoPreferencesFeature`` class as a one-element list, or
every registry entry. Order matches `EDIT_TYPES`. Used by empty if absent. Must register before ``GizmoPreferences``."""
``bim/__init__.py`` to inject the per-type ``GizmoPreferences<X>`` shared = getattr(ui_module, "GizmoPreferencesFeature", None)
classes at the correct point — before ``ui.GizmoPreferences``, which return [shared] if shared is not None else []
references them via ``PointerProperty``."""
out: list[type] = [] # --- Feature-kind predicates ------------------------------------------------
for feature in cls.EDIT_TYPES: # One predicate per registered parametric type. Each is total: accepts any
gpref = getattr(ui_module, f"GizmoPreferences{feature.name.capitalize()}", None) # IFC entity (or None), returns a bool, never raises. Predicates live with
if gpref is not None: # the registry rather than ``tool.Blender.Modifier`` because they ARE the
out.append(gpref) # registry contract — ``find_for_element`` and ``_validated_editing_feature``
return out # resolve them by name. Coupling them on the same class makes a typo at
# registration time an immediate AttributeError instead of a silent None
# predicate that never matches.
@classmethod
def is_array(cls, element: entity_instance) -> bool:
"""True if element is the PARENT of a Bonsai parametric array.
Array children also carry a ``BBIM_Array`` pset (their ``Parent``
field points back to the original), so checking pset presence alone
would falsely match them. The parent is distinguished by
``pset.Parent == element.GlobalId``."""
import ifcopenshell.util.element
if element is None:
return False
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not pset:
return False
return pset.get("Parent") == element.GlobalId
@classmethod
def is_railing(cls, element: entity_instance) -> bool:
if element is None:
return False
return tool.Pset.get_element_pset(element, "BBIM_Railing") is not None
@classmethod
def is_roof(cls, element: entity_instance) -> bool:
if element is None:
return False
return tool.Pset.get_element_pset(element, "BBIM_Roof") is not None
@classmethod
def is_window(cls, element: entity_instance) -> bool:
if element is None:
return False
return tool.Pset.get_element_pset(element, "BBIM_Window") is not None
@classmethod
def is_door(cls, element: entity_instance) -> bool:
if element is None:
return False
return tool.Pset.get_element_pset(element, "BBIM_Door") is not None
@classmethod
def is_stair(cls, element: entity_instance) -> bool:
if element is None:
return False
return tool.Pset.get_element_pset(element, "BBIM_Stair") is not None
@classmethod
def is_wall(cls, element: entity_instance) -> bool:
"""A wall is editable by the parametric gizmo if it is an IfcWall with LAYER2 usage.
Unlike doors/windows/stairs, walls do not carry a proprietary BBIM_Wall pset —
their parametric state lives in standard IFC (axis polyline, IfcMaterialLayerSetUsage,
IfcExtrudedAreaSolid). Any LAYER2 wall qualifies."""
if element is None or not element.is_a("IfcWall"):
return False
return tool.Model.get_usage_type(element) == "LAYER2"
@classmethod
def is_path_connectable_wall(cls, element: entity_instance) -> bool:
"""An IfcWall that may participate in IfcRelConnectsPathElements joins —
either a LAYER2 parametric wall, or a fillet-corner wall whose body is
hand-built but whose axis still drives path connections.
Distinct from ``is_wall``: that predicate gates parametric edits that
would regenerate the body and flatten a curved fillet. Unjoin / join
gizmo polls and path-connection partner enumeration use this looser
predicate so fillet corners (which have no LAYER2 usage by spec) still
surface their join icons."""
if element is None or not element.is_a("IfcWall"):
return False
if tool.Model.get_usage_type(element) == "LAYER2":
return True
import ifcopenshell.util.element
return bool(ifcopenshell.util.element.get_pset(element, "BBIM_Wall", "IsFilletCorner"))
@classmethod
def is_pipe_segment(cls, element: entity_instance) -> bool:
return element is not None and element.is_a("IfcPipeSegment")
@classmethod
def is_duct_segment(cls, element: entity_instance) -> bool:
return element is not None and element.is_a("IfcDuctSegment")
@classmethod
def build_edit_lifecycle(
cls,
feature_name: str,
mixin: type,
labels: tuple[tuple[str, str], tuple[str, str], tuple[str, str]],
bl_options: Optional[set[str]] = None,
enable_extra_props: Optional[dict[str, Any]] = None,
enable_extra_kwargs: Optional[Callable[[Any], dict[str, Any]]] = None,
module_name: Optional[str] = None,
) -> tuple[type, type, type]:
"""Generate (Enable, Finish, Cancel) operator classes for a parametric type.
``mixin`` provides ``_enable_targets`` / ``_finish_targets`` /
``_cancel_targets`` (i.e. inherits from ``ParametricEditMixinBase`` or
a sibling). ``labels`` is ``((enable_label, enable_desc), …)`` in
Enable / Finish / Cancel order.
``bl_idname`` and the Python class name come from the registry entry —
``feature_name`` MUST already be in ``EDIT_TYPES``, otherwise a typo
produces an unregistered operator. Anchoring bl_idnames to the registry
eliminates the silent-mismatch failure mode where a hand-typed
``bl_idname = "bim.enable_editing_dor"`` produces a class that
``find_for_element`` never resolves to.
``enable_extra_props`` declares extra ``bpy.props.*`` descriptors to
attach to the Enable class only (e.g. array's ``item: IntProperty``
carrying the target layer index across redo). When set,
``enable_extra_kwargs`` must also be supplied: it receives the Enable
operator instance and returns a kwargs dict forwarded to
``_enable_targets`` so the mixin's enable phase sees the extras.
``module_name`` sets ``__module__`` on the generated classes — pass
``__name__`` from the calling feature module so Blender's right-click
→ Edit Source resolves to the feature module rather than the factory
site. Defaults to the factory's module, which is sub-optimal for
debugging but harmless."""
import bonsai.tool as _tool # late import: tool/__init__.py wires this module last
feature = cls.find_by_name(feature_name)
if feature is None:
raise RuntimeError(
f"build_edit_lifecycle: {feature_name!r} not in EDIT_TYPES — add a "
f"ParametricObject entry before declaring its operators"
)
if not feature.supports_build_edit_lifecycle:
raise RuntimeError(
f"build_edit_lifecycle: {feature_name!r} has supports_build_edit_lifecycle=False — "
f"its edit lifecycle is bespoke. Either declare "
f"Enable/Finish/CancelEditing{_camel_case(feature_name)} as direct Operator "
f"subclasses, or flip the flag on the EDIT_TYPES entry if the type does fit "
f"the shared mixin contract."
)
if (enable_extra_props is None) != (enable_extra_kwargs is None):
raise RuntimeError(
f"build_edit_lifecycle({feature_name!r}): enable_extra_props and "
f"enable_extra_kwargs must be supplied together — extras with no "
f"kwargs builder are unreachable, kwargs with no extras have nothing to forward"
)
options = bl_options if bl_options is not None else {"REGISTER", "UNDO"}
base_classes = (mixin, bpy.types.Operator, _tool.Ifc.Operator)
capitalised = _camel_case(feature_name)
def _build(
action: str, bl_idname: str, label: str, desc: str, target_method: str, extras: Optional[dict]
) -> type:
if extras and target_method == "_enable_targets":
assert enable_extra_kwargs is not None
kwargs_builder = enable_extra_kwargs
def _execute(self, context: bpy.types.Context) -> set[str]:
return getattr(self, target_method)(context, **kwargs_builder(self))
else:
def _execute(self, context: bpy.types.Context) -> set[str]:
return getattr(self, target_method)(context)
attrs: dict[str, Any] = {
"bl_idname": bl_idname,
"bl_label": label,
"bl_description": desc,
"bl_options": options,
"_execute": _execute,
}
if module_name is not None:
attrs["__module__"] = module_name
if extras:
# Blender's PropertyGroup machinery reads __annotations__ for bpy.props descriptors.
attrs["__annotations__"] = dict(extras)
return type(f"{action}Editing{capitalised}", base_classes, attrs)
return (
_build("Enable", feature.enable_op, labels[0][0], labels[0][1], "_enable_targets", enable_extra_props),
_build("Finish", feature.finish_op, labels[1][0], labels[1][1], "_finish_targets", None),
_build("Cancel", feature.cancel_op, labels[2][0], labels[2][1], "_cancel_targets", None),
)
_edit_type_names = [entry.name for entry in Parametric.EDIT_TYPES]
if len(set(_edit_type_names)) != len(_edit_type_names):
raise RuntimeError(
f"EDIT_TYPES name collision: {_edit_type_names}. Each name is the primary key "
f"for derived bl_idnames, BIM<Name>Properties attributes, is_<name> predicates, "
f"and the uppercase constant — a duplicate silently shadows the first entry."
)
del _edit_type_names
# Bind every registered ParametricObject as an uppercase class attribute so
# call sites can reference ``tool.Parametric.ROOF`` directly. Renaming a
# registry entry renames the constant; a typo at the call site surfaces as
# AttributeError at module load.
for _entry in Parametric.EDIT_TYPES:
setattr(Parametric, _entry.name.upper(), _entry)
del _entry