ifcopenshell.util.shape: guard elevation helpers against unbounded geometry

Issue #6895 reported get_shape_bottom_elevation returning huge identical
garbage (-2e100) on an IFC4X1 file, while the same query worked fine on
IFC2X3. Reproduced live with the reporter's file: all 444 IfcWindow
elements return -2e100.

Investigated the offending representations directly: each window's
"Body" IfcShapeRepresentation is declared RepresentationType SweptSolid,
but its actual Items are IfcTriangulatedFaceSet entities (which belong
under Tessellation, not SweptSolid) plus an IfcGeometricSet containing
thousands of untrimmed IfcLine entities used directly as body geometry
items. An untrimmed IfcLine is an unbounded curve, invalid as a body
representation item; the backing geometry kernel tessellates it using a
"practical infinity" coordinate (around 1e100, matching OpenCASCADE's
Precision::Infinite()) rather than a true float infinity, which is what
get_shape_bottom_elevation's naive min() picks up. All 444 windows
share this pattern, and the file uses no representation identifier
other than Body/SweptSolid anywhere, pointing to a systemic bug in the
authoring tool named in the file header rather than anything IFC4X1
specific. Confirmed schema is incidental: re-declaring the same file's
header as plain IFC4 and re-running reproduces the identical -2e100
result, and a minimal synthetic model with a single untrimmed IfcLine
item reproduces it on a from-scratch, otherwise well-formed IFC4 file.

So the file genuinely is invalid, but returning -2e100 silently with no
signal is also a real, separate robustness gap: any malformed body
representation with an unbounded curve item now produces a nonsensical
"valid-looking" number instead of an obvious error. This adds a guard
to get_bottom_elevation/get_top_elevation and their get_shape_*/
get_element_* variants: any vertex Z ordinate that is non-finite or at
the kernel's "practical infinity" magnitude now makes the function warn
and return NaN instead of folding the sentinel into the result. This
does not attempt to "correct" the geometry or compute an elevation from
the remaining finite vertices; it only turns silent garbage-in into a
loud, unambiguous failure signal, while leaving valid geometry
(including geometry with legitimately large but finite coordinates)
untouched.

Verified live: the reporter's file now returns NaN with a warning for
every window instead of -2e100. A valid extruded wall (bottom=0,
top=3) is unaffected and emits no warning. Added
test/util/test_shape.py, which did not previously exist for this
module; both new tests pass against the fix and the degenerate case
correctly fails against unpatched code. Ran the existing
test/util/ suite before and after: same 6 pre-existing, unrelated
environment failures (missing mathutils, one broken validate-stub
fixture) in both runs, no new failures.

Related to #6895

This commit was created with the assistance of an AI coding tool.
This commit is contained in:
Petru Conduraru
2026-07-21 11:37:41 +03:00
parent e52e5e2e58
commit 86de6c27d6
2 changed files with 194 additions and 13 deletions
@@ -18,7 +18,8 @@
from __future__ import annotations
from math import cos, radians
import warnings
from math import cos, radians, nan
from typing import TYPE_CHECKING, Literal, Optional, Union
import numpy as np
@@ -353,24 +354,56 @@ def get_element_vertices(element: ifcopenshell.entity_instance, geometry: W.Tria
return np.delete((mat @ np.hstack((verts, np.ones((len(verts), 1)))).T).T, -1, axis=1)
# A curve used raw as a body representation item (e.g. an untrimmed IfcLine,
# invalid per IFC's representation item rules) is unbounded. Backing geometry
# kernels tessellate that as a vertex around their "practical infinity" value
# (e.g. OCCT's Precision::Infinite() == 1e100) rather than a true float
# infinity. No legitimate IFC model's coordinates come remotely close to this
# magnitude, so treat it as a signal that the shape's representation is
# degenerate rather than folding it into an elevation result.
PRACTICAL_INFINITY = 1e50
def _degenerate_elevation_warning(z_values: npt.NDArray[np.float64]) -> Optional[float]:
"""Returns NaN with a warning if `z_values` contains a non-finite or practically-infinite
ordinate, otherwise returns None to indicate the values are safe to use.
"""
if np.any(~np.isfinite(z_values)) or np.any(np.abs(z_values) >= PRACTICAL_INFINITY):
warnings.warn(
"Shape has a non-finite or practically-infinite Z ordinate, typically caused by an "
"unbounded curve (e.g. an untrimmed IfcLine) used directly as a body representation "
"item. The representation is likely invalid. Returning NaN instead of a meaningless "
"elevation value."
)
return nan
return None
def get_bottom_elevation(geometry: W.Triangulation) -> float:
"""Gets the lowest local Z ordinate of the geometry
:param geometry: Geometry output calculated by IfcOpenShell
:return: The Z value
:return: The Z value, or NaN if the geometry has a non-finite ordinate
"""
verts_flat = get_vertices(geometry).ravel()
return np.min(verts_flat[2::3]).item()
z_values = verts_flat[2::3]
if (result := _degenerate_elevation_warning(z_values)) is not None:
return result
return np.min(z_values).item()
def get_top_elevation(geometry: W.Triangulation) -> float:
"""Gets the highest local Z ordinate of the geometry
:param geometry: Geometry output calculated by IfcOpenShell
:return: The Z value
:return: The Z value, or NaN if the geometry has a non-finite ordinate
"""
verts_flat = get_vertices(geometry).ravel()
return np.max(verts_flat[2::3]).item()
z_values = verts_flat[2::3]
if (result := _degenerate_elevation_warning(z_values)) is not None:
return result
return np.max(z_values).item()
def get_shape_bottom_elevation(shape: ShapeElementType, geometry: W.Triangulation) -> float:
@@ -381,9 +414,12 @@ def get_shape_bottom_elevation(shape: ShapeElementType, geometry: W.Triangulatio
:param shape: Shape output calculated by IfcOpenShell
:param geometry: Geometry output calculated by IfcOpenShell
:return: The Z value
:return: The Z value, or NaN if the shape has a non-finite ordinate
"""
return min([v[2] for v in get_shape_vertices(shape, geometry)])
z_values = get_shape_vertices(shape, geometry)[:, 2]
if (result := _degenerate_elevation_warning(z_values)) is not None:
return result
return np.min(z_values).item()
def get_shape_top_elevation(shape: ShapeElementType, geometry: W.Triangulation) -> float:
@@ -394,9 +430,12 @@ def get_shape_top_elevation(shape: ShapeElementType, geometry: W.Triangulation)
:param shape: Shape output calculated by IfcOpenShell
:param geometry: Geometry output calculated by IfcOpenShell
:return: The Z value
:return: The Z value, or NaN if the shape has a non-finite ordinate
"""
return max([v[2] for v in get_shape_vertices(shape, geometry)])
z_values = get_shape_vertices(shape, geometry)[:, 2]
if (result := _degenerate_elevation_warning(z_values)) is not None:
return result
return np.max(z_values).item()
def get_element_bottom_elevation(element: ifcopenshell.entity_instance, geometry: W.Triangulation) -> float:
@@ -407,9 +446,12 @@ def get_element_bottom_elevation(element: ifcopenshell.entity_instance, geometry
:param element: The element occurrence
:param geometry: Geometry output calculated by IfcOpenShell
:return: The Z value
:return: The Z value, or NaN if the element has a non-finite ordinate
"""
return min([v[2] for v in get_element_vertices(element, geometry)])
z_values = get_element_vertices(element, geometry)[:, 2]
if (result := _degenerate_elevation_warning(z_values)) is not None:
return result
return np.min(z_values).item()
def get_element_top_elevation(element: ifcopenshell.entity_instance, geometry: W.Triangulation) -> float:
@@ -420,9 +462,12 @@ def get_element_top_elevation(element: ifcopenshell.entity_instance, geometry: W
:param element: The element occurrence
:param geometry: Geometry output calculated by IfcOpenShell
:return: The Z value
:return: The Z value, or NaN if the element has a non-finite ordinate
"""
return max([v[2] for v in get_element_vertices(element, geometry)])
z_values = get_element_vertices(element, geometry)[:, 2]
if (result := _degenerate_elevation_warning(z_values)) is not None:
return result
return np.max(z_values).item()
def get_bbox(vertices: npt.NDArray[np.float64]) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]:
@@ -0,0 +1,136 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 IfcOpenShell contributors
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell 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 Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
# This file was generated with the assistance of an AI coding tool.
import math
import pytest
import ifcopenshell.api.context
import ifcopenshell.api.geometry
import ifcopenshell.api.root
import ifcopenshell.geom
import ifcopenshell.util.shape as subject
import test.bootstrap
class TestElevationHelpers(test.bootstrap.IFC4):
def get_body_context(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
model = ifcopenshell.api.context.add_context(self.file, context_type="Model")
return ifcopenshell.api.context.add_context(
self.file,
context_type="Model",
context_identifier="Body",
target_view="MODEL_VIEW",
parent=model,
)
def create_shape(self, element):
settings = ifcopenshell.geom.settings()
return ifcopenshell.geom.create_shape(settings, element)
def create_degenerate_wall(self, body):
# Mirrors the malformed pattern found live in issue #6895's file: an
# untrimmed IfcLine (an unbounded curve) used directly as a body
# representation item. Valid IFC never does this; IfcLine is only
# valid as the basis curve of a bounded (e.g. trimmed) curve.
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
point = self.file.create_entity("IfcCartesianPoint", (0.0, 0.0, 0.0))
direction = self.file.create_entity("IfcDirection", (0.0, 0.0, 1.0))
vector = self.file.create_entity("IfcVector", direction, 1000.0)
line = self.file.create_entity("IfcLine", point, vector)
geometric_set = self.file.create_entity("IfcGeometricCurveSet", (line,))
shape_representation = self.file.create_entity(
"IfcShapeRepresentation",
ContextOfItems=body,
RepresentationIdentifier="Body",
RepresentationType="GeometricCurveSet",
Items=(geometric_set,),
)
element.Representation = self.file.create_entity(
"IfcProductDefinitionShape", Representations=(shape_representation,)
)
return element
def test_valid_wall_elevations_are_unaffected(self):
body = self.get_body_context()
wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
representation = ifcopenshell.api.geometry.add_wall_representation(
self.file, context=body, length=5, height=3, thickness=0.2
)
ifcopenshell.api.geometry.assign_representation(self.file, product=wall, representation=representation)
ifcopenshell.api.geometry.edit_object_placement(self.file, product=wall)
shape = self.create_shape(wall)
with _no_warnings():
bottom = subject.get_shape_bottom_elevation(shape, shape.geometry)
top = subject.get_shape_top_elevation(shape, shape.geometry)
assert bottom == pytest.approx(0.0, abs=1e-6)
assert top == pytest.approx(3.0, abs=1e-6)
assert subject.get_bottom_elevation(shape.geometry) == pytest.approx(0.0, abs=1e-6)
assert subject.get_top_elevation(shape.geometry) == pytest.approx(3.0, abs=1e-6)
assert subject.get_element_bottom_elevation(wall, shape.geometry) == pytest.approx(0.0, abs=1e-6)
assert subject.get_element_top_elevation(wall, shape.geometry) == pytest.approx(3.0, abs=1e-6)
def test_degenerate_infinite_line_returns_nan_with_warning(self):
body = self.get_body_context()
wall = self.create_degenerate_wall(body)
shape = self.create_shape(wall)
with pytest.warns(UserWarning):
bottom = subject.get_shape_bottom_elevation(shape, shape.geometry)
with pytest.warns(UserWarning):
top = subject.get_shape_top_elevation(shape, shape.geometry)
with pytest.warns(UserWarning):
local_bottom = subject.get_bottom_elevation(shape.geometry)
with pytest.warns(UserWarning):
local_top = subject.get_top_elevation(shape.geometry)
with pytest.warns(UserWarning):
element_bottom = subject.get_element_bottom_elevation(wall, shape.geometry)
with pytest.warns(UserWarning):
element_top = subject.get_element_top_elevation(wall, shape.geometry)
for value in (bottom, top, local_bottom, local_top, element_bottom, element_top):
assert math.isnan(value)
# Sanity check on the underlying assumption: the degenerate curve
# really does tessellate to a practically-infinite vertex, not a
# small or ordinary large number.
z_values = subject.get_vertices(shape.geometry)[:, 2]
assert (abs(z_values) >= subject.PRACTICAL_INFINITY).any()
class _no_warnings:
"""Context manager asserting no warnings are raised (avoids importing `warnings` at module level)."""
def __enter__(self):
import warnings
self._cm = warnings.catch_warnings(record=True)
self._log = self._cm.__enter__()
warnings.simplefilter("always")
return self
def __exit__(self, *exc_info):
self._cm.__exit__(*exc_info)
assert not self._log, f"Unexpected warning(s): {[str(w.message) for w in self._log]}"