diff --git a/src/ifcopenshell-python/ifcopenshell/util/schema.py b/src/ifcopenshell-python/ifcopenshell/util/schema.py index bdd6d489d0..e755095bdf 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/schema.py +++ b/src/ifcopenshell-python/ifcopenshell/util/schema.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . +import functools import json import os import time @@ -148,6 +149,57 @@ def get_subtypes( return get_classes(declaration) +def _enum_value_outside_target(attribute: ifcopenshell_wrapper.attribute, value: Any) -> bool: + """``True`` when ``attribute`` is an enumeration and the string ``value`` + is not in its declared items. Used by the Migrator to silently skip enum + values that exist in the source schema but not the target — without + parsing C++ wrapper error strings.""" + if not isinstance(value, str): + return False + try: + enum_items = ifcopenshell.util.attribute.get_enum_items(attribute) + except (AssertionError, AttributeError): + return False + return value not in enum_items + + +@functools.cache +def geometry_classes_introduced_after(target_schema: IFC_SCHEMA, source_schema: IFC_SCHEMA = "IFC4") -> frozenset[str]: + """``IfcRepresentationItem`` subclasses present in ``source_schema`` but + missing in ``target_schema``. + + Derived from the loaded schema declarations once per (source, target) pair + and cached. The result is the canonical set of geometry classes a + downgrade from ``source_schema`` to ``target_schema`` must convert + (``IfcPolygonalFaceSet``, ``IfcTriangulatedFaceSet``, ``IfcAdvancedBrep``, + B-splines, advanced surfaces, alignment curves on IFC4X3 → 2X3, …) or + purge. Defaults match the IFC4 → IFC2X3 case for backwards compatibility + with the original caller.""" + source = ifcopenshell_wrapper.schema_by_name(source_schema) + target = ifcopenshell_wrapper.schema_by_name(target_schema) + target_names = {decl.name() for decl in target.entities()} + result: set[str] = set() + for decl in source.entities(): + if decl.name() in target_names: + continue + cursor: Any = decl + while cursor is not None: + if cursor.name() == "IfcRepresentationItem": + result.add(decl.name()) + break + cursor = cursor.supertype() + return frozenset(result) + + +def ifc4_only_geometry_classes() -> frozenset[str]: + """Backwards-compatible alias for the IFC4 → IFC2X3 geometry-gap set. + + New code should call :func:`geometry_classes_introduced_after` with the + explicit (target, source) pair so IFC4X3 → IFC2X3 downgrades pick up the + additional IFC4X3-only geometry classes.""" + return geometry_classes_introduced_after("IFC2X3", "IFC4") + + def reassign_class( ifc_file: Union[ifcopenshell.file, None], element: ifcopenshell.entity_instance, new_class: str ) -> ifcopenshell.entity_instance: @@ -263,7 +315,20 @@ class Migrator: migrated_ids: dict[int, int] attribute_overrides: dict[int, dict[int, str]] - def __init__(self): + def __init__(self, *, fallback_element_to_proxy: bool = False) -> None: + """Construct a schema migrator. + + :param fallback_element_to_proxy: When ``True`` and the target schema is + IFC2X3, IFC4 entity classes that have no direct IFC2X3 equivalent + but inherit from ``IfcElement`` / ``IfcElementType`` are migrated as + ``IfcBuildingElementProxy`` / ``IfcBuildingElementProxyType`` + respectively, instead of raising. Caller code is then responsible + for preserving the lost original class information out-of-band (the + ``Migrate`` ifcpatch recipe encodes it into ``ObjectType``). + Defaults to ``False`` so non-recipe callers keep the strict + failure-on-unmappable contract. + """ + self.fallback_element_to_proxy = fallback_element_to_proxy self.migrated_ids = {} self.attribute_overrides = {} self.class_4_to_2x3 = json.load(open(os.path.join(cwd, "class_4_to_2x3.json"), "r")) @@ -379,6 +444,17 @@ class Migrator: self.migrated_ids[element.id()] = new_element.id() return new_element + @staticmethod + def _is_subclass_of(ifc_class: str, ancestor: str, source_file: ifcopenshell.file) -> bool: + schema = ifcopenshell_wrapper.schema_by_name(source_file.schema_identifier) + try: + return is_a(schema.declaration_by_name(ifc_class), ancestor) + except RuntimeError: + # Class doesn't exist in the source schema — happens for cross-schema + # introspection of an entity created with a name the wrapper doesn't + # recognise. Treat as "not a subclass". + return False + def migrate_class( self, element: ifcopenshell.entity_instance, new_file: ifcopenshell.file ) -> ifcopenshell.entity_instance: @@ -389,15 +465,44 @@ class Migrator: if isinstance(value, float): ifc_class = "IfcQuantityNumber" try: - new_element = new_file.create_entity(ifc_class) + return new_file.create_entity(ifc_class) except: - # The element does not exist in this schema - # Complex migration is not yet supported (e.g. polygonal face set to faceted brep) - if new_file.schema == "IFC2X3": - new_element = new_file.create_entity(self.class_4_to_2x3[ifc_class]) - elif new_file.schema == "IFC4": - new_element = new_file.create_entity(self.class_2x3_to_4[ifc_class]) - return new_element + pass + + # The class does not exist in the target schema — look up an equivalent. + # The lookup tables use empty-string as a sentinel meaning "no direct + # equivalent, needs geometric translation" (e.g. polygonal face set → + # faceted brep). Callers that want a clean downgrade are expected to + # preprocess such carriers before calling the Migrator; see the + # `Migrate` ifcpatch recipe. + if new_file.schema == "IFC2X3": + equivalent = self.class_4_to_2x3.get(ifc_class, None) + elif new_file.schema == "IFC4": + equivalent = self.class_2x3_to_4.get(ifc_class, None) + else: + equivalent = None + + # IfcBuildingElementProxy fallback is opt-in (see constructor) — only + # the IfcElement / IfcElementType subtrees have a meaningful generic + # IFC2X3 stand-in; non-element IFC4-only classes (rels, geometry items, + # materials, times) still raise below. + if not equivalent and new_file.schema == "IFC2X3" and self.fallback_element_to_proxy: + if self._is_subclass_of(ifc_class, "IfcElement", element.wrapped_data.file): + equivalent = "IfcBuildingElementProxy" + elif self._is_subclass_of(ifc_class, "IfcElementType", element.wrapped_data.file): + equivalent = "IfcBuildingElementProxyType" + + if not equivalent: + inverses = element.wrapped_data.file.get_inverse(element) + inverse_hint = ", ".join(f"#{i.id()}={i.is_a()}" for i in list(inverses)[:3]) + if len(inverses) > 3: + inverse_hint += f", … (+{len(inverses) - 3} more)" + raise NotImplementedError( + f"Cannot migrate #{element.id()}={ifc_class} to schema " + f"{new_file.schema}: no direct equivalent exists. " + f"Referenced by: {inverse_hint or '(no inverses)'}." + ) + return new_file.create_entity(equivalent) def migrate_attributes( self, @@ -526,11 +631,40 @@ class Migrator: new_value.append(self.migrate(item, new_file)) value = new_value if value is not None: + if _enum_value_outside_target(attribute, value): + # Enum value present in source schema but missing in target + # (typically a downgrade after a cross-class fallback, e.g. + # IfcLamp.PredefinedType=COMPACTFLUORESCENT copied onto + # IfcBuildingElementProxy.CompositionType whose enum is + # IfcElementCompositionEnum). Leave the attribute unset rather + # than abort the whole entity's migration. Detected + # structurally so other RuntimeError causes (type mismatches, + # invalid values) still propagate. + return setattr(new_element, attribute.name(), value) def generate_default_value(self, attribute: ifcopenshell_wrapper.attribute, new_file: ifcopenshell.file) -> Any: if attribute.name() in self.default_values: return self.default_values[attribute.name()] + elif attribute.name() == "Position": + # IFC4 relaxed Position to OPTIONAL for many profile defs; IFC2X3 + # still requires it. Synthesize a unit placement at origin so + # IfcIShapeProfileDef and friends downgrade without crashing + # downstream validators. + try: + type_name = attribute.type_of_attribute().as_named_type().declared_type().name() + except Exception: + type_name = None + if type_name == "IfcAxis2Placement2D": + return new_file.create_entity( + "IfcAxis2Placement2D", + Location=new_file.create_entity("IfcCartesianPoint", (0.0, 0.0)), + ) + if type_name == "IfcAxis2Placement3D": + return new_file.create_entity( + "IfcAxis2Placement3D", + Location=new_file.create_entity("IfcCartesianPoint", (0.0, 0.0, 0.0)), + ) elif attribute.name() == "OwnerHistory": self.default_entities[attribute.name()] = new_file.create_entity( "IfcOwnerHistory", diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index d9d18f0b5f..eb5bbbdcdb 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -21,7 +21,7 @@ from __future__ import annotations import collections.abc from collections.abc import Sequence from itertools import chain -from math import atan, cos, degrees, pi, radians, sin, sqrt, tan +from math import atan, atan2, cos, degrees, hypot, isclose, pi, radians, sin, sqrt, tan from typing import TYPE_CHECKING, Any, Literal, Optional, Union import numpy as np @@ -301,6 +301,130 @@ def intersect_x_axis_2d(p1: VectorType, p2: VectorType, y=0) -> Optional[float]: return x1 + t * (x2 - x1) +def arc_to_polyline_points( + start: VectorType, mid: VectorType, end: VectorType, subdivisions: int = 16 +) -> list[tuple[float, ...]]: + """Approximate a circular arc through (start, mid, end) with chord points. + + The arc is determined uniquely by three points — a circle is fit in the + XY plane and the angle is walked from start through mid to end, sampling + ``subdivisions + 1`` points inclusive of the endpoints. Falls back to a + straight chord ``[start, end]`` for collinear / degenerate inputs. + + Only planar arcs in the XY plane are supported. For 3D inputs (length 3 + tuples), the Z coordinate of each output point is held constant at + ``start[2]``. Inputs where start/mid/end have differing Z values raise + ``ValueError`` rather than silently project — caller should rotate the + arc into the XY plane first if it lives in a non-axis-aligned plane. + + :raises ValueError: if subdivisions < 1, or if 3D inputs have mismatched + Z coordinates (non-planar arc). + """ + if subdivisions < 1: + raise ValueError(f"subdivisions must be >= 1, got {subdivisions}") + if len(start) >= 3: + # Tolerance accommodates floating-point noise from kernel transforms + # — IFC point coordinates that the author wrote as the same Z value + # may diverge by ~1e-15 after placement-matrix round-trips. + z_tol = 1e-9 + if not (isclose(start[2], mid[2], abs_tol=z_tol) and isclose(start[2], end[2], abs_tol=z_tol)): + raise ValueError( + f"arc_to_polyline_points only handles arcs in the XY plane; " + f"got mismatched Z coordinates ({start[2]}, {mid[2]}, {end[2]})." + ) + sx, sy = start[0], start[1] + mx, my = mid[0], mid[1] + ex, ey = end[0], end[1] + d = 2 * (sx * (my - ey) + mx * (ey - sy) + ex * (sy - my)) + if abs(d) < 1e-12: + return [tuple(start), tuple(end)] + cx = ((sx**2 + sy**2) * (my - ey) + (mx**2 + my**2) * (ey - sy) + (ex**2 + ey**2) * (sy - my)) / d + cy = ((sx**2 + sy**2) * (ex - mx) + (mx**2 + my**2) * (sx - ex) + (ex**2 + ey**2) * (mx - sx)) / d + a_start = atan2(sy - cy, sx - cx) + a_mid = atan2(my - cy, mx - cx) + a_end = atan2(ey - cy, ex - cx) + sweep = _signed_sweep_through_mid(a_start, a_mid, a_end) + radius = hypot(sx - cx, sy - cy) + pts: list[tuple[float, ...]] = [] + for i in range(subdivisions + 1): + t = i / subdivisions + angle = a_start + sweep * t + x = cx + radius * cos(angle) + y = cy + radius * sin(angle) + if len(start) == 2: + pts.append((x, y)) + else: + pts.append((x, y, start[2])) + return pts + + +def _signed_sweep_through_mid(a_start: float, a_mid: float, a_end: float) -> float: + """Total angle (radians) from a_start to a_end going through a_mid.""" + two_pi = 2 * pi + ccw_total = (a_end - a_start) % two_pi + ccw_to_mid = (a_mid - a_start) % two_pi + if ccw_to_mid <= ccw_total: + return ccw_total + return -((a_start - a_end) % two_pi) + + +def polygonal_face_set_to_faceted_brep(face_set: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: + """Convert an ``IfcPolygonalFaceSet`` or ``IfcTriangulatedFaceSet`` into an + ``IfcFacetedBrep`` in the same file, preserving vertex coordinates and face + topology (including inner voids on ``IfcIndexedPolygonalFaceWithVoids``). + + The returned brep is the canonical IFC2X3-compatible form of these IFC4 + tessellated representations. The caller is responsible for rewiring inverse + references and removing the source face set when downgrading. + + :raises TypeError: if ``face_set`` is not an ``IfcPolygonalFaceSet`` or + ``IfcTriangulatedFaceSet``. + :raises ValueError: if ``face_set.Coordinates`` is missing or any face's + coordinate index references a vertex outside the coordinate list. + """ + if not (face_set.is_a("IfcPolygonalFaceSet") or face_set.is_a("IfcTriangulatedFaceSet")): + raise TypeError( + f"polygonal_face_set_to_faceted_brep expected IfcPolygonalFaceSet or " + f"IfcTriangulatedFaceSet, got {face_set.is_a()}." + ) + if face_set.Coordinates is None: + raise ValueError(f"{face_set.is_a()} #{face_set.id()} has no Coordinates point list.") + ifc_file = face_set.file + coords = face_set.Coordinates.CoordList + vertex_count = len(coords) + ifc_points = [ifc_file.createIfcCartesianPoint(tuple(c)) for c in coords] + + def _resolve(indices: Sequence[int]) -> list[ifcopenshell.entity_instance]: + # IfcIndexedPolygonalFace.CoordIndex / IfcTriangulatedFaceSet.CoordIndex + # are 1-based. Out-of-range hits early with a clear message rather + # than the cryptic IndexError from list[i-1]. + out = [] + for index in indices: + if not 1 <= index <= vertex_count: + raise ValueError( + f"{face_set.is_a()} #{face_set.id()} face references vertex {index}, " + f"outside CoordList range 1..{vertex_count}." + ) + out.append(ifc_points[index - 1]) + return out + + ifc_faces: list[ifcopenshell.entity_instance] = [] + if face_set.is_a("IfcTriangulatedFaceSet"): + for triangle in face_set.CoordIndex: + loop = ifc_file.createIfcPolyLoop(_resolve(triangle)) + ifc_faces.append(ifc_file.createIfcFace([ifc_file.createIfcFaceOuterBound(loop, True)])) + else: # IfcPolygonalFaceSet + for indexed_face in face_set.Faces: + outer_loop = ifc_file.createIfcPolyLoop(_resolve(indexed_face.CoordIndex)) + bounds = [ifc_file.createIfcFaceOuterBound(outer_loop, True)] + if indexed_face.is_a("IfcIndexedPolygonalFaceWithVoids"): + for inner in indexed_face.InnerCoordIndices or (): + bounds.append(ifc_file.createIfcFaceBound(ifc_file.createIfcPolyLoop(_resolve(inner)), True)) + ifc_faces.append(ifc_file.createIfcFace(bounds)) + + return ifc_file.createIfcFacetedBrep(ifc_file.createIfcClosedShell(ifc_faces)) + + # Note: using ShapeBuilder try not to reuse IFC elements in the process # otherwise you might run into situation where builder.mirror or other operation # is applied twice during one run to the same element diff --git a/src/ifcopenshell-python/test/util/test_schema.py b/src/ifcopenshell-python/test/util/test_schema.py index cb45b6e8db..802e426936 100644 --- a/src/ifcopenshell-python/test/util/test_schema.py +++ b/src/ifcopenshell-python/test/util/test_schema.py @@ -16,6 +16,9 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . +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") diff --git a/src/ifcopenshell-python/test/util/test_shape_builder.py b/src/ifcopenshell-python/test/util/test_shape_builder.py index 6a01a74201..d024ffc13a 100644 --- a/src/ifcopenshell-python/test/util/test_shape_builder.py +++ b/src/ifcopenshell-python/test/util/test_shape_builder.py @@ -16,7 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . -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]