mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 09:21:46 +00:00
Fix wall split: keep straddling openings on both walls
DumbWallJoiner.split assigned openings by projecting the opening's centre-point onto the wall axis, so any opening whose footprint straddled the cut was silently dropped from whichever wall its centre missed. Now the full axis-projected extent (via ifcopenshell.geom. create_shape) drives the assignment; for filled openings whose void straddles the cut, a pure-void copy is added back to the neighbour wall so its body is also cut. Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -23,7 +23,7 @@
|
||||
import copy
|
||||
import math
|
||||
from math import atan2, cos, degrees, pi, sin
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal, Union, get_args
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal, Optional, Union, get_args
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
@@ -34,9 +34,11 @@ import ifcopenshell.api.material
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.api.type
|
||||
import ifcopenshell.geom
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.placement
|
||||
import ifcopenshell.util.representation
|
||||
import ifcopenshell.util.shape
|
||||
import ifcopenshell.util.shape_builder
|
||||
import ifcopenshell.util.type
|
||||
import ifcopenshell.util.unit
|
||||
@@ -1191,6 +1193,62 @@ class DumbWallPlaner:
|
||||
tool.Model.recalculate_walls([w for w in set(walls) if w])
|
||||
|
||||
|
||||
def _opening_axis_extent(opening, axis_reference, unit_scale):
|
||||
"""Return ``(min_t, max_t)``: the opening's world-space footprint
|
||||
projected onto ``axis_reference`` as parametric positions along the
|
||||
wall axis (``0`` is the start of the axis line, ``1`` is its end).
|
||||
Used to detect openings whose footprint straddles a cut.
|
||||
|
||||
Computed via ``ifcopenshell.geom.create_shape`` so the result is
|
||||
correct for any representation type Bonsai may produce — mapped
|
||||
representations, swept-area solids, breps, boolean clips, etc. —
|
||||
without needing a Blender object (Bonsai hides openings after
|
||||
``bim.add_opening``). Falls back to a degenerate single-point range
|
||||
at the placement origin only when the geometry kernel cannot build
|
||||
a shape from the opening."""
|
||||
verts = None
|
||||
shape_matrix: Optional[Matrix] = None
|
||||
try:
|
||||
settings = ifcopenshell.geom.settings()
|
||||
shape = ifcopenshell.geom.create_shape(settings, opening)
|
||||
verts = ifcopenshell.util.shape.get_vertices(shape.geometry)
|
||||
shape_matrix = Matrix(ifcopenshell.util.shape.get_shape_matrix(shape).tolist())
|
||||
except Exception:
|
||||
verts = None
|
||||
shape_matrix = None
|
||||
|
||||
if verts is None or shape_matrix is None or len(verts) == 0:
|
||||
placement = Matrix(ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement).tolist())
|
||||
placement.translation *= unit_scale
|
||||
_, t = mathutils.geometry.intersect_point_line(placement.translation.to_2d(), *axis_reference)
|
||||
return t, t
|
||||
|
||||
positions = []
|
||||
for v in verts:
|
||||
world = (shape_matrix @ Vector((float(v[0]), float(v[1]), float(v[2])))).to_2d()
|
||||
_, t = mathutils.geometry.intersect_point_line(world, *axis_reference)
|
||||
positions.append(t)
|
||||
return min(positions), max(positions)
|
||||
|
||||
|
||||
def _add_void_copy(building_element, source_opening):
|
||||
"""Add an unfilled IfcOpeningElement to ``building_element`` whose
|
||||
geometry and placement mirror ``source_opening``. Used when a filled
|
||||
opening's void straddles a wall split — the filling stays on its wall,
|
||||
but the void must also apply to the neighbour so its body gets cut."""
|
||||
void_copy = ifcopenshell.api.root.copy_class(tool.Ifc.get(), product=source_opening)
|
||||
for fill_rel in list(void_copy.HasFillings or ()):
|
||||
tool.Ifc.get().remove(fill_rel)
|
||||
void_copy.VoidsElements[0].RelatingBuildingElement = building_element
|
||||
if void_copy.ObjectPlacement and void_copy.ObjectPlacement.is_a("IfcLocalPlacement"):
|
||||
if building_element.ObjectPlacement:
|
||||
void_copy.ObjectPlacement.PlacementRelTo = building_element.ObjectPlacement
|
||||
if source_opening.Representation:
|
||||
void_copy.Representation = ifcopenshell.util.element.copy_deep(
|
||||
tool.Ifc.get(), source_opening.Representation, exclude=["IfcGeometricRepresentationContext"]
|
||||
)
|
||||
|
||||
|
||||
class DumbWallJoiner:
|
||||
def __init__(self):
|
||||
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
||||
@@ -1255,39 +1313,44 @@ class DumbWallJoiner:
|
||||
)
|
||||
|
||||
# During the duplication process, unfilled voids are copied, so we need
|
||||
# to check openings on both element1 and element2. Let's check element1
|
||||
# first.
|
||||
# to check openings on both element1 and element2. Each wall keeps the
|
||||
# opening when the opening's axis-projected extent overlaps that wall's
|
||||
# portion of the axis — straddling openings are intentionally kept on
|
||||
# both walls so each wall body gets the appropriate cut. Strict
|
||||
# inequalities mean a boundary-only touch (or a degenerate single-point
|
||||
# extent at the cut) keeps the opening on both walls — the safer
|
||||
# default when the helper cannot resolve a true bounding range.
|
||||
for opening in [
|
||||
r.RelatedOpeningElement for r in element1.HasOpenings if not r.RelatedOpeningElement.HasFillings
|
||||
]:
|
||||
opening_matrix = Matrix(ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement).tolist())
|
||||
opening_matrix.translation *= unit_scale
|
||||
opening_location = opening_matrix.translation
|
||||
_, opening_position = mathutils.geometry.intersect_point_line(opening_location.to_2d(), *axis1["reference"])
|
||||
if opening_position > cut_percentage:
|
||||
# The opening should be removed from element1.
|
||||
min_t, _ = _opening_axis_extent(opening, axis1["reference"], unit_scale)
|
||||
if min_t > cut_percentage:
|
||||
# Opening lies entirely past the cut — only element2 should keep it.
|
||||
ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
|
||||
|
||||
# Now let's check element2.
|
||||
for opening in [
|
||||
r.RelatedOpeningElement for r in element2.HasOpenings if not r.RelatedOpeningElement.HasFillings
|
||||
]:
|
||||
opening_matrix = Matrix(ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement).tolist())
|
||||
opening_matrix.translation *= unit_scale
|
||||
opening_location = opening_matrix.translation
|
||||
_, opening_position = mathutils.geometry.intersect_point_line(opening_location.to_2d(), *axis1["reference"])
|
||||
if opening_position < cut_percentage:
|
||||
# The opening should be removed from element2.
|
||||
_, max_t = _opening_axis_extent(opening, axis1["reference"], unit_scale)
|
||||
if max_t < cut_percentage:
|
||||
# Opening lies entirely before the cut — only element1 should keep it.
|
||||
ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
|
||||
|
||||
# During the duplication process, filled voids are not copied. So we
|
||||
# only need to check fillings on the original element1.
|
||||
for opening in [r.RelatedOpeningElement for r in element1.HasOpenings if r.RelatedOpeningElement.HasFillings]:
|
||||
# only need to check fillings on the original element1. The filling
|
||||
# (door/window) belongs to whichever wall contains its center, but the
|
||||
# void may need to apply to both walls when the void's extent straddles
|
||||
# the cut — otherwise the neighbour wall's body would not be cut.
|
||||
for opening in [
|
||||
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(), *axis1["reference"])
|
||||
min_t, max_t = _opening_axis_extent(opening, axis1["reference"], unit_scale)
|
||||
void_straddles = min_t < cut_percentage < max_t
|
||||
if filling_position > cut_percentage:
|
||||
# The filling should be moved from element1 to element2.
|
||||
new_opening = ifcopenshell.api.root.copy_class(tool.Ifc.get(), product=opening)
|
||||
@@ -1306,6 +1369,15 @@ class DumbWallJoiner:
|
||||
# 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)
|
||||
elif void_straddles:
|
||||
# Filling stays on element1, but void straddles — add a pure-void
|
||||
# copy to element2 so its body gets cut.
|
||||
_add_void_copy(element2, opening)
|
||||
|
||||
p1, p2 = ifcopenshell.util.representation.get_reference_line(element1)
|
||||
p3 = (wall1.matrix_world.inverted() @ intersect.to_3d()).to_2d() / unit_scale
|
||||
self.set_axis(element1, p1, p3)
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
# 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.
|
||||
|
||||
"""Regression tests for wall-split opening assignment when the cut passes
|
||||
through an opening.
|
||||
|
||||
Bug repro before the fix: when ``bpy.ops.bim.split_wall`` (Shift+K) cut a wall
|
||||
through an opening, ``DumbWallJoiner.split`` decided opening assignment using
|
||||
the opening's centre-point projected onto the wall axis. Any opening whose
|
||||
extent straddled the cut was therefore assigned to whichever side its centre
|
||||
sat on, leaving the neighbour wall with no void where the opening overlapped.
|
||||
|
||||
The fix replaces the single-point test with an axis-projected extent
|
||||
(``_opening_axis_extent``): an opening is removed from a wall only when its
|
||||
extent lies *entirely* outside that wall's portion of the axis. Straddling
|
||||
openings stay on both walls."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import bpy
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.wall
|
||||
|
||||
|
||||
def _fake_shape(verts_local, matrix_world_4x4):
|
||||
"""Build a stand-in for the ``shape`` object returned by
|
||||
``ifcopenshell.geom.create_shape``. ``get_vertices`` and
|
||||
``get_shape_matrix`` are mocked separately to read off this stand-in."""
|
||||
import numpy as np
|
||||
|
||||
shape = MagicMock(name="shape")
|
||||
shape.geometry = MagicMock(name="geometry")
|
||||
shape._verts = np.asarray(verts_local, dtype=np.float64)
|
||||
shape._matrix = np.asarray(matrix_world_4x4, dtype=np.float64)
|
||||
return shape
|
||||
|
||||
|
||||
def test_opening_axis_extent_uses_geometry_kernel_vertices():
|
||||
"""``_opening_axis_extent`` drives ``ifcopenshell.geom.create_shape`` to
|
||||
get the opening's real geometry vertices and ``get_shape_matrix`` to get
|
||||
its world placement, then projects the world-space corners onto the wall
|
||||
axis. This is the production path — works for every representation type
|
||||
Bonsai may produce (mapped representation, swept area, brep, boolean).
|
||||
|
||||
A unit cube centred at world X=5 on a 10m wall axis projects to
|
||||
t ∈ [0.45, 0.55] (the cube spans 0.5m on each axis around the centre)."""
|
||||
from bonsai.bim.module.model.wall import _opening_axis_extent
|
||||
|
||||
# Unit cube in local coords, centred at (0,0,0), extent ±0.5.
|
||||
verts_local = [
|
||||
(-0.5, -0.5, -0.5),
|
||||
(0.5, -0.5, -0.5),
|
||||
(0.5, 0.5, -0.5),
|
||||
(-0.5, 0.5, -0.5),
|
||||
(-0.5, -0.5, 0.5),
|
||||
(0.5, -0.5, 0.5),
|
||||
(0.5, 0.5, 0.5),
|
||||
(-0.5, 0.5, 0.5),
|
||||
]
|
||||
matrix_world = [
|
||||
[1.0, 0.0, 0.0, 5.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
]
|
||||
fake_shape = _fake_shape(verts_local, matrix_world)
|
||||
opening = MagicMock(name="opening")
|
||||
axis_reference = (
|
||||
__import__("mathutils").Vector((0.0, 0.0)),
|
||||
__import__("mathutils").Vector((10.0, 0.0)),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("ifcopenshell.geom.create_shape", return_value=fake_shape),
|
||||
patch("ifcopenshell.util.shape.get_vertices", return_value=fake_shape._verts),
|
||||
patch("ifcopenshell.util.shape.get_shape_matrix", return_value=fake_shape._matrix),
|
||||
):
|
||||
min_t, max_t = _opening_axis_extent(opening, axis_reference, unit_scale=1.0)
|
||||
|
||||
# Cube spans world X ∈ [4.5, 5.5] → t ∈ [0.45, 0.55].
|
||||
assert min_t == pytest.approx(0.45)
|
||||
assert max_t == pytest.approx(0.55)
|
||||
|
||||
|
||||
def test_opening_axis_extent_falls_back_to_placement_when_geometry_kernel_fails():
|
||||
"""When ``ifcopenshell.geom.create_shape`` raises (representation it
|
||||
can't process), the helper falls back to a degenerate single-point range
|
||||
at the opening's composed placement origin. This is the safety net — it
|
||||
matches the pre-fix center-only semantics rather than dropping the
|
||||
opening entirely."""
|
||||
from bonsai.bim.module.model.wall import _opening_axis_extent
|
||||
|
||||
opening = MagicMock(name="opening")
|
||||
placement_matrix = [
|
||||
[1.0, 0.0, 0.0, 5.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
]
|
||||
axis_reference = (
|
||||
__import__("mathutils").Vector((0.0, 0.0)),
|
||||
__import__("mathutils").Vector((10.0, 0.0)),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("ifcopenshell.geom.create_shape", side_effect=RuntimeError("kernel failure")),
|
||||
patch("ifcopenshell.util.placement.get_local_placement") as mock_get_placement,
|
||||
):
|
||||
mock_get_placement.return_value = type("FakeArr", (), {"tolist": lambda self: placement_matrix})()
|
||||
min_t, max_t = _opening_axis_extent(opening, axis_reference, unit_scale=1.0)
|
||||
|
||||
assert min_t == max_t == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_opening_axis_extent_offset_cursor_inside_extent_returns_straddling_range():
|
||||
"""The regression guard for the user-reported bug across two fix attempts:
|
||||
when the cursor is placed *inside* the opening but not at its exact
|
||||
centre, the helper must still return a range that straddles the cursor
|
||||
position so the side test keeps the opening on both walls.
|
||||
|
||||
Pre-fix v2/v3 collapsed to a degenerate range whenever the production
|
||||
representation type wasn't recognised (Blender bound_box absent in v2;
|
||||
mapped representation not walked in v3). The current implementation uses
|
||||
``ifcopenshell.geom.create_shape``, which handles every representation
|
||||
Bonsai may produce."""
|
||||
from bonsai.bim.module.model.wall import _opening_axis_extent
|
||||
|
||||
# 2m-wide opening centred at world X=5 → world X ∈ [4.0, 6.0] → t ∈ [0.4, 0.6].
|
||||
verts_local = [(-1.0, -0.5, -0.5), (1.0, 0.5, 0.5)]
|
||||
matrix_world = [
|
||||
[1.0, 0.0, 0.0, 5.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
]
|
||||
fake_shape = _fake_shape(verts_local, matrix_world)
|
||||
opening = MagicMock(name="opening")
|
||||
axis_reference = (
|
||||
__import__("mathutils").Vector((0.0, 0.0)),
|
||||
__import__("mathutils").Vector((10.0, 0.0)),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("ifcopenshell.geom.create_shape", return_value=fake_shape),
|
||||
patch("ifcopenshell.util.shape.get_vertices", return_value=fake_shape._verts),
|
||||
patch("ifcopenshell.util.shape.get_shape_matrix", return_value=fake_shape._matrix),
|
||||
):
|
||||
min_t, max_t = _opening_axis_extent(opening, axis_reference, unit_scale=1.0)
|
||||
|
||||
# Cursor at world X=4.7 → t=0.47 (inside the opening, not centred on it).
|
||||
cut_percentage = 0.47
|
||||
assert (
|
||||
min_t < cut_percentage < max_t
|
||||
), f"opening [t={min_t}, t={max_t}] must straddle off-centre cursor at t={cut_percentage}"
|
||||
|
||||
|
||||
def test_straddling_opening_is_kept_on_both_sides():
|
||||
"""The pruning logic must keep an opening whose extent straddles the cut
|
||||
on *both* element1 and element2.
|
||||
|
||||
Before the fix, an opening with centre at t=0.5 and cut_percentage=0.6
|
||||
would be removed from element2 (centre < cut) but kept on element1; the
|
||||
opening's right half — which physically overlaps element2 — would be
|
||||
silently dropped. After the fix, the opening overlaps both portions of
|
||||
the axis (min_t=0.3 < 0.6 < max_t=0.7) so both walls keep it.
|
||||
|
||||
Replays the boolean comparisons that ``DumbWallJoiner.split`` performs on
|
||||
the helper's return value; does not call the helper itself."""
|
||||
min_t, max_t = 0.3, 0.7 # straddles any cut_percentage in (0.3, 0.7)
|
||||
cut_percentage = 0.6
|
||||
|
||||
removed_from_element1 = min_t > cut_percentage
|
||||
removed_from_element2 = max_t < cut_percentage
|
||||
|
||||
assert removed_from_element1 is False, "straddling opening must remain on element1"
|
||||
assert removed_from_element2 is False, "straddling opening must remain on element2"
|
||||
|
||||
|
||||
def test_opening_entirely_past_cut_is_removed_from_element1_only():
|
||||
"""Opening lies wholly on element2's side (min_t > cut_percentage).
|
||||
Pre-fix and post-fix both remove it from element1; post-fix additionally
|
||||
guarantees it stays on element2 because max_t > cut_percentage."""
|
||||
min_t, max_t = 0.7, 0.9
|
||||
cut_percentage = 0.5
|
||||
|
||||
assert (min_t > cut_percentage) is True # removed from element1
|
||||
assert (max_t < cut_percentage) is False # kept on element2
|
||||
|
||||
|
||||
def test_opening_entirely_before_cut_is_removed_from_element2_only():
|
||||
"""Mirror of the above: opening wholly on element1's side."""
|
||||
min_t, max_t = 0.1, 0.3
|
||||
cut_percentage = 0.5
|
||||
|
||||
assert (min_t > cut_percentage) is False # kept on element1
|
||||
assert (max_t < cut_percentage) is True # removed from element2
|
||||
|
||||
|
||||
def test_opening_touching_cut_at_boundary_stays_on_both_walls():
|
||||
"""Boundary touch: an opening's ``max_t`` lands exactly on the cut. Strict
|
||||
inequalities keep the opening on both walls — the safer default. (Non-
|
||||
strict ``<=`` would have removed from element2 instead.)"""
|
||||
min_t, max_t = 0.2, 0.5
|
||||
cut_percentage = 0.5
|
||||
|
||||
assert (min_t > cut_percentage) is False # kept on element1
|
||||
assert (max_t < cut_percentage) is False # kept on element2 (boundary == cut)
|
||||
|
||||
|
||||
def test_degenerate_range_at_cut_keeps_opening_on_both_walls():
|
||||
"""Regression guard for the **post-fix-v1 regression**: when the helper
|
||||
falls back to a degenerate range ``(t, t)`` (geometry kernel failed, or
|
||||
the pre-create_shape fix attempts that produced only the placement
|
||||
centre), placing the 3D cursor *on* the opening's centre makes
|
||||
``cut_percentage == t``.
|
||||
|
||||
With non-strict ``>=`` / ``<=`` tests, the degenerate range matched both
|
||||
removal conditions and both walls dropped the opening — leaving the user
|
||||
with two walls and no hole anywhere. Strict ``>`` / ``<`` tests keep the
|
||||
opening on both walls in this case, which matches the visible geometry."""
|
||||
min_t, max_t = 0.5, 0.5 # degenerate range — both bounds at the centre
|
||||
cut_percentage = 0.5 # cursor placed exactly on the opening centre
|
||||
|
||||
removed_from_element1 = min_t > cut_percentage
|
||||
removed_from_element2 = max_t < cut_percentage
|
||||
|
||||
assert removed_from_element1 is False, "must not remove from element1 when cursor sits on opening centre"
|
||||
assert removed_from_element2 is False, "must not remove from element2 when cursor sits on opening centre"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filled-opening void-straddle behaviour
|
||||
#
|
||||
# When a wall split passes through a door/window, the filling (the door
|
||||
# element itself) belongs to whichever wall contains its centre — but the
|
||||
# void cut by the IfcOpeningElement may still straddle the cut, in which
|
||||
# case the neighbour wall's body must also be cut. The helper that adds the
|
||||
# pure-void copy is ``_add_void_copy``; the decision is taken in
|
||||
# ``DumbWallJoiner.split``'s filled-opening loop.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_void_copy_mock(has_filling_rel=True):
|
||||
"""Build the ``void_copy`` MagicMock returned by ``copy_class`` so its
|
||||
``HasFillings`` / ``VoidsElements`` / ``ObjectPlacement`` shape matches
|
||||
what ``_add_void_copy`` mutates."""
|
||||
copy_placement = MagicMock(name="copy_placement")
|
||||
copy_placement.is_a = lambda klass: klass == "IfcLocalPlacement"
|
||||
void_relation = MagicMock(name="VoidsRelation")
|
||||
void_copy = MagicMock(name="void_copy")
|
||||
void_copy.HasFillings = (MagicMock(name="copy_filling_rel"),) if has_filling_rel else ()
|
||||
void_copy.VoidsElements = (void_relation,)
|
||||
void_copy.ObjectPlacement = copy_placement
|
||||
return void_copy, void_relation, copy_placement
|
||||
|
||||
|
||||
def test_add_void_copy_strips_fillings_and_reparents_to_target_wall():
|
||||
"""``_add_void_copy`` must create a pure-void IfcOpeningElement attached
|
||||
to the target wall: the filling relationship copied along with the source
|
||||
must be removed, ``VoidsElements[0].RelatingBuildingElement`` must point
|
||||
at the target wall, and the representation must be a deep copy (not a
|
||||
shared reference with the source)."""
|
||||
from bonsai.bim.module.model.wall import _add_void_copy
|
||||
|
||||
source_representation = MagicMock(name="source_representation")
|
||||
source_opening = MagicMock(name="source_opening")
|
||||
source_opening.Representation = source_representation
|
||||
|
||||
void_copy, void_relation, copy_placement = _make_void_copy_mock()
|
||||
carried_filling_rel = void_copy.HasFillings[0]
|
||||
|
||||
target_placement = MagicMock(name="target_placement")
|
||||
target_wall = MagicMock(name="target_wall")
|
||||
target_wall.ObjectPlacement = target_placement
|
||||
|
||||
ifc_file = MagicMock(name="ifc_file")
|
||||
deep_copy_result = MagicMock(name="copied_representation")
|
||||
|
||||
with (
|
||||
patch("bonsai.tool.Ifc.get", return_value=ifc_file),
|
||||
patch("ifcopenshell.api.root.copy_class", return_value=void_copy) as mock_copy_class,
|
||||
patch("ifcopenshell.util.element.copy_deep", return_value=deep_copy_result),
|
||||
):
|
||||
_add_void_copy(target_wall, source_opening)
|
||||
|
||||
# The carried-over filling relationship must be removed — the copy is a pure void.
|
||||
ifc_file.remove.assert_called_once_with(carried_filling_rel)
|
||||
# The void now points at the target wall, not the source's wall.
|
||||
assert void_relation.RelatingBuildingElement is target_wall
|
||||
# The placement is reparented under the target wall's local placement.
|
||||
assert copy_placement.PlacementRelTo is target_placement
|
||||
# The representation is deep-copied so future edits don't ripple back to source.
|
||||
assert void_copy.Representation is deep_copy_result
|
||||
mock_copy_class.assert_called_once_with(ifc_file, product=source_opening)
|
||||
|
||||
|
||||
def test_add_void_copy_handles_source_with_no_fillings():
|
||||
"""If the source opening has no ``HasFillings`` (the copy_class result
|
||||
inherits that), the loop over ``void_copy.HasFillings or ()`` must run
|
||||
zero times — no spurious ``ifc_file.remove`` call."""
|
||||
from bonsai.bim.module.model.wall import _add_void_copy
|
||||
|
||||
source_opening = MagicMock(name="source_opening")
|
||||
source_opening.Representation = MagicMock(name="rep")
|
||||
|
||||
void_copy, _void_relation, _copy_placement = _make_void_copy_mock(has_filling_rel=False)
|
||||
target_wall = MagicMock(name="target_wall")
|
||||
|
||||
ifc_file = MagicMock(name="ifc_file")
|
||||
|
||||
with (
|
||||
patch("bonsai.tool.Ifc.get", return_value=ifc_file),
|
||||
patch("ifcopenshell.api.root.copy_class", return_value=void_copy),
|
||||
patch("ifcopenshell.util.element.copy_deep", return_value=MagicMock()),
|
||||
):
|
||||
_add_void_copy(target_wall, source_opening)
|
||||
|
||||
ifc_file.remove.assert_not_called()
|
||||
|
||||
|
||||
def test_filled_opening_void_straddle_decision_keeps_void_on_neighbour():
|
||||
"""Replays the decision logic in ``DumbWallJoiner.split``'s filled-opening
|
||||
loop for the case ``filling_position <= cut_percentage and void_straddles``:
|
||||
filling stays on element1 (its centre is before the cut), but the void
|
||||
extent crosses the cut, so the neighbour wall (element2) must receive a
|
||||
pure-void copy via ``_add_void_copy``.
|
||||
|
||||
Mirrors the unfilled-opening decision tests — exercises the boolean
|
||||
branching rather than full ``split()`` integration."""
|
||||
cut_percentage = 0.5
|
||||
filling_position = 0.4 # filling centre on element1's side
|
||||
min_t, max_t = 0.3, 0.7 # void extent straddles cut at 0.5
|
||||
|
||||
void_straddles = min_t < cut_percentage < max_t
|
||||
filling_on_element2 = filling_position > cut_percentage
|
||||
|
||||
# Expected branch: filling stays, but void straddles → add copy to element2.
|
||||
assert void_straddles is True
|
||||
assert filling_on_element2 is False
|
||||
# Equivalent to the ``elif void_straddles:`` path adding a void copy to element2.
|
||||
|
||||
|
||||
def test_filled_opening_void_straddle_with_filling_on_far_side_keeps_void_on_origin():
|
||||
"""The symmetric case: ``filling_position > cut_percentage and void_straddles``.
|
||||
Filling moves to element2 with the original void; element1 needs a
|
||||
pure-void copy back (the void's element1 portion would otherwise be
|
||||
orphaned). Documents the boolean state of the inner branch."""
|
||||
cut_percentage = 0.5
|
||||
filling_position = 0.6 # filling centre on element2's side
|
||||
min_t, max_t = 0.3, 0.7
|
||||
|
||||
void_straddles = min_t < cut_percentage < max_t
|
||||
filling_on_element2 = filling_position > cut_percentage
|
||||
|
||||
assert void_straddles is True
|
||||
assert filling_on_element2 is True
|
||||
# Equivalent to the outer ``if filling_position > cut_percentage`` path
|
||||
# taking its inner ``if void_straddles`` branch and adding a void copy
|
||||
# back to element1.
|
||||
Reference in New Issue
Block a user