diff --git a/addon.py b/addon.py index c07c248..04ed28e 100644 --- a/addon.py +++ b/addon.py @@ -11,6 +11,7 @@ import tempfile import traceback import os import shutil +import zipfile from bpy.props import StringProperty, IntProperty, BoolProperty, EnumProperty import io from contextlib import redirect_stdout @@ -200,6 +201,7 @@ class BlenderMCPServer: "execute_code": self.execute_code, "get_polyhaven_status": self.get_polyhaven_status, "get_hyper3d_status": self.get_hyper3d_status, + "get_sketchfab_status": self.get_sketchfab_status, } # Add Polyhaven handlers only if enabled @@ -220,6 +222,14 @@ 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 = { + "search_sketchfab_models": self.search_sketchfab_models, + "download_sketchfab_model": self.download_sketchfab_model, + } + handlers.update(sketchfab_handlers) handler = handlers.get(cmd_type) if handler: @@ -1373,6 +1383,248 @@ class BlenderMCPServer: return {"succeed": False, "error": str(e)} #endregion + #region Sketchfab API + def get_sketchfab_status(self): + """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 + ) + + if response.status_code == 200: + user_data = response.json() + username = user_data.get("username", "Unknown user") + return { + "enabled": True, + "message": f"Sketchfab integration is enabled and ready to use. Logged in as: {username}" + } + else: + print(f"API key test failed with status code {response.status_code}: {response.text}") + return { + "enabled": False, + "message": f"Sketchfab API key seems invalid. Status code: {response.status_code}" + } + except Exception as e: + print(f"Error testing Sketchfab API key: {str(e)}") + return { + "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, + "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 + 3. Enter your Sketchfab API Key + 4. Restart the connection to Claude""" + } + else: + return { + "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"} + + print(f"Searching Sketchfab with query: {query}, categories: {categories}, count: {count}, downloadable: {downloadable}") + + # Build search parameters with exact fields from Sketchfab API docs + params = { + "type": "models", + "q": query, + "count": count, + "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}" + } + + print(f"Making request to Sketchfab API search endpoint with params: {params}") + + # Use the search endpoint as specified in the API documentation + response = requests.get( + "https://api.sketchfab.com/v3/search", + headers=headers, + params=params + ) + + if response.status_code == 401: + print(f"Authentication failed with status code 401. Check your API key.") + return {"error": f"Authentication failed (401). Check your API key."} + + if response.status_code != 200: + print(f"API request failed with status code {response.status_code}: {response.text}") + return {"error": f"API request failed with status code {response.status_code}: {response.text}"} + + response_data = response.json() + print(f"API response received with status code {response.status_code}") + + # 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 json.JSONDecodeError as e: + print(f"JSON decoding error: {str(e)}. Response text: {response.text if 'response' in locals() else 'No response'}") + return {"error": f"Invalid JSON response from Sketchfab API: {str(e)}"} + except Exception as e: + print(f"Error in search_sketchfab_models: {str(e)}") + import traceback + traceback.print_exc() + return {"error": str(e)} + + def download_sketchfab_model(self, uid): + """Download a model from Sketchfab by its UID""" + try: + api_key = bpy.context.scene.blendermcp_sketchfab_api_key + if not api_key: + return {"error": "Sketchfab API key is not configured"} + + print(f"Attempting to download Sketchfab model with UID: {uid}") + + # Use proper authorization header for API key auth + headers = { + "Authorization": f"Token {api_key}" + } + + print(f"Making download request to Sketchfab API with UID: {uid}") + + # Request download URL using the exact endpoint from the documentation + download_endpoint = f"https://api.sketchfab.com/v3/models/{uid}/download" + print(f"Download endpoint: {download_endpoint}") + + response = requests.get( + download_endpoint, + headers=headers + ) + + if response.status_code == 401: + print(f"Authentication failed with status code 401. Check your API key.") + return {"error": f"Authentication failed (401). Check your API key."} + + if response.status_code != 200: + print(f"Download request failed with status code {response.status_code}: {response.text}") + return {"error": f"Download request failed with status code {response.status_code}: {response.text}"} + + data = response.json() + + # Safety check for None data + if data is None: + return {"error": "Received empty response from Sketchfab API for download request"} + + print(f"Download response data: {data}") + + # Extract download URL with safety checks + gltf_data = data.get("gltf") + if not gltf_data: + print(f"No gltf data in response: {data}") + return {"error": "No gltf download URL available for this model. Response: " + str(data)} + + download_url = gltf_data.get("url") + if not download_url: + print(f"No URL in gltf data: {gltf_data}") + return {"error": "No download URL available for this model. Make sure the model is downloadable and you have access."} + + print(f"Download URL obtained: {download_url}") + + # Download the model + model_response = requests.get(download_url) + + if model_response.status_code != 200: + print(f"Model download failed with status code {model_response.status_code}") + return {"error": f"Model download failed with status code {model_response.status_code}"} + + # Save to temporary file + temp_dir = tempfile.mkdtemp() + file_path = os.path.join(temp_dir, f"{uid}.zip") + + print(f"Saving downloaded model to temporary file: {file_path}") + + with open(file_path, "wb") as f: + f.write(model_response.content) + + # Extract the zip file + print(f"Extracting zip file to: {temp_dir}") + with zipfile.ZipFile(file_path, 'r') as zip_ref: + 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: + print(f"No glTF file found in the downloaded model. Directory contents: {os.listdir(temp_dir)}") + return {"error": "No glTF file found in the downloaded model"} + + main_file = os.path.join(temp_dir, gltf_files[0]) + print(f"Found main glTF file: {main_file}") + + # Import the model + print(f"Importing model using Blender's glTF importer") + 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] + print(f"Imported objects: {', '.join(imported_objects)}") + + # Clean up temporary files + try: + shutil.rmtree(temp_dir) + print(f"Temporary directory cleaned up: {temp_dir}") + except Exception as e: + print(f"Failed to clean up temporary directory: {temp_dir}. Error: {str(e)}") + + return { + "success": True, + "message": f"Model imported successfully", + "imported_objects": imported_objects + } + + except json.JSONDecodeError as e: + print(f"JSON decoding error: {str(e)}. Response text: {response.text if 'response' in locals() else 'No response'}") + return {"error": f"Invalid JSON response from Sketchfab API: {str(e)}"} + except Exception as e: + print(f"Error in download_sketchfab_model: {str(e)}") + import traceback + traceback.print_exc() + return {"error": f"Failed to download model: {str(e)}"} + #endregion + # Blender UI Panel class BLENDERMCP_PT_Panel(bpy.types.Panel): bl_label = "Blender MCP" @@ -1394,6 +1646,10 @@ class BLENDERMCP_PT_Panel(bpy.types.Panel): 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: @@ -1492,6 +1748,19 @@ def register(): default="" ) + bpy.types.Scene.blendermcp_use_sketchfab = bpy.props.BoolProperty( + name="Use Sketchfab", + description="Enable Sketchfab asset integration", + default=False + ) + + bpy.types.Scene.blendermcp_sketchfab_api_key = bpy.props.StringProperty( + name="Sketchfab API Key", + subtype="PASSWORD", + 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) @@ -1516,6 +1785,8 @@ def unregister(): del bpy.types.Scene.blendermcp_use_hyper3d del bpy.types.Scene.blendermcp_hyper3d_mode del bpy.types.Scene.blendermcp_hyper3d_api_key + del bpy.types.Scene.blendermcp_use_sketchfab + del bpy.types.Scene.blendermcp_sketchfab_api_key print("BlenderMCP addon unregistered") diff --git a/src/blender_mcp/server.py b/src/blender_mcp/server.py index 6614e10..906aed3 100644 --- a/src/blender_mcp/server.py +++ b/src/blender_mcp/server.py @@ -515,6 +515,144 @@ def get_hyper3d_status(ctx: Context) -> str: logger.error(f"Error checking Hyper3D status: {str(e)}") return f"Error checking Hyper3D status: {str(e)}" +@mcp.tool() +def get_sketchfab_status(ctx: Context) -> str: + """ + Check if Sketchfab integration is enabled in Blender. + Returns a message indicating whether Sketchfab features are available. + """ + try: + blender = get_blender_connection() + result = blender.send_command("get_sketchfab_status") + enabled = result.get("enabled", False) + message = result.get("message", "") + + return message + except Exception as e: + logger.error(f"Error checking Sketchfab status: {str(e)}") + return f"Error checking Sketchfab status: {str(e)}" + +@mcp.tool() +def search_sketchfab_models( + ctx: Context, + query: str, + categories: str = None, + count: int = 20, + downloadable: bool = True +) -> str: + """ + Search for models on Sketchfab with optional filtering. + + Parameters: + - query: Text to search for + - categories: Optional comma-separated list of categories + - count: Maximum number of results to return (default 20) + - downloadable: Whether to include only downloadable models (default True) + + Returns a formatted list of matching models. + """ + try: + + blender = get_blender_connection() + logger.info(f"Searching Sketchfab models with query: {query}, categories: {categories}, count: {count}, downloadable: {downloadable}") + + result = blender.send_command("search_sketchfab_models", { + "query": query, + "categories": categories, + "count": count, + "downloadable": downloadable + }) + + if "error" in result: + logger.error(f"Error from Sketchfab search: {result['error']}") + return f"Error: {result['error']}" + + # Safely get results with fallbacks for None + if result is None: + logger.error("Received None result from Sketchfab search") + return "Error: Received no response from Sketchfab search" + + # Format the results + models = result.get("results", []) or [] + if not models: + return f"No models found matching '{query}'" + + formatted_output = f"Found {len(models)} models matching '{query}':\n\n" + + for model in models: + if model is None: + continue + + model_name = model.get("name", "Unnamed model") + model_uid = model.get("uid", "Unknown ID") + formatted_output += f"- {model_name} (UID: {model_uid})\n" + + # Get user info with safety checks + user = model.get("user") or {} + username = user.get("username", "Unknown author") if isinstance(user, dict) else "Unknown author" + formatted_output += f" Author: {username}\n" + + # Get license info with safety checks + license_data = model.get("license") or {} + license_label = license_data.get("label", "Unknown") if isinstance(license_data, dict) else "Unknown" + formatted_output += f" License: {license_label}\n" + + # Add face count and downloadable status + face_count = model.get("faceCount", "Unknown") + is_downloadable = "Yes" if model.get("isDownloadable") else "No" + formatted_output += f" Face count: {face_count}\n" + formatted_output += f" Downloadable: {is_downloadable}\n\n" + + return formatted_output + except Exception as e: + logger.error(f"Error searching Sketchfab models: {str(e)}") + import traceback + logger.error(traceback.format_exc()) + return f"Error searching Sketchfab models: {str(e)}" + +@mcp.tool() +def download_sketchfab_model( + ctx: Context, + uid: str +) -> str: + """ + Download and import a Sketchfab model by its UID. + + Parameters: + - uid: The unique identifier of the Sketchfab model + + Returns a message indicating success or failure. + The model must be downloadable and you must have proper access rights. + """ + try: + + blender = get_blender_connection() + logger.info(f"Attempting to download Sketchfab model with UID: {uid}") + + result = blender.send_command("download_sketchfab_model", { + "uid": uid + }) + + if result is None: + logger.error("Received None result from Sketchfab download") + return "Error: Received no response from Sketchfab download request" + + if "error" in result: + logger.error(f"Error from Sketchfab download: {result['error']}") + return f"Error: {result['error']}" + + if result.get("success"): + imported_objects = result.get("imported_objects", []) + object_names = ", ".join(imported_objects) if imported_objects else "none" + return f"Successfully imported model. Created objects: {object_names}" + else: + return f"Failed to download model: {result.get('message', 'Unknown error')}" + except Exception as e: + logger.error(f"Error downloading Sketchfab model: {str(e)}") + import traceback + logger.error(traceback.format_exc()) + return f"Error downloading Sketchfab model: {str(e)}" + def _process_bbox(original_bbox: list[float] | list[int] | None) -> list[int] | None: if original_bbox is None: return None @@ -705,7 +843,14 @@ def asset_creation_strategy() -> str: - For objects/models: Use download_polyhaven_asset() with asset_type="models" - For materials/textures: Use download_polyhaven_asset() with asset_type="textures" - For environment lighting: Use download_polyhaven_asset() with asset_type="hdris" - 2. Hyper3D(Rodin) + 2. Sketchfab + Use get_sketchfab_status() to verify its status + If Sketchfab is enabled: + - For objects/models: First search using search_sketchfab_models() with your query + - Then download specific models using download_sketchfab_model() with the UID + - Note that only downloadable models can be accessed, and API key must be properly configured + - Sketchfab has a wider variety of models than PolyHaven, especially for specific subjects + 3. Hyper3D(Rodin) Hyper3D Rodin is good at generating 3D models for single item. So don't try to: 1. Generate the whole scene with one shot @@ -735,11 +880,17 @@ def asset_creation_strategy() -> str: - Ensure that all objects that should not be clipping are not clipping. - Items have right spatial relationship. + 4. Recommended asset source priority: + - For specific existing objects: First try Sketchfab, then PolyHaven + - For generic objects/furniture: First try PolyHaven, then Sketchfab + - For custom or unique items not available in libraries: Use Hyper3D Rodin + - For environment lighting: Use PolyHaven HDRIs + - For materials/textures: Use PolyHaven textures Only fall back to scripting when: - - PolyHaven and Hyper3D are disabled + - PolyHaven, Sketchfab, and Hyper3D are all disabled - A simple primitive is explicitly requested - - No suitable PolyHaven asset exists + - No suitable asset exists in any of the libraries - Hyper3D Rodin failed to generate the desired asset - The task specifically requires a basic material/color """