diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index bc54335c91..428c999274 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -1619,13 +1619,16 @@ class DumbWallJoiner: r.RelatedOpeningElement for r in list(element1.HasOpenings) if r.RelatedOpeningElement.HasFillings ]: rel = opening.HasFillings[0] - filling = rel.RelatedBuildingElement - filling_obj = tool.Ifc.get_object(filling) - filling_location = filling_obj.matrix_world.translation - _, filling_position = mathutils.geometry.intersect_point_line(filling_location.to_2d(), *axis_world_2d) min_t, max_t = _opening_axis_extent(opening, axis_world_2d, unit_scale) + # Use the opening's axis-projected midpoint to classify the side. + # The filling's ``matrix_world.translation`` is flip-fragile — + # ``flip_object`` rotates 180° + translates so the bbox stays + # visually in place, moving the door origin to the opposite + # corner, which would mis-classify a flipped door centred over + # the cut. + opening_midpoint = (min_t + max_t) / 2 void_straddles = min_t < cut_percentage < max_t - if filling_position > cut_percentage: + if opening_midpoint > cut_percentage: # The filling should be moved from element1 to element2. new_opening = ifcopenshell.api.root.copy_class(tool.Ifc.get(), product=opening) new_opening.VoidsElements[0].RelatingBuildingElement = element2 @@ -1640,13 +1643,16 @@ class DumbWallJoiner: rel.RelatingOpeningElement = new_opening - # Remove the old opening - ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening) - if void_straddles: # Filling moved to element2, but void straddles — add a - # pure-void copy back to element1 so its body still gets cut. - _add_void_copy(element1, new_opening) + # pure-void copy back to element1. Read from the original + # ``opening`` whose ObjectPlacement still references + # element1; ``new_opening`` was rebound to element2 and + # would copy element2's frame instead. + _add_void_copy(element1, opening) + + # Remove the old opening + ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening) elif void_straddles: # Filling stays on element1, but void straddles — add a pure-void # copy to element2 so its body gets cut. diff --git a/src/bonsai/test/bim/module/model/test_wall_split_filled_opening.py b/src/bonsai/test/bim/module/model/test_wall_split_filled_opening.py new file mode 100644 index 0000000000..9bbfcbb88a --- /dev/null +++ b/src/bonsai/test/bim/module/model/test_wall_split_filled_opening.py @@ -0,0 +1,66 @@ +# 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. + +"""Pins two contracts in ``DumbWallJoiner.split``'s filled-opening branch: + +1. Side classification reads the opening's axis-projected midpoint, not + the filling's ``matrix_world.translation``. The filling origin is + flip-fragile — ``flip_object`` rotates the filler 180° + translates so + the bbox stays visually in place, which would mis-classify a flipped + door centred over the cut. +2. When the void straddles the cut and the filling moves to element2, + the void copy for element1 is taken from the ORIGINAL opening (whose + ``ObjectPlacement`` still references element1), not the rebound + ``new_opening`` (whose ``PlacementRelTo`` was swapped to element2).""" + +import inspect + +import pytest + +pytestmark = pytest.mark.wall + + +def _split_source(): + from bonsai.bim.module.model.wall import DumbWallJoiner + + return inspect.getsource(DumbWallJoiner.split) + + +def test_side_classification_uses_opening_midpoint_not_filling_origin(): + source = _split_source() + assert "opening_midpoint" in source + # The pre-fix code projected the filling's world translation onto the + # axis to classify; that path must be gone. + assert "filling_obj.matrix_world.translation" not in source + + +def test_void_copy_reads_from_original_opening_before_remove(): + source = _split_source() + # Locate the "filling moves to element2" branch via the opening + # midpoint check; the void-copy and the trailing remove_feature both + # live inside this branch, after the prior unfilled-opening loops. + branch_start = source.index("if opening_midpoint > cut_percentage:") + branch = source[branch_start:] + add_idx = branch.index("_add_void_copy(element1, opening)") + remove_idx = branch.index("feature.remove_feature(tool.Ifc.get(), feature=opening)") + # Read-from-original is the whole point — the rebound ``new_opening`` + # references element2's frame and would shift the void to element1's + # origin in element2's local coords. + assert add_idx < remove_idx