From 2be142eff23b82b2f2d99685edfbd7392e7c029a Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Mon, 6 Oct 2025 13:36:44 +0200 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 fdf741b2099ae5dacfcfe3645eb9c31e40ebfc60 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Tue, 16 Dec 2025 09:26:28 +0100 Subject: [PATCH 8/8] 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