diff --git a/addon.py b/addon.py index 2be9421..461f7a2 100644 --- a/addon.py +++ b/addon.py @@ -168,6 +168,7 @@ class BlenderMCPServer: "get_polyhaven_categories": self.get_polyhaven_categories, "search_polyhaven_assets": self.search_polyhaven_assets, "download_polyhaven_asset": self.download_polyhaven_asset, + "set_texture": self.set_texture, } handler = handlers.get(cmd_type) @@ -508,8 +509,6 @@ class BlenderMCPServer: return {"error": str(e)} def download_polyhaven_asset(self, asset_id, asset_type, resolution="1k", file_format=None): - """Download an asset from Polyhaven and import it into Blender""" - try: # First get the files information files_response = requests.get(f"https://api.polyhaven.com/files/{asset_id}") @@ -613,103 +612,138 @@ class BlenderMCPServer: return {"error": f"Requested resolution or format not available for this HDRI"} elif asset_type == "textures": - # For textures, download available maps if not file_format: file_format = "jpg" # Default format for textures - # Find available maps (diffuse, normal, etc.) downloaded_maps = {} - for map_type in files_data: - if map_type not in ["blend", "gltf"]: # Skip non-texture files - if resolution in files_data[map_type] and file_format in files_data[map_type][resolution]: - file_info = files_data[map_type][resolution][file_format] - file_url = file_info["url"] - - # Download the file directly into Blender's memory - response = requests.get(file_url) - if response.status_code == 200: - # Create a new image in Blender's memory - image_name = f"{asset_id}_{map_type}.{file_format}" - image = bpy.data.images.new(name=image_name, width=1, height=1) + + try: + for map_type in files_data: + if map_type not in ["blend", "gltf"]: # Skip non-texture files + if resolution in files_data[map_type] and file_format in files_data[map_type][resolution]: + file_info = files_data[map_type][resolution][file_format] + file_url = file_info["url"] - # Save the downloaded data - image.file_format = file_format.upper() - image.filepath_raw = f"/tmp/{image_name}" # This is just for reference - image.pack(data=response.content) - - downloaded_maps[map_type] = image + # Use NamedTemporaryFile like we do for HDRIs + with tempfile.NamedTemporaryFile(suffix=f".{file_format}", delete=False) as tmp_file: + # Download the file + response = requests.get(file_url) + if response.status_code == 200: + tmp_file.write(response.content) + tmp_path = tmp_file.name + + # Load image from temporary file + image = bpy.data.images.load(tmp_path) + image.name = f"{asset_id}_{map_type}.{file_format}" + + # Pack the image into .blend file + image.pack() + + # Set color space based on map type + if map_type in ['color', 'diffuse', 'albedo']: + try: + image.colorspace_settings.name = 'sRGB' + except: + pass + else: + try: + image.colorspace_settings.name = 'Non-Color' + except: + pass + + downloaded_maps[map_type] = image + + # Clean up temporary file + try: + os.unlink(tmp_path) + except: + pass - if not downloaded_maps: - return {"error": f"No texture maps found for the requested resolution and format"} - - # Create a new material with the downloaded textures - mat = bpy.data.materials.new(name=asset_id) - mat.use_nodes = True - nodes = mat.node_tree.nodes - links = mat.node_tree.links - - # Clear default nodes - for node in nodes: - nodes.remove(node) - - # Create output node - output = nodes.new(type='ShaderNodeOutputMaterial') - output.location = (300, 0) - - # Create principled BSDF node - principled = nodes.new(type='ShaderNodeBsdfPrincipled') - principled.location = (0, 0) - links.new(principled.outputs[0], output.inputs[0]) - - # Add texture nodes based on available maps - tex_coord = nodes.new(type='ShaderNodeTexCoord') - tex_coord.location = (-800, 0) - - mapping = nodes.new(type='ShaderNodeMapping') - mapping.location = (-600, 0) - links.new(tex_coord.outputs['UV'], mapping.inputs['Vector']) - - # Position offset for texture nodes - x_pos = -400 - y_pos = 300 - - # Connect different texture maps - for map_type, image in downloaded_maps.items(): - tex_node = nodes.new(type='ShaderNodeTexImage') - tex_node.location = (x_pos, y_pos) - tex_node.image = image - tex_node.image.colorspace_settings.name = 'sRGB' if map_type in ['color', 'diffuse', 'albedo'] else 'Non-Color' + if not downloaded_maps: + return {"error": f"No texture maps found for the requested resolution and format"} - links.new(mapping.outputs['Vector'], tex_node.inputs['Vector']) + # Create a new material with the downloaded textures + mat = bpy.data.materials.new(name=asset_id) + mat.use_nodes = True + nodes = mat.node_tree.nodes + links = mat.node_tree.links - # Connect to appropriate input on Principled BSDF - if map_type in ['color', 'diffuse', 'albedo']: - links.new(tex_node.outputs['Color'], principled.inputs['Base Color']) - elif map_type in ['roughness', 'rough']: - links.new(tex_node.outputs['Color'], principled.inputs['Roughness']) - elif map_type in ['metallic', 'metalness', 'metal']: - links.new(tex_node.outputs['Color'], principled.inputs['Metallic']) - elif map_type in ['normal', 'nor']: - # Add normal map node - normal_map = nodes.new(type='ShaderNodeNormalMap') - normal_map.location = (x_pos + 200, y_pos) - links.new(tex_node.outputs['Color'], normal_map.inputs['Color']) - links.new(normal_map.outputs['Normal'], principled.inputs['Normal']) - elif map_type in ['displacement', 'disp', 'height']: - # Add displacement node - disp_node = nodes.new(type='ShaderNodeDisplacement') - disp_node.location = (x_pos + 200, y_pos - 200) - links.new(tex_node.outputs['Color'], disp_node.inputs['Height']) - links.new(disp_node.outputs['Displacement'], output.inputs['Displacement']) + # Clear default nodes + for node in nodes: + nodes.remove(node) - y_pos -= 250 + # Create output node + output = nodes.new(type='ShaderNodeOutputMaterial') + output.location = (300, 0) + + # Create principled BSDF node + principled = nodes.new(type='ShaderNodeBsdfPrincipled') + principled.location = (0, 0) + links.new(principled.outputs[0], output.inputs[0]) + + # Add texture nodes based on available maps + tex_coord = nodes.new(type='ShaderNodeTexCoord') + tex_coord.location = (-800, 0) + + mapping = nodes.new(type='ShaderNodeMapping') + mapping.location = (-600, 0) + links.new(tex_coord.outputs['UV'], mapping.inputs['Vector']) + + # Position offset for texture nodes + x_pos = -400 + y_pos = 300 + + # Connect different texture maps + for map_type, image in downloaded_maps.items(): + tex_node = nodes.new(type='ShaderNodeTexImage') + tex_node.location = (x_pos, y_pos) + tex_node.image = image + + # Set color space based on map type + if map_type.lower() in ['color', 'diffuse', 'albedo']: + try: + tex_node.image.colorspace_settings.name = 'sRGB' + except: + pass # Use default if sRGB not available + else: + try: + tex_node.image.colorspace_settings.name = 'Non-Color' + except: + pass # Use default if Non-Color not available + + links.new(mapping.outputs['Vector'], tex_node.inputs['Vector']) + + # Connect to appropriate input on Principled BSDF + if map_type.lower() in ['color', 'diffuse', 'albedo']: + links.new(tex_node.outputs['Color'], principled.inputs['Base Color']) + elif map_type.lower() in ['roughness', 'rough']: + links.new(tex_node.outputs['Color'], principled.inputs['Roughness']) + elif map_type.lower() in ['metallic', 'metalness', 'metal']: + links.new(tex_node.outputs['Color'], principled.inputs['Metallic']) + elif map_type.lower() in ['normal', 'nor']: + # Add normal map node + normal_map = nodes.new(type='ShaderNodeNormalMap') + normal_map.location = (x_pos + 200, y_pos) + links.new(tex_node.outputs['Color'], normal_map.inputs['Color']) + links.new(normal_map.outputs['Normal'], principled.inputs['Normal']) + elif map_type in ['displacement', 'disp', 'height']: + # Add displacement node + disp_node = nodes.new(type='ShaderNodeDisplacement') + disp_node.location = (x_pos + 200, y_pos - 200) + links.new(tex_node.outputs['Color'], disp_node.inputs['Height']) + links.new(disp_node.outputs['Displacement'], output.inputs['Displacement']) + + y_pos -= 250 + + return { + "success": True, + "message": f"Texture {asset_id} imported as material", + "material": mat.name, + "maps": list(downloaded_maps.keys()) + } - return { - "success": True, - "message": f"Texture {asset_id} imported as material", - "material": mat.name, - "maps": list(downloaded_maps.keys()) - } + except Exception as e: + return {"error": f"Failed to process textures: {str(e)}"} elif asset_type == "models": # For models, prefer glTF format if available @@ -798,6 +832,312 @@ class BlenderMCPServer: except Exception as e: return {"error": f"Failed to download asset: {str(e)}"} + def set_texture(self, object_name, texture_id): + """Apply a previously downloaded Polyhaven texture to an object by creating a new material""" + try: + # Get the object + obj = bpy.data.objects.get(object_name) + if not obj: + return {"error": f"Object not found: {object_name}"} + + # Make sure object can accept materials + if not hasattr(obj, 'data') or not hasattr(obj.data, 'materials'): + return {"error": f"Object {object_name} cannot accept materials"} + + # Find all images related to this texture and ensure they're properly loaded + texture_images = {} + for img in bpy.data.images: + if img.name.startswith(texture_id + "_"): + # Extract the map type from the image name + map_type = img.name.split('_')[-1].split('.')[0] + + # Force a reload of the image + img.reload() + + # Ensure proper color space + if map_type.lower() in ['color', 'diffuse', 'albedo']: + try: + img.colorspace_settings.name = 'sRGB' + except: + pass + else: + try: + img.colorspace_settings.name = 'Non-Color' + except: + pass + + # Ensure the image is packed + if not img.packed_file: + img.pack() + + texture_images[map_type] = img + print(f"Loaded texture map: {map_type} - {img.name}") + + # Debug info + print(f"Image size: {img.size[0]}x{img.size[1]}") + print(f"Color space: {img.colorspace_settings.name}") + print(f"File format: {img.file_format}") + print(f"Is packed: {bool(img.packed_file)}") + + if not texture_images: + return {"error": f"No texture images found for: {texture_id}. Please download the texture first."} + + # Create a new material + new_mat_name = f"{texture_id}_material_{object_name}" + + # Remove any existing material with this name to avoid conflicts + existing_mat = bpy.data.materials.get(new_mat_name) + if existing_mat: + bpy.data.materials.remove(existing_mat) + + new_mat = bpy.data.materials.new(name=new_mat_name) + new_mat.use_nodes = True + + # Set up the material nodes + nodes = new_mat.node_tree.nodes + links = new_mat.node_tree.links + + # Clear default nodes + nodes.clear() + + # Create output node + output = nodes.new(type='ShaderNodeOutputMaterial') + output.location = (600, 0) + + # Create principled BSDF node + principled = nodes.new(type='ShaderNodeBsdfPrincipled') + principled.location = (300, 0) + links.new(principled.outputs[0], output.inputs[0]) + + # Add texture nodes based on available maps + tex_coord = nodes.new(type='ShaderNodeTexCoord') + tex_coord.location = (-800, 0) + + mapping = nodes.new(type='ShaderNodeMapping') + mapping.location = (-600, 0) + # Set a smaller scale to make the texture more visible + mapping.inputs['Scale'].default_value = (0.5, 0.5, 0.5) + links.new(tex_coord.outputs['UV'], mapping.inputs['Vector']) + + # Position offset for texture nodes + x_pos = -400 + y_pos = 300 + + # Connect different texture maps + for map_type, image in texture_images.items(): + tex_node = nodes.new(type='ShaderNodeTexImage') + tex_node.location = (x_pos, y_pos) + tex_node.image = image + + # Set color space based on map type + if map_type.lower() in ['color', 'diffuse', 'albedo']: + try: + tex_node.image.colorspace_settings.name = 'sRGB' + except: + pass # Use default if sRGB not available + else: + try: + tex_node.image.colorspace_settings.name = 'Non-Color' + except: + pass # Use default if Non-Color not available + + links.new(mapping.outputs['Vector'], tex_node.inputs['Vector']) + + # Connect to appropriate input on Principled BSDF + if map_type.lower() in ['color', 'diffuse', 'albedo']: + links.new(tex_node.outputs['Color'], principled.inputs['Base Color']) + elif map_type.lower() in ['roughness', 'rough']: + links.new(tex_node.outputs['Color'], principled.inputs['Roughness']) + elif map_type.lower() in ['metallic', 'metalness', 'metal']: + links.new(tex_node.outputs['Color'], principled.inputs['Metallic']) + elif map_type.lower() in ['normal', 'nor', 'dx', 'gl']: + # Add normal map node + normal_map = nodes.new(type='ShaderNodeNormalMap') + normal_map.location = (x_pos + 200, y_pos) + links.new(tex_node.outputs['Color'], normal_map.inputs['Color']) + links.new(normal_map.outputs['Normal'], principled.inputs['Normal']) + elif map_type.lower() in ['displacement', 'disp', 'height']: + # Add displacement node + disp_node = nodes.new(type='ShaderNodeDisplacement') + disp_node.location = (x_pos + 200, y_pos - 200) + disp_node.inputs['Scale'].default_value = 0.1 # Reduce displacement strength + links.new(tex_node.outputs['Color'], disp_node.inputs['Height']) + links.new(disp_node.outputs['Displacement'], output.inputs['Displacement']) + + y_pos -= 250 + + # Second pass: Connect nodes with proper handling for special cases + texture_nodes = {} + + # First find all texture nodes and store them by map type + for node in nodes: + if node.type == 'TEX_IMAGE' and node.image: + for map_type, image in texture_images.items(): + if node.image == image: + texture_nodes[map_type] = node + break + + # Now connect everything using the nodes instead of images + # Handle base color (diffuse) + for map_name in ['color', 'diffuse', 'albedo']: + if map_name in texture_nodes: + links.new(texture_nodes[map_name].outputs['Color'], principled.inputs['Base Color']) + print(f"Connected {map_name} to Base Color") + break + + # Handle roughness + for map_name in ['roughness', 'rough']: + if map_name in texture_nodes: + links.new(texture_nodes[map_name].outputs['Color'], principled.inputs['Roughness']) + print(f"Connected {map_name} to Roughness") + break + + # Handle metallic + for map_name in ['metallic', 'metalness', 'metal']: + if map_name in texture_nodes: + links.new(texture_nodes[map_name].outputs['Color'], principled.inputs['Metallic']) + print(f"Connected {map_name} to Metallic") + break + + # Handle normal maps + for map_name in ['gl', 'dx', 'nor']: + if map_name in texture_nodes: + normal_map_node = nodes.new(type='ShaderNodeNormalMap') + normal_map_node.location = (100, 100) + links.new(texture_nodes[map_name].outputs['Color'], normal_map_node.inputs['Color']) + links.new(normal_map_node.outputs['Normal'], principled.inputs['Normal']) + print(f"Connected {map_name} to Normal") + break + + # Handle displacement + for map_name in ['displacement', 'disp', 'height']: + if map_name in texture_nodes: + disp_node = nodes.new(type='ShaderNodeDisplacement') + disp_node.location = (300, -200) + disp_node.inputs['Scale'].default_value = 0.1 # Reduce displacement strength + links.new(texture_nodes[map_name].outputs['Color'], disp_node.inputs['Height']) + links.new(disp_node.outputs['Displacement'], output.inputs['Displacement']) + print(f"Connected {map_name} to Displacement") + break + + # Handle ARM texture (Ambient Occlusion, Roughness, Metallic) + if 'arm' in texture_nodes: + separate_rgb = nodes.new(type='ShaderNodeSeparateRGB') + separate_rgb.location = (-200, -100) + links.new(texture_nodes['arm'].outputs['Color'], separate_rgb.inputs['Image']) + + # Connect Roughness (G) if no dedicated roughness map + if not any(map_name in texture_nodes for map_name in ['roughness', 'rough']): + links.new(separate_rgb.outputs['G'], principled.inputs['Roughness']) + print("Connected ARM.G to Roughness") + + # Connect Metallic (B) if no dedicated metallic map + if not any(map_name in texture_nodes for map_name in ['metallic', 'metalness', 'metal']): + links.new(separate_rgb.outputs['B'], principled.inputs['Metallic']) + print("Connected ARM.B to Metallic") + + # For AO (R channel), multiply with base color if we have one + base_color_node = None + for map_name in ['color', 'diffuse', 'albedo']: + if map_name in texture_nodes: + base_color_node = texture_nodes[map_name] + break + + if base_color_node: + mix_node = nodes.new(type='ShaderNodeMixRGB') + mix_node.location = (100, 200) + mix_node.blend_type = 'MULTIPLY' + mix_node.inputs['Fac'].default_value = 0.8 # 80% influence + + # Disconnect direct connection to base color + for link in base_color_node.outputs['Color'].links: + if link.to_socket == principled.inputs['Base Color']: + links.remove(link) + + # Connect through the mix node + links.new(base_color_node.outputs['Color'], mix_node.inputs[1]) + links.new(separate_rgb.outputs['R'], mix_node.inputs[2]) + links.new(mix_node.outputs['Color'], principled.inputs['Base Color']) + print("Connected ARM.R to AO mix with Base Color") + + # Handle AO (Ambient Occlusion) if separate + if 'ao' in texture_nodes: + base_color_node = None + for map_name in ['color', 'diffuse', 'albedo']: + if map_name in texture_nodes: + base_color_node = texture_nodes[map_name] + break + + if base_color_node: + mix_node = nodes.new(type='ShaderNodeMixRGB') + mix_node.location = (100, 200) + mix_node.blend_type = 'MULTIPLY' + mix_node.inputs['Fac'].default_value = 0.8 # 80% influence + + # Disconnect direct connection to base color + for link in base_color_node.outputs['Color'].links: + if link.to_socket == principled.inputs['Base Color']: + links.remove(link) + + # Connect through the mix node + links.new(base_color_node.outputs['Color'], mix_node.inputs[1]) + links.new(texture_nodes['ao'].outputs['Color'], mix_node.inputs[2]) + links.new(mix_node.outputs['Color'], principled.inputs['Base Color']) + print("Connected AO to mix with Base Color") + + # CRITICAL: Make sure to clear all existing materials from the object + while len(obj.data.materials) > 0: + obj.data.materials.pop(index=0) + + # Assign the new material to the object + obj.data.materials.append(new_mat) + + # CRITICAL: Make the object active and select it + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + + # CRITICAL: Force Blender to update the material + bpy.context.view_layer.update() + + # Get the list of texture maps + texture_maps = list(texture_images.keys()) + + # Get info about texture nodes for debugging + material_info = { + "name": new_mat.name, + "has_nodes": new_mat.use_nodes, + "node_count": len(new_mat.node_tree.nodes), + "texture_nodes": [] + } + + for node in new_mat.node_tree.nodes: + if node.type == 'TEX_IMAGE' and node.image: + connections = [] + for output in node.outputs: + for link in output.links: + connections.append(f"{output.name} → {link.to_node.name}.{link.to_socket.name}") + + material_info["texture_nodes"].append({ + "name": node.name, + "image": node.image.name, + "colorspace": node.image.colorspace_settings.name, + "connections": connections + }) + + return { + "success": True, + "message": f"Created new material and applied texture {texture_id} to {object_name}", + "material": new_mat.name, + "maps": texture_maps, + "material_info": material_info + } + + except Exception as e: + print(f"Error in set_texture: {str(e)}") + traceback.print_exc() + return {"error": f"Failed to apply texture: {str(e)}"} + + # Blender UI Panel class BLENDERMCP_PT_Panel(bpy.types.Panel): bl_label = "Blender MCP" diff --git a/src/blender_mcp/server.py b/src/blender_mcp/server.py index 2bbb783..46be803 100644 --- a/src/blender_mcp/server.py +++ b/src/blender_mcp/server.py @@ -572,6 +572,67 @@ def download_polyhaven_asset( logger.error(f"Error downloading Polyhaven asset: {str(e)}") return f"Error downloading Polyhaven asset: {str(e)}" +@mcp.tool() +def set_texture( + ctx: Context, + object_name: str, + texture_id: str +) -> str: + """ + Apply a previously downloaded Polyhaven texture to an object. + + Parameters: + - object_name: Name of the object to apply the texture to + - texture_id: ID of the Polyhaven texture to apply (must be downloaded first) + + Returns a message indicating success or failure. + """ + try: + # Get the global connection + blender = get_blender_connection() + + result = blender.send_command("set_texture", { + "object_name": object_name, + "texture_id": texture_id + }) + + if "error" in result: + return f"Error: {result['error']}" + + if result.get("success"): + material_name = result.get("material", "") + maps = ", ".join(result.get("maps", [])) + + # Add detailed material info + material_info = result.get("material_info", {}) + node_count = material_info.get("node_count", 0) + has_nodes = material_info.get("has_nodes", False) + texture_nodes = material_info.get("texture_nodes", []) + + output = f"Successfully applied texture '{texture_id}' to {object_name}.\n" + output += f"Using material '{material_name}' with maps: {maps}.\n\n" + output += f"Material has nodes: {has_nodes}\n" + output += f"Total node count: {node_count}\n\n" + + if texture_nodes: + output += "Texture nodes:\n" + for node in texture_nodes: + output += f"- {node['name']} using image: {node['image']}\n" + if node['connections']: + output += " Connections:\n" + for conn in node['connections']: + output += f" {conn}\n" + else: + output += "No texture nodes found in the material.\n" + + return output + else: + return f"Failed to apply texture: {result.get('message', 'Unknown error')}" + except Exception as e: + logger.error(f"Error applying texture: {str(e)}") + return f"Error applying texture: {str(e)}" + + # Main execution def main():