mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-05 23:41:44 +00:00
Fix rotated space placement localization during regeneration
set_space_representation_from_polygon was localising the footprint and clipping planes by subtracting only the object origin. For spaces with a rotated ObjectPlacement (e.g. Space 5710 in the test IFC) the footprint was not rotated into the space's local coordinate system, so the regenerated mesh was rotated by the placement angle and appeared at the wrong world location. Now the polygon and planes are transformed with the full inverse of the object's placement matrix, and the plane normals are also rotated. The local mesh is therefore aligned with the object's local axes and appears in the correct world position when the placement is applied. Added regression tests for Space 5710 (rotated placement) and Space 2363 (identity placement) using the real HouseWithGarage_AC22_IFC2X3.ifc fixture. Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -1363,12 +1363,29 @@ class Spatial(bonsai.core.tool.Spatial):
|
||||
|
||||
# Build the geometry in the space's local coordinate system so the IFC
|
||||
# representation is relative to the object's ObjectPlacement.
|
||||
local_poly_si = shapely.affinity.translate(poly_si, -origin.x, -origin.y)
|
||||
# Use the full inverse of the object's placement matrix so rotated spaces
|
||||
# keep the correct footprint orientation.
|
||||
matrix_inv = np.array(obj.matrix_world.inverted())
|
||||
# shapely.affine_transform expects [a, b, d, e, xoff, yoff]
|
||||
# where x' = a*x + b*y + xoff, y' = d*x + e*y + yoff.
|
||||
affine_params = [
|
||||
matrix_inv[0, 0],
|
||||
matrix_inv[0, 1],
|
||||
matrix_inv[1, 0],
|
||||
matrix_inv[1, 1],
|
||||
matrix_inv[0, 3],
|
||||
matrix_inv[1, 3],
|
||||
]
|
||||
local_poly_si = shapely.affinity.affine_transform(poly_si, affine_params)
|
||||
local_base_z = base_z - origin.z
|
||||
|
||||
def localize_plane(plane):
|
||||
point, normal = plane
|
||||
return (np.array(point) - np.array([origin.x, origin.y, origin.z]), normal)
|
||||
local_point = matrix_inv @ np.array([*point, 1.0])
|
||||
rotation_inv = matrix_inv[:3, :3]
|
||||
local_normal = rotation_inv @ np.array(normal)
|
||||
local_normal = local_normal / np.linalg.norm(local_normal)
|
||||
return (local_point[:3], local_normal)
|
||||
|
||||
local_top_planes = [localize_plane(p) for p in (top_planes or [])]
|
||||
local_bottom_planes = [localize_plane(p) for p in (bottom_planes or [])]
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api
|
||||
@@ -610,6 +612,78 @@ class TestSpaceVolumeStrategy(NewFile):
|
||||
assert len(bottom) == 0
|
||||
|
||||
|
||||
class TestRegenerateSpaceFromRealIfc2x3(NewFile):
|
||||
def load_house_with_garage(self):
|
||||
filepath = (
|
||||
Path(__file__).parents[3]
|
||||
/ "ifcopenshell-python"
|
||||
/ "test"
|
||||
/ "IfcRelSpaceBoundary_TestFiles"
|
||||
/ "IfcRelSpaceBoundary2ndLevel"
|
||||
/ "HouseWithGarage_AC22_IFC2X3.ifc"
|
||||
).resolve()
|
||||
bpy.ops.bim.load_project(filepath=filepath.as_posix())
|
||||
ifc = tool.Ifc.get()
|
||||
return ifc
|
||||
|
||||
def _regenerate_space(self, ifc, space_id):
|
||||
space = ifc.by_id(space_id)
|
||||
obj = tool.Ifc.get_object(space)
|
||||
assert obj
|
||||
import numpy as np
|
||||
|
||||
original_verts = np.array([obj.matrix_world @ v.co for v in obj.data.vertices])
|
||||
original_bounds = (
|
||||
original_verts[:, 0].min(),
|
||||
original_verts[:, 0].max(),
|
||||
original_verts[:, 1].min(),
|
||||
original_verts[:, 1].max(),
|
||||
original_verts[:, 2].min(),
|
||||
original_verts[:, 2].max(),
|
||||
)
|
||||
|
||||
# Delete existing related IfcRelSpaceBoundary as in the manual repro.
|
||||
for b in list(space.BoundedBy or []):
|
||||
ifcopenshell.api.boundary.remove_boundary(ifc, b)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
# Patch Spatial helpers so generate_space uses the active IfcSpace.
|
||||
original_get_selected_objects = tool.Spatial.get_selected_objects
|
||||
original_get_active_obj = tool.Spatial.get_active_obj
|
||||
try:
|
||||
tool.Spatial.get_selected_objects = classmethod(lambda cls: [obj])
|
||||
tool.Spatial.get_active_obj = classmethod(lambda cls: obj)
|
||||
bpy.ops.bim.generate_space()
|
||||
finally:
|
||||
tool.Spatial.get_selected_objects = original_get_selected_objects
|
||||
tool.Spatial.get_active_obj = original_get_active_obj
|
||||
|
||||
regen_verts = np.array([obj.matrix_world @ v.co for v in obj.data.vertices])
|
||||
return original_bounds, (
|
||||
regen_verts[:, 0].min(),
|
||||
regen_verts[:, 0].max(),
|
||||
regen_verts[:, 1].min(),
|
||||
regen_verts[:, 1].max(),
|
||||
regen_verts[:, 2].min(),
|
||||
regen_verts[:, 2].max(),
|
||||
)
|
||||
|
||||
def test_regenerate_space_5710_keeps_world_location(self):
|
||||
ifc = self.load_house_with_garage()
|
||||
original, regen = self._regenerate_space(ifc, 5710)
|
||||
for o, r in zip(original, regen):
|
||||
assert r == pytest.approx(o, abs=0.02)
|
||||
|
||||
def test_regenerate_space_2363_keeps_world_location(self):
|
||||
ifc = self.load_house_with_garage()
|
||||
original, regen = self._regenerate_space(ifc, 2363)
|
||||
for o, r in zip(original, regen):
|
||||
assert r == pytest.approx(o, abs=0.02)
|
||||
|
||||
|
||||
class TestGenerateSpaceLocation(NewFile):
|
||||
def test_generate_space_at_non_zero_cursor_location(self):
|
||||
bpy.ops.bim.create_project()
|
||||
|
||||
Reference in New Issue
Block a user