Bonsai: restore opening regen on recalculate_fill

Commit 82dd1d94d switched RecalculateFill from
bonsai.core.geometry.switch_representation to the surgical
tool.Geometry.recut_host to speed up batched host recuts. The trade-off
was intentional for that scope but dropped the implicit opening body
refresh that switch_representation used to provide: SHIFT+G on a door
whose parametric dimensions had drifted from its opening no longer
resized the opening, so the wall recut still hit a stale mapped source.

Extract a targeted single-source helper on tool.Model
(regenerate_filling_opening_body) that regenerates one filling's
mapped opening body via the existing FilledOpeningGenerator and
inverse-substitutes the new representation across every filling that
shares the mapped source. Refactor the family-wide caller
(update_simple_openings, used by the parametric-edit finish path) to
delegate to the same helper, deduped by source id so fragmented type
families where multiple mapped sources coexist all get refreshed.

Call the targeted helper at the top of RecalculateFill._recalculate_fills
for each distinct source among the selected fillings. All body-
representation lookups go through tool.Geometry.get_body_representation
rather than inlining the ("Model", "Body", "MODEL_VIEW") triple. An AST
forward-compat guard pins the call site.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-07-02 09:08:09 +02:00
parent fdf9970685
commit 6ee3c7a15f
3 changed files with 145 additions and 27 deletions
@@ -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:
+67 -27
View File
@@ -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:
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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."
)