From c0f967e81e395f50ac161bf98c688951ac138218 Mon Sep 17 00:00:00 2001 From: Richard Brice <37087370+RickBrice@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:52:46 -0700 Subject: [PATCH] Add UI to pick/override a property or quantity's unit of measure in the Pset/Qto editor Bonsai's Pset/Qto editor could display a property or quantity's own Unit override, but had no UI to author one -- only the project-level Project Units panel existed, which sets defaults, not per-instance overrides. Builds on the edit_pset/edit_qto Unit-wrapping support and the get_unit_scale/get_candidate_units helpers added in the previous commit. - bim/prop.py: Attribute gains unit_id (the STEP id of the property's own override, 0 = project default) and unit_id_enum (the dropdown-driving dynamic enum, "Default ()" plus every candidate unit for the attribute's measure type). update_attribute_unit_id converts the stored value live when a different unit is picked, so the physical quantity is preserved rather than the number being silently relabeled. - tool/pset.py: is_measurable_special_type/get_candidate_units_for_special_type/ resolve_effective_unit/convert_attribute_unit support the picker and the live conversion. get_special_type_for_prop classifies a property by its value's own declared measure type, falling back to an explicitly-attached Unit for generic numeric types (e.g. IfcReal) whose spec carries no unit semantics of its own but which may still legitimately carry one. Seeding in import_pset_from_existing ignores a stray Unit attached to a property whose value has no numeric/measure semantics at all (e.g. text), which used to crash trying to select an identifier the picker's enum items never include. - bim/module/pset/ui.py: the picker widget itself, next to the value field in edit mode, gated on the attribute being measurable. - bim/module/pset/operator.py: EditPset wraps measurable values with their chosen Unit on save, for both properties and quantities. The qto rounding-loop fix reaches into the wrapped dict instead of assuming a bare float/int, which would otherwise zero out every unit-overridden quantity. Adds regression tests across all of the above, including conversion correctness, explicit-clear/default round-trips, an unrelated sibling property's override surviving untouched, and the stray-Unit crash guard. --- src/bonsai/bonsai/bim/module/pset/operator.py | 33 ++- src/bonsai/bonsai/bim/module/pset/ui.py | 4 + src/bonsai/bonsai/bim/prop.py | 51 ++++ src/bonsai/bonsai/tool/pset.py | 84 +++++- src/bonsai/test/bim/test_prop.py | 153 ++++++++++ src/bonsai/test/tool/test_pset.py | 261 ++++++++++++++++++ 6 files changed, 575 insertions(+), 11 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/pset/operator.py b/src/bonsai/bonsai/bim/module/pset/operator.py index f820038807..cf62993e07 100644 --- a/src/bonsai/bonsai/bim/module/pset/operator.py +++ b/src/bonsai/bonsai/bim/module/pset/operator.py @@ -120,13 +120,20 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator): properties = json.loads(self.properties) else: for prop in props.properties: + metadata = prop.metadata if prop.value_type == "IfcPropertySingleValue": - properties[prop.metadata.name] = prop.metadata.get_value() + value = metadata.get_value() elif prop.value_type == "IfcPropertyEnumeratedValue": - value_name = prop.metadata.get_value_name() - properties[prop.metadata.name] = [ - e[value_name] for e in prop.enumerated_value.enumerated_values if e.is_selected - ] + value_name = metadata.get_value_name() + value = [e[value_name] for e in prop.enumerated_value.enumerated_values if e.is_selected] + else: + continue + # None (a purge/skip-creation signal, handled by edit_pset/edit_qto before any + # unit wrapping is unpacked) must stay bare -- only wrap real values. + if value is not None and tool.Pset.is_measurable_special_type(metadata.special_type): + unit = self.file.by_id(metadata.unit_id) if metadata.unit_id else None + value = {"NominalValue": value, "Unit": unit} + properties[metadata.name] = value if pset.is_a() in ("IfcPropertySet", "IfcMaterialProperties", "IfcProfileProperties"): ifcopenshell.api.pset.edit_pset( @@ -140,10 +147,18 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator): for key, value in properties.items(): if value is None: continue - if isinstance(value, float): - properties[key] = round(value, 4) - elif not isinstance(value, int): - properties[key] = 0 + is_wrapped = isinstance(value, dict) and "Unit" in value + raw = value["NominalValue"] if is_wrapped else value + if raw is None: + continue + if isinstance(raw, float): + raw = round(raw, 4) + elif not isinstance(raw, int): + raw = 0 + if is_wrapped: + value["NominalValue"] = raw + else: + properties[key] = raw ifcopenshell.api.pset.edit_qto( self.file, qto=pset, diff --git a/src/bonsai/bonsai/bim/module/pset/ui.py b/src/bonsai/bonsai/bim/module/pset/ui.py index 9e5a5ce8bd..67c9f7b670 100644 --- a/src/bonsai/bonsai/bim/module/pset/ui.py +++ b/src/bonsai/bonsai/bim/module/pset/ui.py @@ -67,6 +67,10 @@ def draw_single_property(prop: IfcProperty, layout: bpy.types.UILayout, copy_ope if prop.metadata.special_type == "URI": op = layout.operator("bim.select_uri_attribute", text="", icon="FILE_FOLDER") op.attribute_data_path = tool.Blender.get_full_data_path(prop.metadata) + if tool.Pset.is_measurable_special_type(prop.metadata.special_type): + unit_row = layout.row(align=True) + unit_row.scale_x = 0.5 + prop_with_search(unit_row, prop.metadata, "unit_id_enum", text="") if prop.metadata.is_optional: layout.prop(prop.metadata, "is_null", icon="RADIOBUT_OFF" if prop.metadata.is_null else "RADIOBUT_ON", text="") if copy_operator: diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py index d7ceff89d4..bb06a9ecb8 100644 --- a/src/bonsai/bonsai/bim/prop.py +++ b/src/bonsai/bonsai/bim/prop.py @@ -33,6 +33,8 @@ from bpy.props import ( ) from bpy.types import PropertyGroup +import ifcopenshell.util.unit + import bonsai.bim import bonsai.bim.handler import bonsai.tool as tool @@ -121,6 +123,50 @@ 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`). + """ + ifc_file = tool.Ifc.get() + if not ifc_file or not tool.Pset.is_measurable_special_type(prop.special_type): + return [(cache_string("0"), cache_string("Default"), "")] + + default_symbol = tool.Pset.get_unit_symbol_for_special_type(prop.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): + 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()) + + # 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: + 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) + label = f"{name} ({symbol})" if symbol else name + items.append((cache_string(str(prop.unit_id)), cache_string(label), "")) + + return items + + +def update_attribute_unit_id(self: "Attribute", context: bpy.types.Context) -> None: + new_unit_id = int(tool.Blender.get_enum_safe(self, "unit_id_enum") or "0") + if ifc_file := tool.Ifc.get(): + # Must run before self.unit_id is overwritten: convert_attribute_unit needs the OLD + # unit_id to know what unit the current value is expressed in. + tool.Pset.convert_attribute_unit(self, new_unit_id, ifc_file) + self.unit_id = new_unit_id + + def update_schema_dir(self: "BIMProperties", context: bpy.types.Context) -> None: import bonsai.bim.schema @@ -314,6 +360,9 @@ class Attribute(PropertyGroup): 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_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) use_explorer_ui: BoolProperty() metadata: StringProperty(name="Metadata", description="For storing some additional information about the attribute") update: StringProperty(name="Update", description="Custom update function to be executed") @@ -345,6 +394,8 @@ class Attribute(PropertyGroup): value_max: float value_max_constraint: bool unit_symbol: str + unit_id: int + unit_id_enum: str use_explorer_ui: bool metadata: str update: str diff --git a/src/bonsai/bonsai/tool/pset.py b/src/bonsai/bonsai/tool/pset.py index 8f903ccd6d..9e8ac173c4 100644 --- a/src/bonsai/bonsai/tool/pset.py +++ b/src/bonsai/bonsai/tool/pset.py @@ -192,6 +192,18 @@ class Pset(bonsai.core.tool.Pset): unit_type = ifcopenshell.util.unit.get_measure_unit_type(measure_class) return unit_type[: -len("UNIT")] if unit_type.endswith("UNIT") else "" + @classmethod + def get_special_type_for_unit(cls, unit: ifcopenshell.entity_instance) -> str: + """Get the ``special_type`` (an IfcUnitEnum value with "UNIT" stripped) directly from + a Unit entity, for properties whose NominalValue is a generic numeric type (e.g. + IfcReal) rather than a proper measure class, but which still carry a real Unit. + """ + unit_type = getattr(unit, "UnitType", None) + if unit_type and unit_type != "USERDEFINED": + return unit_type[: -len("UNIT")] if unit_type.endswith("UNIT") else "" + dimension_type = ifcopenshell.util.unit.identify_unit_dimensions(unit) + return dimension_type[: -len("UNIT")] if dimension_type else "" + @classmethod def get_special_type_for_prop(cls, prop_or_prop_template: ifcopenshell.entity_instance) -> str: """Classify a property/quantity/template by its measure type. @@ -210,7 +222,16 @@ class Pset(bonsai.core.tool.Pset): elif prop_or_prop_template.is_a("IfcPropertySingleValue"): value = prop_or_prop_template.NominalValue if value is not None: - return cls.get_special_type_for_measure_class(value.is_a()) + special_type = cls.get_special_type_for_measure_class(value.is_a()) + if special_type: + return special_type + # Some property sets declare a generic numeric type (e.g. IfcReal) rather + # than a proper measure class, relying on an explicit Unit attribute alone to + # convey the dimension. Still measurable -- derive special_type from the Unit + # itself rather than (fruitlessly) from NominalValue's declared type. + if value.is_a() in ("IfcReal", "IfcInteger"): + if unit := getattr(prop_or_prop_template, "Unit", None): + return cls.get_special_type_for_unit(unit) elif prop_or_prop_template.is_a("IfcPhysicalSimpleQuantity"): entity = prop_or_prop_template.wrapped_data.declaration().as_entity() measure_class = entity.attribute_by_index(3).type_of_attribute().declared_type().name() @@ -232,10 +253,61 @@ class Pset(bonsai.core.tool.Pset): @classmethod def get_unit_symbol_for_prop(cls, prop: ifcopenshell.entity_instance, ifc_file: ifcopenshell.file) -> str: - """Get the unit symbol for an existing property/quantity, respecting its own `Unit` override.""" + """Get the unit symbol for an existing property/quantity, respecting its own `Unit` override. + + Gated on the property being classified as measurable (see `get_special_type_for_prop`, + which already accounts for a Unit attached to a generic numeric value) -- this only + excludes a Unit attached to a property whose value has no numeric/measure semantics at + all (e.g. text), where a stray Unit shouldn't be surfaced as a resolved unit. + """ + if not cls.is_measurable_special_type(cls.get_special_type_for_prop(prop)): + return "" unit = ifcopenshell.util.unit.get_property_unit(prop, ifc_file) return ifcopenshell.util.unit.get_unit_symbol(unit) if unit else "" + # special_type values that don't denote a real unit-bearing measure (see get_special_type_for_prop). + NON_MEASURABLE_SPECIAL_TYPES = frozenset({"", "DATE", "DATETIME", "LOGICAL", "URI", "DURATION"}) + + @classmethod + def is_measurable_special_type(cls, special_type: str) -> bool: + """True if `special_type` (see `get_special_type_for_prop`) denotes a real unit-bearing measure.""" + return special_type not in cls.NON_MEASURABLE_SPECIAL_TYPES + + @classmethod + def get_candidate_units_for_special_type( + cls, special_type: str, ifc_file: ifcopenshell.file + ) -> list[ifcopenshell.entity_instance]: + """All units in the file usable as an override for a `special_type` (see `get_special_type_for_prop`).""" + if not cls.is_measurable_special_type(special_type): + return [] + return ifcopenshell.util.unit.get_candidate_units(ifc_file, f"{special_type}UNIT") + + @classmethod + def resolve_effective_unit( + cls, special_type: str, unit_id: int, ifc_file: ifcopenshell.file + ) -> Union[ifcopenshell.entity_instance, None]: + """The unit a value is currently expressed in: its own override (`unit_id`, a STEP id, + 0 meaning "no override"), or the project default for `special_type` otherwise.""" + if unit_id: + return ifc_file.by_id(unit_id) + return ifcopenshell.util.unit.get_project_unit(ifc_file, f"{special_type}UNIT") + + @classmethod + def convert_attribute_unit(cls, metadata: "Attribute", new_unit_id: int, ifc_file: ifcopenshell.file) -> None: + """Rescale `metadata.float_value` in place so its physical quantity is preserved when + switching from its current effective unit to the unit named by `new_unit_id` (0 = project + default). No-op for non-measurable attributes or when old and new resolve to the same unit. + """ + if not cls.is_measurable_special_type(metadata.special_type): + return + old_unit = cls.resolve_effective_unit(metadata.special_type, metadata.unit_id, ifc_file) + new_unit = cls.resolve_effective_unit(metadata.special_type, new_unit_id, ifc_file) + if old_unit is None or new_unit is None or old_unit == new_unit: + return + old_scale = ifcopenshell.util.unit.get_unit_scale(old_unit) + new_scale = ifcopenshell.util.unit.get_unit_scale(new_unit) + metadata.float_value = metadata.float_value * old_scale / new_scale + @classmethod def import_pset_from_existing( cls, @@ -315,6 +387,14 @@ class Pset(bonsai.core.tool.Pset): 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). + 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) metadata.set_value(metadata.get_value_default() if metadata.is_null else value) process_prop_description(metadata) diff --git a/src/bonsai/test/bim/test_prop.py b/src/bonsai/test/bim/test_prop.py index 2413cc6c7d..31760f3400 100644 --- a/src/bonsai/test/bim/test_prop.py +++ b/src/bonsai/test/bim/test_prop.py @@ -21,7 +21,9 @@ import ifcopenshell import ifcopenshell.api.pset import ifcopenshell.api.root import ifcopenshell.api.unit +import pytest +import bonsai.bim.prop import bonsai.tool as tool from test.bim.bootstrap import NewFile @@ -77,3 +79,154 @@ class TestGetDisplayName(NewFile): assert metadata.unit_symbol == "" assert metadata.display_name == "Foo" + + def test_resolves_a_unit_explicitly_attached_to_a_generic_numeric_value(self): + # A generic IfcReal has no unit semantics per its own declared type, but a property + # set may still explicitly attach a real Unit to a specific instance to convey the + # dimension the spec's generic typing doesn't. That explicit Unit is real, deliberate + # data (not incidental/stray), so it should resolve normally. + 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") + + element = ifc.createIfcWall() + prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcReal(150.0), Unit=length_mm) + metadata = import_single_property(ifc, element, prop) + + assert metadata.unit_symbol == "mm" + assert metadata.display_name == "Foo, mm" + + +class TestGetAttributeUnitEnumItems(NewFile): + def test_returns_default_plus_one_per_candidate_for_a_measurable_type(self): + 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]) + length_m = 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) + + items = bonsai.bim.prop.get_attribute_unit_enum_items(metadata, bpy.context) + identifiers = [i[0] for i in items] + assert identifiers[0] == "0" + assert str(length_mm.id()) in identifiers + assert str(length_m.id()) in identifiers + assert len(items) == 3 # Default + mm + m + + def test_returns_just_default_for_non_measurable_types(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject") + + element = ifc.createIfcWall() + prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcText("Bar")) + metadata = import_single_property(ifc, element, prop) + + items = bonsai.bim.prop.get_attribute_unit_enum_items(metadata, bpy.context) + assert [i[0] for i in items] == ["0"] + + +class TestUpdateAttributeUnitId(NewFile): + def test_syncs_unit_id_and_converts_float_value_when_unit_id_enum_changes(self): + 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]) + length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT") + + element = ifc.createIfcWall() + prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcLengthMeasure(2500.0)) + metadata = import_single_property(ifc, element, prop) + assert metadata.unit_id == 0 + assert metadata.float_value == 2500.0 + + 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 + + +class TestImportPsetFromExistingWithAGenericNumericValueAndAnExplicitUnit(NewFile): + def test_run(self): + # Regression test: some property set specifications declare a property as a generic + # IfcReal rather than a proper measure class, relying on an explicit Unit attribute + # alone to convey the dimension. get_property_unit() already handled this fine for + # display (it checks prop.Unit before looking at NominalValue's type at all), but + # get_special_type_for_prop() only looked at NominalValue's class ending in "Measure" + # -- so special_type came back "", the picker never appeared, and the real Unit + # override never got seeded into unit_id even though it was legitimately set. + 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") + + element = ifc.createIfcWall() + prop = ifc.createIfcPropertySingleValue( + Name="Foo", NominalValue=ifc.createIfcReal(150.0), Unit=length_mm + ) + metadata = import_single_property(ifc, element, prop) + + assert metadata.special_type == "LENGTH" + assert tool.Pset.is_measurable_special_type(metadata.special_type) + assert metadata.unit_symbol == "mm" + assert metadata.unit_id == length_mm.id() + assert metadata.unit_id_enum == str(length_mm.id()) + + items = bonsai.bim.prop.get_attribute_unit_enum_items(metadata, bpy.context) + assert str(length_mm.id()) in [i[0] for i in items] + + +class TestImportPsetFromExistingWithAStrayUnitOnANonMeasureProperty(NewFile): + def test_run(self): + # Regression test: some real-world exporters set a Unit on a property whose + # NominalValue isn't actually a measure (e.g. a text classification), which used + # to crash import_pset_from_existing with "enum '' not found in ('0')" -- unit_id + # was seeded from prop.Unit unconditionally, before the special_type gate that decides + # whether Unit is even meaningful for this property. + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject") + length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT") + + element = ifc.createIfcWall() + prop = ifc.createIfcPropertySingleValue( + Name="Foo", NominalValue=ifc.createIfcLabel("Bar"), Unit=length_m + ) + metadata = import_single_property(ifc, element, prop) # must not raise + + assert metadata.special_type == "" + assert metadata.unit_id == 0 + assert metadata.unit_id_enum == "0" + + +class TestGetAttributeUnitEnumItemsWithAMismatchedUnit(NewFile): + def test_own_unit_is_always_representable_even_if_not_a_normal_candidate(self): + # Regression test: a property's own Unit might not satisfy + # get_candidate_units_for_special_type's matching (e.g. mismatched UnitType in messy + # real-world data). Seeding must never crash trying to select it, and it should still + # show up in the picker so the user can see/change it. + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject") + # A LENGTHUNIT attached to a PRESSURE-typed property -- a real mismatch, not a candidate + # get_candidate_units_for_special_type("PRESSURE", ...) would ever return. + mismatched_unit = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT") + + element = ifc.createIfcWall() + prop = ifc.createIfcPropertySingleValue( + Name="Foo", NominalValue=ifc.createIfcPressureMeasure(5.0), Unit=mismatched_unit + ) + metadata = import_single_property(ifc, element, prop) # must not raise + + assert metadata.special_type == "PRESSURE" + assert metadata.unit_id == mismatched_unit.id() + assert metadata.unit_id_enum == str(mismatched_unit.id()) + + items = bonsai.bim.prop.get_attribute_unit_enum_items(metadata, bpy.context) + assert str(mismatched_unit.id()) in [i[0] for i in items] diff --git a/src/bonsai/test/tool/test_pset.py b/src/bonsai/test/tool/test_pset.py index 32482b2bf4..230574bee4 100644 --- a/src/bonsai/test/tool/test_pset.py +++ b/src/bonsai/test/tool/test_pset.py @@ -22,6 +22,7 @@ import ifcopenshell.api import ifcopenshell.api.pset import ifcopenshell.api.root import ifcopenshell.api.unit +import pytest import bonsai.core.tool import bonsai.tool as tool @@ -131,3 +132,263 @@ class TestImportingATemplatedQuantityRespectsItsOwnUnitOverride(NewFile): metadata = blender_props.properties["Foo"].metadata assert metadata.unit_symbol == "mm" # the quantity's own override, not the project default "m" assert metadata.float_value == 2500.0 # raw stored value, not rescaled + + +class TestIsMeasurableSpecialType(NewFile): + def test_run(self): + for special_type in ("", "DATE", "DATETIME", "LOGICAL", "URI", "DURATION"): + assert subject.is_measurable_special_type(special_type) is False + assert subject.is_measurable_special_type("LENGTH") is True + assert subject.is_measurable_special_type("PRESSURE") is True + + +class TestGetCandidateUnitsForSpecialType(NewFile): + def test_returns_candidates_matching_the_special_type(self): + 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") + length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT") + ifcopenshell.api.unit.add_si_unit(ifc, unit_type="AREAUNIT") + assert set(subject.get_candidate_units_for_special_type("LENGTH", ifc)) == {length_mm, length_m} + + def test_gating_returns_empty_for_non_measurable_special_types(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject") + ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT") + assert subject.get_candidate_units_for_special_type("", ifc) == [] + assert subject.get_candidate_units_for_special_type("URI", ifc) == [] + + +class TestResolveEffectiveUnit(NewFile): + def test_own_override_takes_precedence_over_project_default(self): + 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]) + length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT") + assert subject.resolve_effective_unit("LENGTH", length_m.id(), ifc) == length_m + + def test_falls_back_to_project_default_when_unit_id_is_zero(self): + 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]) + assert subject.resolve_effective_unit("LENGTH", 0, ifc) == length_mm + + +class TestConvertAttributeUnit(NewFile): + def _new_metadata(self, ifc: ifcopenshell.file): + element = ifc.createIfcWall() + obj = bpy.data.objects.new("Wall", None) + tool.Ifc.link(element, obj) + props = obj.PsetProperties + new_prop = props.properties.add() + new_prop.name = "Foo" + return new_prop.metadata + + def test_converts_value_between_two_explicit_units(self): + 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") + length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT") + + metadata = self._new_metadata(ifc) + metadata.special_type = "LENGTH" + metadata.unit_id = length_mm.id() + metadata.float_value = 2500.0 + + subject.convert_attribute_unit(metadata, length_m.id(), ifc) + assert metadata.float_value == pytest.approx(2.5) + + def test_converts_value_when_switching_to_and_from_the_project_default(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject") + length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT") + ifcopenshell.api.unit.assign_unit(ifc, units=[length_m]) + length_ft = ifcopenshell.api.unit.add_conversion_based_unit(ifc, name="foot") + + metadata = self._new_metadata(ifc) + metadata.special_type = "LENGTH" + metadata.unit_id = length_ft.id() + metadata.float_value = 10.0 # 10 ft + + subject.convert_attribute_unit(metadata, 0, ifc) # 0 = switch to project default (m) + assert metadata.float_value == pytest.approx(3.048) + + def test_noop_when_old_and_new_resolve_to_the_same_unit(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject") + length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT") + ifcopenshell.api.unit.assign_unit(ifc, units=[length_m]) + + metadata = self._new_metadata(ifc) + metadata.special_type = "LENGTH" + metadata.unit_id = 0 # already resolves to length_m (the project default) + metadata.float_value = 5.0 + + subject.convert_attribute_unit(metadata, length_m.id(), ifc) + assert metadata.float_value == 5.0 + + def test_noop_for_a_non_measurable_special_type(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject") + length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT") + + metadata = self._new_metadata(ifc) + metadata.special_type = "" + metadata.unit_id = 0 + metadata.float_value = 5.0 + + subject.convert_attribute_unit(metadata, length_m.id(), ifc) + assert metadata.float_value == 5.0 + + +def _build_wrapped_properties_from_ui(blender_props) -> dict: + """Mirrors EditPset._execute()'s properties-building loop (operator.py).""" + properties = {} + for entry in blender_props.properties: + metadata = entry.metadata + value = metadata.get_value() + if value is not None and subject.is_measurable_special_type(metadata.special_type): + unit = tool.Ifc.get().by_id(metadata.unit_id) if metadata.unit_id else None + value = {"NominalValue": value, "Unit": unit} + properties[metadata.name] = value + return properties + + +class TestEditPsetWithUnitOverridePicker(NewFile): + def test_picking_a_different_unit_converts_the_displayed_value_and_writes_it_back(self): + 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]) + length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT") + + element = ifc.createIfcWall() + pset = ifcopenshell.api.pset.add_pset(ifc, product=element, name="Pset_Test") + prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcLengthMeasure(2500.0)) + pset.HasProperties = [prop] + + obj = bpy.data.objects.new("Wall", None) + tool.Ifc.link(element, obj) + blender_props = obj.PsetProperties + subject.import_pset_from_existing(pset, blender_props, None) + + metadata = blender_props.properties["Foo"].metadata + assert metadata.unit_id == 0 + assert metadata.float_value == 2500.0 + + # Simulate the user picking "m" in the unit picker dropdown. + metadata.unit_id_enum = str(length_m.id()) + assert metadata.float_value == pytest.approx(2.5) # converted live, not just relabeled + assert metadata.unit_id == length_m.id() + + properties = _build_wrapped_properties_from_ui(blender_props) + ifcopenshell.api.pset.edit_pset(ifc, pset=pset, properties=properties) + + assert prop.NominalValue.wrappedValue == pytest.approx(2.5) + assert prop.Unit == length_m + + def test_picking_default_after_an_override_converts_back_and_clears_the_unit(self): + 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]) + length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT") + + element = ifc.createIfcWall() + pset = ifcopenshell.api.pset.add_pset(ifc, product=element, name="Pset_Test") + prop = ifc.createIfcPropertySingleValue( + Name="Foo", NominalValue=ifc.createIfcLengthMeasure(2.5), Unit=length_m + ) + pset.HasProperties = [prop] + + obj = bpy.data.objects.new("Wall", None) + tool.Ifc.link(element, obj) + blender_props = obj.PsetProperties + subject.import_pset_from_existing(pset, blender_props, None) + + metadata = blender_props.properties["Foo"].metadata + assert metadata.unit_id == length_m.id() + assert metadata.float_value == 2.5 + + # Simulate picking "Default" (mm). + metadata.unit_id_enum = "0" + assert metadata.float_value == pytest.approx(2500.0) + assert metadata.unit_id == 0 + + properties = _build_wrapped_properties_from_ui(blender_props) + ifcopenshell.api.pset.edit_pset(ifc, pset=pset, properties=properties) + + assert prop.Unit is None + assert prop.NominalValue.wrappedValue == pytest.approx(2500.0) + + def test_editing_an_unrelated_sibling_property_does_not_disturb_this_ones_override(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject") + length_m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT") + ifcopenshell.api.unit.assign_unit(ifc, units=[length_m]) + length_ft = ifcopenshell.api.unit.add_conversion_based_unit(ifc, name="foot") + + element = ifc.createIfcWall() + pset = ifcopenshell.api.pset.add_pset(ifc, product=element, name="Pset_Test") + overridden_prop = ifc.createIfcPropertySingleValue( + Name="Foo", NominalValue=ifc.createIfcLengthMeasure(10.0), Unit=length_ft + ) + untouched_prop = ifc.createIfcPropertySingleValue(Name="Bar", NominalValue=ifc.createIfcLengthMeasure(3.0)) + pset.HasProperties = [overridden_prop, untouched_prop] + + obj = bpy.data.objects.new("Wall", None) + tool.Ifc.link(element, obj) + blender_props = obj.PsetProperties + subject.import_pset_from_existing(pset, blender_props, None) + + # Edit only "Bar", never touching "Foo"'s unit dropdown. + blender_props.properties["Bar"].metadata.float_value = 4.0 + + properties = _build_wrapped_properties_from_ui(blender_props) + ifcopenshell.api.pset.edit_pset(ifc, pset=pset, properties=properties) + + assert overridden_prop.Unit == length_ft # untouched override survives + assert overridden_prop.NominalValue.wrappedValue == 10.0 + assert untouched_prop.NominalValue.wrappedValue == 4.0 + + +class TestEditQtoRoundingLoopPreservesUnitWrappedValues(NewFile): + def test_run(self): + # Regression test for EditPset._execute()'s qto post-processing loop: it must reach + # into {"Unit": ..., "NominalValue": ...}-wrapped values to round them, rather than + # treating the whole dict as a bare float/int (which would zero it out). + properties = { + "Foo": {"NominalValue": 2.123456, "Unit": None}, + "Bar": 3, + } + for key, value in properties.items(): + if value is None: + continue + is_wrapped = isinstance(value, dict) and "Unit" in value + raw = value["NominalValue"] if is_wrapped else value + if raw is None: + continue + if isinstance(raw, float): + raw = round(raw, 4) + elif not isinstance(raw, int): + raw = 0 + if is_wrapped: + value["NominalValue"] = raw + else: + properties[key] = raw + assert properties["Foo"]["NominalValue"] == 2.1235 + assert properties["Foo"]["Unit"] is None + assert properties["Bar"] == 3