diff --git a/src/ifcopenshell-python/ifcopenshell/util/unit.py b/src/ifcopenshell-python/ifcopenshell/util/unit.py index 20acca3e5b..e3c82b812b 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/unit.py +++ b/src/ifcopenshell-python/ifcopenshell/util/unit.py @@ -466,7 +466,16 @@ def cache_units(ifc_file: ifcopenshell.file) -> None: """ ifc_file.units = {} if assignment := get_unit_assignment(ifc_file): - ifc_file.units = {u.UnitType: u for u in assignment.Units if getattr(u, "UnitType", None)} + all_units = assignment.Units or [] + units = {u.UnitType: u for u in all_units if getattr(u, "UnitType", None)} + # As in get_project_unit(): a literal match always wins; an IfcDerivedUnit with no + # literal UnitType match is matched dimensionally instead, only to fill a gap. + for unit in all_units: + if unit.is_a("IfcDerivedUnit"): + dimension = identify_unit_dimensions(unit) + if dimension and dimension not in units: + units[dimension] = unit + ifc_file.units = units def clear_unit_cache(ifc_file: ifcopenshell.file) -> None: @@ -482,6 +491,10 @@ def get_project_unit( ) -> Union[ifcopenshell.entity_instance, None]: """Get the default project unit of a particular unit type + IfcDerivedUnit is matched first by a literal `UnitType` match, then, as a fallback, by + dimensional analysis (:func:`identify_unit_dimensions`), mirroring + :func:`get_candidate_units`. + :param ifc_file: The IFC file. :param unit_type: The type of unit, taken from the list of IFC unit types, such as "LENGTHUNIT". @@ -493,9 +506,13 @@ def get_project_unit( if units := ifc_file.units: return units.get(unit_type, None) if unit_assignment := get_unit_assignment(ifc_file): + dimensional_match = None for unit in unit_assignment.Units or []: if getattr(unit, "UnitType", None) == unit_type: return unit + if dimensional_match is None and unit.is_a("IfcDerivedUnit") and identify_unit_dimensions(unit) == unit_type: + dimensional_match = unit + return dimensional_match def get_candidate_units(ifc_file: ifcopenshell.file, unit_type: str) -> list[ifcopenshell.entity_instance]: diff --git a/src/ifcopenshell-python/test/util/test_unit.py b/src/ifcopenshell-python/test/util/test_unit.py index dd304fd77b..26daad34b6 100644 --- a/src/ifcopenshell-python/test/util/test_unit.py +++ b/src/ifcopenshell-python/test/util/test_unit.py @@ -94,6 +94,37 @@ class TestGetProjectUnit(test.bootstrap.IFC4): assert subject.get_project_unit(self.file, "LENGTHUNIT", use_cache=True) == length2 assert self.file.units == {"LENGTHUNIT": length2, "AREAUNIT": area} + def test_area_and_volume_derived_from_length_are_matched_dimensionally(self): + # AREAUNIT/VOLUMEUNIT have no IfcDerivedUnitEnum member, so a project whose area/volume + # default is an IfcDerivedUnit has no literal UnitType match for either -- get_project_unit + # must still resolve them by dimensional analysis, like get_candidate_units already does. + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT") + area = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "area-ish", {length: 2}) + volume = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "volume-ish", {length: 3}) + ifcopenshell.api.unit.assign_unit(self.file, units=[length, area, volume]) + + assert subject.get_project_unit(self.file, "AREAUNIT") == area + assert subject.get_project_unit(self.file, "VOLUMEUNIT") == volume + + def test_literal_unit_type_match_takes_priority_over_dimensional(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT") + literal_area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT") + derived_area = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "area-ish", {length: 2}) + ifcopenshell.api.unit.assign_unit(self.file, units=[length, literal_area, derived_area]) + + assert subject.get_project_unit(self.file, "AREAUNIT") == literal_area + + def test_dimensional_fallback_also_applies_when_using_a_cache(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT") + area = ifcopenshell.api.unit.add_derived_unit(self.file, "USERDEFINED", "area-ish", {length: 2}) + ifcopenshell.api.unit.assign_unit(self.file, units=[length, area]) + + assert subject.get_project_unit(self.file, "AREAUNIT", use_cache=True) == area + assert self.file.units["AREAUNIT"] == area + class TestGetCandidateUnits(test.bootstrap.IFC4): def test_returns_only_units_matching_the_unit_type(self):