From d506f06f958eb766a252f0228b925f1c9ad27dc6 Mon Sep 17 00:00:00 2001
From: Richard Brice <37087370+RickBrice@users.noreply.github.com>
Date: Mon, 10 Aug 2026 14:29:26 -0700
Subject: [PATCH] Add target-unit pickers to the Qto panels; keep unit symbols
live
---
src/bonsai/bonsai/bim/helper.py | 1 -
src/bonsai/bonsai/bim/module/qto/operator.py | 4 +-
src/bonsai/bonsai/bim/module/qto/prop.py | 28 ++++++
src/bonsai/bonsai/bim/module/qto/ui.py | 20 ++++
src/bonsai/bonsai/bim/prop.py | 53 +++++++----
src/bonsai/bonsai/tool/pset.py | 14 ++-
src/bonsai/bonsai/tool/qto.py | 13 +++
src/bonsai/test/bim/test_prop.py | 96 ++++++++++++++++++++
src/bonsai/test/tool/test_qto.py | 36 ++++++++
9 files changed, 238 insertions(+), 27 deletions(-)
diff --git a/src/bonsai/bonsai/bim/helper.py b/src/bonsai/bonsai/bim/helper.py
index 18e01a5161..d00f7222fa 100644
--- a/src/bonsai/bonsai/bim/helper.py
+++ b/src/bonsai/bonsai/bim/helper.py
@@ -238,7 +238,6 @@ def import_attribute(
elif data_type == "float":
measure_class = attribute.type_of_attribute().declared_type().name()
new.special_type = tool.Pset.get_special_type_for_measure_class(measure_class)
- new.unit_symbol = tool.Pset.get_unit_symbol_for_special_type(new.special_type, tool.Ifc.get())
new.float_value = 0.0 if new.is_null else float(data[attribute.name()])
elif data_type == "enum":
attribute_type = attribute.type_of_attribute()
diff --git a/src/bonsai/bonsai/bim/module/qto/operator.py b/src/bonsai/bonsai/bim/module/qto/operator.py
index 6aa10c9beb..39cdb9a6ce 100644
--- a/src/bonsai/bonsai/bim/module/qto/operator.py
+++ b/src/bonsai/bonsai/bim/module/qto/operator.py
@@ -151,7 +151,7 @@ class CalculateSingleQuantity(bpy.types.Operator, tool.Ifc.Operator):
ifc_file = tool.Ifc.get()
with Profiler("Quantify function time:"):
results = ifc5d.qto.quantify(ifc_file, elements, rules)
- ifc5d.qto.edit_qtos(ifc_file, results)
+ ifc5d.qto.edit_qtos(ifc_file, results, target_units=tool.Qto.get_target_units(), rules=rules)
not_quantified_elements = elements - set(results.keys())
not_quantified_message = tool.Qto.get_not_quantified_elements_message(not_quantified_elements)
@@ -194,7 +194,7 @@ class PerformQuantityTakeOff(bpy.types.Operator, tool.Ifc.Operator):
ifc_file = tool.Ifc.get()
with Profiler("Quantify function time:"):
results = ifc5d.qto.quantify(ifc_file, elements, rules)
- ifc5d.qto.edit_qtos(ifc_file, results)
+ ifc5d.qto.edit_qtos(ifc_file, results, target_units=tool.Qto.get_target_units(), rules=rules)
not_quantified_elements = elements - set(results.keys())
return not_quantified_elements
diff --git a/src/bonsai/bonsai/bim/module/qto/prop.py b/src/bonsai/bonsai/bim/module/qto/prop.py
index 7ffbfcdfa7..adf1f7a2be 100644
--- a/src/bonsai/bonsai/bim/module/qto/prop.py
+++ b/src/bonsai/bonsai/bim/module/qto/prop.py
@@ -27,10 +27,28 @@ from bpy.props import (
)
from bpy.types import PropertyGroup
+import bonsai.bim.prop
import bonsai.tool as tool
CALCULATOR_FUNCTION_ENUM_ITEMS: list[Union[tuple[str, str, str], None]] = []
+# Measure class (matching ifc5d.qto's Function.measure / SI2ProjectUnitConverter.project_units'
+# keys) -> (BIMQtoProperties field name, tool.Pset special_type, UI label).
+MEASURE_TO_TARGET_UNIT_FIELD: dict[str, tuple[str, str, str]] = {
+ "IfcLengthMeasure": ("target_unit_length", "LENGTH", "Length"),
+ "IfcAreaMeasure": ("target_unit_area", "AREA", "Area"),
+ "IfcVolumeMeasure": ("target_unit_volume", "VOLUME", "Volume"),
+ "IfcMassMeasure": ("target_unit_mass", "MASS", "Mass"),
+ "IfcTimeMeasure": ("target_unit_time", "TIME", "Time"),
+}
+
+
+def _target_unit_items(special_type: str):
+ def getter(self: "BIMQtoProperties", context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS:
+ return bonsai.bim.prop.get_unit_enum_items_for_special_type(special_type, tool.Ifc.get())
+
+ return getter
+
def get_qto_rule(self: "BIMQtoProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]:
results: list[tuple[str, str, str]] = []
@@ -92,6 +110,11 @@ class BIMQtoProperties(PropertyGroup):
),
default=False,
)
+ target_unit_length: EnumProperty(items=_target_unit_items("LENGTH"), name="Length Unit")
+ target_unit_area: EnumProperty(items=_target_unit_items("AREA"), name="Area Unit")
+ target_unit_volume: EnumProperty(items=_target_unit_items("VOLUME"), name="Volume Unit")
+ target_unit_mass: EnumProperty(items=_target_unit_items("MASS"), name="Mass Unit")
+ target_unit_time: EnumProperty(items=_target_unit_items("TIME"), name="Time Unit")
if TYPE_CHECKING:
qto_rule: str
@@ -101,3 +124,8 @@ class BIMQtoProperties(PropertyGroup):
qto_name: str
prop_name: str
fallback: bool
+ target_unit_length: str
+ target_unit_area: str
+ target_unit_volume: str
+ target_unit_mass: str
+ target_unit_time: str
diff --git a/src/bonsai/bonsai/bim/module/qto/ui.py b/src/bonsai/bonsai/bim/module/qto/ui.py
index 032df25b20..5cfcd5ba96 100644
--- a/src/bonsai/bonsai/bim/module/qto/ui.py
+++ b/src/bonsai/bonsai/bim/module/qto/ui.py
@@ -17,9 +17,12 @@
# along with Bonsai. If not, see .
import bpy
+import ifc5d.qto
import bonsai.tool as tool
+from bonsai.bim.helper import prop_with_search
from bonsai.bim.module.qto.data import QtoData
+from bonsai.bim.module.qto.prop import MEASURE_TO_TARGET_UNIT_FIELD
class BIM_PT_qto(bpy.types.Panel):
@@ -44,6 +47,14 @@ class BIM_PT_qto(bpy.types.Panel):
row = layout.row()
row.prop(props, "qto_rule", text="")
row.prop(props, "fallback", text="", icon="RADIOBUT_ON" if props.fallback else "RADIOBUT_OFF")
+
+ box = layout.box()
+ box.label(text="Target Units (optional, otherwise project default)")
+ for field_name, _special_type, label in MEASURE_TO_TARGET_UNIT_FIELD.values():
+ row = box.row(align=True)
+ row.label(text=label)
+ prop_with_search(row, props, field_name, text="")
+
row = layout.row()
row.operator("bim.perform_quantity_take_off")
@@ -66,6 +77,15 @@ class BIM_PT_qto_manual(bpy.types.Panel):
row = layout.row()
row.prop(props, "calculator_function", text="Function")
+ calculator = ifc5d.qto.calculators.get(props.calculator)
+ function = calculator.functions.get(props.calculator_function) if calculator else None
+ target_unit_field = MEASURE_TO_TARGET_UNIT_FIELD.get(function.measure) if function else None
+ if target_unit_field:
+ field_name, _special_type, label = target_unit_field
+ row = layout.row(align=True)
+ row.label(text=f"{label} Unit")
+ prop_with_search(row, props, field_name, text="")
+
row = layout.row(align=True)
row.prop(props, "qto_name", text="")
row.prop(props, "prop_name", text="")
diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py
index bb06a9ecb8..e951331f15 100644
--- a/src/bonsai/bonsai/bim/prop.py
+++ b/src/bonsai/bonsai/bim/prop.py
@@ -123,32 +123,39 @@ def get_attribute_enum_values(prop: "Attribute", context: bpy.types.Context) ->
return items
-def get_attribute_unit_enum_items(prop: "Attribute", context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS:
- """Items for `Attribute.unit_id_enum`: "(Project Default)" plus every candidate unit
- matching `prop.special_type`, filtered per-instance since candidates depend on the
- attribute's own measure type (unlike the globally-shared lists in `bonsai.bim.ui.EnumData`).
+def get_unit_enum_items_for_special_type(
+ special_type: str, ifc_file: Union[ifcopenshell.file, None]
+) -> tool.Blender.BLENDER_ENUM_ITEMS:
+ """Items for a unit-override picker: "Default ()" plus every candidate unit
+ matching `special_type`, filtered per-caller since candidates depend on the measure type
+ in question (unlike the globally-shared lists in `bonsai.bim.ui.EnumData`).
"""
- ifc_file = tool.Ifc.get()
- if not ifc_file or not tool.Pset.is_measurable_special_type(prop.special_type):
+ if not ifc_file or not tool.Pset.is_measurable_special_type(special_type):
return [(cache_string("0"), cache_string("Default"), "")]
- default_symbol = tool.Pset.get_unit_symbol_for_special_type(prop.special_type, ifc_file)
+ default_symbol = tool.Pset.get_unit_symbol_for_special_type(special_type, ifc_file)
items: list[tuple[str, str, str]] = [
(cache_string("0"), cache_string(f"Default ({default_symbol})" if default_symbol else "Default"), "")
]
- seen_ids = {0}
- for unit in tool.Pset.get_candidate_units_for_special_type(prop.special_type, ifc_file):
+ for unit in tool.Pset.get_candidate_units_for_special_type(special_type, ifc_file):
name = getattr(unit, "Name", None) or unit.is_a()
symbol = ifcopenshell.util.unit.get_unit_symbol(unit)
label = f"{name} ({symbol})" if symbol else name
items.append((cache_string(str(unit.id())), cache_string(label), ""))
- seen_ids.add(unit.id())
+ return items
- # Defensive: real-world files sometimes carry a Unit that doesn't cleanly match our
- # candidate-matching logic (e.g. a mismatched UnitType). Always keep the attribute's own
- # current override selectable/representable, however unusual, so setting unit_id_enum to
- # match an already-seeded unit_id can never raise "enum not found".
- if prop.unit_id and prop.unit_id not in seen_ids:
+
+def get_attribute_unit_enum_items(prop: "Attribute", context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS:
+ """Items for `Attribute.unit_id_enum`. Wraps `get_unit_enum_items_for_special_type` with a
+ defensive addition: real-world files sometimes carry a Unit that doesn't cleanly match our
+ candidate-matching logic (e.g. a mismatched UnitType). Always keep the attribute's own
+ current override selectable/representable, however unusual, so setting unit_id_enum to
+ match an already-seeded unit_id can never raise "enum not found".
+ """
+ ifc_file = tool.Ifc.get()
+ items = get_unit_enum_items_for_special_type(prop.special_type, ifc_file)
+
+ if prop.unit_id and prop.unit_id not in {int(i[0]) for i in items}:
own_unit = ifc_file.by_id(prop.unit_id)
name = getattr(own_unit, "Name", None) or own_unit.is_a()
symbol = ifcopenshell.util.unit.get_unit_symbol(own_unit)
@@ -302,6 +309,20 @@ def get_display_name(self: "Attribute") -> str:
return f"{name}, {self.unit_symbol}"
+def get_unit_symbol(self: "Attribute") -> str:
+ """The symbol for whatever unit the value is currently expressed in: this property's own
+ override (`unit_id`) if set, else the project default for `special_type`. Computed fresh on
+ every access (rather than cached at import time) so it stays correct immediately after the
+ unit picker changes `unit_id`, and after the project's own default units are edited.
+ """
+ if not tool.Pset.is_measurable_special_type(self.special_type):
+ return ""
+ if not (ifc_file := tool.Ifc.get()):
+ return ""
+ unit = tool.Pset.resolve_effective_unit(self.special_type, self.unit_id, ifc_file)
+ return ifcopenshell.util.unit.get_unit_symbol(unit) if unit else ""
+
+
AttributeDataType = Literal["string", "integer", "float", "boolean", "enum", "file", "list[string]"]
# Either "", "DATE", "DATETIME", "LOGICAL", "URI", "DURATION", or an
# IfcUnitEnum/IfcDerivedUnitEnum value with the "UNIT" suffix stripped (e.g.
@@ -359,7 +380,7 @@ class Attribute(PropertyGroup):
value_max: FloatProperty(description="This is used to validate int_value and float_value")
value_max_constraint: BoolProperty(default=False, description="True if the numerical value has an upper bound")
special_type: StringProperty(name="Special Value Type", default="")
- unit_symbol: StringProperty(name="Unit Symbol", default="")
+ unit_symbol: StringProperty(name="Unit Symbol", get=get_unit_symbol)
unit_id: IntProperty(name="Unit Override", default=0)
"""STEP id of this property/quantity's own Unit override. 0 means "use the project default"."""
unit_id_enum: EnumProperty(items=get_attribute_unit_enum_items, name="Unit", update=update_attribute_unit_id)
diff --git a/src/bonsai/bonsai/tool/pset.py b/src/bonsai/bonsai/tool/pset.py
index 9e8ac173c4..858969f455 100644
--- a/src/bonsai/bonsai/tool/pset.py
+++ b/src/bonsai/bonsai/tool/pset.py
@@ -386,12 +386,12 @@ class Pset(bonsai.core.tool.Pset):
metadata.is_null = value is None
metadata.is_optional = True
metadata.special_type = cls.get_special_type_for_prop(prop)
- metadata.unit_symbol = cls.get_unit_symbol_for_prop(prop, tool.Ifc.get())
- # The prop's OWN Unit override only, not the resolved project-default fallback
- # get_unit_symbol_for_prop() above already accounted for. Some real-world files
- # (e.g. certain exporters) set Unit on properties that aren't actually measures --
- # ignore it there, since we only ever treat Unit as meaningful for measurable
- # special_types (matching the UI picker's own gating).
+ # The prop's OWN Unit override only -- metadata.unit_symbol is computed fresh from
+ # special_type/unit_id on every access (see Attribute.get_unit_symbol), so it
+ # already accounts for the project-default fallback once unit_id is set below.
+ # Some real-world files (e.g. certain exporters) set Unit on properties that
+ # aren't actually measures -- ignore it there, since we only ever treat Unit as
+ # meaningful for measurable special_types (matching the UI picker's own gating).
own_unit = getattr(prop, "Unit", None) if cls.is_measurable_special_type(metadata.special_type) else None
metadata.unit_id = own_unit.id() if own_unit else 0
metadata.unit_id_enum = str(metadata.unit_id)
@@ -472,7 +472,6 @@ class Pset(bonsai.core.tool.Pset):
metadata.is_optional = True
metadata.data_type = cls.get_prop_template_primitive_type(prop_template)
metadata.special_type = cls.get_special_type_for_prop(prop_template)
- metadata.unit_symbol = cls.get_unit_symbol_for_special_type(metadata.special_type, tool.Ifc.get())
if metadata.data_type == "string":
metadata.string_value = "" if metadata.is_null else str(data[prop_template.Name])
@@ -585,7 +584,6 @@ class Pset(bonsai.core.tool.Pset):
metadata.is_null = value is None
metadata.is_optional = True
metadata.special_type = special_type
- metadata.unit_symbol = cls.get_unit_symbol_for_special_type(special_type, tool.Ifc.get())
metadata.set_value(metadata.get_value_default() if metadata.is_null else value)
@classmethod
diff --git a/src/bonsai/bonsai/tool/qto.py b/src/bonsai/bonsai/tool/qto.py
index ec14df6f4c..90dbce4ed4 100644
--- a/src/bonsai/bonsai/tool/qto.py
+++ b/src/bonsai/bonsai/tool/qto.py
@@ -176,6 +176,19 @@ class Qto(bonsai.core.tool.Qto):
is_ifc4x3 = ifc_file.schema == "IFC4X3"
return {rule_id: rule for rule_id, rule in ifc5d.qto.rules.items() if rule_id.startswith("IFC4X3") == is_ifc4x3}
+ @classmethod
+ def get_target_units(cls) -> dict[str, ifcopenshell.entity_instance]:
+ from bonsai.bim.module.qto.prop import MEASURE_TO_TARGET_UNIT_FIELD
+
+ props = cls.get_qto_props()
+ ifc_file = tool.Ifc.get()
+ target_units: dict[str, ifcopenshell.entity_instance] = {}
+ for measure_class, (field_name, _special_type, _label) in MEASURE_TO_TARGET_UNIT_FIELD.items():
+ unit_id = int(tool.Blender.get_enum_safe(props, field_name) or "0")
+ if unit_id:
+ target_units[measure_class] = ifc_file.by_id(unit_id)
+ return target_units
+
@classmethod
def get_not_quantified_elements_message(cls, not_quantified_elements: set[ifcopenshell.entity_instance]) -> str:
not_quantified_message = ""
diff --git a/src/bonsai/test/bim/test_prop.py b/src/bonsai/test/bim/test_prop.py
index 31760f3400..be377d551b 100644
--- a/src/bonsai/test/bim/test_prop.py
+++ b/src/bonsai/test/bim/test_prop.py
@@ -131,6 +131,71 @@ class TestGetAttributeUnitEnumItems(NewFile):
assert [i[0] for i in items] == ["0"]
+class TestGetUnitEnumItemsForSpecialType(NewFile):
+ def test_matches_the_attribute_wrapper_output(self):
+ # Regression test for extracting get_unit_enum_items_for_special_type out of
+ # get_attribute_unit_enum_items: the wrapper must still produce identical items for
+ # the plain (no own-unit-fallback-needed) case.
+ ifc = ifcopenshell.file()
+ tool.Ifc.set(ifc)
+ ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
+ length_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI")
+ ifcopenshell.api.unit.assign_unit(ifc, units=[length_mm])
+ ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
+
+ element = ifc.createIfcWall()
+ prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcLengthMeasure(2.5))
+ metadata = import_single_property(ifc, element, prop)
+
+ direct_items = bonsai.bim.prop.get_unit_enum_items_for_special_type(metadata.special_type, ifc)
+ wrapper_items = bonsai.bim.prop.get_attribute_unit_enum_items(metadata, bpy.context)
+ assert direct_items == wrapper_items
+
+ def test_returns_just_default_when_ifc_file_is_none(self):
+ assert bonsai.bim.prop.get_unit_enum_items_for_special_type("LENGTH", None) == [("0", "Default", "")]
+
+
+class TestUnitSymbolWithAreaVolumeDerivedFromLength(NewFile):
+ """Regression test: AREAUNIT/VOLUMEUNIT have no IfcDerivedUnitEnum member, so a project
+ whose area/volume default is an IfcDerivedUnit rather than a literal-UnitType-matching
+ IfcSIUnit/IfcConversionBasedUnit has no literal UnitType to match on.
+ ifcopenshell.util.unit.get_project_unit() used to only match by literal UnitType, so the
+ read-only unit symbol and the edit-mode "Default ()" picker entry both silently
+ fell back to no symbol at all in that case.
+ """
+
+ def setup_project_with_derived_area_and_volume(self, ifc):
+ ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
+ length = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")
+ area = ifcopenshell.api.unit.add_derived_unit(ifc, "USERDEFINED", "area-ish", {length: 2})
+ volume = ifcopenshell.api.unit.add_derived_unit(ifc, "USERDEFINED", "volume-ish", {length: 3})
+ ifcopenshell.api.unit.assign_unit(ifc, units=[length, area, volume])
+
+ def test_default_picker_entry_shows_the_resolved_symbol(self):
+ ifc = ifcopenshell.file()
+ tool.Ifc.set(ifc)
+ self.setup_project_with_derived_area_and_volume(ifc)
+
+ element = ifc.createIfcWall()
+ prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcAreaMeasure(5.0))
+ metadata = import_single_property(ifc, element, prop)
+
+ items = bonsai.bim.prop.get_attribute_unit_enum_items(metadata, bpy.context)
+ assert items[0][1] == "Default (m2)"
+
+ def test_read_only_display_resolves_the_symbol(self):
+ ifc = ifcopenshell.file()
+ tool.Ifc.set(ifc)
+ self.setup_project_with_derived_area_and_volume(ifc)
+
+ element = ifc.createIfcWall()
+ prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcVolumeMeasure(5.0))
+ metadata = import_single_property(ifc, element, prop)
+
+ assert metadata.unit_symbol == "m3"
+ assert metadata.display_name == "Foo, m3"
+
+
class TestUpdateAttributeUnitId(NewFile):
def test_syncs_unit_id_and_converts_float_value_when_unit_id_enum_changes(self):
ifc = ifcopenshell.file()
@@ -145,11 +210,42 @@ class TestUpdateAttributeUnitId(NewFile):
metadata = import_single_property(ifc, element, prop)
assert metadata.unit_id == 0
assert metadata.float_value == 2500.0
+ assert metadata.unit_symbol == "mm"
+ assert metadata.display_name == "Foo, mm"
metadata.unit_id_enum = str(length_m.id())
assert metadata.unit_id == length_m.id()
assert metadata.float_value == pytest.approx(2.5) # converted, not just relabeled
+ # Regression test: unit_symbol/display_name used to be a snapshot taken once at import
+ # time, so picking a different unit converted the value but left the label showing the
+ # old unit's symbol.
+ assert metadata.unit_symbol == "m"
+ assert metadata.display_name == "Foo, m"
+
+
+class TestUnitSymbolReflectsLiveProjectState(NewFile):
+ def test_symbol_updates_after_a_project_default_unit_is_assigned_later(self):
+ # Regression test: unit_symbol used to be a snapshot computed once at import time, so a
+ # property/quantity imported before its measure type had a project default unit assigned
+ # kept showing no symbol even after one was added, unless the panel was closed and
+ # reopened (re-triggering import). It's now computed fresh on every access.
+ ifc = ifcopenshell.file()
+ tool.Ifc.set(ifc)
+ ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject")
+ # No AREAUNIT assigned yet.
+
+ element = ifc.createIfcWall()
+ prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcAreaMeasure(5.0))
+ metadata = import_single_property(ifc, element, prop)
+ assert metadata.unit_symbol == ""
+ assert metadata.display_name == "Foo"
+
+ area = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="AREAUNIT")
+ ifcopenshell.api.unit.assign_unit(ifc, units=[area])
+
+ assert metadata.unit_symbol == "m2"
+ assert metadata.display_name == "Foo, m2"
class TestImportPsetFromExistingWithAGenericNumericValueAndAnExplicitUnit(NewFile):
diff --git a/src/bonsai/test/tool/test_qto.py b/src/bonsai/test/tool/test_qto.py
index 9ec7dc4efd..497fc064f4 100644
--- a/src/bonsai/test/tool/test_qto.py
+++ b/src/bonsai/test/tool/test_qto.py
@@ -181,6 +181,42 @@ class TestGetCalculatedObjectQuantities(test.bim.bootstrap.NewFile):
assert quantities["NetVolume"] == 282.517
+class TestGetTargetUnits(test.bim.bootstrap.NewFile):
+ def setup_file(self):
+ ifc = ifcopenshell.file()
+ tool.Ifc.set(ifc)
+ ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject", name="Test")
+ return ifc
+
+ def test_default_scene_state_returns_nothing(self):
+ self.setup_file()
+ assert subject.get_target_units() == {}
+
+ def test_setting_a_field_maps_it_to_its_measure_class(self):
+ ifc = self.setup_file()
+ metre = ifc.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
+ millimetre = ifc.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
+ ifcopenshell.api.unit.assign_unit(ifc, units=[metre])
+
+ props = tool.Qto.get_qto_props()
+ props.target_unit_length = str(millimetre.id())
+
+ assert subject.get_target_units() == {"IfcLengthMeasure": millimetre}
+
+ def test_untouched_fields_are_excluded(self):
+ ifc = self.setup_file()
+ metre = ifc.createIfcSIUnit(None, "LENGTHUNIT", None, "METRE")
+ millimetre = ifc.createIfcSIUnit(None, "LENGTHUNIT", "MILLI", "METRE")
+ gram = ifc.createIfcSIUnit(None, "MASSUNIT", None, "GRAM")
+ ifcopenshell.api.unit.assign_unit(ifc, units=[metre, gram])
+
+ props = tool.Qto.get_qto_props()
+ props.target_unit_length = str(millimetre.id())
+ props.target_unit_mass = "0" # explicitly left at "Default"
+
+ assert subject.get_target_units() == {"IfcLengthMeasure": millimetre}
+
+
class TestGetBaseQto(test.bim.bootstrap.NewFile):
def test_run(self):
ifc = ifcopenshell.file()