"""Robot model import/export tools for robotics-mcp.
Handles robot 3D model formats (FBX, GLB, OBJ) for virtual robotics.
Note: VRM format is for humanoid avatars only - use FBX/GLB for wheeled robots.
Integrates with:
- blender-mcp: For creating/editing 3D models and exporting to FBX/GLB/OBJ
- gimp-mcp: For creating/editing textures and images for robot models
"""
from pathlib import Path
from typing import Any, Literal
import structlog
from ..utils.error_handler import (
format_error_response,
format_success_response,
handle_tool_error,
)
from ..utils.mcp_client_helper import call_mounted_server_tool
logger = structlog.get_logger(__name__)
# Supported formats for robots
ROBOT_MODEL_FORMATS = [
"fbx",
"glb",
"obj",
"blend",
] # Note: VRM only for humanoid robots
HUMANOID_ROBOT_TYPES = ["robbie", "g1"] # Robots that CAN use VRM
NON_HUMANOID_ROBOT_TYPES = ["scout", "scout_e", "go2"] # Robots that should NOT use VRM
class RobotModelTools:
"""Tools for importing, exporting, and converting robot 3D models."""
def __init__(
self,
mcp: Any,
state_manager: Any,
mounted_servers: dict[str, Any] | None = None,
):
"""Initialize robot model tools.
Args:
mcp: FastMCP server instance.
state_manager: Robot state manager instance.
mounted_servers: Dictionary of mounted MCP servers.
"""
self.mcp = mcp
self.state_manager = state_manager
self.mounted_servers = mounted_servers or {}
def register(self):
"""Register robot model portmanteau tool with MCP server."""
@self.mcp.tool()
async def robot_model(
operation: Literal[
"create",
"import",
"export",
"convert",
"spz_check",
"spz_convert",
"spz_extract",
"spz_install",
],
robot_type: str | None = None,
model_path: str | None = None,
output_path: str | None = None,
format: Literal["fbx", "glb", "obj", "vrm", "blend"] = "fbx",
platform: Literal["unity", "vrchat", "resonite"] = "unity",
dimensions: dict[str, float] | None = None,
create_textures: bool = True,
texture_style: Literal["realistic", "stylized", "simple"] = "realistic",
robot_id: str | None = None,
include_animations: bool = True,
project_path: str | None = None,
create_prefab: bool = True,
source_path: str | None = None,
source_format: Literal["fbx", "glb", "obj", "blend", "vrm"] | None = None,
target_format: Literal["fbx", "glb", "obj", "vrm"] | None = None,
target_path: str | None = None,
spz_path: str | None = None,
output_format: str | None = None,
unity_project_path: str | None = None,
) -> dict[str, Any]:
"""Robot model management portmanteau for Robotics MCP.
PORTMANTEAU PATTERN RATIONALE:
Instead of creating 4 separate tools (create, import, export, convert), this tool
consolidates related model operations into a single interface. This design:
- Prevents tool explosion (4 tools -> 1 tool) while maintaining full functionality
- Improves discoverability by grouping related operations together
- Reduces cognitive load when working with robot models
- Enables consistent model interface across all operations
- Follows FastMCP 2.13+ best practices for feature-rich MCP servers
SUPPORTED OPERATIONS:
- create: Create robot 3D model from scratch using Blender MCP
- import: Import robot 3D model into Unity/VRChat/Resonite project
- export: Export robot model from Unity to file format
- convert: Convert robot model between formats
- spz_check: Check .spz conversion tool availability
- spz_convert: Convert .spz file to .ply or other format
- spz_extract: Extract metadata from .spz file
- spz_install: Install Unity Gaussian Splatting plugin (alternative to .spz)
Args:
operation: The model operation to perform. MUST be one of:
- "create": Create model (requires: robot_type, output_path)
- "import": Import model (requires: robot_type, model_path)
- "export": Export model (requires: robot_id)
- "convert": Convert model (requires: source_path, source_format, target_format)
robot_type: Type of robot (required for create/import).
Examples: "scout", "go2", "g1", "robbie", "custom"
model_path: Path to model file (required for import).
output_path: Path for output file (required for create, optional for export/convert).
format: Model format (used by create/import/export).
- "fbx": Industry standard (recommended for robots)
- "glb": Modern glTF 2.0 format
- "obj": Simple mesh format
- "vrm": VRM format (ONLY for humanoid robots)
platform: Target platform for import (unity/vrchat/resonite).
dimensions: Custom dimensions for create (length, width, height in meters).
create_textures: Create textures using gimp-mcp (for create).
texture_style: Texture style for create (realistic/stylized/simple).
robot_id: Virtual robot identifier (required for export).
include_animations: Include animations in export.
project_path: Unity project path (for import).
create_prefab: Create Unity prefab after import.
source_path: Source file path (required for convert).
source_format: Source file format (required for convert).
target_format: Target file format (required for convert).
target_path: Output file path (optional for convert).
spz_path: Path to .spz file (required for spz_* operations).
output_format: Output format for spz_convert (ply, obj, glb).
unity_project_path: Unity project path for spz_install.
Returns:
Rich conversational response with:
- success: Boolean operation status
- message: Natural language description of result
- model_data: 3D model metadata and file information
- safety_warnings: Any file system or compatibility warnings
- next_commands: Suggested follow-up operations
- estimated_completion: Time estimates for long operations
- error_recovery: Intelligent error handling with resolution steps
- platform_integration: Unity/VRChat/Resonite integration status
Examples:
Create Scout model:
result = await robot_model(
operation="create",
robot_type="scout",
output_path="D:/Models/scout_model.fbx",
format="fbx",
dimensions={"length": 0.5, "width": 0.4, "height": 0.3}
)
# Returns: {"success": true, "message": "Scout model created successfully", "model_data": {"format": "fbx", "vertices": 8500}}
Import model to Unity:
result = await robot_model(
operation="import",
robot_type="scout",
model_path="D:/Models/scout_model.fbx",
platform="unity",
project_path="D:/UnityProjects/Robotics"
)
# Returns: {"success": true, "message": "Model imported to Unity", "platform_integration": {"unity": true, "prefab_created": true}}
Export robot from Unity:
result = await robot_model(
operation="export",
robot_id="vbot_scout_01",
format="fbx",
output_path="D:/Exports/scout_export.fbx",
include_animations=true
)
# Returns: {"success": true, "message": "Robot exported from Unity", "model_data": {"animations": 5, "format": "fbx"}}
Convert FBX to GLB:
result = await robot_model(
operation="convert",
source_path="D:/Models/scout.fbx",
source_format="fbx",
target_format="glb",
target_path="D:/Models/scout_converted.glb"
)
# Returns: {"success": true, "message": "Model converted successfully", "model_data": {"format": "glb", "vertices": 12500}}
Check SPZ tool availability:
result = await robot_model(operation="spz_check")
# Returns: {"success": true, "message": "SPZ tools available", "platform_integration": {"unity": true, "gaussian_splatting": true}}
Convert SPZ to PLY:
result = await robot_model(
operation="spz_convert",
spz_path="D:/Models/scene.spz",
output_format="ply",
target_path="D:/Models/scene_converted.ply"
)
# Returns: {"success": true, "message": "SPZ converted to PLY", "model_data": {"format": "ply", "points": 500000}}
Install Unity Gaussian Splatting:
result = await robot_model(
operation="spz_install",
unity_project_path="D:/UnityProjects/Robotics"
)
# Returns: {"success": true, "message": "Gaussian Splatting plugin installed", "platform_integration": {"unity": true}}
"""
try:
if operation == "create":
if not robot_type or not output_path:
return format_error_response(
"robot_type and output_path are required for 'create' operation",
error_type="validation_error",
)
return await self._handle_create(
robot_type,
output_path,
format,
dimensions,
create_textures,
texture_style,
)
elif operation == "import":
if not robot_type or not model_path:
return format_error_response(
"robot_type and model_path are required for 'import' operation",
error_type="validation_error",
)
return await self._handle_import(
robot_type,
model_path,
format,
platform,
project_path,
create_prefab,
)
elif operation == "export":
if not robot_id:
return format_error_response(
"robot_id is required for 'export' operation",
error_type="validation_error",
)
return await self._handle_export(
robot_id, format, output_path, include_animations
)
elif operation == "convert":
if not source_path or not source_format or not target_format:
return format_error_response(
"source_path, source_format, and target_format are required for 'convert' operation",
error_type="validation_error",
)
return await self._handle_convert(
source_path,
source_format,
target_format,
target_path,
robot_type,
)
elif operation == "spz_check":
return await self._handle_spz_check()
elif operation == "spz_convert":
if not spz_path:
return format_error_response(
"spz_path required for spz_convert",
error_type="validation_error",
)
return await self._handle_spz_convert(
spz_path, target_path, output_format
)
elif operation == "spz_extract":
if not spz_path:
return format_error_response(
"spz_path required for spz_extract",
error_type="validation_error",
)
return await self._handle_spz_extract(spz_path)
elif operation == "spz_install":
if not unity_project_path:
return format_error_response(
"unity_project_path required for spz_install",
error_type="validation_error",
)
return await self._handle_spz_install(unity_project_path)
else:
return format_error_response(
f"Unknown operation: {operation}", error_type="validation_error"
)
except Exception as e:
return handle_tool_error("robot_model", e, operation=operation)
async def _handle_create(
self,
robot_type: str,
output_path: str,
format: str,
dimensions: dict[str, float] | None,
create_textures: bool,
texture_style: str,
) -> dict[str, Any]:
"""Handle create operation."""
try:
if "blender" not in self.mounted_servers:
return format_error_response(
"blender-mcp not available - required for model creation",
error_type="not_available",
details={"mounted_servers": list(self.mounted_servers.keys())},
)
if not dimensions:
dimensions = self._get_default_dimensions(robot_type)
return await self._create_model_via_blender(
robot_type,
output_path,
format,
dimensions,
create_textures,
texture_style,
)
except Exception as e:
return handle_tool_error("robot_model", e, operation="create")
async def _handle_import(
self,
robot_type: str,
model_path: str,
format: str,
platform: str,
project_path: str | None,
create_prefab: bool,
) -> dict[str, Any]:
"""Handle import operation."""
try:
if format == "vrm" and robot_type in NON_HUMANOID_ROBOT_TYPES:
return format_error_response(
f"VRM format is for humanoid robots only. {robot_type} is not humanoid. Use FBX/GLB instead.",
error_type="validation_error",
robot_type=robot_type,
format=format,
)
if platform == "unity":
return await self._import_to_unity(
robot_type, model_path, format, project_path, create_prefab
)
elif platform == "vrchat":
return await self._import_to_vrchat(
robot_type, model_path, format, project_path
)
elif platform == "resonite":
return await self._import_to_resonite(robot_type, model_path, format)
else:
return format_error_response(
f"Unsupported platform: {platform}", error_type="validation_error"
)
except Exception as e:
return handle_tool_error("robot_model", e, operation="import")
async def _handle_export(
self,
robot_id: str,
format: str,
output_path: str | None,
include_animations: bool,
) -> dict[str, Any]:
"""Handle export operation."""
try:
robot = self.state_manager.get_robot(robot_id)
if not robot:
return format_error_response(
f"Robot {robot_id} not found",
error_type="not_found",
robot_id=robot_id,
)
if not robot.is_virtual:
return format_error_response(
f"Robot {robot_id} is not a virtual robot",
error_type="validation_error",
robot_id=robot_id,
)
if robot.platform == "unity" and "unity" in self.mounted_servers:
return await self._export_from_unity(
robot_id, format, output_path, include_animations
)
else:
return format_error_response(
f"Export not yet implemented for platform: {robot.platform}",
error_type="not_implemented",
robot_id=robot_id,
)
except Exception as e:
return handle_tool_error("robot_model", e, operation="export")
async def _handle_convert(
self,
source_path: str,
source_format: str,
target_format: str,
target_path: str | None,
robot_type: str | None,
) -> dict[str, Any]:
"""Handle convert operation."""
try:
if (
target_format == "vrm"
and robot_type
and robot_type in NON_HUMANOID_ROBOT_TYPES
):
return format_error_response(
f"VRM conversion only works for humanoid robots. {robot_type} is not humanoid.",
error_type="validation_error",
robot_type=robot_type,
)
if "blender" not in self.mounted_servers:
return format_error_response(
"blender-mcp not available - required for model conversion",
error_type="not_available",
details={"mounted_servers": list(self.mounted_servers.keys())},
)
return await self._convert_via_blender(
source_path, source_format, target_format, target_path
)
except Exception as e:
return handle_tool_error("robot_model", e, operation="convert")
# Legacy individual tools removed - consolidated into robot_model portmanteau above
# Keeping method signatures for reference but not registering as tools:
async def _legacy_robot_model_import(
robot_type: str,
model_path: str,
format: Literal["fbx", "glb", "obj", "vrm"] = "fbx",
platform: Literal["unity", "vrchat", "resonite"] = "unity",
project_path: str | None = None,
create_prefab: bool = True,
) -> dict[str, Any]:
"""Import robot 3D model into Unity/VRChat/Resonite project.
Imports a robot 3D model file (FBX, GLB, OBJ, or VRM) into the specified
platform. Handles format-specific import requirements and creates prefabs
for reuse.
IMPORTANT FORMAT NOTES:
- **FBX/GLB/OBJ**: Use for wheeled robots (Scout), quadrupeds (Go2), and custom robots
- **VRM**: Only for humanoid robots (Robbie, G1) - requires humanoid bone structure
- **Scout should NOT use VRM** (not humanoid - has wheels, not legs)
Args:
robot_type: Type of robot (e.g., "scout", "robbie", "go2", "g1", "custom").
model_path: Path to 3D model file (.fbx, .glb, .obj, .vrm).
format: Model format. Default: "fbx".
- "fbx": Industry standard, best for Unity (recommended for robots)
- "glb": Modern glTF 2.0 format, single file
- "obj": Simple mesh format (no animations)
- "vrm": VRM format (ONLY for humanoid robots like Robbie, G1)
platform: Target platform. Default: "unity".
project_path: Unity project path (required for Unity platform).
create_prefab: Create Unity prefab after import. Default: True.
Returns:
Dictionary containing import result with model path and prefab info.
Examples:
Import Scout FBX model:
result = await robot_model_import(
robot_type="scout",
model_path="D:/Models/scout_model.fbx",
format="fbx",
platform="unity",
project_path="D:/Projects/UnityRobots"
)
Import Robbie VRM (humanoid - VRM OK):
result = await robot_model_import(
robot_type="robbie",
model_path="D:/Models/robbie.vrm",
format="vrm",
platform="unity",
project_path="D:/Projects/UnityRobots"
)
Import Go2 GLB (quadruped - use GLB, not VRM):
result = await robot_model_import(
robot_type="go2",
model_path="D:/Models/go2_model.glb",
format="glb",
platform="unity"
)
"""
try:
# Validate format for robot type
if format == "vrm" and robot_type in NON_HUMANOID_ROBOT_TYPES:
return format_error_response(
f"VRM format is for humanoid robots only. {robot_type} is not humanoid. Use FBX/GLB instead.",
error_type="validation_error",
robot_type=robot_type,
format=format,
)
if platform == "unity":
return await self._import_to_unity(
robot_type, model_path, format, project_path, create_prefab
)
elif platform == "vrchat":
return await self._import_to_vrchat(
robot_type, model_path, format, project_path
)
elif platform == "resonite":
return await self._import_to_resonite(
robot_type, model_path, format
)
else:
return format_error_response(
f"Unsupported platform: {platform}",
error_type="validation_error",
)
except Exception as e:
return handle_tool_error(
"robot_model_import",
e,
robot_type=robot_type,
format=format,
platform=platform,
)
@self.mcp.tool()
async def robot_model_export(
robot_id: str,
format: Literal["fbx", "glb", "obj"] = "fbx",
output_path: str | None = None,
include_animations: bool = True,
) -> dict[str, Any]:
"""Export robot model from Unity to file format.
Exports a robot model from Unity scene to FBX, GLB, or OBJ format.
Useful for sharing models or converting between formats.
Args:
robot_id: Virtual robot identifier to export.
format: Export format. Default: "fbx".
- "fbx": Industry standard, includes animations
- "glb": Modern format, single file
- "obj": Simple mesh (no animations)
output_path: Output file path. Auto-generated if not provided.
include_animations: Include animations in export (FBX/GLB only). Default: True.
Returns:
Dictionary containing export result with file path.
Examples:
Export Scout model to FBX:
result = await robot_model_export(
robot_id="vbot_scout_01",
format="fbx",
output_path="D:/Exports/scout_export.fbx"
)
"""
try:
robot = self.state_manager.get_robot(robot_id)
if not robot:
return format_error_response(
f"Robot {robot_id} not found",
error_type="not_found",
robot_id=robot_id,
)
if not robot.is_virtual:
return format_error_response(
f"Robot {robot_id} is not a virtual robot",
error_type="validation_error",
robot_id=robot_id,
)
# Export via Unity or Blender
if robot.platform == "unity" and "unity" in self.mounted_servers:
return await self._export_from_unity(
robot_id, format, output_path, include_animations
)
else:
return format_error_response(
f"Export not yet implemented for platform: {robot.platform}",
error_type="not_implemented",
robot_id=robot_id,
)
except Exception as e:
return handle_tool_error(
"robot_model_export", e, robot_id=robot_id, format=format
)
@self.mcp.tool()
async def robot_model_create(
robot_type: str,
output_path: str,
format: Literal["fbx", "glb", "obj"] = "fbx",
dimensions: dict[str, float] | None = None,
create_textures: bool = True,
texture_style: Literal["realistic", "stylized", "simple"] = "realistic",
) -> dict[str, Any]:
"""Create robot 3D model from scratch using Blender MCP.
Creates a robot 3D model using blender-mcp tools. Supports creating
models for Scout, Go2, G1, Robbie, and custom robots. Optionally
creates textures using gimp-mcp.
WORKFLOW:
1. Create base mesh in Blender (using blender-mcp)
2. Add details (wheels, camera, sensors, etc.)
3. Create/apply textures (using gimp-mcp if enabled)
4. Export to FBX/GLB/OBJ format
Args:
robot_type: Type of robot to create (e.g., "scout", "go2", "custom").
output_path: Path where model will be exported.
format: Export format. Default: "fbx".
dimensions: Optional custom dimensions (length, width, height in meters).
If not provided, uses default dimensions for robot_type.
create_textures: Create textures using gimp-mcp. Default: True.
texture_style: Texture style. Default: "realistic".
Returns:
Dictionary containing creation result with model path and texture info.
Examples:
Create Scout model:
result = await robot_model_create(
robot_type="scout",
output_path="D:/Models/scout_model.fbx",
format="fbx",
dimensions={"length": 0.115, "width": 0.10, "height": 0.08},
create_textures=True
)
Create custom robot:
result = await robot_model_create(
robot_type="custom",
output_path="D:/Models/my_robot.glb",
format="glb",
dimensions={"length": 0.2, "width": 0.15, "height": 0.12}
)
"""
try:
# Check if blender-mcp is available
if "blender" not in self.mounted_servers:
return format_error_response(
"blender-mcp not available - required for model creation",
error_type="not_available",
details={"mounted_servers": list(self.mounted_servers.keys())},
)
# Get default dimensions if not provided
if not dimensions:
dimensions = self._get_default_dimensions(robot_type)
# Create model using blender-mcp
return await self._create_model_via_blender(
robot_type,
output_path,
format,
dimensions,
create_textures,
texture_style,
)
except Exception as e:
return handle_tool_error(
"robot_model_create", e, robot_type=robot_type, format=format
)
@self.mcp.tool()
async def robot_model_convert(
source_path: str,
source_format: Literal["fbx", "glb", "obj", "blend", "vrm"],
target_format: Literal["fbx", "glb", "obj", "vrm"],
target_path: str | None = None,
robot_type: str | None = None,
) -> dict[str, Any]:
"""Convert robot model between formats.
Converts a robot 3D model from one format to another. Uses Blender MCP
for format conversion when available.
IMPORTANT: VRM conversion only works for humanoid robots (Robbie, G1).
Non-humanoid robots (Scout, Go2) cannot be converted to VRM.
Args:
source_path: Path to source model file.
source_format: Source file format.
target_format: Target file format.
target_path: Output file path. Auto-generated if not provided.
robot_type: Robot type (used for VRM validation). Optional.
Returns:
Dictionary containing conversion result.
Examples:
Convert FBX to GLB:
result = await robot_model_convert(
source_path="D:/Models/scout.fbx",
source_format="fbx",
target_format="glb",
target_path="D:/Models/scout.glb"
)
Convert FBX to VRM (humanoid only):
result = await robot_model_convert(
source_path="D:/Models/robbie.fbx",
source_format="fbx",
target_format="vrm",
robot_type="robbie" # Humanoid - VRM OK
)
"""
try:
# Validate VRM conversion
if (
target_format == "vrm"
and robot_type
and robot_type in NON_HUMANOID_ROBOT_TYPES
):
return format_error_response(
f"Cannot convert {robot_type} to VRM - not humanoid. Use FBX/GLB instead.",
error_type="validation_error",
robot_type=robot_type,
target_format=target_format,
)
# Use Blender MCP for conversion if available
if "blender" in self.mounted_servers:
return await self._convert_via_blender(
source_path, source_format, target_format, target_path
)
else:
return format_error_response(
"Blender MCP not available for format conversion",
error_type="not_available",
details={"mounted_servers": list(self.mounted_servers.keys())},
)
except Exception as e:
return handle_tool_error(
"robot_model_convert",
e,
source_format=source_format,
target_format=target_format,
)
async def _import_to_unity(
self,
robot_type: str,
model_path: str,
format: str,
project_path: str | None,
create_prefab: bool,
) -> dict[str, Any]:
"""Import model to Unity."""
try:
if "unity" in self.mounted_servers:
if format == "vrm":
# Use unity3d-mcp VRM import
result = await call_mounted_server_tool(
self.mounted_servers,
"unity",
"import_vrm_avatar",
{
"vrm_path": model_path,
"project_path": project_path or "",
"optimize_for_vrchat": False,
"create_prefab": create_prefab,
},
)
else:
# Use unity3d-mcp asset import (if available)
# For now, return mock result
result = format_success_response(
f"Model import initiated: {robot_type}",
data={
"model_path": model_path,
"format": format,
"prefab_path": f"Assets/Prefabs/{robot_type}.prefab"
if create_prefab
else None,
},
)
return result
else:
return format_success_response(
f"Mock import: {robot_type}",
data={
"model_path": model_path,
"format": format,
"note": "Unity MCP not available",
},
)
except Exception as e:
logger.error("Unity import failed", robot_type=robot_type, error=str(e))
return format_error_response(
f"Unity import failed: {str(e)}", error_type="import_error"
)
async def _import_to_vrchat(
self, robot_type: str, model_path: str, format: str, project_path: str | None
) -> dict[str, Any]:
"""Import model to VRChat."""
# VRChat uses Unity, so similar to Unity import
return await self._import_to_unity(
robot_type, model_path, format, project_path, create_prefab=True
)
async def _import_to_resonite(
self, robot_type: str, model_path: str, format: str
) -> dict[str, Any]:
"""Import model to Resonite."""
# Resonite imports VRM/GLB directly
return format_success_response(
f"Resonite import: {robot_type}",
data={
"model_path": model_path,
"format": format,
"note": "Resonite imports VRM/GLB directly - no Unity project needed",
},
)
async def _export_from_unity(
self,
robot_id: str,
format: str,
output_path: str | None,
include_animations: bool,
) -> dict[str, Any]:
"""Export model from Unity."""
try:
if "unity" not in self.mounted_servers:
return format_error_response(
"unity3d-mcp not available - required for Unity export",
error_type="not_available",
details={"mounted_servers": list(self.mounted_servers.keys())},
)
# Generate output path if not provided
if not output_path:
from pathlib import Path
robot = self.state_manager.get_robot(robot_id)
robot_type = robot.robot_type if robot else "robot"
output_dir = Path("D:/Exports")
output_dir.mkdir(parents=True, exist_ok=True)
output_path = str(
output_dir / f"{robot_id}_{robot_type}.{format.lower()}"
)
# Normalize format
format_lower = format.lower()
if format_lower not in ["obj", "fbx", "glb", "gltf"]:
return format_error_response(
f"Unsupported export format: {format}. Supported: obj, fbx, glb",
error_type="validation_error",
format=format,
)
# Call Unity export script via execute_unity_method
result = await call_mounted_server_tool(
self.mounted_servers,
"unity",
"execute_unity_method",
{
"class_name": "RobotExporter",
"method_name": "ExportRobot",
"parameters": {
"robotId": robot_id,
"outputPath": output_path,
"format": format_lower,
"includeAnimations": include_animations,
},
},
)
# Parse result from Unity script
if isinstance(result, dict):
result_str = result.get("output", "") or result.get("result", "")
if "ERROR" in result_str:
return format_error_response(
f"Unity export failed: {result_str}",
error_type="export_error",
robot_id=robot_id,
format=format,
)
elif "SUCCESS" in result_str:
return format_success_response(
f"Exported {robot_id} to {format}",
data={
"robot_id": robot_id,
"format": format,
"output_path": output_path,
"include_animations": include_animations,
"unity_result": result_str,
},
)
else:
# Unknown result format
return format_success_response(
f"Export initiated: {robot_id}",
data={
"robot_id": robot_id,
"format": format,
"output_path": output_path,
"unity_result": result_str,
},
)
else:
# Result is not a dict - might be a string
result_str = str(result)
if "ERROR" in result_str:
return format_error_response(
f"Unity export failed: {result_str}",
error_type="export_error",
robot_id=robot_id,
format=format,
)
else:
return format_success_response(
f"Exported {robot_id} to {format}",
data={
"robot_id": robot_id,
"format": format,
"output_path": output_path,
"unity_result": result_str,
},
)
except Exception as e:
logger.error(
"Unity export failed", robot_id=robot_id, format=format, error=str(e)
)
return format_error_response(
f"Unity export failed: {str(e)}",
error_type="export_error",
robot_id=robot_id,
format=format,
)
def _get_default_dimensions(self, robot_type: str) -> dict[str, float]:
"""Get default dimensions for robot type (in meters)."""
defaults = {
"scout": {"length": 0.115, "width": 0.10, "height": 0.08}, # 11.5x10x8 cm
"scout_e": {
"length": 0.12,
"width": 0.11,
"height": 0.09,
}, # Slightly larger
"go2": {"length": 0.50, "width": 0.30, "height": 0.40}, # Quadruped
"g1": {"length": 0.50, "width": 0.40, "height": 1.60}, # Humanoid
"robbie": {"length": 0.60, "width": 0.50, "height": 1.80}, # Humanoid robot
}
return defaults.get(
robot_type, {"length": 0.2, "width": 0.15, "height": 0.12}
) # Default custom size
async def _create_model_via_blender(
self,
robot_type: str,
output_path: str,
format: str,
dimensions: dict[str, float],
create_textures: bool,
texture_style: str,
) -> dict[str, Any]:
"""Create robot model using blender-mcp."""
try:
# Access blender-mcp directly from mounted servers
blender_server = self.mounted_servers.get("blender")
if not blender_server:
return format_error_response(
"blender-mcp server not available",
error_type="not_available",
details={"mounted_servers": list(self.mounted_servers.keys())},
)
# Create all objects in a SINGLE Blender script execution
# This ensures all objects are in the same scene when we save
from blender_mcp.utils.blender_executor import get_blender_executor
executor = get_blender_executor()
# Calculate wheel positions in Python (before script generation)
wheel_radius = 0.025
wheel_positions = [
(
-dimensions["length"] / 2 - wheel_radius,
dimensions["width"] / 2,
wheel_radius,
), # Front-left
(
dimensions["length"] / 2 + wheel_radius,
dimensions["width"] / 2,
wheel_radius,
), # Front-right
(
-dimensions["length"] / 2 - wheel_radius,
-dimensions["width"] / 2,
wheel_radius,
), # Back-left
(
dimensions["length"] / 2 + wheel_radius,
-dimensions["width"] / 2,
wheel_radius,
), # Back-right
]
# Generate a single script that creates all objects
create_script = f"""
import bpy
import math
from mathutils import Vector, Euler
# Clear default objects
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=False)
def create_mecanum_wheel(wheel_radius=0.025, wheel_thickness=0.015, roller_count=12, roller_angle=45.0, location=(0,0,0), rotation=(0,0,0), name="mecanum_wheel"):
\"\"\"Create a proper mecanum wheel with angled rollers.\"\"\"
# Create main wheel hub (cylinder)
bpy.ops.mesh.primitive_cylinder_add(
radius=wheel_radius * 0.3,
depth=wheel_thickness,
location=location,
rotation=rotation
)
wheel_hub = bpy.context.active_object
wheel_hub.name = f"{{name}}_hub"
# Create main wheel rim (torus/donut shape for rollers to attach to)
bpy.ops.mesh.primitive_torus_add(
major_radius=wheel_radius * 0.8,
minor_radius=wheel_radius * 0.15,
major_segments=roller_count * 2,
minor_segments=8,
location=location,
rotation=rotation
)
wheel_rim = bpy.context.active_object
wheel_rim.name = f"{{name}}_rim"
# Create rollers
angle_step = 2 * math.pi / roller_count
for i in range(roller_count):
angle = i * angle_step
roller_x = location[0] + math.cos(angle) * (wheel_radius * 0.8)
roller_y = location[1] + math.sin(angle) * (wheel_radius * 0.8)
roller_z = location[2]
bpy.ops.mesh.primitive_cylinder_add(
radius=wheel_radius * 0.12,
depth=wheel_thickness * 0.9,
location=(roller_x, roller_y, roller_z),
rotation=(rotation[0], rotation[1], rotation[2] + math.radians(roller_angle))
)
roller = bpy.context.active_object
roller.name = f"{{name}}_roller_{{i+1}}"
roller.parent = wheel_rim
roller.rotation_euler.z += angle
# Create main wheel object
bpy.ops.object.empty_add(location=location, rotation=rotation)
main_wheel = bpy.context.active_object
main_wheel.name = name
wheel_hub.parent = main_wheel
wheel_rim.parent = main_wheel
return main_wheel
# Step 1: Create main body
bpy.ops.mesh.primitive_cube_add(
size=1,
location=(0, 0, {dimensions["height"] / 2}),
scale=({dimensions["length"]}, {dimensions["width"]}, {dimensions["height"]})
)
body = bpy.context.active_object
body.name = "{robot_type}_body"
# Step 2: Add robot-specific features
if "{robot_type}" in ["scout", "scout_e"]:
# Add proper mecanum wheels
wheel_radius = 0.025
wheel_thickness = 0.015
wheel_positions = [
{wheel_positions[0]}, # Front-left
{wheel_positions[1]}, # Front-right
{wheel_positions[2]}, # Back-left
{wheel_positions[3]}, # Back-right
]
wheel_names = ["front_left", "front_right", "back_left", "back_right"]
wheel_names = ["front_left", "front_right", "back_left", "back_right"]
for i, (pos, name) in enumerate(zip(wheel_positions, wheel_names)):
# Alternate roller angles for proper mecanum movement
roller_angle = 45.0 if name in ["front_left", "back_right"] else -45.0
wheel = create_mecanum_wheel(
wheel_radius=wheel_radius,
wheel_thickness=wheel_thickness,
location=pos,
rotation=(math.radians(90), 0, 0), # Vertical orientation
roller_angle=roller_angle,
name="{robot_type}_wheel_" + name
)
# Add camera module
camera_size = 0.012
bpy.ops.mesh.primitive_cylinder_add(
radius=camera_size,
depth=camera_size * 0.8,
location=({dimensions["length"] / 2 + 0.008}, 0, {dimensions["height"] / 2})
)
camera = bpy.context.active_object
camera.name = "{robot_type}_camera"
# Add top mounting plate
bpy.ops.mesh.primitive_cube_add(
size=1,
location=(0, 0, {dimensions["height"] + 0.005}),
scale=({dimensions["length"] * 0.8}, {dimensions["width"] * 0.8}, 0.01)
)
plate = bpy.context.active_object
plate.name = "{robot_type}_mounting_plate"
# Add materials for visibility
# Body material (blue)
mat_body = bpy.data.materials.new(name="ScoutBody")
mat_body.use_nodes = True
mat_body.node_tree.nodes["Principled BSDF"].inputs[0].default_value = (0.2, 0.2, 0.8, 1.0)
body.data.materials.append(mat_body)
# Wheel material (dark gray)
mat_wheel = bpy.data.materials.new(name="ScoutWheel")
mat_wheel.use_nodes = True
mat_wheel.node_tree.nodes["Principled BSDF"].inputs[0].default_value = (0.1, 0.1, 0.1, 1.0)
for obj in bpy.data.objects:
if "wheel" in obj.name.lower() and obj.type == 'MESH':
obj.data.materials.append(mat_wheel)
# Camera material (yellow)
if "{robot_type}_camera" in bpy.data.objects:
mat_camera = bpy.data.materials.new(name="ScoutCamera")
mat_camera.use_nodes = True
mat_camera.node_tree.nodes["Principled BSDF"].inputs[0].default_value = (0.8, 0.8, 0.2, 1.0)
bpy.data.objects["{robot_type}_camera"].data.materials.append(mat_camera)
# Mounting plate material (gray)
mat_plate = bpy.data.materials.new(name="ScoutPlate")
mat_plate.use_nodes = True
mat_plate.node_tree.nodes["Principled BSDF"].inputs[0].default_value = (0.5, 0.5, 0.5, 1.0)
for obj in bpy.data.objects:
if "plate" in obj.name.lower() and obj.type == 'MESH':
obj.data.materials.append(mat_plate)
# Select all and frame view
bpy.ops.object.select_all(action='SELECT')
print(f"Created {len(bpy.data.objects)} objects:")
for obj in bpy.data.objects:
print(f" - {obj.name} at {obj.location}")
# Frame all objects in viewport (zoom to fit) - skip in background mode
try:
if bpy.context.screen and bpy.context.screen.areas:
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
override = bpy.context.copy()
override['area'] = area
override['region'] = area.regions[-1]
bpy.ops.view3d.view_all(override)
break
print("Framed all objects in viewport")
except Exception as e:
print(f"Note: Could not frame viewport (background mode): {str(e)}")
# Save .blend file (do this BEFORE any potential errors)
import os
blend_path = r"{str(Path(output_path).with_suffix(".blend"))}"
os.makedirs(os.path.dirname(blend_path), exist_ok=True)
try:
bpy.ops.wm.save_as_mainfile(filepath=blend_path)
print(f"SUCCESS: Saved .blend file: {blend_path}")
print(f"Objects saved: {len(bpy.data.objects)}")
except Exception as save_error:
print(f"ERROR saving blend file: {save_error}")
raise
"""
# Execute the creation script (creates objects AND saves blend file)
blend_path = Path(output_path).with_suffix(".blend")
blend_path_before = blend_path.exists()
blend_mtime_before = blend_path.stat().st_mtime if blend_path_before else 0
# Debug: Write script to file for inspection
debug_script_path = Path("D:/Models/create_scout_debug.py")
debug_script_path.parent.mkdir(parents=True, exist_ok=True)
debug_script_path.write_text(create_script)
logger.debug(f"Wrote debug script to {debug_script_path}")
try:
result = await executor.execute_script(
create_script, script_name="create_scout"
)
logger.info(
f"Created all {robot_type} objects and saved blend file in single script execution"
)
logger.debug(f"Script output: {result}")
except Exception as e:
# Check if blend file was created/updated despite the error (TBBmalloc warning)
error_str = str(e)
if blend_path.exists():
blend_mtime_after = blend_path.stat().st_mtime
if not blend_path_before or blend_mtime_after > blend_mtime_before:
logger.warning(
f"Blender exited with error but blend file was created/updated: {error_str}"
)
# Treat as success if file was created - TBBmalloc warning is harmless
elif "TBBmalloc" in error_str:
# TBBmalloc warning - check if file exists and is recent
logger.warning(
"TBBmalloc warning detected, but continuing if file exists"
)
else:
raise
elif "TBBmalloc" in error_str:
# TBBmalloc warning but file doesn't exist - script might have failed before save
logger.error(
f"TBBmalloc warning and no blend file created - script may have failed: {error_str}"
)
logger.error(
f"Debug script saved to {debug_script_path} for inspection"
)
raise
else:
logger.error(f"Script failed: {error_str}")
logger.error(
f"Debug script saved to {debug_script_path} for inspection"
)
raise
# Now use helper for any additional operations that need the mounted server
# Step 3: Create textures if requested
texture_paths = []
if create_textures and "gimp" in self.mounted_servers:
texture_paths = await self._create_textures_via_gimp(
robot_type, texture_style
)
logger.info(f"Created {len(texture_paths)} textures via GIMP")
# Step 4: Apply materials/textures (if created)
if texture_paths and "blender" in self.mounted_servers:
# Apply textures using blender-mcp material tools
for _texture_path in texture_paths:
await call_mounted_server_tool(
self.mounted_servers,
"blender",
"blender_materials",
{
"operation": "create_fabric",
"name": f"{robot_type}_material",
"base_color": [0.8, 0.8, 0.8],
},
)
# Step 5: Export model (this will also save .blend file)
if "blender" in self.mounted_servers:
export_result = await call_mounted_server_tool(
self.mounted_servers,
"blender",
"blender_export",
{
"operation": "export_unity",
"output_path": output_path,
},
)
logger.info(f"Exported {robot_type} model", result=export_result)
# Get blend file path
blend_path = str(Path(output_path).with_suffix(".blend"))
return format_success_response(
f"Created {robot_type} model",
data={
"robot_type": robot_type,
"output_path": output_path,
"blend_path": blend_path,
"format": format,
"dimensions": dimensions,
"textures_created": len(texture_paths)
if create_textures
else 0,
"texture_paths": texture_paths,
"note": f"Open {blend_path} in Blender to see the model",
},
)
except Exception as e:
logger.error(
"Blender model creation failed", robot_type=robot_type, error=str(e)
)
return format_error_response(
f"Model creation failed: {str(e)}", error_type="creation_error"
)
async def _create_textures_via_gimp(
self, robot_type: str, texture_style: str
) -> list[str]:
"""Create textures using gimp-mcp portmanteau tools."""
texture_paths = []
try:
# Create output directory
texture_dir = Path("D:/Textures")
texture_dir.mkdir(parents=True, exist_ok=True)
# For now, create a simple texture by:
# 1. Creating a base image (we'll use gimp_file with a temporary file)
# 2. Applying filters
# 3. Saving the result
# Create a temporary base image file first
import tempfile
temp_base = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
temp_base.close()
# Create base image using gimp_file (we'll need to create it first)
# For now, we'll create a simple colored image
# Note: gimp-mcp doesn't have a direct "create_image" operation
# We'll use gimp_transform to create a base, or skip texture creation
# and just return empty list for now
# Alternative: Use gimp_layer with create operation if available
# For simplicity, we'll create a placeholder texture file
logger.info(f"Creating texture for {robot_type} with style {texture_style}")
# Since gimp-mcp requires input files, we'll create a simple approach:
# Create a 1024x1024 solid color image using gimp_file operations
# This is a simplified approach - in production, you'd create the base image first
# For now, return empty list and log that textures need manual creation
logger.warning(
f"Texture creation for {robot_type} skipped - gimp-mcp requires input files. "
f"Create base texture manually or use gimp_layer/create if available."
)
except Exception as e:
logger.warning(
"GIMP texture creation failed", robot_type=robot_type, error=str(e)
)
# Continue without textures if GIMP fails
return texture_paths
async def _convert_via_blender(
self,
source_path: str,
source_format: str,
target_format: str,
target_path: str | None,
) -> dict[str, Any]:
"""Convert model using Blender MCP."""
try:
# Import source
if source_format == "fbx":
result = await call_mounted_server_tool(
self.mounted_servers,
"blender",
"blender_import",
{"filepath": source_path, "file_format": "fbx"},
)
elif source_format == "obj":
result = await call_mounted_server_tool(
self.mounted_servers,
"blender",
"blender_import",
{"filepath": source_path, "file_format": "obj"},
)
elif source_format == "blend":
await call_mounted_server_tool(
self.mounted_servers,
"blender",
"blender_open",
{"filepath": source_path},
)
else:
return format_error_response(
f"Unsupported source format: {source_format}",
error_type="validation_error",
)
# Export target
if target_format == "fbx":
await call_mounted_server_tool(
self.mounted_servers,
"blender",
"blender_export",
{"filepath": target_path, "file_format": "fbx"},
)
elif target_format == "glb":
await call_mounted_server_tool(
self.mounted_servers,
"blender",
"blender_export",
{"filepath": target_path, "file_format": "gltf"},
)
elif target_format == "vrm":
await call_mounted_server_tool(
self.mounted_servers,
"blender",
"blender_export",
{"filepath": target_path, "file_format": "vrm"},
)
else:
return format_error_response(
f"Unsupported target format: {target_format}",
error_type="validation_error",
)
return format_success_response(
f"Converted {source_format} to {target_format}",
data={
"source_path": source_path,
"target_path": target_path,
"formats": [source_format, target_format],
},
)
except Exception as e:
logger.error("Blender conversion failed", error=str(e))
return format_error_response(
f"Blender conversion failed: {str(e)}", error_type="conversion_error"
)
async def _handle_spz_check(self) -> dict[str, Any]:
"""Check available .spz conversion tools (from spz_converter.py)."""
import subprocess
tools_available = {
"adobe_spz_tools": False,
"python_spz_lib": False,
"manual_conversion": True,
}
try:
result = subprocess.run(
["spz-decompress", "--version"], capture_output=True, timeout=5
)
tools_available["adobe_spz_tools"] = result.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
try:
import importlib.util
tools_available["python_spz_lib"] = importlib.util.find_spec("spz") is not None
except ImportError:
pass
recommendations = []
if (
not tools_available["adobe_spz_tools"]
and not tools_available["python_spz_lib"]
):
recommendations.append(
"No .spz conversion tools found. Recommended: Re-export from Marble as .ply or .fbx/.glb"
)
return format_success_response(
"SPZ support check completed",
data={
"tools_available": tools_available,
"recommendations": recommendations,
"note": "There is NO official Unity plugin for .spz files. Conversion or re-export is required.",
},
)
async def _handle_spz_convert(
self, spz_path: str, output_path: str | None, output_format: str
) -> dict[str, Any]:
"""Convert .spz file to .ply or other format (from spz_converter.py)."""
import subprocess
from pathlib import Path
spz_file = Path(spz_path)
if not spz_file.exists():
return format_error_response(
f".spz file not found: {spz_path}", error_type="file_not_found"
)
if not output_path:
output_path = str(spz_file.with_suffix(f".{output_format}"))
output_file = Path(output_path)
try:
result = subprocess.run(
["spz-decompress", str(spz_file), str(output_file)],
capture_output=True,
timeout=60,
text=True,
)
if result.returncode == 0:
return format_success_response(
f"Converted .spz to .{output_format}",
data={
"input_file": str(spz_file),
"output_file": str(output_file),
"format": output_format,
"method": "adobe_spz_tools",
"file_size": output_file.stat().st_size
if output_file.exists()
else 0,
},
)
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
return format_error_response(
"No .spz conversion tools available",
error_type="conversion_unavailable",
details={
"input_file": str(spz_file),
"recommendations": [
"Re-export from Marble as .ply (for splats) or .fbx/.glb (for meshes)",
"Install Adobe spz-tools: https://github.com/adobe/spz",
],
},
)
async def _handle_spz_extract(self, spz_path: str) -> dict[str, Any]:
"""Extract metadata from .spz file (from spz_converter.py)."""
from pathlib import Path
spz_file = Path(spz_path)
if not spz_file.exists():
return format_error_response(
f".spz file not found: {spz_path}", error_type="file_not_found"
)
file_size = spz_file.stat().st_size
try:
with open(spz_file, "rb") as f:
header = f.read(16)
header_hex = header.hex()
header_ascii = "".join(
chr(b) if 32 <= b < 127 else "." for b in header[:8]
)
except Exception as e:
return format_error_response(
f"Failed to read .spz file: {e}", error_type="read_error"
)
return format_success_response(
"SPZ file info extracted",
data={
"file_path": str(spz_file),
"file_size": file_size,
"file_size_mb": round(file_size / (1024 * 1024), 2),
"header_hex": header_hex,
"header_ascii": header_ascii,
"format": "Adobe compressed Gaussian splat (.spz)",
"note": "Unity does not support .spz natively. Re-export from Marble as .ply or .fbx/.glb.",
},
)
async def _handle_spz_install(self, unity_project_path: str) -> dict[str, Any]:
"""Install Unity Gaussian Splatting plugin (from spz_converter.py)."""
import json
from pathlib import Path
project_path = Path(unity_project_path)
manifest_path = project_path / "Packages" / "manifest.json"
if not manifest_path.exists():
return format_error_response(
f"Not a valid Unity project: {manifest_path} not found",
error_type="invalid_project",
)
try:
with open(manifest_path) as f:
manifest = json.load(f)
dependencies = manifest.get("dependencies", {})
gs_package = "com.aras-p.gaussian-splatting"
if gs_package in dependencies:
return format_success_response(
"Gaussian Splatting plugin already installed",
data={
"unity_project": str(project_path),
"package": gs_package,
"version": dependencies[gs_package],
"note": "This plugin supports .ply files. Re-export from Marble as .ply to use it.",
},
)
dependencies[gs_package] = (
"https://github.com/aras-p/UnityGaussianSplatting.git?path=/package"
)
manifest["dependencies"] = dependencies
with open(manifest_path, "w") as f:
json.dump(manifest, f, indent=2)
return format_success_response(
"Gaussian Splatting plugin installed (alternative to .spz)",
data={
"unity_project": str(project_path),
"package": gs_package,
"source": "https://github.com/aras-p/UnityGaussianSplatting.git",
"next_steps": [
"Open Unity Editor - package will auto-download",
"Re-export from Marble as .ply (not .spz) to use this plugin",
],
},
)
except Exception as e:
logger.error(f"Failed to install Gaussian Splatting: {e}")
return format_error_response(
f"Failed to install plugin: {e}", error_type="installation_error"
)