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
@@ -183,6 +183,39 @@ class TestAssignType(test.bootstrap.IFC4):
assert element.PredefinedType == "USERDEFINED"
assert element.ObjectType == "Test"
def test_class_mismatched_pair_raises(self):
door = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcDoor")
wall_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType")
with pytest.raises(TypeError, match=r"IfcWallType cannot type IfcDoor"):
ifcopenshell.api.type.assign_type(self.file, related_objects=[door], relating_type=wall_type)
assert ifcopenshell.util.element.get_type(door) is None
def test_class_mismatched_pair_does_not_mutate(self):
door = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcDoor")
wall_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType")
rels_before = self.file.by_type("IfcRelDefinesByType")
with pytest.raises(TypeError):
ifcopenshell.api.type.assign_type(self.file, related_objects=[door], relating_type=wall_type)
rels_after = self.file.by_type("IfcRelDefinesByType")
assert rels_after == rels_before
def test_partial_mismatch_in_selection_rejects_whole_call(self):
door = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcDoor")
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
wall_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType")
with pytest.raises(TypeError):
ifcopenshell.api.type.assign_type(self.file, related_objects=[door, wall], relating_type=wall_type)
# The good occurrence must NOT have been typed — partial mutation is the
# bug class this guard exists to prevent.
assert ifcopenshell.util.element.get_type(wall) is None
assert ifcopenshell.util.element.get_type(door) is None
def test_untypable_occurrence_rejected(self):
opening = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcOpeningElement")
any_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType")
with pytest.raises(TypeError):
ifcopenshell.api.type.assign_type(self.file, related_objects=[opening], relating_type=any_type)
class TestAssignTypeIFC2X3(test.bootstrap.IFC2X3, TestAssignType):
pass
@@ -2,7 +2,7 @@ ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('ViewDefinition [CoordinationView]','RevitIdentifiers [ContentGUID: a0df3484-2dab-42c5-b806-8c10d313bee0, VersionGUID: 658c1394-f3a4-43d1-9b3c-eee44a0cd67a, NumberOfSaves: 2]','CoordinateReference [CoordinateBase: Shared Coordinates]'),'2;1');
FILE_NAME('Column_4x3.ifc','2025-03-12T13:53:30+00:00',(''),(''),'ODA SDAI 24.12','Autodesk Revit 25.4.0.32 (ENG) - IFC 25.4.0.32','');
FILE_SCHEMA(('IFC4X3_ADD2'));
FILE_SCHEMA(('IFC2X3'));
ENDSEC;
DATA;
#1=IFCORGANIZATION($,'Autodesk Revit 2025 (ENG)',$,$,$);
@@ -0,0 +1,10 @@
ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1');
FILE_NAME('','2022-12-12T15:43:30',(''),(''),'','','');
FILE_SCHEMA(('IFC2X3'));
ENDSEC;
DATA;
#1=IFCCURVESTYLE($,$,IFCDESCRIPTIVEMEASURE('by layer'),$);
ENDSEC;
END-ISO-10303-21;
@@ -216,6 +216,26 @@ def test_iterator():
assert iterator.initialize()
def test_logging():
logger = ifcopenshell.logger()
logger.OutputFormat(logger.FMT_INMEMORY)
settings = ifcopenshell.geom.settings()
f = ifcopenshell.open(fn)
col = f.by_type("IfcColumn")[0]
_ = ifcopenshell.geom.create_shape(settings, col, logger=logger)
num_log_items = len(list(logger))
col.Representation.Representations[0].Items[0].MappingSource.MappedRepresentation.Items[0].Depth *= -1.0
with pytest.raises(RuntimeError):
_ = ifcopenshell.geom.create_shape(settings, col, logger=logger)
new_items = list(logger)[num_log_items:]
assert ("GEO089", "Non-positive extrusion height encountered for:") in [
(msg.code, msg.message) for msg in new_items
]
if __name__ == "__main__":
import pytest
@@ -86,4 +86,4 @@ def test_setting_logical():
assert '.F.' in str(inst)
inst.LayerOn = True
assert inst.LayerOn is True
assert '.T.' in str(inst)
assert '.T.' in str(inst)
@@ -0,0 +1,19 @@
import ifcopenshell
def test_skip_over_non_entity_instance():
data = """
ISO-10303-21;
HEADER;
FILE_DESCRIPTION((''),'2;1');
FILE_NAME('','',(''),(''),'','','');
FILE_SCHEMA(('IFC2X3'));
ENDSEC;
DATA;
#1=IFCLENGTHMEASURE(0.1);
#5=IFCCARTESIANPOINT((0.,0.));
ENDSEC;
END-ISO-10303-21;
"""
f = ifcopenshell.file.from_string(data)
print(ifcopenshell.get_log())
f.by_id(5)
+1 -1
View File
@@ -45,4 +45,4 @@ def test_file(filename):
if __name__ == "__main__":
pytest.main(["-sx", __file__])
pytest.main(["-sx", __file__, '--import-mode=importlib'])
@@ -74,14 +74,29 @@ def test_opening_unicode():
@pytest.mark.skipif(psutil is None, reason="psutil not installed")
def test_memusage_partial_open():
m0 = psutil.Process().memory_info().rss
f = ifcopenshell.open(fn)
m1 = psutil.Process().memory_info().rss
g = ifcopenshell.open(fn, bypass_types=("IfcRepresentationItem",))
m2 = psutil.Process().memory_info().rss
# arbitrary...
expected_ratio = 0.75
assert (m2 - m1) < (m1 - m0) * expected_ratio
# Run in a subprocess to ensure the file is not already in the process page
# cache from earlier tests, which would make both RSS deltas read as zero.
import subprocess
import sys
script = f"""
import psutil
import ifcopenshell
fn = {repr(fn)}
m0 = psutil.Process().memory_info().rss
f = ifcopenshell.open(fn)
m1 = psutil.Process().memory_info().rss
g = ifcopenshell.open(fn, bypass_types=("IfcRepresentationItem",))
m2 = psutil.Process().memory_info().rss
expected_ratio = 0.75
assert (m2 - m1) < (m1 - m0) * expected_ratio, (
f"bypass_types did not reduce memory: normal open added {{m1 - m0}} bytes, "
f"bypass open added {{m2 - m1}} bytes (expected < {{(m1 - m0) * expected_ratio:.0f}})"
)
"""
result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr or result.stdout
def test_rocks():
@@ -16,6 +16,9 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import pytest
import ifcopenshell
import ifcopenshell.api.project
import ifcopenshell.util.schema as subject
import test.bootstrap
@@ -119,6 +122,157 @@ END-ISO-10303-21;
assert isinstance(qt_float_count_measure_ifc4x3[3], float)
assert qt_float_count_measure_ifc4x3[3] == 723.0
def test_migrate_class_raises_clear_error_for_ifc4_only_non_element_class_to_ifc2x3(self):
"""IFC4-only non-element classes (geometry items, etc.) have no
IfcBuildingElementProxy fallback and must surface a clear error naming
the failing class — not the cryptic 'Entity name not found in schema'."""
ifc4_file = ifcopenshell.api.project.create_file()
point_list = ifc4_file.create_entity("IfcCartesianPointList2D", CoordList=((0.0, 0.0), (1.0, 0.0)))
ifc2x3_file = ifcopenshell.api.project.create_file(version="IFC2X3")
migrator = subject.Migrator()
with pytest.raises(NotImplementedError) as exc_info:
migrator.migrate(point_list, ifc2x3_file)
message = str(exc_info.value)
assert "IfcCartesianPointList2D" in message
assert "IFC2X3" in message
def test_migrate_class_falls_back_to_ifcbuildingelementproxy_when_opt_in(self):
"""With ``fallback_element_to_proxy=True``, IFC4-only IfcElement
subclasses (IfcLamp, IfcPipeSegment, IfcGeographicElement, …) migrate
as IfcBuildingElementProxy instead of raising. Default behavior
(no opt-in) raises so non-recipe callers keep the strict contract."""
ifc4_file = ifcopenshell.api.project.create_file()
lamp = ifc4_file.create_entity("IfcLamp", GlobalId="2K6Z3DR8X37AS9XFvX8GcW")
ifc2x3_file = ifcopenshell.api.project.create_file(version="IFC2X3")
# Default migrator raises (strict contract preserved).
with pytest.raises(NotImplementedError, match="IfcLamp"):
subject.Migrator().migrate(lamp, ifc2x3_file)
# Opt-in migrator substitutes IfcBuildingElementProxy.
ifc2x3_file = ifcopenshell.api.project.create_file(version="IFC2X3")
new_lamp = subject.Migrator(fallback_element_to_proxy=True).migrate(lamp, ifc2x3_file)
assert new_lamp.is_a("IfcBuildingElementProxy")
class TestGetFallbackSchema:
"""Pins the schema-identifier normalisation contract relied on by callers
that need to map upstream variants (IFC4X3_ADD2, IFC2X3_TC1, IFC4_ADD2, …)
to a base schema name for compatibility tables / downgrade detection."""
def test_ifc4x3_variants_collapse_to_ifc4x3(self):
# Longest-prefix-first: IFC4X3_ADD2 must NOT be misclassified as IFC4
# — the function checks IFC4X3 before IFC4.
assert subject.get_fallback_schema("IFC4X3") == "IFC4X3"
assert subject.get_fallback_schema("IFC4X3_ADD1") == "IFC4X3"
assert subject.get_fallback_schema("IFC4X3_ADD2") == "IFC4X3"
assert subject.get_fallback_schema("IFC4X3_RC1") == "IFC4X3"
def test_ifc4_variants_collapse_to_ifc4(self):
assert subject.get_fallback_schema("IFC4") == "IFC4"
assert subject.get_fallback_schema("IFC4_ADD1") == "IFC4"
assert subject.get_fallback_schema("IFC4_ADD2") == "IFC4"
# IFC4X1 / IFC4X2 are draft schemas — collapse to IFC4 by design.
assert subject.get_fallback_schema("IFC4X1") == "IFC4"
assert subject.get_fallback_schema("IFC4X2") == "IFC4"
def test_ifc2x3_variants_collapse_to_ifc2x3(self):
assert subject.get_fallback_schema("IFC2X3") == "IFC2X3"
assert subject.get_fallback_schema("IFC2X3_TC1") == "IFC2X3"
assert subject.get_fallback_schema("IFC2X3_FINAL") == "IFC2X3"
def test_unknown_version_asserts(self):
# Asserts under non-optimised Python; in -O mode would return the
# unmodified input. Caller should guard accordingly.
with pytest.raises(AssertionError):
subject.get_fallback_schema("IFC10")
class TestIfc4OnlyGeometryClasses:
def test_known_ifc4_only_classes_present(self):
result = subject.ifc4_only_geometry_classes()
# Classes that genuinely don't exist in IFC2X3 and inherit
# IfcRepresentationItem in IFC4.
for name in (
"IfcPolygonalFaceSet",
"IfcTriangulatedFaceSet",
"IfcIndexedPolyCurve",
"IfcCartesianPointList3D",
"IfcAdvancedBrep",
):
assert name in result, f"{name} should be classified as IFC4-only geometry"
def test_ifc2x3_compatible_classes_absent(self):
result = subject.ifc4_only_geometry_classes()
# Classes that exist in both schemas — must NOT be flagged.
for name in ("IfcPolyline", "IfcFacetedBrep", "IfcCartesianPoint", "IfcExtrudedAreaSolid"):
assert name not in result, f"{name} exists in IFC2X3, should not be IFC4-only"
def test_non_geometry_ifc4_only_classes_absent(self):
result = subject.ifc4_only_geometry_classes()
# IFC4-only but not IfcRepresentationItem subclasses — out of scope.
for name in ("IfcEvent", "IfcWorkCalendar", "IfcLamp"):
assert name not in result, f"{name} is not an IfcRepresentationItem subclass"
def test_result_is_cached_frozenset(self):
first = subject.ifc4_only_geometry_classes()
second = subject.ifc4_only_geometry_classes()
assert first is second # @functools.cache returns the same object
class TestGeometryClassesIntroducedAfter:
"""Generalised version of ``ifc4_only_geometry_classes`` — pins the
schema-aware contract that supports IFC4X3 → IFC2X3 downgrades, not just
IFC4 → IFC2X3."""
def test_ifc4_to_ifc2x3_matches_legacy_helper(self):
# The legacy ``ifc4_only_geometry_classes`` is now a thin alias.
assert subject.geometry_classes_introduced_after("IFC2X3", "IFC4") == subject.ifc4_only_geometry_classes()
def test_ifc4x3_to_ifc2x3_is_superset_of_ifc4_to_ifc2x3(self):
# IFC4X3 is a superset of IFC4 — every IFC4-only geometry class is
# also missing from IFC2X3 when the source is IFC4X3, plus any new
# IFC4X3-only geometry (alignment curves, distance expressions, …).
ifc4_gap = subject.geometry_classes_introduced_after("IFC2X3", "IFC4")
ifc4x3_gap = subject.geometry_classes_introduced_after("IFC2X3", "IFC4X3")
assert ifc4_gap <= ifc4x3_gap
def test_ifc4_to_ifc4x3_is_empty(self):
# IFC4X3 contains every IFC4 IfcRepresentationItem subclass — no
# IFC4 class is missing from IFC4X3.
assert subject.geometry_classes_introduced_after("IFC4X3", "IFC4") == frozenset()
class TestEnumValueOutsideTarget:
@staticmethod
def _attr(class_name: str, attr_name: str):
schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name("IFC2X3")
decl = schema.declaration_by_name(class_name)
return next(a for a in decl.all_attributes() if a.name() == attr_name)
def test_enum_value_present_in_target_returns_false(self):
# IfcCovering.PredefinedType is IfcCoveringTypeEnum — CEILING is valid.
attr = self._attr("IfcCovering", "PredefinedType")
assert subject._enum_value_outside_target(attr, "CEILING") is False
def test_enum_value_missing_in_target_returns_true(self):
# IfcCoveringTypeEnum has no COMPACTFLUORESCENT (an IfcLampTypeEnum value).
attr = self._attr("IfcCovering", "PredefinedType")
assert subject._enum_value_outside_target(attr, "COMPACTFLUORESCENT") is True
def test_non_enum_attribute_returns_false(self):
# IfcCovering.Name is IfcLabel — not an enum, so the helper must return False.
attr = self._attr("IfcCovering", "Name")
assert subject._enum_value_outside_target(attr, "anything") is False
def test_non_string_value_returns_false(self):
attr = self._attr("IfcCovering", "PredefinedType")
assert subject._enum_value_outside_target(attr, 42) is False
class TestExtendedMaterialProperties(test.bootstrap.IFC4):
def test_migrate_extended_material_properties_ifc2x3_ifc4(self):
ifc2x3_file = ifcopenshell.api.project.create_file(version="IFC2X3")
material = ifc2x3_file.createIfcMaterial(Name="Material")
@@ -16,7 +16,7 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from math import degrees, radians
from math import degrees, radians, sqrt
from typing import Any, Union
import numpy as np
@@ -28,6 +28,7 @@ import test.bootstrap
from ifcopenshell.util.shape_builder import (
ShapeBuilder,
V,
arc_to_polyline_points,
is_x,
np_angle,
np_angle_signed,
@@ -36,9 +37,116 @@ from ifcopenshell.util.shape_builder import (
np_normal,
np_rotation_matrix,
np_to_3d,
polygonal_face_set_to_faceted_brep,
)
class TestArcToPolylinePoints:
def test_quarter_arc_2d_samples_n_plus_one_points(self):
# Quarter arc from (1,0) through (cos45°, sin45°) to (0,1) — unit circle.
sqrt_half = sqrt(0.5)
points = arc_to_polyline_points((1.0, 0.0), (sqrt_half, sqrt_half), (0.0, 1.0), 8)
assert len(points) == 9
assert points[0] == pytest.approx((1.0, 0.0), abs=1e-9)
assert points[-1] == pytest.approx((0.0, 1.0), abs=1e-9)
for x, y in points:
assert x * x + y * y == pytest.approx(1.0, abs=1e-9)
def test_collinear_inputs_fall_back_to_straight_chord(self):
points = arc_to_polyline_points((0.0, 0.0), (1.0, 0.0), (2.0, 0.0), 16)
assert points == [(0.0, 0.0), (2.0, 0.0)]
def test_3d_inputs_with_constant_z_preserved(self):
points = arc_to_polyline_points((1.0, 0.0, 5.0), (0.7071, 0.7071, 5.0), (0.0, 1.0, 5.0), 4)
assert len(points) == 5
assert all(p[2] == 5.0 for p in points)
def test_3d_inputs_with_mismatched_z_raises(self):
with pytest.raises(ValueError, match="XY plane"):
arc_to_polyline_points((1.0, 0.0, 0.0), (0.0, 1.0, 1.0), (-1.0, 0.0, 0.0))
def test_3d_inputs_with_near_equal_z_pass_within_tolerance(self):
# Real IFC files often have float noise of ~1e-15 in Z values that the
# author meant to be identical — kernel transforms introduce it. The
# planar check tolerates this rather than rejecting valid input.
sqrt_half = sqrt(0.5)
points = arc_to_polyline_points(
(1.0, 0.0, 5.0), (sqrt_half, sqrt_half, 5.0 + 1e-15), (0.0, 1.0, 5.0 - 2e-16), 4
)
assert len(points) == 5
def test_subdivisions_zero_raises(self):
with pytest.raises(ValueError, match="subdivisions"):
arc_to_polyline_points((1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), 0)
class TestPolygonalFaceSetToFacetedBrep(test.bootstrap.IFC4):
def test_triangulated_face_set_preserves_coordinates(self):
coords = self.file.create_entity(
"IfcCartesianPointList3D",
CoordList=((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.5, 0.5, 1.0)),
)
face_set = self.file.create_entity(
"IfcTriangulatedFaceSet", Coordinates=coords, CoordIndex=[(1, 2, 4), (2, 3, 4), (3, 1, 4), (1, 3, 2)]
)
brep = polygonal_face_set_to_faceted_brep(face_set)
assert brep.is_a("IfcFacetedBrep")
assert len(brep.Outer.CfsFaces) == 4
# Every CoordList vertex appears in the brep at the same coordinate.
brep_points = {tuple(p.Coordinates) for f in brep.Outer.CfsFaces for p in f.Bounds[0].Bound.Polygon}
assert (0.0, 0.0, 0.0) in brep_points
assert (1.0, 0.0, 0.0) in brep_points
assert (0.0, 1.0, 0.0) in brep_points
assert (0.5, 0.5, 1.0) in brep_points
def test_polygonal_face_set_with_voids_preserves_inner_bounds(self):
# Quad with a triangular hole through it.
coords = self.file.create_entity(
"IfcCartesianPointList3D",
CoordList=(
(0.0, 0.0, 0.0),
(4.0, 0.0, 0.0),
(4.0, 4.0, 0.0),
(0.0, 4.0, 0.0),
(1.0, 1.0, 0.0),
(3.0, 1.0, 0.0),
(2.0, 3.0, 0.0),
),
)
face = self.file.create_entity(
"IfcIndexedPolygonalFaceWithVoids",
CoordIndex=(1, 2, 3, 4),
InnerCoordIndices=[(5, 6, 7)],
)
face_set = self.file.create_entity("IfcPolygonalFaceSet", Coordinates=coords, Faces=[face])
brep = polygonal_face_set_to_faceted_brep(face_set)
assert len(brep.Outer.CfsFaces) == 1
bounds = brep.Outer.CfsFaces[0].Bounds
# Outer + 1 inner bound.
assert len(bounds) == 2
outer = next(b for b in bounds if b.is_a("IfcFaceOuterBound"))
inner = next(b for b in bounds if not b.is_a("IfcFaceOuterBound"))
assert len(outer.Bound.Polygon) == 4
assert len(inner.Bound.Polygon) == 3
def test_wrong_class_raises_typeerror(self):
# An IfcCartesianPointList3D is not a face set.
not_a_face_set = self.file.create_entity("IfcCartesianPointList3D", CoordList=((0.0, 0.0, 0.0),))
with pytest.raises(TypeError, match="IfcPolygonalFaceSet"):
polygonal_face_set_to_faceted_brep(not_a_face_set)
def test_out_of_range_index_raises_valueerror(self):
coords = self.file.create_entity("IfcCartesianPointList3D", CoordList=((0.0, 0.0, 0.0),))
# CoordIndex 5 doesn't exist in a 1-vertex coord list.
face_set = self.file.create_entity("IfcTriangulatedFaceSet", Coordinates=coords, CoordIndex=[(1, 1, 5)])
with pytest.raises(ValueError, match="outside CoordList range"):
polygonal_face_set_to_faceted_brep(face_set)
class TestMathutilsCompatibleMethods(test.bootstrap.IFC4):
def test_np_rotation_matrix(self):
from mathutils import Matrix, Vector # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import]
@@ -19,6 +19,7 @@
from math import pi
import numpy as np
import pytest
import ifcopenshell.api.context
import ifcopenshell.api.georeference
@@ -258,6 +259,23 @@ class TestConvertFileLengthUnits(test.bootstrap.IFC2X3):
assert max(i.id() for i in output) == len(output.entity_names()) + 1
assert subject.get_full_unit_name(subject.get_project_unit(output, "LENGTHUNIT")) == "METRE"
def test_precision_conversion(self):
# Regression test for #6127: IfcGeometricRepresentationContext.Precision
# is typed IfcReal but interpreted in the project length unit, so it must
# be scaled along with the length measures.
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
ifcopenshell.api.unit.assign_unit(self.file, units=[unit])
context = ifcopenshell.api.context.add_context(self.file, context_type="Model")
context.Precision = 0.01
# Subcontexts derive Precision from the parent and must be left alone.
ifcopenshell.api.context.add_context(
self.file, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=context
)
output = subject.convert_file_length_units(self.file, target_units="METER")
new_context = output.by_type("IfcGeometricRepresentationContext", include_subtypes=False)[0]
assert new_context.Precision == pytest.approx(0.00001)
def test_attribute_conversion(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")