Hide wall topology gizmos on array children

Wall topology mutations (merge / join / extend-to-wall / unjoin /
fillet) applied to a Bonsai array child are silently overwritten by
the next ``regenerate_array``; merge also orphans a GUID listed in
the parent's ``BBIM_Array.Data``. Add a central
``tool.Blender.Modifier.any_selected_is_array_child`` predicate and
gate the five wall topology gizmo groups plus the six bound operators
behind it. Operator gating is defence in depth against keymap / F3
invocation paths that bypass the gizmo.

The base ``_wall_gizmo_poll_gate`` keeps its loose two-check shape
(viewport gizmos + no preview). A new
``_wall_topology_gizmo_poll_gate`` wraps it with the array-child
filter and is what the topology gizmos use. Host-opening gizmos
deliberately stay on the loose gate: openings authored on a child
are preserved through ``regenerate_array`` and track with the
replicated instance.

A forward-compat AST guard walks wall.py for ``GizmoGroup`` subclasses
and asserts each routes its poll through the tighter gate or the
central predicate, with an allow-list for the parametric-edit and
preview-owner exceptions. New wall topology gizmos inherit the
contract by construction.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-09 16:50:04 +02:00
parent 17951427fe
commit e0ceda6856
4 changed files with 438 additions and 5 deletions
+44 -5
View File
@@ -77,6 +77,8 @@ _FILLET_DEFAULT_RADIUS_M = 0.5 # Fallback when the leg-fraction heuristic canno
_FILLET_DEFAULT_LEG_FRACTION = 0.25 # Quarter of the shorter available leg — visible without overrunning either wall.
_FILLET_MIN_RADIUS_M = 0.001 # Lower bound — anything smaller renders as a single pixel at common viewport scales.
_ARRAY_CHILD_POLL_MESSAGE = "Selection includes an array child; operate on the array parent instead."
def _wall_gizmo_poll_gate(context: bpy.types.Context) -> bool:
"""Common pre-flight gate every wall gizmo group's ``poll`` runs first:
@@ -91,6 +93,21 @@ def _wall_gizmo_poll_gate(context: bpy.types.Context) -> bool:
return True
def _wall_topology_gizmo_poll_gate(context: bpy.types.Context) -> bool:
"""Tighter gate for wall topology gizmos (merge / join / extend / unjoin
/ fillet): base ``_wall_gizmo_poll_gate`` plus an array-child filter.
Array children are managed replicas — any topology mutation is wiped by
the next ``regenerate_array``, and ``merge`` would orphan a GUID listed
in the parent's ``BBIM_Array.Data``. Host-opening gizmos (add / toggle)
deliberately stay on the base gate so openings remain authorable on
children, which the array regen pipeline preserves."""
if not _wall_gizmo_poll_gate(context):
return False
if tool.Blender.Modifier.any_selected_is_array_child():
return False
return True
def _wall_has_openings(gz_group: bpy.types.GizmoGroup) -> bool:
"""``visible_when`` predicate for the toggle_openings idle slot. Returns
True iff the active object's IFC element exposes a non-empty HasOpenings
@@ -244,6 +261,9 @@ class UnjoinWalls(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Oper
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
if tool.Blender.Modifier.any_selected_is_array_child():
cls.poll_message_set(_ARRAY_CHILD_POLL_MESSAGE)
return False
return True
def _perform(self, context):
@@ -270,6 +290,9 @@ class UnjoinWallPathConnection(_CommitWallDraftsFirstMixin, bpy.types.Operator,
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
if tool.Blender.Modifier.any_selected_is_array_child():
cls.poll_message_set(_ARRAY_CHILD_POLL_MESSAGE)
return False
return True
def _perform(self, context):
@@ -368,6 +391,13 @@ class ExtendWallsToWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.If
bl_description = "Extend and trim selected walls to another wall"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if tool.Blender.Modifier.any_selected_is_array_child():
cls.poll_message_set(_ARRAY_CHILD_POLL_MESSAGE)
return False
return True
def _perform(self, context):
target_obj = None
objs = []
@@ -594,6 +624,9 @@ class SplitWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operat
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
if tool.Blender.Modifier.any_selected_is_array_child():
cls.poll_message_set(_ARRAY_CHILD_POLL_MESSAGE)
return False
return True
def _perform(self, context):
@@ -622,6 +655,9 @@ class MergeWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operat
if len(mesh_objects) != 2:
cls.poll_message_set("Please select exactly two mesh IFC objects.")
return False
if tool.Blender.Modifier.any_selected_is_array_child():
cls.poll_message_set(_ARRAY_CHILD_POLL_MESSAGE)
return False
return True
def _perform(self, context):
@@ -3496,7 +3532,7 @@ class GizmoWallExtendVertically(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not _wall_gizmo_poll_gate(context):
if not _wall_topology_gizmo_poll_gate(context):
return False
selected = tool.Blender.get_selected_objects()
if len(selected) != 2:
@@ -3577,7 +3613,7 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not _wall_gizmo_poll_gate(context):
if not _wall_topology_gizmo_poll_gate(context):
return False
selected = tool.Blender.get_selected_objects()
if len(selected) != 2:
@@ -3784,7 +3820,7 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not _wall_gizmo_poll_gate(context):
if not _wall_topology_gizmo_poll_gate(context):
return False
active = tool.Blender.get_active_object(is_selected=True)
if active is None:
@@ -4150,7 +4186,7 @@ class GizmoWallFilletReedit(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not _wall_gizmo_poll_gate(context):
if not _wall_topology_gizmo_poll_gate(context):
return False
active = tool.Blender.get_active_object(is_selected=True)
if active is None:
@@ -4218,7 +4254,7 @@ class GizmoWallFilletToggleOpenings(bpy.types.GizmoGroup, _WallGeomCachedBillboa
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
if not _wall_gizmo_poll_gate(context):
if not _wall_topology_gizmo_poll_gate(context):
return False
active = tool.Blender.get_active_object(is_selected=True)
if active is None:
@@ -4269,6 +4305,9 @@ class JoinWallsIntersection(_CommitWallDraftsFirstMixin, bpy.types.Operator, too
if not tool.Model.has_selected_ifc_objects():
cls.poll_message_set("No IFC objects selected.")
return False
if tool.Blender.Modifier.any_selected_is_array_child():
cls.poll_message_set(_ARRAY_CHILD_POLL_MESSAGE)
return False
return True
def _perform(self, context: bpy.types.Context) -> set[str]:
+15
View File
@@ -1458,6 +1458,21 @@ class Blender(bonsai.core.tool.Blender):
parent_guid = pset.get("Parent")
return parent_guid is not None and parent_guid != element.GlobalId
@classmethod
def any_selected_is_array_child(cls) -> bool:
"""True if any selected IFC-linked object is a Bonsai array child.
Multi-object wall topology gizmos (merge / join / extend / unjoin
/ fillet) and their bound operators gate on this: any mutation
applied to a child is overwritten on the next
``regenerate_array``, and merge specifically would leave the
parent's ``BBIM_Array.Data`` list pointing at a deleted GUID."""
for obj in tool.Blender.get_selected_objects():
element = tool.Ifc.get_entity(obj)
if element is not None and cls.is_array_child(element):
return True
return False
@classmethod
def is_slab(cls, element: entity_instance) -> bool:
"""A slab is host-eligible for the parametric add-opening gizmo if
@@ -0,0 +1,140 @@
# 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 guard: every multi-object wall topology GizmoGroup
filters Bonsai array children via ``_wall_topology_gizmo_poll_gate`` or
the central ``any_selected_is_array_child`` predicate.
Allow-list (gizmos intentionally outside the rule):
- ``GizmoWallEdition`` single-object parametric edit gizmo. Its base
parametric poll already filters array children.
- ``GizmoWallFilletPreview`` the preview-owner whose poll must fire
WHILE its own preview is active; routing it through the topology gate
would self-block it.
Host-opening gizmos live in a sibling module and intentionally use the
loose base ``_wall_gizmo_poll_gate``: openings track with the child
through ``regenerate_array`` and stay authorable on children.
A new wall ``GizmoGroup`` added without the filter (and not added to the
allow-list with an explanation) fails this test."""
import ast
import inspect
import types
import bpy
import pytest
pytestmark = pytest.mark.model
# Wall gizmo groups intentionally outside the rule. Add a new entry only
# with the in-code reasoning above.
_ALLOWLIST = frozenset({"GizmoWallEdition", "GizmoWallFilletPreview"})
_REQUIRED_CALLEES = frozenset({"_wall_topology_gizmo_poll_gate", "any_selected_is_array_child"})
@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)")
def _wall_module_source():
from bonsai.bim.module.model import wall as wall_mod
return inspect.getsource(wall_mod), wall_mod.__name__
def _wall_gizmo_group_classes():
"""All ``bpy.types.GizmoGroup`` subclasses defined locally in wall.py."""
from bonsai.bim.module.model import wall as wall_mod
out = []
for name in dir(wall_mod):
obj = getattr(wall_mod, name)
if not isinstance(obj, type):
continue
if not issubclass(obj, bpy.types.GizmoGroup) or obj is bpy.types.GizmoGroup:
continue
if obj.__module__ != wall_mod.__name__:
continue
out.append((name, obj))
return out
def _poll_function_calls(class_node):
"""Names of every function called inside ``class_node``'s ``poll`` body.
``ast.Call.func`` may be an ``ast.Name`` (bare call) or an ``ast.Attribute``
(dotted call). For the dotted case the leaf attribute is returned so
``tool.Blender.Modifier.any_selected_is_array_child(...)`` registers as
``any_selected_is_array_child``."""
poll_node = next(
(node for node in class_node.body if isinstance(node, ast.FunctionDef) and node.name == "poll"),
None,
)
if poll_node is None:
return None
names = set()
for sub in ast.walk(poll_node):
if not isinstance(sub, ast.Call):
continue
func = sub.func
if isinstance(func, ast.Name):
names.add(func.id)
elif isinstance(func, ast.Attribute):
names.add(func.attr)
return names
def test_every_wall_gizmo_group_filters_array_children_or_is_allowlisted():
"""For every locally-defined wall ``GizmoGroup`` not in the allow-list,
its ``poll`` must call ``_wall_gizmo_poll_gate`` or the central
``any_selected_is_array_child`` predicate. A failure surfaces the list
of offending classes the fix is a single early-return through the
central helper, mirroring the existing peers."""
source, _module_name = _wall_module_source()
tree = ast.parse(source)
class_nodes = {node.name: node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)}
offenders = []
for class_name, _cls in _wall_gizmo_group_classes():
if class_name in _ALLOWLIST:
continue
node = class_nodes.get(class_name)
if node is None:
offenders.append((class_name, "AST parse did not find the class"))
continue
calls = _poll_function_calls(node)
if calls is None:
offenders.append((class_name, "no poll() defined; expected the array-child filter call"))
continue
if not (calls & _REQUIRED_CALLEES):
offenders.append((class_name, f"poll() does not call any of {sorted(_REQUIRED_CALLEES)}"))
assert not offenders, (
"Wall GizmoGroup classes missing the array-child filter: "
+ ", ".join(f"{n}{why}" for n, why in offenders)
+ ". Route the poll through `_wall_topology_gizmo_poll_gate(context)` "
"so the central `any_selected_is_array_child` filter applies, or add "
"the class to the file's allow-list with a documented reason."
)
@@ -0,0 +1,239 @@
# 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.
"""Behaviour contract: wall topology gizmos and operators reject any
selection that contains a Bonsai array child. Discovers gated gizmo
groups and guarded operators by source inspection so additions inherit
the rule automatically."""
import types
from unittest.mock import patch
import bpy
import pytest
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)")
def _wall_gizmo_groups_using_gate():
"""Wall-module ``bpy.types.GizmoGroup`` subclasses whose ``poll`` calls
``_wall_topology_gizmo_poll_gate``. Discovered by source inspection so
the test tracks the gate's user set as the module grows."""
import inspect
from bonsai.bim.module.model import wall as wall_mod
out = []
for name in dir(wall_mod):
obj = getattr(wall_mod, name)
if not isinstance(obj, type):
continue
if not issubclass(obj, bpy.types.GizmoGroup) or obj is bpy.types.GizmoGroup:
continue
if obj.__module__ != wall_mod.__name__:
continue
poll = obj.__dict__.get("poll")
if poll is None:
continue
try:
src = inspect.getsource(poll)
except (OSError, TypeError):
continue
if "_wall_topology_gizmo_poll_gate" not in src:
continue
out.append((name, obj))
return out
def _wall_operators_with_array_child_guard():
"""Wall-module ``bpy.types.Operator`` subclasses whose ``poll`` references
``any_selected_is_array_child``. The operator-level guard is defence in
depth against keymap / F3 paths that bypass the gizmo entirely."""
import inspect
from bonsai.bim.module.model import wall as wall_mod
out = []
for name in dir(wall_mod):
obj = getattr(wall_mod, name)
if not isinstance(obj, type):
continue
if not issubclass(obj, bpy.types.Operator) or obj is bpy.types.Operator:
continue
if obj.__module__ != wall_mod.__name__:
continue
poll = obj.__dict__.get("poll")
if poll is None:
continue
try:
src = inspect.getsource(poll)
except (OSError, TypeError):
continue
if "any_selected_is_array_child" not in src:
continue
out.append((name, obj))
return out
class TestWallGizmoGroupsHideOnArrayChildSelection:
def test_discovery_finds_wall_multi_object_gizmo_groups(self):
groups = _wall_gizmo_groups_using_gate()
assert groups, (
"Expected at least one wall GizmoGroup whose poll calls "
"_wall_gizmo_poll_gate — discovery walk drifted out of sync?"
)
def test_every_gated_wall_gizmo_hides_when_any_selection_is_array_child(self):
"""Mocks the central ``any_selected_is_array_child`` predicate to True
and asserts every gizmo whose poll routes through
``_wall_gizmo_poll_gate`` returns False. The point is the BEHAVIOUR:
a child wall in the selection must never surface a topology gizmo,
regardless of which gate function the poll calls internally."""
groups = _wall_gizmo_groups_using_gate()
offenders = []
with patch("bonsai.tool.Blender.are_viewport_gizmos_enabled", return_value=True):
with patch("bonsai.bim.module.model.preview_base.any_preview_active", return_value=False):
with patch(
"bonsai.tool.Blender.Modifier.any_selected_is_array_child",
return_value=True,
):
for name, cls in groups:
try:
result = cls.poll(bpy.context)
except Exception as exc: # noqa: BLE001
offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}"))
continue
if result:
offenders.append((name, "poll returned True with array child selected"))
assert not offenders, (
"Wall gizmo polls that surface on array-child selections: "
+ ", ".join(f"{n}{why}" for n, why in offenders)
+ ". Route the poll through _wall_topology_gizmo_poll_gate so the "
"central any_selected_is_array_child filter applies."
)
class TestWallOperatorsRejectArrayChildSelection:
def test_discovery_finds_wall_topology_operators(self):
ops = _wall_operators_with_array_child_guard()
assert ops, (
"Expected at least one wall Operator whose poll references "
"any_selected_is_array_child — discovery walk drifted out of sync?"
)
def test_every_guarded_wall_operator_polls_false_on_array_child_selection(self):
"""Operators reachable from keymaps / F3 must reject array-child
invocation independently of the gizmo gating, because not every
invocation path goes through a gizmo. The shared predicate makes
this a one-line guard per operator; this test pins it for every
operator that opted in."""
ops = _wall_operators_with_array_child_guard()
offenders = []
with patch(
"bonsai.tool.Blender.Modifier.any_selected_is_array_child",
return_value=True,
):
with patch("bonsai.tool.Model.has_selected_ifc_objects", return_value=True):
with patch("bonsai.tool.Model.get_selected_ifc_objects", return_value=[]):
for name, cls in ops:
try:
result = cls.poll(bpy.context)
except Exception as exc: # noqa: BLE001
offenders.append((name, f"poll raised: {type(exc).__name__}: {exc}"))
continue
if result:
offenders.append((name, "poll returned True with array child selected"))
assert not offenders, (
"Wall topology operators that accept array-child selections: "
+ ", ".join(f"{n}{why}" for n, why in offenders)
+ ". Add `if tool.Blender.Modifier.any_selected_is_array_child(): "
"return False` early in the poll."
)
class TestAnySelectedIsArrayChildHelper:
"""Smoke checks on the central predicate. Returns ``False`` when nothing
is selected; returns ``True`` when at least one selected element passes
``is_array_child``."""
def test_returns_false_with_empty_selection(self):
from bonsai import tool
with patch.object(tool.Blender, "get_selected_objects", return_value=[]):
assert tool.Blender.Modifier.any_selected_is_array_child() is False
def test_returns_true_when_any_selected_passes_predicate(self):
from bonsai import tool
child_obj, child_element = object(), object()
parent_obj, parent_element = object(), object()
def get_entity(obj):
return {id(child_obj): child_element, id(parent_obj): parent_element}.get(id(obj))
def is_array_child(element):
return element is child_element
with patch.object(tool.Blender, "get_selected_objects", return_value=[parent_obj, child_obj]):
with patch.object(tool.Ifc, "get_entity", side_effect=get_entity):
with patch.object(tool.Blender.Modifier, "is_array_child", side_effect=is_array_child):
assert tool.Blender.Modifier.any_selected_is_array_child() is True
def test_returns_false_when_no_selected_passes_predicate(self):
from bonsai import tool
parent_obj, parent_element = object(), object()
with patch.object(tool.Blender, "get_selected_objects", return_value=[parent_obj]):
with patch.object(tool.Ifc, "get_entity", return_value=parent_element):
with patch.object(tool.Blender.Modifier, "is_array_child", return_value=False):
assert tool.Blender.Modifier.any_selected_is_array_child() is False
class TestHostOpeningGizmoStaysAvailableOnArrayChildren:
"""Openings on array children are array-safe: ``regenerate_array``
applies opening cuts after replicating child geometry, so an opening
authored on a child survives regen and tracks with the replicated
instance. The host-opening gizmos therefore route through the loose
base wall gate, not the tighter topology gate that excludes
children."""
def test_host_opening_module_does_not_apply_topology_gate(self):
import inspect
from bonsai.bim.module.model import host_add_opening_gizmo
src = inspect.getsource(host_add_opening_gizmo)
assert "_wall_topology_gizmo_poll_gate" not in src, (
"host-opening gizmo module references the topology gate; that "
"would suppress add-opening on array-child hosts. Openings "
"track with the regenerated child via the array regen pipeline."
)
assert "any_selected_is_array_child" not in src, (
"host-opening gizmo module references any_selected_is_array_child; "
"openings are array-safe, drop the filter."
)