coerce: floatify mixed int/float lists in attribute dicts

JSON parses whole numbers as int, but ifcopenshell's C++ binding
requires Python floats for AGGREGATE OF DOUBLE attributes such as
DirectionRatios and Coordinates. Convert list elements to float when
the list already contains at least one float, leaving pure-integer
lists (face indices etc.) unchanged.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Bruno Postle
2026-03-01 20:27:38 +00:00
parent bf5b48d037
commit 9ecc587cb9
2 changed files with 30 additions and 1 deletions
+17 -1
View File
@@ -92,7 +92,7 @@ def coerce_value(
# dict types
if origin is dict:
return json.loads(value_str)
return _floatify_numeric_lists(json.loads(value_str))
# Simple types
if type_hint is str:
@@ -148,6 +148,22 @@ def _coerce_entity_list(value_str: str, lookup_file: ifcopenshell.file | None) -
return [_coerce_entity(item.strip(), lookup_file) for item in items]
def _floatify_numeric_lists(obj):
"""Recursively convert lists of numbers to lists of floats.
IFC C++ bindings require Python floats (not ints) for AGGREGATE OF DOUBLE
attributes (e.g. DirectionRatios, Coordinates). JSON parsing produces ints
for whole numbers like 0, which causes a TypeError at the binding level.
"""
if isinstance(obj, dict):
return {k: _floatify_numeric_lists(v) for k, v in obj.items()}
if isinstance(obj, list) and obj and all(isinstance(v, (int, float)) for v in obj) and any(
isinstance(v, float) for v in obj
):
return [float(v) for v in obj]
return obj
def _split_list(value_str: str) -> list[str]:
"""Split a comma-separated string, handling JSON arrays too."""
value_str = value_str.strip()
+13
View File
@@ -83,6 +83,19 @@ class TestDictCoercion:
result = coerce_value('{"IsExternal": true, "FireRating": "2HR"}', dict[str, object])
assert result == {"IsExternal": True, "FireRating": "2HR"}
def test_mixed_float_int_list_coerced_to_float(self):
# [0.419, 0, 0.908] — JSON integer 0 mixed with floats must become float
# so ifcopenshell AGGREGATE OF DOUBLE attributes (e.g. DirectionRatios) don't reject the list
result = coerce_value('{"DirectionRatios": [0.419, 0, 0.908]}', dict[str, object])
assert result["DirectionRatios"] == pytest.approx([0.419, 0.0, 0.908])
assert all(isinstance(v, float) for v in result["DirectionRatios"])
def test_pure_int_list_not_coerced(self):
# All-integer lists (e.g. face indices) must stay as ints
result = coerce_value('{"CoordIndex": [0, 1, 2]}', dict[str, object])
assert result["CoordIndex"] == [0, 1, 2]
assert all(isinstance(v, int) for v in result["CoordIndex"])
class TestListCoercion:
def test_comma_separated(self):