From d4388ec76dc869473169f267a26fe56bbd881211 Mon Sep 17 00:00:00 2001 From: falken10vdl <33285113+falken10vdl@users.noreply.github.com> Date: Thu, 19 Feb 2026 01:56:10 +0100 Subject: [PATCH] AddReferenceImage: fix regression with IFC2X3 support, refactor to no longer depend on add_representation or update_representation, remove legacy style updating functionality * Enhance AddReferenceImage operator to use file browser instead of independent popup dialogue * Fix dimensions assertion in TestAddReferenceImage * Remove error in return in _execute (it is not execute) * Add IFC2X3 support to AddReferenceImage * Adde unit="LENGTH" to the x/y properties (every length dimension everywhere in the UI is in project length units. No need to say it explicitly) * Manually create the texture always, not just for IFC2X3 * Add poll method to AddReferenceImage operator to check for loaded IFC project * Refactor AddReferenceImage to add representation manually following pattern in root/operator.py's bim.add_element * Improve File explorer options between new and select from existing project Ifc Reference Images * Refactor get_existing_reference_images to use selector for filtering image annotations * No extra args needed after should_add_representation is False * Doing clean=True deletes everything * Don't manually add geometry and materials, don't call bpy.ops. Only create IFC data, then use preexisting loading functions to create geometry. * Black formatting, also now we can start to remove this operator as it becomes obsolete * Consolidate duplicate UV generation into Loader.load_generated_uv_map Replace 3 identical XY-UV baking blocks (create_object IMAGE, bm_add_image_plane, ImageScalingTool) with a single reusable classmethod in tool.Loader. * Fix IFC4 texture display in Solid viewport Texture mode IFC4 IfcTextureCoordinateGenerator Mode=COORD is used, load_texture_maps falls back to load_generated_uv_map to bake XY-UV data onto the mesh. * Fix IFC2X3 texture display * This looks wrong * Remove legacy override image feature, because we now have a proper styles and texture manager * Remove legacy override existing image element, because we now have a dedicated styles texture manager * Remove unnecessary roundtrip to bmesh and mesh --------- Co-authored-by: Dion Moult --- src/bonsai/bonsai/bim/import_ifc.py | 49 +--- .../bonsai/bim/module/drawing/operator.py | 231 ++++++++---------- .../bonsai/bim/module/project/operator.py | 22 +- .../bonsai/bim/module/style/operator.py | 1 + src/bonsai/bonsai/tool/loader.py | 38 ++- src/bonsai/test/tool/test_drawing.py | 6 +- 6 files changed, 153 insertions(+), 194 deletions(-) diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py index 6e04800dd4..9f846d8ccc 100644 --- a/src/bonsai/bonsai/bim/import_ifc.py +++ b/src/bonsai/bonsai/bim/import_ifc.py @@ -85,7 +85,7 @@ class MaterialCreator: if element.is_a("IfcTypeProduct"): self.parse_element_type_material_styles(element) self.parsed_meshes.add(self.mesh.name) - if not self.ifc_import_settings.load_indexed_maps: + if self.ifc_import_settings.load_indexed_maps: self.load_texture_maps(shape_has_openings) self.assign_material_slots_to_faces() tool.Geometry.record_object_materials(obj) @@ -117,7 +117,6 @@ class MaterialCreator: for texture in texture_style.Textures or []: if coords := getattr(texture, "IsMappedBy", None): coords = coords[0] - # IfcTextureCoordinateGenerator handled in the style shader graph if coords.is_a("IfcIndexedTextureMap"): return coords # TODO: support IfcTextureMap @@ -135,6 +134,10 @@ class MaterialCreator: if shape_has_openings and coords.is_a("IfcIndexedTextureMap"): continue tool.Loader.load_indexed_map(coords, self.mesh) + elif tool.Style.get_texture_style(material): + # No explicit coordinate mapping (e.g. IFC2X3 has no IsMappedBy, + # and IFC4 COORD uses generated UVs). Bake XY→UV as fallback. + tool.Loader.load_generated_uv_map(self.mesh) def assign_material_slots_to_faces(self) -> None: if not self.mesh["ios_materials"]: @@ -892,48 +895,6 @@ 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/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 144a687af6..ad90e24693 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -40,6 +40,7 @@ from typing import ( import bmesh import bpy +import logging import ifcopenshell import ifcopenshell.api import ifcopenshell.api.document @@ -50,6 +51,7 @@ import ifcopenshell.ifcopenshell_wrapper import ifcopenshell.util.element import ifcopenshell.util.representation import ifcopenshell.util.selector +import ifcopenshell.util.shape_builder import ifcopenshell.util.unit import numpy as np import shapely @@ -59,6 +61,7 @@ from bpy_extras.io_utils import ImportHelper from lxml import etree from mathutils import Color, Matrix, Vector +import bonsai.bim.import_ifc import bonsai.bim.export_ifc import bonsai.bim.handler import bonsai.bim.helper @@ -138,7 +141,7 @@ class AddAnnotationType(bpy.types.Operator, tool.Ifc.Operator): element.ApplicableOccurrence = f"IfcAnnotation/{object_type}" if props.create_representation_for_type and object_type == "IMAGE": - bpy.ops.bim.add_reference_image("INVOKE_DEFAULT", use_existing_object_by_name=obj.name) + bpy.ops.bim.add_reference_image("INVOKE_DEFAULT", existing_object_by_name=obj.name) class EnableAddAnnotationType(bpy.types.Operator): @@ -1759,7 +1762,7 @@ class AddAnnotation(bpy.types.Operator, tool.Ifc.Operator): enable_editing=True, ) if props.object_type == "IMAGE": - bpy.ops.bim.add_reference_image("INVOKE_DEFAULT", use_existing_object_by_name=obj.name) + bpy.ops.bim.add_reference_image("INVOKE_DEFAULT", existing_object_by_name=obj.name) class AddSheet(bpy.types.Operator, tool.Ifc.Operator): @@ -3802,27 +3805,70 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=True) filter_image: bpy.props.BoolProperty(default=True, options={"HIDDEN", "SKIP_SAVE"}) filter_folder: bpy.props.BoolProperty(default=True, options={"HIDDEN", "SKIP_SAVE"}) + x_length: bpy.props.FloatProperty( + name="X Length", + description="Width of the reference image", + default=1.0, + min=0.001, + soft_min=0.01, + precision=3, + unit="LENGTH", + ) + y_length: bpy.props.FloatProperty( + name="Y Length", + description="Height of the reference image", + default=1.0, + min=0.001, + soft_min=0.01, + precision=3, + unit="LENGTH", + ) - override_existing_image: bpy.props.BoolProperty( - name="Override Existing Image", - default=True, - description=( - "Override image if it was previously loaded to Blender. If disabled, will always create a new image" - ), - ) - use_existing_object_by_name: bpy.props.StringProperty( - name="Use Existing Object By Name", - description="Existing object name to add a style with reference image to. If not provided will create a new object.", - options={"SKIP_SAVE"}, - ) - size: bpy.props.FloatProperty(name="Size", description="Size of the reference image", default=1.0, unit="LENGTH") + @classmethod + def poll(cls, context): + if not tool.Ifc.get(): + cls.poll_message_set("No IFC project is loaded.") + return False + return True + + def invoke(self, context, event): + self._last_filepath = "" + return super().invoke(context, event) + + def check(self, context): + if not hasattr(self, "_last_filepath"): + self._last_filepath = "" + + if self.filepath and self.filepath != self._last_filepath: + self._last_filepath = self.filepath + + abs_path = Path(self.filepath).absolute().resolve() + if abs_path.exists() and abs_path.is_file(): + image = load_image(abs_path.name, str(abs_path.parent), check_existing=False) + 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) + return True + + return False def draw(self, context): + layout = self.layout if Path(tool.Ifc.get_path()).is_file(): - self.layout.prop(self, "use_relative_path") - self.layout.prop(self, "override_existing_image") - self.layout.prop(self, "use_existing_object_by_name") - self.layout.prop(self, "size") + layout.prop(self, "use_relative_path") + else: + self.use_relative_path = False + layout.prop(self, "x_length") + layout.prop(self, "y_length") def _execute(self, context): space = tool.Blender.get_view3d_space() @@ -3837,127 +3883,66 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): image_filepath = Path(tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path)) ifc_file = tool.Ifc.get() - if self.override_existing_image: - params = {"check_existing": True, "force_reload": True} - else: - params = {"check_existing": False} + params = {"check_existing": False} image = load_image(abs_path.name, str(abs_path.parent), **params) - aspect_ratio = image.size[0] / image.size[1] - if aspect_ratio >= 1.0: # Landscape - x_length = self.size - y_length = self.size / aspect_ratio - else: - x_length = self.size / aspect_ratio - y_length = self.size + mesh = bpy.data.meshes.new(image_filepath.stem) + obj = bpy.data.objects.new(image_filepath.stem, mesh) + element = tool.Drawing.run_root_assign_class( + obj=obj, ifc_class="IfcAnnotation", predefined_type="IMAGE", should_add_representation=False + ) - def bm_add_image_plane(mesh): - bm = tool.Blender.get_bmesh_for_mesh(mesh, clean=True) + builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) + hx = self.x_length * 0.5 / unit_scale + hy = self.y_length * 0.5 / unit_scale + verts = [(-hx, -hy, 0.0), ( hx, -hy, 0.0), ( hx, hy, 0.0), (-hx, hy, 0.0)] + item = builder.mesh(verts, [[0, 1, 2, 3]]) - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) - plane_scale = Vector((x_length / 2.0, y_length / 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) + ifc_context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW") + representation = builder.get_representation(ifc_context, [item]) + ifcopenshell.api.geometry.assign_representation(ifc_file, element, representation) - 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: - obj = bpy.data.objects[self.use_existing_object_by_name] - bm_add_image_plane(obj.data) - bpy.ops.bim.update_representation(obj=obj.name, ifc_representation_class="") - else: - temp_mesh = bpy.data.meshes.new("temp_mesh") - bm_add_image_plane(temp_mesh) - obj = bpy.data.objects.new(image_filepath.stem, temp_mesh) - tool.Drawing.run_root_assign_class( - obj=obj, - ifc_class="IfcAnnotation", - predefined_type="IMAGE", - should_add_representation=True, - context=ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW"), - ifc_representation_class=None, - ) - 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) - obj.data.materials.append(None) # new slot - obj.material_slots[0].material = material - bpy.ops.bim.add_style() - - style = tool.Ifc.get_entity(material) - assert style - tool.Style.assign_style_to_object(style, obj) + style = ifcopenshell.api.style.add_style(tool.Ifc.get(), name=image_filepath.stem) + ifcopenshell.api.style.assign_representation_styles( + ifc_file, shape_representation=representation, styles=[style] + ) # TODO: IfcSurfaceStyleRendering is unnecessary here, added it only because # we don't support IfcSurfaceStyleWithTextures without Rendering yet shading_attributes = { - "SurfaceColour": { - "Red": 1.0, - "Green": 1.0, - "Blue": 1.0, - }, + "SurfaceColour": {"Red": 1.0, "Green": 1.0, "Blue": 1.0}, "Transparency": 0.0, "ReflectanceMethod": "NOTDEFINED", } ifcopenshell.api.style.add_surface_style( - tool.Ifc.get(), - style=style, - ifc_class="IfcSurfaceStyleRendering", - attributes=shading_attributes, + tool.Ifc.get(), style=style, ifc_class="IfcSurfaceStyleRendering", attributes=shading_attributes ) - texture = ifc_file.create_entity("IfcImageTexture", Mode="DIFFUSE", URLReference=image_filepath.as_posix()) + + if tool.Ifc.get_schema() == "IFC2X3": + texture = ifc_file.create_entity( + "IfcImageTexture", + RepeatS=True, + RepeatT=True, + TextureType="TEXTURE", + UrlReference=image_filepath.as_posix(), + ) + else: + texture = ifc_file.create_entity("IfcImageTexture", Mode="DIFFUSE", URLReference=image_filepath.as_posix()) + ifc_file.create_entity("IfcTextureCoordinateGenerator", Maps=[texture], Mode="COORD") + textures = [texture] - ifc_file.create_entity("IfcTextureCoordinateGenerator", Maps=textures, Mode="COORD") # UV map ifcopenshell.api.style.add_surface_style( - ifc_file, - style=style, - ifc_class="IfcSurfaceStyleWithTextures", - attributes={"Textures": textures}, + ifc_file, style=style, ifc_class="IfcSurfaceStyleWithTextures", attributes={"Textures": textures} ) - tool.Style.reload_material_from_ifc(material) - tool.Geometry.record_object_materials(obj) + + logger = logging.getLogger("ImportIFC") + ifc_import_settings = bonsai.bim.import_ifc.IfcImportSettings.factory(bpy.context, None, logger) + ifc_importer = bonsai.bim.import_ifc.IfcImporter(ifc_import_settings) + ifc_importer.file = tool.Ifc.get() + ifc_importer.create_style(style) + + bonsai.core.geometry.switch_representation(tool.Ifc, tool.Geometry, obj=obj, representation=representation) class ConvertSVGToDXF(bpy.types.Operator): diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index edef161559..43588584bd 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -3248,29 +3248,9 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator): 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() + tool.Loader.load_generated_uv_map(mesh) mesh.update() element = tool.Ifc.get_entity(self.target_object) diff --git a/src/bonsai/bonsai/bim/module/style/operator.py b/src/bonsai/bonsai/bim/module/style/operator.py index a26cce67f1..e7d1d05e0a 100644 --- a/src/bonsai/bonsai/bim/module/style/operator.py +++ b/src/bonsai/bonsai/bim/module/style/operator.py @@ -87,6 +87,7 @@ class RemoveStyle(bpy.types.Operator, tool.Ifc.Operator): core.remove_style(tool.Ifc, tool.Style, style=tool.Ifc.get().by_id(self.style), reload_styles_ui=True) +# TODO: remove completely class AddStyle(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_style" bl_label = "Add Style" diff --git a/src/bonsai/bonsai/tool/loader.py b/src/bonsai/bonsai/tool/loader.py index d81fec5fd2..94b15e73b7 100644 --- a/src/bonsai/bonsai/tool/loader.py +++ b/src/bonsai/bonsai/tool/loader.py @@ -179,7 +179,8 @@ class Loader(bonsai.core.tool.Loader): def surface_texture_to_dict(cls, surface_texture): if isinstance(surface_texture, dict): return surface_texture - mappings = surface_texture.IsMappedBy or [] + # IsMappedBy is an IFC4+ inverse attribute, not available in IFC2X3. + mappings = getattr(surface_texture, "IsMappedBy", None) or [] surface_texture = surface_texture.get_info() uv_mode = None if mappings: @@ -188,7 +189,7 @@ class Loader(bonsai.core.tool.Loader): uv_mode = "Generated" elif coordinates.is_a("IfcTextureCoordinateGenerator") and coordinates.Mode == "COORD-EYE": uv_mode = "Camera" - surface_texture["uv_mode"] = uv_mode or "UV" + surface_texture["uv_mode"] = uv_mode or "Generated" return surface_texture @classmethod @@ -286,6 +287,9 @@ class Loader(bonsai.core.tool.Loader): for texture in textures: mode = texture.get("Mode", None) + # IFC2X3 IfcImageTexture has no Mode attribute; default to DIFFUSE. + if mode is None and texture["type"] == "IfcImageTexture": + mode = "DIFFUSE" node = None image_url = None @@ -293,7 +297,8 @@ class Loader(bonsai.core.tool.Loader): def get_image() -> Union[bpy.types.Image, None]: # TODO: orphaned textures after shader recreated? if texture["type"] == "IfcImageTexture": - original_image_url = texture["URLReference"] + # IFC2X3 uses UrlReference, IFC4+ uses URLReference. + original_image_url = texture.get("URLReference") or texture.get("UrlReference", "") is_relative = not os.path.isabs(original_image_url) nonlocal image_url image_url = Path(original_image_url) @@ -539,6 +544,33 @@ class Loader(bonsai.core.tool.Loader): for colour in colours: cls.load_indexed_map(colour, mesh) + @classmethod + def load_generated_uv_map(cls, mesh: bpy.types.Mesh) -> None: + bm = bmesh.new() + bm.from_mesh(mesh) + uv_layer = bm.loops.layers.uv.active or bm.loops.layers.uv.new("UVMap") + + all_verts = [v.co for v in bm.verts] + if not all_verts: + bm.free() + return + + min_x = min(v.x for v in all_verts) + max_x = max(v.x for v in all_verts) + min_y = min(v.y for v in all_verts) + max_y = max(v.y for v in all_verts) + width = max_x - min_x + height = max_y - min_y + + for face in bm.faces: + for loop in face.loops: + u = (loop.vert.co.x - min_x) / width if width > 0 else 0.5 + v = (loop.vert.co.y - min_y) / height if height > 0 else 0.5 + loop[uv_layer].uv = (max(0.0, min(1.0, u)), max(0.0, min(1.0, v))) + + bm.to_mesh(mesh) + bm.free() + @classmethod def load_indexed_map(cls, index_map: ifcopenshell.entity_instance, mesh: bpy.types.Mesh) -> None: """Add data from index map as blender mesh attribute. diff --git a/src/bonsai/test/tool/test_drawing.py b/src/bonsai/test/tool/test_drawing.py index b0d2756198..69d7fbde92 100644 --- a/src/bonsai/test/tool/test_drawing.py +++ b/src/bonsai/test/tool/test_drawing.py @@ -934,11 +934,11 @@ class TestAddReferenceImage(NewFile): bpy.ops.bim.save_project(filepath=str(ifc_path), should_save_as=True) filepath = Path("test/files/image.jpg").absolute() - bpy.ops.bim.add_reference_image(filepath=str(filepath)) + bpy.ops.bim.add_reference_image(filepath=str(filepath), x_length=3.53982, y_length=2.0) obj = bpy.data.objects["IfcAnnotation/image"] assert obj is not None - assert tool.Cad.are_vectors_equal(obj.dimensions, Vector((1.0, 0.565, 0.0))) + assert tool.Cad.are_vectors_equal(obj.dimensions, Vector((3.53982, 2.0, 0.0))) material = obj.active_material assert material @@ -957,4 +957,4 @@ class TestAddReferenceImage(NewFile): assert texture_filepath == filepath uv_node = material_nodes["Texture Coordinate"] - assert len(uv_node.outputs["Generated"].links[:]) == 1 + assert len(uv_node.outputs["UV"].links[:]) == 1