diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py index 4598c50099..f430229eca 100644 --- a/src/bonsai/bonsai/bim/import_ifc.py +++ b/src/bonsai/bonsai/bim/import_ifc.py @@ -84,8 +84,10 @@ class MaterialCreator: if element.is_a("IfcTypeProduct"): self.parse_element_type_material_styles(element) self.parsed_meshes.add(self.mesh.name) - if self.ifc_import_settings.load_indexed_maps: - self.load_texture_maps(shape_has_openings) + # Texture UV maps are always loaded: they only cost something when a + # texture style is actually present and textures render wrong without them. + # Indexed colour maps stay behind the load_indexed_maps setting. + self.load_texture_maps(shape_has_openings) self.assign_material_slots_to_faces() tool.Geometry.record_object_materials(obj) del self.mesh["ios_materials"] diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 27cfb90ddc..53fd75fb62 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -4035,11 +4035,6 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): precision=3, unit="LENGTH", ) - show_texture_solid_mode: bpy.props.BoolProperty( - name="Show Texture in Solid mode (slow)", - description="Show Texture in Solid mode (slow)", - default=False, - ) @classmethod def poll(cls, context): @@ -4084,13 +4079,10 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper): layout.prop(self, "use_relative_path") else: self.use_relative_path = False - layout.prop(self, "show_texture_solid_mode") layout.prop(self, "x_length") layout.prop(self, "y_length") def _execute(self, context): - project_props = tool.Project.get_project_props() - project_props.load_indexed_maps = self.show_texture_solid_mode space = tool.Blender.get_view3d_space() if space.shading.color_type != "TEXTURE": space.shading.color_type = "TEXTURE" diff --git a/src/bonsai/bonsai/bim/module/project/prop.py b/src/bonsai/bonsai/bim/module/project/prop.py index 00a6d70d29..6dba3dfb80 100644 --- a/src/bonsai/bonsai/bim/module/project/prop.py +++ b/src/bonsai/bonsai/bim/module/project/prop.py @@ -431,8 +431,8 @@ class BIMProjectProperties(PropertyGroup): element_offset: IntProperty(name="Element Offset", default=0) element_limit: IntProperty(name="Element Offset", default=30000) load_indexed_maps: BoolProperty( - name="Load Indexed Maps", - description="Load indexed maps (UV and color maps)", + name="Load Indexed Colour Maps", + description="Load indexed colour maps (vertex colours). Texture UV maps are always loaded", default=False, # Very slow and hackishly implemented ) links: CollectionProperty(name="Links", type=Link) diff --git a/src/bonsai/docs/reference/geometry_and_materials/index.rst b/src/bonsai/docs/reference/geometry_and_materials/index.rst index b1714a23a3..1d85069bde 100644 --- a/src/bonsai/docs/reference/geometry_and_materials/index.rst +++ b/src/bonsai/docs/reference/geometry_and_materials/index.rst @@ -243,6 +243,42 @@ The Styles panel is used to manage presentation styles for elements, including s curve styles (line color, thickness, etc.), and fill area styles. These styles control how elements are visually represented in various contexts, such as in 2D drawings or 3D views. +Textures +-------- + +Bonsai loads textures defined with ``IfcSurfaceStyleWithTextures``, as long as the surface style also +contains an ``IfcSurfaceStyleRendering``. All three texture sources defined by the IFC schema are supported, +and all of them are verified against the official buildingSMART +`tessellated shape with style `__ +examples: + +- ``IfcImageTexture``: the image is loaded from ``URLReference``. Relative URLs are resolved + against the location of the IFC file. +- ``IfcBlobTexture``: the raster data embedded in the IFC file is decoded into an image. +- ``IfcPixelTexture``: the pixel array (1 to 4 colour components) is converted into an image. + +Texture coordinates are applied as follows: + +- ``IfcIndexedTriangleTextureMap`` and ``IfcIndexedPolygonalTextureMap`` are loaded into the mesh UV layer + automatically. Elements with openings are an exception: their triangulation no longer matches the + original faceset, so indexed UVs are skipped for them. +- ``IfcTextureCoordinateGenerator`` with ``COORD`` and ``COORD-EYE`` modes maps to Blender's generated + and camera texture coordinates respectively. +- ``IfcTextureMap`` is not supported. + +Which texture ``Mode`` is honoured depends on the ``ReflectanceMethod`` of the rendering style: + +- ``PHYSICAL`` and ``NOTDEFINED``: ``DIFFUSE``, ``NORMAL``, ``EMISSIVE``, ``METALLICROUGHNESS`` and + ``OCCLUSION``. +- ``FLAT``: ``EMISSIVE`` only. + +``TextureTransform`` (texture scale and rotation) is not supported yet. Indexed colour maps +(``IfcIndexedColourMap``, vertex colours without a texture) are only loaded when the +"Load Indexed Colour Maps" advanced project load setting is enabled. + +Note that the viewport must use Material Preview or Rendered shading (or Solid shading with the color +set to Texture) for textures to be visible. + Profiles -------- diff --git a/src/bonsai/test/bim/test_texture_uv_import.py b/src/bonsai/test/bim/test_texture_uv_import.py new file mode 100644 index 0000000000..2c43dcbeff --- /dev/null +++ b/src/bonsai/test/bim/test_texture_uv_import.py @@ -0,0 +1,150 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2026 +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# +# This file was generated with the assistance of an AI coding tool. + +"""Texture UV maps must load on a plain project load (issue #6702). + +The official buildingSMART texture examples (blob/image/pixel texture on a +tessellated shape) rendered untextured in Bonsai because the mesh UV layer +from IfcIndexedTriangleTextureMap was only applied behind the +"Load Indexed Colour Maps" advanced setting (default off). Texture UVs are +now always loaded; the setting only gates indexed colour maps.""" + +import bpy +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.context +import ifcopenshell.api.geometry +import ifcopenshell.api.root +import ifcopenshell.api.spatial +import ifcopenshell.api.unit +import ifcopenshell.util.unit +from mathutils import Vector + +import bonsai.tool as tool + +from . import bootstrap + +# One quad split in two triangles, textured with a 2x2 pixel texture. +VERTS = ((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 1.0, 0.0), (0.0, 1.0, 0.0)) +TRIANGLES = ((1, 2, 3), (1, 3, 4)) +UV_COORDS = ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)) +TEX_COORD_INDEX = ((1, 2, 3), (1, 3, 4)) + + +def create_textured_ifc(path: str) -> None: + ifc = ifcopenshell.file(schema="IFC4") + project = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcProject", name="Test") + ifcopenshell.api.unit.assign_unit(ifc) + model = ifcopenshell.api.context.add_context(ifc, context_type="Model") + body = ifcopenshell.api.context.add_context( + ifc, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model + ) + + site = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcSite", name="Site") + ifcopenshell.api.aggregate.assign_object(ifc, products=[site], relating_object=project) + element = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcBuildingElementProxy", name="Textured") + ifcopenshell.api.spatial.assign_container(ifc, products=[element], relating_structure=site) + + coordinates = ifc.create_entity("IfcCartesianPointList3D", CoordList=VERTS) + face_set = ifc.create_entity("IfcTriangulatedFaceSet", Coordinates=coordinates, CoordIndex=TRIANGLES) + representation = ifc.create_entity( + "IfcShapeRepresentation", + ContextOfItems=body, + RepresentationIdentifier="Body", + RepresentationType="Tessellation", + Items=[face_set], + ) + ifcopenshell.api.geometry.assign_representation(ifc, product=element, representation=representation) + ifcopenshell.api.geometry.edit_object_placement(ifc, product=element) + + # 2x2 RGBA pixel texture, self-contained (no external image file needed). + texture = ifc.create_entity( + "IfcPixelTexture", + RepeatS=True, + RepeatT=True, + Mode="DIFFUSE", + Width=2, + Height=2, + ColourComponents=4, + # Bit-string encoding of RGBA pixels: red, green, blue, white. + Pixel=[ + "".join(f"{byte:08b}" for byte in rgba) + for rgba in ((255, 0, 0, 255), (0, 255, 0, 255), (0, 0, 255, 255), (255, 255, 255, 255)) + ], + ) + uv_verts = ifc.create_entity("IfcTextureVertexList", TexCoordsList=UV_COORDS) + ifc.create_entity( + "IfcIndexedTriangleTextureMap", + Maps=[texture], + MappedTo=face_set, + TexCoords=uv_verts, + TexCoordIndex=TEX_COORD_INDEX, + ) + + rendering = ifc.create_entity( + "IfcSurfaceStyleRendering", + SurfaceColour=ifc.create_entity("IfcColourRgb", Red=1.0, Green=1.0, Blue=1.0), + ReflectanceMethod="NOTDEFINED", + ) + textures_style = ifc.create_entity("IfcSurfaceStyleWithTextures", Textures=[texture]) + style = ifc.create_entity("IfcSurfaceStyle", Name="Textured style", Side="BOTH", Styles=[rendering, textures_style]) + ifc.create_entity("IfcStyledItem", Item=face_set, Styles=[style], Name=None) + + ifc.write(path) + + +class TestTextureUVImport(bootstrap.NewFile): + def test_texture_uvs_load_without_indexed_maps_setting(self, tmp_path): + ifc_path = str(tmp_path / "textured.ifc") + create_textured_ifc(ifc_path) + + props = tool.Project.get_project_props() + assert not props.load_indexed_maps # texture UVs must not depend on this setting + bpy.ops.bim.load_project(filepath=ifc_path, should_start_fresh_session=False) + + obj = bpy.data.objects.get("IfcBuildingElementProxy/Textured") + assert obj is not None + mesh = obj.data + assert isinstance(mesh, bpy.types.Mesh) + + # UV layer applied from IfcIndexedTriangleTextureMap. + uv_layer = mesh.uv_layers.active + assert uv_layer is not None + expected = [] + for triangle, tex_coord_index in zip(TRIANGLES, TEX_COORD_INDEX): + for vert_index, uv_index in zip(triangle, tex_coord_index): + expected.append((VERTS[vert_index - 1], UV_COORDS[uv_index - 1])) + assert len(uv_layer.data) == len(expected) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + for poly in mesh.polygons: + for loop_index in poly.loop_indices: + vert = mesh.vertices[mesh.loops[loop_index].vertex_index] + matches = [uv for co, uv in expected if (Vector(co) * unit_scale - vert.co).length < 1e-5] + uv = uv_layer.data[loop_index].uv + assert any((Vector(m) - uv).length < 1e-5 for m in matches) + + # Material has the decoded pixel texture wired to Base Color. + material = next((m for m in mesh.materials if m), None) + assert material is not None and material.use_nodes + image_node = next(n for n in material.node_tree.nodes if n.type == "TEX_IMAGE") + assert image_node.image is not None + assert tuple(image_node.image.size) == (2, 2) + (link,) = image_node.outputs[0].links + assert link.to_socket.name == "Base Color"