mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-28 07:49:59 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user