ifcopenshell.util: schema-aware downgrade helpers

Adds the IFC-library primitives the ifcpatch Migrate recipe needs for a
defensive IFC4 / IFC4X3 -> IFC2X3 downgrade without each caller
reinventing the wheel.

In ifcopenshell.util.schema:
- Migrator(fallback_element_to_proxy=False) opt-in: when True, IFC4-only
  IfcElement subclasses (IfcLamp, IfcPipeSegment, IfcGeographicElement,
  ...) migrate to IfcBuildingElementProxy instead of raising. Default
  preserves the strict failure-on-unmappable contract for existing
  callers (classification API, etc.).
- geometry_classes_introduced_after(target, source) derives the
  IfcRepresentationItem subclasses present in `source` but absent in
  `target` directly from the loaded schemas. Cached per pair. Replaces
  hand-curated class lists that drift with each IFC update.
  ifc4_only_geometry_classes() retained as an alias.
- generate_default_value synthesises a unit IfcAxis2Placement2D /
  IfcAxis2Placement3D when downgrading entities whose Position became
  required in the target schema (IfcIShapeProfileDef and friends in
  IFC2X3).
- Enum-mismatch detection upgraded from string-matched RuntimeError to a
  structural check via ifcopenshell.util.attribute.get_enum_items so
  upgrade paths still surface real bugs loudly.

In ifcopenshell.util.shape_builder:
- polygonal_face_set_to_faceted_brep converts IfcPolygonalFaceSet /
  IfcTriangulatedFaceSet (IFC4-only) directly to IfcFacetedBrep,
  preserving topology including IfcIndexedPolygonalFaceWithVoids inner
  bounds. Validates inputs at the boundary.
- arc_to_polyline_points approximates a circular arc through three
  points with a chord polyline of configurable subdivisions. Tolerates
  floating-point noise on planar Z. Raises on non-planar or invalid
  inputs.

Test coverage: 47 unit tests across schema + shape_builder lanes
covering each helper directly (no transitive-only coverage), including
regression pins for the IFC4X3-prefix ordering invariant in
get_fallback_schema and the strict-default Migrator contract.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-23 09:23:25 +02:00
parent 3c9ee4a71f
commit a2dafc9ceb
4 changed files with 531 additions and 11 deletions
@@ -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]