Warn on shared-rep parametric edits

A user clicking the pen icon on a typed-product occurrence whose body
representation is mapped from its type would silently mutate every
sibling occurrence's geometry. Add a confirmation dialog at the pen-icon
dispatcher (the single chokepoint every feature routes through) showing
the sibling count, with a session-scoped suppress checkbox.

The check is read-only: tool.Model.get_sibling_occurrence_count wraps
tool.Geometry.get_elements_by_representation against the resolved body
rep and subtracts self + type. A forward-compat AST guard pins the
dispatcher monopoly so any future feature that binds pen_gizmo directly
to a feature-specific enable op fails the test before merge.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-09 17:32:44 +02:00
parent 784f0b1fe2
commit b22687891b
7 changed files with 339 additions and 0 deletions
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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"}
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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 ``<receiver>.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)
)
+85
View File
@@ -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