mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-22 07:18:36 +00:00
Merge remote-tracking branch 'origin/v0.8.0' into ifcviewer-wgpu
This commit is contained in:
@@ -42,6 +42,46 @@ class TestImplementsTool(NewFile):
|
||||
assert isinstance(subject(), bonsai.core.tool.Blender)
|
||||
|
||||
|
||||
class TestTransparentColor(NewFile):
|
||||
def test_default_alpha_overrides_to_zero_one(self):
|
||||
assert subject.transparent_color([1.0, 0.5, 0.25, 1.0]) == [1.0, 0.5, 0.25, 0.1]
|
||||
|
||||
def test_explicit_alpha_is_applied(self):
|
||||
assert subject.transparent_color([1.0, 0.5, 0.25, 1.0], alpha=0.5) == [1.0, 0.5, 0.25, 0.5]
|
||||
|
||||
def test_does_not_mutate_input(self):
|
||||
original = [1.0, 0.5, 0.25, 1.0]
|
||||
subject.transparent_color(original)
|
||||
assert original == [1.0, 0.5, 0.25, 1.0]
|
||||
|
||||
def test_returns_new_list_instance(self):
|
||||
original = [1.0, 0.5, 0.25, 1.0]
|
||||
result = subject.transparent_color(original)
|
||||
assert result is not original
|
||||
|
||||
|
||||
class TestViewportDecoratorDrawBatch(NewFile):
|
||||
def test_empty_content_pos_skips_shader_calls(self):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
decorator = subject.ViewportDecorator()
|
||||
decorator.line_shader = MagicMock()
|
||||
decorator.shader = MagicMock()
|
||||
decorator.draw_batch("LINES", [], (1.0, 1.0, 1.0, 1.0))
|
||||
decorator.line_shader.uniform_float.assert_not_called()
|
||||
decorator.shader.uniform_float.assert_not_called()
|
||||
|
||||
def test_empty_indices_skips_shader_calls(self):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
decorator = subject.ViewportDecorator()
|
||||
decorator.line_shader = MagicMock()
|
||||
decorator.shader = MagicMock()
|
||||
decorator.draw_batch("LINES", [(0.0, 0.0, 0.0), (1.0, 0.0, 0.0)], (1.0, 1.0, 1.0, 1.0), indices=[])
|
||||
decorator.line_shader.uniform_float.assert_not_called()
|
||||
decorator.shader.uniform_float.assert_not_called()
|
||||
|
||||
|
||||
class TestCopyNodeGraph(NewFile):
|
||||
def test_run(self):
|
||||
material_to = bpy.data.materials.new("material_to")
|
||||
@@ -183,3 +223,16 @@ class TestNpFrombufferLegacy(NewFile):
|
||||
result = subject.np_frombuffer_legacy(data, n)
|
||||
assert result.shape == (n,)
|
||||
np.testing.assert_allclose(result, np.arange(n))
|
||||
|
||||
|
||||
class TestGetObjectFromGuidMissing(NewFile):
|
||||
"""``get_object_from_guid`` must honour its ``Optional[Object]`` return
|
||||
contract: a GUID that does not resolve in the current IFC file yields
|
||||
``None``, not a ``RuntimeError``. Callers iterate stored GUID lists
|
||||
(array children, library refs, …) and rely on the falsy return to
|
||||
skip stale entries."""
|
||||
|
||||
def test_returns_none_when_guid_not_in_file(self):
|
||||
bpy.ops.bim.create_project()
|
||||
assert tool.Ifc.get() is not None
|
||||
assert subject.get_object_from_guid("3iyt7r$Hf4_hQYNhBIDJI4") is None
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from mathutils import Vector
|
||||
import math
|
||||
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
from bonsai.tool.cad import Cad as subject
|
||||
from test.bim.bootstrap import NewFile
|
||||
@@ -88,3 +90,87 @@ class TestClosestPoints(NewFile):
|
||||
edge1 = (V(0, 0, 0), V(0, 0, 0))
|
||||
edge2 = (V(1, 0, 1), V(2, 0, 2))
|
||||
assert subject.closest_points(edge1, edge2)[0] == (edge1[0], edge2[0])
|
||||
|
||||
|
||||
class TestObbWorldClipPlanes(NewFile):
|
||||
def test_unit_box_at_origin_returns_axis_aligned_planes(self):
|
||||
planes = subject.obb_world_clip_planes(
|
||||
V(0, 0, 0),
|
||||
(V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)),
|
||||
V(1, 1, 1),
|
||||
)
|
||||
assert planes[0] == (-1.0, 0.0, 0.0, 1.0)
|
||||
assert planes[1] == (1.0, 0.0, 0.0, 1.0)
|
||||
assert planes[2] == (0.0, -1.0, 0.0, 1.0)
|
||||
assert planes[3] == (0.0, 1.0, 0.0, 1.0)
|
||||
assert planes[4] == (0.0, 0.0, -1.0, 1.0)
|
||||
assert planes[5] == (0.0, 0.0, 1.0, 1.0)
|
||||
|
||||
def test_center_is_inside_all_planes(self):
|
||||
center = V(5, -3, 2)
|
||||
planes = subject.obb_world_clip_planes(
|
||||
center,
|
||||
(V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)),
|
||||
V(2, 1, 0.5),
|
||||
)
|
||||
assert subject.point_is_inside_clip_planes(planes, center)
|
||||
|
||||
def test_point_just_outside_positive_x_face_rejected(self):
|
||||
planes = subject.obb_world_clip_planes(
|
||||
V(0, 0, 0),
|
||||
(V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)),
|
||||
V(1, 1, 1),
|
||||
)
|
||||
assert subject.point_is_inside_clip_planes(planes, V(0.5, 0, 0))
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(1.5, 0, 0))
|
||||
|
||||
def test_rotated_obb_clips_along_rotated_axes(self):
|
||||
s = math.sin(math.radians(45))
|
||||
planes = subject.obb_world_clip_planes(
|
||||
V(0, 0, 0),
|
||||
(V(s, s, 0), V(-s, s, 0), V(0, 0, 1)),
|
||||
V(1, 1, 1),
|
||||
)
|
||||
assert subject.point_is_inside_clip_planes(planes, V(1.2, 0, 0))
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(1.42, 0, 0))
|
||||
|
||||
def test_zero_extent_axis_does_not_raise(self):
|
||||
planes = subject.obb_world_clip_planes(
|
||||
V(0, 0, 0),
|
||||
(V(1, 0, 0), V(0, 1, 0), V(0, 0, 1)),
|
||||
V(1, 1, 0),
|
||||
)
|
||||
assert subject.point_is_inside_clip_planes(planes, V(0, 0, 0))
|
||||
|
||||
|
||||
class TestObbClipPlanesFromMatrix(NewFile):
|
||||
def test_identity_matches_unit_box(self):
|
||||
planes = subject.obb_clip_planes_from_matrix(Matrix.Identity(4))
|
||||
assert subject.point_is_inside_clip_planes(planes, V(0, 0, 0))
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(2, 0, 0))
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(0, -2, 0))
|
||||
|
||||
def test_translated_host_shifts_clip_region(self):
|
||||
translated = Matrix.Translation(V(10, 0, 0))
|
||||
planes = subject.obb_clip_planes_from_matrix(translated)
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(0, 0, 0))
|
||||
assert subject.point_is_inside_clip_planes(planes, V(10, 0, 0))
|
||||
|
||||
def test_z_rotation_rotates_box(self):
|
||||
rot = Matrix.Rotation(math.radians(45), 4, "Z")
|
||||
planes = subject.obb_clip_planes_from_matrix(rot)
|
||||
assert subject.point_is_inside_clip_planes(planes, V(1.2, 0, 0))
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(1.42, 0, 0))
|
||||
|
||||
def test_host_scale_scales_box_extents(self):
|
||||
scaled = Matrix.Diagonal((2.0, 2.0, 2.0, 1.0))
|
||||
planes = subject.obb_clip_planes_from_matrix(scaled)
|
||||
assert subject.point_is_inside_clip_planes(planes, V(1.9, 0, 0))
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(2.1, 0, 0))
|
||||
|
||||
def test_non_uniform_scale_axis_independent(self):
|
||||
scaled = Matrix.Diagonal((3.0, 1.0, 1.0, 1.0))
|
||||
planes = subject.obb_clip_planes_from_matrix(scaled)
|
||||
assert subject.point_is_inside_clip_planes(planes, V(2.9, 0, 0))
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(3.1, 0, 0))
|
||||
assert not subject.point_is_inside_clip_planes(planes, V(0, 1.1, 0))
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# 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.
|
||||
|
||||
import math
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.spatial
|
||||
import ifcopenshell.api.type
|
||||
import pytest
|
||||
from mathutils import Vector
|
||||
|
||||
import bonsai.tool as tool
|
||||
from test.bim.bootstrap import NewFile
|
||||
|
||||
pytestmark = pytest.mark.clip_box
|
||||
|
||||
|
||||
def _make_ifc_cube(ifc, ifc_class, location=(0.0, 0.0, 0.0), size=2.0):
|
||||
"""Real bpy cube + ifc entity, linked. ``size`` is the cube edge length."""
|
||||
bpy.ops.mesh.primitive_cube_add(size=size, location=location)
|
||||
obj = bpy.context.active_object
|
||||
entity = ifc.create_entity(ifc_class)
|
||||
tool.Ifc.link(entity, obj)
|
||||
return entity, obj
|
||||
|
||||
|
||||
class TestWorldBboxMatrix(NewFile):
|
||||
def test_two_cubes_returns_centred_aabb_matrix(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0)
|
||||
wall_b, _ = _make_ifc_cube(ifc, "IfcWall", location=(4.0, 0.0, 0.0), size=2.0)
|
||||
|
||||
matrix = tool.ClipBox._world_bbox_matrix_for_elements([wall_a, wall_b])
|
||||
|
||||
assert matrix is not None
|
||||
translation, _, scale = matrix.decompose()
|
||||
# World AABB: x in [-1, 5], y/z in [-1, 1] -> center (2, 0, 0), half (3, 1, 1).
|
||||
assert translation.x == pytest.approx(2.0)
|
||||
assert translation.y == pytest.approx(0.0)
|
||||
assert translation.z == pytest.approx(0.0)
|
||||
assert scale.x == pytest.approx(3.0)
|
||||
assert scale.y == pytest.approx(1.0)
|
||||
assert scale.z == pytest.approx(1.0)
|
||||
|
||||
def test_empty_iterable_returns_none(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
assert tool.ClipBox._world_bbox_matrix_for_elements([]) is None
|
||||
|
||||
def test_element_without_blender_object_is_skipped(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0)
|
||||
unbound = ifc.create_entity("IfcWall")
|
||||
|
||||
matrix = tool.ClipBox._world_bbox_matrix_for_elements([wall_a, unbound])
|
||||
|
||||
assert matrix is not None
|
||||
translation, _, scale = matrix.decompose()
|
||||
assert translation.x == pytest.approx(0.0)
|
||||
assert translation.y == pytest.approx(0.0)
|
||||
assert translation.z == pytest.approx(0.0)
|
||||
assert scale.x == pytest.approx(1.0)
|
||||
|
||||
def test_all_filtered_returns_none(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
unbound_a = ifc.create_entity("IfcWall")
|
||||
unbound_b = ifc.create_entity("IfcWall")
|
||||
assert tool.ClipBox._world_bbox_matrix_for_elements([unbound_a, unbound_b]) is None
|
||||
|
||||
def test_coincident_cubes_return_invertible_matrix(self):
|
||||
# Two cubes at the same location collapse to a zero-volume AABB.
|
||||
# The half-extent floor must keep the matrix invertible so downstream
|
||||
# clip-plane math doesn't divide through a singular transform.
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=0.0001)
|
||||
wall_b, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=0.0001)
|
||||
|
||||
matrix = tool.ClipBox._world_bbox_matrix_for_elements([wall_a, wall_b])
|
||||
|
||||
assert matrix is not None
|
||||
# A zero determinant means the matrix would map every point onto a
|
||||
# subspace — the floor must prevent that.
|
||||
assert matrix.determinant() != 0.0
|
||||
|
||||
|
||||
class TestCameraFrustumMatrix(NewFile):
|
||||
def _make_camera(self, location=(0.0, 0.0, 0.0), rotation=None):
|
||||
cam_data = bpy.data.cameras.new("DrawingCam")
|
||||
cam_data.type = "ORTHO"
|
||||
obj = bpy.data.objects.new("DrawingCam", cam_data)
|
||||
bpy.context.scene.collection.objects.link(obj)
|
||||
obj.location = location
|
||||
if rotation is not None:
|
||||
obj.rotation_euler = rotation
|
||||
bpy.context.view_layer.update()
|
||||
return obj
|
||||
|
||||
def test_identity_camera_width_height_drive_in_plane_extents(self):
|
||||
obj = self._make_camera()
|
||||
cam = obj.data
|
||||
cam.clip_start = 0.0
|
||||
cam.clip_end = 10.0
|
||||
cam.BIMCameraProperties.width = 8.0
|
||||
cam.BIMCameraProperties.height = 6.0
|
||||
|
||||
matrix = tool.ClipBox._camera_frustum_matrix(obj)
|
||||
|
||||
translation, _, scale = matrix.decompose()
|
||||
# Identity rotation: box centre at (0, 0, -5) in world (cameras look down -Z).
|
||||
assert translation.x == pytest.approx(0.0)
|
||||
assert translation.y == pytest.approx(0.0)
|
||||
assert translation.z == pytest.approx(-5.0)
|
||||
# Half-extents: width/2, height/2, (clip_end - clip_start) / 2.
|
||||
assert scale.x == pytest.approx(4.0)
|
||||
assert scale.y == pytest.approx(3.0)
|
||||
assert scale.z == pytest.approx(5.0)
|
||||
|
||||
def test_rotated_camera_preserves_rotation_in_matrix(self):
|
||||
obj = self._make_camera(rotation=(0.0, math.radians(90), 0.0))
|
||||
cam = obj.data
|
||||
cam.clip_start = 0.0
|
||||
cam.clip_end = 4.0
|
||||
cam.BIMCameraProperties.width = 2.0
|
||||
cam.BIMCameraProperties.height = 2.0
|
||||
|
||||
matrix = tool.ClipBox._camera_frustum_matrix(obj)
|
||||
|
||||
_, rotation, scale = matrix.decompose()
|
||||
# Scale is rotation-invariant.
|
||||
assert scale.x == pytest.approx(1.0)
|
||||
assert scale.y == pytest.approx(1.0)
|
||||
assert scale.z == pytest.approx(2.0)
|
||||
# The rotation component matches the camera's own rotation; quaternion
|
||||
# dot product near unit magnitude means the orientations agree.
|
||||
cam_rot = obj.matrix_world.decompose()[1]
|
||||
assert abs(cam_rot.dot(rotation)) > 0.999
|
||||
|
||||
def test_returns_none_when_width_height_zero(self):
|
||||
# A camera without usable drawing extents (width or height ≤ 0)
|
||||
# cannot define a clip volume — caller surfaces ERROR + CANCELLED.
|
||||
obj = self._make_camera()
|
||||
cam = obj.data
|
||||
cam.clip_start = 0.0
|
||||
cam.clip_end = 10.0
|
||||
cam.BIMCameraProperties.width = 8.0
|
||||
# height stays at the BIMCameraProperties default (50). We can't set
|
||||
# height=0 here because the update callback divides width/height.
|
||||
# Set width=0 directly via the underlying ID property instead, which
|
||||
# bypasses the registered FloatProperty update path.
|
||||
cam.BIMCameraProperties["width"] = 0.0
|
||||
|
||||
assert tool.ClipBox._camera_frustum_matrix(obj) is None
|
||||
|
||||
|
||||
class TestIterElementsForSource(NewFile):
|
||||
def test_no_ifc_file_returns_empty(self):
|
||||
# NewFile leaves IfcStore purged; tool.Ifc.get() is None here.
|
||||
assert tool.ClipBox.iter_elements_for_source("SPATIAL", "1") == []
|
||||
|
||||
def test_unknown_kind_returns_empty(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
wall = ifc.create_entity("IfcWall")
|
||||
assert tool.ClipBox.iter_elements_for_source("UNKNOWN_KIND", str(wall.id())) == []
|
||||
|
||||
def test_non_integer_source_id_returns_empty(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
assert tool.ClipBox.iter_elements_for_source("SPATIAL", "not_an_int") == []
|
||||
|
||||
def test_unresolved_source_id_returns_empty(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
assert tool.ClipBox.iter_elements_for_source("SPATIAL", "999999") == []
|
||||
|
||||
def test_spatial_returns_decomposition(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
storey = ifc.create_entity("IfcBuildingStorey")
|
||||
wall_a = ifc.create_entity("IfcWall")
|
||||
wall_b = ifc.create_entity("IfcWall")
|
||||
ifcopenshell.api.spatial.assign_container(ifc, products=[wall_a, wall_b], relating_structure=storey)
|
||||
|
||||
result = tool.ClipBox.iter_elements_for_source("SPATIAL", str(storey.id()))
|
||||
|
||||
assert set(result) == {wall_a, wall_b}
|
||||
|
||||
def test_type_returns_occurrences(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
wall_type = ifc.create_entity("IfcWallType")
|
||||
wall_a = ifc.create_entity("IfcWall")
|
||||
wall_b = ifc.create_entity("IfcWall")
|
||||
ifcopenshell.api.type.assign_type(ifc, related_objects=[wall_a, wall_b], relating_type=wall_type)
|
||||
|
||||
result = tool.ClipBox.iter_elements_for_source("TYPE", str(wall_type.id()))
|
||||
|
||||
assert set(result) == {wall_a, wall_b}
|
||||
|
||||
def test_drawing_returns_drawing_entity(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
drawing = ifc.create_entity("IfcAnnotation", ObjectType="DRAWING")
|
||||
|
||||
result = tool.ClipBox.iter_elements_for_source("DRAWING", str(drawing.id()))
|
||||
|
||||
assert result == [drawing]
|
||||
|
||||
def test_status_invalid_value_returns_empty(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
assert tool.ClipBox.iter_elements_for_source("STATUS", "MADE_UP_STATUS") == []
|
||||
|
||||
def test_class_returns_all_instances_of_ifc_class(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
wall_a = ifc.create_entity("IfcWall")
|
||||
wall_b = ifc.create_entity("IfcWall")
|
||||
window = ifc.create_entity("IfcWindow")
|
||||
|
||||
result = tool.ClipBox.iter_elements_for_source("CLASS", "IfcWall")
|
||||
|
||||
assert set(result) == {wall_a, wall_b}
|
||||
assert window not in result
|
||||
|
||||
def test_class_unknown_ifc_class_returns_empty(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
ifc.create_entity("IfcWall")
|
||||
assert tool.ClipBox.iter_elements_for_source("CLASS", "IfcNotARealClass") == []
|
||||
|
||||
|
||||
class TestComputeMatrixForSource(NewFile):
|
||||
def test_spatial_aggregates_contained_elements(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
storey = ifc.create_entity("IfcBuildingStorey")
|
||||
wall_a, _ = _make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0)
|
||||
wall_b, _ = _make_ifc_cube(ifc, "IfcWall", location=(4.0, 0.0, 0.0), size=2.0)
|
||||
ifcopenshell.api.spatial.assign_container(ifc, products=[wall_a, wall_b], relating_structure=storey)
|
||||
|
||||
matrix = tool.ClipBox.compute_matrix_for_source("SPATIAL", str(storey.id()))
|
||||
|
||||
assert matrix is not None
|
||||
translation, _, scale = matrix.decompose()
|
||||
assert translation.x == pytest.approx(2.0)
|
||||
assert scale.x == pytest.approx(3.0)
|
||||
|
||||
def test_no_match_returns_none(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
wall_type = ifc.create_entity("IfcWallType")
|
||||
# No occurrences linked.
|
||||
assert tool.ClipBox.compute_matrix_for_source("TYPE", str(wall_type.id())) is None
|
||||
|
||||
def test_drawing_uses_camera_frustum(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
drawing = ifc.create_entity("IfcAnnotation", ObjectType="DRAWING")
|
||||
cam_data = bpy.data.cameras.new("Cam")
|
||||
cam_data.type = "ORTHO"
|
||||
cam_data.clip_start = 0.0
|
||||
cam_data.clip_end = 10.0
|
||||
cam_data.BIMCameraProperties.width = 4.0
|
||||
cam_data.BIMCameraProperties.height = 4.0
|
||||
obj = bpy.data.objects.new("Cam", cam_data)
|
||||
bpy.context.scene.collection.objects.link(obj)
|
||||
tool.Ifc.link(drawing, obj)
|
||||
|
||||
matrix = tool.ClipBox.compute_matrix_for_source("DRAWING", str(drawing.id()))
|
||||
|
||||
assert matrix is not None
|
||||
_, _, scale = matrix.decompose()
|
||||
assert scale.x == pytest.approx(2.0)
|
||||
assert scale.y == pytest.approx(2.0)
|
||||
assert scale.z == pytest.approx(5.0)
|
||||
|
||||
def test_drawing_with_non_camera_returns_none(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
drawing = ifc.create_entity("IfcAnnotation", ObjectType="DRAWING")
|
||||
obj = bpy.data.objects.new("NotACamera", None)
|
||||
bpy.context.scene.collection.objects.link(obj)
|
||||
tool.Ifc.link(drawing, obj)
|
||||
|
||||
assert tool.ClipBox.compute_matrix_for_source("DRAWING", str(drawing.id())) is None
|
||||
|
||||
def test_status_with_no_matching_elements_returns_none(self):
|
||||
# STATUS pick with a valid status value but no element carrying that
|
||||
# status — the dispatcher must surface "nothing matched" the same way
|
||||
# an empty TYPE / MATERIAL pick does.
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
# Create a wall but never assign its Pset_WallCommon.Status — so a
|
||||
# STATUS=NEW query finds 0 elements.
|
||||
_make_ifc_cube(ifc, "IfcWall", location=(0.0, 0.0, 0.0), size=2.0)
|
||||
|
||||
assert tool.ClipBox.compute_matrix_for_source("STATUS", "NEW") is None
|
||||
|
||||
|
||||
class _FakeRegion:
|
||||
def __init__(self, width, height):
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
|
||||
class _FakeRV3D:
|
||||
def __init__(self, view_matrix=()): # () is a truthy-enough non-None stand-in
|
||||
self.view_matrix = view_matrix
|
||||
self.updated = False
|
||||
self.use_clip_planes = False
|
||||
self.clip_planes = None
|
||||
|
||||
def update(self):
|
||||
self.updated = True
|
||||
|
||||
|
||||
class TestRegionIsRenderable:
|
||||
"""``_region_is_renderable`` gates the clip-plane arm against collapsed /
|
||||
initializing regions whose ``region_3d.update()`` would CTD Blender inside
|
||||
``GPU_matrix_ortho_set`` (the timer-arm crash this guard fixes)."""
|
||||
|
||||
def test_sized_region_with_view_matrix_is_renderable(self):
|
||||
assert tool.ClipBox._region_is_renderable(_FakeRegion(800, 600), _FakeRV3D()) is True
|
||||
|
||||
def test_zero_width_is_not_renderable(self):
|
||||
assert tool.ClipBox._region_is_renderable(_FakeRegion(0, 600), _FakeRV3D()) is False
|
||||
|
||||
def test_zero_height_is_not_renderable(self):
|
||||
assert tool.ClipBox._region_is_renderable(_FakeRegion(800, 0), _FakeRV3D()) is False
|
||||
|
||||
def test_missing_view_matrix_is_not_renderable(self):
|
||||
rv3d = _FakeRV3D()
|
||||
rv3d.view_matrix = None
|
||||
assert tool.ClipBox._region_is_renderable(_FakeRegion(800, 600), rv3d) is False
|
||||
|
||||
def test_arm_region_early_returns_on_zero_size(self):
|
||||
# A collapsed region must never reach temp_override / clip_border /
|
||||
# update() — _arm_region short-circuits at the size guard. Positively
|
||||
# assert update() was NOT called and no clip state was written, so a
|
||||
# regression that drops the guard fails here rather than passing on
|
||||
# "didn't crash".
|
||||
rv3d = _FakeRV3D()
|
||||
tool.ClipBox._arm_region(object(), _FakeRegion(0, 0), rv3d, ())
|
||||
assert rv3d.updated is False
|
||||
assert rv3d.use_clip_planes is False
|
||||
assert rv3d.clip_planes is None
|
||||
@@ -0,0 +1,123 @@
|
||||
# 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.
|
||||
|
||||
"""Forward-compat AST contract: ``core.connection.disconnect_rel`` must have a
|
||||
branch for every ``kind`` emitted by ``tool.connection.Connection`` lookups.
|
||||
|
||||
Adding a new kind (e.g. ``"void"``, ``"fill"``, ``"interferes"``) to
|
||||
``find_rels`` / ``find_rels_for_element`` without extending ``disconnect_rel``
|
||||
would silently regress the disconnect operator and the cascade-on-delete: a new
|
||||
kind would reach the dispatch, hit the ``raise ValueError("Unknown kind")``
|
||||
fallback, and either crash the operator or leave the cascade half-done. This
|
||||
guard makes the symmetry mandatory at test time."""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.model
|
||||
|
||||
|
||||
BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai"
|
||||
TOOL_CONNECTION = BONSAI_ROOT / "tool" / "connection.py"
|
||||
CORE_CONNECTION = BONSAI_ROOT / "core" / "connection.py"
|
||||
|
||||
|
||||
def _find_function(tree: ast.Module, name: str) -> ast.FunctionDef:
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name == name:
|
||||
return node
|
||||
raise AssertionError(f"Function {name!r} not found")
|
||||
|
||||
|
||||
def _find_method(tree: ast.Module, class_name: str, method_name: str) -> ast.FunctionDef:
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ClassDef) and node.name == class_name:
|
||||
for child in node.body:
|
||||
if isinstance(child, ast.FunctionDef) and child.name == method_name:
|
||||
return child
|
||||
raise AssertionError(f"Method {class_name}.{method_name} not found")
|
||||
|
||||
|
||||
def _kinds_emitted_by(method: ast.FunctionDef) -> set[str]:
|
||||
"""Extract every kind label this method emits.
|
||||
|
||||
Looks at exactly two narrow patterns to avoid false positives from
|
||||
docstrings or type-annotation strings:
|
||||
|
||||
- ``_record(rel, "<kind>", …)`` — positional string at index 1, the
|
||||
conventional emit shape in ``find_rels`` / ``find_rels_for_element``.
|
||||
- ``kind = "<a>" if … else "<b>"`` and chained variants — string
|
||||
literals on either branch of an ``ast.IfExp`` assigned to ``kind``.
|
||||
"""
|
||||
kinds: set[str] = set()
|
||||
for node in ast.walk(method):
|
||||
if isinstance(node, ast.Call):
|
||||
func = node.func
|
||||
if isinstance(func, ast.Name) and func.id == "_record" and len(node.args) >= 2:
|
||||
arg = node.args[1]
|
||||
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
|
||||
kinds.add(arg.value)
|
||||
elif isinstance(arg, ast.IfExp):
|
||||
for branch in (arg.body, arg.orelse):
|
||||
if isinstance(branch, ast.Constant) and isinstance(branch.value, str):
|
||||
kinds.add(branch.value)
|
||||
elif isinstance(node, ast.Assign):
|
||||
targets = [t for t in node.targets if isinstance(t, ast.Name) and t.id == "kind"]
|
||||
if not targets or not isinstance(node.value, ast.IfExp):
|
||||
continue
|
||||
for branch in (node.value.body, node.value.orelse):
|
||||
if isinstance(branch, ast.Constant) and isinstance(branch.value, str):
|
||||
kinds.add(branch.value)
|
||||
return kinds
|
||||
|
||||
|
||||
def _kind_branches_in_disconnect_rel(tree: ast.Module) -> set[str]:
|
||||
"""Return every kind matched by ``disconnect_rel``'s ``kind == "…"`` branches."""
|
||||
fn = _find_function(tree, "disconnect_rel")
|
||||
kinds: set[str] = set()
|
||||
for node in ast.walk(fn):
|
||||
if isinstance(node, ast.Compare) and len(node.ops) == 1 and isinstance(node.ops[0], ast.Eq):
|
||||
left = node.left
|
||||
right = node.comparators[0]
|
||||
if isinstance(left, ast.Name) and left.id == "kind":
|
||||
if isinstance(right, ast.Constant) and isinstance(right.value, str):
|
||||
kinds.add(right.value)
|
||||
return kinds
|
||||
|
||||
|
||||
def test_disconnect_rel_handles_every_kind_emitted_by_connection_lookups() -> None:
|
||||
tool_tree = ast.parse(TOOL_CONNECTION.read_text(encoding="utf-8"))
|
||||
core_tree = ast.parse(CORE_CONNECTION.read_text(encoding="utf-8"))
|
||||
|
||||
emitted = _kinds_emitted_by(_find_method(tool_tree, "Connection", "find_rels")) | _kinds_emitted_by(
|
||||
_find_method(tool_tree, "Connection", "find_rels_for_element")
|
||||
)
|
||||
handled = _kind_branches_in_disconnect_rel(core_tree)
|
||||
|
||||
assert emitted, "Sanity check: no kinds extracted — emit pattern may have changed"
|
||||
|
||||
missing = emitted - handled
|
||||
assert not missing, (
|
||||
f"core.connection.disconnect_rel is missing branches for kinds {missing}. "
|
||||
f"Every kind returned by Connection.find_rels / find_rels_for_element "
|
||||
f"must have a matching if/elif branch in the dispatch."
|
||||
)
|
||||
@@ -73,6 +73,83 @@ class TestCreateCamera(NewFile):
|
||||
assert obj.users_collection == tuple()
|
||||
|
||||
|
||||
class TestImportCameraProps(NewFile):
|
||||
def test_imports_perspective_camera_shifts_from_drawing_pset(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
drawing = ifc.createIfcAnnotation(ObjectType="DRAWING")
|
||||
pset = ifcopenshell.api.pset.add_pset(ifc, product=drawing, name="EPset_Drawing")
|
||||
ifcopenshell.api.pset.edit_pset(
|
||||
ifc,
|
||||
pset=pset,
|
||||
properties={"PerspectiveShiftX": 0.125, "PerspectiveShiftY": -0.375},
|
||||
)
|
||||
camera = bpy.data.cameras.new("Camera")
|
||||
camera.type = "PERSP"
|
||||
|
||||
subject.import_camera_props(drawing, camera)
|
||||
|
||||
assert camera.shift_x == pytest.approx(0.125)
|
||||
assert camera.shift_y == pytest.approx(-0.375)
|
||||
|
||||
def test_non_perspective_import_defaults_camera_shifts_to_zero(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
drawing = ifc.createIfcAnnotation(ObjectType="DRAWING")
|
||||
pset = ifcopenshell.api.pset.add_pset(ifc, product=drawing, name="EPset_Drawing")
|
||||
ifcopenshell.api.pset.edit_pset(
|
||||
ifc,
|
||||
pset=pset,
|
||||
properties={"PerspectiveShiftX": 0.125, "PerspectiveShiftY": -0.375},
|
||||
)
|
||||
camera = bpy.data.cameras.new("Camera")
|
||||
camera.type = "ORTHO"
|
||||
camera.shift_x = 1.0
|
||||
camera.shift_y = -1.0
|
||||
|
||||
subject.import_camera_props(drawing, camera)
|
||||
|
||||
assert camera.shift_x == 0.0
|
||||
assert camera.shift_y == 0.0
|
||||
|
||||
|
||||
class TestSyncPerspectiveCameraShifts(NewFile):
|
||||
def test_round_trips_perspective_camera_shifts_through_drawing_pset(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
drawing = ifc.createIfcAnnotation(ObjectType="DRAWING")
|
||||
camera = bpy.data.cameras.new("Camera")
|
||||
camera.type = "PERSP"
|
||||
camera.shift_x = 0.25
|
||||
camera.shift_y = -0.5
|
||||
|
||||
subject.sync_perspective_camera_shifts(drawing, camera)
|
||||
|
||||
pset = ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing")
|
||||
assert pset["PerspectiveShiftX"] == pytest.approx(0.25)
|
||||
assert pset["PerspectiveShiftY"] == pytest.approx(-0.5)
|
||||
|
||||
reloaded_camera = bpy.data.cameras.new("ReloadedCamera")
|
||||
reloaded_camera.type = "PERSP"
|
||||
subject.import_camera_props(drawing, reloaded_camera)
|
||||
|
||||
assert reloaded_camera.shift_x == pytest.approx(0.25)
|
||||
assert reloaded_camera.shift_y == pytest.approx(-0.5)
|
||||
|
||||
def test_ignores_non_perspective_camera_shifts(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
drawing = ifc.createIfcAnnotation(ObjectType="DRAWING")
|
||||
camera = bpy.data.cameras.new("Camera")
|
||||
camera.type = "ORTHO"
|
||||
camera.shift_x = 0.25
|
||||
camera.shift_y = -0.5
|
||||
|
||||
subject.sync_perspective_camera_shifts(drawing, camera)
|
||||
|
||||
assert ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing") is None
|
||||
|
||||
|
||||
class TestCreateSvgSheet(NewFile):
|
||||
def test_run(self):
|
||||
ifc = ifcopenshell.file()
|
||||
@@ -961,3 +1038,27 @@ class TestAddReferenceImage(NewFile):
|
||||
|
||||
uv_node = material_nodes["Texture Coordinate"]
|
||||
assert len(uv_node.outputs["Generated"].links[:]) == 1
|
||||
|
||||
|
||||
class TestIsDrawingActive(NewFile):
|
||||
def test_no_active_camera(self):
|
||||
bpy.context.scene.camera = None
|
||||
assert subject.is_drawing_active() is False
|
||||
|
||||
def test_active_camera_without_ifc_definition(self):
|
||||
bpy.context.scene.camera = subject.create_camera("Camera", mathutils.Matrix(), "PERSPECTIVE", "PLAN_VIEW")
|
||||
assert subject.is_drawing_active() is False
|
||||
|
||||
def test_ifc_linked_camera_in_background_mode(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
camera_obj = subject.create_camera("Camera", mathutils.Matrix(), "PERSPECTIVE", "PLAN_VIEW")
|
||||
drawing = ifc.createIfcAnnotation(ObjectType="DRAWING")
|
||||
tool.Ifc.link(drawing, camera_obj)
|
||||
bpy.context.scene.camera = camera_obj
|
||||
|
||||
# The test suite itself runs Blender in background mode, where no
|
||||
# VIEW_3D area can ever exist -- this is exactly the case the fix
|
||||
# addresses, so this assertion documents that assumption.
|
||||
assert bpy.app.background is True
|
||||
assert subject.is_drawing_active() is True
|
||||
|
||||
@@ -135,6 +135,37 @@ class TestGetRepresentationData(NewFile):
|
||||
assert subject.get_representation_data(representation) == data
|
||||
|
||||
|
||||
class TestGetActiveRepresentation(NewFile):
|
||||
def test_returns_representation_for_live_id(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
representation = ifc.createIfcShapeRepresentation()
|
||||
mesh = bpy.data.meshes.new("Mesh")
|
||||
obj = bpy.data.objects.new("Object", mesh)
|
||||
tool.Geometry.get_mesh_props(mesh).ifc_definition_id = representation.id()
|
||||
assert subject.get_active_representation(obj) == representation
|
||||
|
||||
def test_returns_none_when_mesh_has_no_id(self):
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
obj = bpy.data.objects.new("Object", bpy.data.meshes.new("Mesh"))
|
||||
assert subject.get_active_representation(obj) is None
|
||||
|
||||
def test_returns_none_when_id_is_stale(self):
|
||||
"""A representation rebuild can free the old entity while obj.data
|
||||
still tracks its id. Returning ``None`` keeps every UI redraw alive
|
||||
instead of spamming ``RuntimeError`` from the by_id lookup."""
|
||||
ifc = ifcopenshell.file()
|
||||
tool.Ifc.set(ifc)
|
||||
representation = ifc.createIfcShapeRepresentation()
|
||||
mesh = bpy.data.meshes.new("Mesh")
|
||||
obj = bpy.data.objects.new("Object", mesh)
|
||||
stale_id = representation.id()
|
||||
tool.Geometry.get_mesh_props(mesh).ifc_definition_id = stale_id
|
||||
ifc.remove(representation)
|
||||
assert subject.get_active_representation(obj) is None
|
||||
|
||||
|
||||
class TestGetRepresentationId(NewFile):
|
||||
def test_run(self):
|
||||
ifc = ifcopenshell.file()
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
# 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.
|
||||
|
||||
"""Coalescing tests for ``tool.Geometry.batch_host_recut``.
|
||||
|
||||
The opening/void/array recut paths fan out N host-mesh rebuilds per array of N
|
||||
fillings — the CSG opening-subtraction inside ``switch_representation`` is the
|
||||
most expensive geometry step in the addon. ``batch_host_recut`` queues
|
||||
``recut_host`` + ``update_host_representation`` calls by voided element id and
|
||||
drains each unique host once on the outermost exit. These tests pin the
|
||||
queue/depth/drain contract that the call-site rewrites in subsequent phases
|
||||
rely on."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.geometry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_batch_state():
|
||||
from bonsai import tool
|
||||
|
||||
saved_depth = tool.Geometry._host_batch_depth
|
||||
saved_recut = tool.Geometry._host_recut_queue
|
||||
saved_update = tool.Geometry._host_update_queue
|
||||
tool.Geometry._host_batch_depth = 0
|
||||
tool.Geometry._host_recut_queue = {}
|
||||
tool.Geometry._host_update_queue = {}
|
||||
yield
|
||||
tool.Geometry._host_batch_depth = saved_depth
|
||||
tool.Geometry._host_recut_queue = saved_recut
|
||||
tool.Geometry._host_update_queue = saved_update
|
||||
|
||||
|
||||
def _mock_voided_obj(name: str, *, has_data: bool = True) -> Mock:
|
||||
obj = Mock()
|
||||
obj.name = name
|
||||
obj.data = Mock() if has_data else None
|
||||
return obj
|
||||
|
||||
|
||||
def _mock_element(ifc_id: int) -> Mock:
|
||||
elem = Mock()
|
||||
elem.id.return_value = ifc_id
|
||||
return elem
|
||||
|
||||
|
||||
def test_outside_batch_calls_switch_representation_directly():
|
||||
from bonsai import tool
|
||||
|
||||
voided_obj = _mock_voided_obj("Wall")
|
||||
representation = Mock()
|
||||
|
||||
with patch("bonsai.core.geometry.switch_representation") as recut, patch.object(
|
||||
tool.Ifc, "get_entity", return_value=_mock_element(42)
|
||||
):
|
||||
tool.Geometry.recut_host(voided_obj, representation)
|
||||
|
||||
assert recut.call_count == 1
|
||||
kwargs = recut.call_args.kwargs
|
||||
assert kwargs["obj"] is voided_obj
|
||||
assert kwargs["representation"] is representation
|
||||
|
||||
|
||||
def test_inside_batch_queues_then_drains_once_on_exit():
|
||||
from bonsai import tool
|
||||
|
||||
voided_obj = _mock_voided_obj("Wall")
|
||||
representation = Mock()
|
||||
element = _mock_element(42)
|
||||
|
||||
with patch("bonsai.core.geometry.switch_representation") as recut, patch.object(
|
||||
tool.Ifc, "get_entity", return_value=element
|
||||
), patch.object(tool.Geometry, "get_active_representation", return_value=representation):
|
||||
with tool.Geometry.batch_host_recut():
|
||||
for _ in range(5):
|
||||
tool.Geometry.recut_host(voided_obj, representation)
|
||||
assert recut.call_count == 0, "Inside the batch, no recuts should fire"
|
||||
assert tool.Geometry._host_batch_depth == 1
|
||||
assert len(tool.Geometry._host_recut_queue) == 1
|
||||
assert recut.call_count == 1, "Exactly one drain on outermost exit"
|
||||
|
||||
|
||||
def test_two_different_hosts_drain_separately():
|
||||
from bonsai import tool
|
||||
|
||||
obj_a = _mock_voided_obj("WallA")
|
||||
obj_b = _mock_voided_obj("WallB")
|
||||
elem_a = _mock_element(1)
|
||||
elem_b = _mock_element(2)
|
||||
rep = Mock()
|
||||
|
||||
def get_entity(obj):
|
||||
return elem_a if obj is obj_a else elem_b
|
||||
|
||||
with patch("bonsai.core.geometry.switch_representation") as recut, patch.object(
|
||||
tool.Ifc, "get_entity", side_effect=get_entity
|
||||
), patch.object(tool.Geometry, "get_active_representation", return_value=rep):
|
||||
with tool.Geometry.batch_host_recut():
|
||||
for _ in range(5):
|
||||
tool.Geometry.recut_host(obj_a, rep)
|
||||
for _ in range(3):
|
||||
tool.Geometry.recut_host(obj_b, rep)
|
||||
|
||||
assert recut.call_count == 2
|
||||
drained_objs = [call.kwargs["obj"] for call in recut.call_args_list]
|
||||
assert set(drained_objs) == {obj_a, obj_b}
|
||||
|
||||
|
||||
def test_nested_batches_only_outermost_drains():
|
||||
from bonsai import tool
|
||||
|
||||
voided_obj = _mock_voided_obj("Wall")
|
||||
rep = Mock()
|
||||
|
||||
with patch("bonsai.core.geometry.switch_representation") as recut, patch.object(
|
||||
tool.Ifc, "get_entity", return_value=_mock_element(1)
|
||||
), patch.object(tool.Geometry, "get_active_representation", return_value=rep):
|
||||
with tool.Geometry.batch_host_recut():
|
||||
tool.Geometry.recut_host(voided_obj, rep)
|
||||
with tool.Geometry.batch_host_recut():
|
||||
tool.Geometry.recut_host(voided_obj, rep)
|
||||
assert recut.call_count == 0
|
||||
assert recut.call_count == 0, "Inner exit must not drain — outer batch still open"
|
||||
assert recut.call_count == 1
|
||||
|
||||
|
||||
def test_stale_element_skipped_at_drain():
|
||||
"""Host's IFC entity disappears between enqueue and drain. The dead entity
|
||||
must be skipped silently — not raise — so unrelated hosts in the same batch
|
||||
still get their recut."""
|
||||
from bonsai import tool
|
||||
|
||||
dead_obj = _mock_voided_obj("Wall")
|
||||
rep = Mock()
|
||||
entity_state = {"alive": _mock_element(1)}
|
||||
|
||||
with patch("bonsai.core.geometry.switch_representation") as recut, patch.object(
|
||||
tool.Ifc, "get_entity", side_effect=lambda obj: entity_state["alive"]
|
||||
), patch.object(tool.Geometry, "get_active_representation", return_value=rep):
|
||||
with tool.Geometry.batch_host_recut():
|
||||
tool.Geometry.recut_host(dead_obj, rep)
|
||||
entity_state["alive"] = None
|
||||
|
||||
assert recut.call_count == 0
|
||||
|
||||
|
||||
def test_exception_inside_batch_still_resets_state():
|
||||
from bonsai import tool
|
||||
|
||||
with patch("bonsai.core.geometry.switch_representation"):
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
with tool.Geometry.batch_host_recut():
|
||||
assert tool.Geometry._host_batch_depth == 1
|
||||
raise RuntimeError("boom")
|
||||
|
||||
assert tool.Geometry._host_batch_depth == 0
|
||||
|
||||
|
||||
def test_update_host_representation_outside_batch_fires_operator():
|
||||
from bonsai import tool
|
||||
|
||||
voided_obj = _mock_voided_obj("Wall")
|
||||
bpy_ops_mock = Mock()
|
||||
|
||||
with patch("bonsai.tool.geometry.bpy.ops", new=bpy_ops_mock), patch.object(
|
||||
tool.Ifc, "get_entity", return_value=_mock_element(42)
|
||||
):
|
||||
tool.Geometry.update_host_representation(voided_obj)
|
||||
|
||||
assert bpy_ops_mock.bim.update_representation.call_count == 1
|
||||
assert bpy_ops_mock.bim.update_representation.call_args.kwargs["obj"] == voided_obj.name
|
||||
|
||||
|
||||
def test_update_host_representation_coalesces_inside_batch():
|
||||
from bonsai import tool
|
||||
|
||||
voided_obj = _mock_voided_obj("Wall")
|
||||
bpy_ops_mock = Mock()
|
||||
|
||||
with patch("bonsai.tool.geometry.bpy.ops", new=bpy_ops_mock), patch.object(
|
||||
tool.Ifc, "get_entity", return_value=_mock_element(42)
|
||||
), patch.object(tool.Geometry, "get_active_representation", return_value=Mock()):
|
||||
with tool.Geometry.batch_host_recut():
|
||||
for _ in range(5):
|
||||
tool.Geometry.update_host_representation(voided_obj)
|
||||
assert bpy_ops_mock.bim.update_representation.call_count == 0
|
||||
|
||||
assert bpy_ops_mock.bim.update_representation.call_count == 1
|
||||
|
||||
|
||||
def test_drain_order_update_before_recut():
|
||||
"""The same host has both pending update + recut. update_representation must
|
||||
fire first so the Blender-mesh edits land in IFC before switch_representation
|
||||
re-tessellates from IFC. Reversed order would silently drop user edits."""
|
||||
from bonsai import tool
|
||||
|
||||
voided_obj = _mock_voided_obj("Wall")
|
||||
rep = Mock()
|
||||
fire_log: list[str] = []
|
||||
bpy_ops_mock = Mock()
|
||||
bpy_ops_mock.bim.update_representation.side_effect = lambda **kw: fire_log.append("update")
|
||||
|
||||
with patch("bonsai.tool.geometry.bpy.ops", new=bpy_ops_mock), patch(
|
||||
"bonsai.core.geometry.switch_representation", side_effect=lambda *a, **kw: fire_log.append("recut")
|
||||
), patch.object(tool.Ifc, "get_entity", return_value=_mock_element(42)), patch.object(
|
||||
tool.Geometry, "get_active_representation", return_value=rep
|
||||
):
|
||||
with tool.Geometry.batch_host_recut():
|
||||
tool.Geometry.recut_host(voided_obj, rep)
|
||||
tool.Geometry.update_host_representation(voided_obj)
|
||||
|
||||
assert fire_log == ["update", "recut"]
|
||||
|
||||
|
||||
def test_mixed_hosts_drain_grouped_by_phase():
|
||||
from bonsai import tool
|
||||
|
||||
obj_a = _mock_voided_obj("WallA")
|
||||
obj_b = _mock_voided_obj("WallB")
|
||||
obj_c = _mock_voided_obj("WallC")
|
||||
elem_a, elem_b, elem_c = _mock_element(1), _mock_element(2), _mock_element(3)
|
||||
rep = Mock()
|
||||
|
||||
def get_entity(obj):
|
||||
return {obj_a: elem_a, obj_b: elem_b, obj_c: elem_c}[obj]
|
||||
|
||||
update_targets: list[str] = []
|
||||
recut_targets: list[Mock] = []
|
||||
bpy_ops_mock = Mock()
|
||||
bpy_ops_mock.bim.update_representation.side_effect = lambda **kw: update_targets.append(kw["obj"])
|
||||
|
||||
with patch("bonsai.tool.geometry.bpy.ops", new=bpy_ops_mock), patch(
|
||||
"bonsai.core.geometry.switch_representation",
|
||||
side_effect=lambda *a, **kw: recut_targets.append(kw["obj"]),
|
||||
), patch.object(tool.Ifc, "get_entity", side_effect=get_entity), patch.object(
|
||||
tool.Geometry, "get_active_representation", return_value=rep
|
||||
):
|
||||
with tool.Geometry.batch_host_recut():
|
||||
tool.Geometry.update_host_representation(obj_a)
|
||||
tool.Geometry.recut_host(obj_b, rep)
|
||||
tool.Geometry.update_host_representation(obj_c)
|
||||
tool.Geometry.recut_host(obj_c, rep)
|
||||
|
||||
assert sorted(update_targets) == sorted([obj_a.name, obj_c.name])
|
||||
assert set(recut_targets) == {obj_b, obj_c}
|
||||
@@ -672,6 +672,30 @@ class TestUsingArrays(NewFile):
|
||||
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
||||
assert pset is None, (obj, pset)
|
||||
|
||||
def test_remove_array_tolerates_stale_child_guid(self):
|
||||
"""``bim.remove_array`` and the underlying ``regenerate_array`` must
|
||||
survive a child GUID in ``BBIM_Array.Data`` that no longer resolves
|
||||
in the file. Real-world IFC files can carry dangling array refs
|
||||
from external edits — the remove path is meant to delete those
|
||||
children, so an already-missing entity is the desired terminal
|
||||
state, not a fatal error."""
|
||||
self.setup_array()
|
||||
parent_obj = bpy.context.active_object
|
||||
parent_element = tool.Ifc.get_entity(parent_obj)
|
||||
ifc_file = tool.Ifc.get()
|
||||
|
||||
pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array")
|
||||
data = json.loads(pset["Data"])
|
||||
data[0]["children"].append("3iyt7r$Hf4_hQYNhBIDJI4")
|
||||
ifcopenshell.api.pset.edit_pset(
|
||||
ifc_file,
|
||||
pset=ifc_file.by_id(pset["id"]),
|
||||
properties={"Data": json.dumps(data)},
|
||||
)
|
||||
|
||||
bpy.ops.bim.remove_array(item=0)
|
||||
assert ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array") is None
|
||||
|
||||
|
||||
class TestApplyIfcMaterialChanges(NewFile):
|
||||
def get_used_styles(self, obj: bpy.types.Object) -> set[ifcopenshell.entity_instance]:
|
||||
|
||||
@@ -294,64 +294,125 @@ class TestLoadLinkedModels(NewFile):
|
||||
assert props.links[1].ifc_definition_id == reference2.id()
|
||||
assert props.links[1].has_transformation is True
|
||||
|
||||
def test_load_linked_models_restores_query_from_cache_json(self):
|
||||
"""The selector query used at link time is persisted only in the
|
||||
sidecar cache JSON. Reopening the host IFC must restore it onto the
|
||||
Link PropertyGroup so subsequent Reload/Load replay the same filter."""
|
||||
ifc = ifcopenshell.file()
|
||||
props = tool.Project.get_project_props()
|
||||
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
|
||||
document = ifcopenshell.api.document.add_information(ifc)
|
||||
document.Scope = "LINKED_MODEL"
|
||||
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=False) as tmp:
|
||||
json.dump({"query": "IfcElement, ! IfcOpeningElement"}, tmp)
|
||||
json_path = Path(tmp.name)
|
||||
try:
|
||||
ifc_filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
|
||||
reference = ifcopenshell.api.document.add_reference(ifc, document)
|
||||
reference.Location = Path(ifc_filepath).as_posix()
|
||||
reference.Identification = ""
|
||||
tool.Ifc.set(ifc)
|
||||
subject.load_linked_models_from_ifc()
|
||||
assert len(props.links) == 1
|
||||
assert props.links[0].query == "IfcElement, ! IfcOpeningElement"
|
||||
finally:
|
||||
json_path.unlink(missing_ok=True)
|
||||
|
||||
def test_load_linked_models_query_defaults_empty_without_cache_json(self):
|
||||
"""When no sidecar cache JSON exists, the restored Link's query field
|
||||
must default to the empty string. Empty query is the documented signal
|
||||
for the load path to apply no selector filter."""
|
||||
ifc = ifcopenshell.file()
|
||||
props = tool.Project.get_project_props()
|
||||
ifcopenshell.api.root.create_entity(ifc, "IfcProject")
|
||||
document = ifcopenshell.api.document.add_information(ifc)
|
||||
document.Scope = "LINKED_MODEL"
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ifc_path = Path(tmpdir) / "no-cache.ifc"
|
||||
reference = ifcopenshell.api.document.add_reference(ifc, document)
|
||||
reference.Location = ifc_path.as_posix()
|
||||
reference.Identification = ""
|
||||
tool.Ifc.set(ifc)
|
||||
subject.load_linked_models_from_ifc()
|
||||
assert len(props.links) == 1
|
||||
assert props.links[0].query == ""
|
||||
|
||||
|
||||
class TestCalculateLinkMatrix(NewFile):
|
||||
def _write_cache_json(self, payload: dict) -> Path:
|
||||
"""Write ``payload`` to a fresh sidecar cache JSON path and return it.
|
||||
|
||||
On Windows, ``NamedTemporaryFile(delete=True)`` holds an exclusive
|
||||
handle for the ``with`` block's duration, so the code-under-test
|
||||
cannot open the same path — hence the manual write + unlink pattern.
|
||||
"""
|
||||
tmp = NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=False)
|
||||
try:
|
||||
json.dump(payload, tmp)
|
||||
finally:
|
||||
tmp.close()
|
||||
return Path(tmp.name)
|
||||
|
||||
def test_linking_a_model_without_an_offset_to_our_session_with_no_offset(self):
|
||||
props = tool.Project.get_project_props()
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
|
||||
json_path = self._write_cache_json({"model_project_north": "0", "model_origin_si": "0,0,0"})
|
||||
try:
|
||||
link = props.links.add()
|
||||
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
|
||||
json.dump({"model_project_north": "0", "model_origin_si": "0,0,0"}, tmp)
|
||||
tmp.flush()
|
||||
link.filepath = str(json_path).replace(".ifc.cache.json", ".ifc")
|
||||
gprops.model_project_north = "0"
|
||||
gprops.model_origin_si = "0,0,0"
|
||||
assert np.allclose(subject.calculate_link_matrix(link), np.eye(4))
|
||||
finally:
|
||||
json_path.unlink(missing_ok=True)
|
||||
|
||||
def test_linking_an_offset_model_to_our_session_with_no_offset(self):
|
||||
props = tool.Project.get_project_props()
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
|
||||
json_path = self._write_cache_json({"model_project_north": "0", "model_origin_si": "5,0,0"})
|
||||
try:
|
||||
link = props.links.add()
|
||||
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
|
||||
json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
|
||||
tmp.flush()
|
||||
link.filepath = str(json_path).replace(".ifc.cache.json", ".ifc")
|
||||
gprops.model_project_north = "0"
|
||||
gprops.model_origin_si = "0,0,0"
|
||||
m = np.eye(4)
|
||||
m[0][3] = 5
|
||||
assert np.allclose(subject.calculate_link_matrix(link), m)
|
||||
finally:
|
||||
json_path.unlink(missing_ok=True)
|
||||
|
||||
def test_linking_an_offset_model_to_our_session_with_offset(self):
|
||||
props = tool.Project.get_project_props()
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
|
||||
json_path = self._write_cache_json({"model_project_north": "0", "model_origin_si": "5,0,0"})
|
||||
try:
|
||||
link = props.links.add()
|
||||
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
|
||||
json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
|
||||
tmp.flush()
|
||||
link.filepath = str(json_path).replace(".ifc.cache.json", ".ifc")
|
||||
gprops.model_project_north = "0"
|
||||
gprops.model_origin_si = "2,0,0"
|
||||
m = np.eye(4)
|
||||
m[0][3] = 3
|
||||
assert np.allclose(subject.calculate_link_matrix(link), m)
|
||||
finally:
|
||||
json_path.unlink(missing_ok=True)
|
||||
|
||||
def test_linking_an_offset_model_to_our_session_with_offset_and_transformation(self):
|
||||
props = tool.Project.get_project_props()
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
with NamedTemporaryFile(suffix=".ifc.cache.json", mode="w", delete=True) as tmp:
|
||||
json_path = self._write_cache_json({"model_project_north": "0", "model_origin_si": "5,0,0"})
|
||||
try:
|
||||
link = props.links.add()
|
||||
link.filepath = tmp.name.replace(".ifc.cache.json", ".ifc")
|
||||
link.filepath = str(json_path).replace(".ifc.cache.json", ".ifc")
|
||||
transformation = np.eye(4)
|
||||
transformation[0][3] = 4
|
||||
link.transformation = ",".join(map(str, transformation.reshape(-1)))
|
||||
json.dump({"model_project_north": "0", "model_origin_si": "5,0,0"}, tmp)
|
||||
tmp.flush()
|
||||
gprops.model_project_north = "0"
|
||||
gprops.model_origin_si = "2,0,0"
|
||||
m = np.eye(4)
|
||||
m[0][3] = 7
|
||||
assert np.allclose(subject.calculate_link_matrix(link), m)
|
||||
finally:
|
||||
json_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
class TestLoadingIfcSqlite(NewFile):
|
||||
|
||||
@@ -23,6 +23,7 @@ import ifcopenshell
|
||||
import ifcopenshell.api
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.api.system
|
||||
import ifcopenshell.util.representation
|
||||
import ifcopenshell.util.system
|
||||
import ifcopenshell.util.unit
|
||||
import numpy as np
|
||||
@@ -39,6 +40,132 @@ class TestImplementsTool(NewFile):
|
||||
assert isinstance(subject(), bonsai.core.tool.System)
|
||||
|
||||
|
||||
class TestHasParametricBody(NewFile):
|
||||
"""The MEP-action gizmo predicates gate on ``has_parametric_body``;
|
||||
fittings whose swept body lives on the type via ``IfcMappedItem`` must
|
||||
return True so the pen-icon and lock-icon rows show on the occurrence."""
|
||||
|
||||
def _build_bend_occurrence_with_mapped_body(self):
|
||||
bpy.ops.bim.create_project()
|
||||
ifc_file = tool.Ifc.get()
|
||||
body_ctx = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
|
||||
|
||||
placement = ifc_file.create_entity(
|
||||
"IfcAxis2Placement3D",
|
||||
Location=ifc_file.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)),
|
||||
)
|
||||
line = ifc_file.create_entity(
|
||||
"IfcLine",
|
||||
Pnt=ifc_file.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)),
|
||||
Dir=ifc_file.create_entity(
|
||||
"IfcVector",
|
||||
Orientation=ifc_file.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)),
|
||||
Magnitude=1.0,
|
||||
),
|
||||
)
|
||||
trimmed = ifc_file.create_entity(
|
||||
"IfcTrimmedCurve",
|
||||
BasisCurve=line,
|
||||
Trim1=(ifc_file.create_entity("IfcParameterValue", wrappedValue=0.0),),
|
||||
Trim2=(ifc_file.create_entity("IfcParameterValue", wrappedValue=1.0),),
|
||||
SenseAgreement=True,
|
||||
MasterRepresentation="PARAMETER",
|
||||
)
|
||||
swept = ifc_file.create_entity("IfcSweptDiskSolid", Directrix=trimmed, Radius=0.05)
|
||||
type_body = ifc_file.create_entity(
|
||||
"IfcShapeRepresentation",
|
||||
ContextOfItems=body_ctx,
|
||||
RepresentationIdentifier="Body",
|
||||
RepresentationType="AdvancedSweptSolid",
|
||||
Items=(swept,),
|
||||
)
|
||||
rep_map = ifc_file.create_entity(
|
||||
"IfcRepresentationMap", MappingOrigin=placement, MappedRepresentation=type_body
|
||||
)
|
||||
fitting_type = ifc_file.create_entity(
|
||||
"IfcPipeFittingType",
|
||||
GlobalId=ifcopenshell.guid.new(),
|
||||
Name="BendType",
|
||||
PredefinedType="BEND",
|
||||
RepresentationMaps=(rep_map,),
|
||||
)
|
||||
mapped_item = ifc_file.create_entity(
|
||||
"IfcMappedItem",
|
||||
MappingSource=rep_map,
|
||||
MappingTarget=ifc_file.create_entity(
|
||||
"IfcCartesianTransformationOperator3D",
|
||||
LocalOrigin=ifc_file.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)),
|
||||
),
|
||||
)
|
||||
occurrence_body = ifc_file.create_entity(
|
||||
"IfcShapeRepresentation",
|
||||
ContextOfItems=body_ctx,
|
||||
RepresentationIdentifier="Body",
|
||||
RepresentationType="MappedRepresentation",
|
||||
Items=(mapped_item,),
|
||||
)
|
||||
fitting = ifc_file.create_entity(
|
||||
"IfcPipeFitting",
|
||||
GlobalId=ifcopenshell.guid.new(),
|
||||
Name="Bend",
|
||||
PredefinedType="BEND",
|
||||
Representation=ifc_file.create_entity("IfcProductDefinitionShape", Representations=(occurrence_body,)),
|
||||
)
|
||||
ifc_file.create_entity(
|
||||
"IfcRelDefinesByType",
|
||||
GlobalId=ifcopenshell.guid.new(),
|
||||
RelatedObjects=(fitting,),
|
||||
RelatingType=fitting_type,
|
||||
)
|
||||
return fitting
|
||||
|
||||
def test_returns_true_for_swept_disk_via_mapped_item(self):
|
||||
"""``traverse()`` follows the
|
||||
``IfcMappedItem.MappingSource.MappedRepresentation`` chain so the
|
||||
``IfcSweptDiskSolid`` on the type's body is reachable from the
|
||||
occurrence's body representation. Bend fittings produced by the
|
||||
bend-preview commit path use this exact representation shape."""
|
||||
fitting = self._build_bend_occurrence_with_mapped_body()
|
||||
assert subject.has_parametric_body(fitting) is True
|
||||
|
||||
def test_returns_false_for_tessellated_body(self):
|
||||
"""The bend creation path replaces the swept-disk body with an
|
||||
``IfcTriangulatedFaceSet`` as an upstream geometry-kernel
|
||||
workaround. The traverse finds no extruded / swept solid, so the
|
||||
predicate returns False — pinning the constraint that drives the
|
||||
``BBIM_Fitting`` pset fallback in the bend-icon visibility
|
||||
predicate."""
|
||||
bpy.ops.bim.create_project()
|
||||
ifc_file = tool.Ifc.get()
|
||||
body_ctx = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
|
||||
|
||||
coords = ifc_file.create_entity(
|
||||
"IfcCartesianPointList3D",
|
||||
CoordList=((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)),
|
||||
)
|
||||
tessellation = ifc_file.create_entity(
|
||||
"IfcTriangulatedFaceSet",
|
||||
Coordinates=coords,
|
||||
CoordIndex=((1, 2, 3),),
|
||||
)
|
||||
body = ifc_file.create_entity(
|
||||
"IfcShapeRepresentation",
|
||||
ContextOfItems=body_ctx,
|
||||
RepresentationIdentifier="Body",
|
||||
RepresentationType="Tessellation",
|
||||
Items=(tessellation,),
|
||||
)
|
||||
fitting = ifc_file.create_entity(
|
||||
"IfcPipeFitting",
|
||||
GlobalId=ifcopenshell.guid.new(),
|
||||
Name="TessellatedBend",
|
||||
PredefinedType="BEND",
|
||||
Representation=ifc_file.create_entity("IfcProductDefinitionShape", Representations=(body,)),
|
||||
)
|
||||
|
||||
assert subject.has_parametric_body(fitting) is False
|
||||
|
||||
|
||||
class TestAddPorts(NewFile):
|
||||
def setup_mep_segment(self):
|
||||
bpy.ops.bim.create_project()
|
||||
|
||||
@@ -172,6 +172,48 @@ class TestHasMaterialUsage(NewFile):
|
||||
assert subject.has_material_usage(element) is True
|
||||
|
||||
|
||||
class TestIsRelatingTypeCompatible(NewFile):
|
||||
def test_matched_pair_ifc4(self):
|
||||
ifc = ifcopenshell.file()
|
||||
door = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDoor")
|
||||
door_type = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDoorType")
|
||||
assert subject.is_relating_type_compatible(door, door_type) is True
|
||||
|
||||
def test_mismatched_pair_ifc4(self):
|
||||
ifc = ifcopenshell.file()
|
||||
door = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDoor")
|
||||
wall_type = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWallType")
|
||||
assert subject.is_relating_type_compatible(door, wall_type) is False
|
||||
|
||||
def test_legacy_style_pairing_allowed_in_ifc4(self):
|
||||
ifc = ifcopenshell.file()
|
||||
door = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDoor")
|
||||
door_style = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDoorStyle")
|
||||
assert subject.is_relating_type_compatible(door, door_style) is True
|
||||
|
||||
def test_legacy_style_pairing_refused_in_ifc4x3(self):
|
||||
ifc = ifcopenshell.file(schema="IFC4X3")
|
||||
door = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDoor")
|
||||
try:
|
||||
door_style = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDoorStyle")
|
||||
except Exception:
|
||||
# IfcDoorStyle was removed in IFC4X3 — exclusion holds trivially.
|
||||
return
|
||||
assert subject.is_relating_type_compatible(door, door_style) is False
|
||||
|
||||
def test_untypable_occurrence_returns_false(self):
|
||||
ifc = ifcopenshell.file()
|
||||
opening = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcOpeningElement")
|
||||
any_type = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDoorType")
|
||||
assert subject.is_relating_type_compatible(opening, any_type) is False
|
||||
|
||||
def test_proxy_type_pairing(self):
|
||||
ifc = ifcopenshell.file()
|
||||
proxy = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcBuildingElementProxy")
|
||||
proxy_type = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcBuildingElementProxyType")
|
||||
assert subject.is_relating_type_compatible(proxy, proxy_type) is True
|
||||
|
||||
|
||||
class TestRunGeometryAddRepresentation(NewFile):
|
||||
def test_nothing(self):
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user