Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 132 additions & 2 deletions addons/blender/lightfast_blender_addon.py
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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Comment on lines +424 to +435
Copy link

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_import does not guarantee that every newly-created object is left selected.
Relying on bpy.context.selected_objects can therefore under-report (or mis-report after a prior user selection) and make the object_names list inconsistent with object_count.

A robust way is to diff the object list before/after the import:

-                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)
+                # Identify newly created objects independently of selection state
+                imported_objects = [
+                    obj.name
+                    for obj in bpy.data.objects[initial_object_count:]
+                ]

This guarantees len(imported_objects) == imported_count and avoids selection-state edge cases.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 imported_count == 0:
raise Exception("No objects were imported from OBJ content")
# Get the newly imported objects
+ # Identify newly created objects independently of selection state
+ imported_objects = [
+ obj.name
+ for obj in bpy.data.objects[initial_object_count:]
+ ]
# If a specific name was requested and only one object was imported, rename it
🤖 Prompt for AI Agents
In addons/blender/lightfast_blender_addon.py around lines 424 to 435, the code
uses bpy.context.selected_objects to detect newly imported objects, which can
miss objects if they are not selected by the importer. To fix this, capture the
list of object names before the import, then after the import, compute the
difference between the new list and the old list to identify all newly imported
objects. Replace the selection-based detection with this diff approach to ensure
imported_objects matches imported_count accurately.

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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Bare except: hides real errors and is raising CI warnings.

Catching everything (including KeyboardInterrupt, SystemExit) makes debugging harder and violates PEP 8 / Ruff rule E722.

-                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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
finally:
# Clean up temporary file
try:
os.unlink(temp_file_path)
except:
pass # Ignore cleanup errors
finally:
# Clean up temporary file
try:
os.unlink(temp_file_path)
except FileNotFoundError:
# File already deleted – fine.
pass
except OSError as cleanup_err:
print(f"[import_obj] Temp-file cleanup failed: {cleanup_err}")
🧰 Tools
🪛 Ruff (0.11.9)

452-452: Do not use bare except

(E722)

🪛 GitHub Actions: CI/CD Pipeline

[error] 452-452: Ruff: Do not use bare except (E722)

🤖 Prompt for AI Agents
In addons/blender/lightfast_blender_addon.py around lines 448 to 454, replace
the bare except clause in the cleanup block with a more specific exception
handler, such as catching only OSError or Exception, to avoid hiding critical
errors like KeyboardInterrupt or SystemExit. Apply this change consistently to
other similar cleanup blocks to comply with PEP 8 and reduce CI warnings.

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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Second bare except: – mirror the scoped fix above.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
os.unlink(temp_file_path)
except:
pass # Ignore cleanup errors
try:
os.unlink(temp_file_path)
except FileNotFoundError:
pass
except OSError as cleanup_err:
print(f"[export_obj] Temp-file cleanup failed: {cleanup_err}")
🧰 Tools
🪛 Ruff (0.11.9)

519-519: Do not use bare except

(E722)

🪛 GitHub Actions: CI/CD Pipeline

[error] 519-519: Ruff: Do not use bare except (E722)

🤖 Prompt for AI Agents
In addons/blender/lightfast_blender_addon.py around lines 517 to 520, the bare
except clause used to ignore errors during file cleanup should be replaced with
a more specific exception handler, such as catching OSError. Additionally, log
the exception details instead of silently passing to aid in debugging potential
issues during cleanup.


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"
Expand Down
168 changes: 168 additions & 0 deletions examples/obj_transfer_example.py
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Result handling is brittle – don’t assume call_tool always returns a [Response] list

import_result[0].text assumes:

  1. call_tool() returns a non-empty list
  2. The first element has a .text attribute

If call_tool() is updated to return a plain string / dict (or an empty list on error), json.loads() will raise.

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
In examples/obj_transfer_example.py around lines 78 to 83, the code assumes
call_tool() returns a non-empty list with elements having a .text attribute,
which is brittle and can cause json.loads() to fail. To fix this, first check if
import_result is empty and return early if so. Then, determine if import_result
is a list; if yes, extract the payload from the first element's .text attribute
if it exists, otherwise use the element directly. If import_result is not a
list, use it as the payload. Finally, wrap json.loads(payload) in a try-except
block to catch TypeError and json.JSONDecodeError, logging an error and
returning if parsing fails.


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())
Loading
Loading