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."
+ )