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 <noreply@anthropic.com>
(cherry picked from commit 4ceadd8f10)
This commit is contained in:
Petru Conduraru
2026-07-08 13:34:03 +03:00
committed by Dion Moult
parent be56984e12
commit 0d8813348c
6 changed files with 93 additions and 13 deletions
+16 -2
View File
@@ -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
+3 -3
View File
@@ -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": {
@@ -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": {
+3 -3
View File
@@ -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": {
@@ -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": {
+67 -1
View File
@@ -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", ""),