diff --git a/src/bonsai/bonsai/bim/module/unit/operator.py b/src/bonsai/bonsai/bim/module/unit/operator.py index f0cee9a6be..20701f4ce7 100644 --- a/src/bonsai/bonsai/bim/module/unit/operator.py +++ b/src/bonsai/bonsai/bim/module/unit/operator.py @@ -27,11 +27,30 @@ if TYPE_CHECKING: from bpy.stub_internal import rna_enums +def draw_length_rescale_warning(layout: bpy.types.UILayout, factor: float) -> None: + layout.label(text="The project length unit is about to change.", icon="ERROR") + layout.label(text="Stored length values are not converted: they keep their numbers,") + layout.label(text=f"so the model is physically rescaled by {factor:g}x on the next load.") + layout.label(text="To change units while keeping the model size, use the") + layout.label(text="ConvertLengthUnit recipe in Quality Control > Patch instead.") + + class AssignSceneUnits(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.assign_scene_units" bl_label = "Assign Scene Units" bl_description = "Add new units based on the current Blender scene units and assign them as project default." bl_options = {"REGISTER", "UNDO"} + rescale_factor: bpy.props.FloatProperty(options={"HIDDEN", "SKIP_SAVE"}) + + def invoke(self, context, event): + factor = tool.Unit.get_scene_units_rescale_factor() + if factor is None: + return self.execute(context) + self.rescale_factor = factor + return context.window_manager.invoke_props_dialog(self, width=420) + + def draw(self, context): + draw_length_rescale_warning(self.layout, self.rescale_factor) def _execute(self, context): core.assign_scene_units(tool.Ifc, tool.Unit) @@ -44,6 +63,17 @@ class AssignUnit(bpy.types.Operator, tool.Ifc.Operator): bl_description = "Assign provided unit as the default project unit for it's unit type." bl_options = {"REGISTER", "UNDO"} unit: bpy.props.IntProperty() + rescale_factor: bpy.props.FloatProperty(options={"HIDDEN", "SKIP_SAVE"}) + + def invoke(self, context, event): + factor = tool.Unit.get_assign_unit_rescale_factor(tool.Ifc.get().by_id(self.unit)) + if factor is None: + return self.execute(context) + self.rescale_factor = factor + return context.window_manager.invoke_props_dialog(self, width=420) + + def draw(self, context): + draw_length_rescale_warning(self.layout, self.rescale_factor) def _execute(self, context): core.assign_unit(tool.Ifc, tool.Unit, unit=tool.Ifc.get().by_id(self.unit)) @@ -158,6 +188,17 @@ class EditUnit(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Edit Unit" bl_options = {"REGISTER", "UNDO"} unit: bpy.props.IntProperty() + rescale_factor: bpy.props.FloatProperty(options={"HIDDEN", "SKIP_SAVE"}) + + def invoke(self, context, event): + factor = tool.Unit.get_edit_unit_rescale_factor(tool.Ifc.get().by_id(self.unit)) + if factor is None: + return self.execute(context) + self.rescale_factor = factor + return context.window_manager.invoke_props_dialog(self, width=420) + + def draw(self, context): + draw_length_rescale_warning(self.layout, self.rescale_factor) def _execute(self, context): core.edit_unit(tool.Ifc, tool.Unit, unit=tool.Ifc.get().by_id(self.unit)) diff --git a/src/bonsai/bonsai/tool/unit.py b/src/bonsai/bonsai/tool/unit.py index 5bef7feae1..c2eab86873 100644 --- a/src/bonsai/bonsai/tool/unit.py +++ b/src/bonsai/bonsai/tool/unit.py @@ -362,6 +362,71 @@ class Unit(bonsai.core.tool.Unit): def get_scene_unit_si_prefix(cls, name: str) -> str | None: return name.split("/")[0] if "/" in name else None + @classmethod + def get_unit_si_scale(cls, unit: ifcopenshell.entity_instance) -> float | None: + """Get a named unit's magnitude in SI units, or None if it cannot be determined.""" + scale = 1.0 + while unit.is_a("IfcConversionBasedUnit"): + value = unit.ConversionFactor.ValueComponent.wrappedValue + if not isinstance(value, (int, float)): + return None + scale *= value + unit = unit.ConversionFactor.UnitComponent + if unit.is_a("IfcSIUnit"): + return scale * ifcopenshell.util.unit.get_prefix_multiplier(unit.Prefix) + return None + + @classmethod + def get_project_rescale_factor(cls, new_si_scale: float | None) -> float | None: + """Get the factor by which stored lengths would physically rescale, or None if they wouldn't. + + Compares the prospective SI scale of the project length unit against the + current one. Returns None when the scale is unknown, unchanged, or the + project has no length-bearing data worth warning about yet. + """ + if new_si_scale is None or not new_si_scale: + return None + ifc_file = tool.Ifc.get() + if not ifc_file.by_type("IfcShapeRepresentation") and not ifc_file.by_type("IfcMaterialLayer"): + return None + old_si_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) + if not old_si_scale or math.isclose(new_si_scale, old_si_scale, rel_tol=1e-9): + return None + return new_si_scale / old_si_scale + + @classmethod + def is_project_unit(cls, unit: ifcopenshell.entity_instance) -> bool: + assignment = ifcopenshell.util.unit.get_unit_assignment(tool.Ifc.get()) + return bool(assignment) and unit in (assignment.Units or ()) + + @classmethod + def get_assign_unit_rescale_factor(cls, unit: ifcopenshell.entity_instance) -> float | None: + """Get the rescale factor assigning this unit as project default would cause, or None.""" + if getattr(unit, "UnitType", None) != "LENGTHUNIT": + return None + return cls.get_project_rescale_factor(cls.get_unit_si_scale(unit)) + + @classmethod + def get_edit_unit_rescale_factor(cls, unit: ifcopenshell.entity_instance) -> float | None: + """Get the rescale factor applying the currently edited unit attributes would cause, or None.""" + if not unit.is_a("IfcSIUnit") or unit.UnitType != "LENGTHUNIT" or not cls.is_project_unit(unit): + return None + attributes = cls.export_unit_attributes() + if attributes.get("UnitType") != "LENGTHUNIT" or attributes.get("Name") != "METRE": + return None + return cls.get_project_rescale_factor(ifcopenshell.util.unit.get_prefix_multiplier(attributes.get("Prefix"))) + + @classmethod + def get_scene_units_rescale_factor(cls) -> float | None: + """Get the rescale factor assigning the Blender scene length unit would cause, or None.""" + if not (name := cls.get_scene_unit_name("LENGTHUNIT")): + return None + if cls.is_si_unit(name): + new_scale = ifcopenshell.util.unit.get_prefix_multiplier(cls.get_scene_unit_si_prefix(name)) + else: + new_scale = ifcopenshell.util.unit.si_conversions.get(name) + return cls.get_project_rescale_factor(new_scale) + @classmethod def import_unit_attributes(cls, unit: ifcopenshell.entity_instance) -> None: props = cls.get_unit_props() diff --git a/src/bonsai/test/tool/test_unit.py b/src/bonsai/test/tool/test_unit.py index 392650da71..a14c957e92 100644 --- a/src/bonsai/test/tool/test_unit.py +++ b/src/bonsai/test/tool/test_unit.py @@ -448,3 +448,86 @@ class TestSetActiveUnit(NewFile): subject.set_active_unit(unit) props = tool.Unit.get_unit_props() assert props.active_unit_id == unit.id() + + +class TestGetUnitSiScale(NewFile): + def test_run(self): + tool.Ifc.set(ifc := ifcopenshell.file()) + assert subject.get_unit_si_scale(ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT")) == 1 + mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI") + assert subject.get_unit_si_scale(mm) == 0.001 + foot = ifcopenshell.api.unit.add_conversion_based_unit(ifc, name="foot") + assert round(subject.get_unit_si_scale(foot), 4) == 0.3048 + thing = ifcopenshell.api.unit.add_context_dependent_unit(ifc, name="THINGS", unit_type="LENGTHUNIT") + assert subject.get_unit_si_scale(thing) is None + + +class TestGetProjectRescaleFactor(NewFile): + def test_run(self): + tool.Ifc.set(ifc := ifcopenshell.file()) + ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject") + unit = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT") + ifcopenshell.api.unit.assign_unit(ifc, units=[unit]) + assert subject.get_project_rescale_factor(0.001) is None + ifc.createIfcShapeRepresentation() + assert subject.get_project_rescale_factor(0.001) == 0.001 + assert subject.get_project_rescale_factor(1.0) is None + assert subject.get_project_rescale_factor(None) is None + + +class TestIsProjectUnit(NewFile): + def test_run(self): + tool.Ifc.set(ifc := ifcopenshell.file()) + ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject") + m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT") + mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI") + assert subject.is_project_unit(m) is False + ifcopenshell.api.unit.assign_unit(ifc, units=[m]) + assert subject.is_project_unit(m) is True + assert subject.is_project_unit(mm) is False + + +class TestGetAssignUnitRescaleFactor(NewFile): + def test_run(self): + tool.Ifc.set(ifc := ifcopenshell.file()) + ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject") + m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT") + mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI") + area = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="AREAUNIT") + ifcopenshell.api.unit.assign_unit(ifc, units=[m]) + ifc.createIfcShapeRepresentation() + assert subject.get_assign_unit_rescale_factor(mm) == 0.001 + assert subject.get_assign_unit_rescale_factor(m) is None + assert subject.get_assign_unit_rescale_factor(area) is None + + +class TestGetEditUnitRescaleFactor(NewFile): + def test_run(self): + tool.Ifc.set(ifc := ifcopenshell.file()) + ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject") + m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT") + mm = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT", prefix="MILLI") + ifcopenshell.api.unit.assign_unit(ifc, units=[m]) + ifc.createIfcShapeRepresentation() + subject.import_unit_attributes(m) + assert subject.get_edit_unit_rescale_factor(m) is None + props = tool.Unit.get_unit_props() + prefix = props.unit_attributes["Prefix"] + prefix.is_null = False + prefix.enum_value = "MILLI" + assert subject.get_edit_unit_rescale_factor(m) == 0.001 + assert subject.get_edit_unit_rescale_factor(mm) is None + + +class TestGetSceneUnitsRescaleFactor(NewFile): + def test_run(self): + tool.Ifc.set(ifc := ifcopenshell.file()) + ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject") + m = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="LENGTHUNIT") + ifcopenshell.api.unit.assign_unit(ifc, units=[m]) + ifc.createIfcShapeRepresentation() + bpy.context.scene.unit_settings.system = "METRIC" + bpy.context.scene.unit_settings.length_unit = "MILLIMETERS" + assert subject.get_scene_units_rescale_factor() == 0.001 + bpy.context.scene.unit_settings.length_unit = "METERS" + assert subject.get_scene_units_rescale_factor() is None