mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-10 22:16:41 +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:
committed by
Thomas Krijnen
parent
2c3935bf9d
commit
8efb5ae515
@@ -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_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.
|
_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:
|
def _wall_gizmo_poll_gate(context: bpy.types.Context) -> bool:
|
||||||
"""Common pre-flight gate every wall gizmo group's ``poll`` runs first:
|
"""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
|
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:
|
def _wall_has_openings(gz_group: bpy.types.GizmoGroup) -> bool:
|
||||||
"""``visible_when`` predicate for the toggle_openings idle slot. Returns
|
"""``visible_when`` predicate for the toggle_openings idle slot. Returns
|
||||||
True iff the active object's IFC element exposes a non-empty HasOpenings
|
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():
|
if not tool.Model.has_selected_ifc_objects():
|
||||||
cls.poll_message_set("No IFC objects selected.")
|
cls.poll_message_set("No IFC objects selected.")
|
||||||
return False
|
return False
|
||||||
|
if tool.Blender.Modifier.any_selected_is_array_child():
|
||||||
|
cls.poll_message_set(_ARRAY_CHILD_POLL_MESSAGE)
|
||||||
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _perform(self, context):
|
def _perform(self, context):
|
||||||
@@ -270,6 +290,9 @@ class UnjoinWallPathConnection(_CommitWallDraftsFirstMixin, bpy.types.Operator,
|
|||||||
if not tool.Model.has_selected_ifc_objects():
|
if not tool.Model.has_selected_ifc_objects():
|
||||||
cls.poll_message_set("No IFC objects selected.")
|
cls.poll_message_set("No IFC objects selected.")
|
||||||
return False
|
return False
|
||||||
|
if tool.Blender.Modifier.any_selected_is_array_child():
|
||||||
|
cls.poll_message_set(_ARRAY_CHILD_POLL_MESSAGE)
|
||||||
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _perform(self, context):
|
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_description = "Extend and trim selected walls to another wall"
|
||||||
bl_options = {"REGISTER", "UNDO"}
|
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):
|
def _perform(self, context):
|
||||||
target_obj = None
|
target_obj = None
|
||||||
objs = []
|
objs = []
|
||||||
@@ -594,6 +624,9 @@ class SplitWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operat
|
|||||||
if not tool.Model.has_selected_ifc_objects():
|
if not tool.Model.has_selected_ifc_objects():
|
||||||
cls.poll_message_set("No IFC objects selected.")
|
cls.poll_message_set("No IFC objects selected.")
|
||||||
return False
|
return False
|
||||||
|
if tool.Blender.Modifier.any_selected_is_array_child():
|
||||||
|
cls.poll_message_set(_ARRAY_CHILD_POLL_MESSAGE)
|
||||||
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _perform(self, context):
|
def _perform(self, context):
|
||||||
@@ -622,6 +655,9 @@ class MergeWall(_CommitWallDraftsFirstMixin, bpy.types.Operator, tool.Ifc.Operat
|
|||||||
if len(mesh_objects) != 2:
|
if len(mesh_objects) != 2:
|
||||||
cls.poll_message_set("Please select exactly two mesh IFC objects.")
|
cls.poll_message_set("Please select exactly two mesh IFC objects.")
|
||||||
return False
|
return False
|
||||||
|
if tool.Blender.Modifier.any_selected_is_array_child():
|
||||||
|
cls.poll_message_set(_ARRAY_CHILD_POLL_MESSAGE)
|
||||||
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _perform(self, context):
|
def _perform(self, context):
|
||||||
@@ -3496,7 +3532,7 @@ class GizmoWallExtendVertically(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def poll(cls, context: bpy.types.Context) -> bool:
|
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
|
return False
|
||||||
selected = tool.Blender.get_selected_objects()
|
selected = tool.Blender.get_selected_objects()
|
||||||
if len(selected) != 2:
|
if len(selected) != 2:
|
||||||
@@ -3577,7 +3613,7 @@ class GizmoWallJoinIntersection(bpy.types.GizmoGroup, _WallGeomCachedBillboardin
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def poll(cls, context: bpy.types.Context) -> bool:
|
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
|
return False
|
||||||
selected = tool.Blender.get_selected_objects()
|
selected = tool.Blender.get_selected_objects()
|
||||||
if len(selected) != 2:
|
if len(selected) != 2:
|
||||||
@@ -3784,7 +3820,7 @@ class GizmoWallUnjoinSingle(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def poll(cls, context: bpy.types.Context) -> bool:
|
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
|
return False
|
||||||
active = tool.Blender.get_active_object(is_selected=True)
|
active = tool.Blender.get_active_object(is_selected=True)
|
||||||
if active is None:
|
if active is None:
|
||||||
@@ -4150,7 +4186,7 @@ class GizmoWallFilletReedit(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMix
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def poll(cls, context: bpy.types.Context) -> bool:
|
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
|
return False
|
||||||
active = tool.Blender.get_active_object(is_selected=True)
|
active = tool.Blender.get_active_object(is_selected=True)
|
||||||
if active is None:
|
if active is None:
|
||||||
@@ -4218,7 +4254,7 @@ class GizmoWallFilletToggleOpenings(bpy.types.GizmoGroup, _WallGeomCachedBillboa
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def poll(cls, context: bpy.types.Context) -> bool:
|
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
|
return False
|
||||||
active = tool.Blender.get_active_object(is_selected=True)
|
active = tool.Blender.get_active_object(is_selected=True)
|
||||||
if active is None:
|
if active is None:
|
||||||
@@ -4269,6 +4305,9 @@ class JoinWallsIntersection(_CommitWallDraftsFirstMixin, bpy.types.Operator, too
|
|||||||
if not tool.Model.has_selected_ifc_objects():
|
if not tool.Model.has_selected_ifc_objects():
|
||||||
cls.poll_message_set("No IFC objects selected.")
|
cls.poll_message_set("No IFC objects selected.")
|
||||||
return False
|
return False
|
||||||
|
if tool.Blender.Modifier.any_selected_is_array_child():
|
||||||
|
cls.poll_message_set(_ARRAY_CHILD_POLL_MESSAGE)
|
||||||
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _perform(self, context: bpy.types.Context) -> set[str]:
|
def _perform(self, context: bpy.types.Context) -> set[str]:
|
||||||
|
|||||||
@@ -1458,6 +1458,21 @@ class Blender(bonsai.core.tool.Blender):
|
|||||||
parent_guid = pset.get("Parent")
|
parent_guid = pset.get("Parent")
|
||||||
return parent_guid is not None and parent_guid != element.GlobalId
|
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
|
@classmethod
|
||||||
def is_slab(cls, element: entity_instance) -> bool:
|
def is_slab(cls, element: entity_instance) -> bool:
|
||||||
"""A slab is host-eligible for the parametric add-opening gizmo if
|
"""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