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