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