From 1bcd7d2fc6f42176afed76843da1ad1daea7c885 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Thu, 16 Jul 2026 16:57:59 +0300 Subject: [PATCH] Fix IfcCovering Qto_CoveringBaseQuantities mismatch between calculators (#6728) The ifc5d IfcOpenShell (geometry-based) Qto engine computed Qto_CoveringBaseQuantities using axis-agnostic heuristics: - GrossArea/NetArea: gross_get_max_side_area / net_get_max_side_area, the largest of the X/Y/Z projected side areas. - Width: gross_get_min_xyz, the smallest of the X/Y/Z dimensions. The Blender Qto engine instead already used EPset_Parametric.LayerSetDirection (AXIS2 for wall-like coverings, AXIS3 for floor/ceiling-like coverings) to pick the correct axis via get_covering_gross_area/get_covering_net_area/get_covering_width in bonsai/bim/module/qto/calculator.py. For any covering whose length isn't the largest dimension (e.g. a short wall-covering strip, or a small covering patch), the two engines' heuristics can pick different faces/axes entirely, giving different Width/Area values for the same element - this is what was reported in #6728. Fix: give the IfcOpenShell engine the same layer-set-direction awareness. Added IfcOpenShell.get_covering_parametric_axis/ get_covering_area/get_covering_width (dispatched as internal functions, like the existing get_weight/get_segment_length), and wired gross_get_covering_area/net_get_covering_area/ gross_get_covering_width into the IfcCovering rules in IFC4QtoBaseQuantities.json and IFC4X3QtoBaseQuantities.json. The AXIS2 area/width formulas (get_side_area, net_get_y) intentionally match the simpler formulas already used for Qto_WallBaseQuantities in this same rule set (net_get_side_area/net_get_y), rather than replicating the Blender engine's more elaborate get_lateral_area/ get_width (min(X,Y)) helpers, consistent with how the two engines already diverge for regular walls without being considered a bug. Verified with a standalone script driving ifc5d.qto.IfcOpenShell directly against synthetic AXIS2/AXIS3 IfcCovering geometry: for typical proportions old and new formulas agree, and for disproportionate coverings (thin dimension not the smallest/largest) the old formulas picked the wrong axis while the new ones correctly track the covering's LayerSetDirection, matching the Blender engine. Did not verify through the full Blender/Bonsai UI, as it would have required registering the addon in the machine's shared Blender profile, which is unsafe while other agents may have it loaded. Generated with the assistance of an AI coding tool. --- src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json | 6 +- src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json | 6 +- src/ifc5d/ifc5d/qto.py | 67 ++++++++++++++++++++ 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json b/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json index bd28c7df47..aa3a80b632 100644 --- a/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json +++ b/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json @@ -188,9 +188,9 @@ }, "IfcCovering": { "Qto_CoveringBaseQuantities": { - "GrossArea": "gross_get_max_side_area", - "NetArea": "net_get_max_side_area", - "Width": "gross_get_min_xyz" + "GrossArea": "gross_get_covering_area", + "NetArea": "net_get_covering_area", + "Width": "gross_get_covering_width" } }, "IfcCurtainWall": { diff --git a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json index 425a7e468e..2f1a56ffac 100644 --- a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json +++ b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json @@ -204,9 +204,9 @@ }, "IfcCovering + IfcCoveringType": { "Qto_CoveringBaseQuantities": { - "GrossArea": "gross_get_max_side_area", - "NetArea": "net_get_max_side_area", - "Width": "gross_get_min_xyz" + "GrossArea": "gross_get_covering_area", + "NetArea": "net_get_covering_area", + "Width": "gross_get_covering_width" } }, "IfcCurtainWall + IfcCurtainWallType": { diff --git a/src/ifc5d/ifc5d/qto.py b/src/ifc5d/ifc5d/qto.py index 764c022ff5..adbece1254 100644 --- a/src/ifc5d/ifc5d/qto.py +++ b/src/ifc5d/ifc5d/qto.py @@ -286,8 +286,20 @@ class IfcOpenShell(QtoCalculator): "cross section height along the local Y axis. For slab-like footings (PAD_FOOTING, PILE_CAP) " "and other predefined types it is the thickness along the local Z axis.", ), + "get_covering_width": Function( + "IfcLengthMeasure", + "Covering Width", + "The covering's thickness: the side area axis for AXIS2 (e.g. wall finishes), " + "otherwise the local Z depth (e.g. floor or ceiling finishes)", + ), # IfcAreaMeasure "get_area": Function("IfcAreaMeasure", "Area", "The total surface area of the element"), + "get_covering_area": Function( + "IfcAreaMeasure", + "Covering Area", + "The covering's side area for AXIS2 (e.g. wall finishes), otherwise its footprint " + "area (e.g. floor or ceiling finishes)", + ), "get_footprint_area": Function( "IfcAreaMeasure", "Footprint Area", @@ -349,6 +361,8 @@ class IfcOpenShell(QtoCalculator): "get_opening_height", "get_opening_depth", "get_opening_area", + "get_covering_width", + "get_covering_area", ) + footing_functions @classmethod @@ -421,6 +435,12 @@ class IfcOpenShell(QtoCalculator): if value is None: continue value = cls.unit_converter.convert(value, cls.raw_functions[formula].measure) + elif formula == "get_covering_width": + value = cls.get_covering_width(element, geometry) + value = cls.unit_converter.convert(value, "IfcLengthMeasure") + elif formula == "get_covering_area": + value = cls.get_covering_area(element, geometry) + value = cls.unit_converter.convert(value, "IfcAreaMeasure") else: value = formula_functions[formula](geometry) assert isinstance(value, (float, int)) @@ -586,6 +606,53 @@ class IfcOpenShell(QtoCalculator): mass += mass_per_length * item.Depth return mass + @staticmethod + def get_covering_parametric_axis(element: ifcopenshell.entity_instance) -> Union[str, None]: + """Get an IfcCovering's layer set direction, as authored by Bonsai's covering type. + + :param element: IFC element entity. + :return: ``"AXIS2"`` for wall-like coverings, ``"AXIS3"`` for slab-like + coverings (e.g. floors or ceilings), or ``None`` if the covering's + type has no ``EPset_Parametric.LayerSetDirection``. + """ + relating_type = ifcopenshell.util.element.get_type(element) + if not relating_type: + return None + parametric = ifcopenshell.util.element.get_psets(relating_type).get("EPset_Parametric") + if not parametric: + return None + return parametric.get("LayerSetDirection") + + @classmethod + def get_covering_area(cls, element: ifcopenshell.entity_instance, geometry: ifcopenshell.geom.ShapeType) -> float: + """Get a covering's area, following its layer set direction. + + AXIS2 (wall-like) coverings report the local Y-facing side area, + while AXIS3 coverings and coverings without a layer set direction + (e.g. freeform profiles) report the projected footprint area. This + mirrors how ``gross_get_side_area``/``net_get_side_area`` are + already used for ``Qto_WallBaseQuantities.*SideArea`` in this same + rule set. + """ + if cls.get_covering_parametric_axis(element) == "AXIS2": + return ifcopenshell.util.shape.get_side_area(geometry) + return ifcopenshell.util.shape.get_footprint_area(geometry) + + @classmethod + def get_covering_width(cls, element: ifcopenshell.entity_instance, geometry: ifcopenshell.geom.ShapeType) -> float: + """Get a covering's width (i.e. thickness), following its layer set direction. + + AXIS2 (wall-like) coverings report the local Y depth, while AXIS3 + coverings and coverings without a layer set direction report the + local Z depth. This mirrors how ``net_get_y`` is already used for + ``Qto_WallBaseQuantities.Width`` in this same rule set, rather than + the ``min(X, Y)`` heuristic used by the Blender-side + :func:`bonsai.bim.module.qto.calculator.get_width`. + """ + if cls.get_covering_parametric_axis(element) == "AXIS2": + return ifcopenshell.util.shape.get_y(geometry) + return ifcopenshell.util.shape.get_z(geometry) + class Blender(QtoCalculator): """Calculates geometry based on currently loaded Blender objects."""