diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py index 539ec89736..05bcbed01a 100644 --- a/src/bonsai/bonsai/bim/import_ifc.py +++ b/src/bonsai/bonsai/bim/import_ifc.py @@ -889,6 +889,48 @@ class IfcImporter: 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 + + 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/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/classification/data.py b/src/bonsai/bonsai/bim/module/classification/data.py index acc88af10a..a1fa3a6d58 100644 --- a/src/bonsai/bonsai/bim/module/classification/data.py +++ b/src/bonsai/bonsai/bim/module/classification/data.py @@ -104,6 +104,7 @@ class ReferencesData: if element: for reference in ifcopenshell.util.classification.get_references(element): data = reference.get_info() + data["ifcClassificationReference"] = reference del data["ReferencedSource"] results.append(data) return results 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 344c247af2..077f97553f 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, @@ -152,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: @@ -248,10 +258,20 @@ 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: row = self.layout.row(align=True) + + 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: diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index e3726d5073..248fae5775 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -3716,18 +3716,100 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): 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": + space.shading.color_type = "TEXTURE" + self.report( + {"WARNING"}, + '"Object Color" for Viewport Shading: Solid changed to "Texture" 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() @@ -3736,13 +3818,39 @@ 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) + + if not bm.loops.layers.uv: + uv_layer = bm.loops.layers.uv.new() + else: + 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) + tool.Blender.apply_bmesh(mesh, bm) if self.use_existing_object_by_name: @@ -3763,6 +3871,23 @@ 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) + + 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) material = bpy.data.materials.new(name=image_filepath.stem) @@ -3803,6 +3928,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): bl_idname = "bim.convert_svg_to_dxf" diff --git a/src/bonsai/bonsai/bim/module/project/__init__.py b/src/bonsai/bonsai/bim/module/project/__init__.py index 1316eb0325..6db688185c 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, diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 0650cda238..8f22492132 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -119,6 +119,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": @@ -2894,3 +2896,201 @@ 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/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index 6674a1a51a..6fe5b55604 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -356,8 +356,23 @@ 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() prop_with_search(self.layout, pprops, "template_file", text="Template") + 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") diff --git a/src/bonsai/bonsai/bim/module/project/workspace.py b/src/bonsai/bonsai/bim/module/project/workspace.py index e7b9220fc1..59b86e431d 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,14 @@ 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" + 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): bl_idname = "bim.explore_hotkey" @@ -110,3 +119,20 @@ 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") 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..24ffdc672e 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,50 @@ 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" + + @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") diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py index 7a4464c986..08137a133e 100644 --- a/src/bonsai/bonsai/bim/prop.py +++ b/src/bonsai/bonsai/bim/prop.py @@ -586,7 +586,33 @@ 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"), + ("GRAM", "Gram", "Grams"), + ("POUND", "Pound", "Pounds"), + ("OUNCE", "Ounce", "Ounces"), + ("TONNE", "Tonne", "Metric Tons"), + ], + name="Mass Unit", + default="KILOGRAM", + ) + time_unit: EnumProperty( + items=[ + ("SECOND", "Second", "Seconds"), + ("MINUTE", "Minute", "Minutes"), + ("HOUR", "Hour", "Hours"), + ("DAY", "Day", "Days"), + ], + name="Time Unit", + default="HOUR", + ) if TYPE_CHECKING: is_dirty: bool schema_dir: str @@ -599,6 +625,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/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index b7ed2180c1..4a1114d831 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 @@ -1355,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" 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 a075c9c5e5..a4c52508f1 100644 --- a/src/bonsai/bonsai/core/unit.py +++ b/src/bonsai/bonsai/core/unit.py @@ -28,20 +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] + + 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] + 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")) + planeangleunit = ifc.run("unit.add_conversion_based_unit", name="degree") + units = [lengthunit, areaunit, volumeunit, planeangleunit] - planeangleunit = ifc.run("unit.add_conversion_based_unit", name="degree") - - ifc.run("unit.assign_unit", units=[lengthunit, areaunit, volumeunit, planeangleunit]) + 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 d01d51f3cb..c982c43523 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.lower() + elif unit_type == "TIMEUNIT": + return bim_props.time_unit.lower() else: assert_never(unit_type) @@ -359,6 +363,24 @@ 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 == "GRAM": + return None + elif unit == "KILOGRAM": + return "KILO" + elif unit == "TONNE": + 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: @@ -477,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 d79b6b83fb..2bbd783cc3 100644 --- a/src/bonsai/test/core/test_unit.py +++ b/src/bonsai/test/core/test_unit.py @@ -26,38 +26,119 @@ 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) + 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.assign_unit", units=["lengthunit", "areaunit", "volumeunit", "planeangleunit", "massunit", "timeunit"] + ).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("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.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") + 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_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") + 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"] + ).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.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") + + 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", "planeangleunit", "massunit", "timeunit"] + ).should_be_called() + subject.assign_scene_units(ifc, unit) + + class TestAssignUnit: def test_run(self, ifc, unit): ifc.run("unit.assign_unit", units=["unit"]).should_be_called() 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 bb1e920257..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. @@ -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}) diff --git a/src/ifcopenshell-python/ifcopenshell/api/unit/assign_unit.py b/src/ifcopenshell-python/ifcopenshell/api/unit/assign_unit.py index d57acbd435..5861e4248a 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/unit/assign_unit.py +++ b/src/ifcopenshell-python/ifcopenshell/api/unit/assign_unit.py @@ -55,8 +55,12 @@ def assign_unit( length = ifcopenshell.api.unit.add_si_unit(model, unit_type="LENGTHUNIT", prefix="MILLI") area = ifcopenshell.api.unit.add_si_unit(model, unit_type="AREAUNIT") - # Make it our default units, if we are doing a metric building - ifcopenshell.api.unit.assign_unit(model, units=[length, area]) + # 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, you may specify without any arguments to # automatically create millimeters, square meters, and cubic meters @@ -139,6 +143,7 @@ class Usecase: elif unit_type == "volume": dimensional_exponents = self.file.createIfcDimensionalExponents(3, 0, 0, 0, 0, 0, 0) name_prefix = "cubic" + si_unit = self.file.createIfcSIUnit( None, "{}UNIT".format(unit_type.upper()), 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/api/unit/test_add_conversion_based_unit.py b/src/ifcopenshell-python/test/api/unit/test_add_conversion_based_unit.py index 03a8a7edfd..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 @@ -40,6 +40,54 @@ class TestAddConversionBasedUnitIFC2X3(test.bootstrap.IFC2X3): assert si_unit.Prefix is None assert si_unit.Name == "METRE" + 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 + class TestAddConversionBasedUnitIFC4(test.bootstrap.IFC4, TestAddConversionBasedUnitIFC2X3): def test_adding_a_unit_with_offset(self): @@ -61,3 +109,8 @@ class TestAddConversionBasedUnitIFC4(test.bootstrap.IFC4, TestAddConversionBased 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"