From b8211143b1b825d0ac3894ab3e5800775f440583 Mon Sep 17 00:00:00 2001 From: Richard Brice <37087370+RickBrice@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:01:56 -0700 Subject: [PATCH] Show resolved unit symbols in read-only Pset/Qto view; add write-back and fallback regression tests Previously, unit symbols only appeared while a Pset/Qto was in edit mode (pencil icon) -- the read-only summary view read raw {name: value} dicts straight from ifcopenshell.util.element.get_psets(), a completely separate path from the Attribute/unit_symbol machinery, so it never showed a label even after the earlier fixes. This matters for the "someone in the field just looking at values" use case, not just editing. - bim/module/pset/data.py: switch to get_psets(verbose=True) to get each property's own entity id, then resolve its unit symbol the same override-aware way the edit-mode path does (tool.Pset.get_unit_symbol_for_prop). Falls back gracefully (empty symbol) for IfcPreDefinedPropertySet attributes, which aren't IfcProperty entities and can't carry a Unit override. - bim/module/pset/ui.py: read-only value button now shows "250 mm" instead of just "250". Also adds the regression tests planned but not yet committed: - test/tool/test_pset.py: edit a property with its own Unit override and write it back, confirming no rescale and the override survives. - test/bim/test_prop.py (new): get_display_name() falls back to the plain name (no crash) when no unit is resolvable or the project has no units assigned at all. --- src/bonsai/bonsai/bim/helper.py | 8 +- src/bonsai/bonsai/bim/module/pset/data.py | 19 +++- src/bonsai/bonsai/bim/module/pset/ui.py | 7 +- src/bonsai/bonsai/bim/prop.py | 48 ++-------- src/bonsai/bonsai/tool/pset.py | 102 +++++++++++++++------- src/bonsai/test/bim/feature/pset.feature | 16 +--- src/bonsai/test/bim/test_prop.py | 79 +++++++++++++++++ src/bonsai/test/tool/test_pset.py | 79 +++++++++++++++++ 8 files changed, 261 insertions(+), 97 deletions(-) create mode 100644 src/bonsai/test/bim/test_prop.py diff --git a/src/bonsai/bonsai/bim/helper.py b/src/bonsai/bonsai/bim/helper.py index ab4a0875ab..18e01a5161 100644 --- a/src/bonsai/bonsai/bim/helper.py +++ b/src/bonsai/bonsai/bim/helper.py @@ -236,11 +236,9 @@ def import_attribute( elif data_type == "integer": new.int_value = 0 if new.is_null else int(data[attribute.name()]) elif data_type == "float": - attribute_type = attribute.type_of_attribute() - if attribute_type._is("IfcLengthMeasure"): - new.special_type = "LENGTH" - elif attribute_type._is("IfcForceMeasure"): - new.special_type = "FORCE" + measure_class = attribute.type_of_attribute().declared_type().name() + new.special_type = tool.Pset.get_special_type_for_measure_class(measure_class) + new.unit_symbol = tool.Pset.get_unit_symbol_for_special_type(new.special_type, tool.Ifc.get()) new.float_value = 0.0 if new.is_null else float(data[attribute.name()]) elif data_type == "enum": attribute_type = attribute.type_of_attribute() diff --git a/src/bonsai/bonsai/bim/module/pset/data.py b/src/bonsai/bonsai/bim/module/pset/data.py index 969f54f387..a4f92f492a 100644 --- a/src/bonsai/bonsai/bim/module/pset/data.py +++ b/src/bonsai/bonsai/bim/module/pset/data.py @@ -54,7 +54,7 @@ class Data: ifc_file = tool.Ifc.get() results = [] psetqtos = ifcopenshell.util.element.get_psets( - element, psets_only=psets_only, qtos_only=qtos_only, should_inherit=False + element, psets_only=psets_only, qtos_only=qtos_only, should_inherit=False, verbose=True ) for name, data in sorted(psetqtos.items()): pset = ifc_file.by_id(data["id"]) @@ -69,13 +69,28 @@ class Data: "id": data["id"], "Name": name, "is_expanded": is_expanded.get(data["id"], True), - "Properties": [{"Name": k, "NominalValue": v} for k, v in sorted(data.items()) if k != "id"], + "Properties": [ + cls.property_display_data(ifc_file, k, v) for k, v in sorted(data.items()) if k != "id" + ], "shared_pset_uses": len(pset_uses), "has_template": has_template, } ) return sorted(results, key=lambda v: v["Name"]) + @classmethod + def property_display_data(cls, ifc_file: ifcopenshell.file, name: str, verbose_value: Any) -> dict[str, Any]: + # Predefined property sets (e.g. IfcDoorPanelProperties) expose plain + # attribute values even in verbose mode, since they're typed IFC + # attributes rather than IfcProperty entities with their own id/Unit. + if not isinstance(verbose_value, dict): + return {"Name": name, "NominalValue": verbose_value, "UnitSymbol": ""} + + unit_symbol = "" + if (prop_id := verbose_value.get("id")) and (prop_entity := ifc_file.by_id(prop_id)): + unit_symbol = tool.Pset.get_unit_symbol_for_prop(prop_entity, ifc_file) + return {"Name": name, "NominalValue": verbose_value["value"], "UnitSymbol": unit_symbol} + @classmethod def format_pset_enum(cls, psets): enum_items = [] diff --git a/src/bonsai/bonsai/bim/module/pset/ui.py b/src/bonsai/bonsai/bim/module/pset/ui.py index df99acd73c..9e5a5ce8bd 100644 --- a/src/bonsai/bonsai/bim/module/pset/ui.py +++ b/src/bonsai/bonsai/bim/module/pset/ui.py @@ -203,9 +203,10 @@ def draw_psetqto_ui( row = box.row(align=True) row.scale_y = 0.8 row.label(text=prop["Name"]) - op = row.operator( - "bim.select_similar", text=get_display_value(nominal_value), icon="NONE", emboss=False - ) + display_value = get_display_value(nominal_value) + if unit_symbol := prop["UnitSymbol"]: + display_value = f"{display_value} {unit_symbol}" + op = row.operator("bim.select_similar", text=display_value, icon="NONE", emboss=False) op.key = '"' + pset["Name"].replace('"', '\\"') + '"."' + prop["Name"].replace('"', '\\"') + '"' # calculate sum of all selected objects if active_operator: diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py index e10243d266..d7ceff89d4 100644 --- a/src/bonsai/bonsai/bim/prop.py +++ b/src/bonsai/bonsai/bim/prop.py @@ -21,7 +21,6 @@ import os from typing import TYPE_CHECKING, Any, Literal, Union, assert_never, get_args import bpy -import ifcopenshell.util.unit from bpy.props import ( BoolProperty, CollectionProperty, @@ -250,44 +249,19 @@ def set_numerical_value(self: "Attribute", value_name: str, new_value: Union[flo self[value_name] = new_value -def get_length_value(self: "Attribute") -> float: - si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - return self.float_value * si_conversion - - -def set_length_value(self: "Attribute", value: float) -> None: - si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - self.float_value = value / si_conversion - - def get_display_name(self: "Attribute") -> str: - DISPLAY_UNIT_TYPES = ("AREA", "VOLUME", "FORCE") name = self.name - if not self.special_type or self.special_type not in DISPLAY_UNIT_TYPES: + if not self.unit_symbol: return name - - unit_type = f"{self.special_type}UNIT" - project_unit = ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), unit_type) - if not project_unit: - return name - - unit_symbol = ifcopenshell.util.unit.get_unit_symbol(project_unit) - return f"{name}, {unit_symbol}" + return f"{name}, {self.unit_symbol}" AttributeDataType = Literal["string", "integer", "float", "boolean", "enum", "file", "list[string]"] -AttributeSpecialType = Literal[ - "", - "DATE", - "DATETIME", - "LENGTH", - "AREA", - "VOLUME", - "FORCE", - "LOGICAL", - "URI", - "DURATION", -] +# Either "", "DATE", "DATETIME", "LOGICAL", "URI", "DURATION", or an +# IfcUnitEnum/IfcDerivedUnitEnum value with the "UNIT" suffix stripped (e.g. +# "LENGTH", "PRESSURE", "MODULUSOFELASTICITY") as returned by +# tool.Pset.get_special_type_for_prop(). +AttributeSpecialType = str class Attribute(PropertyGroup): @@ -318,9 +292,6 @@ class Attribute(PropertyGroup): get=lambda self: float(self.get("float_value", 0.0)), set=set_float_value, ) - length_value: FloatProperty( - name="Value", description=tooltip, get=get_length_value, set=set_length_value, unit="LENGTH" - ) enum_items: StringProperty(name="Value") """Json serialized mapping of enum items: Typically a dictionary of string identifiers to item names. @@ -342,6 +313,7 @@ class Attribute(PropertyGroup): value_max: FloatProperty(description="This is used to validate int_value and float_value") value_max_constraint: BoolProperty(default=False, description="True if the numerical value has an upper bound") special_type: StringProperty(name="Special Value Type", default="") + unit_symbol: StringProperty(name="Unit Symbol", default="") 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") @@ -357,7 +329,6 @@ class Attribute(PropertyGroup): bool_value: bool int_value: int float_value: float - length_value: float enum_items: str enum_items_dynamic: str enum_descriptions: bpy.types.bpy_prop_collection_idprop[StrProperty] @@ -373,6 +344,7 @@ class Attribute(PropertyGroup): value_min_constraint: bool value_max: float value_max_constraint: bool + unit_symbol: str use_explorer_ui: bool metadata: str update: str @@ -430,8 +402,6 @@ class Attribute(PropertyGroup): elif data_type == "integer": return "int_value" elif data_type == "float": - if display_only and self.special_type == "LENGTH": - return "length_value" return "float_value" elif data_type == "enum": return "enum_value" diff --git a/src/bonsai/bonsai/tool/pset.py b/src/bonsai/bonsai/tool/pset.py index 47d5155eee..8f903ccd6d 100644 --- a/src/bonsai/bonsai/tool/pset.py +++ b/src/bonsai/bonsai/tool/pset.py @@ -26,6 +26,7 @@ import ifcopenshell import ifcopenshell.api.pset import ifcopenshell.util.attribute import ifcopenshell.util.element +import ifcopenshell.util.unit import bonsai.bim.helper import bonsai.bim.schema @@ -168,42 +169,72 @@ class Pset(bonsai.core.tool.Pset): pset_id=0, pset_name=cls.get_pset_name(obj, obj_type), pset_type="PSET", obj=obj, obj_type=obj_type ) + # Templates for quantities can specify their kind via TemplateType (e.g. + # "Q_LENGTH") instead of PrimaryMeasureType. IfcQuantityCount has no + # associated measure/unit, so it is intentionally absent here. + QUANTITY_TEMPLATE_TYPE_TO_SPECIAL_TYPE = { + "Q_LENGTH": "LENGTH", + "Q_AREA": "AREA", + "Q_VOLUME": "VOLUME", + "Q_WEIGHT": "MASS", + "Q_TIME": "TIME", + } + @classmethod - def get_special_type_for_prop( - cls, prop_or_prop_template: ifcopenshell.entity_instance - ) -> Literal["LENGTH"] | Literal["AREA"] | Literal["VOLUME"] | Literal["URI"] | Literal[""]: - special_type = "" + def get_special_type_for_measure_class(cls, measure_class: str) -> str: + """Get the ``special_type`` (an IfcUnitEnum value with "UNIT" stripped) for an IFC measure class. + + :param measure_class: An IFC measure class name, e.g. "IfcLengthMeasure". + :return: E.g. "LENGTH", or "" if the class has no associated unit type. + """ + if not measure_class.endswith("Measure"): + return "" + 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_prop(cls, prop_or_prop_template: ifcopenshell.entity_instance) -> str: + """Classify a property/quantity/template by its measure type. + + :return: An IfcUnitEnum value with the "UNIT" suffix stripped (e.g. + "LENGTH", "PRESSURE"), "URI" for IfcURIReference, or "" if the + value has no associated unit type. + """ if prop_or_prop_template.is_a("IfcPropertyTemplate"): primary_measure_type = prop_or_prop_template.PrimaryMeasureType - template_type = prop_or_prop_template.TemplateType - if primary_measure_type in ("IfcPositiveLengthMeasure", "IfcLengthMeasure") or template_type == "Q_LENGTH": - special_type = "LENGTH" - elif primary_measure_type == "IfcAreaMeasure" or template_type == "Q_AREA": - special_type = "AREA" - elif primary_measure_type == "IfcVolumeMeasure" or template_type == "Q_VOLUME": - special_type = "VOLUME" - elif primary_measure_type == "IfcURIReference": - special_type = "URI" - else: - if prop_or_prop_template.is_a("IfcPropertySingleValue"): - value = prop_or_prop_template.NominalValue - if value is not None: - value_type = value.is_a() - if value_type in ("IfcLengthMeasure", "IfcPositiveLengthMeasure"): - special_type = "LENGTH" - elif value_type == "IfcAreaMeasure": - special_type = "AREA" - elif value_type == "IfcVolumeMeasure": - special_type = "VOLUME" - elif prop_or_prop_template.is_a("IfcPhysicalSimpleQuantity"): - prop_class = prop_or_prop_template.is_a() - if prop_class == "IfcQuantityArea": - special_type = "AREA" - elif prop_class == "IfcQuantityVolume": - special_type = "VOLUME" - elif prop_class == "IfcQuantityLength": - special_type = "LENGTH" - return special_type + if primary_measure_type == "IfcURIReference": + return "URI" + if primary_measure_type: + return cls.get_special_type_for_measure_class(primary_measure_type) + return cls.QUANTITY_TEMPLATE_TYPE_TO_SPECIAL_TYPE.get(prop_or_prop_template.TemplateType, "") + 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()) + 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() + return cls.get_special_type_for_measure_class(measure_class) + return "" + + @classmethod + def get_unit_symbol_for_special_type(cls, special_type: str, ifc_file: ifcopenshell.file) -> str: + """Get the project's default unit symbol for a `special_type` (see `get_special_type_for_prop`). + + Used where there's no property instance to check for a `Unit` override + (e.g. a template, or a native IFC entity attribute, neither of which + can carry one). + """ + if not special_type or special_type == "URI": + return "" + unit = ifcopenshell.util.unit.get_project_unit(ifc_file, f"{special_type}UNIT") + return ifcopenshell.util.unit.get_unit_symbol(unit) if unit else "" + + @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.""" + unit = ifcopenshell.util.unit.get_property_unit(prop, ifc_file) + return ifcopenshell.util.unit.get_unit_symbol(unit) if unit else "" @classmethod def import_pset_from_existing( @@ -283,6 +314,7 @@ class Pset(bonsai.core.tool.Pset): metadata.is_null = value is None metadata.is_optional = True metadata.special_type = cls.get_special_type_for_prop(prop) + metadata.unit_symbol = cls.get_unit_symbol_for_prop(prop, tool.Ifc.get()) metadata.set_value(metadata.get_value_default() if metadata.is_null else value) process_prop_description(metadata) @@ -360,6 +392,7 @@ class Pset(bonsai.core.tool.Pset): metadata.is_optional = True metadata.data_type = cls.get_prop_template_primitive_type(prop_template) metadata.special_type = cls.get_special_type_for_prop(prop_template) + metadata.unit_symbol = cls.get_unit_symbol_for_special_type(metadata.special_type, tool.Ifc.get()) if metadata.data_type == "string": metadata.string_value = "" if metadata.is_null else str(data[prop_template.Name]) @@ -409,6 +442,8 @@ class Pset(bonsai.core.tool.Pset): cls.import_single_value_from_template(pset_template, prop_template, simplified_data, props) elif prop_template.TemplateType.startswith("Q_"): + if prop_data: + continue # Existing quantity will be added later by import_pset_from_existing. cls.import_single_value_from_template(pset_template, prop_template, simplified_data, props) elif prop_template.TemplateType == "P_ENUMERATEDVALUE": @@ -470,6 +505,7 @@ class Pset(bonsai.core.tool.Pset): metadata.is_null = value is None metadata.is_optional = True metadata.special_type = special_type + metadata.unit_symbol = cls.get_unit_symbol_for_special_type(special_type, tool.Ifc.get()) metadata.set_value(metadata.get_value_default() if metadata.is_null else value) @classmethod diff --git a/src/bonsai/test/bim/feature/pset.feature b/src/bonsai/test/bim/feature/pset.feature index aef379bf5c..01c577c1ff 100644 --- a/src/bonsai/test/bim/feature/pset.feature +++ b/src/bonsai/test/bim/feature/pset.feature @@ -361,31 +361,22 @@ Scenario: Edit pset length property Given an empty IFC project And I press "mesh.add_stair" And the variable "pset" is "tool.Pset.get_element_pset(tool.Ifc.get_entity(bpy.context.active_object), 'Pset_StairFlightCommon').id()" - And the variable "si_conversion" is "ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())" And I press "bim.enable_pset_editing(pset_id={pset}, obj='IfcStairFlight/StairFlight', obj_type='Object')" # Testing IfcPositiveLengthMeasure type of prop Then "active_object.PsetProperties.properties['TreadLength'].metadata.special_type" is "LENGTH" And "active_object.PsetProperties.properties['TreadLength'].metadata.float_value" is "250" - And "active_object.PsetProperties.properties['TreadLength'].metadata.length_value" is roughly "0.25" When I set "active_object.PsetProperties.properties['TreadLength'].metadata.float_value" to "350" Then "active_object.PsetProperties.properties['TreadLength'].metadata.float_value" is roughly "350" - When I set "active_object.PsetProperties.properties['TreadLength'].metadata.length_value" to "0.45" - Then "active_object.PsetProperties.properties['TreadLength'].metadata.float_value" is roughly "450" - # Testing IfcLengthMeasure type of prop Then "active_object.PsetProperties.properties['NosingLength'].metadata.special_type" is "LENGTH" And "active_object.PsetProperties.properties['NosingLength'].metadata.float_value" is "0.0" - And "active_object.PsetProperties.properties['NosingLength'].metadata.length_value" is roughly "0.0" When I set "active_object.PsetProperties.properties['NosingLength'].metadata.float_value" to "350" Then "active_object.PsetProperties.properties['NosingLength'].metadata.float_value" is roughly "350" - When I set "active_object.PsetProperties.properties['NosingLength'].metadata.length_value" to "0.45" - Then "active_object.PsetProperties.properties['NosingLength'].metadata.float_value" is roughly "450" - When I press "bim.edit_pset(obj='IfcStairFlight/StairFlight', obj_type='Object')" Then nothing happens @@ -394,19 +385,14 @@ Scenario: Edit qset length property And I press "mesh.add_stair" And I press "bim.perform_quantity_take_off" And the variable "pset" is "tool.Pset.get_element_pset(tool.Ifc.get_entity(bpy.context.active_object), 'Qto_StairFlightBaseQuantities').id()" - And the variable "si_conversion" is "ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())" And I press "bim.enable_pset_editing(pset_id={pset}, obj='IfcStairFlight/StairFlight', obj_type='Object')" # Testing Q_LENGTH type of prop Then "active_object.PsetProperties.properties['Length'].metadata.special_type" is "LENGTH" And "active_object.PsetProperties.properties['Length'].metadata.float_value" is roughly "2156.485" - And "active_object.PsetProperties.properties['Length'].metadata.length_value" is roughly "2.156" When I set "active_object.PsetProperties.properties['Length'].metadata.float_value" to "350" - Then "active_object.PsetProperties.properties['Length'].metadata.length_value" is roughly "0.35" - - When I set "active_object.PsetProperties.properties['Length'].metadata.length_value" to "0.45" - Then "active_object.PsetProperties.properties['Length'].metadata.float_value" is roughly "450" + Then "active_object.PsetProperties.properties['Length'].metadata.float_value" is roughly "350" When I press "bim.edit_pset(obj='IfcStairFlight/StairFlight', obj_type='Object')" Then nothing happens diff --git a/src/bonsai/test/bim/test_prop.py b/src/bonsai/test/bim/test_prop.py new file mode 100644 index 0000000000..2413cc6c7d --- /dev/null +++ b/src/bonsai/test/bim/test_prop.py @@ -0,0 +1,79 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + +import bpy +import ifcopenshell +import ifcopenshell.api.pset +import ifcopenshell.api.root +import ifcopenshell.api.unit + +import bonsai.tool as tool +from test.bim.bootstrap import NewFile + + +def import_single_property(ifc, element, prop): + """Import a single existing IfcProperty into a real, addon-registered + PsetProperties collection, exactly as the property editor does, and + return its `metadata` (an `Attribute`).""" + pset = ifcopenshell.api.pset.add_pset(ifc, product=element, name="Pset_Test") + pset.HasProperties = [prop] + obj = bpy.data.objects.new(prop.Name, None) + tool.Ifc.link(element, obj) + props = obj.PsetProperties + tool.Pset.import_pset_from_existing(pset, props, None) + return props.properties[prop.Name].metadata + + +class TestGetDisplayName(NewFile): + def test_appends_the_resolved_unit_symbol(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject") + pressure = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="PRESSUREUNIT") + ifcopenshell.api.unit.assign_unit(ifc, units=[pressure]) + + element = ifc.createIfcWall() + prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcPressureMeasure(5.0)) + metadata = import_single_property(ifc, element, prop) + + assert metadata.display_name == "Foo, Pa" + + def test_falls_back_to_the_plain_name_when_no_unit_is_resolvable(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject") + # No units assigned to the project at all -- nothing to resolve. + + element = ifc.createIfcWall() + prop = ifc.createIfcPropertySingleValue(Name="Foo", NominalValue=ifc.createIfcPressureMeasure(5.0)) + metadata = import_single_property(ifc, element, prop) + + assert metadata.unit_symbol == "" + assert metadata.display_name == "Foo" + + def test_falls_back_to_the_plain_name_for_a_non_measure_property(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) + + assert metadata.unit_symbol == "" + assert metadata.display_name == "Foo" diff --git a/src/bonsai/test/tool/test_pset.py b/src/bonsai/test/tool/test_pset.py index 9b01105748..32482b2bf4 100644 --- a/src/bonsai/test/tool/test_pset.py +++ b/src/bonsai/test/tool/test_pset.py @@ -20,6 +20,8 @@ import bpy import ifcopenshell import ifcopenshell.api import ifcopenshell.api.pset +import ifcopenshell.api.root +import ifcopenshell.api.unit import bonsai.core.tool import bonsai.tool as tool @@ -52,3 +54,80 @@ class TestIsPsetEmpty(NewFile): assert subject.is_pset_empty(pset) is False ifcopenshell.api.pset.edit_pset(ifc, pset=pset, properties={"Foo": None}) assert subject.is_pset_empty(pset) is True + + +class TestEditingAnOverriddenUnitPropertyRoundTrips(NewFile): + def test_run(self): + # Project default is mm, but this property is authored directly in m. + 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_symbol == "m" + assert metadata.float_value == 2.5 # raw stored value, not rescaled to the project's mm + + # Simulate a user edit in the property editor. + metadata.float_value = 3.5 + + # Simulate what EditPset.execute() does: collect the raw value straight + # off the metadata and write it back, with no rescaling step. + properties = {"Foo": metadata.get_value()} + ifcopenshell.api.pset.edit_pset(ifc, pset=pset, properties=properties) + + assert prop.NominalValue.wrappedValue == 3.5 # not rescaled to 3500mm + assert prop.Unit == length_m # override preserved + + +class TestImportingATemplatedQuantityRespectsItsOwnUnitOverride(NewFile): + def test_run(self): + # Regression test: import_pset_from_template's Q_ branch used to + # unconditionally re-template existing quantities, which shadowed + # their own Unit override with the project default -- edit mode + # showed "m" while the read-only panel correctly showed "mm". + # Project default is m, but this quantity is authored directly in mm. + 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_mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI") + + element = ifc.createIfcBeam() + qto = ifcopenshell.api.pset.add_qto(ifc, product=element, name="Qto_Test") + quantity = ifc.createIfcQuantityLength(Name="Foo", Unit=length_mm, LengthValue=2500.0) + qto.Quantities = [quantity] + + pset_template = ifc.createIfcPropertySetTemplate( + Name="Qto_Test", + TemplateType="PSET_TYPEDRIVENOVERRIDE", + ApplicableEntity="IfcBeam", + HasPropertyTemplates=[ifc.createIfcSimplePropertyTemplate(Name="Foo", TemplateType="Q_LENGTH")], + ) + + obj = bpy.data.objects.new("Beam", None) + tool.Ifc.link(element, obj) + blender_props = obj.PsetProperties + # Mirrors core/pset.py's enable_pset_editing: template pass, then existing-data pass. + subject.import_pset_from_template(pset_template, qto, blender_props) + subject.import_pset_from_existing(qto, blender_props, pset_template) + + assert len(blender_props.properties) == 1 # not duplicated by the template pass + 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