From 1cdd44996b9e3d00e120efedd84e7f8af805773e Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Mon, 1 Sep 2025 09:47:41 +0200 Subject: [PATCH 01/25] Add classification system to the Object Information, Clasifiction References Subpanel --- .../bonsai/bim/module/classification/data.py | 11 ++++++++ .../bonsai/bim/module/classification/ui.py | 26 +++++++++++++++---- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/classification/data.py b/src/bonsai/bonsai/bim/module/classification/data.py index acc88af10a..c3a54aa372 100644 --- a/src/bonsai/bonsai/bim/module/classification/data.py +++ b/src/bonsai/bonsai/bim/module/classification/data.py @@ -104,6 +104,17 @@ class ReferencesData: if element: for reference in ifcopenshell.util.classification.get_references(element): data = reference.get_info() + + if reference.ReferencedSource: + if reference.ReferencedSource.is_a("IfcClassification"): + data["ClassificationSystemName"] = reference.ReferencedSource.Name + elif reference.ReferencedSource.is_a("IfcClassificationReference"): + current = reference.ReferencedSource + while current.ReferencedSource and current.ReferencedSource.is_a("IfcClassificationReference"): + current = current.ReferencedSource + if current.ReferencedSource and current.ReferencedSource.is_a("IfcClassification"): + data["ClassificationSystemName"] = current.ReferencedSource.Name + del data["ReferencedSource"] results.append(data) return results diff --git a/src/bonsai/bonsai/bim/module/classification/ui.py b/src/bonsai/bonsai/bim/module/classification/ui.py index 344c247af2..acd27803b2 100644 --- a/src/bonsai/bonsai/bim/module/classification/ui.py +++ b/src/bonsai/bonsai/bim/module/classification/ui.py @@ -252,18 +252,34 @@ class ReferenceUI: def draw_reference_ui(self, reference: dict[str, Any]) -> None: row = self.layout.row(align=True) + + split = row.split(factor=0.3) + col1 = split.column() + split = split.split(factor=0.3) + col2 = split.column() + split = split.split(factor=0.7) + col3 = split.column() + col4 = split.column() + + if reference.get("ClassificationSystemName"): + col1.label(text=reference["ClassificationSystemName"], icon="OUTLINER_COLLECTION") + else: + col1.label(text="") + if self.file.schema == "IFC2X3": name = reference["ItemReference"] or "No Identification" else: name = reference["Identification"] or "No Identification" - row.label(text=name, icon="ASSET_MANAGER") - row.label(text=reference["Name"] or "") + col2.label(text=name, icon="ASSET_MANAGER") + col3.label(text=reference["Name"] or "") + + button_row = col4.row(align=True) if reference["Location"]: - row.operator("bim.open_uri", icon="URL", text="").uri = reference["Location"] + button_row.operator("bim.open_uri", icon="URL", text="").uri = reference["Location"] if not self.props.active_reference_id: - op = row.operator("bim.enable_editing_classification_reference", text="", icon="GREASEPENCIL") + op = button_row.operator("bim.enable_editing_classification_reference", text="", icon="GREASEPENCIL") op.reference = reference["id"] - op = row.operator("bim.remove_classification_reference", text="", icon="X") + op = button_row.operator("bim.remove_classification_reference", text="", icon="X") op.reference = reference["id"] op.obj = self.obj op.obj_type = self.data.obj_type From a4743b5e0420231e3993b26ad92035f60ed19c2d Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Mon, 29 Sep 2025 17:26:41 +0200 Subject: [PATCH 02/25] refactor using row in uy and util.classification.get_classifiation(reference) --- .../bonsai/bim/module/classification/data.py | 12 +------ .../bonsai/bim/module/classification/ui.py | 33 +++++++++---------- 2 files changed, 16 insertions(+), 29 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/classification/data.py b/src/bonsai/bonsai/bim/module/classification/data.py index c3a54aa372..a1fa3a6d58 100644 --- a/src/bonsai/bonsai/bim/module/classification/data.py +++ b/src/bonsai/bonsai/bim/module/classification/data.py @@ -104,17 +104,7 @@ class ReferencesData: if element: for reference in ifcopenshell.util.classification.get_references(element): data = reference.get_info() - - if reference.ReferencedSource: - if reference.ReferencedSource.is_a("IfcClassification"): - data["ClassificationSystemName"] = reference.ReferencedSource.Name - elif reference.ReferencedSource.is_a("IfcClassificationReference"): - current = reference.ReferencedSource - while current.ReferencedSource and current.ReferencedSource.is_a("IfcClassificationReference"): - current = current.ReferencedSource - if current.ReferencedSource and current.ReferencedSource.is_a("IfcClassification"): - data["ClassificationSystemName"] = current.ReferencedSource.Name - + data["ifcClassificationReference"] = reference del data["ReferencedSource"] results.append(data) return results diff --git a/src/bonsai/bonsai/bim/module/classification/ui.py b/src/bonsai/bonsai/bim/module/classification/ui.py index acd27803b2..14223274f0 100644 --- a/src/bonsai/bonsai/bim/module/classification/ui.py +++ b/src/bonsai/bonsai/bim/module/classification/ui.py @@ -21,6 +21,8 @@ import bpy import bonsai.bim.helper import bonsai.tool as tool import bonsai.bim.module.classification.prop as classification_prop +import ifcopenshell.util.classification + from bpy.types import Panel, UIList from bonsai.bim.module.classification.data import ( ClassificationsData, @@ -253,33 +255,28 @@ class ReferenceUI: def draw_reference_ui(self, reference: dict[str, Any]) -> None: row = self.layout.row(align=True) - split = row.split(factor=0.3) - col1 = split.column() - split = split.split(factor=0.3) - col2 = split.column() - split = split.split(factor=0.7) - col3 = split.column() - col4 = split.column() - - if reference.get("ClassificationSystemName"): - col1.label(text=reference["ClassificationSystemName"], icon="OUTLINER_COLLECTION") - else: - col1.label(text="") + classification_entity = ifcopenshell.util.classification.get_classification( + reference["ifcClassificationReference"] + ) + classification_name = classification_entity.Name if classification_entity else "" + row.label(text=classification_name, icon="OUTLINER_COLLECTION") if self.file.schema == "IFC2X3": name = reference["ItemReference"] or "No Identification" else: name = reference["Identification"] or "No Identification" - col2.label(text=name, icon="ASSET_MANAGER") - col3.label(text=reference["Name"] or "") + row.label(text=name, icon="ASSET_MANAGER") - button_row = col4.row(align=True) + # Name + row.label(text=reference.get("Name") or "") + + # Buttons if reference["Location"]: - button_row.operator("bim.open_uri", icon="URL", text="").uri = reference["Location"] + row.operator("bim.open_uri", icon="URL", text="").uri = reference["Location"] if not self.props.active_reference_id: - op = button_row.operator("bim.enable_editing_classification_reference", text="", icon="GREASEPENCIL") + op = row.operator("bim.enable_editing_classification_reference", text="", icon="GREASEPENCIL") op.reference = reference["id"] - op = button_row.operator("bim.remove_classification_reference", text="", icon="X") + op = row.operator("bim.remove_classification_reference", text="", icon="X") op.reference = reference["id"] op.obj = self.obj op.obj_type = self.data.obj_type From c3c63370bacc47a63c44b74c616892064b257317 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Mon, 29 Sep 2025 17:42:20 +0200 Subject: [PATCH 03/25] sort rows black formatting --- src/bonsai/bonsai/bim/module/classification/ui.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/classification/ui.py b/src/bonsai/bonsai/bim/module/classification/ui.py index 14223274f0..abb915ef13 100644 --- a/src/bonsai/bonsai/bim/module/classification/ui.py +++ b/src/bonsai/bonsai/bim/module/classification/ui.py @@ -154,7 +154,15 @@ class ReferenceUI: row = self.layout.row(align=True) row.label(text="No References") - for reference in self.data.data["references"]: + def get_classification_name(reference): + classification_entity = ifcopenshell.util.classification.get_classification( + reference["ifcClassificationReference"] + ) + return classification_entity.Name if classification_entity else "" + + sorted_references = sorted(self.data.data["references"], key=get_classification_name) + + for reference in sorted_references: if self.props.active_reference_id == reference["id"]: self.draw_editable_ui() else: @@ -266,11 +274,7 @@ class ReferenceUI: else: name = reference["Identification"] or "No Identification" row.label(text=name, icon="ASSET_MANAGER") - - # Name row.label(text=reference.get("Name") or "") - - # Buttons if reference["Location"]: row.operator("bim.open_uri", icon="URL", text="").uri = reference["Location"] if not self.props.active_reference_id: From ffc538da8730eecf641442eaf1dc768a68f3b627 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Mon, 29 Sep 2025 17:45:45 +0200 Subject: [PATCH 04/25] cleanup consistent calling --- src/bonsai/bonsai/bim/module/classification/ui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/classification/ui.py b/src/bonsai/bonsai/bim/module/classification/ui.py index abb915ef13..51ef3dd303 100644 --- a/src/bonsai/bonsai/bim/module/classification/ui.py +++ b/src/bonsai/bonsai/bim/module/classification/ui.py @@ -274,7 +274,7 @@ class ReferenceUI: else: name = reference["Identification"] or "No Identification" row.label(text=name, icon="ASSET_MANAGER") - row.label(text=reference.get("Name") or "") + row.label(text=reference["Name"] or "") if reference["Location"]: row.operator("bim.open_uri", icon="URL", text="").uri = reference["Location"] if not self.props.active_reference_id: From 19b9a55404ecfa7ba0712809fa2e9948846a2e4c Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Sat, 22 Nov 2025 20:12:49 +0100 Subject: [PATCH 05/25] Add posibility to edit classification system name in menu after invoking enable_editing_classification_reference --- .../bonsai/bim/module/classification/operator.py | 10 ++++++++-- src/bonsai/bonsai/bim/module/classification/prop.py | 1 + src/bonsai/bonsai/bim/module/classification/ui.py | 3 +++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/classification/operator.py b/src/bonsai/bonsai/bim/module/classification/operator.py index 600b86a3d5..a6fd0c6df5 100644 --- a/src/bonsai/bonsai/bim/module/classification/operator.py +++ b/src/bonsai/bonsai/bim/module/classification/operator.py @@ -257,7 +257,9 @@ class EnableEditingClassificationReference(bpy.types.Operator): def execute(self, context): props = tool.Classification.get_classification_reference_props() props.reference_attributes.clear() - bonsai.bim.helper.import_attributes(tool.Ifc.get().by_id(self.reference), props.reference_attributes) + ifc_reference = tool.Ifc.get().by_id(self.reference) + bonsai.bim.helper.import_attributes(ifc_reference, props.reference_attributes) + props.classification_system_name = ifc_reference.ReferencedSource.Name or "" props.active_reference_id = self.reference return {"FINISHED"} @@ -324,9 +326,13 @@ class EditClassificationReference(bpy.types.Operator, tool.Ifc.Operator): props = tool.Classification.get_classification_reference_props() attributes = bonsai.bim.helper.export_attributes(props.reference_attributes) ifc_file = tool.Ifc.get() + reference_entity = ifc_file.by_id(props.active_reference_id) + referenced_source = reference_entity.ReferencedSource + if props.classification_system_name: + referenced_source.Name = props.classification_system_name ifcopenshell.api.classification.edit_reference( ifc_file, - reference=ifc_file.by_id(props.active_reference_id), + reference=reference_entity, attributes=attributes, ) bpy.ops.bim.disable_editing_classification_reference() diff --git a/src/bonsai/bonsai/bim/module/classification/prop.py b/src/bonsai/bonsai/bim/module/classification/prop.py index d85e4e8c4e..4e2de0119d 100644 --- a/src/bonsai/bonsai/bim/module/classification/prop.py +++ b/src/bonsai/bonsai/bim/module/classification/prop.py @@ -95,6 +95,7 @@ class BIMClassificationReferenceProperties(PropertyGroup): classifications: EnumProperty(items=get_classifications, name="Classifications") reference_attributes: CollectionProperty(name="Reference Attributes", type=Attribute) active_reference_id: IntProperty(name="Active Reference Id") + classification_system_name: StringProperty(name="Classification System Name") if TYPE_CHECKING: is_adding: bool diff --git a/src/bonsai/bonsai/bim/module/classification/ui.py b/src/bonsai/bonsai/bim/module/classification/ui.py index 51ef3dd303..077f97553f 100644 --- a/src/bonsai/bonsai/bim/module/classification/ui.py +++ b/src/bonsai/bonsai/bim/module/classification/ui.py @@ -258,6 +258,9 @@ class ReferenceUI: row = self.layout.row(align=True) row.operator("bim.edit_classification_reference", text="Save changes", icon="CHECKMARK") row.operator("bim.disable_editing_classification_reference", text="", icon="CANCEL") + row = self.layout.row() + row.prop(self.props, "classification_system_name", text="Classification System Name") + bonsai.bim.helper.draw_attributes(self.props.reference_attributes, self.layout) def draw_reference_ui(self, reference: dict[str, Any]) -> None: From 2be142eff23b82b2f2d99685edfbd7392e7c029a Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Mon, 6 Oct 2025 13:36:44 +0200 Subject: [PATCH 06/25] Add unwrapping of UV for IfcReferenceImage --- .../bonsai/bim/module/drawing/operator.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 5077b0bbf0..9e5cb3330c 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -3701,6 +3701,13 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): layout.prop(self, "use_existing_object_by_name") def _execute(self, context): + space = tool.Blender.get_view3d_space() + if space and space.shading.type == "SOLID" and space.shading.color_type != "TEXTURE": + self.report( + {"WARNING"}, + 'Please change to "Texture" in "Object Color" for Viewport Shading: Solid to see the reference image properly.', + ) + abs_path = Path(self.filepath).absolute().resolve() image_filepath = Path(tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path)) ifc_file = tool.Ifc.get() @@ -3716,6 +3723,20 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): plane_scale = (Vector(image.size) / min(image.size)).to_3d() matrix = Matrix.LocRotScale(None, None, plane_scale) bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=1, matrix=matrix, calc_uvs=False) + + if not bm.loops.layers.uv: + uv_layer = bm.loops.layers.uv.new() + else: + uv_layer = bm.loops.layers.uv.active + + aspect_ratio = image.size[1] / image.size[0] + for face in bm.faces: + for loop in face.loops: + vert = loop.vert + v = (vert.co.y * 0.5) + 0.5 + u = (vert.co.x * 0.5 * aspect_ratio) + 0.5 + loop[uv_layer].uv = (u, v) + tool.Blender.apply_bmesh(mesh, bm) if self.use_existing_object_by_name: From d8263349cd6312a9d915794e67d21cfbf80b6fd7 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Mon, 6 Oct 2025 14:22:15 +0200 Subject: [PATCH 07/25] Add UV mapping for IfcAnnotation images in IfcImporter --- src/bonsai/bonsai/bim/import_ifc.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py index 539ec89736..1f4ebaaa9b 100644 --- a/src/bonsai/bonsai/bim/import_ifc.py +++ b/src/bonsai/bonsai/bim/import_ifc.py @@ -888,7 +888,34 @@ class IfcImporter: self.set_matrix_world( obj, tool.Loader.apply_blender_offset_to_matrix_world(obj, self.get_element_matrix(element)) ) - + if element.is_a("IfcAnnotation") and getattr(element, "ObjectType", None) == "IMAGE": + image = None + if obj.data and obj.data.materials and obj.data.materials[0]: + material = obj.data.materials[0] + if material.use_nodes and material.node_tree: + for node in material.node_tree.nodes: + if node.type == 'TEX_IMAGE' and node.image: + image = node.image + break + if image: + import bmesh + bm = bmesh.new() + bm.from_mesh(obj.data) + if not bm.loops.layers.uv: + uv_layer = bm.loops.layers.uv.new() + else: + uv_layer = bm.loops.layers.uv.active + aspect_ratio = image.size[1] / image.size[0] + for face in bm.faces: + for loop in face.loops: + vert = loop.vert + v = (vert.co.y * 0.5) + 0.5 + u = (vert.co.x * 0.5 * aspect_ratio) + 0.5 + loop[uv_layer].uv = (u, v) + bm.to_mesh(obj.data) + bm.free() + obj.data.update() + return obj def load_existing_meshes(self) -> None: From ec51c67373973300c540f55aef77cc94d6162a28 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Tue, 7 Oct 2025 17:56:46 +0200 Subject: [PATCH 08/25] For ifcReferenceImage by default set color type to "Texture" and warn user --- src/bonsai/bonsai/bim/module/drawing/operator.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 9e5cb3330c..0653c37835 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -3702,10 +3702,11 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): def _execute(self, context): space = tool.Blender.get_view3d_space() - if space and space.shading.type == "SOLID" and space.shading.color_type != "TEXTURE": + if space.shading.color_type != "TEXTURE": + space.shading.color_type = "TEXTURE" self.report( {"WARNING"}, - 'Please change to "Texture" in "Object Color" for Viewport Shading: Solid to see the reference image properly.', + '"Object Color" for Viewport Shading: Solid changed to "Texture" to see the reference image properly.', ) abs_path = Path(self.filepath).absolute().resolve() From 069873053ad3563cfeede852a25a78b01e8c41ef Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Wed, 8 Oct 2025 01:23:52 +0200 Subject: [PATCH 09/25] Add Image Scaling Tool and integrate with workspace hotkeys --- src/bonsai/bonsai/bim/import_ifc.py | 30 ++- .../bonsai/bim/module/project/__init__.py | 3 +- .../bonsai/bim/module/project/operator.py | 197 ++++++++++++++++++ .../bonsai/bim/module/project/workspace.py | 23 ++ 4 files changed, 244 insertions(+), 9 deletions(-) diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py index 1f4ebaaa9b..027e220992 100644 --- a/src/bonsai/bonsai/bim/import_ifc.py +++ b/src/bonsai/bonsai/bim/import_ifc.py @@ -888,6 +888,7 @@ class IfcImporter: self.set_matrix_world( obj, tool.Loader.apply_blender_offset_to_matrix_world(obj, self.get_element_matrix(element)) ) + if element.is_a("IfcAnnotation") and getattr(element, "ObjectType", None) == "IMAGE": image = None if obj.data and obj.data.materials and obj.data.materials[0]: @@ -905,17 +906,30 @@ class IfcImporter: uv_layer = bm.loops.layers.uv.new() else: uv_layer = bm.loops.layers.uv.active - aspect_ratio = image.size[1] / image.size[0] - for face in bm.faces: - for loop in face.loops: - vert = loop.vert - v = (vert.co.y * 0.5) + 0.5 - u = (vert.co.x * 0.5 * aspect_ratio) + 0.5 - loop[uv_layer].uv = (u, v) + + if bm.verts: + min_x = min(v.co.x for v in bm.verts) + max_x = max(v.co.x for v in bm.verts) + min_y = min(v.co.y for v in bm.verts) + max_y = max(v.co.y for v in bm.verts) + + width = max_x - min_x + height = max_y - min_y + + for face in bm.faces: + for loop in face.loops: + vert = loop.vert + u = (vert.co.x - min_x) / width if width > 0 else 0.5 + v = (vert.co.y - min_y) / height if height > 0 else 0.5 + + u = max(0.0, min(1.0, u)) + v = max(0.0, min(1.0, v)) + + loop[uv_layer].uv = (u, v) + bm.to_mesh(obj.data) bm.free() obj.data.update() - return obj def load_existing_meshes(self) -> None: diff --git a/src/bonsai/bonsai/bim/module/project/__init__.py b/src/bonsai/bonsai/bim/module/project/__init__.py index 1316eb0325..bae6a119d6 100644 --- a/src/bonsai/bonsai/bim/module/project/__init__.py +++ b/src/bonsai/bonsai/bim/module/project/__init__.py @@ -42,6 +42,7 @@ classes = ( operator.ExportIFC, operator.FlipClippingPlane, operator.IFCFileHandlerOperator, + operator.ImageScalingTool, operator.LinkIfc, operator.LoadLink, operator.LoadLinkedProject, @@ -130,4 +131,4 @@ def unregister(): if kc: for km, kmi in addon_keymaps: km.keymap_items.remove(kmi) - addon_keymaps.clear() + addon_keymaps.clear() \ No newline at end of file diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 2f5b9c906e..30eedd038e 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -2842,3 +2842,200 @@ class ClearMeasurement(bpy.types.Operator): MeasureDecorator.uninstall() tool.Blender.update_viewport() return {"FINISHED"} + +class ImageScalingTool(bpy.types.Operator, PolylineOperator): + bl_idname = "bim.image_scaling_tool" + bl_label = "Image Scaling Tool" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.space_data.type == "VIEW_3D" + + def __init__(self, *args, **kwargs): + bpy.types.Operator.__init__(self, *args, **kwargs) + PolylineOperator.__init__(self) + self.input_options = ["DISTANCE"] + self.input_ui = tool.Polyline.create_input_ui(input_options=self.input_options) + self.selected_points = [] + self.target_object = None + self.current_distance_value = "" + self.is_typing_distance = False + self.calculated_distance = 0.0 + + if tool.Ifc.get(): + self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + else: + self.unit_scale = tool.Blender.get_unit_scale() + + def modal(self, context, event): + if not self.target_object or not context.active_object or context.active_object != self.target_object: + self.report({"ERROR"}, "Image annotation was deselected. Tool cancelled.") + return self.cancel_tool(context) + + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + + self.handle_lock_axis(context, event) + + if event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}: + self.handle_mouse_move(context, event) + return {"PASS_THROUGH"} + + self.handle_custom_instructions(context) + self.handle_mouse_move(context, event) + self.choose_axis(event, z=True) + self.choose_plane(event) + self.handle_snap_selection(context, event) + + if event.type == "LEFTMOUSE" and event.value == "PRESS": + if len(self.selected_points) < 2: + snapped_point = self.snapping_points[0] + point_3d = snapped_point['point'].copy() + self.selected_points.append(point_3d) + + if len(self.selected_points) == 2: + self.calculate_distance() + self.current_distance_value = f"{self.calculated_distance:.3f}" + self.is_typing_distance = False + self.input_ui.set_value("DISTANCE", self.calculated_distance) + + elif len(self.selected_points) == 2: + if event.type in {"RET", "NUMPAD_ENTER"} and event.value == "PRESS": + return self.apply_scaling(context) + + if event.unicode and event.unicode.isprintable() and event.value == "PRESS": + if event.unicode.isdigit() or event.unicode == ".": + if not self.is_typing_distance: + self.current_distance_value = event.unicode + self.is_typing_distance = True + else: + self.current_distance_value += event.unicode + + distance_value = float(self.current_distance_value) + self.input_ui.set_value("DISTANCE", distance_value) + + elif event.type in {"BACK_SPACE", "DEL"} and event.value == "PRESS": + if len(self.current_distance_value) > 0: + self.current_distance_value = self.current_distance_value[:-1] + distance_value = float(self.current_distance_value) if self.current_distance_value else self.calculated_distance + self.input_ui.set_value("DISTANCE", distance_value) + + self.handle_keyboard_input(context, event) + + result = self.handle_cancelation(context, event) + if result is not None: + return result + + return {"RUNNING_MODAL"} + + def invoke(self, context, event): + active_obj = context.active_object + self.target_object = active_obj + super().invoke(context, event) + return {"RUNNING_MODAL"} + + def cancel_tool(self, context): + context.workspace.status_text_set(text=None) + if hasattr(self, 'tool_state'): + self.tool_state.plane_method = None + PolylineDecorator.uninstall() + tool.Blender.update_viewport() + return {"CANCELLED"} + + def handle_custom_instructions(self, context): + if len(self.selected_points) == 0: + instruction_text = "Click First Point on Image" + elif len(self.selected_points) == 1: + instruction_text = "Click Second Point on Image" + elif len(self.selected_points) == 2: + if self.is_typing_distance: + instruction_text = f"Distance: {self.current_distance_value} - Press Enter to Apply" + else: + instruction_text = f"Measured: {self.calculated_distance:.3f} - Type New Distance or Press Enter" + else: + instruction_text = "Image Scaling Tool" + + context.workspace.status_text_set(text=instruction_text) + + def calculate_distance(self): + if len(self.selected_points) == 2: + point1 = self.selected_points[0] + point2 = self.selected_points[1] + distance_3d = (point2 - point1).length + self.calculated_distance = distance_3d / self.unit_scale + + def apply_scaling(self, context): + if len(self.selected_points) != 2: + self.report({"ERROR"}, "Two points must be selected") + return {"CANCELLED"} + + target_distance = float(self.current_distance_value) + + if target_distance <= 0: + self.report({"ERROR"}, "Distance must be positive") + return {"CANCELLED"} + + if self.calculated_distance <= 0: + self.report({"ERROR"}, "Selected points are too close together") + return {"CANCELLED"} + + scale_factor = target_distance / self.calculated_distance + + if self.target_object: + import bmesh + + mesh = self.target_object.data + + bm = bmesh.new() + bm.from_mesh(mesh) + + bmesh.ops.scale(bm, vec=(scale_factor, scale_factor, 1.0), verts=bm.verts) + + if bm.loops.layers.uv: + uv_layer = bm.loops.layers.uv.active + + min_x = min(v.co.x for v in bm.verts) + max_x = max(v.co.x for v in bm.verts) + min_y = min(v.co.y for v in bm.verts) + max_y = max(v.co.y for v in bm.verts) + + width = max_x - min_x + height = max_y - min_y + + for face in bm.faces: + for loop in face.loops: + vert = loop.vert + u = (vert.co.x - min_x) / width if width > 0 else 0.5 + v = (vert.co.y - min_y) / height if height > 0 else 0.5 + + u = max(0.0, min(1.0, u)) + v = max(0.0, min(1.0, v)) + loop[uv_layer].uv = (u, v) + + bm.to_mesh(mesh) + bm.free() + mesh.update() + + element = tool.Ifc.get_entity(self.target_object) + if element and element.Representation: + for representation in element.Representation.Representations: + for item in representation.Items: + if item.is_a('IfcPolygonalFaceSet') and item.Coordinates: + new_coords = [] + for vertex in mesh.vertices: + co = self.target_object.matrix_world @ vertex.co + new_coords.append([co.x, co.y, co.z]) + + item.Coordinates.CoordList = new_coords + + self.report({"INFO"}, f"Applied scale factor: {scale_factor:.4f}") + + context.workspace.status_text_set(text=None) + self.tool_state.plane_method = None + PolylineDecorator.uninstall() + tool.Blender.update_viewport() + + return {"FINISHED"} + + diff --git a/src/bonsai/bonsai/bim/module/project/workspace.py b/src/bonsai/bonsai/bim/module/project/workspace.py index e7b9220fc1..a5ec38a5ee 100644 --- a/src/bonsai/bonsai/bim/module/project/workspace.py +++ b/src/bonsai/bonsai/bim/module/project/workspace.py @@ -38,6 +38,7 @@ class ExploreTool(bpy.types.WorkSpaceTool): ("bim.explore_hotkey", {"type": "F", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_F")]}), ("bim.explore_hotkey", {"type": "C", "value": "PRESS", "alt": True}, {"properties": [("hotkey", "A_C")]}), ("bim.explore_hotkey", {"type": "M", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_M")]}), + ("bim.explore_hotkey", {"type": "S", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_S")]}), ) def draw_settings(context, layout, ws_tool): @@ -71,6 +72,13 @@ class ExploreTool(bpy.types.WorkSpaceTool): row = layout.row(align=True) op = row.operator("bim.clear_measurement", text="", icon="X") + row = layout.row(align=True) + row.label(text="", icon="EVENT_SHIFT") + row.label(text="", icon="EVENT_S") + row = layout.row(align=True) + op = row.operator("bim.explore_hotkey", text="Image Scaling Tool", icon="IMAGE_PLANE") + op.hotkey = "S_S" + class ExploreHotkey(bpy.types.Operator): bl_idname = "bim.explore_hotkey" @@ -110,3 +118,18 @@ class ExploreHotkey(bpy.types.Operator): bpy.ops.bim.measure_face_area_tool("INVOKE_DEFAULT") else: bpy.ops.bim.measure_tool("INVOKE_DEFAULT", measure_type=measure_type) + + def hotkey_S_S(self): + active_obj = bpy.context.active_object + selected_objects = tool.Blender.get_selected_objects() + element = tool.Ifc.get_entity(active_obj) if active_obj else None + + if (not active_obj or + not element or + not element.is_a("IfcAnnotation") or + len(selected_objects) != 1 or + not tool.Drawing.is_annotation_object_type(element, "IMAGE")): + self.report({"ERROR"}, "Please select one image annotation first.") + return + + bpy.ops.bim.image_scaling_tool("INVOKE_DEFAULT") From 834634aedbc9484b2752ac94e1594e3fafdf9f30 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Mon, 3 Nov 2025 10:16:50 +0100 Subject: [PATCH 10/25] Properly AddReferenceImage operator initialization (ready for IFC edit mode) --- src/bonsai/bonsai/bim/module/drawing/operator.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 0653c37835..7ef2a1603b 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -3758,6 +3758,16 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): ) tool.Blender.remove_data_block(temp_mesh) + + element = tool.Ifc.get_entity(obj) + if element and isinstance(obj.data, bpy.types.Mesh): + representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + if representation and representation.Items: + item_id = representation.Items[0].id() + num_faces = len(obj.data.polygons) + obj.data["ios_item_ids"] = [item_id] * num_faces + tool.Blender.Attribute.fill_attribute(obj.data, "ios_item_ids", "FACE", "INT", [item_id] * num_faces) + tool.Blender.set_active_object(obj) material = bpy.data.materials.new(name=image_filepath.stem) From 43b12d55d441775ec9db0a38f72d0ba08cdc8d9c Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Mon, 3 Nov 2025 11:17:44 +0100 Subject: [PATCH 11/25] Add dimensions dialog for reference image and update scaling logic --- .../bonsai/bim/module/drawing/operator.py | 120 ++++++++++++++++-- 1 file changed, 108 insertions(+), 12 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 7ef2a1603b..25965966b3 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -3688,18 +3688,92 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): description="Existing object name to add a style with reference image to. If not provided will create a new object.", options={"SKIP_SAVE"}, ) + + x_length: bpy.props.FloatProperty( + name="X Length", + description="Width of the reference image in project units", + default=1.0, + min=0.001, + soft_min=0.01, + precision=3, + ) + y_length: bpy.props.FloatProperty( + name="Y Length", + description="Height of the reference image in project units", + default=1.0, + min=0.001, + soft_min=0.01, + precision=3, + ) + + _show_dimensions_dialog: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"}) def draw(self, context): layout = self.layout - if Path(tool.Ifc.get_path()).is_file(): - layout.prop(self, "use_relative_path") + + if getattr(self, '_show_dimensions_dialog', False): + if tool.Ifc.get(): + length_unit = ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), "LENGTHUNIT") + if length_unit: + unit_name = ifcopenshell.util.unit.get_full_unit_name(length_unit).lower() + else: + unit_name = "project units" + layout.label(text=f"Set Reference Image Dimensions (in {unit_name}):") + else: + layout.label(text="Set Reference Image Dimensions (in project units):") + layout.separator() + layout.prop(self, "x_length") + layout.prop(self, "y_length") else: - self.use_relative_path = False - layout.label(text="Save the .ifc file first ") - layout.label(text="to use relative paths.") - layout.prop(self, "override_existing_image") - layout.prop(self, "use_existing_object_by_name") + if Path(tool.Ifc.get_path()).is_file(): + layout.prop(self, "use_relative_path") + else: + self.use_relative_path = False + layout.label(text="Save the .ifc file first ") + layout.label(text="to use relative paths.") + layout.prop(self, "override_existing_image") + layout.prop(self, "use_existing_object_by_name") + + def invoke(self, context, event): + if not getattr(self, '_show_dimensions_dialog', False): + context.window_manager.fileselect_add(self) + return {'RUNNING_MODAL'} + else: + return context.window_manager.invoke_props_dialog(self) + def execute(self, context): + if not getattr(self, '_show_dimensions_dialog', False): + abs_path = Path(self.filepath).absolute().resolve() + if self.override_existing_image: + params = {"check_existing": True, "force_reload": True} + else: + params = {"check_existing": False} + + try: + image = load_image(abs_path.name, str(abs_path.parent), **params) + + image_width_px = image.size[0] + image_height_px = image.size[1] + aspect_ratio = image_width_px / image_height_px + + if aspect_ratio >= 1.0: + self.x_length = 1.0 + self.y_length = 1.0 / aspect_ratio + else: + self.x_length = aspect_ratio + self.y_length = 1.0 + + bpy.data.images.remove(image) + + except Exception as e: + self.report({'ERROR'}, f"Failed to load image: {str(e)}") + return {'CANCELLED'} + + self._show_dimensions_dialog = True + return context.window_manager.invoke_props_dialog(self) + + return self._execute(context) + def _execute(self, context): space = tool.Blender.get_view3d_space() if space.shading.color_type != "TEXTURE": @@ -3717,11 +3791,13 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): params = {"check_existing": True, "force_reload": True} else: params = {"check_existing": False} - image = load_image(abs_path.name, abs_path.parent, **params) + image = load_image(abs_path.name, str(abs_path.parent), **params) def bm_add_image_plane(mesh): bm = tool.Blender.get_bmesh_for_mesh(mesh, clean=True) - plane_scale = (Vector(image.size) / min(image.size)).to_3d() + + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) + plane_scale = Vector((self.x_length * unit_scale / 2.0, self.y_length * unit_scale / 2.0, 1.0)) matrix = Matrix.LocRotScale(None, None, plane_scale) bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=1, matrix=matrix, calc_uvs=False) @@ -3730,12 +3806,22 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): else: uv_layer = bm.loops.layers.uv.active - aspect_ratio = image.size[1] / image.size[0] + min_x = min(v.co.x for v in bm.verts) + max_x = max(v.co.x for v in bm.verts) + min_y = min(v.co.y for v in bm.verts) + max_y = max(v.co.y for v in bm.verts) + + width = max_x - min_x + height = max_y - min_y + for face in bm.faces: for loop in face.loops: vert = loop.vert - v = (vert.co.y * 0.5) + 0.5 - u = (vert.co.x * 0.5 * aspect_ratio) + 0.5 + u = (vert.co.x - min_x) / width if width > 0 else 0.5 + v = (vert.co.y - min_y) / height if height > 0 else 0.5 + + u = max(0.0, min(1.0, u)) + v = max(0.0, min(1.0, v)) loop[uv_layer].uv = (u, v) tool.Blender.apply_bmesh(mesh, bm) @@ -3767,6 +3853,14 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): num_faces = len(obj.data.polygons) obj.data["ios_item_ids"] = [item_id] * num_faces tool.Blender.Attribute.fill_attribute(obj.data, "ios_item_ids", "FACE", "INT", [item_id] * num_faces) + + for item in representation.Items: + if item.is_a('IfcPolygonalFaceSet') and item.Coordinates: + new_coords = [] + for vertex in obj.data.vertices: + co = obj.matrix_world @ vertex.co + new_coords.append([co.x, co.y, co.z]) + item.Coordinates.CoordList = new_coords tool.Blender.set_active_object(obj) @@ -3807,6 +3901,8 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): ) tool.Style.reload_material_from_ifc(material) tool.Geometry.record_object_materials(obj) + + return {'FINISHED'} class ConvertSVGToDXF(bpy.types.Operator): From d2dfeb79a3f13d801e8a44aa079f99998ea84efe Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Sat, 22 Nov 2025 22:20:39 +0100 Subject: [PATCH 12/25] Refactor AddReferenceImage to use public property for dimensions dialog --- src/bonsai/bonsai/bim/module/drawing/operator.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 25965966b3..7397c93f63 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -3706,12 +3706,12 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): precision=3, ) - _show_dimensions_dialog: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"}) + show_dimensions_dialog: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"}) def draw(self, context): layout = self.layout - if getattr(self, '_show_dimensions_dialog', False): + if getattr(self, 'show_dimensions_dialog', False): if tool.Ifc.get(): length_unit = ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), "LENGTHUNIT") if length_unit: @@ -3735,14 +3735,14 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): layout.prop(self, "use_existing_object_by_name") def invoke(self, context, event): - if not getattr(self, '_show_dimensions_dialog', False): + if not getattr(self, 'show_dimensions_dialog', False): context.window_manager.fileselect_add(self) return {'RUNNING_MODAL'} else: return context.window_manager.invoke_props_dialog(self) def execute(self, context): - if not getattr(self, '_show_dimensions_dialog', False): + if not getattr(self, 'show_dimensions_dialog', False): abs_path = Path(self.filepath).absolute().resolve() if self.override_existing_image: params = {"check_existing": True, "force_reload": True} @@ -3769,7 +3769,7 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): self.report({'ERROR'}, f"Failed to load image: {str(e)}") return {'CANCELLED'} - self._show_dimensions_dialog = True + self.show_dimensions_dialog = True return context.window_manager.invoke_props_dialog(self) return self._execute(context) From cb89fbfd86cc8dfef45712c1fb2d6c85f5afc04d Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Tue, 4 Nov 2025 14:19:43 +0100 Subject: [PATCH 13/25] Add attribute info for relating type --- src/bonsai/bonsai/bim/module/type/__init__.py | 4 + src/bonsai/bonsai/bim/module/type/data.py | 23 ++++++ src/bonsai/bonsai/bim/module/type/operator.py | 78 +++++++++++++++++++ src/bonsai/bonsai/bim/module/type/prop.py | 5 ++ src/bonsai/bonsai/bim/module/type/ui.py | 48 +++++++++++- 5 files changed, 157 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/type/__init__.py b/src/bonsai/bonsai/bim/module/type/__init__.py index 646ab48fcf..b77bcbfb8e 100644 --- a/src/bonsai/bonsai/bim/module/type/__init__.py +++ b/src/bonsai/bonsai/bim/module/type/__init__.py @@ -23,8 +23,11 @@ classes = ( operator.AssignType, operator.AutoRenameOccurrences, operator.DisableEditingType, + operator.DisableEditingTypeAttributes, operator.DuplicateType, + operator.EditTypeAttributes, operator.EnableEditingType, + operator.EnableEditingTypeAttributes, operator.RemoveType, operator.RenameType, operator.SelectSimilarType, @@ -33,6 +36,7 @@ classes = ( operator.UnassignType, prop.BIMTypeProperties, ui.BIM_PT_type, + ui.BIM_PT_type_attributes, ) diff --git a/src/bonsai/bonsai/bim/module/type/data.py b/src/bonsai/bonsai/bim/module/type/data.py index d93b86261d..119456875a 100644 --- a/src/bonsai/bonsai/bim/module/type/data.py +++ b/src/bonsai/bonsai/bim/module/type/data.py @@ -42,6 +42,7 @@ class TypeData: "is_product": cls.is_product(), "total_instances": cls.total_instances(), "relating_type": cls.relating_type(), + "relating_type_attributes": cls.relating_type_attributes(), } ) @@ -92,3 +93,25 @@ class TypeData: element_type = ifcopenshell.util.element.get_type(element) if element_type: return {"id": element_type.id(), "name": f"{element_type.is_a()}/{element_type.Name or 'Unnamed'}"} + + @classmethod + def relating_type_attributes(cls): + results = [] + element = tool.Ifc.get_entity(bpy.context.active_object) + element_type = ifcopenshell.util.element.get_type(element) + if not element_type: + return results + + data = element_type.get_info() + if "GlobalId" in data: + excluded_keys = ["id", "type"] + else: + excluded_keys = ["type"] + exclude_value_types = (tuple, ifcopenshell.entity_instance) + for key, value in data.items(): + if value is None or isinstance(value, exclude_value_types) or key in excluded_keys: + continue + if key == "id": + key = "STEP ID" + results.append({"name": key, "value": str(value)}) + return results diff --git a/src/bonsai/bonsai/bim/module/type/operator.py b/src/bonsai/bonsai/bim/module/type/operator.py index 60a52fdb9b..b09b9fe5a8 100644 --- a/src/bonsai/bonsai/bim/module/type/operator.py +++ b/src/bonsai/bonsai/bim/module/type/operator.py @@ -24,6 +24,7 @@ import ifcopenshell.util.representation import ifcopenshell.util.type import ifcopenshell.util.unit import ifcopenshell.api +import ifcopenshell.api.attribute import ifcopenshell.api.type import bonsai.bim.helper import bonsai.tool as tool @@ -416,3 +417,80 @@ class DuplicateType(bpy.types.Operator, tool.Ifc.Operator): self.layout.prop( self, "assign_selected_objects", text=f"Assign {len(ifc_objects)} Selected Object(s) to New Type" ) + + +class EnableEditingTypeAttributes(bpy.types.Operator): + bl_idname = "bim.enable_editing_type_attributes" + bl_label = "Enable Editing Type Attributes" + bl_description = "Enable editing the attributes of the relating type" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + obj = context.active_object + if not obj: + return {"CANCELLED"} + + element = tool.Ifc.get_entity(obj) + if not element: + return {"CANCELLED"} + + element_type = ifcopenshell.util.element.get_type(element) + if not element_type: + return {"CANCELLED"} + + props = tool.Type.get_object_type_props(obj) + props.type_attributes.clear() + + bonsai.bim.helper.import_attributes(element_type, props.type_attributes) + props.is_editing_type_attributes = True + return {"FINISHED"} + + +class DisableEditingTypeAttributes(bpy.types.Operator): + bl_idname = "bim.disable_editing_type_attributes" + bl_label = "Disable Editing Type Attributes" + bl_description = "Disable editing the attributes of the relating type" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + obj = context.active_object + if not obj: + return {"CANCELLED"} + + props = tool.Type.get_object_type_props(obj) + props.type_attributes.clear() + props.property_unset("is_editing_type_attributes") + return {"FINISHED"} + + +class EditTypeAttributes(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.edit_type_attributes" + bl_label = "Edit Type Attributes" + bl_description = "Save the changes to the relating type's attributes" + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context): + obj = context.active_object + if not obj: + return {"CANCELLED"} + + element = tool.Ifc.get_entity(obj) + if not element: + return {"CANCELLED"} + + element_type = ifcopenshell.util.element.get_type(element) + if not element_type: + return {"CANCELLED"} + + props = tool.Type.get_object_type_props(obj) + attributes = bonsai.bim.helper.export_attributes(props.type_attributes) + + ifcopenshell.api.attribute.edit_attributes(tool.Ifc.get(), product=element_type, attributes=attributes) + + type_obj = tool.Ifc.get_object(element_type) + if type_obj: + tool.Root.set_object_name(type_obj, element_type) + + bpy.ops.bim.disable_editing_type_attributes() + + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/type/prop.py b/src/bonsai/bonsai/bim/module/type/prop.py index 814d678390..2786636404 100644 --- a/src/bonsai/bonsai/bim/module/type/prop.py +++ b/src/bonsai/bonsai/bim/module/type/prop.py @@ -20,6 +20,7 @@ import bpy import ifcopenshell.util.element import ifcopenshell.util.type from bonsai.bim.module.type.data import TypeData +from bonsai.bim.prop import Attribute import bonsai.tool as tool from typing import TYPE_CHECKING, Union from bpy.types import PropertyGroup @@ -90,9 +91,13 @@ class BIMTypeProperties(PropertyGroup): update=update_relating_type_from_object, poll=is_object_class_applicable, ) + is_editing_type_attributes: BoolProperty(name="Is Editing Type Attributes") + type_attributes: CollectionProperty(type=Attribute, name="Type Attributes") if TYPE_CHECKING: is_editing_type: bool relating_type_class: str relating_type: str relating_type_object: Union[bpy.types.Object, None] + is_editing_type_attributes: bool + type_attributes: bpy.types.bpy_prop_collection_idprop[Attribute] diff --git a/src/bonsai/bonsai/bim/module/type/ui.py b/src/bonsai/bonsai/bim/module/type/ui.py index a368f38867..2370782046 100644 --- a/src/bonsai/bonsai/bim/module/type/ui.py +++ b/src/bonsai/bonsai/bim/module/type/ui.py @@ -20,7 +20,7 @@ import bpy import bonsai.tool as tool import bonsai.bim.module.type.prop as type_prop from bpy.types import Panel -from bonsai.bim.helper import prop_with_search +from bonsai.bim.helper import prop_with_search, get_display_value from bonsai.bim.module.type.data import TypeData @@ -107,5 +107,51 @@ class BIM_PT_type(Panel): row.operator("bim.enable_editing_type", icon="GREASEPENCIL", text="") +class BIM_PT_type_attributes(Panel): + bl_label = "Type Attributes" + bl_idname = "BIM_PT_type_attributes" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "object" + bl_parent_id = "BIM_PT_type" + bl_options = {"DEFAULT_CLOSED"} + + @classmethod + def poll(cls, context): + if not TypeData.is_loaded: + TypeData.load() + return bool(TypeData.data.get("is_product") and TypeData.data.get("relating_type")) + + def draw(self, context): + if not TypeData.is_loaded: + TypeData.load() + + assert (layout := self.layout) + assert (obj := context.active_object) + + if not TypeData.data.get("relating_type"): + layout.label(text="No Relating Type", icon="INFO") + return + + props = tool.Type.get_object_type_props(obj) + + if props.is_editing_type_attributes: + row = layout.row(align=True) + row.operator("bim.edit_type_attributes", icon="CHECKMARK", text="Save Attributes") + row.operator("bim.disable_editing_type_attributes", icon="CANCEL", text="") + + import bonsai.bim.helper + bonsai.bim.helper.draw_attributes(props.type_attributes, layout) + else: + row = layout.row() + row.operator("bim.enable_editing_type_attributes", icon="GREASEPENCIL", text="Edit") + + for attribute in TypeData.data["relating_type_attributes"]: + row = layout.row(align=True) + row.label(text=attribute["name"]) + value = get_display_value(attribute["value"]) + row.label(text=value) + + def add_object_button(self, context): self.layout.operator("bim.add_occurrence", icon="PLUGIN") From 487a2014f5b2977a35153e1b6d426754492f676d Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Mon, 1 Sep 2025 21:29:05 +0200 Subject: [PATCH 14/25] proposal to add massunit and timeunit in project creation wizard --- .../bonsai/bim/module/project/operator.py | 8 +++ src/bonsai/bonsai/bim/module/project/ui.py | 4 ++ src/bonsai/bonsai/bim/prop.py | 23 +++++++ src/bonsai/bonsai/core/unit.py | 19 +++++- src/bonsai/bonsai/tool/unit.py | 26 +++++++- .../ifcopenshell/api/unit/assign_unit.py | 66 ++++++++++++++++++- 6 files changed, 142 insertions(+), 4 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 0650cda238..34d6cdb91d 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -98,6 +98,8 @@ class NewProject(bpy.types.Operator): bpy.context.scene.unit_settings.length_unit = "METERS" bim_props.area_unit = "SQUARE_METRE" bim_props.volume_unit = "CUBIC_METRE" + bim_props.mass_unit = "KILOGRAM" + bim_props.time_unit = "SECOND" pprops.template_file = "0" elif self.preset == "metric_mm": pprops.export_schema = "IFC4" @@ -105,6 +107,8 @@ class NewProject(bpy.types.Operator): bpy.context.scene.unit_settings.length_unit = "MILLIMETERS" bim_props.area_unit = "SQUARE_METRE" bim_props.volume_unit = "CUBIC_METRE" + bim_props.mass_unit = "KILOGRAM" + bim_props.time_unit = "SECOND" pprops.template_file = "0" elif self.preset == "imperial_ft": pprops.export_schema = "IFC4" @@ -112,6 +116,8 @@ class NewProject(bpy.types.Operator): bpy.context.scene.unit_settings.length_unit = "FEET" bim_props.area_unit = "square foot" bim_props.volume_unit = "cubic foot" + bim_props.mass_unit = "POUND" + bim_props.time_unit = "SECOND" pprops.template_file = "0" elif self.preset == "demo": pprops.export_schema = "IFC4" @@ -119,6 +125,8 @@ class NewProject(bpy.types.Operator): bpy.context.scene.unit_settings.length_unit = "MILLIMETERS" bim_props.area_unit = "SQUARE_METRE" bim_props.volume_unit = "CUBIC_METRE" + bim_props.mass_unit = "KILOGRAM" + bim_props.time_unit = "SECOND" pprops.template_file = "IFC4 Demo Template.ifc" if self.preset != "wizard": diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index 6674a1a51a..731a9d0688 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -356,6 +356,10 @@ class BIM_PT_new_project_wizard(Panel): row.prop(props, "area_unit", text="Area Unit") row = self.layout.row() row.prop(props, "volume_unit", text="Volume Unit") + row = self.layout.row() + row.prop(props, "mass_unit", text="Mass Unit") + row = self.layout.row() + row.prop(props, "time_unit", text="Time Unit") prop_with_search(self.layout, pprops, "template_file", text="Template") row = self.layout.row() diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py index 7a4464c986..eeea68eab9 100644 --- a/src/bonsai/bonsai/bim/prop.py +++ b/src/bonsai/bonsai/bim/prop.py @@ -586,7 +586,28 @@ class BIMProperties(PropertyGroup): ], name="IFC Volume Unit", ) + mass_unit: EnumProperty( + items=[ + ("KILOGRAM", "Kilogram", "Kilograms"), + ("GRAM", "Gram", "Grams"), + ("POUND", "Pound (Mass)", "Pounds"), + ("OUNCE", "Ounce", "Ounces"), + ("TON", "Ton", "Metric Tons"), + ], + name="Mass Unit", + default="KILOGRAM", + ) + time_unit: EnumProperty( + items=[ + ("SECOND", "Second", "Seconds"), + ("MINUTE", "Minutes", "Minutes"), + ("HOUR", "Hour", "Hours"), + ("DAY", "Day", "Days"), + ], + name="Time Unit", + default="HOUR", + ) if TYPE_CHECKING: is_dirty: bool schema_dir: str @@ -599,6 +620,8 @@ class BIMProperties(PropertyGroup): section_line_decorator_width: float area_unit: str volume_unit: str + mass_unit: str + time_unit: str class IfcParameter(PropertyGroup): diff --git a/src/bonsai/bonsai/core/unit.py b/src/bonsai/bonsai/core/unit.py index a075c9c5e5..8517343b37 100644 --- a/src/bonsai/bonsai/core/unit.py +++ b/src/bonsai/bonsai/core/unit.py @@ -34,14 +34,29 @@ def assign_scene_units(ifc: type[tool.Ifc], unit: type[tool.Unit]) -> None: areaunit = ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix=prefix) prefix = unit.get_scene_unit_si_prefix("VOLUMEUNIT") volumeunit = ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix=prefix) + + prefix = unit.get_scene_unit_si_prefix("MASSUNIT") + if prefix == "CONVERSION": + mass_unit_name = unit.get_scene_unit_name("MASSUNIT") + massunit = ifc.run("unit.add_conversion_based_unit", name=mass_unit_name.lower()) + else: + massunit = ifc.run("unit.add_si_unit", unit_type="MASSUNIT", prefix=prefix) + + prefix = unit.get_scene_unit_si_prefix("TIMEUNIT") + if prefix == "CONVERSION": + time_unit_name = unit.get_scene_unit_name("TIMEUNIT") + timeunit = ifc.run("unit.add_conversion_based_unit", name=time_unit_name.lower()) + else: + timeunit = ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix=prefix) else: lengthunit = ifc.run("unit.add_conversion_based_unit", name=unit.get_scene_unit_name("LENGTHUNIT")) areaunit = ifc.run("unit.add_conversion_based_unit", name=unit.get_scene_unit_name("AREAUNIT")) volumeunit = ifc.run("unit.add_conversion_based_unit", name=unit.get_scene_unit_name("VOLUMEUNIT")) + massunit = ifc.run("unit.add_conversion_based_unit", name=unit.get_scene_unit_name("MASSUNIT")) + timeunit = ifc.run("unit.add_conversion_based_unit", name=unit.get_scene_unit_name("TIMEUNIT")) planeangleunit = ifc.run("unit.add_conversion_based_unit", name="degree") - - ifc.run("unit.assign_unit", units=[lengthunit, areaunit, volumeunit, planeangleunit]) + ifc.run("unit.assign_unit", units=[lengthunit, areaunit, volumeunit, planeangleunit, massunit, timeunit]) def assign_unit(ifc: type[tool.Ifc], unit_tool: type[tool.Unit], unit: ifcopenshell.entity_instance) -> None: diff --git a/src/bonsai/bonsai/tool/unit.py b/src/bonsai/bonsai/tool/unit.py index d01d51f3cb..b18cb6ff09 100644 --- a/src/bonsai/bonsai/tool/unit.py +++ b/src/bonsai/bonsai/tool/unit.py @@ -266,7 +266,7 @@ def parse_distance_string(input_string: str, use_project_unit: bool = True) -> t class Unit(bonsai.core.tool.Unit): - UNIT_TYPE = Literal["LENGTHUNIT", "AREAUNIT", "VOLUMEUNIT"] + UNIT_TYPE = Literal["LENGTHUNIT", "AREAUNIT", "VOLUMEUNIT", "MASSUNIT", "TIMEUNIT"] @staticmethod def format_distance(meters: float, use_imperial: bool = None, **kwargs) -> str: @@ -343,6 +343,10 @@ class Unit(bonsai.core.tool.Unit): return bim_props.area_unit elif unit_type == "VOLUMEUNIT": return bim_props.volume_unit + elif unit_type == "MASSUNIT": + return bim_props.mass_unit + elif unit_type == "TIMEUNIT": + return bim_props.time_unit else: assert_never(unit_type) @@ -359,6 +363,26 @@ class Unit(bonsai.core.tool.Unit): unit = bim_props.area_unit elif unit_type == "VOLUMEUNIT": unit = bim_props.volume_unit + elif unit_type == "MASSUNIT": + unit = bim_props.mass_unit + if unit == "KILOGRAM": + return "KILO" + elif unit == "GRAM": + return None + elif unit == "TON": + return "MEGA" + elif unit in ["POUND", "OUNCE"]: + return "CONVERSION" + else: + return None + + elif unit_type == "TIMEUNIT": + unit = bim_props.time_unit + if unit == "SECOND": + return None + else: + return "CONVERSION" + else: assert_never(unit_type) if "/" in unit: diff --git a/src/ifcopenshell-python/ifcopenshell/api/unit/assign_unit.py b/src/ifcopenshell-python/ifcopenshell/api/unit/assign_unit.py index d57acbd435..58abfd4ea4 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/unit/assign_unit.py +++ b/src/ifcopenshell-python/ifcopenshell/api/unit/assign_unit.py @@ -27,6 +27,8 @@ def assign_unit( length: Optional[dict] = None, area: Optional[dict] = None, volume: Optional[dict] = None, + mass: Optional[dict] = None, + time: Optional[dict] = None, ) -> ifcopenshell.entity_instance: """Assign default project units @@ -51,9 +53,11 @@ def assign_unit( # You need a project before you can assign units. ifcopenshell.api.root.create_entity(model, ifc_class="IfcProject") - # Millimeters and square meters + # Millimeters, square meters, kilograms, and seconds length = ifcopenshell.api.unit.add_si_unit(model, unit_type="LENGTHUNIT", prefix="MILLI") area = ifcopenshell.api.unit.add_si_unit(model, unit_type="AREAUNIT") + mass = ifcopenshell.api.unit.add_si_unit(model, unit_type="MASSUNIT", prefix="KILO") + time = ifcopenshell.api.unit.add_si_unit(model, unit_type="TIMEUNIT") # Make it our default units, if we are doing a metric building ifcopenshell.api.unit.assign_unit(model, units=[length, area]) @@ -71,6 +75,8 @@ def assign_unit( usecase.settings["length"] = length or {"is_metric": True, "raw": "MILLIMETERS"} usecase.settings["area"] = area or {"is_metric": True, "raw": "METERS"} usecase.settings["volume"] = volume or {"is_metric": True, "raw": "METERS"} + usecase.settings["mass"] = mass or {"is_metric": True, "raw": "KILOGRAM"} + usecase.settings["time"] = time or {"is_metric": True, "raw": "SECOND"} return usecase.execute() @@ -116,7 +122,45 @@ class Usecase: units.add(unit) unit_assignment.Units = list(units) + def create_time_conversion_unit(self, name: str, factor: float) -> ifcopenshell.entity_instance: + """Create a conversion-based time unit""" + dimensional_exponents = self.file.createIfcDimensionalExponents(0, 0, 1, 0, 0, 0, 0) + si_unit = self.file.createIfcSIUnit(None, "TIMEUNIT", None, "SECOND") + value_component = self.file.create_entity("IfcReal", **{"wrappedValue": factor}) + conversion_factor = self.file.createIfcMeasureWithUnit(value_component, si_unit) + return self.file.createIfcConversionBasedUnit(dimensional_exponents, "TIMEUNIT", name, conversion_factor) + + def create_mass_conversion_unit(self, name: str, factor: float) -> ifcopenshell.entity_instance: + """Create a conversion-based mass unit""" + dimensional_exponents = self.file.createIfcDimensionalExponents(0, 1, 0, 0, 0, 0, 0) # Mass dimension + si_unit = self.file.createIfcSIUnit(None, "MASSUNIT", "KILO", "GRAM") + value_component = self.file.create_entity("IfcReal", **{"wrappedValue": factor}) + conversion_factor = self.file.createIfcMeasureWithUnit(value_component, si_unit) + return self.file.createIfcConversionBasedUnit(dimensional_exponents, "MASSUNIT", name, conversion_factor) + def create_metric_unit(self, unit_type: str, data: dict) -> ifcopenshell.entity_instance: + if unit_type == "mass": + if data["raw"] == "KILOGRAM": + return self.file.createIfcSIUnit(None, "MASSUNIT", "KILO", "GRAM") + elif data["raw"] == "GRAM": + return self.file.createIfcSIUnit(None, "MASSUNIT", None, "GRAM") + elif data["raw"] == "TON": + return self.file.createIfcSIUnit(None, "MASSUNIT", "MEGA", "GRAM") + else: + return self.file.createIfcSIUnit(None, "MASSUNIT", "KILO", "GRAM") + + elif unit_type == "time": + if data["raw"] == "SECOND": + return self.file.createIfcSIUnit(None, "TIMEUNIT", None, "SECOND") + elif data["raw"] == "MINUTE": + return self.create_time_conversion_unit("minute", 60.0) + elif data["raw"] == "HOUR": + return self.create_time_conversion_unit("hour", 3600.0) + elif data["raw"] == "DAY": + return self.create_time_conversion_unit("day", 86400.0) + else: + return self.file.createIfcSIUnit(None, "TIMEUNIT", None, "SECOND") + type_prefix = "" if unit_type == "area": type_prefix = "SQUARE_" @@ -139,6 +183,26 @@ class Usecase: elif unit_type == "volume": dimensional_exponents = self.file.createIfcDimensionalExponents(3, 0, 0, 0, 0, 0, 0) name_prefix = "cubic" + elif unit_type == "mass": + if data["raw"] == "POUND": + return self.create_mass_conversion_unit("pound", 0.45359237) + elif data["raw"] == "OUNCE": + return self.create_mass_conversion_unit("ounce", 0.0283495) + else: + return self.create_mass_conversion_unit("pound", 0.45359237) + + elif unit_type == "time": + if data["raw"] == "SECOND": + return self.file.createIfcSIUnit(None, "TIMEUNIT", None, "SECOND") + elif data["raw"] == "MINUTE": + return self.create_time_conversion_unit("minute", 60.0) + elif data["raw"] == "HOUR": + return self.create_time_conversion_unit("hour", 3600.0) + elif data["raw"] == "DAY": + return self.create_time_conversion_unit("day", 86400.0) + else: + return self.file.createIfcSIUnit(None, "TIMEUNIT", None, "SECOND") + si_unit = self.file.createIfcSIUnit( None, "{}UNIT".format(unit_type.upper()), From 146301b0c01e9196c00fd88b4bf616300cb7d871 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Mon, 1 Sep 2025 22:32:52 +0200 Subject: [PATCH 15/25] use kilogram as default for SI and Seconds for Imperial --- src/bonsai/bonsai/bim/prop.py | 2 +- src/bonsai/bonsai/core/unit.py | 9 +++++++-- src/bonsai/bonsai/tool/unit.py | 8 +++----- .../ifcopenshell/api/unit/add_conversion_based_unit.py | 6 +++++- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py index eeea68eab9..93730b475b 100644 --- a/src/bonsai/bonsai/bim/prop.py +++ b/src/bonsai/bonsai/bim/prop.py @@ -590,7 +590,7 @@ class BIMProperties(PropertyGroup): items=[ ("KILOGRAM", "Kilogram", "Kilograms"), ("GRAM", "Gram", "Grams"), - ("POUND", "Pound (Mass)", "Pounds"), + ("POUND", "Pound", "Pounds"), ("OUNCE", "Ounce", "Ounces"), ("TON", "Ton", "Metric Tons"), ], diff --git a/src/bonsai/bonsai/core/unit.py b/src/bonsai/bonsai/core/unit.py index 8517343b37..2e8e627cb7 100644 --- a/src/bonsai/bonsai/core/unit.py +++ b/src/bonsai/bonsai/core/unit.py @@ -52,8 +52,13 @@ def assign_scene_units(ifc: type[tool.Ifc], unit: type[tool.Unit]) -> None: lengthunit = ifc.run("unit.add_conversion_based_unit", name=unit.get_scene_unit_name("LENGTHUNIT")) areaunit = ifc.run("unit.add_conversion_based_unit", name=unit.get_scene_unit_name("AREAUNIT")) volumeunit = ifc.run("unit.add_conversion_based_unit", name=unit.get_scene_unit_name("VOLUMEUNIT")) - massunit = ifc.run("unit.add_conversion_based_unit", name=unit.get_scene_unit_name("MASSUNIT")) - timeunit = ifc.run("unit.add_conversion_based_unit", name=unit.get_scene_unit_name("TIMEUNIT")) + massunit = ifc.run("unit.add_conversion_based_unit", name=unit.get_scene_unit_name("MASSUNIT").lower()) + + time_unit_name = unit.get_scene_unit_name("TIMEUNIT") + if time_unit_name == "SECOND": + timeunit = ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix=None) + else: + timeunit = ifc.run("unit.add_conversion_based_unit", name=time_unit_name.lower()) planeangleunit = ifc.run("unit.add_conversion_based_unit", name="degree") ifc.run("unit.assign_unit", units=[lengthunit, areaunit, volumeunit, planeangleunit, massunit, timeunit]) diff --git a/src/bonsai/bonsai/tool/unit.py b/src/bonsai/bonsai/tool/unit.py index b18cb6ff09..293136fb8c 100644 --- a/src/bonsai/bonsai/tool/unit.py +++ b/src/bonsai/bonsai/tool/unit.py @@ -365,24 +365,22 @@ class Unit(bonsai.core.tool.Unit): unit = bim_props.volume_unit elif unit_type == "MASSUNIT": unit = bim_props.mass_unit - if unit == "KILOGRAM": - return "KILO" - elif unit == "GRAM": + if unit == "GRAM": return None + elif unit == "KILOGRAM": + return "KILO" elif unit == "TON": return "MEGA" elif unit in ["POUND", "OUNCE"]: return "CONVERSION" else: return None - elif unit_type == "TIMEUNIT": unit = bim_props.time_unit if unit == "SECOND": return None else: return "CONVERSION" - else: assert_never(unit_type) if "/" in unit: diff --git a/src/ifcopenshell-python/ifcopenshell/api/unit/add_conversion_based_unit.py b/src/ifcopenshell-python/ifcopenshell/api/unit/add_conversion_based_unit.py index bb1e920257..51aa200810 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/unit/add_conversion_based_unit.py +++ b/src/ifcopenshell-python/ifcopenshell/api/unit/add_conversion_based_unit.py @@ -61,7 +61,11 @@ def add_conversion_based_unit( dimensions = ifcopenshell.util.unit.named_dimensions[unit_type] exponents = file.createIfcDimensionalExponents(*dimensions) si_name = ifcopenshell.util.unit.si_type_names[unit_type] - si_unit = file.createIfcSIUnit(UnitType=unit_type, Name=si_name) + + if unit_type == "MASSUNIT": + si_unit = file.createIfcSIUnit(UnitType=unit_type, Name=si_name, Prefix="KILO") + else: + si_unit = file.createIfcSIUnit(UnitType=unit_type, Name=si_name) conversion_real = ifcopenshell.util.unit.si_conversions.get(name, 1) value_component = file.create_entity("IfcReal", **{"wrappedValue": conversion_real}) From a4e76ed6eeebfd339e6dcc42354390a1b4ce8013 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Fri, 5 Sep 2025 20:22:56 +0200 Subject: [PATCH 16/25] Updated with core developer feedback: fixed for Tonne and added tests --- src/bonsai/bonsai/bim/prop.py | 4 +- src/bonsai/bonsai/tool/unit.py | 6 +- src/bonsai/test/core/test_unit.py | 155 +++++++++++- src/bonsai/test/tool/test_unit.py | 143 +++++++++++ .../api/unit/add_conversion_based_unit.py | 2 +- .../ifcopenshell/util/unit.py | 3 + .../test/util/test_unit.py | 230 ++++++++++++++++++ 7 files changed, 535 insertions(+), 8 deletions(-) diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py index 93730b475b..7b24d2a1d1 100644 --- a/src/bonsai/bonsai/bim/prop.py +++ b/src/bonsai/bonsai/bim/prop.py @@ -592,7 +592,7 @@ class BIMProperties(PropertyGroup): ("GRAM", "Gram", "Grams"), ("POUND", "Pound", "Pounds"), ("OUNCE", "Ounce", "Ounces"), - ("TON", "Ton", "Metric Tons"), + ("TONNE", "Tonne", "Metric Tons"), ], name="Mass Unit", default="KILOGRAM", @@ -601,7 +601,7 @@ class BIMProperties(PropertyGroup): time_unit: EnumProperty( items=[ ("SECOND", "Second", "Seconds"), - ("MINUTE", "Minutes", "Minutes"), + ("MINUTE", "Minute", "Minutes"), ("HOUR", "Hour", "Hours"), ("DAY", "Day", "Days"), ], diff --git a/src/bonsai/bonsai/tool/unit.py b/src/bonsai/bonsai/tool/unit.py index 293136fb8c..a3c9569d62 100644 --- a/src/bonsai/bonsai/tool/unit.py +++ b/src/bonsai/bonsai/tool/unit.py @@ -344,9 +344,9 @@ class Unit(bonsai.core.tool.Unit): elif unit_type == "VOLUMEUNIT": return bim_props.volume_unit elif unit_type == "MASSUNIT": - return bim_props.mass_unit + return bim_props.mass_unit.lower() elif unit_type == "TIMEUNIT": - return bim_props.time_unit + return bim_props.time_unit.lower() else: assert_never(unit_type) @@ -369,7 +369,7 @@ class Unit(bonsai.core.tool.Unit): return None elif unit == "KILOGRAM": return "KILO" - elif unit == "TON": + elif unit == "TONNE": return "MEGA" elif unit in ["POUND", "OUNCE"]: return "CONVERSION" diff --git a/src/bonsai/test/core/test_unit.py b/src/bonsai/test/core/test_unit.py index d79b6b83fb..d758b7829c 100644 --- a/src/bonsai/test/core/test_unit.py +++ b/src/bonsai/test/core/test_unit.py @@ -26,6 +26,9 @@ class TestAssignSceneUnits: unit.get_scene_unit_si_prefix("LENGTHUNIT").should_be_called().will_return("prefix") unit.get_scene_unit_si_prefix("AREAUNIT").should_be_called().will_return("prefix") unit.get_scene_unit_si_prefix("VOLUMEUNIT").should_be_called().will_return("prefix") + unit.get_scene_unit_si_prefix("MASSUNIT").should_be_called().will_return("KILO") + unit.get_scene_unit_si_prefix("TIMEUNIT").should_be_called().will_return(None) + ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="prefix").should_be_called().will_return( "lengthunit" ) @@ -38,7 +41,13 @@ class TestAssignSceneUnits: ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit") - ifc.run("unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit"]).should_be_called() + ifc.run("unit.add_si_unit", unit_type="MASSUNIT", prefix="KILO").should_be_called().will_return("massunit") + + ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix=None).should_be_called().will_return("timeunit") + + ifc.run( + "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"] + ).should_be_called() subject.assign_scene_units(ifc, unit) def test_creating_and_assigning_imperial_units(self, ifc, unit): @@ -54,7 +63,15 @@ class TestAssignSceneUnits: ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit") - ifc.run("unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit"]).should_be_called() + unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("massname") + ifc.run("unit.add_conversion_based_unit", name="massname").should_be_called().will_return("massunit") + + unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("timename") + ifc.run("unit.add_conversion_based_unit", name="timename").should_be_called().will_return("timeunit") + + ifc.run( + "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"] + ).should_be_called() subject.assign_scene_units(ifc, unit) @@ -162,3 +179,137 @@ class TestEditUnit: unit.import_units().should_be_called() unit.clear_active_unit().should_be_called() subject.edit_unit(ifc, unit, unit="unit") + + +class TestAssignSceneUnitsWithMassAndTime: + def test_creating_and_assigning_metric_units_with_mass_and_time(self, ifc, unit): + unit.is_scene_unit_metric().should_be_called().will_return(True) + unit.get_scene_unit_si_prefix("LENGTHUNIT").should_be_called().will_return("prefix") + unit.get_scene_unit_si_prefix("AREAUNIT").should_be_called().will_return("prefix") + unit.get_scene_unit_si_prefix("VOLUMEUNIT").should_be_called().will_return("prefix") + unit.get_scene_unit_si_prefix("MASSUNIT").should_be_called().will_return("KILO") + unit.get_scene_unit_si_prefix("TIMEUNIT").should_be_called().will_return("CONVERSION") + unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("minute") + + ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="prefix").should_be_called().will_return( + "lengthunit" + ) + ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix="prefix").should_be_called().will_return("areaunit") + ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix="prefix").should_be_called().will_return( + "volumeunit" + ) + ifc.run("unit.add_si_unit", unit_type="MASSUNIT", prefix="KILO").should_be_called().will_return("massunit") + ifc.run("unit.add_conversion_based_unit", name="minute").should_be_called().will_return("timeunit") + ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit") + + ifc.run( + "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"] + ).should_be_called() + subject.assign_scene_units(ifc, unit) + + def test_creating_and_assigning_imperial_units_with_mass_and_time(self, ifc, unit): + unit.is_scene_unit_metric().should_be_called().will_return(False) + unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("foot") + unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("square foot") + unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("cubic foot") + unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("pound") + unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("SECOND") + + ifc.run("unit.add_conversion_based_unit", name="foot").should_be_called().will_return("lengthunit") + ifc.run("unit.add_conversion_based_unit", name="square foot").should_be_called().will_return("areaunit") + ifc.run("unit.add_conversion_based_unit", name="cubic foot").should_be_called().will_return("volumeunit") + ifc.run("unit.add_conversion_based_unit", name="pound").should_be_called().will_return("massunit") + ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix=None).should_be_called().will_return("timeunit") + ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit") + + ifc.run( + "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"] + ).should_be_called() + subject.assign_scene_units(ifc, unit) + + def test_creating_and_assigning_conversion_based_mass_units(self, ifc, unit): + unit.is_scene_unit_metric().should_be_called().will_return(False) + unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("foot") + unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("square foot") + unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("cubic foot") + unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("tonne") + unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("minute") + + ifc.run("unit.add_conversion_based_unit", name="foot").should_be_called().will_return("lengthunit") + ifc.run("unit.add_conversion_based_unit", name="square foot").should_be_called().will_return("areaunit") + ifc.run("unit.add_conversion_based_unit", name="cubic foot").should_be_called().will_return("volumeunit") + ifc.run("unit.add_conversion_based_unit", name="tonne").should_be_called().will_return("massunit") + ifc.run("unit.add_conversion_based_unit", name="minute").should_be_called().will_return("timeunit") + ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit") + + ifc.run( + "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"] + ).should_be_called() + subject.assign_scene_units(ifc, unit) + + +class TestAddTimeUnit: + def test_adding_si_time_unit(self, ifc, unit): + ifc.run("unit.add_si_unit", unit_type="TIMEUNIT").should_be_called().will_return("unit") + unit.import_units().should_be_called() + assert subject.add_si_unit(ifc, unit, unit_type="TIMEUNIT") == "unit" + + def test_adding_conversion_based_time_unit_minute(self, ifc, unit): + ifc.run("unit.add_conversion_based_unit", name="minute").should_be_called().will_return("unit") + unit.import_units().should_be_called() + assert subject.add_conversion_based_unit(ifc, unit, name="minute") == "unit" + + def test_adding_conversion_based_time_unit_hour(self, ifc, unit): + ifc.run("unit.add_conversion_based_unit", name="hour").should_be_called().will_return("unit") + unit.import_units().should_be_called() + assert subject.add_conversion_based_unit(ifc, unit, name="hour") == "unit" + + def test_adding_conversion_based_time_unit_day(self, ifc, unit): + ifc.run("unit.add_conversion_based_unit", name="day").should_be_called().will_return("unit") + unit.import_units().should_be_called() + assert subject.add_conversion_based_unit(ifc, unit, name="day") == "unit" + + +class TestAssignMassAndTimeUnits: + def test_assigning_mass_unit(self, ifc, unit): + ifc.run("unit.assign_unit", units=["massunit"]).should_be_called() + unit.import_units().should_be_called() + subject.assign_unit(ifc, unit, unit="massunit") + + def test_assigning_time_unit(self, ifc, unit): + ifc.run("unit.assign_unit", units=["timeunit"]).should_be_called() + unit.import_units().should_be_called() + subject.assign_unit(ifc, unit, unit="timeunit") + + def test_assigning_multiple_mass_and_time_units(self, ifc, unit): + ifc.run("unit.assign_unit", units=["massunit"]).should_be_called() + unit.import_units().should_be_called() + ifc.run("unit.assign_unit", units=["timeunit"]).should_be_called() + unit.import_units().should_be_called() + + subject.assign_unit(ifc, unit, unit="massunit") + subject.assign_unit(ifc, unit, unit="timeunit") + + +class TestUnassignMassAndTimeUnits: + def test_unassigning_mass_unit(self, ifc, unit): + ifc.run("unit.unassign_unit", units=["massunit"]).should_be_called() + unit.import_units().should_be_called() + subject.unassign_unit(ifc, unit, unit="massunit") + + def test_unassigning_time_unit(self, ifc, unit): + ifc.run("unit.unassign_unit", units=["timeunit"]).should_be_called() + unit.import_units().should_be_called() + subject.unassign_unit(ifc, unit, unit="timeunit") + + +class TestRemoveMassAndTimeUnits: + def test_removing_mass_unit(self, ifc, unit): + ifc.run("unit.remove_unit", unit="massunit").should_be_called() + unit.import_units().should_be_called() + subject.remove_unit(ifc, unit, unit="massunit") + + def test_removing_time_unit(self, ifc, unit): + ifc.run("unit.remove_unit", unit="timeunit").should_be_called() + unit.import_units().should_be_called() + subject.remove_unit(ifc, unit, unit="timeunit") diff --git a/src/bonsai/test/tool/test_unit.py b/src/bonsai/test/tool/test_unit.py index 212c72a559..190a8fa302 100644 --- a/src/bonsai/test/tool/test_unit.py +++ b/src/bonsai/test/tool/test_unit.py @@ -135,6 +135,35 @@ class TestGetSceneUnitName(NewFile): bpy.context.scene.unit_settings.system = "NONE" assert subject.get_scene_unit_name("LENGTHUNIT") == "foot" + def test_getting_mass_unit_names(self): + """Test getting mass unit names for different systems""" + assert bpy.context.scene + props = tool.Blender.get_bim_props() + props.mass_unit = "GRAM" + assert subject.get_scene_unit_name("MASSUNIT") == "gram" + props.mass_unit = "KILOGRAM" + assert subject.get_scene_unit_name("MASSUNIT") == "kilogram" + props.mass_unit = "POUND" + assert subject.get_scene_unit_name("MASSUNIT") == "pound" + props.mass_unit = "OUNCE" + assert subject.get_scene_unit_name("MASSUNIT") == "ounce" + props.mass_unit = "TONNE" + assert subject.get_scene_unit_name("MASSUNIT") == "tonne" + + def test_getting_time_unit_names(self): + """Test getting time unit names for different systems""" + assert bpy.context.scene + props = tool.Blender.get_bim_props() + + props.time_unit = "SECOND" + assert subject.get_scene_unit_name("TIMEUNIT") == "second" + props.time_unit = "MINUTE" + assert subject.get_scene_unit_name("TIMEUNIT") == "minute" + props.time_unit = "HOUR" + assert subject.get_scene_unit_name("TIMEUNIT") == "hour" + props.time_unit = "DAY" + assert subject.get_scene_unit_name("TIMEUNIT") == "day" + class TestGetSceneUnitSIPrefix: def test_run(self): @@ -162,6 +191,30 @@ class TestGetSceneUnitSIPrefix: props.volume_unit = "MILLI/CUBIC_METRE" assert subject.get_scene_unit_si_prefix("VOLUMEUNIT") == "MILLI" + def test_mass_and_time_unit_prefixes(self): + assert bpy.context.scene + props = tool.Blender.get_bim_props() + + props.mass_unit = "KILOGRAM" + assert subject.get_scene_unit_si_prefix("MASSUNIT") == "KILO" + props.mass_unit = "GRAM" + assert subject.get_scene_unit_si_prefix("MASSUNIT") is None + props.mass_unit = "POUND" + assert subject.get_scene_unit_si_prefix("MASSUNIT") == "CONVERSION" + props.mass_unit = "OUNCE" + assert subject.get_scene_unit_si_prefix("MASSUNIT") == "CONVERSION" + props.mass_unit = "TONNE" + assert subject.get_scene_unit_si_prefix("MASSUNIT") == "MEGA" + + props.time_unit = "SECOND" + assert subject.get_scene_unit_si_prefix("TIMEUNIT") is None + props.time_unit = "MINUTE" + assert subject.get_scene_unit_si_prefix("TIMEUNIT") == "CONVERSION" + props.time_unit = "HOUR" + assert subject.get_scene_unit_si_prefix("TIMEUNIT") == "CONVERSION" + props.time_unit = "DAY" + assert subject.get_scene_unit_si_prefix("TIMEUNIT") == "CONVERSION" + class TestImportUnitAttributes(NewFile): def test_importing_derived_units(self): @@ -298,6 +351,96 @@ class TestImportUnits(NewFile): assert props.units[5].unit_type == unit6.UnitType assert props.units[5].ifc_class == unit6.is_a() + def test_importing_mass_and_time_units(self): + """Test importing mass and time conversion based units""" + ifc = ifcopenshell.api.project.create_file() + tool.Ifc.set(ifc) + + tonne_unit = ifcopenshell.api.unit.add_conversion_based_unit(ifc, name="tonne") + pound_unit = ifcopenshell.api.unit.add_conversion_based_unit(ifc, name="pound") + ounce_unit = ifcopenshell.api.unit.add_conversion_based_unit(ifc, name="ounce") + + minute_unit = ifcopenshell.api.unit.add_conversion_based_unit(ifc, name="minute") + hour_unit = ifcopenshell.api.unit.add_conversion_based_unit(ifc, name="hour") + day_unit = ifcopenshell.api.unit.add_conversion_based_unit(ifc, name="day") + + kg_unit = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="MASSUNIT", prefix="KILO") + gram_unit = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="MASSUNIT") + second_unit = ifcopenshell.api.unit.add_si_unit(ifc, unit_type="TIMEUNIT") + + ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject") + ifcopenshell.api.unit.assign_unit(ifc, units=[tonne_unit, minute_unit, kg_unit]) + + subject.import_units() + props = tool.Unit.get_unit_props() + + assert len(props.units) == 15 + + unit_ids = [u.ifc_definition_id for u in props.units] + assert tonne_unit.id() in unit_ids + assert pound_unit.id() in unit_ids + assert ounce_unit.id() in unit_ids + assert minute_unit.id() in unit_ids + assert hour_unit.id() in unit_ids + assert day_unit.id() in unit_ids + assert kg_unit.id() in unit_ids + assert gram_unit.id() in unit_ids + assert second_unit.id() in unit_ids + + tonne_prop = next(u for u in props.units if u.ifc_definition_id == tonne_unit.id()) + assert tonne_prop.name == "tonne" + assert tonne_prop.unit_type == "MASSUNIT" + assert tonne_prop.is_assigned is True + assert tonne_prop.ifc_class == "IfcConversionBasedUnit" + + pound_prop = next(u for u in props.units if u.ifc_definition_id == pound_unit.id()) + assert pound_prop.name == "pound" + assert pound_prop.unit_type == "MASSUNIT" + assert pound_prop.is_assigned is False + assert pound_prop.ifc_class == "IfcConversionBasedUnit" + + ounce_prop = next(u for u in props.units if u.ifc_definition_id == ounce_unit.id()) + assert ounce_prop.name == "ounce" + assert ounce_prop.unit_type == "MASSUNIT" + assert ounce_prop.is_assigned is False + assert ounce_prop.ifc_class == "IfcConversionBasedUnit" + + kg_prop = next(u for u in props.units if u.ifc_definition_id == kg_unit.id()) + assert kg_prop.name == "KILOGRAM" + assert kg_prop.unit_type == "MASSUNIT" + assert kg_prop.is_assigned is True + assert kg_prop.ifc_class == "IfcSIUnit" + + gram_prop = next(u for u in props.units if u.ifc_definition_id == gram_unit.id()) + assert gram_prop.name == "GRAM" + assert gram_prop.unit_type == "MASSUNIT" + assert gram_prop.is_assigned is False + assert gram_prop.ifc_class == "IfcSIUnit" + + minute_prop = next(u for u in props.units if u.ifc_definition_id == minute_unit.id()) + assert minute_prop.name == "minute" + assert minute_prop.unit_type == "TIMEUNIT" + assert minute_prop.is_assigned is True + assert minute_prop.ifc_class == "IfcConversionBasedUnit" + + hour_prop = next(u for u in props.units if u.ifc_definition_id == hour_unit.id()) + assert hour_prop.name == "hour" + assert hour_prop.unit_type == "TIMEUNIT" + assert hour_prop.is_assigned is False + assert hour_prop.ifc_class == "IfcConversionBasedUnit" + + day_prop = next(u for u in props.units if u.ifc_definition_id == day_unit.id()) + assert day_prop.name == "day" + assert day_prop.unit_type == "TIMEUNIT" + assert day_prop.is_assigned is False + assert day_prop.ifc_class == "IfcConversionBasedUnit" + + second_prop = next(u for u in props.units if u.ifc_definition_id == second_unit.id()) + assert second_prop.name == "SECOND" + assert second_prop.unit_type == "TIMEUNIT" + assert second_prop.is_assigned is False + assert second_prop.ifc_class == "IfcSIUnit" + class TestIsSceneUnitMetric(NewFile): def test_run(self): diff --git a/src/ifcopenshell-python/ifcopenshell/api/unit/add_conversion_based_unit.py b/src/ifcopenshell-python/ifcopenshell/api/unit/add_conversion_based_unit.py index 51aa200810..6a4f17f8fd 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/unit/add_conversion_based_unit.py +++ b/src/ifcopenshell-python/ifcopenshell/api/unit/add_conversion_based_unit.py @@ -32,7 +32,7 @@ def add_conversion_based_unit( function. You can choose from one of: inch, foot, yard, mile, square inch, square foot, square yard, acre, square mile, cubic inch, cubic foot, cubic yard, litre, fluid ounce UK, fluid ounce US, pint UK, pint - US, gallon UK, gallon US, degree, ounce, pound, ton UK, ton US, lbf, + US, gallon UK, gallon US, degree, ounce, pound, ton UK, ton US, tonne, lbf, kip, psi, ksi, minute, hour, day, btu, and fahrenheit. :param name: A converted name chosen from the list above. diff --git a/src/ifcopenshell-python/ifcopenshell/util/unit.py b/src/ifcopenshell-python/ifcopenshell/util/unit.py index cc767fe3a7..86254cb826 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/unit.py +++ b/src/ifcopenshell-python/ifcopenshell/util/unit.py @@ -209,6 +209,7 @@ si_conversions = { "pound": 0.454, "ton UK": 1016.0469088, "ton US": 907.18474, + "tonne": 1000.0, "lbf": 4.4482216153, "kip": 4448.2216153, "psi": 6894.7572932, @@ -253,6 +254,7 @@ imperial_types = { "pound": "MASSUNIT", "ton UK": "MASSUNIT", "ton US": "MASSUNIT", + "tonne": "MASSUNIT", "lbf": "FORCEUNIT", "kip": "FORCEUNIT", "psi": "PRESSUREUNIT", @@ -323,6 +325,7 @@ unit_symbols = { "pound": "lb", "ton UK": "ton", "ton US": "ton", + "tonne": "t", "lbf": "lbf", "kip": "kip", "psi": "psi", diff --git a/src/ifcopenshell-python/test/util/test_unit.py b/src/ifcopenshell-python/test/util/test_unit.py index 6e28a28e70..ff7b7eede5 100644 --- a/src/ifcopenshell-python/test/util/test_unit.py +++ b/src/ifcopenshell-python/test/util/test_unit.py @@ -391,3 +391,233 @@ class TestConvertFileLengthUnitsIFC4(test.bootstrap.IFC4, TestConvertFileLengthU class TestConvertFileLengthUnitsIFC4X3(test.bootstrap.IFC4X3, TestConvertFileLengthUnits): pass + + +class TestAddConversionBasedUnitMassAndTime(test.bootstrap.IFC4): + def test_adding_mass_units_creates_proper_massunit(self): + mass_units = [ + ("tonne", 1000.0), + ("pound", 0.454), + ("ounce", 0.02835), + ("ton UK", 1016.0469088), + ("ton US", 907.18474), + ] + + for name, expected_conversion in mass_units: + unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name=name) + + assert unit.is_a("IfcConversionBasedUnit") + + assert unit.UnitType == "MASSUNIT" + + assert unit.Name == name + + actual_conversion = unit.ConversionFactor.ValueComponent.wrappedValue + assert actual_conversion == expected_conversion + + target_unit = unit.ConversionFactor.UnitComponent + assert target_unit.is_a("IfcSIUnit") + assert target_unit.UnitType == "MASSUNIT" + assert target_unit.Name == "GRAM" + assert target_unit.Prefix == "KILO" + + def test_adding_time_units_creates_proper_timeunit(self): + time_units = [ + ("minute", 60), + ("hour", 3600), + ("day", 86400), + ] + + for name, expected_conversion in time_units: + unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name=name) + + assert unit.is_a("IfcConversionBasedUnit") + + assert unit.UnitType == "TIMEUNIT" + + assert unit.Name == name + + actual_conversion = unit.ConversionFactor.ValueComponent.wrappedValue + assert actual_conversion == expected_conversion + + target_unit = unit.ConversionFactor.UnitComponent + assert target_unit.is_a("IfcSIUnit") + assert target_unit.UnitType == "TIMEUNIT" + assert target_unit.Name == "SECOND" + assert target_unit.Prefix is None + + def test_mass_unit_integration_with_project(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + + kg_unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="MASSUNIT", prefix="KILO") + tonne_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="tonne") + pound_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="pound") + + ifcopenshell.api.unit.assign_unit(self.file, units=[kg_unit]) + project_mass_unit = subject.get_project_unit(self.file, "MASSUNIT") + assert project_mass_unit == kg_unit + + unit_assignment = subject.get_unit_assignment(self.file) + assert unit_assignment + assigned_units = list(unit_assignment.Units or []) + assert kg_unit in assigned_units + + ifcopenshell.api.unit.assign_unit(self.file, units=[tonne_unit]) + project_mass_unit = subject.get_project_unit(self.file, "MASSUNIT") + assert project_mass_unit == tonne_unit + + unit_assignment = subject.get_unit_assignment(self.file) + assigned_units = list(unit_assignment.Units or []) + assert tonne_unit in assigned_units + + length_unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT") + ifcopenshell.api.unit.assign_unit(self.file, units=[pound_unit, length_unit]) + + unit_assignment = subject.get_unit_assignment(self.file) + assigned_units = list(unit_assignment.Units or []) + assert pound_unit in assigned_units + assert length_unit in assigned_units + + def test_time_unit_integration_with_project(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + + second_unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="TIMEUNIT") + minute_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="minute") + hour_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="hour") + + ifcopenshell.api.unit.assign_unit(self.file, units=[second_unit]) + project_time_unit = subject.get_project_unit(self.file, "TIMEUNIT") + assert project_time_unit == second_unit + + unit_assignment = subject.get_unit_assignment(self.file) + assert unit_assignment + assigned_units = list(unit_assignment.Units or []) + assert second_unit in assigned_units + + ifcopenshell.api.unit.assign_unit(self.file, units=[minute_unit]) + project_time_unit = subject.get_project_unit(self.file, "TIMEUNIT") + assert project_time_unit == minute_unit + + unit_assignment = subject.get_unit_assignment(self.file) + assigned_units = list(unit_assignment.Units or []) + assert minute_unit in assigned_units + + length_unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT") + ifcopenshell.api.unit.assign_unit(self.file, units=[hour_unit, length_unit]) + + unit_assignment = subject.get_unit_assignment(self.file) + assigned_units = list(unit_assignment.Units or []) + assert hour_unit in assigned_units + assert length_unit in assigned_units + + def test_multiple_unit_types_can_coexist(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + + kg_unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="MASSUNIT", prefix="KILO") + minute_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="minute") + meter_unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT") + + ifcopenshell.api.unit.assign_unit(self.file, units=[kg_unit, minute_unit, meter_unit]) + + unit_assignment = subject.get_unit_assignment(self.file) + assigned_units = list(unit_assignment.Units or []) + + assert kg_unit in assigned_units + assert minute_unit in assigned_units + assert meter_unit in assigned_units + + assert subject.get_project_unit(self.file, "MASSUNIT") == kg_unit + assert subject.get_project_unit(self.file, "TIMEUNIT") == minute_unit + assert subject.get_project_unit(self.file, "LENGTHUNIT") == meter_unit + + def test_unknown_mass_or_time_units_fall_back_to_userdefined(self): + unknown_mass_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="unknown_mass_unit") + assert unknown_mass_unit.UnitType == "USERDEFINED" + + unknown_time_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="unknown_time_unit") + assert unknown_time_unit.UnitType == "USERDEFINED" + + def test_mass_units_individually(self): + tonne_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="tonne") + assert tonne_unit.UnitType == "MASSUNIT" + assert tonne_unit.ConversionFactor.ValueComponent.wrappedValue == 1000.0 + + pound_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="pound") + assert pound_unit.UnitType == "MASSUNIT" + assert pound_unit.ConversionFactor.ValueComponent.wrappedValue == 0.454 + + ounce_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="ounce") + assert ounce_unit.UnitType == "MASSUNIT" + assert ounce_unit.ConversionFactor.ValueComponent.wrappedValue == 0.02835 + + def test_time_units_individually(self): + minute_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="minute") + assert minute_unit.UnitType == "TIMEUNIT" + assert minute_unit.ConversionFactor.ValueComponent.wrappedValue == 60 + + hour_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="hour") + assert hour_unit.UnitType == "TIMEUNIT" + assert hour_unit.ConversionFactor.ValueComponent.wrappedValue == 3600 + + day_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="day") + assert day_unit.UnitType == "TIMEUNIT" + assert day_unit.ConversionFactor.ValueComponent.wrappedValue == 86400 + + +class TestMassAndTimeUnitDictionaries(test.bootstrap.IFC4): + def test_mass_units_in_imperial_types(self): + expected_mass_units = ["ounce", "pound", "ton UK", "ton US", "tonne"] + + def test_mass_units_in_imperial_types(self): + expected_mass_units = ["ounce", "pound", "ton UK", "ton US", "tonne"] + + for unit_name in expected_mass_units: + assert unit_name in subject.imperial_types + assert subject.imperial_types[unit_name] == "MASSUNIT" + + def test_time_units_in_imperial_types(self): + expected_time_units = ["minute", "hour", "day"] + + for unit_name in expected_time_units: + assert unit_name in subject.imperial_types + assert subject.imperial_types[unit_name] == "TIMEUNIT" + + def test_mass_units_have_conversion_factors(self): + expected_mass_conversions = { + "ounce": 0.02835, + "pound": 0.454, + "ton UK": 1016.0469088, + "ton US": 907.18474, + "tonne": 1000.0, + } + + for unit_name, expected_factor in expected_mass_conversions.items(): + assert unit_name in subject.si_conversions + assert subject.si_conversions[unit_name] == expected_factor + + def test_time_units_have_conversion_factors(self): + expected_time_conversions = { + "minute": 60, + "hour": 3600, + "day": 86400, + } + + for unit_name, expected_factor in expected_time_conversions.items(): + assert unit_name in subject.si_conversions + assert subject.si_conversions[unit_name] == expected_factor + + def test_mass_and_time_units_have_symbols(self): + expected_symbols = { + "ounce": "oz", + "pound": "lb", + "ton UK": "ton", + "ton US": "ton", + "tonne": "t", + "minute": "min", + "hour": "hr", + "day": "day", + } + + for unit_name, expected_symbol in expected_symbols.items(): + assert unit_name in subject.unit_symbols + assert subject.unit_symbols[unit_name] == expected_symbol From 169ca806c06608da134f0c0f64c6ca334750c7c2 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Wed, 10 Sep 2025 23:07:47 +0200 Subject: [PATCH 17/25] Updated with core developer feedback --- src/bonsai/test/core/test_unit.py | 209 ++++------------ .../ifcopenshell/api/unit/assign_unit.py | 74 +----- .../unit/test_add_conversion_based_unit.py | 108 ++++++++ .../test/util/test_unit.py | 230 ------------------ 4 files changed, 167 insertions(+), 454 deletions(-) diff --git a/src/bonsai/test/core/test_unit.py b/src/bonsai/test/core/test_unit.py index d758b7829c..00ffe1dd5e 100644 --- a/src/bonsai/test/core/test_unit.py +++ b/src/bonsai/test/core/test_unit.py @@ -32,45 +32,78 @@ class TestAssignSceneUnits: ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="prefix").should_be_called().will_return( "lengthunit" ) - ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix="prefix").should_be_called().will_return("areaunit") - ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix="prefix").should_be_called().will_return( "volumeunit" ) - + ifc.run("unit.add_si_unit", unit_type="MASSUNIT", prefix="KILO").should_be_called().will_return("massunit") + ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix=None).should_be_called().will_return("timeunit") ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit") - ifc.run("unit.add_si_unit", unit_type="MASSUNIT", prefix="KILO").should_be_called().will_return("massunit") - - ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix=None).should_be_called().will_return("timeunit") - ifc.run( - "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"] + "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "massunit", "timeunit", "planeangleunit"] ).should_be_called() subject.assign_scene_units(ifc, unit) def test_creating_and_assigning_imperial_units(self, ifc, unit): unit.is_scene_unit_metric().should_be_called().will_return(False) - unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("lengthname") - ifc.run("unit.add_conversion_based_unit", name="lengthname").should_be_called().will_return("lengthunit") - - unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("areaname") - ifc.run("unit.add_conversion_based_unit", name="areaname").should_be_called().will_return("areaunit") - - unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("volumename") - ifc.run("unit.add_conversion_based_unit", name="volumename").should_be_called().will_return("volumeunit") + unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("foot") + unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("square foot") + unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("cubic foot") + unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("pound") + unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("SECOND") + ifc.run("unit.add_conversion_based_unit", name="foot").should_be_called().will_return("lengthunit") + ifc.run("unit.add_conversion_based_unit", name="square foot").should_be_called().will_return("areaunit") + ifc.run("unit.add_conversion_based_unit", name="cubic foot").should_be_called().will_return("volumeunit") + ifc.run("unit.add_conversion_based_unit", name="pound").should_be_called().will_return("massunit") + ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix=None).should_be_called().will_return("timeunit") ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit") - unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("massname") - ifc.run("unit.add_conversion_based_unit", name="massname").should_be_called().will_return("massunit") + ifc.run( + "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "massunit", "timeunit", "planeangleunit"] + ).should_be_called() + subject.assign_scene_units(ifc, unit) - unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("timename") - ifc.run("unit.add_conversion_based_unit", name="timename").should_be_called().will_return("timeunit") + def test_creating_metric_units_with_conversion_based_mass_and_time(self, ifc, unit): + unit.is_scene_unit_metric().should_be_called().will_return(True) + unit.get_scene_unit_si_prefix("LENGTHUNIT").should_be_called().will_return("MILLI") + unit.get_scene_unit_si_prefix("AREAUNIT").should_be_called().will_return(None) + unit.get_scene_unit_si_prefix("VOLUMEUNIT").should_be_called().will_return(None) + unit.get_scene_unit_si_prefix("MASSUNIT").should_be_called().will_return("CONVERSION") + unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("tonne") + unit.get_scene_unit_si_prefix("TIMEUNIT").should_be_called().will_return("CONVERSION") + unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("minute") + + ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="MILLI").should_be_called().will_return("lengthunit") + ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix=None).should_be_called().will_return("areaunit") + ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix=None).should_be_called().will_return("volumeunit") + ifc.run("unit.add_conversion_based_unit", name="tonne").should_be_called().will_return("massunit") + ifc.run("unit.add_conversion_based_unit", name="minute").should_be_called().will_return("timeunit") + ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit") ifc.run( - "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"] + "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "massunit", "timeunit", "planeangleunit"] + ).should_be_called() + subject.assign_scene_units(ifc, unit) + + def test_creating_imperial_units_with_conversion_based_units(self, ifc, unit): + unit.is_scene_unit_metric().should_be_called().will_return(False) + unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("inch") + unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("square inch") + unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("cubic inch") + unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("ounce") + unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("hour") + + ifc.run("unit.add_conversion_based_unit", name="inch").should_be_called().will_return("lengthunit") + ifc.run("unit.add_conversion_based_unit", name="square inch").should_be_called().will_return("areaunit") + ifc.run("unit.add_conversion_based_unit", name="cubic inch").should_be_called().will_return("volumeunit") + ifc.run("unit.add_conversion_based_unit", name="ounce").should_be_called().will_return("massunit") + ifc.run("unit.add_conversion_based_unit", name="hour").should_be_called().will_return("timeunit") + ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit") + + ifc.run( + "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "massunit", "timeunit", "planeangleunit"] ).should_be_called() subject.assign_scene_units(ifc, unit) @@ -179,137 +212,3 @@ class TestEditUnit: unit.import_units().should_be_called() unit.clear_active_unit().should_be_called() subject.edit_unit(ifc, unit, unit="unit") - - -class TestAssignSceneUnitsWithMassAndTime: - def test_creating_and_assigning_metric_units_with_mass_and_time(self, ifc, unit): - unit.is_scene_unit_metric().should_be_called().will_return(True) - unit.get_scene_unit_si_prefix("LENGTHUNIT").should_be_called().will_return("prefix") - unit.get_scene_unit_si_prefix("AREAUNIT").should_be_called().will_return("prefix") - unit.get_scene_unit_si_prefix("VOLUMEUNIT").should_be_called().will_return("prefix") - unit.get_scene_unit_si_prefix("MASSUNIT").should_be_called().will_return("KILO") - unit.get_scene_unit_si_prefix("TIMEUNIT").should_be_called().will_return("CONVERSION") - unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("minute") - - ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="prefix").should_be_called().will_return( - "lengthunit" - ) - ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix="prefix").should_be_called().will_return("areaunit") - ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix="prefix").should_be_called().will_return( - "volumeunit" - ) - ifc.run("unit.add_si_unit", unit_type="MASSUNIT", prefix="KILO").should_be_called().will_return("massunit") - ifc.run("unit.add_conversion_based_unit", name="minute").should_be_called().will_return("timeunit") - ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit") - - ifc.run( - "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"] - ).should_be_called() - subject.assign_scene_units(ifc, unit) - - def test_creating_and_assigning_imperial_units_with_mass_and_time(self, ifc, unit): - unit.is_scene_unit_metric().should_be_called().will_return(False) - unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("foot") - unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("square foot") - unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("cubic foot") - unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("pound") - unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("SECOND") - - ifc.run("unit.add_conversion_based_unit", name="foot").should_be_called().will_return("lengthunit") - ifc.run("unit.add_conversion_based_unit", name="square foot").should_be_called().will_return("areaunit") - ifc.run("unit.add_conversion_based_unit", name="cubic foot").should_be_called().will_return("volumeunit") - ifc.run("unit.add_conversion_based_unit", name="pound").should_be_called().will_return("massunit") - ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix=None).should_be_called().will_return("timeunit") - ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit") - - ifc.run( - "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"] - ).should_be_called() - subject.assign_scene_units(ifc, unit) - - def test_creating_and_assigning_conversion_based_mass_units(self, ifc, unit): - unit.is_scene_unit_metric().should_be_called().will_return(False) - unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("foot") - unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("square foot") - unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("cubic foot") - unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("tonne") - unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("minute") - - ifc.run("unit.add_conversion_based_unit", name="foot").should_be_called().will_return("lengthunit") - ifc.run("unit.add_conversion_based_unit", name="square foot").should_be_called().will_return("areaunit") - ifc.run("unit.add_conversion_based_unit", name="cubic foot").should_be_called().will_return("volumeunit") - ifc.run("unit.add_conversion_based_unit", name="tonne").should_be_called().will_return("massunit") - ifc.run("unit.add_conversion_based_unit", name="minute").should_be_called().will_return("timeunit") - ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit") - - ifc.run( - "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"] - ).should_be_called() - subject.assign_scene_units(ifc, unit) - - -class TestAddTimeUnit: - def test_adding_si_time_unit(self, ifc, unit): - ifc.run("unit.add_si_unit", unit_type="TIMEUNIT").should_be_called().will_return("unit") - unit.import_units().should_be_called() - assert subject.add_si_unit(ifc, unit, unit_type="TIMEUNIT") == "unit" - - def test_adding_conversion_based_time_unit_minute(self, ifc, unit): - ifc.run("unit.add_conversion_based_unit", name="minute").should_be_called().will_return("unit") - unit.import_units().should_be_called() - assert subject.add_conversion_based_unit(ifc, unit, name="minute") == "unit" - - def test_adding_conversion_based_time_unit_hour(self, ifc, unit): - ifc.run("unit.add_conversion_based_unit", name="hour").should_be_called().will_return("unit") - unit.import_units().should_be_called() - assert subject.add_conversion_based_unit(ifc, unit, name="hour") == "unit" - - def test_adding_conversion_based_time_unit_day(self, ifc, unit): - ifc.run("unit.add_conversion_based_unit", name="day").should_be_called().will_return("unit") - unit.import_units().should_be_called() - assert subject.add_conversion_based_unit(ifc, unit, name="day") == "unit" - - -class TestAssignMassAndTimeUnits: - def test_assigning_mass_unit(self, ifc, unit): - ifc.run("unit.assign_unit", units=["massunit"]).should_be_called() - unit.import_units().should_be_called() - subject.assign_unit(ifc, unit, unit="massunit") - - def test_assigning_time_unit(self, ifc, unit): - ifc.run("unit.assign_unit", units=["timeunit"]).should_be_called() - unit.import_units().should_be_called() - subject.assign_unit(ifc, unit, unit="timeunit") - - def test_assigning_multiple_mass_and_time_units(self, ifc, unit): - ifc.run("unit.assign_unit", units=["massunit"]).should_be_called() - unit.import_units().should_be_called() - ifc.run("unit.assign_unit", units=["timeunit"]).should_be_called() - unit.import_units().should_be_called() - - subject.assign_unit(ifc, unit, unit="massunit") - subject.assign_unit(ifc, unit, unit="timeunit") - - -class TestUnassignMassAndTimeUnits: - def test_unassigning_mass_unit(self, ifc, unit): - ifc.run("unit.unassign_unit", units=["massunit"]).should_be_called() - unit.import_units().should_be_called() - subject.unassign_unit(ifc, unit, unit="massunit") - - def test_unassigning_time_unit(self, ifc, unit): - ifc.run("unit.unassign_unit", units=["timeunit"]).should_be_called() - unit.import_units().should_be_called() - subject.unassign_unit(ifc, unit, unit="timeunit") - - -class TestRemoveMassAndTimeUnits: - def test_removing_mass_unit(self, ifc, unit): - ifc.run("unit.remove_unit", unit="massunit").should_be_called() - unit.import_units().should_be_called() - subject.remove_unit(ifc, unit, unit="massunit") - - def test_removing_time_unit(self, ifc, unit): - ifc.run("unit.remove_unit", unit="timeunit").should_be_called() - unit.import_units().should_be_called() - subject.remove_unit(ifc, unit, unit="timeunit") diff --git a/src/ifcopenshell-python/ifcopenshell/api/unit/assign_unit.py b/src/ifcopenshell-python/ifcopenshell/api/unit/assign_unit.py index 58abfd4ea4..f9563410cd 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/unit/assign_unit.py +++ b/src/ifcopenshell-python/ifcopenshell/api/unit/assign_unit.py @@ -27,8 +27,6 @@ def assign_unit( length: Optional[dict] = None, area: Optional[dict] = None, volume: Optional[dict] = None, - mass: Optional[dict] = None, - time: Optional[dict] = None, ) -> ifcopenshell.entity_instance: """Assign default project units @@ -53,19 +51,16 @@ def assign_unit( # You need a project before you can assign units. ifcopenshell.api.root.create_entity(model, ifc_class="IfcProject") - # Millimeters, square meters, kilograms, and seconds + # Create units explicitly length = ifcopenshell.api.unit.add_si_unit(model, unit_type="LENGTHUNIT", prefix="MILLI") area = ifcopenshell.api.unit.add_si_unit(model, unit_type="AREAUNIT") mass = ifcopenshell.api.unit.add_si_unit(model, unit_type="MASSUNIT", prefix="KILO") - time = ifcopenshell.api.unit.add_si_unit(model, unit_type="TIMEUNIT") + time = ifcopenshell.api.unit.add_conversion_based_unit(model, name="minute") - # Make it our default units, if we are doing a metric building - ifcopenshell.api.unit.assign_unit(model, units=[length, area]) + # Assign all units to the project + ifcopenshell.api.unit.assign_unit(model, units=[length, area, mass, time]) - # Alternatively, you may specify without any arguments to - # automatically create millimeters, square meters, and cubic meters - # as a convenience for testing purposes. Sorry imperial folks, we - # prioritise metric here. + # Alternatively, for basic metric units (length, area, volume only) ifcopenshell.api.unit.assign_unit(model) """ usecase = Usecase() @@ -75,8 +70,6 @@ def assign_unit( usecase.settings["length"] = length or {"is_metric": True, "raw": "MILLIMETERS"} usecase.settings["area"] = area or {"is_metric": True, "raw": "METERS"} usecase.settings["volume"] = volume or {"is_metric": True, "raw": "METERS"} - usecase.settings["mass"] = mass or {"is_metric": True, "raw": "KILOGRAM"} - usecase.settings["time"] = time or {"is_metric": True, "raw": "SECOND"} return usecase.execute() @@ -122,45 +115,7 @@ class Usecase: units.add(unit) unit_assignment.Units = list(units) - def create_time_conversion_unit(self, name: str, factor: float) -> ifcopenshell.entity_instance: - """Create a conversion-based time unit""" - dimensional_exponents = self.file.createIfcDimensionalExponents(0, 0, 1, 0, 0, 0, 0) - si_unit = self.file.createIfcSIUnit(None, "TIMEUNIT", None, "SECOND") - value_component = self.file.create_entity("IfcReal", **{"wrappedValue": factor}) - conversion_factor = self.file.createIfcMeasureWithUnit(value_component, si_unit) - return self.file.createIfcConversionBasedUnit(dimensional_exponents, "TIMEUNIT", name, conversion_factor) - - def create_mass_conversion_unit(self, name: str, factor: float) -> ifcopenshell.entity_instance: - """Create a conversion-based mass unit""" - dimensional_exponents = self.file.createIfcDimensionalExponents(0, 1, 0, 0, 0, 0, 0) # Mass dimension - si_unit = self.file.createIfcSIUnit(None, "MASSUNIT", "KILO", "GRAM") - value_component = self.file.create_entity("IfcReal", **{"wrappedValue": factor}) - conversion_factor = self.file.createIfcMeasureWithUnit(value_component, si_unit) - return self.file.createIfcConversionBasedUnit(dimensional_exponents, "MASSUNIT", name, conversion_factor) - def create_metric_unit(self, unit_type: str, data: dict) -> ifcopenshell.entity_instance: - if unit_type == "mass": - if data["raw"] == "KILOGRAM": - return self.file.createIfcSIUnit(None, "MASSUNIT", "KILO", "GRAM") - elif data["raw"] == "GRAM": - return self.file.createIfcSIUnit(None, "MASSUNIT", None, "GRAM") - elif data["raw"] == "TON": - return self.file.createIfcSIUnit(None, "MASSUNIT", "MEGA", "GRAM") - else: - return self.file.createIfcSIUnit(None, "MASSUNIT", "KILO", "GRAM") - - elif unit_type == "time": - if data["raw"] == "SECOND": - return self.file.createIfcSIUnit(None, "TIMEUNIT", None, "SECOND") - elif data["raw"] == "MINUTE": - return self.create_time_conversion_unit("minute", 60.0) - elif data["raw"] == "HOUR": - return self.create_time_conversion_unit("hour", 3600.0) - elif data["raw"] == "DAY": - return self.create_time_conversion_unit("day", 86400.0) - else: - return self.file.createIfcSIUnit(None, "TIMEUNIT", None, "SECOND") - type_prefix = "" if unit_type == "area": type_prefix = "SQUARE_" @@ -183,25 +138,6 @@ class Usecase: elif unit_type == "volume": dimensional_exponents = self.file.createIfcDimensionalExponents(3, 0, 0, 0, 0, 0, 0) name_prefix = "cubic" - elif unit_type == "mass": - if data["raw"] == "POUND": - return self.create_mass_conversion_unit("pound", 0.45359237) - elif data["raw"] == "OUNCE": - return self.create_mass_conversion_unit("ounce", 0.0283495) - else: - return self.create_mass_conversion_unit("pound", 0.45359237) - - elif unit_type == "time": - if data["raw"] == "SECOND": - return self.file.createIfcSIUnit(None, "TIMEUNIT", None, "SECOND") - elif data["raw"] == "MINUTE": - return self.create_time_conversion_unit("minute", 60.0) - elif data["raw"] == "HOUR": - return self.create_time_conversion_unit("hour", 3600.0) - elif data["raw"] == "DAY": - return self.create_time_conversion_unit("day", 86400.0) - else: - return self.file.createIfcSIUnit(None, "TIMEUNIT", None, "SECOND") si_unit = self.file.createIfcSIUnit( None, diff --git a/src/ifcopenshell-python/test/api/unit/test_add_conversion_based_unit.py b/src/ifcopenshell-python/test/api/unit/test_add_conversion_based_unit.py index 03a8a7edfd..87ed50ec8f 100644 --- a/src/ifcopenshell-python/test/api/unit/test_add_conversion_based_unit.py +++ b/src/ifcopenshell-python/test/api/unit/test_add_conversion_based_unit.py @@ -18,6 +18,7 @@ import test.bootstrap import ifcopenshell.api.unit +import ifcopenshell.util.unit as subject class TestAddConversionBasedUnitIFC2X3(test.bootstrap.IFC2X3): @@ -61,3 +62,110 @@ class TestAddConversionBasedUnitIFC4(test.bootstrap.IFC4, TestAddConversionBased assert si_unit.Prefix is None assert si_unit.Name == "KELVIN" assert unit.ConversionOffset == -459.67 + + def test_adding_mass_units_creates_proper_massunit(self): + mass_units = [ + ("tonne", 1000.0), + ("pound", 0.454), + ("ounce", 0.02835), + ("ton UK", 1016.0469088), + ("ton US", 907.18474), + ] + + for name, expected_conversion in mass_units: + unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name=name) + + assert unit.is_a("IfcConversionBasedUnit") + assert unit.UnitType == "MASSUNIT" + assert unit.Name == name + + actual_conversion = unit.ConversionFactor.ValueComponent.wrappedValue + assert actual_conversion == expected_conversion + + target_unit = unit.ConversionFactor.UnitComponent + assert target_unit.is_a("IfcSIUnit") + assert target_unit.UnitType == "MASSUNIT" + assert target_unit.Name == "GRAM" + assert target_unit.Prefix == "KILO" + + def test_adding_time_units_creates_proper_timeunit(self): + time_units = [ + ("minute", 60), + ("hour", 3600), + ("day", 86400), + ] + + for name, expected_conversion in time_units: + unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name=name) + + assert unit.is_a("IfcConversionBasedUnit") + assert unit.UnitType == "TIMEUNIT" + assert unit.Name == name + + actual_conversion = unit.ConversionFactor.ValueComponent.wrappedValue + assert actual_conversion == expected_conversion + + target_unit = unit.ConversionFactor.UnitComponent + assert target_unit.is_a("IfcSIUnit") + assert target_unit.UnitType == "TIMEUNIT" + assert target_unit.Name == "SECOND" + assert target_unit.Prefix is None + + def test_unknown_units_fall_back_to_userdefined(self): + unknown_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="unknown_unit") + assert unknown_unit.UnitType == "USERDEFINED" + assert unknown_unit.Name == "unknown_unit" + + def test_mass_units_in_imperial_types(self): + expected_mass_units = ["ounce", "pound", "ton UK", "ton US", "tonne"] + + for unit_name in expected_mass_units: + assert unit_name in subject.imperial_types + assert subject.imperial_types[unit_name] == "MASSUNIT" + + def test_time_units_in_imperial_types(self): + expected_time_units = ["minute", "hour", "day"] + + for unit_name in expected_time_units: + assert unit_name in subject.imperial_types + assert subject.imperial_types[unit_name] == "TIMEUNIT" + + def test_mass_units_have_conversion_factors(self): + expected_mass_conversions = { + "ounce": 0.02835, + "pound": 0.454, + "ton UK": 1016.0469088, + "ton US": 907.18474, + "tonne": 1000.0, + } + + for unit_name, expected_factor in expected_mass_conversions.items(): + assert unit_name in subject.si_conversions + assert subject.si_conversions[unit_name] == expected_factor + + def test_time_units_have_conversion_factors(self): + expected_time_conversions = { + "minute": 60, + "hour": 3600, + "day": 86400, + } + + for unit_name, expected_factor in expected_time_conversions.items(): + assert unit_name in subject.si_conversions + assert subject.si_conversions[unit_name] == expected_factor + + def test_mass_and_time_units_have_symbols(self): + expected_symbols = { + "ounce": "oz", + "pound": "lb", + "ton UK": "ton", + "ton US": "ton", + "tonne": "t", + "minute": "min", + "hour": "hr", + "day": "day", + } + + for unit_name, expected_symbol in expected_symbols.items(): + assert unit_name in subject.unit_symbols + assert subject.unit_symbols[unit_name] == expected_symbol diff --git a/src/ifcopenshell-python/test/util/test_unit.py b/src/ifcopenshell-python/test/util/test_unit.py index ff7b7eede5..6e28a28e70 100644 --- a/src/ifcopenshell-python/test/util/test_unit.py +++ b/src/ifcopenshell-python/test/util/test_unit.py @@ -391,233 +391,3 @@ class TestConvertFileLengthUnitsIFC4(test.bootstrap.IFC4, TestConvertFileLengthU class TestConvertFileLengthUnitsIFC4X3(test.bootstrap.IFC4X3, TestConvertFileLengthUnits): pass - - -class TestAddConversionBasedUnitMassAndTime(test.bootstrap.IFC4): - def test_adding_mass_units_creates_proper_massunit(self): - mass_units = [ - ("tonne", 1000.0), - ("pound", 0.454), - ("ounce", 0.02835), - ("ton UK", 1016.0469088), - ("ton US", 907.18474), - ] - - for name, expected_conversion in mass_units: - unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name=name) - - assert unit.is_a("IfcConversionBasedUnit") - - assert unit.UnitType == "MASSUNIT" - - assert unit.Name == name - - actual_conversion = unit.ConversionFactor.ValueComponent.wrappedValue - assert actual_conversion == expected_conversion - - target_unit = unit.ConversionFactor.UnitComponent - assert target_unit.is_a("IfcSIUnit") - assert target_unit.UnitType == "MASSUNIT" - assert target_unit.Name == "GRAM" - assert target_unit.Prefix == "KILO" - - def test_adding_time_units_creates_proper_timeunit(self): - time_units = [ - ("minute", 60), - ("hour", 3600), - ("day", 86400), - ] - - for name, expected_conversion in time_units: - unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name=name) - - assert unit.is_a("IfcConversionBasedUnit") - - assert unit.UnitType == "TIMEUNIT" - - assert unit.Name == name - - actual_conversion = unit.ConversionFactor.ValueComponent.wrappedValue - assert actual_conversion == expected_conversion - - target_unit = unit.ConversionFactor.UnitComponent - assert target_unit.is_a("IfcSIUnit") - assert target_unit.UnitType == "TIMEUNIT" - assert target_unit.Name == "SECOND" - assert target_unit.Prefix is None - - def test_mass_unit_integration_with_project(self): - ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") - - kg_unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="MASSUNIT", prefix="KILO") - tonne_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="tonne") - pound_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="pound") - - ifcopenshell.api.unit.assign_unit(self.file, units=[kg_unit]) - project_mass_unit = subject.get_project_unit(self.file, "MASSUNIT") - assert project_mass_unit == kg_unit - - unit_assignment = subject.get_unit_assignment(self.file) - assert unit_assignment - assigned_units = list(unit_assignment.Units or []) - assert kg_unit in assigned_units - - ifcopenshell.api.unit.assign_unit(self.file, units=[tonne_unit]) - project_mass_unit = subject.get_project_unit(self.file, "MASSUNIT") - assert project_mass_unit == tonne_unit - - unit_assignment = subject.get_unit_assignment(self.file) - assigned_units = list(unit_assignment.Units or []) - assert tonne_unit in assigned_units - - length_unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT") - ifcopenshell.api.unit.assign_unit(self.file, units=[pound_unit, length_unit]) - - unit_assignment = subject.get_unit_assignment(self.file) - assigned_units = list(unit_assignment.Units or []) - assert pound_unit in assigned_units - assert length_unit in assigned_units - - def test_time_unit_integration_with_project(self): - ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") - - second_unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="TIMEUNIT") - minute_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="minute") - hour_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="hour") - - ifcopenshell.api.unit.assign_unit(self.file, units=[second_unit]) - project_time_unit = subject.get_project_unit(self.file, "TIMEUNIT") - assert project_time_unit == second_unit - - unit_assignment = subject.get_unit_assignment(self.file) - assert unit_assignment - assigned_units = list(unit_assignment.Units or []) - assert second_unit in assigned_units - - ifcopenshell.api.unit.assign_unit(self.file, units=[minute_unit]) - project_time_unit = subject.get_project_unit(self.file, "TIMEUNIT") - assert project_time_unit == minute_unit - - unit_assignment = subject.get_unit_assignment(self.file) - assigned_units = list(unit_assignment.Units or []) - assert minute_unit in assigned_units - - length_unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT") - ifcopenshell.api.unit.assign_unit(self.file, units=[hour_unit, length_unit]) - - unit_assignment = subject.get_unit_assignment(self.file) - assigned_units = list(unit_assignment.Units or []) - assert hour_unit in assigned_units - assert length_unit in assigned_units - - def test_multiple_unit_types_can_coexist(self): - ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") - - kg_unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="MASSUNIT", prefix="KILO") - minute_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="minute") - meter_unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT") - - ifcopenshell.api.unit.assign_unit(self.file, units=[kg_unit, minute_unit, meter_unit]) - - unit_assignment = subject.get_unit_assignment(self.file) - assigned_units = list(unit_assignment.Units or []) - - assert kg_unit in assigned_units - assert minute_unit in assigned_units - assert meter_unit in assigned_units - - assert subject.get_project_unit(self.file, "MASSUNIT") == kg_unit - assert subject.get_project_unit(self.file, "TIMEUNIT") == minute_unit - assert subject.get_project_unit(self.file, "LENGTHUNIT") == meter_unit - - def test_unknown_mass_or_time_units_fall_back_to_userdefined(self): - unknown_mass_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="unknown_mass_unit") - assert unknown_mass_unit.UnitType == "USERDEFINED" - - unknown_time_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="unknown_time_unit") - assert unknown_time_unit.UnitType == "USERDEFINED" - - def test_mass_units_individually(self): - tonne_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="tonne") - assert tonne_unit.UnitType == "MASSUNIT" - assert tonne_unit.ConversionFactor.ValueComponent.wrappedValue == 1000.0 - - pound_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="pound") - assert pound_unit.UnitType == "MASSUNIT" - assert pound_unit.ConversionFactor.ValueComponent.wrappedValue == 0.454 - - ounce_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="ounce") - assert ounce_unit.UnitType == "MASSUNIT" - assert ounce_unit.ConversionFactor.ValueComponent.wrappedValue == 0.02835 - - def test_time_units_individually(self): - minute_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="minute") - assert minute_unit.UnitType == "TIMEUNIT" - assert minute_unit.ConversionFactor.ValueComponent.wrappedValue == 60 - - hour_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="hour") - assert hour_unit.UnitType == "TIMEUNIT" - assert hour_unit.ConversionFactor.ValueComponent.wrappedValue == 3600 - - day_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="day") - assert day_unit.UnitType == "TIMEUNIT" - assert day_unit.ConversionFactor.ValueComponent.wrappedValue == 86400 - - -class TestMassAndTimeUnitDictionaries(test.bootstrap.IFC4): - def test_mass_units_in_imperial_types(self): - expected_mass_units = ["ounce", "pound", "ton UK", "ton US", "tonne"] - - def test_mass_units_in_imperial_types(self): - expected_mass_units = ["ounce", "pound", "ton UK", "ton US", "tonne"] - - for unit_name in expected_mass_units: - assert unit_name in subject.imperial_types - assert subject.imperial_types[unit_name] == "MASSUNIT" - - def test_time_units_in_imperial_types(self): - expected_time_units = ["minute", "hour", "day"] - - for unit_name in expected_time_units: - assert unit_name in subject.imperial_types - assert subject.imperial_types[unit_name] == "TIMEUNIT" - - def test_mass_units_have_conversion_factors(self): - expected_mass_conversions = { - "ounce": 0.02835, - "pound": 0.454, - "ton UK": 1016.0469088, - "ton US": 907.18474, - "tonne": 1000.0, - } - - for unit_name, expected_factor in expected_mass_conversions.items(): - assert unit_name in subject.si_conversions - assert subject.si_conversions[unit_name] == expected_factor - - def test_time_units_have_conversion_factors(self): - expected_time_conversions = { - "minute": 60, - "hour": 3600, - "day": 86400, - } - - for unit_name, expected_factor in expected_time_conversions.items(): - assert unit_name in subject.si_conversions - assert subject.si_conversions[unit_name] == expected_factor - - def test_mass_and_time_units_have_symbols(self): - expected_symbols = { - "ounce": "oz", - "pound": "lb", - "ton UK": "ton", - "ton US": "ton", - "tonne": "t", - "minute": "min", - "hour": "hr", - "day": "day", - } - - for unit_name, expected_symbol in expected_symbols.items(): - assert unit_name in subject.unit_symbols - assert subject.unit_symbols[unit_name] == expected_symbol From 93579b8f663ed597776e29203d3e10f0104d5c81 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Mon, 15 Sep 2025 23:38:26 +0200 Subject: [PATCH 18/25] Cleanup based on core developer's feedback --- src/bonsai/test/core/test_unit.py | 8 +- .../ifcopenshell/api/unit/assign_unit.py | 15 ++- .../unit/test_add_conversion_based_unit.py | 101 ++++-------------- 3 files changed, 36 insertions(+), 88 deletions(-) diff --git a/src/bonsai/test/core/test_unit.py b/src/bonsai/test/core/test_unit.py index 00ffe1dd5e..818ad72021 100644 --- a/src/bonsai/test/core/test_unit.py +++ b/src/bonsai/test/core/test_unit.py @@ -41,7 +41,7 @@ class TestAssignSceneUnits: ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit") ifc.run( - "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "massunit", "timeunit", "planeangleunit"] + "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"] ).should_be_called() subject.assign_scene_units(ifc, unit) @@ -61,7 +61,7 @@ class TestAssignSceneUnits: ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit") ifc.run( - "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "massunit", "timeunit", "planeangleunit"] + "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"] ).should_be_called() subject.assign_scene_units(ifc, unit) @@ -83,7 +83,7 @@ class TestAssignSceneUnits: ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit") ifc.run( - "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "massunit", "timeunit", "planeangleunit"] + "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"] ).should_be_called() subject.assign_scene_units(ifc, unit) @@ -103,7 +103,7 @@ class TestAssignSceneUnits: ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit") ifc.run( - "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "massunit", "timeunit", "planeangleunit"] + "unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"] ).should_be_called() subject.assign_scene_units(ifc, unit) diff --git a/src/ifcopenshell-python/ifcopenshell/api/unit/assign_unit.py b/src/ifcopenshell-python/ifcopenshell/api/unit/assign_unit.py index f9563410cd..5861e4248a 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/unit/assign_unit.py +++ b/src/ifcopenshell-python/ifcopenshell/api/unit/assign_unit.py @@ -51,16 +51,21 @@ def assign_unit( # You need a project before you can assign units. ifcopenshell.api.root.create_entity(model, ifc_class="IfcProject") - # Create units explicitly + # Millimeters and square meters length = ifcopenshell.api.unit.add_si_unit(model, unit_type="LENGTHUNIT", prefix="MILLI") area = ifcopenshell.api.unit.add_si_unit(model, unit_type="AREAUNIT") - mass = ifcopenshell.api.unit.add_si_unit(model, unit_type="MASSUNIT", prefix="KILO") - time = ifcopenshell.api.unit.add_conversion_based_unit(model, name="minute") - # Assign all units to the project + # Optionally, add mass and time units + mass = ifcopenshell.api.unit.add_si_unit(model, unit_type="MASSUNIT", prefix="KILO") + time = ifcopenshell.api.unit.add_si_unit(model, unit_type="TIMEUNIT") + + # Make these the default units for the project ifcopenshell.api.unit.assign_unit(model, units=[length, area, mass, time]) - # Alternatively, for basic metric units (length, area, volume only) + # Alternatively, you may specify without any arguments to + # automatically create millimeters, square meters, and cubic meters + # as a convenience for testing purposes. Sorry imperial folks, we + # prioritise metric here. ifcopenshell.api.unit.assign_unit(model) """ usecase = Usecase() diff --git a/src/ifcopenshell-python/test/api/unit/test_add_conversion_based_unit.py b/src/ifcopenshell-python/test/api/unit/test_add_conversion_based_unit.py index 87ed50ec8f..93a5002396 100644 --- a/src/ifcopenshell-python/test/api/unit/test_add_conversion_based_unit.py +++ b/src/ifcopenshell-python/test/api/unit/test_add_conversion_based_unit.py @@ -18,8 +18,6 @@ import test.bootstrap import ifcopenshell.api.unit -import ifcopenshell.util.unit as subject - class TestAddConversionBasedUnitIFC2X3(test.bootstrap.IFC2X3): def test_run(self): @@ -41,28 +39,6 @@ class TestAddConversionBasedUnitIFC2X3(test.bootstrap.IFC2X3): assert si_unit.Prefix is None assert si_unit.Name == "METRE" - -class TestAddConversionBasedUnitIFC4(test.bootstrap.IFC4, TestAddConversionBasedUnitIFC2X3): - def test_adding_a_unit_with_offset(self): - unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="fahrenheit") - assert unit.is_a("IfcConversionBasedUnitWithOffset") - assert unit.Dimensions.LengthExponent == 0 - assert unit.Dimensions.MassExponent == 0 - assert unit.Dimensions.TimeExponent == 0 - assert unit.Dimensions.ElectricCurrentExponent == 0 - assert unit.Dimensions.ThermodynamicTemperatureExponent == 1 - assert unit.Dimensions.AmountOfSubstanceExponent == 0 - assert unit.Dimensions.LuminousIntensityExponent == 0 - assert unit.UnitType == "THERMODYNAMICTEMPERATUREUNIT" - assert unit.Name == "fahrenheit" - assert unit.ConversionFactor.ValueComponent.wrappedValue == 1.8 - si_unit = unit.ConversionFactor.UnitComponent - assert si_unit.is_a("IfcSIUnit") - assert si_unit.UnitType == "THERMODYNAMICTEMPERATUREUNIT" - assert si_unit.Prefix is None - assert si_unit.Name == "KELVIN" - assert unit.ConversionOffset == -459.67 - def test_adding_mass_units_creates_proper_massunit(self): mass_units = [ ("tonne", 1000.0), @@ -111,61 +87,28 @@ class TestAddConversionBasedUnitIFC4(test.bootstrap.IFC4, TestAddConversionBased assert target_unit.Name == "SECOND" assert target_unit.Prefix is None +class TestAddConversionBasedUnitIFC4(test.bootstrap.IFC4, TestAddConversionBasedUnitIFC2X3): + def test_adding_a_unit_with_offset(self): + unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="fahrenheit") + assert unit.is_a("IfcConversionBasedUnitWithOffset") + assert unit.Dimensions.LengthExponent == 0 + assert unit.Dimensions.MassExponent == 0 + assert unit.Dimensions.TimeExponent == 0 + assert unit.Dimensions.ElectricCurrentExponent == 0 + assert unit.Dimensions.ThermodynamicTemperatureExponent == 1 + assert unit.Dimensions.AmountOfSubstanceExponent == 0 + assert unit.Dimensions.LuminousIntensityExponent == 0 + assert unit.UnitType == "THERMODYNAMICTEMPERATUREUNIT" + assert unit.Name == "fahrenheit" + assert unit.ConversionFactor.ValueComponent.wrappedValue == 1.8 + si_unit = unit.ConversionFactor.UnitComponent + assert si_unit.is_a("IfcSIUnit") + assert si_unit.UnitType == "THERMODYNAMICTEMPERATUREUNIT" + assert si_unit.Prefix is None + assert si_unit.Name == "KELVIN" + assert unit.ConversionOffset == -459.67 + def test_unknown_units_fall_back_to_userdefined(self): unknown_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="unknown_unit") assert unknown_unit.UnitType == "USERDEFINED" - assert unknown_unit.Name == "unknown_unit" - - def test_mass_units_in_imperial_types(self): - expected_mass_units = ["ounce", "pound", "ton UK", "ton US", "tonne"] - - for unit_name in expected_mass_units: - assert unit_name in subject.imperial_types - assert subject.imperial_types[unit_name] == "MASSUNIT" - - def test_time_units_in_imperial_types(self): - expected_time_units = ["minute", "hour", "day"] - - for unit_name in expected_time_units: - assert unit_name in subject.imperial_types - assert subject.imperial_types[unit_name] == "TIMEUNIT" - - def test_mass_units_have_conversion_factors(self): - expected_mass_conversions = { - "ounce": 0.02835, - "pound": 0.454, - "ton UK": 1016.0469088, - "ton US": 907.18474, - "tonne": 1000.0, - } - - for unit_name, expected_factor in expected_mass_conversions.items(): - assert unit_name in subject.si_conversions - assert subject.si_conversions[unit_name] == expected_factor - - def test_time_units_have_conversion_factors(self): - expected_time_conversions = { - "minute": 60, - "hour": 3600, - "day": 86400, - } - - for unit_name, expected_factor in expected_time_conversions.items(): - assert unit_name in subject.si_conversions - assert subject.si_conversions[unit_name] == expected_factor - - def test_mass_and_time_units_have_symbols(self): - expected_symbols = { - "ounce": "oz", - "pound": "lb", - "ton UK": "ton", - "ton US": "ton", - "tonne": "t", - "minute": "min", - "hour": "hr", - "day": "day", - } - - for unit_name, expected_symbol in expected_symbols.items(): - assert unit_name in subject.unit_symbols - assert subject.unit_symbols[unit_name] == expected_symbol + assert unknown_unit.Name == "unknown_unit" \ No newline at end of file From b1c4822315c483086724e49780b26c05db3d9d8c Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Mon, 15 Sep 2025 23:42:34 +0200 Subject: [PATCH 19/25] black formatting --- .../test/api/unit/test_add_conversion_based_unit.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/test/api/unit/test_add_conversion_based_unit.py b/src/ifcopenshell-python/test/api/unit/test_add_conversion_based_unit.py index 93a5002396..a29b9b2ad8 100644 --- a/src/ifcopenshell-python/test/api/unit/test_add_conversion_based_unit.py +++ b/src/ifcopenshell-python/test/api/unit/test_add_conversion_based_unit.py @@ -19,6 +19,7 @@ import test.bootstrap import ifcopenshell.api.unit + class TestAddConversionBasedUnitIFC2X3(test.bootstrap.IFC2X3): def test_run(self): unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="foot") @@ -87,6 +88,7 @@ class TestAddConversionBasedUnitIFC2X3(test.bootstrap.IFC2X3): assert target_unit.Name == "SECOND" assert target_unit.Prefix is None + class TestAddConversionBasedUnitIFC4(test.bootstrap.IFC4, TestAddConversionBasedUnitIFC2X3): def test_adding_a_unit_with_offset(self): unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="fahrenheit") @@ -111,4 +113,4 @@ class TestAddConversionBasedUnitIFC4(test.bootstrap.IFC4, TestAddConversionBased def test_unknown_units_fall_back_to_userdefined(self): unknown_unit = ifcopenshell.api.unit.add_conversion_based_unit(self.file, name="unknown_unit") assert unknown_unit.UnitType == "USERDEFINED" - assert unknown_unit.Name == "unknown_unit" \ No newline at end of file + assert unknown_unit.Name == "unknown_unit" From f902c7a50af601b4882f77539b755dda447ceab1 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Tue, 16 Sep 2025 18:24:20 +0200 Subject: [PATCH 20/25] ui change to add or not add mass and time units --- .../bonsai/bim/module/project/operator.py | 6 -- src/bonsai/bonsai/bim/module/project/ui.py | 15 +++-- src/bonsai/bonsai/bim/prop.py | 5 ++ src/bonsai/bonsai/core/tool.py | 2 +- src/bonsai/bonsai/core/unit.py | 57 ++++++++++--------- src/bonsai/bonsai/tool/unit.py | 6 ++ src/bonsai/test/core/test_unit.py | 31 ++++++++++ 7 files changed, 83 insertions(+), 39 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 34d6cdb91d..26850ce85a 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -98,8 +98,6 @@ class NewProject(bpy.types.Operator): bpy.context.scene.unit_settings.length_unit = "METERS" bim_props.area_unit = "SQUARE_METRE" bim_props.volume_unit = "CUBIC_METRE" - bim_props.mass_unit = "KILOGRAM" - bim_props.time_unit = "SECOND" pprops.template_file = "0" elif self.preset == "metric_mm": pprops.export_schema = "IFC4" @@ -107,8 +105,6 @@ class NewProject(bpy.types.Operator): bpy.context.scene.unit_settings.length_unit = "MILLIMETERS" bim_props.area_unit = "SQUARE_METRE" bim_props.volume_unit = "CUBIC_METRE" - bim_props.mass_unit = "KILOGRAM" - bim_props.time_unit = "SECOND" pprops.template_file = "0" elif self.preset == "imperial_ft": pprops.export_schema = "IFC4" @@ -116,8 +112,6 @@ class NewProject(bpy.types.Operator): bpy.context.scene.unit_settings.length_unit = "FEET" bim_props.area_unit = "square foot" bim_props.volume_unit = "cubic foot" - bim_props.mass_unit = "POUND" - bim_props.time_unit = "SECOND" pprops.template_file = "0" elif self.preset == "demo": pprops.export_schema = "IFC4" diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index 731a9d0688..ed6e65e674 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -357,15 +357,20 @@ class BIM_PT_new_project_wizard(Panel): row = self.layout.row() row.prop(props, "volume_unit", text="Volume Unit") row = self.layout.row() - row.prop(props, "mass_unit", text="Mass Unit") - row = self.layout.row() - row.prop(props, "time_unit", text="Time Unit") prop_with_search(self.layout, pprops, "template_file", text="Template") - + self.layout.use_property_split = False + row = self.layout.row() + label = "Add Mass and Time Units" if not props.add_mass_time_units else "Remove Mass and Time Units" + row.prop(props, "add_mass_time_units", toggle=True, text=label) + self.layout.use_property_split = True + if props.add_mass_time_units: + row = self.layout.row() + row.prop(props, "mass_unit", text="Mass Unit") + row = self.layout.row() + row.prop(props, "time_unit", text="Time Unit") row = self.layout.row() row.operator("bim.create_project") - class BIM_PT_project_library(Panel): bl_label = "Project Library" bl_idname = "BIM_PT_project_library" diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py index 7b24d2a1d1..c5a5bd9300 100644 --- a/src/bonsai/bonsai/bim/prop.py +++ b/src/bonsai/bonsai/bim/prop.py @@ -586,6 +586,11 @@ class BIMProperties(PropertyGroup): ], name="IFC Volume Unit", ) + add_mass_time_units: bpy.props.BoolProperty( + name="Add Mass and Time Units", + description="Enable to define mass and time units for the project", + default=False + ) mass_unit: EnumProperty( items=[ ("KILOGRAM", "Kilogram", "Kilograms"), diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 7fcfd25fde..d8e4dd2396 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -1123,7 +1123,7 @@ class Unit: def set_active_unit(cls, unit): pass def get_project_currency_unit(cls): pass def get_currency_name(cls): pass - + def add_mass_and_time_units(cls): pass @interface class Voider: diff --git a/src/bonsai/bonsai/core/unit.py b/src/bonsai/bonsai/core/unit.py index 2e8e627cb7..a4c52508f1 100644 --- a/src/bonsai/bonsai/core/unit.py +++ b/src/bonsai/bonsai/core/unit.py @@ -28,40 +28,43 @@ if TYPE_CHECKING: def assign_scene_units(ifc: type[tool.Ifc], unit: type[tool.Unit]) -> None: if unit.is_scene_unit_metric(): - prefix = unit.get_scene_unit_si_prefix("LENGTHUNIT") - lengthunit = ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix=prefix) - prefix = unit.get_scene_unit_si_prefix("AREAUNIT") - areaunit = ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix=prefix) - prefix = unit.get_scene_unit_si_prefix("VOLUMEUNIT") - volumeunit = ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix=prefix) + lengthunit = ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix=unit.get_scene_unit_si_prefix("LENGTHUNIT")) + areaunit = ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix=unit.get_scene_unit_si_prefix("AREAUNIT")) + volumeunit = ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix=unit.get_scene_unit_si_prefix("VOLUMEUNIT")) + planeangleunit = ifc.run("unit.add_conversion_based_unit", name="degree") + units = [lengthunit, areaunit, volumeunit, planeangleunit] - prefix = unit.get_scene_unit_si_prefix("MASSUNIT") - if prefix == "CONVERSION": - mass_unit_name = unit.get_scene_unit_name("MASSUNIT") - massunit = ifc.run("unit.add_conversion_based_unit", name=mass_unit_name.lower()) - else: - massunit = ifc.run("unit.add_si_unit", unit_type="MASSUNIT", prefix=prefix) + if unit.add_mass_and_time_units(): + prefix = unit.get_scene_unit_si_prefix("MASSUNIT") + if prefix == "CONVERSION": + massunit = ifc.run("unit.add_conversion_based_unit", name=unit.get_scene_unit_name("MASSUNIT").lower()) + else: + massunit = ifc.run("unit.add_si_unit", unit_type="MASSUNIT", prefix=prefix) + prefix = unit.get_scene_unit_si_prefix("TIMEUNIT") + if prefix == "CONVERSION": + timeunit = ifc.run("unit.add_conversion_based_unit", name=unit.get_scene_unit_name("TIMEUNIT").lower()) + else: + timeunit = ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix=prefix) + units += [massunit, timeunit] - prefix = unit.get_scene_unit_si_prefix("TIMEUNIT") - if prefix == "CONVERSION": - time_unit_name = unit.get_scene_unit_name("TIMEUNIT") - timeunit = ifc.run("unit.add_conversion_based_unit", name=time_unit_name.lower()) - else: - timeunit = ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix=prefix) else: lengthunit = ifc.run("unit.add_conversion_based_unit", name=unit.get_scene_unit_name("LENGTHUNIT")) areaunit = ifc.run("unit.add_conversion_based_unit", name=unit.get_scene_unit_name("AREAUNIT")) volumeunit = ifc.run("unit.add_conversion_based_unit", name=unit.get_scene_unit_name("VOLUMEUNIT")) - massunit = ifc.run("unit.add_conversion_based_unit", name=unit.get_scene_unit_name("MASSUNIT").lower()) + planeangleunit = ifc.run("unit.add_conversion_based_unit", name="degree") + units = [lengthunit, areaunit, volumeunit, planeangleunit] - time_unit_name = unit.get_scene_unit_name("TIMEUNIT") - if time_unit_name == "SECOND": - timeunit = ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix=None) - else: - timeunit = ifc.run("unit.add_conversion_based_unit", name=time_unit_name.lower()) - - planeangleunit = ifc.run("unit.add_conversion_based_unit", name="degree") - ifc.run("unit.assign_unit", units=[lengthunit, areaunit, volumeunit, planeangleunit, massunit, timeunit]) + if unit.add_mass_and_time_units(): + massunit = ifc.run("unit.add_conversion_based_unit", name=unit.get_scene_unit_name("MASSUNIT").lower()) + time_unit_name = unit.get_scene_unit_name("TIMEUNIT") + if time_unit_name == "SECOND": + timeunit = ifc.run("unit.add_si_unit", unit_type="TIMEUNIT", prefix=None) + else: + timeunit = ifc.run("unit.add_conversion_based_unit", name=time_unit_name.lower()) + units += [massunit, timeunit] + print("Add mass and time units:", unit.add_mass_and_time_units()) + print("Assigning units:", units) + ifc.run("unit.assign_unit", units=units) def assign_unit(ifc: type[tool.Ifc], unit_tool: type[tool.Unit], unit: ifcopenshell.entity_instance) -> None: diff --git a/src/bonsai/bonsai/tool/unit.py b/src/bonsai/bonsai/tool/unit.py index a3c9569d62..c982c43523 100644 --- a/src/bonsai/bonsai/tool/unit.py +++ b/src/bonsai/bonsai/tool/unit.py @@ -499,3 +499,9 @@ class Unit(bonsai.core.tool.Unit): elif ifc_class == "IfcMonetaryUnit": return "COPY_ID" return "MOD_MESHDEFORM" + + @classmethod + def add_mass_and_time_units(cls) -> bool: + """Return True if the user wants to add mass and time units, False otherwise.""" + bim_props = tool.Blender.get_bim_props() + return getattr(bim_props, "add_mass_time_units", False) diff --git a/src/bonsai/test/core/test_unit.py b/src/bonsai/test/core/test_unit.py index 818ad72021..2bbd783cc3 100644 --- a/src/bonsai/test/core/test_unit.py +++ b/src/bonsai/test/core/test_unit.py @@ -26,6 +26,7 @@ class TestAssignSceneUnits: unit.get_scene_unit_si_prefix("LENGTHUNIT").should_be_called().will_return("prefix") unit.get_scene_unit_si_prefix("AREAUNIT").should_be_called().will_return("prefix") unit.get_scene_unit_si_prefix("VOLUMEUNIT").should_be_called().will_return("prefix") + unit.add_mass_and_time_units().should_be_called().will_return(True) unit.get_scene_unit_si_prefix("MASSUNIT").should_be_called().will_return("KILO") unit.get_scene_unit_si_prefix("TIMEUNIT").should_be_called().will_return(None) @@ -45,11 +46,25 @@ class TestAssignSceneUnits: ).should_be_called() subject.assign_scene_units(ifc, unit) + def test_creating_and_assigning_metric_units_without_mass_and_time(self, ifc, unit): + unit.is_scene_unit_metric().should_be_called().will_return(True) + unit.get_scene_unit_si_prefix("LENGTHUNIT").should_be_called().will_return("CENTI") + unit.get_scene_unit_si_prefix("AREAUNIT").should_be_called().will_return("CENTI") + unit.get_scene_unit_si_prefix("VOLUMEUNIT").should_be_called().will_return("CENTI") + unit.add_mass_and_time_units().should_be_called().will_return(False) + ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix="CENTI").should_be_called().will_return("lengthunit") + ifc.run("unit.add_si_unit", unit_type="AREAUNIT", prefix="CENTI").should_be_called().will_return("areaunit") + ifc.run("unit.add_si_unit", unit_type="VOLUMEUNIT", prefix="CENTI").should_be_called().will_return("volumeunit") + ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit") + ifc.run("unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit"]).should_be_called() + subject.assign_scene_units(ifc, unit) + def test_creating_and_assigning_imperial_units(self, ifc, unit): unit.is_scene_unit_metric().should_be_called().will_return(False) unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("foot") unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("square foot") unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("cubic foot") + unit.add_mass_and_time_units().should_be_called().will_return(True) unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("pound") unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("SECOND") @@ -65,11 +80,26 @@ class TestAssignSceneUnits: ).should_be_called() subject.assign_scene_units(ifc, unit) + def test_creating_and_assigning_imperial_units_without_mass_and_time(self, ifc, unit): + unit.is_scene_unit_metric().should_be_called().will_return(False) + unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("yard") + unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("square yard") + unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("cubic yard") + unit.add_mass_and_time_units().should_be_called().will_return(False) + ifc.run("unit.add_conversion_based_unit", name="yard").should_be_called().will_return("lengthunit") + ifc.run("unit.add_conversion_based_unit", name="square yard").should_be_called().will_return("areaunit") + ifc.run("unit.add_conversion_based_unit", name="cubic yard").should_be_called().will_return("volumeunit") + ifc.run("unit.add_conversion_based_unit", name="degree").should_be_called().will_return("planeangleunit") + ifc.run("unit.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit"]).should_be_called() + subject.assign_scene_units(ifc, unit) + + def test_creating_metric_units_with_conversion_based_mass_and_time(self, ifc, unit): unit.is_scene_unit_metric().should_be_called().will_return(True) unit.get_scene_unit_si_prefix("LENGTHUNIT").should_be_called().will_return("MILLI") unit.get_scene_unit_si_prefix("AREAUNIT").should_be_called().will_return(None) unit.get_scene_unit_si_prefix("VOLUMEUNIT").should_be_called().will_return(None) + unit.add_mass_and_time_units().should_be_called().will_return(True) unit.get_scene_unit_si_prefix("MASSUNIT").should_be_called().will_return("CONVERSION") unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("tonne") unit.get_scene_unit_si_prefix("TIMEUNIT").should_be_called().will_return("CONVERSION") @@ -92,6 +122,7 @@ class TestAssignSceneUnits: unit.get_scene_unit_name("LENGTHUNIT").should_be_called().will_return("inch") unit.get_scene_unit_name("AREAUNIT").should_be_called().will_return("square inch") unit.get_scene_unit_name("VOLUMEUNIT").should_be_called().will_return("cubic inch") + unit.add_mass_and_time_units().should_be_called().will_return(True) unit.get_scene_unit_name("MASSUNIT").should_be_called().will_return("ounce") unit.get_scene_unit_name("TIMEUNIT").should_be_called().will_return("hour") From f222a8ba4f9fdb67af173f9c1940c8267fd8eddf Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Mon, 29 Sep 2025 09:13:18 +0200 Subject: [PATCH 21/25] Add mass and time units as a panel to project wizard UI --- .../bonsai/bim/module/project/__init__.py | 2 ++ src/bonsai/bonsai/bim/module/project/ui.py | 29 +++++++++++++------ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/project/__init__.py b/src/bonsai/bonsai/bim/module/project/__init__.py index 1316eb0325..7e1bdb7ff0 100644 --- a/src/bonsai/bonsai/bim/module/project/__init__.py +++ b/src/bonsai/bonsai/bim/module/project/__init__.py @@ -114,6 +114,7 @@ def register(): kmi = km.keymap_items.new("bim.save_project", "S", "PRESS", ctrl=True) kmi.properties.should_save_as = False addon_keymaps.append((km, kmi)) + bpy.utils.register_class(ui.BIM_PT_mass_time_units) def unregister(): @@ -131,3 +132,4 @@ def unregister(): for km, kmi in addon_keymaps: km.keymap_items.remove(kmi) addon_keymaps.clear() + bpy.utils.unregister_class(ui.BIM_PT_mass_time_units) diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index ed6e65e674..f1b087e34b 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -358,19 +358,30 @@ class BIM_PT_new_project_wizard(Panel): row.prop(props, "volume_unit", text="Volume Unit") row = self.layout.row() prop_with_search(self.layout, pprops, "template_file", text="Template") - self.layout.use_property_split = False - row = self.layout.row() - label = "Add Mass and Time Units" if not props.add_mass_time_units else "Remove Mass and Time Units" - row.prop(props, "add_mass_time_units", toggle=True, text=label) self.layout.use_property_split = True - if props.add_mass_time_units: - row = self.layout.row() - row.prop(props, "mass_unit", text="Mass Unit") - row = self.layout.row() - row.prop(props, "time_unit", text="Time Unit") row = self.layout.row() row.operator("bim.create_project") +class BIM_PT_mass_time_units(Panel): + bl_label = "Mass and Time Units" + bl_idname = "BIM_PT_mass_time_units" + bl_options = {"DEFAULT_CLOSED"} + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + bl_parent_id = "BIM_PT_new_project_wizard" + + def draw(self, context): + layout = self.layout + props = tool.Blender.get_bim_props() + label = "Add Mass and Time Units" if not props.add_mass_time_units else "Remove Mass and Time Units" + layout.prop(props, "add_mass_time_units", toggle=True, text=label) + if props.add_mass_time_units: + row = layout.row() + row.prop(props, "mass_unit", text="Mass Unit") + row = layout.row() + row.prop(props, "time_unit", text="Time Unit") + class BIM_PT_project_library(Panel): bl_label = "Project Library" bl_idname = "BIM_PT_project_library" From a98cad161e1e773d2f6aebfce758a775ec427e2b Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Mon, 29 Sep 2025 09:31:00 +0200 Subject: [PATCH 22/25] Refactor mass and time units panel into collapsible section using .panel method in project wizard UI --- .../bonsai/bim/module/project/__init__.py | 2 -- src/bonsai/bonsai/bim/module/project/ui.py | 31 ++++++++----------- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/project/__init__.py b/src/bonsai/bonsai/bim/module/project/__init__.py index 7e1bdb7ff0..1316eb0325 100644 --- a/src/bonsai/bonsai/bim/module/project/__init__.py +++ b/src/bonsai/bonsai/bim/module/project/__init__.py @@ -114,7 +114,6 @@ def register(): kmi = km.keymap_items.new("bim.save_project", "S", "PRESS", ctrl=True) kmi.properties.should_save_as = False addon_keymaps.append((km, kmi)) - bpy.utils.register_class(ui.BIM_PT_mass_time_units) def unregister(): @@ -132,4 +131,3 @@ def unregister(): for km, kmi in addon_keymaps: km.keymap_items.remove(kmi) addon_keymaps.clear() - bpy.utils.unregister_class(ui.BIM_PT_mass_time_units) diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index f1b087e34b..b0dae84afe 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -358,29 +358,24 @@ class BIM_PT_new_project_wizard(Panel): row.prop(props, "volume_unit", text="Volume Unit") row = self.layout.row() prop_with_search(self.layout, pprops, "template_file", text="Template") + + header, body = self.layout.panel("Mass and Time Units", default_closed=True) + if header: + header.label(text="Mass and Time Units") + if body: + label = "Add Mass and Time Units" if not props.add_mass_time_units else "Remove Mass and Time Units" + body.prop(props, "add_mass_time_units", toggle=True, text=label) + if props.add_mass_time_units: + row = body.row() + row.prop(props, "mass_unit", text="Mass Unit") + row = body.row() + row.prop(props, "time_unit", text="Time Unit") + self.layout.use_property_split = True row = self.layout.row() row.operator("bim.create_project") -class BIM_PT_mass_time_units(Panel): - bl_label = "Mass and Time Units" - bl_idname = "BIM_PT_mass_time_units" - bl_options = {"DEFAULT_CLOSED"} - bl_space_type = "PROPERTIES" - bl_region_type = "WINDOW" - bl_context = "scene" - bl_parent_id = "BIM_PT_new_project_wizard" - def draw(self, context): - layout = self.layout - props = tool.Blender.get_bim_props() - label = "Add Mass and Time Units" if not props.add_mass_time_units else "Remove Mass and Time Units" - layout.prop(props, "add_mass_time_units", toggle=True, text=label) - if props.add_mass_time_units: - row = layout.row() - row.prop(props, "mass_unit", text="Mass Unit") - row = layout.row() - row.prop(props, "time_unit", text="Time Unit") class BIM_PT_project_library(Panel): bl_label = "Project Library" From 5294fa1cbe0276a73ee28f9a3e303d5d5fb8cc53 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Mon, 15 Dec 2025 13:39:46 +0100 Subject: [PATCH 23/25] Add preference for mass and time units visibility in project wizard --- src/bonsai/bonsai/bim/module/project/ui.py | 24 +++++++++++----------- src/bonsai/bonsai/bim/prop.py | 2 +- src/bonsai/bonsai/bim/ui.py | 8 ++++++++ 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index b0dae84afe..6fe5b55604 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -359,24 +359,24 @@ class BIM_PT_new_project_wizard(Panel): row = self.layout.row() prop_with_search(self.layout, pprops, "template_file", text="Template") - header, body = self.layout.panel("Mass and Time Units", default_closed=True) - if header: - header.label(text="Mass and Time Units") - if body: - label = "Add Mass and Time Units" if not props.add_mass_time_units else "Remove Mass and Time Units" - body.prop(props, "add_mass_time_units", toggle=True, text=label) - if props.add_mass_time_units: - row = body.row() - row.prop(props, "mass_unit", text="Mass Unit") - row = body.row() - row.prop(props, "time_unit", text="Time Unit") + if tool.Blender.get_addon_preferences().mass_time_units_in_wizard: + header, body = self.layout.panel("Mass and Time Units", default_closed=True) + if header: + header.label(text="Mass and Time Units") + if body: + label = "Add Mass and Time Units" if not props.add_mass_time_units else "Remove Mass and Time Units" + body.prop(props, "add_mass_time_units", toggle=True, text=label) + if props.add_mass_time_units: + row = body.row() + row.prop(props, "mass_unit", text="Mass Unit") + row = body.row() + row.prop(props, "time_unit", text="Time Unit") self.layout.use_property_split = True row = self.layout.row() row.operator("bim.create_project") - class BIM_PT_project_library(Panel): bl_label = "Project Library" bl_idname = "BIM_PT_project_library" diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py index c5a5bd9300..08137a133e 100644 --- a/src/bonsai/bonsai/bim/prop.py +++ b/src/bonsai/bonsai/bim/prop.py @@ -589,7 +589,7 @@ class BIMProperties(PropertyGroup): add_mass_time_units: bpy.props.BoolProperty( name="Add Mass and Time Units", description="Enable to define mass and time units for the project", - default=False + default=False, ) mass_unit: EnumProperty( items=[ diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index b7ed2180c1..d41546904c 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -684,6 +684,12 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): default=False, ) + mass_time_units_in_wizard: BoolProperty( + name="Mass and time units in project wizard", + description="Show mass and time units section in the new project wizard panel", + default=False, + ) + if TYPE_CHECKING: svg2pdf_command: str svg2dxf_command: str @@ -720,6 +726,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): doc: DocPreferences default_parameters: DefaultParameters container_hide_show_isolate: bool + mass_time_units_in_wizard: bool def draw(self, context: bpy.types.Context) -> None: layout = self.layout @@ -901,6 +908,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): def draw_extras_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: layout.prop(self, "container_hide_show_isolate") + layout.prop(self, "mass_time_units_in_wizard") # Scene panel groups From fdf741b2099ae5dacfcfe3645eb9c31e40ebfc60 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Tue, 16 Dec 2025 09:26:28 +0100 Subject: [PATCH 24/25] Add description to Image Scaling Tool operator for clarity + black --- src/bonsai/bonsai/bim/import_ifc.py | 15 ++--- .../bonsai/bim/module/drawing/operator.py | 55 +++++++++---------- .../bonsai/bim/module/project/__init__.py | 2 +- .../bonsai/bim/module/project/operator.py | 51 ++++++++--------- .../bonsai/bim/module/project/workspace.py | 15 +++-- 5 files changed, 71 insertions(+), 67 deletions(-) diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py index 027e220992..05bcbed01a 100644 --- a/src/bonsai/bonsai/bim/import_ifc.py +++ b/src/bonsai/bonsai/bim/import_ifc.py @@ -895,38 +895,39 @@ class IfcImporter: material = obj.data.materials[0] if material.use_nodes and material.node_tree: for node in material.node_tree.nodes: - if node.type == 'TEX_IMAGE' and node.image: + if node.type == "TEX_IMAGE" and node.image: image = node.image break if image: import bmesh + bm = bmesh.new() bm.from_mesh(obj.data) if not bm.loops.layers.uv: uv_layer = bm.loops.layers.uv.new() else: uv_layer = bm.loops.layers.uv.active - + if bm.verts: min_x = min(v.co.x for v in bm.verts) max_x = max(v.co.x for v in bm.verts) min_y = min(v.co.y for v in bm.verts) max_y = max(v.co.y for v in bm.verts) - + width = max_x - min_x height = max_y - min_y - + for face in bm.faces: for loop in face.loops: vert = loop.vert u = (vert.co.x - min_x) / width if width > 0 else 0.5 v = (vert.co.y - min_y) / height if height > 0 else 0.5 - + u = max(0.0, min(1.0, u)) v = max(0.0, min(1.0, v)) - + loop[uv_layer].uv = (u, v) - + bm.to_mesh(obj.data) bm.free() obj.data.update() diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 7397c93f63..31a3b90932 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -3688,7 +3688,7 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): description="Existing object name to add a style with reference image to. If not provided will create a new object.", options={"SKIP_SAVE"}, ) - + x_length: bpy.props.FloatProperty( name="X Length", description="Width of the reference image in project units", @@ -3698,20 +3698,20 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): precision=3, ) y_length: bpy.props.FloatProperty( - name="Y Length", + name="Y Length", description="Height of the reference image in project units", default=1.0, min=0.001, soft_min=0.01, precision=3, ) - + show_dimensions_dialog: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"}) def draw(self, context): layout = self.layout - - if getattr(self, 'show_dimensions_dialog', False): + + if getattr(self, "show_dimensions_dialog", False): if tool.Ifc.get(): length_unit = ifcopenshell.util.unit.get_project_unit(tool.Ifc.get(), "LENGTHUNIT") if length_unit: @@ -3733,47 +3733,47 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): layout.label(text="to use relative paths.") layout.prop(self, "override_existing_image") layout.prop(self, "use_existing_object_by_name") - + def invoke(self, context, event): - if not getattr(self, 'show_dimensions_dialog', False): + if not getattr(self, "show_dimensions_dialog", False): context.window_manager.fileselect_add(self) - return {'RUNNING_MODAL'} + return {"RUNNING_MODAL"} else: return context.window_manager.invoke_props_dialog(self) def execute(self, context): - if not getattr(self, 'show_dimensions_dialog', False): + if not getattr(self, "show_dimensions_dialog", False): abs_path = Path(self.filepath).absolute().resolve() if self.override_existing_image: params = {"check_existing": True, "force_reload": True} else: params = {"check_existing": False} - + try: image = load_image(abs_path.name, str(abs_path.parent), **params) - + image_width_px = image.size[0] image_height_px = image.size[1] aspect_ratio = image_width_px / image_height_px - + if aspect_ratio >= 1.0: self.x_length = 1.0 self.y_length = 1.0 / aspect_ratio else: self.x_length = aspect_ratio self.y_length = 1.0 - + bpy.data.images.remove(image) - + except Exception as e: - self.report({'ERROR'}, f"Failed to load image: {str(e)}") - return {'CANCELLED'} - + self.report({"ERROR"}, f"Failed to load image: {str(e)}") + return {"CANCELLED"} + self.show_dimensions_dialog = True return context.window_manager.invoke_props_dialog(self) - + return self._execute(context) - + def _execute(self, context): space = tool.Blender.get_view3d_space() if space.shading.color_type != "TEXTURE": @@ -3795,7 +3795,7 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): def bm_add_image_plane(mesh): bm = tool.Blender.get_bmesh_for_mesh(mesh, clean=True) - + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) plane_scale = Vector((self.x_length * unit_scale / 2.0, self.y_length * unit_scale / 2.0, 1.0)) matrix = Matrix.LocRotScale(None, None, plane_scale) @@ -3810,16 +3810,16 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): max_x = max(v.co.x for v in bm.verts) min_y = min(v.co.y for v in bm.verts) max_y = max(v.co.y for v in bm.verts) - + width = max_x - min_x height = max_y - min_y - + for face in bm.faces: for loop in face.loops: vert = loop.vert u = (vert.co.x - min_x) / width if width > 0 else 0.5 v = (vert.co.y - min_y) / height if height > 0 else 0.5 - + u = max(0.0, min(1.0, u)) v = max(0.0, min(1.0, v)) loop[uv_layer].uv = (u, v) @@ -3844,7 +3844,6 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): ) tool.Blender.remove_data_block(temp_mesh) - element = tool.Ifc.get_entity(obj) if element and isinstance(obj.data, bpy.types.Mesh): representation = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") @@ -3853,9 +3852,9 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): num_faces = len(obj.data.polygons) obj.data["ios_item_ids"] = [item_id] * num_faces tool.Blender.Attribute.fill_attribute(obj.data, "ios_item_ids", "FACE", "INT", [item_id] * num_faces) - + for item in representation.Items: - if item.is_a('IfcPolygonalFaceSet') and item.Coordinates: + if item.is_a("IfcPolygonalFaceSet") and item.Coordinates: new_coords = [] for vertex in obj.data.vertices: co = obj.matrix_world @ vertex.co @@ -3901,8 +3900,8 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): ) tool.Style.reload_material_from_ifc(material) tool.Geometry.record_object_materials(obj) - - return {'FINISHED'} + + return {"FINISHED"} class ConvertSVGToDXF(bpy.types.Operator): diff --git a/src/bonsai/bonsai/bim/module/project/__init__.py b/src/bonsai/bonsai/bim/module/project/__init__.py index bae6a119d6..6db688185c 100644 --- a/src/bonsai/bonsai/bim/module/project/__init__.py +++ b/src/bonsai/bonsai/bim/module/project/__init__.py @@ -131,4 +131,4 @@ def unregister(): if kc: for km, kmi in addon_keymaps: km.keymap_items.remove(kmi) - addon_keymaps.clear() \ No newline at end of file + addon_keymaps.clear() diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 30eedd038e..55246171b0 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -2843,6 +2843,7 @@ class ClearMeasurement(bpy.types.Operator): tool.Blender.update_viewport() return {"FINISHED"} + class ImageScalingTool(bpy.types.Operator, PolylineOperator): bl_idname = "bim.image_scaling_tool" bl_label = "Image Scaling Tool" @@ -2862,7 +2863,7 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator): self.current_distance_value = "" self.is_typing_distance = False self.calculated_distance = 0.0 - + if tool.Ifc.get(): self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) else: @@ -2872,7 +2873,7 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator): if not self.target_object or not context.active_object or context.active_object != self.target_object: self.report({"ERROR"}, "Image annotation was deselected. Tool cancelled.") return self.cancel_tool(context) - + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) tool.Blender.update_viewport() @@ -2891,9 +2892,9 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator): if event.type == "LEFTMOUSE" and event.value == "PRESS": if len(self.selected_points) < 2: snapped_point = self.snapping_points[0] - point_3d = snapped_point['point'].copy() + point_3d = snapped_point["point"].copy() self.selected_points.append(point_3d) - + if len(self.selected_points) == 2: self.calculate_distance() self.current_distance_value = f"{self.calculated_distance:.3f}" @@ -2903,7 +2904,7 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator): elif len(self.selected_points) == 2: if event.type in {"RET", "NUMPAD_ENTER"} and event.value == "PRESS": return self.apply_scaling(context) - + if event.unicode and event.unicode.isprintable() and event.value == "PRESS": if event.unicode.isdigit() or event.unicode == ".": if not self.is_typing_distance: @@ -2911,14 +2912,16 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator): self.is_typing_distance = True else: self.current_distance_value += event.unicode - + distance_value = float(self.current_distance_value) self.input_ui.set_value("DISTANCE", distance_value) - + elif event.type in {"BACK_SPACE", "DEL"} and event.value == "PRESS": if len(self.current_distance_value) > 0: self.current_distance_value = self.current_distance_value[:-1] - distance_value = float(self.current_distance_value) if self.current_distance_value else self.calculated_distance + distance_value = ( + float(self.current_distance_value) if self.current_distance_value else self.calculated_distance + ) self.input_ui.set_value("DISTANCE", distance_value) self.handle_keyboard_input(context, event) @@ -2937,7 +2940,7 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator): def cancel_tool(self, context): context.workspace.status_text_set(text=None) - if hasattr(self, 'tool_state'): + if hasattr(self, "tool_state"): self.tool_state.plane_method = None PolylineDecorator.uninstall() tool.Blender.update_viewport() @@ -2955,7 +2958,7 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator): instruction_text = f"Measured: {self.calculated_distance:.3f} - Type New Distance or Press Enter" else: instruction_text = "Image Scaling Tool" - + context.workspace.status_text_set(text=instruction_text) def calculate_distance(self): @@ -2984,51 +2987,51 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator): if self.target_object: import bmesh - + mesh = self.target_object.data - + bm = bmesh.new() bm.from_mesh(mesh) - + bmesh.ops.scale(bm, vec=(scale_factor, scale_factor, 1.0), verts=bm.verts) - + if bm.loops.layers.uv: uv_layer = bm.loops.layers.uv.active - + min_x = min(v.co.x for v in bm.verts) max_x = max(v.co.x for v in bm.verts) min_y = min(v.co.y for v in bm.verts) max_y = max(v.co.y for v in bm.verts) - + width = max_x - min_x height = max_y - min_y - + for face in bm.faces: for loop in face.loops: vert = loop.vert u = (vert.co.x - min_x) / width if width > 0 else 0.5 v = (vert.co.y - min_y) / height if height > 0 else 0.5 - + u = max(0.0, min(1.0, u)) v = max(0.0, min(1.0, v)) loop[uv_layer].uv = (u, v) - + bm.to_mesh(mesh) bm.free() mesh.update() - + element = tool.Ifc.get_entity(self.target_object) if element and element.Representation: for representation in element.Representation.Representations: for item in representation.Items: - if item.is_a('IfcPolygonalFaceSet') and item.Coordinates: + if item.is_a("IfcPolygonalFaceSet") and item.Coordinates: new_coords = [] for vertex in mesh.vertices: co = self.target_object.matrix_world @ vertex.co new_coords.append([co.x, co.y, co.z]) - + item.Coordinates.CoordList = new_coords - + self.report({"INFO"}, f"Applied scale factor: {scale_factor:.4f}") context.workspace.status_text_set(text=None) @@ -3037,5 +3040,3 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator): tool.Blender.update_viewport() return {"FINISHED"} - - diff --git a/src/bonsai/bonsai/bim/module/project/workspace.py b/src/bonsai/bonsai/bim/module/project/workspace.py index a5ec38a5ee..59b86e431d 100644 --- a/src/bonsai/bonsai/bim/module/project/workspace.py +++ b/src/bonsai/bonsai/bim/module/project/workspace.py @@ -78,6 +78,7 @@ class ExploreTool(bpy.types.WorkSpaceTool): row = layout.row(align=True) op = row.operator("bim.explore_hotkey", text="Image Scaling Tool", icon="IMAGE_PLANE") op.hotkey = "S_S" + op.description = "Scale Image Annotation. Allows to scale an IfcReferenceImage. Select image, select tool. Check lower left corner instructions to select two points and provide real distance between them" class ExploreHotkey(bpy.types.Operator): @@ -123,12 +124,14 @@ class ExploreHotkey(bpy.types.Operator): active_obj = bpy.context.active_object selected_objects = tool.Blender.get_selected_objects() element = tool.Ifc.get_entity(active_obj) if active_obj else None - - if (not active_obj or - not element or - not element.is_a("IfcAnnotation") or - len(selected_objects) != 1 or - not tool.Drawing.is_annotation_object_type(element, "IMAGE")): + + if ( + not active_obj + or not element + or not element.is_a("IfcAnnotation") + or len(selected_objects) != 1 + or not tool.Drawing.is_annotation_object_type(element, "IMAGE") + ): self.report({"ERROR"}, "Please select one image annotation first.") return From 21c4382b7105ec302cb708bea69a21c44ec0580e Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Tue, 16 Dec 2025 06:56:00 -0600 Subject: [PATCH 25/25] Small UI tweaks --- src/bonsai/bonsai/bim/module/attribute/ui.py | 2 +- src/bonsai/bonsai/bim/module/type/ui.py | 1 - src/bonsai/bonsai/bim/ui.py | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/attribute/ui.py b/src/bonsai/bonsai/bim/module/attribute/ui.py index d0b109a7a8..0665c84dd5 100644 --- a/src/bonsai/bonsai/bim/module/attribute/ui.py +++ b/src/bonsai/bonsai/bim/module/attribute/ui.py @@ -58,7 +58,7 @@ def draw_ui(context: bpy.types.Context, layout: bpy.types.UILayout, attributes) class BIM_PT_object_attributes(Panel): - bl_label = "Attributes" + bl_label = "Object Attributes" bl_idname = "BIM_PT_object_attributes" bl_space_type = "PROPERTIES" bl_region_type = "WINDOW" diff --git a/src/bonsai/bonsai/bim/module/type/ui.py b/src/bonsai/bonsai/bim/module/type/ui.py index 2370782046..24ffdc672e 100644 --- a/src/bonsai/bonsai/bim/module/type/ui.py +++ b/src/bonsai/bonsai/bim/module/type/ui.py @@ -114,7 +114,6 @@ class BIM_PT_type_attributes(Panel): bl_region_type = "WINDOW" bl_context = "object" bl_parent_id = "BIM_PT_type" - bl_options = {"DEFAULT_CLOSED"} @classmethod def poll(cls, context): diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index d41546904c..4a1114d831 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -1363,7 +1363,7 @@ class BIM_PT_tab_sandbox(Panel): # Object panel groups class BIM_PT_tab_object_metadata(Panel): - bl_label = "Object Metadata" + bl_label = "Object" bl_space_type = "PROPERTIES" bl_region_type = "WINDOW" bl_context = "scene"