From 248fb3e3fb91c076c09c0fd701b6cc0710fd9f3a Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Mon, 20 Jul 2026 09:57:46 +0300 Subject: [PATCH] Bonsai: warn before a unit change that would silently rescale the model Assigning a different length unit as project default, editing the assigned SI length unit's prefix, or assigning Blender scene units only reassigns the IfcUnitAssignment. The length values already stored in the file keep their numbers, so every stored IfcLengthMeasure changes its physical meaning and the whole model is rescaled by the unit ratio on the next load (a 2 m wall reloads as 2 mm after switching metre to millimetre). The viewport looks unchanged because Blender meshes stay in metres, which makes the corruption completely silent until reload. The unit operators now detect when the effective length scale of the project would change while the file already contains length-bearing data (shape representations or material layers) and open a confirmation dialog stating the rescale factor and pointing to the ConvertLengthUnit patch recipe, which converts all stored values and preserves physical size. Fresh projects without geometry keep the frictionless one-click flow, and script/EXEC calls are unaffected. Fixes #6400 Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/unit/operator.py | 41 +++++++++ src/bonsai/bonsai/tool/unit.py | 65 +++++++++++++++ src/bonsai/test/tool/test_unit.py | 83 +++++++++++++++++++ 3 files changed, 189 insertions(+) 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