from pathlib import Path import bpy from .image_data import ( configure_image_user, image_base_name, image_pixels, is_animated_image, ) NODE_ASSET_NAME = "O_AnyImage.blend" def node_asset_path(): return Path(__file__).resolve().parents[2] / "assets" / NODE_ASSET_NAME def load_asset_node_group(group_name): existing = bpy.data.node_groups.get(group_name) if existing is not None: return existing asset_path = node_asset_path() if not asset_path.is_file(): raise RuntimeError(f"Missing node asset: {asset_path}") if bpy.app.version < (5, 0, 0): directory = str(asset_path) + "/NodeTree/" result = bpy.ops.wm.append( filepath=directory + group_name, directory=directory, filename=group_name, link=False, do_reuse_local_id=True, ) if "FINISHED" not in result: raise RuntimeError("Unable to append the node asset") node_group = bpy.data.node_groups.get(group_name) if node_group is None: raise RuntimeError("Appended node asset is unavailable") node_group.use_fake_user = True return node_group with bpy.data.libraries.load(str(asset_path), link=True) as (source, target): if group_name not in source.node_groups: raise RuntimeError(f"Node asset is missing {group_name}") target.node_groups = [group_name] linked_group = target.node_groups[0] node_group = bpy.data.pack_linked_ids_hierarchy(root_id=linked_group) if linked_group != node_group and linked_group.users == 0: bpy.data.node_groups.remove(linked_group) node_group.use_fake_user = True return node_group def modifier_input_identifier(node_group, name): for item in node_group.interface.items_tree: if item.item_type != "SOCKET" or item.in_out != "INPUT": continue if item.name == name: return item.identifier raise RuntimeError(f"Geometry Nodes input is missing: {name}") def _modifier_input_slots(modifier): """Return the runtime input collection, or None for the legacy API.""" properties = getattr(modifier, "properties", None) if properties is None: return None return getattr(properties, "inputs", None) def set_modifier_input(modifier, identifier, value): """Assign a value to a Geometry Nodes modifier input socket.""" inputs = _modifier_input_slots(modifier) if inputs is not None: # Blender 5.2+ exposes inputs as runtime RNA keyed by socket identifier. getattr(inputs, identifier).value = value else: # Blender 4.x exposes inputs as modifier ID properties. modifier[identifier] = value DISPLACEMENT_STRENGTH = 1.0 DISPLACEMENT_MIDLEVEL = 0.0 BUMP_BASE_DISTANCE = 0.001 MATERIAL_DISPLACEMENT_ATTRIBUTE = "o_material_displacement" BACKGROUND_DEPTH_ATTRIBUTE = "o_background_depth" DEPTH_SCALE_ATTRIBUTE = "o_depth_scale" IMAGE_MATERIAL_NODE_GROUP_NAME = "O Image Layer" DEPTH_MATERIAL_NODE_GROUP_NAME = "O Image Depth Layer" SHADELESS_NODE_GROUP_NAME = "O Shadeless" VIEW_TRANSFORM_COLOR_SPACES = ( ("ACES 1.3", "ACES 1.3 sRGB"), ("ACES 2.0", "ACES 2.0 sRGB"), ("AgX", "AgX Base sRGB"), ("Filmic", "Filmic sRGB"), ("Khronos PBR Neutral", "Khronos PBR Neutral sRGB"), ) def material_color_space_name(scene): view_settings = getattr(scene, "view_settings", None) view_transform = getattr(view_settings, "view_transform", "") normalized = str(view_transform).strip().casefold() for view_name, color_space in VIEW_TRANSFORM_COLOR_SPACES: if normalized.startswith(view_name.casefold()): return color_space return "sRGB" def set_material_color_space(image, scene=None): if scene is None: scene = getattr(bpy.context, "scene", None) settings = image.colorspace_settings preferred = material_color_space_name(scene) original = settings.name pixels = None if preferred != original and ( getattr(image, "source", None) == "GENERATED" or getattr(image, "is_dirty", False) ): pixels = image_pixels(image) for color_space in dict.fromkeys((preferred, "sRGB", original)): if not color_space: continue try: settings.name = color_space except (TypeError, ValueError): continue if settings.name == color_space: if pixels is not None: image.pixels.foreach_set(pixels) image.update() return color_space return settings.name def material_node_group(depth_plane=False): group_name = ( DEPTH_MATERIAL_NODE_GROUP_NAME if depth_plane else IMAGE_MATERIAL_NODE_GROUP_NAME ) existing = bpy.data.node_groups.get(group_name) if existing is not None: for item in existing.interface.items_tree: if ( item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name == "Normal" ): item.default_value = (0.5, 0.5, 1.0, 1.0) return existing group = bpy.data.node_groups.new(group_name, "ShaderNodeTree") interface = group.interface input_sockets = [ ("Color", "NodeSocketColor"), ("Alpha", "NodeSocketFloat"), ("Bump Scale", "NodeSocketFloat"), ] normal_group = not depth_plane if normal_group: input_sockets.insert(2, ("Normal", "NodeSocketColor")) input_sockets.insert(3, ("Normal Scale", "NodeSocketFloat")) input_sockets.insert(4, ("Object Space", "NodeSocketBool")) if depth_plane: input_sockets.insert(2, ("Depth", "NodeSocketFloat")) for name, socket_type in input_sockets: socket = interface.new_socket( name=name, in_out="INPUT", socket_type=socket_type, ) if name == "Alpha": socket.default_value = 1.0 socket.min_value = 0.0 socket.max_value = 1.0 socket.subtype = "FACTOR" elif name == "Bump Scale": socket.default_value = 0.0 socket.min_value = -10000.0 socket.max_value = 10000.0 elif name == "Normal": socket.default_value = (0.5, 0.5, 1.0, 1.0) elif name == "Normal Scale": socket.default_value = 1.0 socket.min_value = 0.0 socket.max_value = 10.0 elif name == "Object Space": socket.default_value = False output_sockets = [ ("Color", "NodeSocketColor"), ("Roughness", "NodeSocketFloat"), ("Alpha", "NodeSocketFloat"), ("Normal", "NodeSocketVector"), ] if depth_plane: output_sockets.append(("Displacement", "NodeSocketVector")) for name, socket_type in output_sockets: interface.new_socket( name=name, in_out="OUTPUT", socket_type=socket_type, ) nodes = group.nodes links = group.links group_input = nodes.new("NodeGroupInput") group_input.location = (-500.0, 100.0) group_output = nodes.new("NodeGroupOutput") group_output.location = (300.0, 100.0) roughness_curve = nodes.new("ShaderNodeFloatCurve") roughness_curve.mapping.initialize() curve_points = roughness_curve.mapping.curves[0].points curve_points[0].location = (0.0, 0.5) curve_points[-1].location = (1.0, 0.1) roughness_curve.mapping.update() roughness_curve.location = (-250.0, 350.0) bump = nodes.new("ShaderNodeBump") bump.location = (100.0, 0.0) if normal_group: tangent_normal = nodes.new("ShaderNodeNormalMap") tangent_normal.space = "TANGENT" tangent_normal.location = (-500.0, -100.0) object_normal = nodes.new("ShaderNodeNormalMap") object_normal.space = "OBJECT" object_normal.location = (-500.0, -350.0) choose_normal = nodes.new("ShaderNodeMix") choose_normal.data_type = "VECTOR" choose_normal.location = (-150.0, -150.0) for normal_map in (tangent_normal, object_normal): links.new(group_input.outputs["Normal"], normal_map.inputs["Color"]) links.new( group_input.outputs["Normal Scale"], normal_map.inputs["Strength"], ) links.new(group_input.outputs["Object Space"], choose_normal.inputs[0]) links.new(tangent_normal.outputs["Normal"], choose_normal.inputs[4]) links.new(object_normal.outputs["Normal"], choose_normal.inputs[5]) links.new(choose_normal.outputs["Result"], bump.inputs["Normal"]) displacement = None processed_alpha = group_input.outputs["Alpha"] bump_depth = group_input.outputs["Color"] if depth_plane: displacement = nodes.new("ShaderNodeDisplacement") displacement.inputs["Midlevel"].default_value = 0.0 displacement.inputs["Scale"].default_value = 1.0 group_input.location = (-1200.0, 100.0) group_output.location = (1050.0, 100.0) roughness_curve.location = (-300.0, 450.0) bump.location = (750.0, 50.0) displacement.location = (750.0, -300.0) background_attribute = nodes.new("ShaderNodeAttribute") background_attribute.attribute_type = "GEOMETRY" background_attribute.attribute_name = BACKGROUND_DEPTH_ATTRIBUTE background_attribute.location = (-950.0, -500.0) material_attribute = nodes.new("ShaderNodeAttribute") material_attribute.attribute_type = "GEOMETRY" material_attribute.attribute_name = MATERIAL_DISPLACEMENT_ATTRIBUTE material_attribute.location = (-50.0, -650.0) strength_attribute = nodes.new("ShaderNodeAttribute") strength_attribute.attribute_type = "GEOMETRY" strength_attribute.attribute_name = DEPTH_SCALE_ATTRIBUTE strength_attribute.location = (200.0, -650.0) foreground_mask = nodes.new("ShaderNodeMath") foreground_mask.operation = "GREATER_THAN" foreground_mask.inputs[1].default_value = 0.5 foreground_mask.location = (-950.0, -250.0) clamp_foreground = nodes.new("ShaderNodeMath") clamp_foreground.operation = "MAXIMUM" clamp_foreground.location = (-650.0, -400.0) masked_depth = nodes.new("ShaderNodeMix") masked_depth.data_type = "FLOAT" masked_depth.location = (-350.0, -350.0) enabled_depth = nodes.new("ShaderNodeMath") enabled_depth.operation = "MULTIPLY" enabled_depth.location = (250.0, -350.0) scaled_depth = nodes.new("ShaderNodeMath") scaled_depth.operation = "MULTIPLY" scaled_depth.location = (450.0, -350.0) links.new(group_input.outputs["Alpha"], foreground_mask.inputs[0]) links.new(group_input.outputs["Depth"], clamp_foreground.inputs[0]) links.new(background_attribute.outputs["Fac"], clamp_foreground.inputs[1]) links.new(foreground_mask.outputs[0], masked_depth.inputs[0]) links.new(background_attribute.outputs["Fac"], masked_depth.inputs[2]) links.new(clamp_foreground.outputs[0], masked_depth.inputs[3]) links.new(masked_depth.outputs["Result"], enabled_depth.inputs[0]) links.new(material_attribute.outputs["Fac"], enabled_depth.inputs[1]) links.new(enabled_depth.outputs[0], scaled_depth.inputs[0]) links.new(strength_attribute.outputs["Fac"], scaled_depth.inputs[1]) processed_alpha = foreground_mask.outputs[0] processed_depth = scaled_depth.outputs[0] links.new(group_input.outputs["Color"], group_output.inputs["Color"]) links.new(group_input.outputs["Color"], roughness_curve.inputs["Value"]) links.new( roughness_curve.outputs["Value"], group_output.inputs["Roughness"], ) links.new(processed_alpha, group_output.inputs["Alpha"]) links.new(bump_depth, bump.inputs["Height"]) bump_distance = nodes.new("ShaderNodeMath") bump_distance.operation = "MULTIPLY" bump_distance.inputs[1].default_value = BUMP_BASE_DISTANCE bump_distance.location = (500.0, 100.0) if depth_plane else (-250.0, 150.0) links.new(group_input.outputs["Bump Scale"], bump_distance.inputs[0]) links.new(bump_distance.outputs[0], bump.inputs["Distance"]) links.new(bump.outputs["Normal"], group_output.inputs["Normal"]) if displacement is not None: links.new(processed_depth, displacement.inputs["Height"]) links.new( displacement.outputs["Displacement"], group_output.inputs["Displacement"], ) group.color_tag = "SHADER" group.description = ( "Prepare image depth material outputs." if depth_plane else f"Prepare {group_name} material outputs." ) group.use_fake_user = True return group def create_image_material( source_object, color_image, depth_scale, displacement_image=None, displacement_midlevel=0.0, displacement_scale=1.0, shadeless=False, frame_count=1, frame_start=1, color_frame_offset=0, color_frame_duration=None, depth_frame_offset=0, depth_frame_duration=None, use_cyclic=None, normal_image=None, normal_space="TANGENT", ior=None, scene=None, ): material = bpy.data.materials.new(image_base_name(source_object.data)) material.use_nodes = True if hasattr(material, "surface_render_method"): material.surface_render_method = "DITHERED" nodes = material.node_tree.nodes nodes.clear() color_texture = nodes.new("ShaderNodeTexImage") uv_map = nodes.new("ShaderNodeUVMap") output = nodes.new("ShaderNodeOutputMaterial") uv_map.uv_map = "UVMap" uv_map.location = (-1250.0, 350.0) material_image = color_image set_material_color_space(material_image, scene) color_texture.image = material_image color_texture.interpolation = "Linear" color_texture.extension = "EXTEND" color_texture.location = (-1000.0, 350.0) output.location = (850.0, 350.0) extracted_frames = frame_count if frame_count > 1 else None color_frames = ( color_frame_duration if color_frame_duration is not None else extracted_frames ) depth_frames = ( depth_frame_duration if depth_frame_duration is not None else extracted_frames ) material_frames = color_frames material_frame_offset = color_frame_offset if frame_count > 1 or is_animated_image(material_image): configure_image_user( color_texture.image_user, material_frames, frame_start=frame_start, frame_offset=material_frame_offset, use_cyclic=use_cyclic, ) links = material.node_tree.links links.new(uv_map.outputs["UV"], color_texture.inputs["Vector"]) alpha = color_texture.outputs["Alpha"] material_inputs = nodes.new("ShaderNodeGroup") is_depth_plane = displacement_image is not None material_inputs.node_tree = material_node_group(is_depth_plane) material_inputs.location = (-300.0, 350.0) material_inputs.inputs["Bump Scale"].default_value = depth_scale if "Object Space" in material_inputs.inputs: if normal_space not in {"TANGENT", "OBJECT"}: raise ValueError(f"Unsupported normal space: {normal_space}") material_inputs.inputs["Object Space"].default_value = normal_space == "OBJECT" links.new(color_texture.outputs["Color"], material_inputs.inputs["Color"]) links.new(alpha, material_inputs.inputs["Alpha"]) if normal_image is not None and "Normal" in material_inputs.inputs: normal_texture = nodes.new("ShaderNodeTexImage") normal_texture.image = normal_image normal_texture.interpolation = "Linear" normal_texture.extension = "EXTEND" normal_texture.location = (-1000.0, -50.0) links.new(uv_map.outputs["UV"], normal_texture.inputs["Vector"]) links.new(normal_texture.outputs["Color"], material_inputs.inputs["Normal"]) if shadeless: shader = nodes.new("ShaderNodeGroup") shader.node_tree = load_asset_node_group(SHADELESS_NODE_GROUP_NAME) shader.name = SHADELESS_NODE_GROUP_NAME transparent = nodes.new("ShaderNodeBsdfTransparent") mix_shader = nodes.new("ShaderNodeMixShader") shader.location = (0.0, 550.0) transparent.location = (0.0, 150.0) mix_shader.location = (300.0, 350.0) links.new( material_inputs.outputs["Color"], shader.inputs["Color"], ) links.new( material_inputs.outputs["Alpha"], mix_shader.inputs["Fac"], ) links.new( transparent.outputs["BSDF"], mix_shader.inputs[1], ) links.new( shader.outputs["Shader"], mix_shader.inputs[2], ) links.new( mix_shader.outputs["Shader"], output.inputs["Surface"], ) else: shader = nodes.new("ShaderNodeBsdfPrincipled") shader.location = (250.0, 400.0) if ior is not None: shader.inputs["IOR"].default_value = float(ior) links.new( material_inputs.outputs["Color"], shader.inputs["Base Color"], ) links.new( material_inputs.outputs["Color"], shader.inputs["Subsurface Radius"], ) links.new( material_inputs.outputs["Color"], shader.inputs["Emission Color"], ) links.new(material_inputs.outputs["Alpha"], shader.inputs["Alpha"]) links.new(material_inputs.outputs["Normal"], shader.inputs["Normal"]) links.new( material_inputs.outputs["Normal"], shader.inputs["Coat Normal"], ) links.new( material_inputs.outputs["Roughness"], shader.inputs["Roughness"], ) links.new(shader.outputs["BSDF"], output.inputs["Surface"]) if displacement_image is not None: depth_texture = nodes.new("ShaderNodeTexImage") depth_texture.image = displacement_image depth_texture.interpolation = "Linear" depth_texture.extension = "EXTEND" if frame_count > 1 or is_animated_image(displacement_image): configure_image_user( depth_texture.image_user, depth_frames, frame_start=frame_start, frame_offset=depth_frame_offset, use_cyclic=use_cyclic, ) depth_texture.location = (-1000.0, -400.0) links.new(uv_map.outputs["UV"], depth_texture.inputs["Vector"]) displacement_height = depth_texture.outputs["Color"] if displacement_midlevel != 0.0 or displacement_scale != 1.0: depth_adjust = nodes.new("ShaderNodeMath") depth_adjust.operation = "SUBTRACT" depth_adjust.inputs[1].default_value = displacement_midlevel depth_adjust.location = (500.0, -550.0) depth_scale_node = nodes.new("ShaderNodeMath") depth_scale_node.operation = "MULTIPLY" depth_scale_node.inputs[1].default_value = displacement_scale depth_scale_node.location = (700.0, -550.0) links.new(displacement_height, depth_adjust.inputs[0]) links.new(depth_adjust.outputs[0], depth_scale_node.inputs[0]) links.new(depth_scale_node.outputs[0], material_inputs.inputs["Depth"]) else: links.new(displacement_height, material_inputs.inputs["Depth"]) links.new( material_inputs.outputs["Displacement"], output.inputs["Displacement"], ) set_displacement_only(material) return material def set_displacement_only(material): for owner, property_name in ( (material, "surface_displacement_method"), (material, "displacement_method"), (getattr(material, "cycles", None), "displacement_method"), ): if owner is not None and hasattr(owner, property_name): setattr(owner, property_name, "DISPLACEMENT") return def finalize_object_result( context, source_object, result_object, *, keep_source=False, ): """Activate a result object and optionally replace its source object.""" source_name = source_object.name for selected in context.selected_objects: selected.select_set(False) result_object.select_set(True) context.view_layer.objects.active = result_object if not keep_source: bpy.data.objects.remove(source_object, do_unlink=True) result_object.name = source_name return result_object