Return nan for non-manifold volume and skip it in QTO (#6125)

util.shape.get_volume sums signed tetrahedra (divergence theorem), which
is only valid for a closed watertight mesh. For a non-manifold or open
mesh (e.g. the IfcPolygonalFaceSet slabs in the report, 19 of 152 edges
not shared by exactly two triangles) it returned a wild value: 17.71 m3
against a 1.82 m3 bounding box, a 9.7x overestimate.

get_volume now runs an O(faces) edge-parity check and returns nan when the
mesh is not a closed manifold, instead of a meaningless number. The ifc5d
QTO consumer skips a nan result rather than writing it into the IFC, so a
non-manifold element simply gets no volume quantity. Closed manifold
volumes are unchanged (a 2x3x0.5 box still reports exactly 3.0).

Verified: the three reported slabs go from 17.71 to nan (no NetVolume
written), while a manifold box still writes its correct NetVolume.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Petru Conduraru
2026-07-11 08:57:13 +03:00
parent 8deefe497c
commit 8eaf6beece
2 changed files with 24 additions and 2 deletions
+5
View File
@@ -445,6 +445,11 @@ class IfcOpenShell(QtoCalculator):
value = formula_functions[formula](geometry)
assert isinstance(value, (float, int))
value = cls.unit_converter.convert(value, IfcOpenShell.raw_functions[formula].measure)
# get_volume returns nan for a non-manifold mesh (its volume is
# undefined, see #6125); skip such quantities rather than writing
# nan into the IFC. The self-inequality test avoids importing math.
if isinstance(value, float) and value != value:
continue
results[element][name][quantity] = value
if not iterator.next():
break
@@ -71,10 +71,14 @@ def is_x(value: float, x: float, tolerance: Optional[float] = None) -> bool:
def get_volume(geometry: W.Triangulation) -> float:
"""Calculates the total internal volume of a geometry
Volumes of non-manifold geometry will be unpredictable.
The volume is derived from the divergence theorem (summing signed
tetrahedra), which is only meaningful for a closed manifold (watertight)
mesh. For non-manifold or open geometry that value is undefined and can be
wildly over- or under-estimated, so ``float("nan")`` is returned instead of
a bogus number. See https://github.com/IfcOpenShell/IfcOpenShell/issues/6125.
:param geometry: Geometry output calculated by IfcOpenShell
:return: The volume in m3
:return: The volume in m3, or ``nan`` if the mesh is not a closed manifold
"""
# https://stackoverflow.com/questions/1406029/how-to-calculate-the-volume-of-a-3d-mesh-object-the-surface-of-which-is-made-up
@@ -90,6 +94,19 @@ def get_volume(geometry: W.Triangulation) -> float:
# Can't optimize it using buffers - performance seems to get only worse.
verts = geometry.verts
faces = geometry.faces
# A watertight (closed manifold) mesh shares every edge between exactly two
# triangles. If that does not hold the signed-tetrahedra sum below is
# meaningless, so bail out with nan rather than returning a wild value.
edge_face_count: dict[tuple[int, int], int] = {}
for i in range(0, len(faces), 3):
tri = (faces[i], faces[i + 1], faces[i + 2])
for a, b in ((tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])):
edge = (a, b) if a < b else (b, a)
edge_face_count[edge] = edge_face_count.get(edge, 0) + 1
if any(count != 2 for count in edge_face_count.values()):
return float("nan")
grouped_verts = [[verts[i], verts[i + 1], verts[i + 2]] for i in range(0, len(verts), 3)]
volumes = [
signed_triangle_volume(grouped_verts[faces[i]], grouped_verts[faces[i + 1]], grouped_verts[faces[i + 2]])