diff --git a/src/bonsai/bonsai/bim/module/model/opening.py b/src/bonsai/bonsai/bim/module/model/opening.py index 4f0ded743f..d144bacf69 100644 --- a/src/bonsai/bonsai/bim/module/model/opening.py +++ b/src/bonsai/bonsai/bim/module/model/opening.py @@ -608,6 +608,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: diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index bf9005768f..ad3af1f59c 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -2060,47 +2060,87 @@ 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. Callers + targeting a single user-selected filling should use this rather than + the family-wide variant to avoid touching unrelated sibling sources.""" 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: + 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: 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." + )