diff --git a/src/bonsai/bonsai/bim/module/geometry/ui.py b/src/bonsai/bonsai/bim/module/geometry/ui.py
index 212e6ca74b..68e3e0fa47 100644
--- a/src/bonsai/bonsai/bim/module/geometry/ui.py
+++ b/src/bonsai/bonsai/bim/module/geometry/ui.py
@@ -17,9 +17,9 @@
# along with Bonsai. If not, see .
import bpy
+import ifcopenshell.util.unit
from bpy.types import Menu, Panel, UIList
-import ifcopenshell.util.unit
import bonsai.bim
import bonsai.tool as tool
from bonsai.bim.helper import prop_with_search
diff --git a/src/bonsai/bonsai/bim/module/model/host_add_opening_gizmo.py b/src/bonsai/bonsai/bim/module/model/host_add_opening_gizmo.py
index 64863fff22..ff0e692bac 100644
--- a/src/bonsai/bonsai/bim/module/model/host_add_opening_gizmo.py
+++ b/src/bonsai/bonsai/bim/module/model/host_add_opening_gizmo.py
@@ -33,6 +33,7 @@ from mathutils import Vector
import bonsai.tool as tool
from bonsai.bim.module.drawing import gizmos as gizmo
+from bonsai.bim.module.model.opening import is_filling_supported
from bonsai.bim.module.model.wall import (
_get_wall_geom_cached,
_wall_camera_facing_icon_y,
@@ -52,6 +53,20 @@ def is_supported_host(element) -> bool:
return tool.Parametric.is_path_connectable_wall(element) or element.is_a("IfcSlab") or element.is_a("IfcRoof")
+def is_supported_filling_or_opening(element) -> bool:
+ """Total predicate for the add-opening gizmo poll. ``None`` (raw Blender
+ mesh) is accepted because the operator converts unclassified meshes
+ into ``IfcOpeningElement`` instances. ``IfcOpeningElement`` is accepted
+ because reassigning an existing opening to a new host is a legal path
+ through the operator. Otherwise defer to the generator's own
+ supported-filling predicate."""
+ if element is None:
+ return True
+ if element.is_a("IfcOpeningElement"):
+ return True
+ return is_filling_supported(element)
+
+
def _resolve_active_host(context: bpy.types.Context, n_selected: int):
"""Shared poll prologue: gizmo gate + selection cardinality + active-in-
selected + IFC entity lookup + supported-host predicate. Returns the
@@ -72,12 +87,14 @@ def _resolve_active_host(context: bpy.types.Context, n_selected: int):
class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin):
- """Activates when a host element (wall / slab / roof) is the active object
- and exactly one other selected object is *not* itself a host.
+ """Activates when exactly two objects are selected and one is a fillable
+ host (wall / slab / roof) while the other is a valid filling (door /
+ window / existing opening, or a plain Blender mesh).
- Renders a single ``VIEW3D_GT_add_opening`` icon at the void object's
- projected location on the host. A click dispatches ``bim.add_opening``,
- which handles any element exposing the ``HasOpenings`` inverse.
+ Selection-order independent: the host role is identified by class, not
+ by active state. The "+" icon anchors on the host's surface regardless
+ of which object was clicked first. The dispatched ``bim.add_opening``
+ operator also handles either order.
Per-frame positioning keeps the icon facing the camera as the viewport
orbits."""
@@ -90,22 +107,29 @@ class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin
@classmethod
def poll(cls, context: bpy.types.Context) -> bool:
- element = _resolve_active_host(context, n_selected=2)
- if element is None:
+ if not _wall_gizmo_poll_gate(context):
return False
- # The operator itself filters on HasOpenings, but checking here keeps
- # the icon from appearing on host classes that can't accept openings
- # in the active IFC schema.
- if not hasattr(element, "HasOpenings"):
+ selected = list(tool.Blender.get_selected_objects())
+ if len(selected) != 2:
return False
active = context.active_object
- other = next(o for o in tool.Blender.get_selected_objects() if o is not active)
- # Host + host pairings are claimed by host-specific gizmos (wall-join,
- # extend-vertical, …) — suppress here so the add-opening icon never
- # stacks on top of them.
- if is_supported_host(tool.Ifc.get_entity(other)):
+ if active is None or active not in selected:
return False
- return True
+ a_element = tool.Ifc.get_entity(selected[0])
+ b_element = tool.Ifc.get_entity(selected[1])
+ return cls._is_apply_opening_pair(a_element, b_element) or cls._is_apply_opening_pair(b_element, a_element)
+
+ @staticmethod
+ def _is_apply_opening_pair(host_element, filling_element) -> bool:
+ """``host_element`` qualifies as a fillable host AND ``filling_element``
+ qualifies as a filling. Used twice with the operands swapped so the
+ gizmo polls true regardless of which of the two selected objects is
+ active."""
+ if not is_supported_host(host_element):
+ return False
+ if not hasattr(host_element, "HasOpenings"):
+ return False
+ return is_supported_filling_or_opening(filling_element)
def setup(self, context: bpy.types.Context) -> None:
default_color, highlight_color = self.get_decoration_colors()
@@ -114,18 +138,20 @@ class GizmoHostAddOpening(bpy.types.GizmoGroup, _WallGeomCachedBillboardingMixin
)
def position_gizmos(self, context: bpy.types.Context) -> None:
- host_obj = context.active_object
- if not host_obj:
+ selected = list(tool.Blender.get_selected_objects())
+ if len(selected) != 2:
return
- selected = tool.Blender.get_selected_objects()
- other = next((o for o in selected if o is not host_obj), None)
- if not other:
- return
- element = tool.Ifc.get_entity(host_obj)
- if not element:
+ a, b = selected[0], selected[1]
+ a_element = tool.Ifc.get_entity(a)
+ b_element = tool.Ifc.get_entity(b)
+ if is_supported_host(a_element):
+ host_obj, host_element, other = a, a_element, b
+ elif is_supported_host(b_element):
+ host_obj, host_element, other = b, b_element, a
+ else:
return
- if tool.Parametric.is_path_connectable_wall(element):
+ if tool.Parametric.is_path_connectable_wall(host_element):
world_pos = wall_anchor(context, self, host_obj, other)
else:
world_pos = layer3_anchor(host_obj, other)
diff --git a/src/bonsai/bonsai/bim/module/model/opening.py b/src/bonsai/bonsai/bim/module/model/opening.py
index 4f0ded743f..0508883c35 100644
--- a/src/bonsai/bonsai/bim/module/model/opening.py
+++ b/src/bonsai/bonsai/bim/module/model/opening.py
@@ -240,6 +240,15 @@ def _store_batch_in_cache(cache_key: tuple[int, str], batch: "gpu.types.GPUBatch
_batch_cache[cache_key] = (epoch, batch)
+def is_filling_supported(element) -> bool:
+ """True when Bonsai's opening generator can derive an opening from this
+ element. IFC's schema permits any IfcElement as a filling; Bonsai
+ currently supports only IfcDoor and IfcWindow because those are the
+ classes with OverallWidth/OverallHeight attributes (or their types'
+ ELEVATION_VIEW profiles) that the generator can consume."""
+ return element is not None and element.is_a() in ("IfcDoor", "IfcWindow")
+
+
class FilledOpeningGenerator:
def generate(
self,
@@ -608,6 +617,25 @@ class RecalculateFill(bpy.types.Operator, tool.Ifc.Operator):
return self._recalculate_fills(context)
def _recalculate_fills(self, context):
+ # Refresh each selected filling's mapped opening source before
+ # recutting the host. Dedup by source id covers the common shared-
+ # source case in one rewrite while leaving unrelated sibling sources
+ # untouched.
+ seen_source_ids: set[int] = set()
+ for obj in context.selected_objects:
+ element = tool.Ifc.get_entity(obj)
+ if not element or not element.FillsVoids:
+ continue
+ opening = element.FillsVoids[0].RelatingOpeningElement
+ body = tool.Geometry.get_body_representation(opening)
+ if body is None:
+ continue
+ source = tool.Geometry.resolve_mapped_representation(body)
+ if source.id() in seen_source_ids:
+ continue
+ seen_source_ids.add(source.id())
+ tool.Model.regenerate_filling_opening_body(element)
+
for obj in context.selected_objects:
element = tool.Ifc.get_entity(obj)
if not element or not element.FillsVoids:
@@ -958,27 +986,29 @@ class EditOpenings(Operator, tool.Ifc.Operator):
for opening_element in opening_elements:
opening_obj = tool.Ifc.get_object(opening_element)
- similar_openings = bonsai.core.geometry.get_similar_openings(tool.Ifc, opening_element)
- similar_openings_building_objs = bonsai.core.geometry.get_similar_openings_building_objs(
- tool.Ifc, similar_openings
- )
- building_objs.update(similar_openings_building_objs)
-
if opening_obj:
- if tool.Ifc.is_edited(opening_obj):
- tool.Geometry.run_geometry_update_representation(obj=opening_obj)
- bonsai.core.geometry.edit_similar_opening_placement(
- tool.Geometry, opening_element, similar_openings
- )
- elif tool.Ifc.is_moved(opening_obj):
- bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=opening_obj)
+ opening_edited = tool.Ifc.is_edited(opening_obj)
+ opening_moved = tool.Ifc.is_moved(opening_obj)
+ # Sibling walls only need a viewport-level refresh when the
+ # opening's shape or placement actually changed — a pure
+ # show/hide toggle leaves them in their existing state.
+ if opening_edited or opening_moved:
+ similar_openings = bonsai.core.geometry.get_similar_openings(tool.Ifc, opening_element)
+ similar_openings_building_objs = bonsai.core.geometry.get_similar_openings_building_objs(
+ tool.Ifc, similar_openings
+ )
+ building_objs.update(similar_openings_building_objs)
+ if opening_edited:
+ tool.Geometry.run_geometry_update_representation(obj=opening_obj)
+ else:
+ bonsai.core.geometry.edit_object_placement(
+ tool.Ifc, tool.Geometry, tool.Surveyor, obj=opening_obj
+ )
bonsai.core.geometry.edit_similar_opening_placement(
tool.Geometry, opening_element, similar_openings
)
+ building_objs.update(self.get_all_building_objects_of_similar_openings(opening_element))
- building_objs.update(
- self.get_all_building_objects_of_similar_openings(opening_element)
- ) # NB this has nothing to do with clone similar_opening
tool.Ifc.unlink(element=opening_element)
if props.representation_obj == opening_obj:
props.representation_obj = None
diff --git a/src/bonsai/bonsai/bim/module/model/ui.py b/src/bonsai/bonsai/bim/module/model/ui.py
index e2b73dfe3d..13b918e5fd 100644
--- a/src/bonsai/bonsai/bim/module/model/ui.py
+++ b/src/bonsai/bonsai/bim/module/model/ui.py
@@ -22,9 +22,9 @@ from collections.abc import Iterable
from typing import TYPE_CHECKING, Any
import bpy
+import ifcopenshell.util.unit
from bpy.types import Panel
-import ifcopenshell.util.unit
import bonsai.bim
import bonsai.tool as tool
from bonsai.bim.helper import prop_with_search
diff --git a/src/bonsai/bonsai/bim/module/void/operator.py b/src/bonsai/bonsai/bim/module/void/operator.py
index 4f5d18f56c..433ea3a06b 100644
--- a/src/bonsai/bonsai/bim/module/void/operator.py
+++ b/src/bonsai/bonsai/bim/module/void/operator.py
@@ -26,7 +26,7 @@ import bonsai.bim.handler
import bonsai.core.geometry
import bonsai.core.root
import bonsai.tool as tool
-from bonsai.bim.module.model.opening import FilledOpeningGenerator
+from bonsai.bim.module.model.opening import FilledOpeningGenerator, is_filling_supported
class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
@@ -34,11 +34,13 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Apply Opening"
bl_options = {"REGISTER", "UNDO"}
bl_description = (
- "Apply opening objects to an Element.\n\n"
- "The Element and the openings to be applied should be selected. The order of selection is not important.\n"
- "Opening can be just a Blender mesh object.\n\n"
- "Shift+click: keep the filling at its current matrix_world — skip the wall-axis snap "
- "and the rl1/rl2 Z-elevation default that the regular click applies."
+ "Cuts openings in a wall, slab, or roof using selected shape objects — "
+ "doors, windows, existing openings, or plain (non-IFC) meshes. "
+ "Selection order doesn't matter.\n\n"
+ "Doors and windows also fill the opening. Other IFC classes are currently "
+ "unsupported by the opening generator and get skipped with a warning.\n\n"
+ "Shift+click: keep each opening at its shape object's current position "
+ "instead of snapping to the wall."
)
# Toggled by ``invoke`` when the user holds SHIFT during a gizmo / hotkey
@@ -84,8 +86,14 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
self.report({"INFO"}, "You can't add an opening to another opening.")
continue
elif not element1.is_a("IfcOpeningElement") and not element2.is_a("IfcOpeningElement"):
- if element1.is_a("IfcWindow") or element1.is_a("IfcDoor"): # Add a fill to an element.
+ if is_filling_supported(element1): # Add a fill to an element.
obj1, obj2 = obj2, obj1
+ elif not is_filling_supported(element2):
+ self.report(
+ {"INFO"},
+ f"Cannot apply {element2.is_a()} as an opening — Bonsai currently supports only IfcDoor and IfcWindow as parametric fillings.",
+ )
+ continue
FilledOpeningGenerator().generate(
obj2,
obj1,
diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py
index bf9005768f..dee8c218f2 100644
--- a/src/bonsai/bonsai/tool/model.py
+++ b/src/bonsai/bonsai/tool/model.py
@@ -2060,47 +2060,86 @@ class Model(bonsai.core.tool.Model):
return (vertices, edges, faces)
@classmethod
- def update_simple_openings(cls, element: ifcopenshell.entity_instance) -> None:
+ def regenerate_filling_opening_body(cls, filling: ifcopenshell.entity_instance) -> Optional[bpy.types.Object]:
+ """Regenerate only the mapped source used by ``filling``'s opening so
+ it matches ``filling``'s current parametric dimensions.
+
+ Returns the voided host Blender object so the caller can recut it,
+ or ``None`` if ``filling`` has no opening to refresh or the host is
+ an aggregate (no mesh data to recut against)."""
from bonsai.bim.module.model.opening import FilledOpeningGenerator
- ifc_file = tool.Ifc.get()
- fillings = {e: tool.Ifc.get_object(e) for e in tool.Array.get_parametric_propagation_targets(element)}
+ if not filling.FillsVoids:
+ return None
- voided_objs = set()
- has_replaced_opening_representation = False
+ ifc_file = tool.Ifc.get()
+ opening = filling.FillsVoids[0].RelatingOpeningElement
+ voided_obj = tool.Ifc.get_object(opening.VoidsElements[0].RelatingBuildingElement)
+ if voided_obj is None or voided_obj.data is None:
+ return None
+
+ old_representation = tool.Geometry.get_body_representation(opening)
+ if old_representation is None:
+ return voided_obj
+ old_representation = tool.Geometry.resolve_mapped_representation(old_representation)
+
+ ifcopenshell.api.geometry.unassign_representation(ifc_file, product=opening, representation=old_representation)
+
+ filling_obj = tool.Ifc.get_object(filling)
+ new_representation = FilledOpeningGenerator().generate_opening_from_filling(
+ filling, filling_obj, voided_obj.dimensions[1]
+ )
+
+ for inverse in ifc_file.get_inverse(old_representation):
+ ifcopenshell.util.element.replace_attribute(inverse, old_representation, new_representation)
+
+ ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_representation)
+
+ return voided_obj
+
+ @classmethod
+ def regenerate_simple_opening_bodies(cls, element: ifcopenshell.entity_instance) -> set:
+ """Regenerate every distinct mapped opening source within ``element``'s
+ type-occurrence family so each one matches the family's current
+ parametric dimensions.
+
+ Most occurrences share a single mapped source — refreshing it once
+ propagates to every filling via inverse-substitution. Some families,
+ especially those imported from foreign authoring tools, fragment into
+ several mapped sources for the same type; dedup is by source id so
+ every distinct source gets one refresh. Returns the set of Blender
+ objects whose host representation needs a viewport-level recut
+ (callers handle the recut themselves)."""
+ ifc_file = tool.Ifc.get()
+ fillings = list(tool.Array.get_parametric_propagation_targets(element))
+
+ voided_objs: set = set()
+ seen_source_ids: set[int] = set()
for filling in fillings:
if not filling.FillsVoids:
continue
opening = filling.FillsVoids[0].RelatingOpeningElement
voided_obj = tool.Ifc.get_object(opening.VoidsElements[0].RelatingBuildingElement)
- voided_objs.add(voided_obj)
+ if voided_obj is not None:
+ voided_objs.add(voided_obj)
- # We assume all occurrences of the same element type (e.g. a window)
- # will use openings of the same thickness.
- # Generator we use by default will create a really thick opening representation
- # to make sure it will fit for walls with different thickness.
- if has_replaced_opening_representation:
+ body = tool.Geometry.get_body_representation(opening)
+ if body is None:
continue
+ source = tool.Geometry.resolve_mapped_representation(body)
+ if source.id() in seen_source_ids:
+ continue
+ seen_source_ids.add(source.id())
- old_representation = ifcopenshell.util.representation.get_representation(
- opening, "Model", "Body", "MODEL_VIEW"
- )
- old_representation = tool.Geometry.resolve_mapped_representation(old_representation)
- ifcopenshell.api.geometry.unassign_representation(
- ifc_file, product=opening, representation=old_representation
- )
+ cls.regenerate_filling_opening_body(filling)
- new_representation = FilledOpeningGenerator().generate_opening_from_filling(
- filling, fillings[filling], voided_obj.dimensions[1]
- )
+ return voided_objs
- for inverse in ifc_file.get_inverse(old_representation):
- ifcopenshell.util.element.replace_attribute(inverse, old_representation, new_representation)
-
- ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_representation)
-
- has_replaced_opening_representation = True
+ @classmethod
+ def update_simple_openings(cls, element: ifcopenshell.entity_instance) -> None:
+ voided_objs = cls.regenerate_simple_opening_bodies(element)
+ fillings = {e: tool.Ifc.get_object(e) for e in tool.Array.get_parametric_propagation_targets(element)}
tool.Model.reload_body_representation(voided_objs)
if fillings:
@@ -3108,6 +3147,26 @@ class Model(bonsai.core.tool.Model):
obj = tool.Ifc.get_object(rel.RelatingElement)
tool.Geometry.commit_placement_if_moved(obj)
queue.add((rel.RelatingElement, obj))
+
+ # Sync filling and opening placements so subsequent wall recuts
+ # operate on the up-to-date opening positions — a filling moved
+ # along the wall's reference line otherwise stays cut at its old
+ # spot.
+ for element, wall in queue:
+ if not wall:
+ continue
+ for rel in getattr(element, "HasOpenings", []) or []:
+ opening = rel.RelatedOpeningElement
+ for fill_rel in getattr(opening, "HasFillings", []) or []:
+ filling = fill_rel.RelatedBuildingElement
+ filling_obj = tool.Ifc.get_object(filling)
+ if filling_obj is None or not tool.Ifc.is_moved(filling_obj):
+ continue
+ bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=filling_obj)
+ ifcopenshell.api.geometry.edit_object_placement(
+ tool.Ifc.get(), product=opening, matrix=filling_obj.matrix_world
+ )
+
for element, wall in queue:
if not wall:
continue
diff --git a/src/bonsai/test/bim/module/model/test_host_add_opening_gizmo.py b/src/bonsai/test/bim/module/model/test_host_add_opening_gizmo.py
index 9e42ec34c5..86275c660e 100644
--- a/src/bonsai/test/bim/module/model/test_host_add_opening_gizmo.py
+++ b/src/bonsai/test/bim/module/model/test_host_add_opening_gizmo.py
@@ -51,22 +51,29 @@ _IFC_CLASS_BY_KIND = {
"slab": "IfcSlab",
"roof": "IfcRoof",
"plain": "IfcDiscreteAccessory",
+ "door": "IfcDoor",
+ "window": "IfcWindow",
+ "opening": "IfcOpeningElement",
+ "covering": "IfcCovering",
}
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)."""
+ Mirrors ``ifcopenshell.entity_instance.is_a``'s two call shapes:
+ ``is_a("Foo")`` returns True when the entity's class is ``Foo``, and
+ ``is_a()`` returns the class name as a string. ``HasOpenings`` is
+ optional so the poll's ``hasattr`` guard branch is reachable."""
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:
+ def is_a(self, type_name: str | None = None):
+ if type_name is None:
+ return self._ifc_class
return self._ifc_class == type_name
@@ -168,6 +175,40 @@ def test_poll_rejects_host_host_pairs(active_kind, other_kind, patched_tool):
assert _run_poll(patched_tool, active_kind=active_kind, other_kind=other_kind) is False
+@pytest.mark.parametrize("filling_kind", ["door", "window", "opening", "mesh"])
+def test_poll_accepts_host_with_supported_filling(filling_kind, patched_tool):
+ """The apply-opening gizmo must activate when the secondary selection
+ is a class the operator can dispatch on: ``IfcDoor`` / ``IfcWindow``
+ (filled openings), ``IfcOpeningElement`` (existing opening reassigned
+ to a new host), or a raw Blender mesh (converted to an opening)."""
+ assert _run_poll(patched_tool, active_kind="wall", other_kind=filling_kind) is True
+
+
+@pytest.mark.parametrize("non_filling_kind", ["covering", "plain"])
+def test_poll_rejects_host_with_non_filling(non_filling_kind, patched_tool):
+ """An IFC entity whose class the apply-opening operator can't dispatch
+ on must keep the gizmo hidden — clicking it would otherwise dispatch
+ the operator on a class whose geometry the opening generator can't
+ derive, causing a deep traceback in the geometry kernel."""
+ assert _run_poll(patched_tool, active_kind="wall", other_kind=non_filling_kind) is False
+
+
+@pytest.mark.parametrize("filling_kind", ["door", "window", "opening", "mesh"])
+def test_poll_accepts_filling_active_with_host_other(filling_kind, patched_tool):
+ """The poll must be selection-order independent: the icon should appear
+ whether the user clicked the host first or the filling first. The
+ operator handles either order, so the gizmo should match."""
+ assert _run_poll(patched_tool, active_kind=filling_kind, other_kind="wall") is True
+
+
+@pytest.mark.parametrize("non_filling_kind", ["covering", "plain"])
+def test_poll_rejects_non_filling_active_with_host_other(non_filling_kind, patched_tool):
+ """The selection-order independence must not loosen the filling
+ predicate — covering + wall stays rejected regardless of which is
+ active."""
+ assert _run_poll(patched_tool, active_kind=non_filling_kind, other_kind="wall") 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
@@ -265,12 +306,16 @@ def _run_position_layer3_branch(
icon = SimpleNamespace(matrix_basis=None, hide=True)
self_stub = SimpleNamespace(add_opening_icon=icon)
- host_element = object()
+ # Host identification in the gizmo branches on the entity's class, so
+ # the sentinel must respond to ``is_a``. The non-host selection has no
+ # IFC entity (mesh-like) and is accepted as a filling.
+ host_element = _FakeIfcEntity("IfcSlab")
+ entity_map = {id(host_obj): host_element, id(other): None}
with contextlib.ExitStack() as stack:
stack.enter_context(
patched_tool(
selected_list=selected,
- entity=host_element,
+ entity=lambda o: entity_map.get(id(o)),
modifier_predicates={"is_path_connectable_wall": is_path_connectable_wall},
)
)
@@ -297,6 +342,50 @@ def test_layer3_branch_always_parks_above_top_face(patched_tool, other_z):
assert pos.z == pytest.approx(0.2 + BaseParametricGizmoGroup.ICON_Z_OFFSET)
+def test_position_gizmos_identifies_host_by_class_when_selected_second(patched_tool):
+ """Host role in ``position_gizmos`` is resolved by IFC class, not by
+ active-object position — so a slab clicked SECOND (filling first,
+ host active or not) still anchors the icon correctly on the slab.
+ This pins the selection-order independence of the positioner (the
+ poll's independence is covered separately by the poll parametrize)."""
+ from bonsai.bim.module.drawing import gizmos as gizmo_module
+ from bonsai.bim.module.model.host_add_opening_gizmo import GizmoHostAddOpening
+
+ other = SimpleNamespace(matrix_world=Matrix.Translation(Vector((0.7, 0.4, 1.0))))
+ host_obj = SimpleNamespace(matrix_world=Matrix.Identity(4), bound_box=[(0.0, 0.0, 0.0), (0.0, 0.0, 0.2)] * 4)
+ # Host at index 1; the filling (no IFC entity) sits at index 0 as active.
+ selected = [other, host_obj]
+ context = SimpleNamespace(active_object=other)
+ icon = SimpleNamespace(matrix_basis=None, hide=True)
+ self_stub = SimpleNamespace(add_opening_icon=icon)
+
+ entity_map = {id(host_obj): _FakeIfcEntity("IfcSlab"), id(other): None}
+ with contextlib.ExitStack() as stack:
+ stack.enter_context(
+ patched_tool(
+ selected_list=selected,
+ entity=lambda o: entity_map.get(id(o)),
+ modifier_predicates={"is_path_connectable_wall": False},
+ )
+ )
+ 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)
+
+ # Icon anchors on the host's top face (slab bound_box top-Z = 0.2) at
+ # the void's XY — same result as when the host was at index 0.
+ from bonsai.bim.module.drawing.gizmos import BaseParametricGizmoGroup
+
+ pos = icon.matrix_basis.translation
+ 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
# ---------------------------------------------------------------------------
diff --git a/src/bonsai/test/bim/module/model/test_recalculate_fill_forward_compat.py b/src/bonsai/test/bim/module/model/test_recalculate_fill_forward_compat.py
new file mode 100644
index 0000000000..7c56914226
--- /dev/null
+++ b/src/bonsai/test/bim/module/model/test_recalculate_fill_forward_compat.py
@@ -0,0 +1,59 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+"""AST contract: ``RecalculateFill`` must invoke
+``regenerate_simple_opening_bodies`` before recutting hosts.
+
+Hosts recut with a surgical mesh-only path don't refresh the shared mapped
+opening source — so any change to a parametric filling's dimensions stays
+invisible at the opening boundary until the body representation is
+regenerated. Pinning the call site forces future refactors to keep the
+regen step in place."""
+
+import ast
+from pathlib import Path
+
+import pytest
+
+pytestmark = pytest.mark.model
+
+
+def _recalculate_fill_body_source() -> str:
+ from bonsai.bim.module.model import opening as opening_module
+
+ source = Path(opening_module.__file__).read_text(encoding="utf-8")
+ tree = ast.parse(source)
+ for node in ast.walk(tree):
+ if isinstance(node, ast.ClassDef) and node.name == "RecalculateFill":
+ for child in node.body:
+ if isinstance(child, ast.FunctionDef) and child.name == "_recalculate_fills":
+ return ast.unparse(child)
+ raise AssertionError("RecalculateFill._recalculate_fills was not found in opening.py")
+
+
+def test_recalculate_fill_regenerates_opening_bodies_before_recut():
+ body = _recalculate_fill_body_source()
+ assert "regenerate_filling_opening_body" in body, (
+ "RecalculateFill._recalculate_fills must call "
+ "tool.Model.regenerate_filling_opening_body for each selected "
+ "filling before recutting the host. Without that call the host is "
+ "recut against a stale shared mapped opening source, so changes "
+ "to filling dimensions never surface."
+ )