Merge remote-tracking branch 'origin/v0.8.0' into ifcviewer-wgpu

This commit is contained in:
Thomas Krijnen
2026-07-09 13:21:39 +02:00
373 changed files with 22411 additions and 4242 deletions
+53
View File
@@ -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
+87 -1
View File
@@ -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."
)
+101
View File
@@ -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
+31
View File
@@ -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}
+24
View File
@@ -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]:
+77 -16
View File
@@ -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):
+127
View File
@@ -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()
+42
View File
@@ -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