Bonsai: implement Add Derived Unit in the units UI (#2554)

The IfcDerivedUnit branch of the Add Unit panel was a literal
`pass  # TODO`, so derived units (velocity, thermal conductance, etc.)
could not be created from the UI even though
ifcopenshell.api.unit.add_derived_unit already supports them.

Add the missing flow, mirroring the module's existing add-unit patterns:
- UnitsData caches derived_unit_types (IfcDerivedUnitEnum from the schema,
  USERDEFINED excluded since it additionally requires a UserDefinedType)
  and named_units (the project's existing IfcNamedUnits, labelled with
  their full unit name and unit type).
- A DerivedUnitElement property group (unit enum + integer exponent) and a
  derived_unit_elements collection on BIMUnitProperties.
- Operators to add/remove element rows and AddDerivedUnit, which validates
  (at least one element per the schema's SET [1:?], no unselected unit, no
  duplicate units), builds the {named unit: exponent} mapping, and calls
  core.add_derived_unit -> unit.add_derived_unit; rows are cleared on
  success.
- The panel draws the type dropdown, per-row unit/exponent/remove controls,
  and an Add Element button.

Verified live in headless Blender with the real operators: metre^1 x
second^-1 with LINEARVELOCITYUNIT creates
IfcDerivedUnit((IfcDerivedUnitElement(metre,1), IfcDerivedUnitElement(
second,-1)), .LINEARVELOCITYUNIT.), assignable via bim.assign_unit; the
empty-rows and duplicate-unit error paths report cleanly; existing SI and
monetary unit flows are unaffected. Core test_unit.py: 19 passed
(includes a new TestAddDerivedUnit).

Maintainer context: Moult invited an implementation on the issue
("Indeed I couldn't think of a nice UI for it, go for it!").

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Petru Conduraru
2026-07-12 15:00:26 +03:00
parent 0b7e25a3ef
commit d9d56664cc
7 changed files with 126 additions and 1 deletions
@@ -23,6 +23,8 @@ from . import operator, prop, ui
classes = (
operator.AddContextDependentUnit,
operator.AddConversionBasedUnit,
operator.AddDerivedUnit,
operator.AddDerivedUnitElement,
operator.AddMonetaryUnit,
operator.AddSIUnit,
operator.AssignSceneUnits,
@@ -32,9 +34,11 @@ classes = (
operator.EditUnit,
operator.EnableEditingUnit,
operator.LoadUnits,
operator.RemoveDerivedUnitElement,
operator.RemoveUnit,
operator.UnassignUnit,
prop.Unit,
prop.DerivedUnitElement,
prop.BIMUnitProperties,
ui.BIM_PT_units,
ui.BIM_UL_units,
+17
View File
@@ -41,6 +41,8 @@ class UnitsData:
"unit_classes": cls.unit_classes(),
"named_unit_types": cls.named_unit_types(),
"conversion_unit_types": cls.conversion_unit_types(),
"derived_unit_types": cls.derived_unit_types(),
"named_units": cls.named_units(),
"total_units": cls.get_total_units(),
}
cls.is_loaded = True
@@ -88,6 +90,21 @@ class UnitsData:
def conversion_unit_types(cls):
return [(u, u, "") for u in ifcopenshell.util.unit.si_conversions.keys()]
@classmethod
def derived_unit_types(cls):
assert (entity := tool.Ifc.schema().declaration_by_name("IfcDerivedUnit").as_entity())
values = ifcopenshell.util.attribute.get_enum_items(entity.all_attributes()[1])
# USERDEFINED is skipped as it additionally requires a UserDefinedType.
return [(c, c, "") for c in sorted(values) if c != "USERDEFINED"]
@classmethod
def named_units(cls):
results = []
for unit in sorted(tool.Ifc.get().by_type("IfcNamedUnit"), key=lambda u: u.id()):
name = ifcopenshell.util.unit.get_full_unit_name(unit)
results.append((str(unit.id()), f"{name} ({unit.UnitType})", ""))
return results
@classmethod
def get_total_units(cls):
ifc = tool.Ifc.get()
@@ -132,6 +132,56 @@ class AddContextDependentUnit(bpy.types.Operator, tool.Ifc.Operator):
core.add_context_dependent_unit(tool.Ifc, tool.Unit, unit_type=self.unit_type, name=self.name)
class AddDerivedUnitElement(bpy.types.Operator):
bl_idname = "bim.add_derived_unit_element"
bl_label = "Add Derived Unit Element"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Add a new named unit and exponent to the derived unit definition"
def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
props = tool.Unit.get_unit_props()
props.derived_unit_elements.add()
return {"FINISHED"}
class RemoveDerivedUnitElement(bpy.types.Operator):
bl_idname = "bim.remove_derived_unit_element"
bl_label = "Remove Derived Unit Element"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Remove this named unit and exponent from the derived unit definition"
index: bpy.props.IntProperty()
def execute(self, context) -> set["rna_enums.OperatorReturnItems"]:
props = tool.Unit.get_unit_props()
props.derived_unit_elements.remove(self.index)
return {"FINISHED"}
class AddDerivedUnit(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_derived_unit"
bl_label = "Add Derived Unit"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Add a derived unit built from the defined named units and exponents"
def _execute(self, context):
props = tool.Unit.get_unit_props()
elements = {}
for element in props.derived_unit_elements:
if not element.unit:
self.report({"ERROR"}, "No named unit selected. Add a named unit (e.g. an SI unit) first.")
return {"CANCELLED"}
unit = tool.Ifc.get().by_id(int(element.unit))
if unit in elements:
self.report({"ERROR"}, f"Unit '{unit.Name}' is used in more than one element.")
return {"CANCELLED"}
elements[unit] = element.exponent
if not elements:
self.report({"ERROR"}, "A derived unit requires at least one element. Click 'Add Element' first.")
return {"CANCELLED"}
core.add_derived_unit(tool.Ifc, tool.Unit, unit_type=props.derived_unit_types, elements=elements)
props.derived_unit_elements.clear()
class EnableEditingUnit(bpy.types.Operator):
bl_idname = "bim.enable_editing_unit"
bl_label = "Enable Editing Unit"
+25
View File
@@ -50,6 +50,27 @@ def get_named_unit_types(self: "BIMUnitProperties", context: bpy.types.Context)
return UnitsData.data["named_unit_types"]
def get_derived_unit_types(self: "BIMUnitProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]:
if not UnitsData.is_loaded:
UnitsData.load()
return UnitsData.data["derived_unit_types"]
def get_named_units(self: "DerivedUnitElement", context: bpy.types.Context) -> list[tuple[str, str, str]]:
if not UnitsData.is_loaded:
UnitsData.load()
return UnitsData.data["named_units"]
class DerivedUnitElement(PropertyGroup):
unit: EnumProperty(items=get_named_units, name="Unit")
exponent: IntProperty(name="Exponent", default=1)
if TYPE_CHECKING:
unit: str
exponent: int
class Unit(PropertyGroup):
unit_type: StringProperty(name="Unit Type")
is_assigned: BoolProperty(name="Is Assigned")
@@ -71,6 +92,8 @@ class BIMUnitProperties(PropertyGroup):
unit_classes: EnumProperty(items=get_unit_classes, name="Unit Classes")
conversion_unit_types: EnumProperty(items=get_conversion_unit_types, name="Conversion Unit Types")
named_unit_types: EnumProperty(items=get_named_unit_types, name="Named Unit Types")
derived_unit_types: EnumProperty(items=get_derived_unit_types, name="Derived Unit Types")
derived_unit_elements: CollectionProperty(name="Derived Unit Elements", type=DerivedUnitElement)
unit_attributes: CollectionProperty(name="Unit Attributes", type=Attribute)
if TYPE_CHECKING:
@@ -81,4 +104,6 @@ class BIMUnitProperties(PropertyGroup):
unit_classes: str
conversion_unit_types: str
named_unit_types: str
derived_unit_types: str
derived_unit_elements: bpy.types.bpy_prop_collection_idprop[DerivedUnitElement]
unit_attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
+10 -1
View File
@@ -85,7 +85,16 @@ class BIM_PT_units(Panel):
op = row.operator("bim.add_conversion_based_unit", text="", icon="ADD")
op.name = self.props.conversion_unit_types
elif self.props.unit_classes == "IfcDerivedUnit":
pass # TODO
prop_with_search(row, self.props, "derived_unit_types", text="")
row.operator("bim.add_derived_unit", text="", icon="ADD")
box = self.layout.box()
for i, element in enumerate(self.props.derived_unit_elements):
row = box.row(align=True)
prop_with_search(row, element, "unit", text="")
row.prop(element, "exponent", text="")
row.operator("bim.remove_derived_unit_element", text="", icon="X").index = i
row = box.row()
row.operator("bim.add_derived_unit_element", text="Add Element", icon="ADD")
elif self.props.unit_classes == "IfcSIUnit":
prop_with_search(row, self.props, "named_unit_types", text="")
op = row.operator("bim.add_si_unit", text="", icon="ADD")
+11
View File
@@ -91,6 +91,17 @@ def add_conversion_based_unit(ifc: type[tool.Ifc], unit: type[tool.Unit], name:
return result
def add_derived_unit(
ifc: type[tool.Ifc],
unit: type[tool.Unit],
unit_type: str,
elements: dict[ifcopenshell.entity_instance, int],
) -> ifcopenshell.entity_instance:
result = ifc.run("unit.add_derived_unit", unit_type=unit_type, userdefinedtype=None, attributes=elements)
unit.import_units()
return result
def enable_editing_unit(unit_tool: type[tool.Unit], unit: ifcopenshell.entity_instance) -> None:
unit_tool.set_active_unit(unit)
unit_tool.import_unit_attributes(unit)
+9
View File
@@ -174,6 +174,15 @@ class TestAddConversionBasedUnit:
assert subject.add_conversion_based_unit(ifc, unit, name="name") == "unit"
class TestAddDerivedUnit:
def test_run(self, ifc, unit):
ifc.run(
"unit.add_derived_unit", unit_type="unit_type", userdefinedtype=None, attributes="elements"
).should_be_called().will_return("unit")
unit.import_units().should_be_called()
assert subject.add_derived_unit(ifc, unit, unit_type="unit_type", elements="elements") == "unit"
class TestEnableEditingUnit:
def test_run(self, unit):
unit.set_active_unit("unit").should_be_called()