diff --git a/src/bonsai/bonsai/bim/handler.py b/src/bonsai/bonsai/bim/handler.py index c8f91ed6ee..c8502a4c0e 100644 --- a/src/bonsai/bonsai/bim/handler.py +++ b/src/bonsai/bonsai/bim/handler.py @@ -418,9 +418,10 @@ def get_user(ifc: ifcopenshell.file) -> Union[ifcopenshell.entity_instance, None def viewport_shading_changed_callback(area: bpy.types.Area) -> None: - shading = area.spaces.active.shading.type - if shading == "RENDERED": - tool.Style.get_style_props().active_style_type = "External" + shading_type = area.spaces.active.shading.type + tool.Style.restore_material_style_types(shading_type) + if shading_type == "SOLID": + area.spaces.active.shading.color_type = "MATERIAL" def subscribe_to_viewport_shading_changes(): diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py index 4598c50099..864ce97445 100644 --- a/src/bonsai/bonsai/bim/import_ifc.py +++ b/src/bonsai/bonsai/bim/import_ifc.py @@ -133,10 +133,6 @@ 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"]: diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index efe36b0c78..0a342b9331 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -1299,6 +1299,10 @@ class LoadProjectElements(bpy.types.Operator): tool.Project.set_default_modeling_dimensions() tool.Root.reload_grid_decorator() bonsai.bim.handler.refresh_ui_data() + for screen in bpy.data.screens: + for area in screen.areas: + if area.type == "VIEW_3D": + bonsai.bim.handler.viewport_shading_changed_callback(area) return {"FINISHED"} def get_decomposition_elements(self) -> set[ifcopenshell.entity_instance]: diff --git a/src/bonsai/bonsai/bim/module/style/__init__.py b/src/bonsai/bonsai/bim/module/style/__init__.py index 6f13fd6f2d..3102435160 100644 --- a/src/bonsai/bonsai/bim/module/style/__init__.py +++ b/src/bonsai/bonsai/bim/module/style/__init__.py @@ -45,7 +45,9 @@ classes = ( operator.SelectByStyle, operator.SelectStyleInStylesUI, operator.SetAssetMaterialToExternalStyle, + operator.SuggestShadeFromExternalStyle, operator.UnlinkStyle, + operator.TogglePreferIfcShading, operator.UpdateCurrentStyle, operator.UpdateStyleColours, operator.UpdateStyleTextures, diff --git a/src/bonsai/bonsai/bim/module/style/operator.py b/src/bonsai/bonsai/bim/module/style/operator.py index 4672b806f5..8642b82066 100644 --- a/src/bonsai/bonsai/bim/module/style/operator.py +++ b/src/bonsai/bonsai/bim/module/style/operator.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +import colorsys import os from pathlib import Path from typing import Any, Union @@ -238,13 +239,15 @@ class UpdateCurrentStyle(bpy.types.Operator): if not isinstance(obj.data, (bpy.types.Mesh, bpy.types.Curve)): continue for mat in obj.data.materials: - if ( - mat - and mat not in updated_materials - and (msprops_ := tool.Style.get_material_style_props(mat)).ifc_definition_id != 0 - ): - msprops_.active_style_type = current_style_type - updated_materials.add(mat) + if not mat: + continue + msprops_ = tool.Style.get_material_style_props(mat) + if msprops_.ifc_definition_id == 0: + continue + if mat in updated_materials: + continue + msprops_.active_style_type = current_style_type + updated_materials.add(mat) return {"FINISHED"} @@ -457,10 +460,14 @@ class ActivateExternalStyle(bpy.types.Operator): self.report({"ERROR"}, f"Error loading external style for \"{material.name}\" - {db['msg']}") return {"CANCELLED"} - self.copy_material_attributes(db["data_block"], material) + ext_mat = db["data_block"] + self.copy_material_attributes(ext_mat, material) if tool.Style.get_use_nodes(material): - tool.Blender.copy_node_graph(material, db["data_block"]) - bpy.data.materials.remove(db["data_block"]) + if material.get("bim_dual_branch"): + tool.Style.update_external_branch(material, ext_mat) + else: + tool.Style.setup_dual_branch(material, ext_mat) + bpy.data.materials.remove(ext_mat) return {"FINISHED"} def copy_material_attributes(self, source, target): @@ -503,6 +510,218 @@ class ActivateExternalStyle(bpy.types.Operator): set_prop(prop_name) +class TogglePreferIfcShading(bpy.types.Operator): + bl_idname = "bim.toggle_prefer_ifc_shading" + bl_label = "Toggle Flat/Pretty" + bl_description = ( + "Toggle between Flat (IFC-native shading) and Pretty (external .blend style) for ALL styles.\n\n" + "SHIFT+CLICK to apply to this style only" + ) + bl_options = {"REGISTER", "UNDO"} + material_name: bpy.props.StringProperty(name="Material Name", default="", options={"SKIP_SAVE"}) + single_only: bpy.props.BoolProperty(name="Single Only", default=False, options={"SKIP_SAVE"}) + + def invoke(self, context, event): + if event.shift: + self.single_only = True + return self.execute(context) + + def execute(self, context): + wm = context.window_manager + space = tool.Blender.get_view3d_space() + is_solid = space and space.shading.type == "SOLID" + + if is_solid: + if space.shading.color_type == "TEXTURE": + space.shading.color_type = "MATERIAL" + else: + meshes_needing_uv = [] + for obj in bpy.context.scene.objects: + if not isinstance(obj.data, bpy.types.Mesh): + continue + for slot in obj.material_slots: + mat = slot.material + if not mat or not tool.Blender.get_ifc_definition_id(mat): + continue + style_elements = tool.Style.get_style_elements(mat) + if style_elements.get("IfcSurfaceStyleWithTextures") and not obj.data.uv_layers: + meshes_needing_uv.append(obj.data) + break + wm.progress_begin(0, max(len(meshes_needing_uv), 1)) + try: + for i, mesh in enumerate(meshes_needing_uv): + tool.Loader.load_generated_uv_map(mesh) + wm.progress_update(i) + finally: + wm.progress_end() + space.shading.color_type = "TEXTURE" + return {"FINISHED"} + + if self.single_only: + mat = bpy.data.materials.get(self.material_name) + if not mat: + return {"CANCELLED"} + msprops = tool.Style.get_material_style_props(mat) + msprops.prefer_ifc_shading = not msprops.prefer_ifc_shading + else: + # Default: apply to all IFC materials + source_mat = bpy.data.materials.get(self.material_name) + new_value = not tool.Style.get_material_style_props(source_mat).prefer_ifc_shading if source_mat else True + ifc_mats = [m for m in bpy.data.materials if tool.Blender.get_ifc_definition_id(m)] + for mat in ifc_mats: + tool.Style.get_material_style_props(mat).prefer_ifc_shading = new_value + return {"FINISHED"} + + +class SuggestShadeFromExternalStyle(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.suggest_shade_from_external_style" + bl_label = "Suggest Shade from External Style" + bl_description = ( + "Generate a Shade style (Surface Colour + Transparency) from the external .blend style.\n\n" + "ALT+CLICK to apply to all styles with an external .blend style" + ) + bl_options = {"REGISTER", "UNDO"} + material_name: bpy.props.StringProperty(name="Material Name", default="", options={"SKIP_SAVE"}) + all_styles: bpy.props.BoolProperty(name="All Styles", default=False, options={"SKIP_SAVE"}) + value_offset: bpy.props.FloatProperty( + name="Value", + description="Offset added to the colour's value (-1 = fully dark, 0 = unchanged, +1 = fully light)", + default=0.0, + min=-1.0, + max=1.0, + step=1, + precision=2, + options={"SKIP_SAVE"}, + ) + saturation_factor: bpy.props.FloatProperty( + name="Saturation", + description="Scale applied to the colour's saturation (0 = greyscale, 1 = unchanged, >1 = more saturated)", + default=1.0, + min=0.0, + max=2.0, + step=1, + precision=2, + options={"SKIP_SAVE"}, + ) + + def invoke(self, context, event): + if event.alt: + self.all_styles = True + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + layout = self.layout + layout.prop(self, "value_offset", slider=True) + layout.prop(self, "saturation_factor", slider=True) + if self.all_styles: + layout.label(text="Will apply to all external styles", icon="INFO") + + def _execute(self, context): + if self.all_styles: + candidates = [ + (mat, tool.Style.get_style_elements(mat)) + for mat in bpy.data.materials + if tool.Blender.get_ifc_definition_id(mat) + ] + candidates = [(mat, se) for mat, se in candidates if tool.Style.has_blender_external_style(se)] + wm = context.window_manager + wm.progress_begin(0, max(len(candidates), 1)) + count = 0 + color_cache: dict[tuple[str, str, str], tuple | None] = {} + try: + for i, (mat, style_elements) in enumerate(candidates): + wm.progress_update(i) + if self._apply_to_material( + mat, style_elements, self.value_offset, self.saturation_factor, color_cache + ): + count += 1 + finally: + wm.progress_end() + self.report({"INFO"}, f"Shade style generated for {count} style(s).") + else: + mat = bpy.data.materials.get(self.material_name) + if not mat: + return {"CANCELLED"} + style_elements = tool.Style.get_style_elements(mat) + if not tool.Style.has_blender_external_style(style_elements): + self.report({"ERROR"}, "No external .blend style assigned. Please assign an external style first.") + return {"CANCELLED"} + self._apply_to_material(mat, style_elements, self.value_offset, self.saturation_factor) + props = tool.Style.get_style_props() + if props.is_editing: + core.load_styles(tool.Style, style_type=props.style_type) + + def _apply_to_material( + self, + material: bpy.types.Material, + style_elements: dict, + value_offset: float = 0.0, + saturation_factor: float = 1.0, + color_cache: "dict[tuple[str, str, str], tuple | None] | None" = None, + ) -> bool: + external_style = style_elements["IfcExternallyDefinedSurfaceStyle"] + style_path = Path(tool.Ifc.resolve_uri(external_style.Location)) + data_block_type, data_block = external_style.Identification.split("/") + + cache_key = (str(style_path), data_block_type, data_block) + if color_cache is not None and cache_key in color_cache: + cached = color_cache[cache_key] + if cached is None: + return False # previously failed for this path + surface_colour, transparency = cached + else: + try: + db = tool.Blender.append_data_block(str(style_path), data_block_type, data_block) + except OSError as e: + self.report({"WARNING"}, f'Could not open blend file for "{material.name}": {e}') + if color_cache is not None: + color_cache[cache_key] = None + return False + if not db["data_block"]: + self.report({"WARNING"}, f'Could not load external style for "{material.name}": {db["msg"]}') + if color_cache is not None: + color_cache[cache_key] = None + return False + + ext_mat = db["data_block"] + surface_colour, transparency = tool.Style.get_representative_material_color(ext_mat) + bpy.data.materials.remove(ext_mat) + if color_cache is not None: + color_cache[cache_key] = (surface_colour, transparency) + + if value_offset != 0.0 or saturation_factor != 1.0: + h, s, v = colorsys.rgb_to_hsv(*surface_colour) + v = max(0.0, min(1.0, v + value_offset)) + s = max(0.0, min(1.0, s * saturation_factor)) + surface_colour = colorsys.hsv_to_rgb(h, s, v) + + ifc_style = tool.Ifc.get_entity(material) + attributes: dict = { + "SurfaceColour": { + "Name": None, + "Red": surface_colour[0], + "Green": surface_colour[1], + "Blue": surface_colour[2], + }, + } + if tool.Ifc.get_schema() != "IFC2X3": + attributes["Transparency"] = transparency + + shading_style = style_elements.get("IfcSurfaceStyleShading") + if shading_style: + tool.Ifc.run("style.edit_surface_style", style=shading_style, attributes=attributes) + else: + tool.Ifc.run( + "style.add_surface_style", + style=ifc_style, + ifc_class="IfcSurfaceStyleShading", + attributes=attributes, + ) + material.diffuse_color = (*surface_colour, 1.0 - transparency) + tool.Style.sync_flat_branch_shading(material, surface_colour, transparency) + return True + + class DisableEditingStyles(bpy.types.Operator): bl_idname = "bim.disable_editing_styles" bl_options = {"REGISTER", "UNDO"} diff --git a/src/bonsai/bonsai/bim/module/style/prop.py b/src/bonsai/bonsai/bim/module/style/prop.py index 7dfcad4f07..4165248dc3 100644 --- a/src/bonsai/bonsai/bim/module/style/prop.py +++ b/src/bonsai/bonsai/bim/module/style/prop.py @@ -372,6 +372,16 @@ def update_shading_style(self: "BIMStyleProperties", context: bpy.types.Context) tool.Style.switch_shading(blender_material, self.active_style_type) +def update_prefer_ifc_shading(self: "BIMStyleProperties", context: bpy.types.Context) -> None: + style_elements = tool.Style.get_style_elements(self.id_data) + has_external = tool.Style.has_blender_external_style(style_elements) + if self.prefer_ifc_shading or not has_external: + self.active_style_type = "Shading" + else: + self.active_style_type = "External" + self.id_data.update_tag() + + class BIMStyleProperties(PropertyGroup): ifc_definition_id: IntProperty(name="IFC Definition ID") active_style_type: EnumProperty( @@ -381,9 +391,19 @@ class BIMStyleProperties(PropertyGroup): default="Shading", update=update_shading_style, ) + prefer_ifc_shading: BoolProperty( + name="Flat / Pretty", + description=( + "Toggle between Flat (IFC-native shading) and Pretty (external .blend style). " + "When set to Flat, viewport switches to Material Preview or Rendered will not activate the external style." + ), + default=False, + update=update_prefer_ifc_shading, + ) is_renaming: BoolProperty(description="Used to prevent triggering handler callback.", default=False) if TYPE_CHECKING: ifc_definition_id: int active_style_type: tool.Style.StyleType + prefer_ifc_shading: bool is_renaming: bool diff --git a/src/bonsai/bonsai/bim/module/style/ui.py b/src/bonsai/bonsai/bim/module/style/ui.py index 45120ae1ed..99f901f195 100644 --- a/src/bonsai/bonsai/bim/module/style/ui.py +++ b/src/bonsai/bonsai/bim/module/style/ui.py @@ -110,6 +110,11 @@ class BIM_PT_styles(Panel): op = row.operator("bim.update_current_style", icon="FILE_REFRESH", text="") op.style_id = style.ifc_definition_id + if active_style and self.props.style_type == "IfcSurfaceStyle": + if material := style.blender_material: + msprops = tool.Style.get_material_style_props(material) + self.draw_style_status_row(material, msprops) + if self.props.style_type == "IfcSurfaceStyle": self.layout.label(text="Surface Style Element:") col = self.layout.column(align=True) @@ -161,6 +166,66 @@ class BIM_PT_styles(Panel): edit_label = "Save Lighting Style" self.draw_edit_ui(edit_label) + def draw_style_status_row(self, material: bpy.types.Material, msprops) -> None: + space = tool.Blender.get_view3d_space() + box = self.layout.box() + + obj = bpy.context.active_object + + parts = [] + if space: + shading_type = space.shading.type + shading_labels = { + "SOLID": "Solid", + "MATERIAL": "Material Preview", + "RENDERED": "Rendered", + "WIREFRAME": "Wireframe", + } + parts.append(f"Viewport: {shading_labels.get(shading_type, shading_type)}") + else: + parts.append("No 3D viewport") + shading_type = None + + if obj: + obj_has_uv = isinstance(obj.data, bpy.types.Mesh) and bool(obj.data.uv_layers) + uv_label = "UV \u2713" if obj_has_uv else "UV \u2717" + parts.append(f"Selected Object: {obj.name} {uv_label}") + else: + parts.append("Selected Object: None") + + if shading_type == "SOLID": + is_flat = space.shading.color_type != "TEXTURE" + mode_label = "Flat" + dep_label = "Shade" + if not is_flat: + mode_label = "Pretty" + dep_label = "Texture \u2192 Shade" + elif shading_type in ("MATERIAL", "RENDERED"): + is_flat = msprops.prefer_ifc_shading + mode_label = "Flat" + dep_label = "Render+Texture \u2192 Render \u2192 Shade" + if not is_flat: + mode_label = "Pretty" + dep_label = "External \u2192 Render+Texture \u2192 Render \u2192 Shade" + else: + is_flat = False + mode_label = "" + dep_label = "" + + row1 = box.row(align=True) + row1.label(text=" | ".join(parts)) + + if mode_label: + row2 = box.row(align=True) + row2.label(text=f"Current Mode: {mode_label} \u2014 {dep_label}") + + row3 = box.row(align=True) + row3.alignment = "RIGHT" + op = row3.operator("bim.suggest_shade_from_external_style", text="", icon="BRUSHES_ALL") + op.material_name = material.name + op = row3.operator("bim.toggle_prefer_ifc_shading", text="", icon="UV_SYNC_SELECT") + op.material_name = material.name + def draw_surface_style_shading(self): row = self.layout.row() row.prop(self.props, "surface_colour") diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index aa9656c76e..9322b1b94a 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -803,6 +803,40 @@ class Blender(bonsai.core.tool.Blender): # restore shader editor settings shader_editor.pin = previous_pin_setting + @classmethod + def copy_node_graph_additive( + cls, material_to: bpy.types.Material, material_from: bpy.types.Material + ) -> bpy.types.ShaderNodeOutputMaterial | None: + """Paste nodes from material_from alongside the existing nodes in material_to. + + Unlike copy_node_graph this does NOT clear the existing node tree first. + Returns the OUTPUT_MATERIAL node that was added from material_from, or None. + """ + temp_override = cls.get_shader_editor_context() + shader_editor = temp_override["space"] + + before_names = {n.name for n in material_to.node_tree.nodes} + + previous_pin_setting = shader_editor.pin + shader_editor.pin = True + shader_editor.node_tree = material_from.node_tree + + for node in material_from.node_tree.nodes: + node.select = True + with bpy.context.temp_override(**temp_override): + bpy.ops.node.clipboard_copy() + + shader_editor.node_tree = material_to.node_tree + with bpy.context.temp_override(**temp_override): + bpy.ops.node.clipboard_paste(offset=(0, 0)) + + shader_editor.pin = previous_pin_setting + + for node in material_to.node_tree.nodes: + if node.name not in before_names and node.type == "OUTPUT_MATERIAL": + return node + return None + @classmethod def get_material_node( cls, blender_material: bpy.types.Material, node_type: str, kwargs: Optional[dict] = {} diff --git a/src/bonsai/bonsai/tool/style.py b/src/bonsai/bonsai/tool/style.py index aa3e508ef0..8a5eef0d75 100644 --- a/src/bonsai/bonsai/tool/style.py +++ b/src/bonsai/bonsai/tool/style.py @@ -597,6 +597,146 @@ class Style(bonsai.core.tool.Style): external_style = style_elements.get("IfcExternallyDefinedSurfaceStyle", None) return bool(external_style and external_style.Location and external_style.Location.endswith(".blend")) + @classmethod + def _color_from_principled(cls, node: bpy.types.Node) -> tuple[tuple[float, float, float], float]: + color = cls._resolve_color_socket(node.inputs["Base Color"]) + alpha_socket = node.inputs["Alpha"] + alpha_source = cls._upstream_color_source(alpha_socket) + if alpha_source and alpha_source[0] == "IMAGE": + pixels = alpha_source[1].pixels[:] + n = len(pixels) // 4 + step = max(1, n // 4096) + a_sum = sum(pixels[i * 4 + 3] for i in range(0, n, step)) + count = len(range(0, n, step)) or 1 + transparency = 1.0 - (a_sum / count) + else: + transparency = 1.0 - alpha_socket.default_value + return color, transparency + + @classmethod + def _color_from_shader_socket( + cls, socket: bpy.types.NodeSocket, seen: set[str] | None = None + ) -> tuple[tuple[float, float, float], float] | None: + if seen is None: + seen = set() + for link in socket.links: + node = link.from_node + if node.name in seen: + continue + seen.add(node.name) + if node.type == "BSDF_PRINCIPLED": + return cls._color_from_principled(node) + if node.type in ("BSDF_DIFFUSE", "DIFFUSE_BSDF"): + return cls._resolve_color_socket(node.inputs["Color"]), 0.0 + if node.type == "BSDF_GLASS": + return cls._resolve_color_socket(node.inputs["Color"]), 0.0 + if node.type in ("MIX_SHADER", "ADD_SHADER"): + for inp in node.inputs: + if inp.type == "SHADER" and inp.is_linked: + result = cls._color_from_shader_socket(inp, seen) + if result: + return result + return None + + @classmethod + def get_representative_material_color( + cls, material: bpy.types.Material + ) -> tuple[tuple[float, float, float], float]: + if material.node_tree: + nodes = material.node_tree.nodes + output_node = next( + (n for n in nodes if n.type == "OUTPUT_MATERIAL" and n.is_active_output), None + ) or next((n for n in nodes if n.type == "OUTPUT_MATERIAL"), None) + if output_node: + result = cls._color_from_shader_socket(output_node.inputs["Surface"]) + if result: + return result + # Fallback: scan all shader nodes if no output node or graph traversal found nothing + for node in nodes: + if node.type == "BSDF_PRINCIPLED": + return cls._color_from_principled(node) + for node in nodes: + if node.type in ("BSDF_DIFFUSE", "DIFFUSE_BSDF"): + return cls._resolve_color_socket(node.inputs["Color"]), 0.0 + for node in nodes: + if node.type == "BSDF_GLASS": + return cls._resolve_color_socket(node.inputs["Color"]), 0.0 + color = tuple(material.diffuse_color[:3]) + transparency = 1.0 - material.diffuse_color[3] + return color, transparency + + @classmethod + def _collect_upstream_sources(cls, socket: bpy.types.NodeSocket, seen: set[str]) -> list[tuple[str, object]]: + """Recursively collect all upstream colour/image sources reachable from *socket*.""" + results = [] + for link in socket.links: + node = link.from_node + if node.name in seen: + continue + seen.add(node.name) + if node.type == "TEX_IMAGE": + results.append(("IMAGE", node.image)) + elif node.type == "VALTORGB": + results.append(("COLORRAMP", node)) + else: + for inp in node.inputs: + if inp.is_linked: + results.extend(cls._collect_upstream_sources(inp, seen)) + return results + + @classmethod + def _upstream_color_source( + cls, socket: bpy.types.NodeSocket, seen: set[str] | None = None + ) -> tuple[str, object] | None: + sources = cls._collect_upstream_sources(socket, set() if seen is None else seen) + # Prefer a concrete image texture over a colour ramp (which may be greyscale/procedural). + for s in sources: + if s[0] == "IMAGE": + return s + for s in sources: + if s[0] == "COLORRAMP": + return s + return None + + @classmethod + def _resolve_color_socket(cls, socket: bpy.types.NodeSocket) -> tuple[float, float, float]: + source = cls._upstream_color_source(socket) + if source is None: + return tuple(socket.default_value[:3]) + kind, obj = source + if kind == "IMAGE": + return cls._average_image_color(obj) + if kind == "COLORRAMP": + return cls._average_colorramp_color(obj) + return tuple(socket.default_value[:3]) + + @staticmethod + def _average_image_color(image: bpy.types.Image) -> tuple[float, float, float]: + pixels = image.pixels[:] + n = len(pixels) // 4 + if n == 0: + return (0.5, 0.5, 0.5) + step = max(1, n // 4096) + r_sum = g_sum = b_sum = 0.0 + count = 0 + for i in range(0, n, step): + base = i * 4 + r_sum += pixels[base] + g_sum += pixels[base + 1] + b_sum += pixels[base + 2] + count += 1 + return (r_sum / count, g_sum / count, b_sum / count) + + @staticmethod + def _average_colorramp_color(node: bpy.types.Node) -> tuple[float, float, float]: + elements = node.color_ramp.elements + if not elements: + return (0.5, 0.5, 0.5) + r = sum(e.color[0] for e in elements) / len(elements) + g = sum(e.color[1] for e in elements) / len(elements) + b = sum(e.color[2] for e in elements) / len(elements) + return (r, g, b) + @classmethod def is_editing_styles(cls) -> bool: props = cls.get_style_props() @@ -674,9 +814,179 @@ class Style(bonsai.core.tool.Style): props = cls.get_material_style_props(blender_material) props.active_style_type = props.active_style_type + @classmethod + def get_branch_outputs( + cls, material: bpy.types.Material + ) -> tuple[bpy.types.ShaderNode | None, bpy.types.ShaderNode | None]: + """Return (external_output_node, flat_output_node), or (None, None) if not dual-branch.""" + if not material.node_tree: + return None, None + ext = material.node_tree.nodes.get("BIM_Output_External") + fast = material.node_tree.nodes.get("BIM_Output_Flat") + return ext, fast + + @classmethod + def _remove_external_branch(cls, material: bpy.types.Material) -> None: + """Remove all nodes reachable from BIM_Output_External (walks links backwards).""" + if not material.node_tree: + return + nodes = material.node_tree.nodes + output = nodes.get("BIM_Output_External") + if not output: + return + to_remove: set[str] = set() + stack = [output] + while stack: + node = stack.pop() + if node.name in to_remove: + continue + to_remove.add(node.name) + for inp in node.inputs: + for link in inp.links: + stack.append(link.from_node) + for name in list(to_remove): + n = nodes.get(name) + if n: + nodes.remove(n) + + @classmethod + def _build_flat_branch_nodes(cls, material: bpy.types.Material) -> bpy.types.ShaderNode: + """Add a Principled BSDF flat-branch to material's existing node tree. + + Reads IfcSurfaceStyleRendering or IfcSurfaceStyleShading from the linked IFC entity. + Defaults to a white BSDF when no IFC shading data is available. + Returns the new Material Output node (named BIM_Output_Flat, is_active_output=False). + """ + from mathutils import Vector + + style_elements = cls.get_style_elements(material) + nodes = material.node_tree.nodes + links = material.node_tree.links + + bsdf = nodes.new("ShaderNodeBsdfPrincipled") + bsdf.location = Vector((10, -600)) + output = nodes.new("ShaderNodeOutputMaterial") + output.name = "BIM_Output_Flat" + output.location = Vector((300, -600)) + output.is_active_output = False + links.new(bsdf.outputs["BSDF"], output.inputs["Surface"]) + + rendering_style = None + shading_only = None + for surface_style in style_elements.values(): + if surface_style.is_a() == "IfcSurfaceStyleShading": + shading_only = surface_style + elif surface_style.is_a("IfcSurfaceStyleRendering"): + rendering_style = surface_style + shading_only = None + + if rendering_style: + d = tool.Loader.surface_style_to_dict(rendering_style) + if d.get("DiffuseColour"): + ctype, cval = d["DiffuseColour"] + if ctype == "IfcColourRgb": + bsdf.inputs["Base Color"].default_value = cval + (1,) + solid_color = cval + else: + cval = tuple(v * cval for v in d["SurfaceColour"]) + bsdf.inputs["Base Color"].default_value = cval + (1,) + solid_color = cval + else: + r, g, b = d["SurfaceColour"] + bsdf.inputs["Base Color"].default_value = (r, g, b, 1.0) + solid_color = (r, g, b) + if d.get("SpecularColour"): + ctype, cval = d["SpecularColour"] + if ctype == "IfcNormalisedRatioMeasure": + bsdf.inputs["Metallic"].default_value = cval + if d.get("SpecularHighlight"): + bsdf.inputs["Roughness"].default_value = d["SpecularHighlight"] + transparency = d.get("Transparency") or 0.0 + bsdf.inputs["Alpha"].default_value = 1 - transparency + if transparency > 0: + material.blend_method = "BLEND" + material.diffuse_color = solid_color + (1.0 - transparency,) + elif shading_only: + d = tool.Loader.surface_style_to_dict(shading_only) + r, g, b = d["SurfaceColour"] + alpha = 1 - (d.get("Transparency") or 0.0) + bsdf.inputs["Base Color"].default_value = (r, g, b, 1.0) + bsdf.inputs["Alpha"].default_value = alpha + if alpha < 1.0: + material.blend_method = "BLEND" + material.diffuse_color = (r, g, b, alpha) + # else: leave default white Principled BSDF + return output + + @classmethod + def setup_dual_branch(cls, material: bpy.types.Material, ext_material: bpy.types.Material) -> bool: + """Build a dual-branch node tree: flat branch from IFC data + external branch from ext_material. + + Clears any existing nodes and builds both branches from scratch. + External branch output (BIM_Output_External) is set active — Pretty mode. + Flat branch output (BIM_Output_Flat) is inactive — Flat mode. + Returns True on success; False if no shader editor is available (falls back to single-branch). + """ + cls.set_use_nodes(material, True) + for n in material.node_tree.nodes[:]: + material.node_tree.nodes.remove(n) + + cls._build_flat_branch_nodes(material) + + ext_output = tool.Blender.copy_node_graph_additive(material, ext_material) + if not ext_output: + # No shader editor available: fall back to single-branch + tool.Blender.copy_node_graph(material, ext_material) + return False + + ext_output.name = "BIM_Output_External" + ext_output.is_active_output = True + material["bim_dual_branch"] = True + return True + + @classmethod + def update_external_branch(cls, material: bpy.types.Material, ext_material: bpy.types.Material) -> None: + """Replace the external-branch nodes of an already dual-branch material.""" + cls._remove_external_branch(material) + ext_output = tool.Blender.copy_node_graph_additive(material, ext_material) + if ext_output: + ext_output.name = "BIM_Output_External" + ext_output.is_active_output = True + fast = material.node_tree.nodes.get("BIM_Output_Flat") + if fast: + fast.is_active_output = False + + @classmethod + def sync_flat_branch_shading( + cls, material: bpy.types.Material, surface_colour: tuple[float, float, float], transparency: float + ) -> None: + """Update the flat-branch Principled BSDF with new shading values. + + Call this after creating or editing IfcSurfaceStyleShading so the flat branch + stays in sync without requiring a full setup_dual_branch rebuild. + """ + if not material.node_tree: + return + fast_output = material.node_tree.nodes.get("BIM_Output_Flat") + if not fast_output: + return + for link in fast_output.inputs["Surface"].links: + if link.from_node.type == "BSDF_PRINCIPLED": + bsdf = link.from_node + r, g, b = surface_colour + bsdf.inputs["Base Color"].default_value = (r, g, b, 1.0) + bsdf.inputs["Alpha"].default_value = 1.0 - transparency + break + @classmethod def switch_shading(cls, blender_material: bpy.types.Material, style_type: StyleType) -> None: if style_type == "External": + ext, fast = cls.get_branch_outputs(blender_material) + if ext and fast: + ext.is_active_output = True + fast.is_active_output = False + blender_material.update_tag() + return try: bpy.ops.bim.activate_external_style(material_name=blender_material.name) except RuntimeError as error: @@ -684,21 +994,41 @@ class Style(bonsai.core.tool.Style): return raise error elif style_type == "Shading": + ext, fast = cls.get_branch_outputs(blender_material) + if ext and fast: + fast.is_active_output = True + ext.is_active_output = False + blender_material.update_tag() + return style_elements = tool.Style.get_style_elements(blender_material) rendering_style = None texture_style = None + shading_only_style = None for surface_style in style_elements.values(): if surface_style.is_a() == "IfcSurfaceStyleShading": + shading_only_style = surface_style tool.Loader.create_surface_style_shading(blender_material, surface_style) elif surface_style.is_a("IfcSurfaceStyleRendering"): rendering_style = surface_style + shading_only_style = None # rendering overrides shading-only path tool.Loader.create_surface_style_rendering(blender_material, surface_style) elif surface_style.is_a("IfcSurfaceStyleWithTextures"): texture_style = surface_style if rendering_style and texture_style: tool.Loader.create_surface_style_with_textures(blender_material, rendering_style, texture_style) + elif shading_only_style and not rendering_style: + # create a minimal Principled BSDF so Material Preview/Rendered shows the colour instead of white. + tool.Style.set_use_nodes(blender_material, True) + tool.Loader.restart_material_node_tree(blender_material) + bsdf = tool.Blender.get_material_node(blender_material, "BSDF_PRINCIPLED") + if bsdf: + r, g, b, a = blender_material.diffuse_color + bsdf.inputs["Base Color"].default_value = (r, g, b, 1) + bsdf.inputs["Alpha"].default_value = a + if a < 1.0: + blender_material.blend_method = "BLEND" else: assert False, f"Invalid style type found: {style_type}" @@ -744,3 +1074,36 @@ class Style(bonsai.core.tool.Style): elements = ifcopenshell.util.element.get_elements_by_style(tool.Ifc.get(), style) objects = [tool.Ifc.get_object(e) for e in elements] tool.Geometry.reload_representation(objects) + + _last_shading_type: str | None = None + + @classmethod + def restore_material_style_types(cls, shading_type: str) -> None: + """Set each IFC material's active_style_type to the richest available for the given viewport mode. + + In SOLID mode all materials use "Shading". + In MATERIAL_PREVIEW / RENDERED, materials with an external .blend style use "External" + unless prefer_ifc_shading is set on that material. + """ + if cls._last_shading_type == shading_type: + return + cls._last_shading_type = shading_type + + for material in bpy.data.materials: + if not tool.Blender.get_ifc_definition_id(material): + continue + props = cls.get_material_style_props(material) + style_elements = cls.get_style_elements(material) + if shading_type == "SOLID": + props.active_style_type = "Shading" + shading = style_elements.get("IfcSurfaceStyleRendering") or style_elements.get("IfcSurfaceStyleShading") + if shading: + d = tool.Loader.surface_style_to_dict(shading) + alpha = 1.0 - (d.get("Transparency") or 0.0) + material.diffuse_color = d["SurfaceColour"] + (alpha,) + else: # MATERIAL_PREVIEW or RENDERED + if cls.has_blender_external_style(style_elements) and not props.prefer_ifc_shading: + props.active_style_type = "External" + else: + props.active_style_type = "Shading" + material.update_tag()