Refactor bim/parametric_lifecycle — drift triad + Cancel polish

Three changes to the shared Enable/Finish/Cancel mixins:

1. Always-on drift triad on ParametricEditMixinBase. The base now
   provides ``_handle_drift_on_enable`` / ``_handle_drift_on_finish``
   / ``_handle_drift_on_cancel`` classmethods, called from the
   per-mixin ``_enable_one`` / ``_finish_one`` / ``_cancel_one``.
   Pre-edit Blender-side translations commit to IFC on Enable
   (apply_scale=False — only translation/rotation, not the user's
   accidental scale), in-edit drag commits on Finish (apply_scale=True),
   and Cancel restores the committed IFC placement via
   ``restore_or_rebaseline_placement``. Prevents the
   "uncommitted drag disappears on Finish" and "preview snaps back
   on Cancel" UX bugs.

2. ``_ParametricEditMixinBase`` renamed to ``ParametricEditMixinBase``
   (public). Per-feature mixins that need to subclass directly
   (e.g., when neither FeatureModifier nor PathPreserving fits)
   can do so without reaching into a private name.

3. ``_update_modifier_bmesh`` (PathPreserving) renamed to
   ``_restore_viewport_after_cancel``. The old name was inaccurate
   for subclasses that load a different IFC representation on
   Cancel rather than rebuilding a bmesh preview from props.

Plus two polish changes:

* ``_mark_type_thumbnail_dirty`` helper on the base centralises the
  ``ifcopenshell.util.element.get_type`` + thumbnail-mark pattern
  that both mixins repeated inline.
* ``FeatureModifierEditMixin._cancel_one`` and
  ``PathPreservingEditMixin._cancel_one`` wrap the restore in
  ``try/finally`` so ``props.is_editing = False`` flips even on
  partial restore failure. Without this, a Cancel that raised
  mid-restore would leave the user locked out of the edit lifecycle.
* ``PathPreservingEditMixin._finish_one`` / ``_cancel_one`` skip the
  pset commit + viewport rebuild when the draft equals the stored
  pset (no-op Enable→Finish round-trip should not pollute the
  representation list or burn an undo entry).

``FeatureModifierEditMixin._finish_one`` now routes the pset commit
through ``tool.Pset.write_bbim_data`` instead of inlining the
``createIfcText(json.dumps(...))`` + ``ifcopenshell.api.pset.edit_pset``
dance. Two test assertions updated to match.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-05-27 15:50:42 +02:00
committed by Thomas Krijnen
parent ed6530f68b
commit aba9986628
2 changed files with 149 additions and 67 deletions
+141 -61
View File
@@ -18,29 +18,54 @@
#
# This file was generated with the assistance of an AI coding tool.
"""Shared Enable / Finish / Cancel lifecycle mixins for parametric-edit operators.
"""Shared operator mixins for parametric-edit operators.
Two mixins fit the parametric-edit triads in ``bim/module/model/``:
Edit-lifecycle mixins (Enable / Finish / Cancel):
`FeatureModifierEditMixin` — door, window (BBIM_<Type> pset; nested
lining/panel properties; Finish + Cancel route through
``ifcopenshell.api.feature``).
`PathPreservingEditMixin` — railing, roof (path_data preserved across
edit; only general kwargs are user-editable).
`FeatureModifierEditMixin`
Door, Window — BBIM_<Type> pset with nested ``lining_properties`` /
``panel_properties``; Finish calls ``update_<type>_modifier_representation``
via ``ifcopenshell.api.feature``; Cancel restores via ``switch_representation``.
Pattern selection (which approach a new feature should adopt):
Every parametric edit lifecycle commits to one of three patterns. Pick by
answering "does the feature share the Enable→Finish→Cancel shape that
one of the existing mixins already encodes?":
`PathPreservingEditMixin`
Railing, Roof — BBIM_<Type> pset whose ``path_data`` is preserved through
edit (only general kwargs are user-editable); Finish calls
``update_<type>_modifier_bmesh`` / ``update_<type>_modifier_ifc_data``;
Cancel re-reads the pset and rebuilds the bmesh preview.
A. Inherit one of the shared mixins below and route through
`tool.Parametric.build_edit_lifecycle`:
Stair and Wall stay standalone — their lifecycles diverge in ways that don't
fit either mixin without optional escape hatches (Stair has a unique
``update_ifc_stair_props`` post-Finish step + a separate ``get_props_kwargs_for_ifc_export``;
Wall is validation-first, snapshot-driven, no preview regen in operators).
- `FeatureModifierEditMixin` when the feature stores its pset as
`{general fields} + {lining_properties: {...}} + {panel_properties: {...}}`
and Finish must call a per-type `update_<type>_modifier_representation`.
This module sits separately from `bonsai.tool.Parametric` (the registry +
auto-commit) because it imports ``bonsai.tool`` freely, while the registry
itself must stay light — ``tool/blender.py`` consumes the registry at module load."""
- `PathPreservingEditMixin` when the feature's pset carries a
`path_data` field that survives general-kwarg edits untouched, with
a separate Enable/Finish/Cancel lifecycle for path editing itself.
B. Write a per-feature mixin that subclasses `ParametricEditMixinBase`
and provides `_enable_targets` / `_finish_targets` / `_cancel_targets`,
then route through `build_edit_lifecycle`. Pick this when the
feature's pset roundtrip or representation handling diverges from the
shared mixins but the Enable→Finish→Cancel shape still fits.
C. Declare standalone Enable/Finish/Cancel Operator subclasses (no
factory) when the feature's parameter-change logic is sufficiently
unique that even a per-feature mixin would force optional hooks or
dead branches. Such operators MUST call the matrix_world drift
helpers (`tool.Geometry.commit_placement_if_moved` on Enable/Finish,
`tool.Geometry.restore_or_rebaseline_placement` on Cancel) — the
drift contract is enforced uniformly regardless of which pattern the
operators adopt.
The authoritative list of registered parametric types — and which use
`build_edit_lifecycle` vs. standalone operators — lives in
`tool/parametric.py`'s `EDIT_TYPES` and is enforced by the registry
contract tests in `test/bim/test_parametric_registry.py`.
This module hosts operator-side mixins that import ``bonsai.tool`` freely.
The lightweight parametric registry consumed at addon-enable time must stay
free of such imports and lives separately in ``tool/parametric.py``."""
from __future__ import annotations
@@ -48,9 +73,7 @@ import json
from typing import TYPE_CHECKING, ClassVar
import bpy
import ifcopenshell.api.pset
import ifcopenshell.util.element
import ifcopenshell.util.representation
import bonsai.core.geometry
import bonsai.tool as tool
@@ -59,8 +82,8 @@ if TYPE_CHECKING:
from ifcopenshell import entity_instance
class _ParametricEditMixinBase:
"""Common scaffolding for parametric edit-triad mixins.
class ParametricEditMixinBase:
"""Common scaffolding for parametric edit-lifecycle mixins.
Each per-type subclass provides four hooks:
@@ -69,6 +92,11 @@ class _ParametricEditMixinBase:
``_get_props(obj)``: PropertyGroup accessor
``_iter_targets(context)``: list of objects to act on (default: ``[active_object]``)
Drift handling is built in: pre-edit matrix_world drift commits to IFC on
Enable, in-edit drag commits on Finish, and Cancel restores the committed
IFC placement. This prevents an uncommitted drag from disappearing on
Finish or snapping back on Cancel.
Operator subclasses call one of ``_enable_targets`` / ``_finish_targets`` /
``_cancel_targets`` from their ``_execute`` method."""
@@ -99,8 +127,29 @@ class _ParametricEditMixinBase:
return None
return element, cls._get_props(obj)
@classmethod
def _handle_drift_on_enable(cls, obj: bpy.types.Object) -> None:
tool.Geometry.commit_placement_if_moved(obj, apply_scale=False)
class FeatureModifierEditMixin(_ParametricEditMixinBase):
@classmethod
def _handle_drift_on_finish(cls, obj: bpy.types.Object) -> None:
tool.Geometry.commit_placement_if_moved(obj)
@classmethod
def _handle_drift_on_cancel(cls, obj: bpy.types.Object, element: entity_instance) -> None:
tool.Geometry.restore_or_rebaseline_placement(obj, element)
@classmethod
def _mark_type_thumbnail_dirty(cls, element: entity_instance) -> None:
"""Mark the element's type's preview thumbnail for refresh so the
property-panel preview reflects post-edit geometry. No-op for
occurrences without a backing type."""
element_type = ifcopenshell.util.element.get_type(element)
if element_type:
tool.Model.mark_thumbnail_for_update(element_type)
class FeatureModifierEditMixin(ParametricEditMixinBase):
"""Lifecycle for door- and window-style parametric modifier operators.
Enable:
@@ -121,10 +170,7 @@ class FeatureModifierEditMixin(_ParametricEditMixinBase):
@classmethod
def _update_modifier_representation(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
"""Hook: call the per-type ``update_<type>_modifier_representation``.
Door's helper takes ``obj``; window's takes ``context``. The hook lets
each subclass forward to its existing helper without unifying signatures."""
"""Hook: call the per-type ``update_<type>_modifier_representation``."""
raise NotImplementedError
@classmethod
@@ -133,6 +179,7 @@ class FeatureModifierEditMixin(_ParametricEditMixinBase):
if resolved is None:
return
element, props = resolved
cls._handle_drift_on_enable(obj)
data = json.loads(ifcopenshell.util.element.get_pset(element, cls.pset_name, "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
@@ -152,12 +199,9 @@ class FeatureModifierEditMixin(_ParametricEditMixinBase):
data["lining_properties"] = props.get_lining_kwargs(convert_to_project_units=True)
data["panel_properties"] = props.get_panel_kwargs(convert_to_project_units=True)
cls._update_modifier_representation(obj, context)
element_type = ifcopenshell.util.element.get_type(element)
if element_type:
tool.Model.mark_thumbnail_for_update(element_type)
pset = tool.Pset.get_element_pset(element, cls.pset_name)
data_text = tool.Ifc.get().createIfcText(json.dumps(data, default=list))
ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": data_text})
cls._mark_type_thumbnail_dirty(element)
tool.Pset.write_bbim_data(element, cls.pset_name, data)
cls._handle_drift_on_finish(obj)
# Set only on success: if any IFC op above raised, the user's draft survives for retry.
props.is_editing = False
@@ -167,13 +211,21 @@ class FeatureModifierEditMixin(_ParametricEditMixinBase):
if resolved is None:
return
element, props = resolved
data = json.loads(ifcopenshell.util.element.get_pset(element, cls.pset_name, "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
props.set_props_kwargs_from_ifc_data(data)
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
bonsai.core.geometry.switch_representation(tool.Ifc, tool.Geometry, obj=obj, representation=body)
props.is_editing = False
# Cancel must always clear is_editing — leaving it True after a
# restore-failure would block the user from re-entering edit mode and
# the next save's stale-flag heal would silently roll back the
# cancellation. Wrap the restore in try/finally so the flag flips
# even on partial failure.
try:
data = json.loads(ifcopenshell.util.element.get_pset(element, cls.pset_name, "Data"))
data.update(data.pop("lining_properties"))
data.update(data.pop("panel_properties"))
props.set_props_kwargs_from_ifc_data(data)
body = tool.Geometry.get_body_representation(element)
bonsai.core.geometry.switch_representation(tool.Ifc, tool.Geometry, obj=obj, representation=body)
cls._handle_drift_on_cancel(obj, element)
finally:
props.is_editing = False
def _enable_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
@@ -191,19 +243,20 @@ class FeatureModifierEditMixin(_ParametricEditMixinBase):
return {"FINISHED"}
class PathPreservingEditMixin(_ParametricEditMixinBase):
class PathPreservingEditMixin(ParametricEditMixinBase):
"""Lifecycle for railing- and roof-style parametric modifier operators.
Distinctive: ``path_data`` is part of the BBIM_<Type> pset but is **not**
user-editable through this triad — it survives the edit untouched, only
user-editable through this lifecycle — it survives the edit untouched, only
general kwargs are diffed. (Path editing has its own separate operator
pair, ``Enable/Finish/CancelEditing<Type>Path``, out of scope here.)
Enable:
Fetch pset data via ``tool.Model.get_modeling_bbim_pset_data`` → set
draft props → ``is_editing = True``. The subclass post-load hook
lets railing JSON-serialise ``path_data`` for the PropertyGroup
string field.
can reshape the dict to fit the PropertyGroup's storage layout
(e.g., pre-serialise a structured pset value to JSON for a
``StringProperty`` field).
Finish:
Read fresh pset → keep ``path_data`` → gather ``general`` kwargs
@@ -213,16 +266,18 @@ class PathPreservingEditMixin(_ParametricEditMixinBase):
Cancel:
Read fresh pset → restore draft props → call
``_update_modifier_bmesh`` (per-type bmesh preview) →
``is_editing = False``."""
``_restore_viewport_after_cancel`` (per-type viewport restore — typically
rebuilds the bmesh preview, but subclasses may load a different
representation entirely) → ``is_editing = False``."""
@classmethod
def _post_load_data(cls, data: dict) -> dict:
"""Hook: optionally transform the pset data dict after loading and before
passing to ``set_props_kwargs_from_ifc_data``. Default: pass-through.
Railing overrides to JSON-serialise ``path_data`` (its
BIMRailingProperties.path_data is a ``StringProperty`` holding JSON)."""
Override when the PropertyGroup stores a structured pset field as a
serialised primitive — e.g., a list/dict value mapped onto a
``StringProperty`` requires JSON-encoding here."""
return data
@classmethod
@@ -238,9 +293,12 @@ class PathPreservingEditMixin(_ParametricEditMixinBase):
raise NotImplementedError
@classmethod
def _update_modifier_bmesh(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
"""Hook: per-type ``update_<type>_modifier_bmesh`` — rebuilds the
bmesh preview to match the current draft props (used by Cancel)."""
def _restore_viewport_after_cancel(cls, obj: bpy.types.Object, context: bpy.types.Context) -> None:
"""Hook: restore the viewport mesh to match the just-restored draft props.
Most subclasses rebuild a bmesh preview from props. Subclasses whose
committed IFC representation diverges from the preview may switch
the mesh back to the committed representation instead."""
raise NotImplementedError
@classmethod
@@ -249,6 +307,7 @@ class PathPreservingEditMixin(_ParametricEditMixinBase):
if resolved is None:
return
_element, props = resolved
cls._handle_drift_on_enable(obj)
data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)["data_dict"]
data = cls._post_load_data(data)
props.set_props_kwargs_from_ifc_data(data)
@@ -261,11 +320,18 @@ class PathPreservingEditMixin(_ParametricEditMixinBase):
return
element, props = resolved
pset_data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)
path_data = pset_data["data_dict"]["path_data"]
stored = pset_data["data_dict"]
data = props.get_general_kwargs(convert_to_project_units=True)
data["path_data"] = path_data
cls._update_pset(element, data)
cls._update_modifier_ifc_data(obj, context)
data["path_data"] = stored["path_data"]
# Skip the pset commit when the draft is identical to the stored pset:
# an Enable → Finish-without-changes cycle should not pollute the
# representation list or burn an undo entry. Drift commit still runs
# unconditionally — matrix_world drift is independent of pset content.
if data != stored:
cls._update_pset(element, data)
cls._update_modifier_ifc_data(obj, context)
cls._mark_type_thumbnail_dirty(element)
cls._handle_drift_on_finish(obj)
# Set only on success: if any IFC op above raised, the user's draft survives for retry.
props.is_editing = False
@@ -274,12 +340,26 @@ class PathPreservingEditMixin(_ParametricEditMixinBase):
resolved = cls._resolve(obj)
if resolved is None:
return
_element, props = resolved
data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)["data_dict"]
data = cls._post_load_data(data)
props.set_props_kwargs_from_ifc_data(data)
cls._update_modifier_bmesh(obj, context)
props.is_editing = False
element, props = resolved
try:
pset_data = tool.Model.get_modeling_bbim_pset_data(obj, cls.pset_name)
stored = pset_data["data_dict"]
draft = props.get_general_kwargs(convert_to_project_units=True)
draft["path_data"] = stored["path_data"]
nothing_changed = draft == stored
data = cls._post_load_data(stored)
props.set_props_kwargs_from_ifc_data(data)
# Skip the viewport rebuild on a no-op cancel: the mesh on screen is
# still the committed representation, and the per-type viewport-restore
# hook may be expensive (some subclasses reload a high-poly IFC
# representation rather than rebuild a preview mesh).
if not nothing_changed:
cls._restore_viewport_after_cancel(obj, context)
cls._handle_drift_on_cancel(obj, element)
finally:
# Always clear the flag — see ``FeatureModifierEditMixin._cancel_one``
# for the rationale.
props.is_editing = False
def _enable_targets(self, context: bpy.types.Context) -> set[str]:
for obj in self._iter_targets(context):
@@ -198,10 +198,12 @@ def test_feature_modifier_finish_one_clears_is_editing_and_writes_pset(patched_t
assert props.is_editing is False
assert obj in cls.representations_called
# edit_pset is called exactly once; properties key is "Data" wrapping JSON.
patched_tool_and_ifc["ifc"].api.pset.edit_pset.assert_called_once()
kwargs = patched_tool_and_ifc["ifc"].api.pset.edit_pset.call_args.kwargs
assert "properties" in kwargs and "Data" in kwargs["properties"]
# tool.Pset.write_bbim_data is called exactly once with the merged dict.
patched_tool_and_ifc["tool"].Pset.write_bbim_data.assert_called_once()
call_args = patched_tool_and_ifc["tool"].Pset.write_bbim_data.call_args
assert call_args.args[1] == "BBIM_Door" # pset_name positional arg
written_data = call_args.args[2]
assert "lining_properties" in written_data and "panel_properties" in written_data
def test_feature_modifier_finish_one_exception_leaves_draft_in_progress(patched_tool_and_ifc):
@@ -302,7 +304,7 @@ def _path_mixin_cls(match=True):
cls.ifc_data_updates.append(obj)
@classmethod
def _update_modifier_bmesh(cls, obj, context):
def _restore_viewport_after_cancel(cls, obj, context):
cls.bmesh_updates.append(obj)
return _TestPathMixin
@@ -344,7 +346,7 @@ def test_path_preserving_finish_one_preserves_path_data_and_clears_is_editing(pa
assert obj in cls.ifc_data_updates
def test_path_preserving_cancel_one_calls_update_modifier_bmesh(patched_tool_and_ifc):
def test_path_preserving_cancel_one_calls_restore_viewport_after_cancel(patched_tool_and_ifc):
props = _FakePathProps()
props.is_editing = True
obj = _make_obj(props)