From 0d8813348c5712d3697b105277a9ef2382ddd045 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Wed, 8 Jul 2026 13:34:03 +0300 Subject: [PATCH] Fix IfcFooting Qto_FootingBaseQuantities axis mapping per predefined type #4783 Footings are authored two ways with different local axis conventions. Beam-like footings (STRIP_FOOTING, FOOTING_BEAM) are a profile extruded along local Z, so Length is local Z and the cross section sits on local X (Width, horizontal) and local Y (Height, vertical). Slab-like footings (PAD_FOOTING, PILE_CAP) have their footprint on local X/Y and their thickness (Height) on local Z. The engine rule set is keyed per IfcFooting and cannot branch on predefined type, so the previous static rule (Height=net_get_z, Length=net_get_max_xy, Width=null) swapped Length and Height for beam-like footings and never emitted Width. Add predefined-type-aware get_footing_length/width/height to the IfcOpenShell and Blender calculators, and point the IfcFooting rule at them in all four IFC4/IFC4X3 ios/Blender rule files. Confirmed by authoring footings through the real Bonsai generators and measuring world-axis orientation: a beam-like footing with a 0.3 wide by 0.6 tall cross section and 6.0 run reports Length 6.0, Width 0.3, Height 0.6, with the 0.3 physically horizontal and 0.6 physically vertical; a 2.0x1.5x0.3 pad reports Length 2.0, Width 1.5, Height 0.3. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit 4ceadd8f10966bdf139200250b377d1bc99917f2) --- .../bonsai/bim/module/qto/calculator.py | 18 ++++- src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json | 6 +- .../ifc5d/IFC4QtoBaseQuantitiesBlender.json | 2 +- src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json | 6 +- .../ifc5d/IFC4X3QtoBaseQuantitiesBlender.json | 6 +- src/ifc5d/ifc5d/qto.py | 68 ++++++++++++++++++- 6 files changed, 93 insertions(+), 13 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/qto/calculator.py b/src/bonsai/bonsai/bim/module/qto/calculator.py index 4921cab262..63b55c0166 100644 --- a/src/bonsai/bonsai/bim/module/qto/calculator.py +++ b/src/bonsai/bonsai/bim/module/qto/calculator.py @@ -104,7 +104,7 @@ def get_footing_length(o: bpy.types.Object) -> float: return get_length(o) if predefined_type == "FOOTING_BEAM" or predefined_type == "STRIP_FOOTING": return get_z(o) - elif predefined_type == "PAD_FOOTING": + elif predefined_type == "PAD_FOOTING" or predefined_type == "PILE_CAP": return max(get_x(o), get_y(o)) else: return get_length(o) @@ -197,12 +197,26 @@ def get_footing_height(o: bpy.types.Object) -> float: return get_height(o) if predefined_type == "FOOTING_BEAM" or predefined_type == "STRIP_FOOTING": return get_y(o) - elif predefined_type == "PAD_FOOTING": + elif predefined_type == "PAD_FOOTING" or predefined_type == "PILE_CAP": return get_z(o) else: return get_height(o) +def get_footing_width(o: bpy.types.Object) -> float: + element = tool.Ifc.get_entity(o) + assert element + predefined_type = ifcopenshell.util.element.get_predefined_type(element) + if not predefined_type: + return get_width(o) + if predefined_type == "FOOTING_BEAM" or predefined_type == "STRIP_FOOTING": + return get_x(o) + elif predefined_type == "PAD_FOOTING" or predefined_type == "PILE_CAP": + return get_width(o) + else: + return get_width(o) + + def get_height(o: bpy.types.Object) -> float: """_summary_: Returns the height of the object bounding box diff --git a/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json b/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json index 8275619ca4..7d35e64b9a 100644 --- a/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json +++ b/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json @@ -318,12 +318,12 @@ "GrossSurfaceArea": null, "GrossVolume": null, "GrossWeight": "gross_get_weight", - "Height": "net_get_z", - "Length": "net_get_max_xy", + "Height": "net_get_footing_height", + "Length": "net_get_footing_length", "NetVolume": "net_get_volume", "NetWeight": "net_get_weight", "OuterSurfaceArea": null, - "Width": null + "Width": "net_get_footing_width" } }, "IfcHeatExchanger": { diff --git a/src/ifc5d/ifc5d/IFC4QtoBaseQuantitiesBlender.json b/src/ifc5d/ifc5d/IFC4QtoBaseQuantitiesBlender.json index ad09c19493..e60e38827b 100644 --- a/src/ifc5d/ifc5d/IFC4QtoBaseQuantitiesBlender.json +++ b/src/ifc5d/ifc5d/IFC4QtoBaseQuantitiesBlender.json @@ -323,7 +323,7 @@ "NetVolume": "get_net_volume", "NetWeight": "get_net_weight", "OuterSurfaceArea": "get_outer_surface_area", - "Width": "get_width" + "Width": "get_footing_width" } }, "IfcHeatExchanger": { diff --git a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json index 08ec3e3c27..44806c2ccf 100644 --- a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json +++ b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantities.json @@ -363,12 +363,12 @@ "GrossSurfaceArea": null, "GrossVolume": null, "GrossWeight": "gross_get_weight", - "Height": "net_get_z", - "Length": "net_get_max_xy", + "Height": "net_get_footing_height", + "Length": "net_get_footing_length", "NetVolume": "net_get_volume", "NetWeight": "net_get_weight", "OuterSurfaceArea": null, - "Width": null + "Width": "net_get_footing_width" } }, "IfcGeotechnicalStratum": { diff --git a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json index 6706a8b738..5d899e94fe 100644 --- a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json +++ b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json @@ -363,12 +363,12 @@ "GrossSurfaceArea": "get_gross_surface_area", "GrossVolume": "get_gross_volume", "GrossWeight": "get_gross_weight", - "Height": "get_height", - "Length": "get_length", + "Height": "get_footing_height", + "Length": "get_footing_length", "NetVolume": "get_net_volume", "NetWeight": "get_net_weight", "OuterSurfaceArea": "get_outer_surface_area", - "Width": "get_width" + "Width": "get_footing_width" } }, "IfcGeotechnicalStratum": { diff --git a/src/ifc5d/ifc5d/qto.py b/src/ifc5d/ifc5d/qto.py index 5e119cf989..764c022ff5 100644 --- a/src/ifc5d/ifc5d/qto.py +++ b/src/ifc5d/ifc5d/qto.py @@ -265,6 +265,27 @@ class IfcOpenShell(QtoCalculator): "Opening Depth", "The depth of an opening (through the voided element), guessing the opening orientation", ), + "get_footing_length": Function( + "IfcLengthMeasure", + "Footing Length", + "The footing length. For beam-like footings (STRIP_FOOTING, FOOTING_BEAM) this is the " + "extruded run along the local Z axis. For slab-like footings (PAD_FOOTING, PILE_CAP) and " + "other predefined types it is the longer footprint side (the larger of local X or Y).", + ), + "get_footing_width": Function( + "IfcLengthMeasure", + "Footing Width", + "The footing width. For beam-like footings (STRIP_FOOTING, FOOTING_BEAM) this is the " + "cross section width along the local X axis. For slab-like footings (PAD_FOOTING, PILE_CAP) " + "and other predefined types it is the shorter footprint side (the smaller of local X or Y).", + ), + "get_footing_height": Function( + "IfcLengthMeasure", + "Footing Height", + "The footing height. For beam-like footings (STRIP_FOOTING, FOOTING_BEAM) this is the " + "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.", + ), # IfcAreaMeasure "get_area": Function("IfcAreaMeasure", "Area", "The total surface area of the element"), "get_footprint_area": Function( @@ -312,6 +333,15 @@ class IfcOpenShell(QtoCalculator): functions[f"gross_{k}"] = Function(v.measure, f"Gross {v.name}", v.description) functions[f"net_{k}"] = Function(v.measure, f"Net {v.name}", v.description) + # Predefined-type-aware footing functions. They read the element's predefined type to + # pick the correct local axis, so they receive the element (not just the geometry) and + # cannot live in ifcopenshell.util.shape. + footing_functions = ( + "get_footing_length", + "get_footing_width", + "get_footing_height", + ) + internal_functions = ( "get_segment_length", "get_weight", @@ -319,7 +349,7 @@ class IfcOpenShell(QtoCalculator): "get_opening_height", "get_opening_depth", "get_opening_area", - ) + ) + footing_functions @classmethod def calculate(cls, ifc_file, elements, qtos, results): @@ -386,6 +416,11 @@ class IfcOpenShell(QtoCalculator): elif formula.startswith("get_opening_"): value = cls.get_opening_quantity(geometry, formula) value = cls.unit_converter.convert(value, IfcOpenShell.raw_functions[formula].measure) + elif formula in cls.footing_functions: + value = getattr(cls, formula)(element, geometry) + if value is None: + continue + value = cls.unit_converter.convert(value, cls.raw_functions[formula].measure) else: value = formula_functions[formula](geometry) assert isinstance(value, (float, int)) @@ -469,6 +504,36 @@ class IfcOpenShell(QtoCalculator): z = item.Depth return max([x, y, z]) + # Footings are authored two ways, so a single static axis rule cannot be correct for both. + # Beam-like footings (STRIP_FOOTING, FOOTING_BEAM) are a profile extruded along the local Z + # axis, so the run (Length) is local Z and the cross section sits on local X (Width) and + # local Y (Height). Slab-like footings (PAD_FOOTING, PILE_CAP) have their footprint on the + # local X/Y plane and their thickness (Height) on local Z. These functions branch on the + # predefined type to pick the right axis. This mirrors the Blender calculator's + # get_footing_length / get_footing_width / get_footing_height. + _beam_like_footings = ("STRIP_FOOTING", "FOOTING_BEAM") + + @classmethod + def get_footing_length(cls, element: ifcopenshell.entity_instance, geometry: ifcopenshell.geom.ShapeType) -> float: + predefined_type = ifcopenshell.util.element.get_predefined_type(element) + if predefined_type in cls._beam_like_footings: + return ifcopenshell.util.shape.get_z(geometry) + return max(ifcopenshell.util.shape.get_x(geometry), ifcopenshell.util.shape.get_y(geometry)) + + @classmethod + def get_footing_width(cls, element: ifcopenshell.entity_instance, geometry: ifcopenshell.geom.ShapeType) -> float: + predefined_type = ifcopenshell.util.element.get_predefined_type(element) + if predefined_type in cls._beam_like_footings: + return ifcopenshell.util.shape.get_x(geometry) + return min(ifcopenshell.util.shape.get_x(geometry), ifcopenshell.util.shape.get_y(geometry)) + + @classmethod + def get_footing_height(cls, element: ifcopenshell.entity_instance, geometry: ifcopenshell.geom.ShapeType) -> float: + predefined_type = ifcopenshell.util.element.get_predefined_type(element) + if predefined_type in cls._beam_like_footings: + return ifcopenshell.util.shape.get_y(geometry) + return ifcopenshell.util.shape.get_z(geometry) + @classmethod def get_weight( cls, @@ -544,6 +609,7 @@ class Blender(QtoCalculator): "get_width": Function("IfcLengthMeasure", "Width", ""), "get_footing_height": Function("IfcLengthMeasure", "Height", ""), "get_footing_length": Function("IfcLengthMeasure", "Length", ""), + "get_footing_width": Function("IfcLengthMeasure", "Width", ""), # IfcAreaMeasure "get_covering_gross_area": Function("IfcAreaMeasure", "Covering Gross Area", ""), "get_covering_net_area": Function("IfcAreaMeasure", "Covering Net Area", ""),