diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py
index 1b6d57f95f..64ecafdf7b 100644
--- a/src/bonsai/bonsai/bim/module/model/__init__.py
+++ b/src/bonsai/bonsai/bim/module/model/__init__.py
@@ -192,6 +192,7 @@ classes = (
prop.BIMBendPreviewProperties,
prop.BIMWallFilletPreviewProperties,
prop.BIMPreviewProperties,
+ prop.BIMParametricEditDialogPrefs,
ui.BIM_PT_array,
ui.BIM_PT_stair,
ui.BIM_PT_wall,
@@ -349,6 +350,9 @@ def register():
type=prop.BIMExternalParametricGeometryProperties
)
bpy.types.Scene.BIMPreviewProperties = bpy.props.PointerProperty(type=prop.BIMPreviewProperties)
+ bpy.types.WindowManager.BIMParametricEditDialogPrefs = bpy.props.PointerProperty(
+ type=prop.BIMParametricEditDialogPrefs
+ )
bpy.types.VIEW3D_MT_add.prepend(ui.add_menu)
bpy.app.handlers.load_post.append(handler.load_post)
@@ -374,6 +378,7 @@ def unregister():
tool.Parametric.unregister_object_properties()
del bpy.types.Object.BIMExternalParametricGeometryProperties
del bpy.types.Scene.BIMPreviewProperties
+ del bpy.types.WindowManager.BIMParametricEditDialogPrefs
bpy.app.handlers.load_post.remove(handler.load_post)
bpy.types.VIEW3D_MT_add.remove(ui.add_menu)
diff --git a/src/bonsai/bonsai/bim/module/model/array.py b/src/bonsai/bonsai/bim/module/model/array.py
index 40c6e7cd61..bef8f6fe11 100644
--- a/src/bonsai/bonsai/bim/module/model/array.py
+++ b/src/bonsai/bonsai/bim/module/model/array.py
@@ -15,6 +15,8 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see .
+#
+# This file was modified with the assistance of an AI coding tool.
import json
from typing import ClassVar
@@ -881,6 +883,36 @@ class EnableEditingParametric(bpy.types.Operator):
default="",
description="Operator bl_idname to invoke (e.g., 'bim.enable_editing_door').",
)
+ sibling_count: bpy.props.IntProperty(default=0, options={"HIDDEN"})
+
+ @staticmethod
+ def should_show_shared_rep_dialog(*, suppress: bool, has_entity: bool, sibling_count: int) -> bool:
+ """Pure decision for the pre-edit warning. Returns ``True`` only when the
+ edit will silently mutate other elements' geometry AND the user has not
+ opted out of the warning for this session."""
+ if suppress or not has_entity:
+ return False
+ return sibling_count > 0
+
+ def invoke(self, context, event):
+ prefs = getattr(context.window_manager, "BIMParametricEditDialogPrefs", None)
+ suppress = bool(prefs and prefs.suppress_shared_rep_warning)
+ obj = context.active_object
+ element = tool.Ifc.get_entity(obj) if obj else None
+ self.sibling_count = tool.Model.get_sibling_occurrence_count(element) if element is not None else 0
+ if self.should_show_shared_rep_dialog(
+ suppress=suppress, has_entity=element is not None, sibling_count=self.sibling_count
+ ):
+ return context.window_manager.invoke_props_dialog(self, width=400)
+ return self.execute(context)
+
+ def draw(self, context):
+ layout = self.layout
+ layout.label(text="Shared geometry", icon="ERROR")
+ layout.label(text=f"Geometry is shared with {self.sibling_count} other element(s).")
+ layout.label(text="Edits will affect them too.")
+ prefs = context.window_manager.BIMParametricEditDialogPrefs
+ layout.prop(prefs, "suppress_shared_rep_warning", text="Don't show this again for this session")
def execute(self, context):
# Malformed ``feature_enable_op`` (missing dot) would otherwise crash
diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py
index c91ae1f322..b9709c067a 100644
--- a/src/bonsai/bonsai/bim/module/model/prop.py
+++ b/src/bonsai/bonsai/bim/module/model/prop.py
@@ -2149,3 +2149,23 @@ class BIMPreviewProperties(PropertyGroup):
if TYPE_CHECKING:
bend: BIMBendPreviewProperties
wall_fillet: BIMWallFilletPreviewProperties
+
+
+class BIMParametricEditDialogPrefs(PropertyGroup):
+ """Session-scoped flag for the parametric-edit pen-icon dispatcher.
+
+ Attached to ``WindowManager`` so the state lives for one Blender session
+ and resets on restart — the right scope for "don't show this again for
+ this session" toggles."""
+
+ suppress_shared_rep_warning: bpy.props.BoolProperty(
+ name="Suppress shared-representation warning",
+ description=(
+ "When true, the pen-icon dispatcher skips the shared-geometry "
+ "confirmation dialog. Resets on Blender restart."
+ ),
+ default=False,
+ )
+
+ if TYPE_CHECKING:
+ suppress_shared_rep_warning: bool
diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py
index b012d933e9..d59f653036 100644
--- a/src/bonsai/bonsai/tool/model.py
+++ b/src/bonsai/bonsai/tool/model.py
@@ -372,6 +372,28 @@ class Model(bonsai.core.tool.Model):
else:
break
+ @classmethod
+ def get_sibling_occurrence_count(cls, element: ifcopenshell.entity_instance) -> int:
+ """Number of *other* products sharing this element's body representation.
+
+ Returns the count of products bound to the same resolved body rep, minus
+ ``element`` itself and minus its type (if any). Zero when the element has
+ no body rep, no resolved rep, or no siblings. A non-zero result means a
+ parametric edit on ``element`` will silently mutate other instances'
+ geometry."""
+ body_rep = tool.Geometry.get_body_representation(element)
+ if not body_rep:
+ return 0
+ resolved = ifcopenshell.util.representation.resolve_representation(body_rep)
+ if not resolved:
+ return 0
+ elements = tool.Geometry.get_elements_by_representation(resolved)
+ elements.discard(element)
+ element_type = ifcopenshell.util.element.get_type(element)
+ if element_type is not None:
+ elements.discard(element_type)
+ return len(elements)
+
unit_scale: float
vertices: list[Vector]
edges: list[Sequence[int]]
diff --git a/src/bonsai/test/bim/module/model/test_enable_editing_parametric.py b/src/bonsai/test/bim/module/model/test_enable_editing_parametric.py
new file mode 100644
index 0000000000..299c6565ad
--- /dev/null
+++ b/src/bonsai/test/bim/module/model/test_enable_editing_parametric.py
@@ -0,0 +1,84 @@
+# 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 .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+"""Tests for the universal pen-icon dispatcher's pre-edit warning path.
+
+The dispatcher gates the parametric-edit triad behind a confirmation dialog
+whenever the active element's body representation is shared with sibling
+occurrences (typed product + mapped representation). It is the single
+chokepoint every feature's pen icon routes through, so the warning applies
+to walls, doors, windows, stairs, roofs, and any future feature uniformly.
+
+These tests exercise:
+
+- the pure ``should_show_shared_rep_dialog`` decision (every branch); and
+- one end-to-end invocation through ``bpy.ops`` to pin the wiring between
+ the decision and ``invoke_props_dialog``."""
+
+import types
+
+import bpy
+import pytest
+
+from bonsai.bim.module.model.array import EnableEditingParametric
+
+pytestmark = pytest.mark.model
+
+
+@pytest.fixture(autouse=True)
+def _require_real_bpy():
+ if not isinstance(bpy, types.ModuleType) or hasattr(bpy, "_mock_name"):
+ pytest.skip("requires real Blender (bpy is mocked or absent)")
+
+
+class TestShouldShowSharedRepDialog:
+ """Exhaustive truth table for the pre-edit-warning decision. Keeping this
+ pure (no bpy, no operator instance) means a future change to the dispatch
+ wiring can't silently flip a branch — the decision is independently pinned."""
+
+ decide = staticmethod(EnableEditingParametric.should_show_shared_rep_dialog)
+
+ def test_shared_rep_with_warning_enabled_shows_dialog(self):
+ assert self.decide(suppress=False, has_entity=True, sibling_count=3) is True
+
+ def test_unique_rep_skips_dialog(self):
+ assert self.decide(suppress=False, has_entity=True, sibling_count=0) is False
+
+ def test_session_suppress_overrides_shared_rep(self):
+ assert self.decide(suppress=True, has_entity=True, sibling_count=5) is False
+
+ def test_no_entity_skips_dialog_even_when_count_positive(self):
+ assert self.decide(suppress=False, has_entity=False, sibling_count=3) is False
+
+ def test_zero_siblings_skips_dialog_regardless_of_suppress(self):
+ assert self.decide(suppress=False, has_entity=True, sibling_count=0) is False
+ assert self.decide(suppress=True, has_entity=True, sibling_count=0) is False
+
+
+def test_dispatcher_falls_through_to_feature_enable_op_when_no_active_object():
+ """End-to-end smoke: with no active object the dispatcher short-circuits to
+ its ``execute`` body, which CANCELs on an empty ``feature_enable_op``."""
+ bpy.context.window_manager.BIMParametricEditDialogPrefs.suppress_shared_rep_warning = False
+ try:
+ with bpy.context.temp_override(active_object=None):
+ result = bpy.ops.bim.enable_editing_parametric("INVOKE_DEFAULT", feature_enable_op="")
+ finally:
+ bpy.context.window_manager.BIMParametricEditDialogPrefs.suppress_shared_rep_warning = False
+ assert result == {"CANCELLED"}
diff --git a/src/bonsai/test/bim/test_pen_dispatcher_forward_compat.py b/src/bonsai/test/bim/test_pen_dispatcher_forward_compat.py
new file mode 100644
index 0000000000..6ebf78f239
--- /dev/null
+++ b/src/bonsai/test/bim/test_pen_dispatcher_forward_compat.py
@@ -0,0 +1,91 @@
+# 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 .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+"""Forward-compat AST contract for the pen-icon dispatcher monopoly.
+
+Every parametric gizmo group's pen icon must bind to the universal
+``bim.enable_editing_parametric`` dispatcher rather than the feature's own
+enable operator. The dispatcher is the single chokepoint where pre-edit
+checks (shared-representation warning, future safety gates) run; a feature
+that binds directly bypasses every such check silently."""
+
+import ast
+from pathlib import Path
+
+import pytest
+
+pytestmark = pytest.mark.drawing
+
+
+BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai"
+BIM_DIR = BONSAI_ROOT / "bim"
+DISPATCHER_IDNAME = "bim.enable_editing_parametric"
+
+
+def _iter_pen_gizmo_target_set_operator_calls(tree: ast.Module):
+ """Yield each ``ast.Call`` matching ``.pen_gizmo.target_set_operator(...)``.
+ Receiver is any attribute access (``self.pen_gizmo``, ``group.pen_gizmo``, etc.)."""
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Call):
+ continue
+ func = node.func
+ if not isinstance(func, ast.Attribute) or func.attr != "target_set_operator":
+ continue
+ receiver = func.value
+ if not isinstance(receiver, ast.Attribute) or receiver.attr != "pen_gizmo":
+ continue
+ yield node
+
+
+def test_every_pen_gizmo_binding_routes_through_the_universal_dispatcher() -> None:
+ violations: list[str] = []
+ found_any = False
+ for path in BIM_DIR.rglob("*.py"):
+ try:
+ tree = ast.parse(path.read_text(encoding="utf-8"))
+ except SyntaxError:
+ continue
+ for call in _iter_pen_gizmo_target_set_operator_calls(tree):
+ found_any = True
+ if not call.args:
+ violations.append(f"{path}:{call.lineno} pen_gizmo.target_set_operator() called with no args")
+ continue
+ first_arg = call.args[0]
+ if not isinstance(first_arg, ast.Constant) or not isinstance(first_arg.value, str):
+ violations.append(
+ f"{path}:{call.lineno} pen_gizmo.target_set_operator() first arg is not a string literal"
+ )
+ continue
+ if first_arg.value != DISPATCHER_IDNAME:
+ violations.append(
+ f"{path}:{call.lineno} pen_gizmo.target_set_operator({first_arg.value!r}) "
+ f"bypasses the universal dispatcher"
+ )
+
+ assert found_any, (
+ "No pen_gizmo.target_set_operator(...) calls found anywhere under bim/. "
+ "Either the gizmo-binding pattern has been refactored away (this test "
+ "needs updating) or the search root is wrong."
+ )
+ assert not violations, (
+ "Pen-icon bindings must route through the universal dispatcher "
+ f"({DISPATCHER_IDNAME!r}) so the shared-representation warning and any "
+ "future pre-edit checks apply to every feature. Violations:\n " + "\n ".join(violations)
+ )
diff --git a/src/bonsai/test/tool/test_model.py b/src/bonsai/test/tool/test_model.py
index 6798515962..fd9e3dfad8 100644
--- a/src/bonsai/test/tool/test_model.py
+++ b/src/bonsai/test/tool/test_model.py
@@ -934,3 +934,88 @@ class TestOffsetWall(NewFile):
usage.DirectionSense = "NEGATIVE"
subject.offset_wall(obj, "EXTERIOR")
assert usage.OffsetFromReferenceLine == 100
+
+
+class TestGetSiblingOccurrenceCount(NewFile):
+ """The pen-icon dispatcher's pre-edit warning depends on this count: zero
+ means the edit is safe (unique geometry), non-zero means the edit will
+ silently mutate other instances sharing the same resolved body rep."""
+
+ def _make_body_subcontext(self, ifc: ifcopenshell.file) -> ifcopenshell.entity_instance:
+ import ifcopenshell.api.context
+
+ ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject", name="Project")
+ parent = ifcopenshell.api.context.add_context(ifc, context_type="Model")
+ return ifcopenshell.api.context.add_context(
+ ifc,
+ context_type="Model",
+ context_identifier="Body",
+ target_view="MODEL_VIEW",
+ parent=parent,
+ )
+
+ def _create_wall_with_body_rep(
+ self,
+ ifc: ifcopenshell.file,
+ body_subcontext: ifcopenshell.entity_instance,
+ name: str = "Wall",
+ ) -> ifcopenshell.entity_instance:
+ wall = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall", name=name)
+ rep = ifc.createIfcShapeRepresentation(
+ ContextOfItems=body_subcontext,
+ RepresentationIdentifier="Body",
+ RepresentationType="SweptSolid",
+ Items=[ifc.createIfcExtrudedAreaSolid()],
+ )
+ ifcopenshell.api.geometry.assign_representation(ifc, product=wall, representation=rep)
+ return wall
+
+ def test_returns_zero_when_element_has_no_body_representation(self):
+ ifc = ifcopenshell.file()
+ tool.Ifc.set(ifc)
+ wall = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall")
+ assert subject.get_sibling_occurrence_count(wall) == 0
+
+ def test_returns_zero_when_element_has_unique_body_representation(self):
+ ifc = ifcopenshell.file()
+ tool.Ifc.set(ifc)
+ body = self._make_body_subcontext(ifc)
+ wall = self._create_wall_with_body_rep(ifc, body)
+ assert subject.get_sibling_occurrence_count(wall) == 0
+
+ def test_returns_sibling_count_excluding_self_and_type(self):
+ ifc = ifcopenshell.file()
+ tool.Ifc.set(ifc)
+ body = self._make_body_subcontext(ifc)
+ wall_type = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWallType", name="WAL01")
+ type_rep = ifc.createIfcShapeRepresentation(
+ ContextOfItems=body,
+ RepresentationIdentifier="Body",
+ RepresentationType="SweptSolid",
+ Items=[ifc.createIfcExtrudedAreaSolid()],
+ )
+ ifcopenshell.api.geometry.assign_representation(ifc, product=wall_type, representation=type_rep)
+
+ occurrences = [ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall", name=f"Wall{i}") for i in range(3)]
+ ifcopenshell.api.type.assign_type(ifc, related_objects=occurrences, relating_type=wall_type)
+
+ assert subject.get_sibling_occurrence_count(occurrences[0]) == 2
+ assert subject.get_sibling_occurrence_count(occurrences[1]) == 2
+ assert subject.get_sibling_occurrence_count(occurrences[2]) == 2
+
+ def test_type_with_occurrences_reports_its_occurrence_count(self):
+ ifc = ifcopenshell.file()
+ tool.Ifc.set(ifc)
+ body = self._make_body_subcontext(ifc)
+ wall_type = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWallType", name="WAL01")
+ type_rep = ifc.createIfcShapeRepresentation(
+ ContextOfItems=body,
+ RepresentationIdentifier="Body",
+ RepresentationType="SweptSolid",
+ Items=[ifc.createIfcExtrudedAreaSolid()],
+ )
+ ifcopenshell.api.geometry.assign_representation(ifc, product=wall_type, representation=type_rep)
+ occurrences = [ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall", name=f"Wall{i}") for i in range(2)]
+ ifcopenshell.api.type.assign_type(ifc, related_objects=occurrences, relating_type=wall_type)
+
+ assert subject.get_sibling_occurrence_count(wall_type) == 2