Files
IfcOpenShell/src/ifcopenshell-python/test/util/test_unit.py
T
Petru Conduraru bade0647e8 util.unit: scale RepresentationContext Precision on unit conversion #6127
IfcGeometricRepresentationContext.Precision is typed as a plain IfcReal
but is interpreted in the project length unit, so the IfcLengthMeasure
traversal in convert_file_length_units never touched it. A model
converted from mm to m kept a Precision of e.g. 0.01 (fine in mm, huge
in m), which breaks downstream geometry interpretation such as
IfcConvert boolean cleanup.

Subcontexts derive Precision from their parent, so only root contexts
are scaled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:21:25 +02:00

425 lines
23 KiB
Python

# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2023 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcOpenShell.
#
# IfcOpenShell is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcOpenShell 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 Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
from math import pi
import numpy as np
import pytest
import ifcopenshell.api.context
import ifcopenshell.api.georeference
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.api.unit
import ifcopenshell.util.element
import ifcopenshell.util.geolocation
import ifcopenshell.util.unit as subject
import test.bootstrap
from ifcopenshell.util.shape_builder import ShapeBuilder
class TestMmToM:
def test_converts_a_positive_value(self):
assert subject.mm_to_m(150) == 0.15
def test_returns_zero_for_zero(self):
assert subject.mm_to_m(0) == 0.0
def test_passes_through_negative_values(self):
assert subject.mm_to_m(-25) == -0.025
class TestCacheUnits(test.bootstrap.IFC4):
def test_run(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
ifcopenshell.api.unit.assign_unit(self.file, units=[length, area])
assert self.file.units == {}
subject.cache_units(self.file)
assert self.file.units == {"LENGTHUNIT": length, "AREAUNIT": area}
class TestClearUnitCache(test.bootstrap.IFC4):
def test_run(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
ifcopenshell.api.unit.assign_unit(self.file, units=[length, area])
subject.cache_units(self.file)
subject.clear_unit_cache(self.file)
assert self.file.units == {}
class TestGetProjectUnit(test.bootstrap.IFC4):
def test_run(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
ifcopenshell.api.unit.assign_unit(self.file, units=[length, area])
assert subject.get_project_unit(self.file, "LENGTHUNIT") == length
assert subject.get_project_unit(self.file, "AREAUNIT") == area
def test_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", prefix="MILLI")
length2 = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="CENTI")
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
ifcopenshell.api.unit.assign_unit(self.file, units=[length, area])
assert self.file.units == {}
assert subject.get_project_unit(self.file, "LENGTHUNIT", use_cache=True) == length
assert self.file.units == {"LENGTHUNIT": length, "AREAUNIT": area}
ifcopenshell.api.unit.assign_unit(self.file, units=[length2])
assert subject.get_project_unit(self.file, "LENGTHUNIT", use_cache=True) == length
subject.clear_unit_cache(self.file)
assert subject.get_project_unit(self.file, "LENGTHUNIT", use_cache=True) == length2
assert self.file.units == {"LENGTHUNIT": length2, "AREAUNIT": area}
class TestGetPropertyUnit(test.bootstrap.IFC4):
def test_no_unit(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
prop = self.file.createIfcQuantityLength(Name="Foo", LengthValue=42.0)
assert subject.get_property_unit(prop, self.file) is None
def test_simple_quantity(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
length2 = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="CENTI")
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
ifcopenshell.api.unit.assign_unit(self.file, units=[length, area])
prop = self.file.createIfcQuantityLength(Name="Foo", LengthValue=42.0)
assert subject.get_property_unit(prop, self.file) == length
prop.Unit = length2
assert subject.get_property_unit(prop, self.file) == length2
def test_single_value(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
length2 = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="CENTI")
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
ifcopenshell.api.unit.assign_unit(self.file, units=[length, area])
prop = self.file.createIfcPropertySingleValue(Name="Foo", NominalValue=self.file.createIfcLengthMeasure(42.0))
assert subject.get_property_unit(prop, self.file) == length
prop.Unit = length2
assert subject.get_property_unit(prop, self.file) == length2
def test_enumerated_value(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
length2 = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="CENTI")
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
ifcopenshell.api.unit.assign_unit(self.file, units=[length, area])
prop = self.file.createIfcPropertyEnumeratedValue(
Name="Foo", EnumerationValues=[self.file.createIfcLengthMeasure(42.0)]
)
assert subject.get_property_unit(prop, self.file) == length
prop.EnumerationValues = []
prop.EnumerationReference = self.file.createIfcPropertyEnumeration(
"Foo", [self.file.createIfcAreaMeasure(42.0)]
)
assert subject.get_property_unit(prop, self.file) == area
prop.EnumerationReference = self.file.createIfcPropertyEnumeration(
"Foo", [self.file.createIfcAreaMeasure(42.0)], Unit=length2
)
assert subject.get_property_unit(prop, self.file) == length2
prop.EnumerationValues = [self.file.createIfcAreaMeasure(42.0)]
assert subject.get_property_unit(prop, self.file) == length2
prop.EnumerationReference.Unit = None
assert subject.get_property_unit(prop, self.file) == area
def test_list_value(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
length2 = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="CENTI")
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
ifcopenshell.api.unit.assign_unit(self.file, units=[length, area])
prop = self.file.createIfcPropertyListValue(Name="Foo", ListValues=[self.file.createIfcLengthMeasure(42.0)])
assert subject.get_property_unit(prop, self.file) == length
prop.Unit = length2
assert subject.get_property_unit(prop, self.file) == length2
prop.Unit = None
prop.ListValues = []
assert subject.get_property_unit(prop, self.file) is None
def test_bounded_value(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
length = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
length2 = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="CENTI")
area = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="AREAUNIT")
ifcopenshell.api.unit.assign_unit(self.file, units=[length, area])
prop = self.file.createIfcPropertyBoundedValue(Name="Foo")
assert subject.get_property_unit(prop, self.file) is None
prop.UpperBoundValue = self.file.createIfcLengthMeasure(42.0)
assert subject.get_property_unit(prop, self.file) == length
prop.UpperBoundValue = None
prop.LowerBoundValue = self.file.createIfcLengthMeasure(42.0)
assert subject.get_property_unit(prop, self.file) == length
prop.LowerBoundValue = None
prop.SetPointValue = self.file.createIfcLengthMeasure(42.0)
assert subject.get_property_unit(prop, self.file) == length
prop.Unit = length2
assert subject.get_property_unit(prop, self.file) == length2
class TestConvert(test.bootstrap.IFC4):
def test_run(self):
assert subject.convert(1, None, "METRE", None, "METRE") == 1
assert subject.convert(1, None, "METRE", "MILLI", "METRE") == 1000
assert subject.convert(1000, "MILLI", "METRE", None, "METRE") == 1
assert subject.convert(1, None, "SQUARE_METRE", None, "SQUARE_METRE") == 1
assert subject.convert(1, None, "SQUARE_METRE", "MILLI", "SQUARE_METRE") == 1000000
assert subject.convert(1, None, "CUBIC_METRE", "MILLI", "CUBIC_METRE") == 1000000000
class TestCalculateUnitScale(test.bootstrap.IFC4):
def test_prefix_and_conversion_based_units_are_considered(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
length = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="foot")
length.ConversionFactor.UnitComponent.Prefix = "MILLI"
ifcopenshell.api.unit.assign_unit(self.file, units=[length])
assert subject.calculate_unit_scale(self.file) == 0.3048 * 0.001
angle = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="degree")
angle.ConversionFactor.UnitComponent.Prefix = "MILLI"
ifcopenshell.api.unit.assign_unit(self.file, units=[angle])
assert subject.calculate_unit_scale(self.file, "PLANEANGLEUNIT") == pi / 180 * 0.001
class TestFormatLength(test.bootstrap.IFC4):
def test_run(self):
assert subject.format_length(1, 1, decimal_places=0, unit_system="metric") == "1"
assert subject.format_length(1, 1, decimal_places=2, unit_system="metric") == "1.00"
assert subject.format_length(3, 5, decimal_places=2, unit_system="metric") == "5.00"
assert subject.format_length(3.123, 0.01, decimal_places=2, unit_system="metric") == "3.12"
assert subject.format_length(3, 1, unit_system="imperial", input_unit="foot") == "3'"
assert subject.format_length(3.5, 1, unit_system="imperial", input_unit="foot") == "3' - 6\""
assert subject.format_length(3.123, 1, unit_system="imperial", input_unit="foot") == "3' - 1\""
assert subject.format_length(3.123, 2, unit_system="imperial", input_unit="foot") == "3' - 1 1/2\""
assert subject.format_length(3.123, 4, unit_system="imperial", input_unit="foot") == "3' - 1 1/2\""
assert subject.format_length(3.123, 32, unit_system="imperial", input_unit="foot") == "3' - 1 15/32\""
assert subject.format_length(24, 1, unit_system="imperial", input_unit="inch") == "2'"
assert subject.format_length(25.23, 1, unit_system="imperial", input_unit="inch") == "2' - 1\""
assert subject.format_length(25.23, 4, unit_system="imperial", input_unit="inch") == "2' - 1 1/4\""
assert subject.format_length(3, 1, unit_system="imperial", input_unit="foot", output_unit="inch") == '36"'
assert subject.format_length(3.5, 1, unit_system="imperial", input_unit="foot", output_unit="inch") == '42"'
assert subject.format_length(3.123, 1, unit_system="imperial", input_unit="foot", output_unit="inch") == '37"'
assert (
subject.format_length(3.123, 2, unit_system="imperial", input_unit="foot", output_unit="inch") == '37 1/2"'
)
assert (
subject.format_length(3.123, 4, unit_system="imperial", input_unit="foot", output_unit="inch") == '37 1/2"'
)
assert (
subject.format_length(3.123, 32, unit_system="imperial", input_unit="foot", output_unit="inch")
== '37 15/32"'
)
assert subject.format_length(24, 1, unit_system="imperial", input_unit="inch", output_unit="inch") == '24"'
assert subject.format_length(25.23, 1, unit_system="imperial", input_unit="inch", output_unit="inch") == '25"'
assert (
subject.format_length(25.23, 4, unit_system="imperial", input_unit="inch", output_unit="inch") == '25 1/4"'
)
class TestIsAttrType(test.bootstrap.IFC4):
def test_run(self):
schema = ifcopenshell.schema_by_name("IFC4")
declaration = schema.declaration_by_name("IfcPropertySingleValue").as_entity()
assert declaration
nominal_value = declaration.attribute_by_index(2).type_of_attribute()
assert subject.is_attr_type(nominal_value, "IfcValue")
assert subject.is_attr_type(nominal_value, "IfcLengthMeasure")
assert not subject.is_attr_type(nominal_value, "IfcLengthMeasure", include_select_types=False)
class TestConvertFileLengthUnits(test.bootstrap.IFC2X3):
def test_run(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
ifcopenshell.api.unit.assign_unit(self.file, units=[unit])
output = subject.convert_file_length_units(self.file, target_units="METER")
# there was some renumbering bug in the rocksdb rewrite this statement is to test for that
assert max(i.id() for i in output) == len(output.wrapped_data.entity_names()) + 1
assert subject.get_full_unit_name(subject.get_project_unit(output, "LENGTHUNIT")) == "METRE"
def test_precision_conversion(self):
# Regression test for #6127: IfcGeometricRepresentationContext.Precision
# is typed IfcReal but interpreted in the project length unit, so it must
# be scaled along with the length measures.
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
ifcopenshell.api.unit.assign_unit(self.file, units=[unit])
context = ifcopenshell.api.context.add_context(self.file, context_type="Model")
context.Precision = 0.01
# Subcontexts derive Precision from the parent and must be left alone.
ifcopenshell.api.context.add_context(
self.file, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=context
)
output = subject.convert_file_length_units(self.file, target_units="METER")
new_context = output.by_type("IfcGeometricRepresentationContext", include_subtypes=False)[0]
assert new_context.Precision == pytest.approx(0.00001)
def test_attribute_conversion(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
builder = ShapeBuilder(self.file)
rectangle = builder.rectangle((100, 100))
extrusion = builder.extrude(rectangle, 1000)
# IfcLengthMeasure entities.
product = ifcopenshell.api.root.create_entity(self.file, "IfcWall")
pset = ifcopenshell.api.pset.add_pset(self.file, product, "TestPset")
# Consider weird case when same entity is reused.
length_measure = self.file.create_entity("IfcLengthMeasure", 50.0)
enum_property = self.file.create_entity(
"IfcPropertyEnumeratedValue",
Name="Enum",
# Not entirely sure if there are real life cases when mixed typed entities used in the list
# but just to be safe.
EnumerationValues=[
length_measure,
self.file.create_entity("IfcLabel", "TEXT"),
self.file.create_entity("IfcLengthMeasure", 250.0),
length_measure,
],
)
ifcopenshell.api.pset.edit_pset(
self.file,
pset,
properties={
"Length": self.file.create_entity("IfcLengthMeasure", 25.0),
"Enum": enum_property,
},
)
ifcopenshell.api.unit.assign_unit(self.file, units=[unit])
output = subject.convert_file_length_units(self.file, target_units="METER")
assert subject.get_full_unit_name(subject.get_project_unit(output, "LENGTHUNIT")) == "METRE"
extrusion = output.by_type("IfcExtrudedAreaSolid")[0]
# Simple float attribute.
assert extrusion.Depth == 1
# List of floats in IFC2X3 and list of lists of floats in IFC4+.
rectangle = extrusion.SweptArea.OuterCurve
expected_points = [(0.0, 0.0), (0.1, 0.0), (0.1, 0.1), (0.0, 0.1)]
if self.file.schema == "IFC2X3":
# IfcPolyline.
points = [p.Coordinates for p in rectangle.Points]
expected_points += expected_points[:1]
else:
# IfcIndexedPolyCurve.
points = rectangle.Points.CoordList
assert np.allclose(points, expected_points)
# IfcLengthMeasure entities.
product = output.by_type("IfcWall")[0]
pset_data = ifcopenshell.util.element.get_pset(product, "TestPset")
assert pset_data["Length"] == 0.025
assert pset_data["Enum"] == [0.05, "TEXT", 0.25, 0.05]
def test_converting_map_conversion_if_there_is_no_map_unit(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
ifcopenshell.api.context.add_context(self.file, "Model")
ifcopenshell.api.georeference.add_georeferencing(self.file)
ifcopenshell.api.georeference.edit_georeferencing(self.file, coordinate_operation={"Eastings": 10000})
ifcopenshell.api.unit.assign_unit(self.file, units=[unit])
output = subject.convert_file_length_units(self.file, target_units="METER")
assert subject.get_full_unit_name(subject.get_project_unit(output, "LENGTHUNIT")) == "METRE"
if self.file.schema == "IFC2X3":
parameters = ifcopenshell.util.geolocation.get_helmert_transformation_parameters(output)
assert parameters
assert parameters.e == 10
else:
assert output.by_type("IfcMapConversion")[0].Eastings == 10
def test_preserving_enh_if_there_is_a_map_unit(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI")
meter = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
ifcopenshell.api.context.add_context(self.file, "Model")
ifcopenshell.api.georeference.add_georeferencing(self.file)
map_unit = subject.get_full_unit_name(meter) if self.file.schema == "IFC2X3" else meter
ifcopenshell.api.georeference.edit_georeferencing(
self.file,
projected_crs={"MapUnit": map_unit},
coordinate_operation={"Eastings": 10, "Scale": 0.001},
)
ifcopenshell.api.unit.assign_unit(self.file, units=[unit])
output = subject.convert_file_length_units(self.file, target_units="METER")
assert subject.get_full_unit_name(subject.get_project_unit(output, "LENGTHUNIT")) == "METRE"
if self.file.schema == "IFC2X3":
parameters = ifcopenshell.util.geolocation.get_helmert_transformation_parameters(output)
assert parameters
assert parameters.e == 10
assert parameters.n == 0
assert parameters.scale == 1
crs = ifcopenshell.util.element.get_pset(output.by_type("IfcProject")[0], name="ePSet_ProjectedCRS")
assert crs["MapUnit"] == "METRE"
else:
map_conversion = output.by_type("IfcMapConversion")[0]
assert map_conversion.Eastings == 10
assert map_conversion.Northings == 0
assert map_conversion.Scale == 1
assert subject.get_full_unit_name(output.by_type("IfcProjectedCRS")[0].MapUnit) == "METRE"
def test_preserving_enh_if_there_is_a_map_unit_which_is_also_the_project_default(self):
ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
meter = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT")
ifcopenshell.api.context.add_context(self.file, "Model")
ifcopenshell.api.georeference.add_georeferencing(self.file)
map_unit = subject.get_full_unit_name(meter) if self.file.schema == "IFC2X3" else meter
ifcopenshell.api.georeference.edit_georeferencing(
self.file,
projected_crs={"MapUnit": map_unit},
coordinate_operation={"Eastings": 10, "Scale": 1},
)
ifcopenshell.api.unit.assign_unit(self.file, units=[meter])
output = subject.convert_file_length_units(self.file, target_units="MILLIMETER")
assert subject.get_full_unit_name(subject.get_project_unit(output, "LENGTHUNIT")) == "MILLIMETRE"
if self.file.schema == "IFC2X3":
parameters = ifcopenshell.util.geolocation.get_helmert_transformation_parameters(output)
assert parameters
assert parameters.e == 10
assert parameters.n == 0
assert parameters.scale == 1
crs = ifcopenshell.util.element.get_pset(output.by_type("IfcProject")[0], name="ePSet_ProjectedCRS")
assert crs["MapUnit"] == "METRE"
else:
map_conversion = output.by_type("IfcMapConversion")[0]
assert map_conversion.Eastings == 10
assert map_conversion.Northings == 0
assert map_conversion.Scale == 0.001
assert subject.get_full_unit_name(output.by_type("IfcProjectedCRS")[0].MapUnit) == "METRE"
unit_assignment = subject.get_unit_assignment(output)
assert unit_assignment
assert len(unit_assignment.Units) == 1
class TestConvertFileLengthUnitsIFC4(test.bootstrap.IFC4, TestConvertFileLengthUnits):
pass
class TestConvertFileLengthUnitsIFC4X3(test.bootstrap.IFC4X3, TestConvertFileLengthUnits):
pass