From a97276b8b1ffe84874b54c58475a82f33585828c Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Thu, 2 Jul 2026 15:22:13 -0500 Subject: [PATCH] Bonsai: load external styles and layerset slicing for linked IFC models Linked models previously flattened every style to a flat diffuse-color material. Now, styles carrying an IfcExternallyDefinedSurfaceStyle that points to a .blend file get the referenced material appended into the link's .cache.blend, in both the chunked and instanced loading paths. Relative style locations resolve against the linked IFC, and appended materials are deduplicated and stripped of stale IFC ids. Multi-layer elements (IfcMaterialLayerSetUsage) are now routed through the instanced path and sliced with slice_layerset_mesh so each layer shows its material style, using the external material when available. slice_layerset_mesh gained a pluggable style-to-material resolver and no longer appends duplicate materials for layers sharing one style. Co-Authored-By: Claude Fable 5 --- .../bonsai/bim/module/project/operator.py | 128 ++++++++++++++++-- src/bonsai/bonsai/tool/loader.py | 19 ++- 2 files changed, 134 insertions(+), 13 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index b0d9fc166d..d09d8dd3ff 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -2127,6 +2127,12 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper): meshes: dict[str, bpy.types.Mesh] # Material names is derived from diffuse as in 'r-g-b-a'. blender_mats: dict[str, bpy.types.Material] + # Materials appended from external .blend styles, keyed by style id. + # None means the style has no loadable external .blend style. + external_style_mats: dict[int, Union[bpy.types.Material, None]] + # Appended data-blocks keyed by (filepath, data_block_type, name) + # so styles sharing the same external material don't append duplicates. + appended_external_blocks: dict[tuple[str, str, str], Union[bpy.types.Material, None]] def invoke(self, context, event): # Invoke is for debugging purposes, users are not intended to use this method really. @@ -2212,6 +2218,9 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper): with open(self.json_filepath, "w") as f: json.dump(data, f) + self.external_style_mats = {} + self.appended_external_blocks = {} + for settings in tool.Loader.settings.context_settings: if not self.elements: break @@ -2251,8 +2260,10 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper): mat = tuple(mat) blender_mat = blender_mats.get(mat, None) if not blender_mat: - blender_mat = bpy.data.materials.new("Chunk") - blender_mat.diffuse_color = mat + blender_mat = self.get_external_material(int(mat[4])) + if not blender_mat: + blender_mat = bpy.data.materials.new("Chunk") + blender_mat.diffuse_color = mat[:4] blender_mats[mat] = blender_mat mat_results.append(blender_mat) @@ -2270,11 +2281,16 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper): while True: # Main loop. shape = iterator.get() assert isinstance(shape, W.TriangulationElement) - results.add(self.file.by_id(shape.id)) + element = self.file.by_id(shape.id) + results.add(element) geometry = shape.geometry - # Elements with a lot of geometry benefit from instancing to save memory - if ifcopenshell.util.shape.get_faces(geometry).shape[0] > 333: # 333 tris + # Elements with a lot of geometry benefit from instancing to save memory. + # Multi-layer elements also take this path as they need their own + # local-space mesh to be sliced into per-layer materials. + if ifcopenshell.util.shape.get_faces(geometry).shape[0] > 333 or self.is_multilayer_element( + element + ): # 333 tris self.process_occurrence(shape) if not iterator.next(): if not chunked_verts: @@ -2291,9 +2307,15 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper): ms = np.vstack([default_mat, ifcopenshell.util.shape.get_material_colors(shape.geometry)]) mi = ifcopenshell.util.shape.get_faces_material_style_ids(shape.geometry) + # Style ids ride along as a 5th column so styles with + # external .blend materials survive the per-color dedup. + style_ids = np.zeros((len(ms), 1)) for geom_material_idx, geom_material in enumerate(shape.geometry.materials): if not geom_material.instance_id(): ms[geom_material_idx + 1] = (0.8, 0.8, 0.8, 1) + elif self.get_external_material(geom_material.instance_id()): + style_ids[geom_material_idx + 1] = geom_material.instance_id() + ms = np.hstack((ms, style_ids)) chunked_materials.append(ms) chunked_material_ids.append(mi + material_offset + 1) material_offset += len(ms) @@ -2380,12 +2402,14 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper): diffuse = (material.diffuse.r(), material.diffuse.g(), material.diffuse.b(), alpha) else: diffuse = (0.8, 0.8, 0.8, 1) # Blender's default material - material_name = f"{diffuse[0]}-{diffuse[1]}-{diffuse[2]}-{diffuse[3]}" - blender_mat = self.blender_mats.get(material_name, None) + blender_mat = self.get_external_material(material.instance_id()) if not blender_mat: - blender_mat = bpy.data.materials.new(material_name) - blender_mat.diffuse_color = diffuse - self.blender_mats[material_name] = blender_mat + material_name = f"{diffuse[0]}-{diffuse[1]}-{diffuse[2]}-{diffuse[3]}" + blender_mat = self.blender_mats.get(material_name, None) + if not blender_mat: + blender_mat = bpy.data.materials.new(material_name) + blender_mat.diffuse_color = diffuse + self.blender_mats[material_name] = blender_mat slot_index = mesh.materials.find(material.name) if slot_index == -1: mesh.materials.append(blender_mat) @@ -2398,6 +2422,8 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper): mesh.polygons.foreach_set("material_index", material_index) mesh.update() + mesh = tool.Loader.slice_layerset_mesh(element, mesh, style_to_material=self.get_style_material) + self.meshes[geometry.id] = mesh obj = bpy.data.objects.new(tool.Loader.get_name(element), mesh) @@ -2410,6 +2436,88 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper): self.collection.objects.link(obj) + def get_external_material(self, style_id: int) -> Union[bpy.types.Material, None]: + """Get the Blender material referenced by a style's external .blend style, if it has one. + + The material is appended from the external .blend file on first use and + cached, so it ends up saved inside the link's .cache.blend. + """ + if not style_id: + return None + if style_id in self.external_style_mats: + return self.external_style_mats[style_id] + + material = None + # instance_id may also refer to an IfcMaterial when the item has + # a material but no style, hence the class check. + style = self.file.by_id(style_id) + external = None + if style.is_a("IfcSurfaceStyle"): + external = next((s for s in style.Styles if s.is_a("IfcExternallyDefinedSurfaceStyle")), None) + + if ( + external + and external.Location + and external.Location.endswith(".blend") + and external.Identification + and "/" in external.Identification + ): + location = Path(external.Location) + if not location.is_absolute(): + # Relative locations are relative to the linked IFC, not the host. + location = Path(self.filepath).parent / location + data_block_type, data_block = external.Identification.split("/", 1) + key = (str(location), data_block_type, data_block) + if key in self.appended_external_blocks: + material = self.appended_external_blocks[key] + elif not location.exists(): + print(f"WARNING. External style file not found for {style}: '{location}'") + self.appended_external_blocks[key] = None + else: + db = tool.Blender.append_data_block(str(location), data_block_type, data_block) + material = db["data_block"] + if not isinstance(material, bpy.types.Material): + print(f"WARNING. Failed to load external style for {style}: {db['msg'] or 'not a material'}") + material = None + else: + # The source .blend may have been authored in a Bonsai session - + # unlink any stale IFC id so it's not misinterpreted here or in the host. + tool.Style.get_material_style_props(material).ifc_definition_id = 0 + self.appended_external_blocks[key] = material + + self.external_style_mats[style_id] = material + return material + + def is_multilayer_element(self, element: ifcopenshell.entity_instance) -> bool: + material = ifcopenshell.util.element.get_material(element) + return bool( + material and material.is_a("IfcMaterialLayerSetUsage") and len(material.ForLayerSet.MaterialLayers) > 1 + ) + + def get_style_material(self, style: ifcopenshell.entity_instance) -> Union[bpy.types.Material, None]: + """Resolve a style to a Blender material for slice_layerset_mesh. + + Prefers the style's external .blend material, falling back to a flat + diffuse material as used for the rest of the linked geometry. + """ + if material := self.get_external_material(style.id()): + return material + # IfcSurfaceStyleRendering is a subclass of IfcSurfaceStyleShading. + shading = next((s for s in style.Styles if s.is_a("IfcSurfaceStyleShading")), None) + if shading: + colour = shading.SurfaceColour + alpha = 1.0 - (getattr(shading, "Transparency", None) or 0.0) + diffuse = (colour.Red, colour.Green, colour.Blue, alpha) + else: + diffuse = (0.8, 0.8, 0.8, 1.0) + material_name = f"{diffuse[0]}-{diffuse[1]}-{diffuse[2]}-{diffuse[3]}" + material = self.blender_mats.get(material_name, None) + if not material: + material = bpy.data.materials.new(material_name) + material.diffuse_color = diffuse + self.blender_mats[material_name] = material + return material + def create_object( self, verts: np.ndarray, diff --git a/src/bonsai/bonsai/tool/loader.py b/src/bonsai/bonsai/tool/loader.py index 47613db47c..97434ebdc6 100644 --- a/src/bonsai/bonsai/tool/loader.py +++ b/src/bonsai/bonsai/tool/loader.py @@ -23,7 +23,7 @@ import os import re from math import atan, radians from pathlib import Path -from typing import Any, Optional, Union, cast +from typing import Any, Callable, Optional, Union, cast import bmesh import bpy @@ -1073,7 +1073,19 @@ class Loader(bonsai.core.tool.Loader): return mesh @classmethod - def slice_layerset_mesh(cls, element: ifcopenshell.entity_instance, mesh: bpy.types.Mesh) -> bpy.types.Mesh: + def slice_layerset_mesh( + cls, + element: ifcopenshell.entity_instance, + mesh: bpy.types.Mesh, + style_to_material: Optional[Callable[[ifcopenshell.entity_instance], Union[bpy.types.Material, None]]] = None, + ) -> bpy.types.Mesh: + """Bisect a layerset element's mesh at layer boundaries and assign each layer its material style. + + :param style_to_material: Callback resolving an IfcSurfaceStyle to a Blender material. + Defaults to the IFC-linked material, which only works for the actively edited project. + """ + if style_to_material is None: + style_to_material = tool.Ifc.get_object if not (material := ifcopenshell.util.element.get_material(element)): return mesh elif material.is_a("IfcMaterialLayerSetUsage"): @@ -1121,7 +1133,8 @@ class Loader(bonsai.core.tool.Loader): continue if (material_index := styles.get(style, None)) is None: material_index = len(mesh.materials) - mesh.materials.append(tool.Ifc.get_object(style)) + mesh.materials.append(style_to_material(style)) + styles[style] = material_index if i == last_i: for face in bisect_geom["geom"]: if isinstance(face, bmesh.types.BMFace):