diff --git a/addon.py b/addon.py index 1dc68c7..28e0905 100644 --- a/addon.py +++ b/addon.py @@ -28,6 +28,10 @@ bl_info = { RODIN_FREE_TRIAL_KEY = "k9TcfFoEhNd9cCPP2guHAHHHkctZHIRhZDywZ1euGUXwihbYLpOjQhofby80NJez" +# Add User-Agent as required by Poly Haven API +REQ_HEADERS = requests.utils.default_headers() +REQ_HEADERS.update({"User-Agent": "blender-mcp"}) + class BlenderMCPServer: def __init__(self, host='localhost', port=9876): self.host = host @@ -35,34 +39,34 @@ class BlenderMCPServer: self.running = False self.socket = None self.server_thread = None - + def start(self): if self.running: print("Server is already running") return - + self.running = True - + try: # Create socket self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.socket.bind((self.host, self.port)) self.socket.listen(1) - + # Start server thread self.server_thread = threading.Thread(target=self._server_loop) self.server_thread.daemon = True self.server_thread.start() - + print(f"BlenderMCP server started on {self.host}:{self.port}") except Exception as e: print(f"Failed to start server: {str(e)}") self.stop() - + def stop(self): self.running = False - + # Close socket if self.socket: try: @@ -70,7 +74,7 @@ class BlenderMCPServer: except: pass self.socket = None - + # Wait for thread to finish if self.server_thread: try: @@ -79,21 +83,21 @@ class BlenderMCPServer: except: pass self.server_thread = None - + print("BlenderMCP server stopped") - + def _server_loop(self): """Main server loop in a separate thread""" print("Server thread started") self.socket.settimeout(1.0) # Timeout to allow for stopping - + while self.running: try: # Accept new connection try: client, address = self.socket.accept() print(f"Connected to client: {address}") - + # Handle client in a separate thread client_thread = threading.Thread( target=self._handle_client, @@ -112,15 +116,15 @@ class BlenderMCPServer: if not self.running: break time.sleep(0.5) - + print("Server thread stopped") - + def _handle_client(self, client): """Handle connected client""" print("Client handler started") client.settimeout(None) # No timeout buffer = b'' - + try: while self.running: # Receive data @@ -129,13 +133,13 @@ class BlenderMCPServer: if not data: print("Client disconnected") break - + buffer += data try: # Try to parse command command = json.loads(buffer.decode('utf-8')) buffer = b'' - + # Execute command in Blender's main thread def execute_wrapper(): try: @@ -157,7 +161,7 @@ class BlenderMCPServer: except: pass return None - + # Schedule execution in main thread bpy.app.timers.register(execute_wrapper, first_interval=0.0) except json.JSONDecodeError: @@ -177,9 +181,9 @@ class BlenderMCPServer: def execute_command(self, command): """Execute a command in the main Blender thread""" - try: + try: return self._execute_command_internal(command) - + except Exception as e: print(f"Error executing command: {str(e)}") traceback.print_exc() @@ -193,7 +197,7 @@ class BlenderMCPServer: # Add a handler for checking PolyHaven status if cmd_type == "get_polyhaven_status": return {"status": "success", "result": self.get_polyhaven_status()} - + # Base handlers that are always available handlers = { "get_scene_info": self.get_scene_info, @@ -204,7 +208,7 @@ class BlenderMCPServer: "get_hyper3d_status": self.get_hyper3d_status, "get_sketchfab_status": self.get_sketchfab_status, } - + # Add Polyhaven handlers only if enabled if bpy.context.scene.blendermcp_use_polyhaven: polyhaven_handlers = { @@ -214,7 +218,7 @@ class BlenderMCPServer: "set_texture": self.set_texture, } handlers.update(polyhaven_handlers) - + # Add Hyper3d handlers only if enabled if bpy.context.scene.blendermcp_use_hyper3d: polyhaven_handlers = { @@ -223,7 +227,7 @@ class BlenderMCPServer: "import_generated_asset": self.import_generated_asset, } handlers.update(polyhaven_handlers) - + # Add Sketchfab handlers only if enabled if bpy.context.scene.blendermcp_use_sketchfab: sketchfab_handlers = { @@ -246,8 +250,8 @@ class BlenderMCPServer: else: return {"status": "error", "message": f"Unknown command type: {cmd_type}"} - - + + def get_scene_info(self): """Get information about the current Blender scene""" try: @@ -259,29 +263,29 @@ class BlenderMCPServer: "objects": [], "materials_count": len(bpy.data.materials), } - + # Collect minimal object information (limit to first 10 objects) for i, obj in enumerate(bpy.context.scene.objects): if i >= 10: # Reduced from 20 to 10 break - + obj_info = { "name": obj.name, "type": obj.type, # Only include basic location data - "location": [round(float(obj.location.x), 2), - round(float(obj.location.y), 2), + "location": [round(float(obj.location.x), 2), + round(float(obj.location.y), 2), round(float(obj.location.z), 2)], } scene_info["objects"].append(obj_info) - + print(f"Scene info collected: {len(scene_info['objects'])} objects") return scene_info except Exception as e: print(f"Error in get_scene_info: {str(e)}") traceback.print_exc() return {"error": str(e)} - + @staticmethod def _get_aabb(obj): """ Returns the world-space axis-aligned bounding box (AABB) of an object. """ @@ -303,13 +307,13 @@ class BlenderMCPServer: ] - + def get_object_info(self, name): """Get detailed information about a specific object""" obj = bpy.data.objects.get(name) if not obj: raise ValueError(f"Object not found: {name}") - + # Basic object info obj_info = { "name": obj.name, @@ -324,12 +328,12 @@ class BlenderMCPServer: if obj.type == "MESH": bounding_box = self._get_aabb(obj) obj_info["world_bounding_box"] = bounding_box - + # Add material slots for slot in obj.material_slots: if slot.material: obj_info["materials"].append(slot.material.name) - + # Add mesh data if applicable if obj.type == 'MESH' and obj.data: mesh = obj.data @@ -338,66 +342,66 @@ class BlenderMCPServer: "edges": len(mesh.edges), "polygons": len(mesh.polygons), } - + return obj_info - + def get_viewport_screenshot(self, max_size=800, filepath=None, format="png"): """ Capture a screenshot of the current 3D viewport and save it to the specified path. - + Parameters: - max_size: Maximum size in pixels for the largest dimension of the image - filepath: Path where to save the screenshot file - format: Image format (png, jpg, etc.) - + Returns success/error status """ try: if not filepath: return {"error": "No filepath provided"} - + # Find the active 3D viewport area = None for a in bpy.context.screen.areas: if a.type == 'VIEW_3D': area = a break - + if not area: return {"error": "No 3D viewport found"} - + # Take screenshot with proper context override with bpy.context.temp_override(area=area): bpy.ops.screen.screenshot_area(filepath=filepath) - + # Load and resize if needed img = bpy.data.images.load(filepath) width, height = img.size - + if max(width, height) > max_size: scale = max_size / max(width, height) new_width = int(width * scale) new_height = int(height * scale) img.scale(new_width, new_height) - + # Set format and save img.file_format = format.upper() img.save() width, height = new_width, new_height - + # Cleanup Blender image data bpy.data.images.remove(img) - + return { "success": True, "width": width, "height": height, "filepath": filepath } - + except Exception as e: return {"error": str(e)} - + def execute_code(self, code): """Execute arbitrary Blender Python code""" # This is powerful but potentially dangerous - use with caution @@ -409,43 +413,43 @@ class BlenderMCPServer: capture_buffer = io.StringIO() with redirect_stdout(capture_buffer): exec(code, namespace) - + captured_output = capture_buffer.getvalue() return {"executed": True, "result": captured_output} except Exception as e: raise Exception(f"Code execution error: {str(e)}") - - + + def get_polyhaven_categories(self, asset_type): """Get categories for a specific asset type from Polyhaven""" try: if asset_type not in ["hdris", "textures", "models", "all"]: return {"error": f"Invalid asset type: {asset_type}. Must be one of: hdris, textures, models, all"} - - response = requests.get(f"https://api.polyhaven.com/categories/{asset_type}") + + response = requests.get(f"https://api.polyhaven.com/categories/{asset_type}", headers=REQ_HEADERS) if response.status_code == 200: return {"categories": response.json()} else: return {"error": f"API request failed with status code {response.status_code}"} except Exception as e: return {"error": str(e)} - + def search_polyhaven_assets(self, asset_type=None, categories=None): """Search for assets from Polyhaven with optional filtering""" try: url = "https://api.polyhaven.com/assets" params = {} - + if asset_type and asset_type != "all": if asset_type not in ["hdris", "textures", "models"]: return {"error": f"Invalid asset type: {asset_type}. Must be one of: hdris, textures, models, all"} params["type"] = asset_type - + if categories: params["categories"] = categories - - response = requests.get(url, params=params) + + response = requests.get(url, params=params, headers=REQ_HEADERS) if response.status_code == 200: # Limit the response size to avoid overwhelming Blender assets = response.json() @@ -455,68 +459,68 @@ class BlenderMCPServer: if i >= 20: # Limit to 20 assets break limited_assets[key] = value - + return {"assets": limited_assets, "total_count": len(assets), "returned_count": len(limited_assets)} else: return {"error": f"API request failed with status code {response.status_code}"} except Exception as e: return {"error": str(e)} - + def download_polyhaven_asset(self, asset_id, asset_type, resolution="1k", file_format=None): try: # First get the files information - files_response = requests.get(f"https://api.polyhaven.com/files/{asset_id}") + files_response = requests.get(f"https://api.polyhaven.com/files/{asset_id}", headers=REQ_HEADERS) if files_response.status_code != 200: return {"error": f"Failed to get asset files: {files_response.status_code}"} - + files_data = files_response.json() - + # Handle different asset types if asset_type == "hdris": # For HDRIs, download the .hdr or .exr file if not file_format: file_format = "hdr" # Default format for HDRIs - + if "hdri" in files_data and resolution in files_data["hdri"] and file_format in files_data["hdri"][resolution]: file_info = files_data["hdri"][resolution][file_format] file_url = file_info["url"] - + # For HDRIs, we need to save to a temporary file first # since Blender can't properly load HDR data directly from memory with tempfile.NamedTemporaryFile(suffix=f".{file_format}", delete=False) as tmp_file: # Download the file - response = requests.get(file_url) + response = requests.get(file_url, headers=REQ_HEADERS) if response.status_code != 200: return {"error": f"Failed to download HDRI: {response.status_code}"} - + tmp_file.write(response.content) tmp_path = tmp_file.name - + try: # Create a new world if none exists if not bpy.data.worlds: bpy.data.worlds.new("World") - + world = bpy.data.worlds[0] world.use_nodes = True node_tree = world.node_tree - + # Clear existing nodes for node in node_tree.nodes: node_tree.nodes.remove(node) - + # Create nodes tex_coord = node_tree.nodes.new(type='ShaderNodeTexCoord') tex_coord.location = (-800, 0) - + mapping = node_tree.nodes.new(type='ShaderNodeMapping') mapping.location = (-600, 0) - + # Load the image from the temporary file env_tex = node_tree.nodes.new(type='ShaderNodeTexEnvironment') env_tex.location = (-400, 0) env_tex.image = bpy.data.images.load(tmp_path) - + # Use a color space that exists in all Blender versions if file_format.lower() == 'exr': # Try to use Linear color space for EXR files @@ -533,30 +537,30 @@ class BlenderMCPServer: break # Stop if we successfully set a color space except: continue - + background = node_tree.nodes.new(type='ShaderNodeBackground') background.location = (-200, 0) - + output = node_tree.nodes.new(type='ShaderNodeOutputWorld') output.location = (0, 0) - + # Connect nodes node_tree.links.new(tex_coord.outputs['Generated'], mapping.inputs['Vector']) node_tree.links.new(mapping.outputs['Vector'], env_tex.inputs['Vector']) node_tree.links.new(env_tex.outputs['Color'], background.inputs['Color']) node_tree.links.new(background.outputs['Background'], output.inputs['Surface']) - + # Set as active world bpy.context.scene.world = world - + # Clean up temporary file try: tempfile._cleanup() # This will clean up all temporary files except: pass - + return { - "success": True, + "success": True, "message": f"HDRI {asset_id} imported successfully", "image_name": env_tex.image.name } @@ -564,35 +568,35 @@ class BlenderMCPServer: return {"error": f"Failed to set up HDRI in Blender: {str(e)}"} else: return {"error": f"Requested resolution or format not available for this HDRI"} - + elif asset_type == "textures": if not file_format: file_format = "jpg" # Default format for textures - + downloaded_maps = {} - + 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"] - + # 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) + response = requests.get(file_url, headers=REQ_HEADERS) 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: @@ -604,56 +608,56 @@ class BlenderMCPServer: 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) mapping.vector_type = 'TEXTURE' # Changed from default 'POINT' to 'TEXTURE' 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: @@ -665,9 +669,9 @@ class BlenderMCPServer: 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']) @@ -687,62 +691,62 @@ class BlenderMCPServer: 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, + "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 if not file_format: file_format = "gltf" # Default format for models - + if file_format in files_data and resolution in files_data[file_format]: file_info = files_data[file_format][resolution][file_format] file_url = file_info["url"] - + # Create a temporary directory to store the model and its dependencies temp_dir = tempfile.mkdtemp() main_file_path = "" - + try: # Download the main model file main_file_name = file_url.split("/")[-1] main_file_path = os.path.join(temp_dir, main_file_name) - - response = requests.get(file_url) + + response = requests.get(file_url, headers=REQ_HEADERS) if response.status_code != 200: return {"error": f"Failed to download model: {response.status_code}"} - + with open(main_file_path, "wb") as f: f.write(response.content) - + # Check for included files and download them if "include" in file_info and file_info["include"]: for include_path, include_info in file_info["include"].items(): # Get the URL for the included file - this is the fix include_url = include_info["url"] - + # Create the directory structure for the included file include_file_path = os.path.join(temp_dir, include_path) os.makedirs(os.path.dirname(include_file_path), exist_ok=True) - + # Download the included file - include_response = requests.get(include_url) + include_response = requests.get(include_url, headers=REQ_HEADERS) if include_response.status_code == 200: with open(include_file_path, "wb") as f: f.write(include_response.content) else: print(f"Failed to download included file: {include_path}") - + # Import the model into Blender if file_format == "gltf" or file_format == "glb": bpy.ops.import_scene.gltf(filepath=main_file_path) @@ -754,19 +758,19 @@ class BlenderMCPServer: # For blend files, we need to append or link with bpy.data.libraries.load(main_file_path, link=False) as (data_from, data_to): data_to.objects = data_from.objects - + # Link the objects to the scene for obj in data_to.objects: if obj is not None: bpy.context.collection.objects.link(obj) else: return {"error": f"Unsupported model format: {file_format}"} - + # Get the names of imported objects imported_objects = [obj.name for obj in bpy.context.selected_objects] - + return { - "success": True, + "success": True, "message": f"Model {asset_id} imported successfully", "imported_objects": imported_objects } @@ -778,10 +782,10 @@ class BlenderMCPServer: shutil.rmtree(temp_dir) else: return {"error": f"Requested format or resolution not available for this model"} - + else: return {"error": f"Unsupported asset type: {asset_type}"} - + except Exception as e: return {"error": f"Failed to download asset: {str(e)}"} @@ -792,21 +796,21 @@ class BlenderMCPServer: 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: @@ -818,14 +822,14 @@ class BlenderMCPServer: 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}") @@ -834,53 +838,53 @@ class BlenderMCPServer: 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) mapping.vector_type = 'TEXTURE' # Changed from default 'POINT' to 'TEXTURE' 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: @@ -892,9 +896,9 @@ class BlenderMCPServer: 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']) @@ -915,12 +919,12 @@ class BlenderMCPServer: 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: @@ -928,7 +932,7 @@ class BlenderMCPServer: 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']: @@ -936,21 +940,21 @@ class BlenderMCPServer: 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: @@ -960,7 +964,7 @@ class BlenderMCPServer: 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: @@ -971,47 +975,47 @@ class BlenderMCPServer: 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 @@ -1019,41 +1023,41 @@ class BlenderMCPServer: 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, @@ -1061,21 +1065,21 @@ class BlenderMCPServer: "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}", @@ -1083,7 +1087,7 @@ class BlenderMCPServer: "maps": texture_maps, "material_info": material_info } - + except Exception as e: print(f"Error in set_texture: {str(e)}") traceback.print_exc() @@ -1096,7 +1100,7 @@ class BlenderMCPServer: return {"enabled": True, "message": "PolyHaven integration is enabled and ready to use."} else: return { - "enabled": False, + "enabled": False, "message": """PolyHaven integration is currently disabled. To enable it: 1. In the 3D Viewport, find the BlenderMCP panel in the sidebar (press N if hidden) 2. Check the 'Use assets from Poly Haven' checkbox @@ -1110,7 +1114,7 @@ class BlenderMCPServer: if enabled: if not bpy.context.scene.blendermcp_hyper3d_api_key: return { - "enabled": False, + "enabled": False, "message": """Hyper3D Rodin integration is currently enabled, but API key is not given. To enable it: 1. In the 3D Viewport, find the BlenderMCP panel in the sidebar (press N if hidden) 2. Keep the 'Use Hyper3D Rodin 3D model generation' checkbox checked @@ -1126,7 +1130,7 @@ class BlenderMCPServer: } else: return { - "enabled": False, + "enabled": False, "message": """Hyper3D Rodin integration is currently disabled. To enable it: 1. In the 3D Viewport, find the BlenderMCP panel in the sidebar (press N if hidden) 2. Check the 'Use Hyper3D Rodin 3D model generation' checkbox @@ -1172,7 +1176,7 @@ class BlenderMCPServer: return data except Exception as e: return {"error": str(e)} - + def create_rodin_job_fal_ai( self, text_prompt: str=None, @@ -1226,7 +1230,7 @@ class BlenderMCPServer: return { "status_list": [i["status"] for i in data["jobs"]] } - + def poll_rodin_job_status_fal_ai(self, request_id: str): """Call the job status API to get the job status""" response = requests.get( @@ -1245,21 +1249,21 @@ class BlenderMCPServer: # Import the GLB file bpy.ops.import_scene.gltf(filepath=filepath) - + # Ensure the context is updated bpy.context.view_layer.update() - + # Get all imported objects imported_objects = list(set(bpy.data.objects) - existing_objects) # imported_objects = [obj for obj in bpy.context.view_layer.objects if obj.select_get()] - + if not imported_objects: print("Error: No objects were imported.") return - + # Identify the mesh object mesh_obj = None - + if len(imported_objects) == 1 and imported_objects[0].type == 'MESH': mesh_obj = imported_objects[0] print("Single mesh imported, no cleanup needed.") @@ -1274,14 +1278,14 @@ class BlenderMCPServer: potential_mesh = parent_obj.children[0] if potential_mesh.type == 'MESH': print("GLB structure confirmed: Empty node with one mesh child.") - + # Unparent the mesh from the empty node potential_mesh.parent = None - + # Remove the empty node bpy.data.objects.remove(parent_obj) print("Removed empty node, keeping only the mesh.") - + mesh_obj = potential_mesh else: print("Error: Child is not a mesh object.") @@ -1292,7 +1296,7 @@ class BlenderMCPServer: else: print("Error: Expected an empty node with one mesh child or a single mesh object.") return - + # Rename the mesh if needed try: if mesh_obj and mesh_obj.name is not None and mesh_name: @@ -1334,25 +1338,25 @@ class BlenderMCPServer: prefix=task_uuid, suffix=".glb", ) - + try: # Download the content response = requests.get(i["url"], stream=True) response.raise_for_status() # Raise an exception for HTTP errors - + # Write the content to the temporary file for chunk in response.iter_content(chunk_size=8192): temp_file.write(chunk) - + # Close the file temp_file.close() - + except Exception as e: # Clean up the file if there's an error temp_file.close() os.unlink(temp_file.name) return {"succeed": False, "error": str(e)} - + break else: return {"succeed": False, "error": "Generation failed. Please first make sure that all jobs of the task are done and then try again later."} @@ -1373,13 +1377,13 @@ class BlenderMCPServer: if obj.type == "MESH": bounding_box = self._get_aabb(obj) result["world_bounding_box"] = bounding_box - + return { "succeed": True, **result } except Exception as e: return {"succeed": False, "error": str(e)} - + def import_generated_asset_fal_ai(self, request_id: str, name: str): """Fetch the generated asset, import into blender""" response = requests.get( @@ -1390,7 +1394,7 @@ class BlenderMCPServer: ) data_ = response.json() temp_file = None - + temp_file = tempfile.NamedTemporaryFile( delete=False, prefix=request_id, @@ -1401,14 +1405,14 @@ class BlenderMCPServer: # Download the content response = requests.get(data_["model_mesh"]["url"], stream=True) response.raise_for_status() # Raise an exception for HTTP errors - + # Write the content to the temporary file for chunk in response.iter_content(chunk_size=8192): temp_file.write(chunk) - + # Close the file temp_file.close() - + except Exception as e: # Clean up the file if there's an error temp_file.close() @@ -1431,7 +1435,7 @@ class BlenderMCPServer: if obj.type == "MESH": bounding_box = self._get_aabb(obj) result["world_bounding_box"] = bounding_box - + return { "succeed": True, **result } @@ -1444,48 +1448,48 @@ class BlenderMCPServer: """Get the current status of Sketchfab integration""" enabled = bpy.context.scene.blendermcp_use_sketchfab api_key = bpy.context.scene.blendermcp_sketchfab_api_key - + # Test the API key if present if api_key: try: headers = { "Authorization": f"Token {api_key}" } - + response = requests.get( "https://api.sketchfab.com/v3/me", headers=headers, timeout=30 # Add timeout of 30 seconds ) - + if response.status_code == 200: user_data = response.json() username = user_data.get("username", "Unknown user") return { - "enabled": True, + "enabled": True, "message": f"Sketchfab integration is enabled and ready to use. Logged in as: {username}" } else: return { - "enabled": False, + "enabled": False, "message": f"Sketchfab API key seems invalid. Status code: {response.status_code}" } except requests.exceptions.Timeout: return { - "enabled": False, + "enabled": False, "message": "Timeout connecting to Sketchfab API. Check your internet connection." } except Exception as e: return { - "enabled": False, + "enabled": False, "message": f"Error testing Sketchfab API key: {str(e)}" } - + if enabled and api_key: return {"enabled": True, "message": "Sketchfab integration is enabled and ready to use."} elif enabled and not api_key: return { - "enabled": False, + "enabled": False, "message": """Sketchfab integration is currently enabled, but API key is not given. To enable it: 1. In the 3D Viewport, find the BlenderMCP panel in the sidebar (press N if hidden) 2. Keep the 'Use Sketchfab' checkbox checked @@ -1494,21 +1498,21 @@ class BlenderMCPServer: } else: return { - "enabled": False, + "enabled": False, "message": """Sketchfab integration is currently disabled. To enable it: 1. In the 3D Viewport, find the BlenderMCP panel in the sidebar (press N if hidden) 2. Check the 'Use assets from Sketchfab' checkbox 3. Enter your Sketchfab API Key 4. Restart the connection to Claude""" } - + def search_sketchfab_models(self, query, categories=None, count=20, downloadable=True): """Search for models on Sketchfab based on query and optional filters""" try: api_key = bpy.context.scene.blendermcp_sketchfab_api_key if not api_key: return {"error": "Sketchfab API key is not configured"} - + # Build search parameters with exact fields from Sketchfab API docs params = { "type": "models", @@ -1517,17 +1521,17 @@ class BlenderMCPServer: "downloadable": downloadable, "archives_flavours": False } - + if categories: params["categories"] = categories - + # Make API request to Sketchfab search endpoint # The proper format according to Sketchfab API docs for API key auth headers = { "Authorization": f"Token {api_key}" } - - + + # Use the search endpoint as specified in the API documentation response = requests.get( "https://api.sketchfab.com/v3/search", @@ -1535,26 +1539,26 @@ class BlenderMCPServer: params=params, timeout=30 # Add timeout of 30 seconds ) - + if response.status_code == 401: return {"error": "Authentication failed (401). Check your API key."} - + if response.status_code != 200: return {"error": f"API request failed with status code {response.status_code}"} - + response_data = response.json() - + # Safety check on the response structure if response_data is None: return {"error": "Received empty response from Sketchfab API"} - + # Handle 'results' potentially missing from response results = response_data.get("results", []) if not isinstance(results, list): return {"error": f"Unexpected response format from Sketchfab API: {response_data}"} - + return response_data - + except requests.exceptions.Timeout: return {"error": "Request timed out. Check your internet connection."} except json.JSONDecodeError as e: @@ -1570,111 +1574,111 @@ class BlenderMCPServer: api_key = bpy.context.scene.blendermcp_sketchfab_api_key if not api_key: return {"error": "Sketchfab API key is not configured"} - + # Use proper authorization header for API key auth headers = { "Authorization": f"Token {api_key}" } - + # Request download URL using the exact endpoint from the documentation download_endpoint = f"https://api.sketchfab.com/v3/models/{uid}/download" - + response = requests.get( download_endpoint, headers=headers, timeout=30 # Add timeout of 30 seconds ) - + if response.status_code == 401: return {"error": "Authentication failed (401). Check your API key."} - + if response.status_code != 200: return {"error": f"Download request failed with status code {response.status_code}"} - + data = response.json() - + # Safety check for None data if data is None: return {"error": "Received empty response from Sketchfab API for download request"} - + # Extract download URL with safety checks gltf_data = data.get("gltf") if not gltf_data: return {"error": "No gltf download URL available for this model. Response: " + str(data)} - + download_url = gltf_data.get("url") if not download_url: return {"error": "No download URL available for this model. Make sure the model is downloadable and you have access."} - + # Download the model (already has timeout) model_response = requests.get(download_url, timeout=60) # 60 second timeout - + if model_response.status_code != 200: return {"error": f"Model download failed with status code {model_response.status_code}"} - + # Save to temporary file temp_dir = tempfile.mkdtemp() zip_file_path = os.path.join(temp_dir, f"{uid}.zip") - + with open(zip_file_path, "wb") as f: f.write(model_response.content) - + # Extract the zip file with enhanced security with zipfile.ZipFile(zip_file_path, 'r') as zip_ref: # More secure zip slip prevention for file_info in zip_ref.infolist(): # Get the path of the file file_path = file_info.filename - + # Convert directory separators to the current OS style # This handles both / and \ in zip entries target_path = os.path.join(temp_dir, os.path.normpath(file_path)) - + # Get absolute paths for comparison abs_temp_dir = os.path.abspath(temp_dir) abs_target_path = os.path.abspath(target_path) - + # Ensure the normalized path doesn't escape the target directory if not abs_target_path.startswith(abs_temp_dir): with suppress(Exception): shutil.rmtree(temp_dir) return {"error": "Security issue: Zip contains files with path traversal attempt"} - + # Additional explicit check for directory traversal if ".." in file_path: with suppress(Exception): shutil.rmtree(temp_dir) return {"error": "Security issue: Zip contains files with directory traversal sequence"} - + # If all files passed security checks, extract them zip_ref.extractall(temp_dir) - + # Find the main glTF file gltf_files = [f for f in os.listdir(temp_dir) if f.endswith('.gltf') or f.endswith('.glb')] - + if not gltf_files: with suppress(Exception): shutil.rmtree(temp_dir) return {"error": "No glTF file found in the downloaded model"} - + main_file = os.path.join(temp_dir, gltf_files[0]) - + # Import the model bpy.ops.import_scene.gltf(filepath=main_file) - + # Get the names of imported objects imported_objects = [obj.name for obj in bpy.context.selected_objects] - + # Clean up temporary files with suppress(Exception): shutil.rmtree(temp_dir) - + return { "success": True, "message": "Model imported successfully", "imported_objects": imported_objects } - + except requests.exceptions.Timeout: return {"error": "Request timed out. Check your internet connection and try again with a simpler model."} except json.JSONDecodeError as e: @@ -1692,11 +1696,11 @@ class BLENDERMCP_PT_Panel(bpy.types.Panel): bl_space_type = 'VIEW_3D' bl_region_type = 'UI' bl_category = 'BlenderMCP' - + def draw(self, context): layout = self.layout scene = context.scene - + layout.prop(scene, "blendermcp_port") layout.prop(scene, "blendermcp_use_polyhaven", text="Use assets from Poly Haven") @@ -1705,11 +1709,11 @@ class BLENDERMCP_PT_Panel(bpy.types.Panel): layout.prop(scene, "blendermcp_hyper3d_mode", text="Rodin Mode") layout.prop(scene, "blendermcp_hyper3d_api_key", text="API Key") layout.operator("blendermcp.set_hyper3d_free_trial_api_key", text="Set Free Trial API Key") - + layout.prop(scene, "blendermcp_use_sketchfab", text="Use assets from Sketchfab") if scene.blendermcp_use_sketchfab: layout.prop(scene, "blendermcp_sketchfab_api_key", text="API Key") - + if not scene.blendermcp_server_running: layout.operator("blendermcp.start_server", text="Connect to MCP server") else: @@ -1720,7 +1724,7 @@ class BLENDERMCP_PT_Panel(bpy.types.Panel): class BLENDERMCP_OT_SetFreeTrialHyper3DAPIKey(bpy.types.Operator): bl_idname = "blendermcp.set_hyper3d_free_trial_api_key" bl_label = "Set Free Trial API Key" - + def execute(self, context): context.scene.blendermcp_hyper3d_api_key = RODIN_FREE_TRIAL_KEY context.scene.blendermcp_hyper3d_mode = 'MAIN_SITE' @@ -1732,18 +1736,18 @@ class BLENDERMCP_OT_StartServer(bpy.types.Operator): bl_idname = "blendermcp.start_server" bl_label = "Connect to Claude" bl_description = "Start the BlenderMCP server to connect with Claude" - + def execute(self, context): scene = context.scene - + # Create a new server instance if not hasattr(bpy.types, "blendermcp_server") or not bpy.types.blendermcp_server: bpy.types.blendermcp_server = BlenderMCPServer(port=scene.blendermcp_port) - + # Start the server bpy.types.blendermcp_server.start() scene.blendermcp_server_running = True - + return {'FINISHED'} # Operator to stop the server @@ -1751,17 +1755,17 @@ class BLENDERMCP_OT_StopServer(bpy.types.Operator): bl_idname = "blendermcp.stop_server" bl_label = "Stop the connection to Claude" bl_description = "Stop the connection to Claude" - + def execute(self, context): scene = context.scene - + # Stop the server if it exists if hasattr(bpy.types, "blendermcp_server") and bpy.types.blendermcp_server: bpy.types.blendermcp_server.stop() del bpy.types.blendermcp_server - + scene.blendermcp_server_running = False - + return {'FINISHED'} # Registration functions @@ -1773,12 +1777,12 @@ def register(): min=1024, max=65535 ) - + bpy.types.Scene.blendermcp_server_running = bpy.props.BoolProperty( name="Server Running", default=False ) - + bpy.types.Scene.blendermcp_use_polyhaven = bpy.props.BoolProperty( name="Use Poly Haven", description="Enable Poly Haven asset integration", @@ -1807,7 +1811,7 @@ def register(): description="API Key provided by Hyper3D", default="" ) - + bpy.types.Scene.blendermcp_use_sketchfab = bpy.props.BoolProperty( name="Use Sketchfab", description="Enable Sketchfab asset integration", @@ -1820,12 +1824,12 @@ def register(): description="API Key provided by Sketchfab", default="" ) - + bpy.utils.register_class(BLENDERMCP_PT_Panel) bpy.utils.register_class(BLENDERMCP_OT_SetFreeTrialHyper3DAPIKey) bpy.utils.register_class(BLENDERMCP_OT_StartServer) bpy.utils.register_class(BLENDERMCP_OT_StopServer) - + print("BlenderMCP addon registered") def unregister(): @@ -1833,12 +1837,12 @@ def unregister(): if hasattr(bpy.types, "blendermcp_server") and bpy.types.blendermcp_server: bpy.types.blendermcp_server.stop() del bpy.types.blendermcp_server - + bpy.utils.unregister_class(BLENDERMCP_PT_Panel) bpy.utils.unregister_class(BLENDERMCP_OT_SetFreeTrialHyper3DAPIKey) bpy.utils.unregister_class(BLENDERMCP_OT_StartServer) bpy.utils.unregister_class(BLENDERMCP_OT_StopServer) - + del bpy.types.Scene.blendermcp_port del bpy.types.Scene.blendermcp_server_running del bpy.types.Scene.blendermcp_use_polyhaven