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.
This commit is contained in:
Petru Conduraru
2026-07-16 16:57:59 +03:00
parent 98a25eca96
commit 1bcd7d2fc6
3 changed files with 73 additions and 6 deletions
+3 -3
View File
@@ -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": {
+3 -3
View File
@@ -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": {
+67
View File
@@ -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."""