OverrideDelete.track_aggregates() snapshotted live
entity_instance wrappers before the deletion loop. When the
aggregate itself was also selected for deletion,
delete_empty_aggregates() later called .id() on a wrapper whose
backing native entity had already been freed by remove_product,
segfaulting instead of raising a catchable Python exception.
Store aggregate ids instead of wrappers, and re-resolve each one
through tool.Ifc.get_entity_by_id() after the delete loop, which
safely returns None for ids that no longer exist.
Fixes IfcOpenShell#9299.
Fixes #9299.
Deleting an IFC aggregate together with its parts in one bim.override_object_delete batch takes Blender down with a native segfault, losing unsaved work. It is a C++ fault in the binding layer, not a Python exception, so nothing on the Bonsai side can catch it.
Root cause
OverrideDelete.track_aggregates() collects live ifcopenshell.entity_instance wrappers before the deletion loop runs. When the aggregate is itself part of the selection, remove_product frees its backing C++ entity during that loop. delete_empty_aggregates() then calls .id() on the now stale wrapper.
The existing guard could never work:
entity_instance.id() forwards directly to self.wrapped_data.id(), a raw C++ call with no liveness check, so the dangling dereference is a segfault rather than a Python exception.
Fix
Store aggregate ids instead of wrappers, and re-resolve each id through tool.Ifc.get_entity_by_id(), which wraps file.by_id() in except RuntimeError and returns None for a removed entity. Its docstring already describes exactly this use: "useful to check whether entity_id is still exists in IFC".
This is the direction the reporter proposed, and it fixes the problem at the point where the assumption is wrong, which is retaining a handle across a mutation, rather than adding another guard that cannot fire.
Verification
Reproduced live in Blender 5.2.0 using the reporter's script, in an isolated profile against ifcopenshell 0.8.5.
Before: exit 139, crash log backtrace matching the issue exactly (_wrap_entity_instance_id, then entity_instance.py:442 id, then delete_empty_aggregates at operator.py:1009, then _execute at 955).
After: OP RESULT: {'FINISHED'} and the IFC graph empty, with 0 remaining stairs, flights and slabs.
A scenario covering aggregate plus part deletion is added to src/bonsai/test/bim/feature/geometry.feature, alongside the existing override delete scenarios. Scope honesty: that scenario was validated through an equivalent isolated Blender harness, red before and green after, but it has not been run through the project's pytest-blender CI harness locally, and a pure Python test cannot catch a native crash in any case.
black and ruff clean on the changed file, both before and after.
Related, not changed here
BIM_OT_aggregate_unassign_object._execute in bim/module/aggregate/operator.py uses the same pattern, collecting live wrappers in a first pass and calling .is_a(), .id(), get_parts() and remove_product() on them in a second. It does not crash today because the first pass only unassigns, but it is the same fragile shape. Left alone to keep this PR to one bug.
Separately, it is worth considering whether entity_instance accessors should raise a catchable exception when the backing entity has been removed. That would close this class of bug rather than this instance of it, but it is a core decision rather than a Bonsai one.
Produced with AI assistance.