mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-05 20:06:25 +00:00
Generalise opening gizmos + DRY toolbar plumbing
Add openings — GizmoWallAddOpening only fired when a wall was active + co-selected with a non-host; slabs and roofs got no in-viewport handle. GizmoHostAddOpening covers all three host types via is_supported_host, dispatching walls to the axis-projection anchor and slabs/roofs to a world-Z anchor lifted just above the host's top face (predictable height regardless of the void's vertical position). Show openings on hosts with their own parametric-edit toolbar — GizmoRoofEdition gains an idle-row toggle_openings_gizmo parallel to the wall's, parked at the cancel-slot X next to the pen. Visible only when the host carries HasOpenings and the edit triad is idle. Roof overrides get_element_height to return the mesh's world-AABB top in object-local Z, so the WHOLE pen-row anchors visibly above sloped or stepped roof bodies. The wall's idle-row toggle now also hides when HasOpenings is empty. Show openings on hosts WITHOUT a parametric-edit toolbar — GizmoHostToggleOpenings scoped strictly to the fallback case: a single host selected, HasOpenings non-empty, NOT a path-connectable wall, NOT a parametric roof. Covers slabs today plus any foreign-authored IfcRoof without BBIM_Roof. Anchored at object origin XY + world-AABB top Z. When slab parametric-edit eventually lands, the slab predicate joins the exclusion list and this gizmo's poll narrows automatically. Operator move — ToggleWallOpenings was already host-agnostic; renamed to ToggleHostOpenings in opening.py (bl_idname bim.toggle_host_openings). Three callers (the wall idle-row binding, GizmoWallFilletToggleOpenings, and workspace.py's hotkey_A_O for Alt+O) now route through the renamed operator. The Alt+O binding is surfaced in the operator's bl_description so it appears in F3 search and hover tooltips. DRY refactors — * GizmoWallAddOpening deleted (subsumed by GizmoHostAddOpening) * tool.Blender.get_object_world_bounding_box added as the world-AABB sibling of the existing local helper; 3 inline call sites in tool/misc.py (set_object_origin_to_bottom, scale_object_to_height) and gizmos.py adopt it (2 other sites in drawing/operator.py and project/operator.py inherently need raw transformed corners for per-corner plane / NDC tests — not AABB candidates) * BaseParametricGizmoGroup gains setup_pen_row_toggle_openings_icon + update_pen_row_toggle_openings_icon; wall + roof + any future host gizmo wire up the idle-row toggle with two one-line calls * _resolve_active_host shared poll prologue between the two host gizmos (gate + selection count + active-in-selected + entity lookup + supported-host check) * HasOpenings non-empty checks at 3 sites route through tool.Geometry.has_openings * hotkey_A_O body collapsed to bpy.ops.bim.toggle_host_openings() The forward-compat AST guard pinning "must accept fillet-corner walls" retargets from GizmoWallAddOpening.poll to is_supported_host. Generated with the assistance of an AI coding tool.
This commit is contained in:
committed by
Thomas Krijnen
parent
c398deba71
commit
046917f75d
@@ -0,0 +1,212 @@
|
||||
# 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.
|
||||
|
||||
"""Shared fixtures and factories for ``test/bim/module/model/`` gizmo and
|
||||
decorator tests.
|
||||
|
||||
The boundary between Blender / IFC / Bonsai's ``tool.*`` layer is patched
|
||||
identically across many model-test files (viewport-state, selection, IFC
|
||||
entity lookup, modifier predicates, view-camera state). The ``patched_tool``
|
||||
fixture below centralises that patch stack so each test names only the
|
||||
boundary methods it cares about; everything else is left to production.
|
||||
|
||||
Factory helpers (``make_obj``, ``make_element``, ``make_context``,
|
||||
``make_ifc_file``) replace near-identical local helpers that previously
|
||||
lived in each file.
|
||||
|
||||
When to use these fixtures in a new test file:
|
||||
|
||||
- Adding a gizmo / decorator test that patches ``tool.Blender`` or
|
||||
``tool.Ifc`` boundary methods? Request the ``patched_tool`` fixture
|
||||
as a test parameter and call it as a context-manager factory.
|
||||
- Need a stub ``bpy.types.Object`` / ``ifcopenshell.entity_instance`` /
|
||||
``poll()`` context / ``ifcopenshell.file``? Import the matching factory
|
||||
from this module rather than re-rolling locally.
|
||||
- Need to reset module-level state (e.g. a decorator cache token) between
|
||||
tests? Define an ``@pytest.fixture(autouse=True)`` reset in the test
|
||||
file itself — these stay file-local because they target state specific
|
||||
to one decorator/module and globalising the reset would surprise
|
||||
unrelated tests.
|
||||
|
||||
Layout note: pure helpers (``make_*``) live alongside the fixture in this
|
||||
file rather than a sibling ``test_utils.py``. pytest's documented role for
|
||||
``conftest.py`` is fixtures, so this is a mild convention bend — kept here
|
||||
because the helper count is small and the dependencies (``tool``, ``Mock``)
|
||||
already need to be imported for the fixture itself. Split into a separate
|
||||
module if the helper count grows past ~6 or any helper picks up its own
|
||||
non-trivial dependencies."""
|
||||
|
||||
import contextlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import ifcopenshell
|
||||
import pytest
|
||||
|
||||
from bonsai import tool
|
||||
|
||||
|
||||
def make_obj(*, session_uid=None, selected=True, **attrs):
|
||||
"""Mock a ``bpy.types.Object`` with attributes commonly read by gizmos.
|
||||
|
||||
``session_uid`` is set only when provided so tests that don't care about
|
||||
object identity (most poll() tests use ``object()`` sentinels) can use
|
||||
``make_obj()`` without a spurious uid. ``selected`` wires ``select_get()``
|
||||
to return the given boolean. Extra attrs are set as plain attributes.
|
||||
|
||||
A bare ``Mock()`` is required because ``Mock(spec=bpy.types.Object)``
|
||||
rejects ``select_get`` — Blender's C-registered methods aren't exposed
|
||||
to Python introspection."""
|
||||
obj = Mock()
|
||||
if session_uid is not None:
|
||||
obj.session_uid = session_uid
|
||||
obj.select_get.return_value = selected
|
||||
for name, value in attrs.items():
|
||||
setattr(obj, name, value)
|
||||
return obj
|
||||
|
||||
|
||||
def make_element(step_id=None, *, ifc_class=None, **attrs):
|
||||
"""Mock an ``ifcopenshell.entity_instance`` with the surfaces gizmos read.
|
||||
|
||||
``step_id`` populates ``element.id()``. ``ifc_class`` wires ``is_a(name)``
|
||||
to return True only when ``name == ifc_class``. Extra kwargs become plain
|
||||
attributes (e.g. ``HasOpenings=()``)."""
|
||||
element = Mock()
|
||||
if step_id is not None:
|
||||
element.id.return_value = step_id
|
||||
if ifc_class is not None:
|
||||
element.is_a.side_effect = lambda type_name: type_name == ifc_class
|
||||
for name, value in attrs.items():
|
||||
setattr(element, name, value)
|
||||
return element
|
||||
|
||||
|
||||
def make_context(*, active=None, selected=(), scene=None):
|
||||
"""``SimpleNamespace`` stub with the ``poll()`` reads tests exercise:
|
||||
``active_object``, ``selected_objects``, and ``scene``. ``selected`` is
|
||||
materialised to a list so tests can iterate without re-walking a generator.
|
||||
``scene`` defaults to an empty namespace so guards that walk
|
||||
``context.scene.BIMPreviewProperties`` (via ``getattr(..., default=None)``)
|
||||
treat the preview as inactive — pass a custom namespace to activate."""
|
||||
return SimpleNamespace(
|
||||
active_object=active,
|
||||
selected_objects=list(selected),
|
||||
scene=scene if scene is not None else SimpleNamespace(),
|
||||
)
|
||||
|
||||
|
||||
def make_ifc_file(elements_by_guid: dict | None = None) -> MagicMock:
|
||||
"""Mock ``ifcopenshell.file`` with ``spec=`` so attribute typos surface as
|
||||
``AttributeError`` instead of silently auto-creating a child mock.
|
||||
|
||||
When ``elements_by_guid`` is given, ``by_guid`` is wired to look up the
|
||||
mapping and raise ``RuntimeError`` on a missing guid — same shape as the
|
||||
real ifcopenshell.file behaviour, so a test that depends on orphan handling
|
||||
sees an exception rather than a silent ``None``."""
|
||||
f = MagicMock(spec=ifcopenshell.file, name="ifc_file")
|
||||
if elements_by_guid is not None:
|
||||
|
||||
def _by_guid(guid):
|
||||
try:
|
||||
return elements_by_guid[guid]
|
||||
except KeyError:
|
||||
raise RuntimeError(f"no entity with guid {guid}")
|
||||
|
||||
f.by_guid.side_effect = _by_guid
|
||||
return f
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_tool():
|
||||
"""Context-manager factory for the ``tool.*`` boundary patches that nearly
|
||||
every gizmo / decorator test repeats. Use as::
|
||||
|
||||
with patched_tool(viewport_gizmos=True, selected=[obj_a, obj_b],
|
||||
modifier_predicates={"is_wall": True}):
|
||||
GizmoFoo.poll(context)
|
||||
|
||||
Only the kwargs you pass are patched — anything left as ``None`` (or
|
||||
omitted) keeps production behaviour. Values can be:
|
||||
|
||||
- ``viewport_gizmos`` / ``view_top_down`` / ``addon_prefs``: passed to
|
||||
``return_value=`` of the corresponding patch.
|
||||
- ``selected``: wrapped in ``set(...)`` for ``get_selected_objects``
|
||||
(matches the production return type for ``poll()``-side reads).
|
||||
- ``selected_list``: as-is for ``get_selected_objects`` when order
|
||||
matters (some operators iterate it). Mutually exclusive with
|
||||
``selected`` — if both are passed, ``selected`` wins and
|
||||
``selected_list`` is ignored. Pass only one.
|
||||
- ``entity``: either a callable (used as ``side_effect``) or a single
|
||||
value (used as ``return_value``).
|
||||
- ``modifier_predicates``: dict ``{predicate_name: bool_or_callable}``.
|
||||
Callables are wired as ``side_effect``, bools as ``return_value``.
|
||||
- ``screen_up``: ``return_value`` for ``get_screen_up_world``.
|
||||
|
||||
Patches close on context-manager exit via an ``ExitStack`` — no
|
||||
``try/finally`` bookkeeping in the test body."""
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _factory(
|
||||
*,
|
||||
viewport_gizmos=None,
|
||||
addon_prefs=None,
|
||||
selected=None,
|
||||
selected_list=None,
|
||||
entity=None,
|
||||
modifier_predicates=None,
|
||||
view_top_down=None,
|
||||
screen_up=None,
|
||||
):
|
||||
with contextlib.ExitStack() as stack:
|
||||
if viewport_gizmos is not None:
|
||||
stack.enter_context(
|
||||
patch.object(tool.Blender, "are_viewport_gizmos_enabled", return_value=viewport_gizmos)
|
||||
)
|
||||
if addon_prefs is not None:
|
||||
stack.enter_context(patch.object(tool.Blender, "get_addon_preferences", return_value=addon_prefs))
|
||||
if selected is not None:
|
||||
stack.enter_context(patch.object(tool.Blender, "get_selected_objects", return_value=set(selected)))
|
||||
elif selected_list is not None:
|
||||
stack.enter_context(
|
||||
patch.object(tool.Blender, "get_selected_objects", return_value=list(selected_list))
|
||||
)
|
||||
if entity is not None:
|
||||
if callable(entity):
|
||||
stack.enter_context(patch.object(tool.Ifc, "get_entity", side_effect=entity))
|
||||
else:
|
||||
stack.enter_context(patch.object(tool.Ifc, "get_entity", return_value=entity))
|
||||
if modifier_predicates:
|
||||
for name, value in modifier_predicates.items():
|
||||
# Parametric feature-kind predicates live on tool.Parametric; the
|
||||
# remaining cardinality / non-parametric predicates (is_array_child,
|
||||
# is_slab, is_eligible_for_*) stay on tool.Blender.Modifier.
|
||||
target = tool.Parametric if hasattr(tool.Parametric, name) else tool.Blender.Modifier
|
||||
if callable(value):
|
||||
stack.enter_context(patch.object(target, name, side_effect=value))
|
||||
else:
|
||||
stack.enter_context(patch.object(target, name, return_value=value))
|
||||
if view_top_down is not None:
|
||||
stack.enter_context(patch.object(tool.Blender, "is_view_top_down", return_value=view_top_down))
|
||||
if screen_up is not None:
|
||||
stack.enter_context(patch.object(tool.Blender, "get_screen_up_world", return_value=screen_up))
|
||||
yield
|
||||
|
||||
return _factory
|
||||
@@ -0,0 +1,463 @@
|
||||
# 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.
|
||||
|
||||
"""Poll + positioning tests for ``GizmoHostAddOpening``.
|
||||
|
||||
The gizmo dispatches on element type: walls keep the existing axis-projection
|
||||
math, while LAYER3 hosts (slabs, roofs) use a world-Z face bias derived from
|
||||
the void object's elevation. Each branch is exercised independently with
|
||||
mocks so the per-type contract is pinned without launching a full Blender
|
||||
modelling session."""
|
||||
|
||||
import contextlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from test.bim.bootstrap import NewFile
|
||||
from test.bim.module.model.conftest import make_context
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# poll() — entry gate per host type and per co-selection shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_IFC_CLASS_BY_KIND = {
|
||||
"wall": "IfcWall",
|
||||
"slab": "IfcSlab",
|
||||
"roof": "IfcRoof",
|
||||
"plain": "IfcDiscreteAccessory",
|
||||
}
|
||||
|
||||
|
||||
class _FakeIfcEntity:
|
||||
"""Minimal stand-in for an ``ifcopenshell.entity_instance`` in poll tests.
|
||||
|
||||
Provides the two surfaces the gizmo's poll consults: ``is_a(type_name)``
|
||||
(used directly by ``is_supported_host`` for slab/roof) and an optional
|
||||
``HasOpenings`` attribute (probed by the poll's ``hasattr`` guard)."""
|
||||
|
||||
def __init__(self, ifc_class: str, has_openings: bool = True):
|
||||
self._ifc_class = ifc_class
|
||||
if has_openings:
|
||||
self.HasOpenings = ()
|
||||
|
||||
def is_a(self, type_name: str) -> bool:
|
||||
return self._ifc_class == type_name
|
||||
|
||||
|
||||
def _build_poll_callbacks(selected, active_kind, other_kind):
|
||||
"""Build the ``(get_entity, is_path_connectable_wall)`` side-effect
|
||||
callables that simulate one poll() invocation. ``active_kind`` /
|
||||
``other_kind`` accept ``"wall"``, ``"slab"``, ``"roof"``, ``"plain"``
|
||||
(non-host IFC element), ``"mesh"`` (no IFC entity), or ``None``
|
||||
(object outside the selection set).
|
||||
|
||||
Wall recognition goes through ``tool.Parametric.is_path_connectable_wall``
|
||||
so fillet-corner walls (which have no LAYER2 usage) also surface the
|
||||
add-opening icon; slab/roof use ``is_a`` on the fake entity so the
|
||||
broadened class-based predicate is exercised."""
|
||||
sentinels = {kind: _FakeIfcEntity(_IFC_CLASS_BY_KIND[kind]) for kind in _IFC_CLASS_BY_KIND}
|
||||
# The "plain" sentinel lacks HasOpenings so the hasattr guard branch
|
||||
# is reachable from the corresponding poll test.
|
||||
sentinels["plain"] = _FakeIfcEntity(_IFC_CLASS_BY_KIND["plain"], has_openings=False)
|
||||
|
||||
def entity_for(kind):
|
||||
if kind in (None, "mesh"):
|
||||
return None
|
||||
return sentinels[kind]
|
||||
|
||||
entity_map = {}
|
||||
if len(selected) >= 1:
|
||||
entity_map[id(selected[0])] = entity_for(active_kind)
|
||||
if len(selected) >= 2:
|
||||
entity_map[id(selected[1])] = entity_for(other_kind)
|
||||
|
||||
def get_entity(obj):
|
||||
return entity_map.get(id(obj))
|
||||
|
||||
def is_path_connectable_wall(element):
|
||||
return element is sentinels["wall"]
|
||||
|
||||
return get_entity, is_path_connectable_wall
|
||||
|
||||
|
||||
def _run_poll(
|
||||
patched_tool, prefs_on=True, n_selected=2, active_in_selected=True, active_kind="wall", other_kind="mesh"
|
||||
):
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
|
||||
|
||||
selected = [object() for _ in range(n_selected)]
|
||||
active = selected[0] if (active_in_selected and selected) else object()
|
||||
get_entity, is_path_connectable_wall = _build_poll_callbacks(selected, active_kind, other_kind)
|
||||
|
||||
with patched_tool(
|
||||
viewport_gizmos=prefs_on,
|
||||
selected=selected,
|
||||
entity=get_entity,
|
||||
modifier_predicates={"is_path_connectable_wall": is_path_connectable_wall},
|
||||
):
|
||||
return GizmoHostAddOpening.poll(make_context(active=active, selected=selected))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host_kind", ["wall", "slab", "roof"])
|
||||
def test_poll_accepts_each_host_with_a_plain_mesh_void(host_kind, patched_tool):
|
||||
assert _run_poll(patched_tool, active_kind=host_kind, other_kind="mesh") is True
|
||||
|
||||
|
||||
def test_poll_rejects_when_gizmo_toggle_off(patched_tool):
|
||||
assert _run_poll(patched_tool, prefs_on=False) is False
|
||||
|
||||
|
||||
def test_poll_rejects_when_selection_count_is_not_two(patched_tool):
|
||||
assert _run_poll(patched_tool, n_selected=1) is False
|
||||
assert _run_poll(patched_tool, n_selected=3) is False
|
||||
|
||||
|
||||
def test_poll_rejects_when_active_is_not_in_selection(patched_tool):
|
||||
assert _run_poll(patched_tool, active_in_selected=False) is False
|
||||
|
||||
|
||||
def test_poll_rejects_when_active_has_no_ifc_entity(patched_tool):
|
||||
assert _run_poll(patched_tool, active_kind="mesh") is False
|
||||
|
||||
|
||||
def test_poll_rejects_when_active_is_not_a_host(patched_tool):
|
||||
# "plain" sentinel is recognised as an IFC entity but is none of wall/slab/roof.
|
||||
assert _run_poll(patched_tool, active_kind="plain") is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"active_kind,other_kind",
|
||||
[
|
||||
("wall", "wall"), # wall-join gizmo owns this
|
||||
("slab", "slab"), # future slab-edit gizmo
|
||||
("roof", "roof"),
|
||||
("wall", "slab"), # extend-vertically gizmo overlaps with this
|
||||
("slab", "wall"),
|
||||
("roof", "wall"),
|
||||
],
|
||||
)
|
||||
def test_poll_rejects_host_host_pairs(active_kind, other_kind, patched_tool):
|
||||
"""Host + host pairings must be suppressed so the icon never stacks with
|
||||
the wall-join / extend-vertical / future slab-edit gizmos."""
|
||||
assert _run_poll(patched_tool, active_kind=active_kind, other_kind=other_kind) is False
|
||||
|
||||
|
||||
def test_poll_rejects_active_host_without_has_openings(patched_tool):
|
||||
# Real-world equivalent: an IFC class that the active schema strips
|
||||
# ``HasOpenings`` from (e.g., a non-element subtype). The active sentinel
|
||||
# is set up as a connectable wall but with no HasOpenings attribute.
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
|
||||
|
||||
selected = [object(), object()]
|
||||
active = selected[0]
|
||||
host_sentinel = object() # No HasOpenings attribute
|
||||
other_sentinel = None
|
||||
|
||||
with patched_tool(
|
||||
viewport_gizmos=True,
|
||||
selected=selected,
|
||||
entity=lambda o: host_sentinel if o is selected[0] else other_sentinel,
|
||||
modifier_predicates={"is_path_connectable_wall": lambda e: e is host_sentinel},
|
||||
):
|
||||
assert GizmoHostAddOpening.poll(make_context(active=active, selected=selected)) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# position_gizmos() — branch dispatch and per-branch anchor math
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_position_wall_branch(patched_tool, *, other_translation=(0.5, 0.0, 0.0), top_down=True):
|
||||
"""Drive the wall branch with stub IFC reads, returning the icon's
|
||||
matrix_basis translation."""
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo_module
|
||||
from bonsai.bim.module.model import host_add_opening_gizmo as host_mod
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
|
||||
|
||||
geom = {"anchor_x": 0.0, "length": 2.0, "height": 3.0, "offset": 0.0, "thickness": 0.2}
|
||||
wall_element = object()
|
||||
active = SimpleNamespace(matrix_world=Matrix.Identity(4))
|
||||
other = SimpleNamespace(matrix_world=Matrix.Translation(Vector(other_translation)))
|
||||
selected = [active, other]
|
||||
context = SimpleNamespace(active_object=active)
|
||||
icon = SimpleNamespace(matrix_basis=None, hide=True)
|
||||
self_stub = SimpleNamespace(add_opening_icon=icon)
|
||||
|
||||
with contextlib.ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patched_tool(
|
||||
selected_list=selected,
|
||||
entity=wall_element,
|
||||
modifier_predicates={"is_path_connectable_wall": True},
|
||||
view_top_down=top_down,
|
||||
screen_up=Vector((0.0, 1.0, 0.0)),
|
||||
)
|
||||
)
|
||||
stack.enter_context(patch.object(host_mod, "_get_wall_geom_cached", return_value=geom))
|
||||
stack.enter_context(patch.object(host_mod, "_wall_camera_facing_icon_y", return_value=0.0))
|
||||
stack.enter_context(patch.object(gizmo_module, "get_billboard_rotation", return_value=Matrix.Identity(4)))
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
gizmo_module, "billboarded_at", side_effect=lambda pos, rot, scale=0.5: Matrix.Translation(pos)
|
||||
)
|
||||
)
|
||||
GizmoHostAddOpening.position_gizmos(self_stub, context)
|
||||
return icon.matrix_basis.translation
|
||||
|
||||
|
||||
def test_wall_branch_drops_height_lift_in_top_down_view(patched_tool):
|
||||
"""In plan view the wall-top Z lift must collapse to zero and the icon
|
||||
must instead offset along screen-up — otherwise the icon stacks on top
|
||||
of the wall outline and the user can't see it."""
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
|
||||
pos = _run_position_wall_branch(patched_tool, top_down=True)
|
||||
assert pos.z == pytest.approx(0.0)
|
||||
assert pos.y == pytest.approx(BaseParametricGizmoGroup.SCREEN_STACK_OFFSET)
|
||||
|
||||
|
||||
def _run_position_layer3_branch(
|
||||
patched_tool, *, host_world_z_range=(0.0, 0.2), other_z=1.0, other_xy=(0.7, 0.4), is_path_connectable_wall=False
|
||||
):
|
||||
"""Drive the LAYER3 (slab/roof) branch and return the icon translation.
|
||||
|
||||
``host_world_z_range`` sets the world-Z extents of the host's bounding box
|
||||
(the gizmo picks top vs bottom by comparing the void's Z to the box
|
||||
midpoint). ``is_path_connectable_wall`` keeps a single helper for both
|
||||
branches by flipping the dispatch predicate."""
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo_module
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
|
||||
|
||||
z_min, z_max = host_world_z_range
|
||||
# bound_box returns 8 corners in local space; we only need their world-Z
|
||||
# range to drive the branch, so fix XY at zero and vary Z.
|
||||
local_corners = [(0.0, 0.0, z_min), (0.0, 0.0, z_max)] * 4
|
||||
host_obj = SimpleNamespace(matrix_world=Matrix.Identity(4), bound_box=local_corners)
|
||||
other = SimpleNamespace(matrix_world=Matrix.Translation(Vector((other_xy[0], other_xy[1], other_z))))
|
||||
selected = [host_obj, other]
|
||||
context = SimpleNamespace(active_object=host_obj)
|
||||
icon = SimpleNamespace(matrix_basis=None, hide=True)
|
||||
self_stub = SimpleNamespace(add_opening_icon=icon)
|
||||
|
||||
host_element = object()
|
||||
with contextlib.ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patched_tool(
|
||||
selected_list=selected,
|
||||
entity=host_element,
|
||||
modifier_predicates={"is_path_connectable_wall": is_path_connectable_wall},
|
||||
)
|
||||
)
|
||||
stack.enter_context(patch.object(gizmo_module, "get_billboard_rotation", return_value=Matrix.Identity(4)))
|
||||
stack.enter_context(
|
||||
patch.object(
|
||||
gizmo_module, "billboarded_at", side_effect=lambda pos, rot, scale=0.5: Matrix.Translation(pos)
|
||||
)
|
||||
)
|
||||
GizmoHostAddOpening.position_gizmos(self_stub, context)
|
||||
return icon.matrix_basis.translation
|
||||
|
||||
|
||||
@pytest.mark.parametrize("other_z", [1.0, 0.1, -1.0])
|
||||
def test_layer3_branch_always_parks_above_top_face(patched_tool, other_z):
|
||||
"""Icon parks above the host's top face regardless of the void's Z —
|
||||
predictable height every time. Void's XY is preserved so clicking the
|
||||
icon dispatches the operator at the intended XY position."""
|
||||
from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
|
||||
|
||||
pos = _run_position_layer3_branch(patched_tool, host_world_z_range=(0.0, 0.2), other_z=other_z, other_xy=(0.7, 0.4))
|
||||
assert pos.x == pytest.approx(0.7)
|
||||
assert pos.y == pytest.approx(0.4)
|
||||
assert pos.z == pytest.approx(0.2 + BaseParametricGizmoGroup.ICON_Z_OFFSET)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_supported_host() — predicate totality
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_is_supported_host_returns_false_for_none():
|
||||
"""Total predicate: ``None`` short-circuits to False without raising."""
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
|
||||
|
||||
assert is_supported_host(None) is False
|
||||
|
||||
|
||||
def test_is_supported_host_accepts_bare_ifc_slab():
|
||||
"""The slab branch is class-based — any ``IfcSlab`` qualifies, even
|
||||
without LAYER3 parametric usage. The positioner reads ``obj.bound_box``,
|
||||
which works for both parametric and imported geometry."""
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
|
||||
|
||||
assert is_supported_host(_FakeIfcEntity("IfcSlab")) is True
|
||||
|
||||
|
||||
def test_is_supported_host_accepts_bare_ifc_roof():
|
||||
"""The roof branch is class-based, not pset-based — a bare ``IfcRoof``
|
||||
imported from another IFC tool qualifies even without the Bonsai
|
||||
BBIM_Roof parametric marker that ``tool.Parametric.is_roof``
|
||||
would require."""
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
|
||||
|
||||
assert is_supported_host(_FakeIfcEntity("IfcRoof")) is True
|
||||
|
||||
|
||||
def test_is_supported_host_rejects_non_host_ifc_class():
|
||||
"""Non-host IFC classes are filtered — covers ``IfcCovering`` (which has
|
||||
HasOpenings but is not a wall/slab/roof) and prevents the gizmo from
|
||||
surfacing on arbitrary building elements."""
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
|
||||
|
||||
assert is_supported_host(_FakeIfcEntity("IfcCovering")) is False
|
||||
assert is_supported_host(_FakeIfcEntity("IfcDiscreteAccessory")) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end smoke: gizmo's target operator handles host + mesh-void selection
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The gizmo binds ``bim.add_opening`` via ``setup_icon_gizmo`` — clicking the
|
||||
# icon dispatches that operator with the current selection set. The operator
|
||||
# has its own target/opening detection that swaps based on which selected
|
||||
# object carries an IFC entity. This smoke test pins that handoff: with a
|
||||
# host as the active object and a non-IFC mesh as the "void", the operator
|
||||
# creates an ``IfcOpeningElement`` linked to the host via the standard
|
||||
# ``HasOpenings`` inverse.
|
||||
|
||||
|
||||
class TestAddOpeningIntegrationOnSlab(NewFile):
|
||||
def test_creates_opening_when_slab_is_active_with_mesh_void(self):
|
||||
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
|
||||
bpy.ops.bim.create_project()
|
||||
ifc_file = tool.Ifc.get()
|
||||
slab_type = ifc_file.by_type("IfcSlabType")[0]
|
||||
bpy.ops.bim.add_occurrence(relating_type_id=slab_type.id())
|
||||
slab = ifc_file.by_type("IfcSlab")[0]
|
||||
slab_obj = tool.Ifc.get_object(slab)
|
||||
assert isinstance(slab_obj, bpy.types.Object)
|
||||
assert len(slab.HasOpenings) == 0
|
||||
|
||||
void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh"))
|
||||
bpy.context.scene.collection.objects.link(void_obj)
|
||||
void_obj.matrix_world = void_obj.matrix_world.copy()
|
||||
void_obj.matrix_world.translation = (
|
||||
slab_obj.matrix_world.translation.x,
|
||||
slab_obj.matrix_world.translation.y,
|
||||
slab_obj.matrix_world.translation.z + 1.0,
|
||||
)
|
||||
|
||||
tool.Blender.set_objects_selection(bpy.context, slab_obj, (slab_obj, void_obj))
|
||||
bpy.ops.bim.add_opening()
|
||||
|
||||
assert len(slab.HasOpenings) == 1
|
||||
opening = slab.HasOpenings[0].RelatedOpeningElement
|
||||
assert opening.is_a("IfcOpeningElement")
|
||||
|
||||
|
||||
class TestAddOpeningPollOnForeignAuthoredSlab(NewFile):
|
||||
def test_poll_resolves_true_for_slab_without_layer3_usage(self):
|
||||
"""An ``IfcSlab`` loaded from a non-Bonsai IFC carries no
|
||||
``IfcMaterialLayerSetUsage``, so ``tool.Blender.Modifier.is_slab``
|
||||
rejects it — yet the gizmo's widened predicate accepts any
|
||||
``IfcSlab`` because the positioner only reads the bound box.
|
||||
This pins the bare-class branch through the full ``poll`` path
|
||||
with real bpy + ifcopenshell state."""
|
||||
import ifcopenshell.api.material
|
||||
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import (
|
||||
GizmoHostAddOpening,
|
||||
is_supported_host,
|
||||
)
|
||||
|
||||
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
|
||||
bpy.ops.bim.create_project()
|
||||
ifc_file = tool.Ifc.get()
|
||||
slab_type = ifc_file.by_type("IfcSlabType")[0]
|
||||
bpy.ops.bim.add_occurrence(relating_type_id=slab_type.id())
|
||||
slab = ifc_file.by_type("IfcSlab")[0]
|
||||
slab_obj = tool.Ifc.get_object(slab)
|
||||
assert isinstance(slab_obj, bpy.types.Object)
|
||||
|
||||
# Strip every material association so the slab has no direct
|
||||
# LayerSetUsage and nothing to inherit from the type. The slab is
|
||||
# now a foreign-authored IFC class in everything but provenance.
|
||||
ifcopenshell.api.material.unassign_material(ifc_file, products=[slab, slab_type])
|
||||
assert tool.Blender.Modifier.is_slab(slab) is False
|
||||
assert is_supported_host(slab) is True
|
||||
|
||||
void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh"))
|
||||
bpy.context.scene.collection.objects.link(void_obj)
|
||||
void_obj.matrix_world = void_obj.matrix_world.copy()
|
||||
void_obj.matrix_world.translation = (
|
||||
slab_obj.matrix_world.translation.x,
|
||||
slab_obj.matrix_world.translation.y,
|
||||
slab_obj.matrix_world.translation.z + 1.0,
|
||||
)
|
||||
|
||||
tool.Blender.set_objects_selection(bpy.context, slab_obj, (slab_obj, void_obj))
|
||||
assert GizmoHostAddOpening.poll(bpy.context) is True
|
||||
|
||||
|
||||
class TestAddOpeningPollOnForeignAuthoredRoof(NewFile):
|
||||
def test_poll_resolves_true_for_roof_without_bbim_pset(self):
|
||||
"""A mesh-bodied ``IfcRoof`` promoted from a raw Blender mesh
|
||||
carries no ``BBIM_Roof`` pset, so ``tool.Parametric.is_roof``
|
||||
rejects it — yet the gizmo's widened predicate accepts any
|
||||
``IfcRoof`` because the positioner only reads the bound box. This
|
||||
fixture mirrors how a foreign IFC roof loads (geometry + IFC
|
||||
identity, no parametric markers)."""
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import (
|
||||
GizmoHostAddOpening,
|
||||
is_supported_host,
|
||||
)
|
||||
|
||||
tool.Project.get_project_props().template_file = "IFC4 Demo Template.ifc"
|
||||
bpy.ops.bim.create_project()
|
||||
|
||||
bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0))
|
||||
roof_obj = bpy.context.active_object
|
||||
assert roof_obj is not None
|
||||
tool.Root.get_root_props().ifc_product = "IfcElement"
|
||||
bpy.ops.bim.assign_class(ifc_class="IfcRoof")
|
||||
roof = tool.Ifc.get_entity(roof_obj)
|
||||
assert roof is not None and roof.is_a("IfcRoof")
|
||||
assert tool.Parametric.is_roof(roof) is False
|
||||
assert is_supported_host(roof) is True
|
||||
|
||||
void_obj = bpy.data.objects.new("VoidMesh", bpy.data.meshes.new("VoidMesh"))
|
||||
bpy.context.scene.collection.objects.link(void_obj)
|
||||
void_obj.matrix_world = void_obj.matrix_world.copy()
|
||||
void_obj.matrix_world.translation = (
|
||||
roof_obj.matrix_world.translation.x,
|
||||
roof_obj.matrix_world.translation.y,
|
||||
roof_obj.matrix_world.translation.z + 1.0,
|
||||
)
|
||||
|
||||
tool.Blender.set_objects_selection(bpy.context, roof_obj, (roof_obj, void_obj))
|
||||
assert GizmoHostAddOpening.poll(bpy.context) is True
|
||||
@@ -135,29 +135,30 @@ def test_every_wall_gizmo_group_resolves_get_decoration_colors():
|
||||
)
|
||||
|
||||
|
||||
def test_gizmo_wall_add_opening_accepts_fillet_corner_active():
|
||||
"""``GizmoWallAddOpening.poll`` must gate on ``is_path_connectable_wall``,
|
||||
not the strict ``is_wall`` predicate. Fillet-corner walls carry no LAYER2
|
||||
usage by IFC spec, so the strict predicate rejects them and the
|
||||
add-opening icon never surfaces over a curved corner — symmetry with the
|
||||
join / unjoin / extend wall gizmos (all of which already poll on the
|
||||
looser predicate) is required for the user to drop openings into fillet
|
||||
def test_host_add_opening_accepts_fillet_corner_active():
|
||||
"""``is_supported_host`` (the gate ``GizmoHostAddOpening.poll`` dispatches
|
||||
through) must classify walls via ``is_path_connectable_wall``, not the
|
||||
strict ``is_wall`` predicate. Fillet-corner walls carry no LAYER2 usage
|
||||
by IFC spec, so the strict predicate rejects them and the add-opening
|
||||
icon never surfaces over a curved corner — symmetry with the join /
|
||||
unjoin / extend wall gizmos (all of which already poll on the looser
|
||||
predicate) is required for the user to drop openings into fillet
|
||||
corners at all."""
|
||||
from bonsai.bim.module.model.wall import GizmoWallAddOpening
|
||||
from bonsai.bim.module.model.host_add_opening_gizmo import is_supported_host
|
||||
|
||||
source = textwrap.dedent(inspect.getsource(GizmoWallAddOpening.poll))
|
||||
source = textwrap.dedent(inspect.getsource(is_supported_host))
|
||||
tree = ast.parse(source)
|
||||
attr_names = {node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)}
|
||||
|
||||
assert "is_path_connectable_wall" in attr_names, (
|
||||
"GizmoWallAddOpening.poll must gate on tool.Parametric.is_path_connectable_wall "
|
||||
"for both the active element and the partner-exclusion check. The strict "
|
||||
"is_wall predicate hides the add-opening gizmo over every fillet-corner wall."
|
||||
"is_supported_host must gate walls on tool.Parametric.is_path_connectable_wall. "
|
||||
"The strict is_wall predicate hides the add-opening gizmo over every "
|
||||
"fillet-corner wall."
|
||||
)
|
||||
assert "is_wall" not in attr_names, (
|
||||
"GizmoWallAddOpening.poll must NOT call .is_wall — that strict predicate "
|
||||
"drops fillet-corner walls. Use is_path_connectable_wall instead, matching "
|
||||
"the host gate every other wall-state gizmo group uses."
|
||||
"is_supported_host must NOT call .is_wall — that strict predicate drops "
|
||||
"fillet-corner walls. Use is_path_connectable_wall instead, matching the "
|
||||
"host gate every other wall-state gizmo group uses."
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user