ifc5d: measure openings in their real orientation on both take-off engines

See #6835. Qto_OpeningElementBaseQuantities came out axis-scrambled for
openings authored in a Z-up local frame (X along the voided wall, Y
through it, Z vertical), which is how Bonsai authors every wall opening:

- The IfcOpenShell engine mapped Height to the local Y extent and Depth
  to the local Z extent, so a 0.9 x 2.0 door opening with Bonsai's
  default 1.2m void depth reported Height 1.2 and Depth 2.0, and Area
  (max side area) picked the through-wall side, 2.4 instead of 1.8.
  This matches the wrong Height=1.2/Area=1.2 screenshots reported for a
  1x1 window opening in #6835.
- The Blender engine mapped opening Width to get_length, which returns
  the longest bounding box edge, i.e. the opening height for typical
  door openings (the same defect 4adaf0d fixed for IfcDoor Width), and
  get_opening_depth used min(x, y), which returns the opening width
  whenever the width is smaller than the void depth.

The IfcOpenShell engine now has opening-aware internal calculators
(get_opening_width/height/depth/area) that detect horizontal (slab
style) openings with the same heuristic as the Blender calculator, so
slab opening depths keep reporting the slab thickness. The Blender
ruleset uses get_x for opening Width, and get_opening_depth measures the
through-element Y extent for vertical openings.

Door and window quantities themselves are addressed separately: the
Blender engine door Width was fixed in 4adaf0d, and the remaining
door/window defects (door not quantified on the IfcOpenShell engine,
inflated areas) are fixed by the attribute-based calculators in #8389.

Generated with the assistance of an AI coding tool.

(cherry picked from commit efac8a0ec0)
This commit is contained in:
Petru Conduraru
2026-07-21 11:56:01 +03:00
committed by Dion Moult
parent 297d8c981f
commit be56984e12
7 changed files with 152 additions and 11 deletions
+92
View File
@@ -0,0 +1,92 @@
# Ifc5D - IFC costing utility
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# This file is part of Ifc5D.
#
# Ifc5D 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.
#
# Ifc5D 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 Ifc5D. If not, see <http://www.gnu.org/licenses/>.
# This file was generated with the assistance of an AI coding tool.
import ifcopenshell
import ifcopenshell.api.context
import ifcopenshell.api.root
import ifcopenshell.api.unit
import pytest
import ifc5d.qto
class TestOpeningQuantities:
"""Openings authored in a Z-up local frame, as produced by Bonsai (#6835)."""
def setup_method(self):
self.file = ifcopenshell.file(schema="IFC4X3")
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject", name="Test")
f = self.file
units = [
f.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE"),
f.createIfcSIUnit(None, "AREAUNIT", None, "SQUARE_METRE"),
f.createIfcSIUnit(None, "VOLUMEUNIT", None, "CUBIC_METRE"),
]
ifcopenshell.api.unit.assign_unit(self.file, units=units)
model = ifcopenshell.api.context.add_context(self.file, context_type="Model")
self.body = ifcopenshell.api.context.add_context(
self.file, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model
)
def create_opening(self, profile_x: float, profile_y: float, position, extrude_dir, depth: float):
f = self.file
opening = ifcopenshell.api.root.create_entity(f, ifc_class="IfcOpeningElement")
opening.ObjectPlacement = f.createIfcLocalPlacement(
None, f.createIfcAxis2Placement3D(f.createIfcCartesianPoint((0.0, 0.0, 0.0)), None, None)
)
profile = f.createIfcRectangleProfileDef("AREA", None, None, profile_x, profile_y)
solid = f.createIfcExtrudedAreaSolid(profile, position, f.createIfcDirection(extrude_dir), depth)
rep = f.createIfcShapeRepresentation(self.body, "Body", "SweptSolid", [solid])
opening.Representation = f.createIfcProductDefinitionShape(None, None, [rep])
return opening
def quantify(self, opening) -> dict[str, float]:
rules = ifc5d.qto.rules["IFC4X3QtoBaseQuantities"]
results = ifc5d.qto.quantify(self.file, {opening}, rules)
return results[opening]["Qto_OpeningElementBaseQuantities"]
def test_vertical_wall_opening(self):
# A 0.9 x 2.0 door opening voiding a wall along +Y, with Bonsai's
# oversized 1.2m void depth: local extents x=0.9, y=1.2, z=2.0.
f = self.file
position = f.createIfcAxis2Placement3D(
f.createIfcCartesianPoint((0.0, -0.6, 1.0)),
f.createIfcDirection((0.0, -1.0, 0.0)),
f.createIfcDirection((1.0, 0.0, 0.0)),
)
opening = self.create_opening(0.9, 2.0, position, (0.0, 0.0, -1.0), 1.2)
quantities = self.quantify(opening)
assert quantities["Width"] == pytest.approx(0.9)
assert quantities["Height"] == pytest.approx(2.0)
assert quantities["Depth"] == pytest.approx(1.2)
assert quantities["Area"] == pytest.approx(1.8)
assert quantities["Volume"] == pytest.approx(2.16)
def test_horizontal_slab_opening(self):
# A 1.0 x 0.5 opening voiding a 0.3 thick slab: extents x=1.0, y=0.5, z=0.3.
f = self.file
position = f.createIfcAxis2Placement3D(f.createIfcCartesianPoint((0.0, 0.0, 0.0)), None, None)
opening = self.create_opening(1.0, 0.5, position, (0.0, 0.0, -1.0), 0.3)
quantities = self.quantify(opening)
assert quantities["Width"] == pytest.approx(1.0)
assert quantities["Height"] == pytest.approx(0.5)
assert quantities["Depth"] == pytest.approx(0.3)
assert quantities["Area"] == pytest.approx(0.5)
assert quantities["Volume"] == pytest.approx(0.15)