mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-10 22:16:41 +00:00
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 defect4adaf0dfixed 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 in4adaf0d, 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.
This commit is contained in:
committed by
Massimo Fabbro
parent
e52e5e2e58
commit
efac8a0ec0
@@ -225,7 +225,7 @@ def get_opening_depth(obj: bpy.types.Object) -> float:
|
|||||||
if is_opening_horizontal(obj):
|
if is_opening_horizontal(obj):
|
||||||
return get_height(obj)
|
return get_height(obj)
|
||||||
else:
|
else:
|
||||||
return get_width(obj)
|
return get_y(obj)
|
||||||
|
|
||||||
|
|
||||||
def get_opening_mapping_area(obj: bpy.types.Object) -> float:
|
def get_opening_mapping_area(obj: bpy.types.Object) -> float:
|
||||||
|
|||||||
@@ -383,11 +383,11 @@
|
|||||||
},
|
},
|
||||||
"IfcOpeningElement": {
|
"IfcOpeningElement": {
|
||||||
"Qto_OpeningElementBaseQuantities": {
|
"Qto_OpeningElementBaseQuantities": {
|
||||||
"Area": "gross_get_max_side_area",
|
"Area": "gross_get_opening_area",
|
||||||
"Depth": "gross_get_z",
|
"Depth": "gross_get_opening_depth",
|
||||||
"Height": "gross_get_y",
|
"Height": "gross_get_opening_height",
|
||||||
"Volume": "gross_get_volume",
|
"Volume": "gross_get_volume",
|
||||||
"Width": "gross_get_x"
|
"Width": "gross_get_opening_width"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"IfcOutlet": {
|
"IfcOutlet": {
|
||||||
|
|||||||
@@ -387,7 +387,7 @@
|
|||||||
"Depth": "get_opening_depth",
|
"Depth": "get_opening_depth",
|
||||||
"Height": "get_opening_height",
|
"Height": "get_opening_height",
|
||||||
"Volume": "get_net_volume",
|
"Volume": "get_net_volume",
|
||||||
"Width": "get_length"
|
"Width": "get_x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"IfcOutlet": {
|
"IfcOutlet": {
|
||||||
|
|||||||
@@ -472,11 +472,11 @@
|
|||||||
},
|
},
|
||||||
"IfcOpeningElement": {
|
"IfcOpeningElement": {
|
||||||
"Qto_OpeningElementBaseQuantities": {
|
"Qto_OpeningElementBaseQuantities": {
|
||||||
"Area": "gross_get_max_side_area",
|
"Area": "gross_get_opening_area",
|
||||||
"Depth": "gross_get_z",
|
"Depth": "gross_get_opening_depth",
|
||||||
"Height": "gross_get_y",
|
"Height": "gross_get_opening_height",
|
||||||
"Volume": "gross_get_volume",
|
"Volume": "gross_get_volume",
|
||||||
"Width": "gross_get_x"
|
"Width": "gross_get_opening_width"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"IfcOutlet + IfcOutletType": {
|
"IfcOutlet + IfcOutletType": {
|
||||||
|
|||||||
@@ -476,7 +476,7 @@
|
|||||||
"Depth": "get_opening_depth",
|
"Depth": "get_opening_depth",
|
||||||
"Height": "get_opening_height",
|
"Height": "get_opening_height",
|
||||||
"Volume": "get_net_volume",
|
"Volume": "get_net_volume",
|
||||||
"Width": "get_length"
|
"Width": "get_x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"IfcOutlet + IfcOutletType": {
|
"IfcOutlet + IfcOutletType": {
|
||||||
|
|||||||
@@ -254,6 +254,17 @@ class IfcOpenShell(QtoCalculator):
|
|||||||
"get_segment_length": Function(
|
"get_segment_length": Function(
|
||||||
"IfcLengthMeasure", "Segment Length", "Intelligently guesses the length of flow segments"
|
"IfcLengthMeasure", "Segment Length", "Intelligently guesses the length of flow segments"
|
||||||
),
|
),
|
||||||
|
"get_opening_width": Function(
|
||||||
|
"IfcLengthMeasure", "Opening Width", "The width of an opening, guessing the opening orientation"
|
||||||
|
),
|
||||||
|
"get_opening_height": Function(
|
||||||
|
"IfcLengthMeasure", "Opening Height", "The height of an opening, guessing the opening orientation"
|
||||||
|
),
|
||||||
|
"get_opening_depth": Function(
|
||||||
|
"IfcLengthMeasure",
|
||||||
|
"Opening Depth",
|
||||||
|
"The depth of an opening (through the voided element), guessing the opening orientation",
|
||||||
|
),
|
||||||
# IfcAreaMeasure
|
# IfcAreaMeasure
|
||||||
"get_area": Function("IfcAreaMeasure", "Area", "The total surface area of the element"),
|
"get_area": Function("IfcAreaMeasure", "Area", "The total surface area of the element"),
|
||||||
"get_footprint_area": Function(
|
"get_footprint_area": Function(
|
||||||
@@ -276,6 +287,9 @@ class IfcOpenShell(QtoCalculator):
|
|||||||
"Side area",
|
"Side area",
|
||||||
"The side (non-projected) are of the shape as seen from the local Y-axis",
|
"The side (non-projected) are of the shape as seen from the local Y-axis",
|
||||||
),
|
),
|
||||||
|
"get_opening_area": Function(
|
||||||
|
"IfcAreaMeasure", "Opening Area", "The area of an opening, guessing the opening orientation"
|
||||||
|
),
|
||||||
"get_top_area": Function(
|
"get_top_area": Function(
|
||||||
"IfcAreaMeasure",
|
"IfcAreaMeasure",
|
||||||
"Top area",
|
"Top area",
|
||||||
@@ -301,6 +315,10 @@ class IfcOpenShell(QtoCalculator):
|
|||||||
internal_functions = (
|
internal_functions = (
|
||||||
"get_segment_length",
|
"get_segment_length",
|
||||||
"get_weight",
|
"get_weight",
|
||||||
|
"get_opening_width",
|
||||||
|
"get_opening_height",
|
||||||
|
"get_opening_depth",
|
||||||
|
"get_opening_area",
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -365,6 +383,9 @@ class IfcOpenShell(QtoCalculator):
|
|||||||
value = cls.get_weight(element, geometry, calculation_type)
|
value = cls.get_weight(element, geometry, calculation_type)
|
||||||
if value is None:
|
if value is None:
|
||||||
continue
|
continue
|
||||||
|
elif formula.startswith("get_opening_"):
|
||||||
|
value = cls.get_opening_quantity(geometry, formula)
|
||||||
|
value = cls.unit_converter.convert(value, IfcOpenShell.raw_functions[formula].measure)
|
||||||
else:
|
else:
|
||||||
value = formula_functions[formula](geometry)
|
value = formula_functions[formula](geometry)
|
||||||
assert isinstance(value, (float, int))
|
assert isinstance(value, (float, int))
|
||||||
@@ -389,6 +410,34 @@ class IfcOpenShell(QtoCalculator):
|
|||||||
)
|
)
|
||||||
return iterators
|
return iterators
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_opening_quantity(cls, geometry: ifcopenshell.geom.ShapeType, formula: str) -> float:
|
||||||
|
"""Get an opening dimension or area, guessing the opening orientation.
|
||||||
|
|
||||||
|
Vertical (wall) openings are measured in a Z-up local frame: X along
|
||||||
|
the voided element, Y through it, Z vertical. An opening is treated as
|
||||||
|
horizontal (e.g. voiding a slab) when its Z extent is smaller than
|
||||||
|
both X and Y, matching the Blender calculator's heuristic.
|
||||||
|
|
||||||
|
:param geometry: Geometry output calculated by IfcOpenShell
|
||||||
|
:param formula: One of the ``get_opening_*`` internal function names.
|
||||||
|
:return: The dimension or area in SI units.
|
||||||
|
"""
|
||||||
|
x = ifcopenshell.util.shape.get_x(geometry)
|
||||||
|
y = ifcopenshell.util.shape.get_y(geometry)
|
||||||
|
z = ifcopenshell.util.shape.get_z(geometry)
|
||||||
|
is_horizontal = z < x and z < y
|
||||||
|
if formula == "get_opening_width":
|
||||||
|
return x
|
||||||
|
if formula == "get_opening_height":
|
||||||
|
return min(x, y) if is_horizontal else z
|
||||||
|
if formula == "get_opening_depth":
|
||||||
|
return z if is_horizontal else y
|
||||||
|
assert formula == "get_opening_area"
|
||||||
|
if is_horizontal:
|
||||||
|
return ifcopenshell.util.shape.get_footprint_area(geometry)
|
||||||
|
return ifcopenshell.util.shape.get_side_area(geometry)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_segment_length(cls, element: ifcopenshell.entity_instance) -> Union[float, None]:
|
def get_segment_length(cls, element: ifcopenshell.entity_instance) -> Union[float, None]:
|
||||||
"""Get segment length.
|
"""Get segment length.
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user