-
Notifications
You must be signed in to change notification settings - Fork 2
feat: Add OBJ import and export functionality to Blender MCP server #29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,5 +1,3 @@ | ||||||||||||||||||||||||||||||||
| # Code created by Siddharth Ahuja: www.github.com/ahujasid © 2025 | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| import io | ||||||||||||||||||||||||||||||||
| import json | ||||||||||||||||||||||||||||||||
| import socket | ||||||||||||||||||||||||||||||||
|
|
@@ -282,6 +280,8 @@ def _execute_command_internal(self, command): | |||||||||||||||||||||||||||||||
| "get_scene_info": self.get_scene_info, | ||||||||||||||||||||||||||||||||
| "get_object_info": self.get_object_info, | ||||||||||||||||||||||||||||||||
| "execute_code": self.execute_code, | ||||||||||||||||||||||||||||||||
| "import_obj": self.import_obj, | ||||||||||||||||||||||||||||||||
| "export_obj": self.export_obj, | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| handler = handlers.get(cmd_type) | ||||||||||||||||||||||||||||||||
| if handler: | ||||||||||||||||||||||||||||||||
|
|
@@ -393,6 +393,136 @@ def execute_code(self, code): | |||||||||||||||||||||||||||||||
| f"Code execution error: {str(e)}\nTraceback:\n{traceback.format_exc()}" | ||||||||||||||||||||||||||||||||
| ) from e | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| def import_obj(self, obj_content, object_name="ImportedObject"): | ||||||||||||||||||||||||||||||||
| """Import OBJ content directly into Blender.""" | ||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||
| import os | ||||||||||||||||||||||||||||||||
| import tempfile | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| # Validate inputs | ||||||||||||||||||||||||||||||||
| if not obj_content or not obj_content.strip(): | ||||||||||||||||||||||||||||||||
| raise ValueError("OBJ content cannot be empty") | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| # Create a temporary file with the OBJ content | ||||||||||||||||||||||||||||||||
| with tempfile.NamedTemporaryFile( | ||||||||||||||||||||||||||||||||
| mode="w", suffix=".obj", delete=False | ||||||||||||||||||||||||||||||||
| ) as temp_file: | ||||||||||||||||||||||||||||||||
| temp_file.write(obj_content) | ||||||||||||||||||||||||||||||||
| temp_file_path = temp_file.name | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||
| # Store current object count for validation | ||||||||||||||||||||||||||||||||
| initial_object_count = len(bpy.data.objects) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| # Import the OBJ file | ||||||||||||||||||||||||||||||||
| bpy.ops.wm.obj_import(filepath=temp_file_path) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| # Check if objects were imported | ||||||||||||||||||||||||||||||||
| final_object_count = len(bpy.data.objects) | ||||||||||||||||||||||||||||||||
| imported_count = final_object_count - initial_object_count | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| if imported_count == 0: | ||||||||||||||||||||||||||||||||
| raise Exception("No objects were imported from OBJ content") | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| # Get the newly imported objects | ||||||||||||||||||||||||||||||||
| imported_objects = [] | ||||||||||||||||||||||||||||||||
| for obj in bpy.context.selected_objects: | ||||||||||||||||||||||||||||||||
| if obj.name not in [ | ||||||||||||||||||||||||||||||||
| o.name for o in bpy.data.objects[:initial_object_count] | ||||||||||||||||||||||||||||||||
| ]: | ||||||||||||||||||||||||||||||||
| imported_objects.append(obj.name) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| # If a specific name was requested and only one object was imported, rename it | ||||||||||||||||||||||||||||||||
| if len(imported_objects) == 1 and object_name != "ImportedObject": | ||||||||||||||||||||||||||||||||
| imported_obj = bpy.data.objects[imported_objects[0]] | ||||||||||||||||||||||||||||||||
| imported_obj.name = object_name | ||||||||||||||||||||||||||||||||
| imported_objects[0] = object_name | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||
| "imported": True, | ||||||||||||||||||||||||||||||||
| "object_count": imported_count, | ||||||||||||||||||||||||||||||||
| "object_names": imported_objects, | ||||||||||||||||||||||||||||||||
| "message": f"Successfully imported {imported_count} object(s) from OBJ content", | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| finally: | ||||||||||||||||||||||||||||||||
| # Clean up temporary file | ||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||
| os.unlink(temp_file_path) | ||||||||||||||||||||||||||||||||
| except: | ||||||||||||||||||||||||||||||||
| pass # Ignore cleanup errors | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
Comment on lines
+448
to
+454
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bare Catching everything (including - except:
- pass # Ignore cleanup errors
+ except FileNotFoundError:
+ # File already deleted – fine.
+ pass
+ except OSError as cleanup_err:
+ print(f"[import_obj] Temp-file cleanup failed: {cleanup_err}")Repeat this pattern in other cleanup blocks to appease static analysis and keep error logs meaningful. 📝 Committable suggestion
Suggested change
🧰 Tools🪛 Ruff (0.11.9)452-452: Do not use bare (E722) 🪛 GitHub Actions: CI/CD Pipeline[error] 452-452: Ruff: Do not use bare 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||
| except Exception as e: | ||||||||||||||||||||||||||||||||
| traceback.print_exc() | ||||||||||||||||||||||||||||||||
| raise Exception(f"OBJ import error: {str(e)}") from e | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| def export_obj(self, object_name=None): | ||||||||||||||||||||||||||||||||
| """Export object(s) to OBJ format and return the content.""" | ||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||
| import os | ||||||||||||||||||||||||||||||||
| import tempfile | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| # Determine which objects to export | ||||||||||||||||||||||||||||||||
| if object_name: | ||||||||||||||||||||||||||||||||
| # Export specific object | ||||||||||||||||||||||||||||||||
| target_obj = bpy.data.objects.get(object_name) | ||||||||||||||||||||||||||||||||
| if not target_obj: | ||||||||||||||||||||||||||||||||
| raise ValueError(f"Object '{object_name}' not found") | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| # Select only the target object | ||||||||||||||||||||||||||||||||
| bpy.ops.object.select_all(action="DESELECT") | ||||||||||||||||||||||||||||||||
| target_obj.select_set(True) | ||||||||||||||||||||||||||||||||
| bpy.context.view_layer.objects.active = target_obj | ||||||||||||||||||||||||||||||||
| export_objects = [object_name] | ||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||
| # Export selected objects | ||||||||||||||||||||||||||||||||
| selected_objects = [obj.name for obj in bpy.context.selected_objects] | ||||||||||||||||||||||||||||||||
| if not selected_objects: | ||||||||||||||||||||||||||||||||
| raise ValueError("No objects selected for export") | ||||||||||||||||||||||||||||||||
| export_objects = selected_objects | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| # Create temporary file for export | ||||||||||||||||||||||||||||||||
| with tempfile.NamedTemporaryFile( | ||||||||||||||||||||||||||||||||
| mode="w", suffix=".obj", delete=False | ||||||||||||||||||||||||||||||||
| ) as temp_file: | ||||||||||||||||||||||||||||||||
| temp_file_path = temp_file.name | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||
| # Export to OBJ file | ||||||||||||||||||||||||||||||||
| bpy.ops.wm.obj_export( | ||||||||||||||||||||||||||||||||
| filepath=temp_file_path, | ||||||||||||||||||||||||||||||||
| export_selected_objects=True, | ||||||||||||||||||||||||||||||||
| export_materials=False, # Keep it simple for now | ||||||||||||||||||||||||||||||||
| export_normals=True, | ||||||||||||||||||||||||||||||||
| export_uv=True, | ||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| # Read the exported content | ||||||||||||||||||||||||||||||||
| with open(temp_file_path, "r") as f: | ||||||||||||||||||||||||||||||||
| obj_content = f.read() | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| if not obj_content.strip(): | ||||||||||||||||||||||||||||||||
| raise Exception("Export resulted in empty OBJ content") | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||
| "exported": True, | ||||||||||||||||||||||||||||||||
| "object_names": export_objects, | ||||||||||||||||||||||||||||||||
| "obj_content": obj_content, | ||||||||||||||||||||||||||||||||
| "content_size": len(obj_content), | ||||||||||||||||||||||||||||||||
| "message": f"Successfully exported {len(export_objects)} object(s) to OBJ format", | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| finally: | ||||||||||||||||||||||||||||||||
| # Clean up temporary file | ||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||
| os.unlink(temp_file_path) | ||||||||||||||||||||||||||||||||
| except: | ||||||||||||||||||||||||||||||||
| pass # Ignore cleanup errors | ||||||||||||||||||||||||||||||||
|
Comment on lines
+517
to
+520
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Second bare Same rationale as the earlier cleanup block; please specialise the exception or at least log the error. - except:
- pass # Ignore cleanup errors
+ except FileNotFoundError:
+ pass
+ except OSError as cleanup_err:
+ print(f"[export_obj] Temp-file cleanup failed: {cleanup_err}")📝 Committable suggestion
Suggested change
🧰 Tools🪛 Ruff (0.11.9)519-519: Do not use bare (E722) 🪛 GitHub Actions: CI/CD Pipeline[error] 519-519: Ruff: Do not use bare 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| except Exception as e: | ||||||||||||||||||||||||||||||||
| traceback.print_exc() | ||||||||||||||||||||||||||||||||
| raise Exception(f"OBJ export error: {str(e)}") from e | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| class BLENDERMCP_PT_Panel(bpy.types.Panel): | ||||||||||||||||||||||||||||||||
| bl_label = "Lightfast MCP" | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,168 @@ | ||
| #!/usr/bin/env python3 | ||
| """ | ||
| Example: OBJ File Transfer with Blender MCP Server | ||
| This example demonstrates how to: | ||
| 1. Export an OBJ file from Blender | ||
| 2. Import an OBJ file into Blender | ||
| 3. Transfer OBJ content through the MCP socket connection | ||
| Prerequisites: | ||
| - Blender running with the Lightfast MCP addon enabled | ||
| - Blender MCP server running (uv run lightfast-blender-server) | ||
| """ | ||
|
|
||
| import asyncio | ||
| import json | ||
|
|
||
| from fastmcp import Client | ||
|
|
||
| # Sample OBJ content for a simple cube | ||
| SAMPLE_CUBE_OBJ = """# Simple cube OBJ | ||
| v -1.0 -1.0 -1.0 | ||
| v 1.0 -1.0 -1.0 | ||
| v 1.0 1.0 -1.0 | ||
| v -1.0 1.0 -1.0 | ||
| v -1.0 -1.0 1.0 | ||
| v 1.0 -1.0 1.0 | ||
| v 1.0 1.0 1.0 | ||
| v -1.0 1.0 1.0 | ||
| f 1 2 3 4 | ||
| f 5 8 7 6 | ||
| f 1 5 6 2 | ||
| f 2 6 7 3 | ||
| f 3 7 8 4 | ||
| f 5 1 4 8 | ||
| """ | ||
|
|
||
|
|
||
| async def main(): | ||
| """Demonstrate OBJ file transfer functionality.""" | ||
|
|
||
| # Connect to the Blender MCP server | ||
| server_url = "http://localhost:8001/mcp" | ||
|
|
||
| print("🔌 Connecting to Blender MCP server...") | ||
|
|
||
| try: | ||
| async with Client(server_url) as client: | ||
| print("✅ Connected to Blender MCP server") | ||
|
|
||
| # List available tools | ||
| tools = await client.list_tools() | ||
| tool_names = [tool.name for tool in tools] | ||
| print(f"📝 Available tools: {tool_names}") | ||
|
|
||
| # Check if our new tools are available | ||
| if ( | ||
| "import_obj_file" not in tool_names | ||
| or "export_obj_file" not in tool_names | ||
| ): | ||
| print( | ||
| "❌ OBJ import/export tools not found. Make sure you're using the updated server." | ||
| ) | ||
| return | ||
|
|
||
| print("\n" + "=" * 50) | ||
| print("🔄 Testing OBJ Import") | ||
| print("=" * 50) | ||
|
|
||
| # Test 1: Import a sample cube | ||
| print("📥 Importing sample cube OBJ...") | ||
| import_result = await client.call_tool( | ||
| "import_obj_file", | ||
| {"obj_content": SAMPLE_CUBE_OBJ, "object_name": "SampleCube"}, | ||
| ) | ||
|
|
||
| if import_result: | ||
| result_data = json.loads(import_result[0].text) | ||
| print(f"✅ Import result: {result_data}") | ||
| else: | ||
| print("❌ Import failed - no result returned") | ||
| return | ||
|
Comment on lines
+78
to
+83
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Result handling is brittle – don’t assume
If A safer pattern: if not import_result:
logger.error("No import result returned")
return
if isinstance(import_result, list):
payload = import_result[0].text if hasattr(import_result[0], "text") else import_result[0]
else:
payload = import_result
try:
result_data = json.loads(payload)
except (TypeError, json.JSONDecodeError) as exc:
logger.error("Malformed response from server", exc_info=exc)
return🤖 Prompt for AI Agents |
||
|
|
||
| print("\n" + "=" * 50) | ||
| print("📊 Getting Scene State") | ||
| print("=" * 50) | ||
|
|
||
| # Get current scene state to see our imported object | ||
| scene_result = await client.call_tool("get_state") | ||
| if scene_result: | ||
| scene_data = json.loads(scene_result[0].text) | ||
| print(f"📋 Scene objects: {scene_data.get('objects', [])}") | ||
| print(f"📊 Object count: {scene_data.get('object_count', 0)}") | ||
|
|
||
| print("\n" + "=" * 50) | ||
| print("📤 Testing OBJ Export") | ||
| print("=" * 50) | ||
|
|
||
| # Test 2: Export the cube we just imported | ||
| print("📤 Exporting SampleCube...") | ||
| export_result = await client.call_tool( | ||
| "export_obj_file", {"object_name": "SampleCube"} | ||
| ) | ||
|
|
||
| if export_result: | ||
| result_data = json.loads(export_result[0].text) | ||
| print("✅ Export successful!") | ||
| print(f"📊 Exported objects: {result_data.get('object_names', [])}") | ||
| print(f"📏 Content size: {result_data.get('content_size', 0)} bytes") | ||
|
|
||
| # Show first few lines of exported OBJ content | ||
| obj_content = result_data.get("obj_content", "") | ||
| if obj_content: | ||
| lines = obj_content.split("\n")[:10] | ||
| print("📄 First 10 lines of exported OBJ:") | ||
| for i, line in enumerate(lines, 1): | ||
| print(f" {i:2d}: {line}") | ||
| if len(obj_content.split("\n")) > 10: | ||
| print(" ... (truncated)") | ||
| else: | ||
| print("❌ Export failed - no result returned") | ||
|
|
||
| print("\n" + "=" * 50) | ||
| print("🔄 Testing Round-trip Transfer") | ||
| print("=" * 50) | ||
|
|
||
| # Test 3: Round-trip test - export then re-import | ||
| if export_result: | ||
| result_data = json.loads(export_result[0].text) | ||
| exported_obj_content = result_data.get("obj_content", "") | ||
|
|
||
| if exported_obj_content: | ||
| print("🔄 Re-importing exported OBJ as 'RoundTripCube'...") | ||
| reimport_result = await client.call_tool( | ||
| "import_obj_file", | ||
| { | ||
| "obj_content": exported_obj_content, | ||
| "object_name": "RoundTripCube", | ||
| }, | ||
| ) | ||
|
|
||
| if reimport_result: | ||
| result_data = json.loads(reimport_result[0].text) | ||
| print(f"✅ Round-trip successful: {result_data}") | ||
| else: | ||
| print("❌ Round-trip failed") | ||
|
|
||
| print("\n" + "=" * 50) | ||
| print("🎯 Summary") | ||
| print("=" * 50) | ||
| print("✅ OBJ file transfer through sockets is working!") | ||
| print("📝 You can now:") | ||
| print(" • Import OBJ content directly into Blender") | ||
| print(" • Export Blender objects as OBJ content") | ||
| print(" • Transfer 3D models between applications via MCP") | ||
|
|
||
| except Exception as e: | ||
| print(f"❌ Error: {e}") | ||
| print("\n🔧 Troubleshooting:") | ||
| print(" 1. Make sure Blender is running") | ||
| print(" 2. Enable the Lightfast MCP addon in Blender") | ||
| print(" 3. Start the Blender MCP server: uv run lightfast-blender-server") | ||
| print(" 4. Check that the addon shows 'Server active' status") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| asyncio.run(main()) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Imported-object detection may miss objects that the importer leaves un-selected.
bpy.ops.wm.obj_importdoes not guarantee that every newly-created object is left selected.Relying on
bpy.context.selected_objectscan therefore under-report (or mis-report after a prior user selection) and make theobject_nameslist inconsistent withobject_count.A robust way is to diff the object list before/after the import:
This guarantees
len(imported_objects) == imported_countand avoids selection-state edge cases.📝 Committable suggestion
🤖 Prompt for AI Agents