mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 07:51:47 +00:00
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:
@@ -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]:
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
Reference in New Issue
Block a user