From e0ceda685674a4cde72b1a17ae3193b51d878466 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 9 Jun 2026 16:50:04 +0200 Subject: [PATCH] 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. --- src/bonsai/bonsai/bim/module/model/wall.py | 49 +++- src/bonsai/bonsai/tool/blender.py | 15 ++ ..._wall_array_child_filter_forward_compat.py | 140 ++++++++++ .../model/test_wall_gizmos_array_children.py | 239 ++++++++++++++++++ 4 files changed, 438 insertions(+), 5 deletions(-) create mode 100644 src/bonsai/test/bim/module/model/test_wall_array_child_filter_forward_compat.py create mode 100644 src/bonsai/test/bim/module/model/test_wall_gizmos_array_children.py diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 5411902cc3..cf1b7b2ed2 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -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]: diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index 5e54909094..bb6235a426 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -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 diff --git a/src/bonsai/test/bim/module/model/test_wall_array_child_filter_forward_compat.py b/src/bonsai/test/bim/module/model/test_wall_array_child_filter_forward_compat.py new file mode 100644 index 0000000000..5d790ece50 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_array_child_filter_forward_compat.py @@ -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 . +# +# 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." + ) diff --git a/src/bonsai/test/bim/module/model/test_wall_gizmos_array_children.py b/src/bonsai/test/bim/module/model/test_wall_gizmos_array_children.py new file mode 100644 index 0000000000..41498c4220 --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_gizmos_array_children.py @@ -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 . +# +# 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." + )