diff --git a/src/bonsai/bonsai/bim/module/qto/calculator.py b/src/bonsai/bonsai/bim/module/qto/calculator.py index 638d603f89..4921cab262 100644 --- a/src/bonsai/bonsai/bim/module/qto/calculator.py +++ b/src/bonsai/bonsai/bim/module/qto/calculator.py @@ -225,7 +225,7 @@ def get_opening_depth(obj: bpy.types.Object) -> float: if is_opening_horizontal(obj): return get_height(obj) else: - return get_width(obj) + return get_y(obj) def get_opening_mapping_area(obj: bpy.types.Object) -> float: diff --git a/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json b/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json index 2c3cdcd0f8..8275619ca4 100644 --- a/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json +++ b/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json @@ -383,11 +383,11 @@ }, "IfcOpeningElement": { "Qto_OpeningElementBaseQuantities": { - "Area": "gross_get_max_side_area", - "Depth": "gross_get_z", - "Height": "gross_get_y", + "Area": "gross_get_opening_area", + "Depth": "gross_get_opening_depth", + "Height": "gross_get_opening_height", "Volume": "gross_get_volume", - "Width": "gross_get_x" + "Width": "gross_get_opening_width" } }, "IfcOutlet": { diff --git a/src/ifc5d/ifc5d/IFC4QtoBaseQuantitiesBlender.json b/src/ifc5d/ifc5d/IFC4QtoBaseQuantitiesBlender.json index 43dd4b2842..ad09c19493 100644 --- a/src/ifc5d/ifc5d/IFC4QtoBaseQuantitiesBlender.json +++ b/src/ifc5d/ifc5d/IFC4QtoBaseQuantitiesBlender.json @@ -387,7 +387,7 @@ "Depth": "get_opening_depth", "Height": "get_opening_height", "Volume": "get_net_volume", - "Width": "get_length" + "Width": "get_x" } }, "IfcOutlet": { diff --git a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json index 7a2c70b3af..08ec3e3c27 100644 --- a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json +++ b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json @@ -472,11 +472,11 @@ }, "IfcOpeningElement": { "Qto_OpeningElementBaseQuantities": { - "Area": "gross_get_max_side_area", - "Depth": "gross_get_z", - "Height": "gross_get_y", + "Area": "gross_get_opening_area", + "Depth": "gross_get_opening_depth", + "Height": "gross_get_opening_height", "Volume": "gross_get_volume", - "Width": "gross_get_x" + "Width": "gross_get_opening_width" } }, "IfcOutlet + IfcOutletType": { diff --git a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json index 02f6717028..6706a8b738 100644 --- a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json +++ b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json @@ -476,7 +476,7 @@ "Depth": "get_opening_depth", "Height": "get_opening_height", "Volume": "get_net_volume", - "Width": "get_length" + "Width": "get_x" } }, "IfcOutlet + IfcOutletType": { diff --git a/src/ifc5d/ifc5d/qto.py b/src/ifc5d/ifc5d/qto.py index 955c6fd7af..5e119cf989 100644 --- a/src/ifc5d/ifc5d/qto.py +++ b/src/ifc5d/ifc5d/qto.py @@ -254,6 +254,17 @@ class IfcOpenShell(QtoCalculator): "get_segment_length": Function( "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 "get_area": Function("IfcAreaMeasure", "Area", "The total surface area of the element"), "get_footprint_area": Function( @@ -276,6 +287,9 @@ class IfcOpenShell(QtoCalculator): "Side area", "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( "IfcAreaMeasure", "Top area", @@ -301,6 +315,10 @@ class IfcOpenShell(QtoCalculator): internal_functions = ( "get_segment_length", "get_weight", + "get_opening_width", + "get_opening_height", + "get_opening_depth", + "get_opening_area", ) @classmethod @@ -365,6 +383,9 @@ class IfcOpenShell(QtoCalculator): value = cls.get_weight(element, geometry, calculation_type) if value is None: 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: value = formula_functions[formula](geometry) assert isinstance(value, (float, int)) @@ -389,6 +410,34 @@ class IfcOpenShell(QtoCalculator): ) 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 def get_segment_length(cls, element: ifcopenshell.entity_instance) -> Union[float, None]: """Get segment length. diff --git a/src/ifc5d/test/test_qto.py b/src/ifc5d/test/test_qto.py new file mode 100644 index 0000000000..8723a29836 --- /dev/null +++ b/src/ifc5d/test/test_qto.py @@ -0,0 +1,92 @@ +# Ifc5D - IFC costing utility +# Copyright (C) 2026 Dion Moult +# +# 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 . + +# 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)