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.
This commit is contained in:
Petru Conduraru
2026-07-20 09:57:46 +03:00
parent 55a2430d71
commit 248fb3e3fb
3 changed files with 189 additions and 0 deletions
@@ -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))
+65
View File
@@ -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()
+83
View File
@@ -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