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 (<symbol>)" 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.
This commit is contained in:
Richard Brice
2026-08-10 10:52:46 -07:00
parent 3a3c00e6f6
commit c0f967e81e
6 changed files with 575 additions and 11 deletions
+24 -9
View File
@@ -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,
+4
View File
@@ -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:
+51
View File
@@ -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
+82 -2
View File
@@ -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)
+153
View File
@@ -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 '<id>' 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]
+261
View File
@@ -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