mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 17:31:45 +00:00
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:
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user