mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-05 23:41:44 +00:00
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:
@@ -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]}"
|
||||
Reference in New Issue
Block a user