Skip to content
Merged
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
38 changes: 38 additions & 0 deletions api/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"""

import asyncio
import json
import mimetypes
import httpx
from mcp.server import Server
Expand Down Expand Up @@ -83,6 +84,37 @@ async def list_tools() -> list[Tool]:
"required": ["job_id"],
},
),
Tool(
name="modly_inspect_mesh",
description=(
"Diagnose a mesh without modifying it: watertight, manifold, holes, "
"self-intersections, parts, dimensions, volume, UVs/normals, printability."
),
inputSchema={
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Workspace-relative path to the mesh (e.g. 'Default/mesh.glb').",
},
},
"required": ["path"],
},
),
Tool(
name="modly_suggest_fixes",
description="Inspect a mesh and list the operations (with params) that would fix its problems.",
inputSchema={
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Workspace-relative path to the mesh (e.g. 'Default/mesh.glb').",
},
},
"required": ["path"],
},
),
Tool(
name="modly_decimate_mesh",
description="Reduce the polygon count of a mesh using quadric edge collapse decimation.",
Expand Down Expand Up @@ -217,6 +249,12 @@ async def _dispatch(client: httpx.AsyncClient, name: str, args: dict) -> str:
parts.append(f"Error: {s['error']}")
return " | ".join(parts)

elif name in ("modly_inspect_mesh", "modly_suggest_fixes"):
op = name.removeprefix("modly_")
r = await client.post(f"{API_BASE}/optimize/op/{op}", json={"path": args["path"]})
r.raise_for_status()
return json.dumps(r.json(), indent=2)

elif name == "modly_decimate_mesh":
r = await client.post(
f"{API_BASE}/optimize/mesh",
Expand Down
89 changes: 89 additions & 0 deletions api/routers/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
- **list_models** — List all downloaded 3D generation models ready to use.
- **unload_models** — Unload all 3D generation models from GPU VRAM to free memory.
- **get_mesh_info** — Get info about the current mesh in the 3D viewer (path, triangle count).
- **inspect_mesh(path)** — Diagnose a mesh without modifying it: watertight, manifold, holes, self-intersections, parts, dimensions, volume, UVs/normals, and whether it is 3D-printable.
- **suggest_fixes(path)** — Inspect a mesh and list the operations (with params) that would fix its problems.
- **decimate_mesh(path, target_faces)** — Reduce the polygon count of a mesh.
- **smooth_mesh(path, iterations)** — Apply Laplacian smoothing to a mesh.
- **get_generation_status(job_id)** — Poll the status of an ongoing 3D generation job.
Expand All @@ -39,6 +41,7 @@

- Always use tools to act on the scene — never just describe what you would do.
- If you need the current mesh path, call get_mesh_info first.
- To answer questions about a mesh's quality or printability, call inspect_mesh — never guess. Before repairing a mesh, call suggest_fixes to pick the right operations.
- If you need to run a workflow but don't know the ID, call list_workflows first.
- To create a workflow, ONLY use extension ids listed under "Available extensions" in the context. Never invent an id. Chain steps so each step's input type matches the previous step's output type.
- For a workflow's input, `input_type` MUST be exactly one of: `image`, `text`, or `mesh`. These map to the Image, Text, and Load 3D Mesh nodes. Never invent another input. Pick the one matching the first step's expected input.
Expand Down Expand Up @@ -72,6 +75,40 @@
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "inspect_mesh",
"description": "Diagnose a mesh without modifying it: watertight, manifold, holes, self-intersections, disconnected parts, dimensions, volume, UVs/normals, and whether it is 3D-printable.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Workspace-relative path to the mesh file. Use get_mesh_info to obtain it.",
},
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "suggest_fixes",
"description": "Inspect a mesh and list the operations (with parameters) that would fix its problems. Does not modify the mesh.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Workspace-relative path to the mesh file. Use get_mesh_info to obtain it.",
},
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
Expand Down Expand Up @@ -259,6 +296,42 @@ def _build_workflow_graph(name: str, description: str, input_type: str, steps: l
return {"name": name, "description": description, "nodes": all_nodes, "edges": edges}


def _format_inspection(report: dict) -> str:
"""Render an inspect_mesh report as compact text for the model."""
def yes_no(value) -> str:
return "unknown" if value is None else ("yes" if value else "no")

dims = " × ".join(f"{d:.4g}" for d in report.get("dimensions", []))
volume = report.get("volume")
self_hits = report.get("self_intersecting_face_count")
lines = [
f"Printable: {yes_no(report.get('printable'))}",
f"Faces: {report.get('face_count', '?'):,}, vertices: {report.get('vertex_count', '?'):,}, parts: {report.get('component_count', '?')}",
f"Watertight: {yes_no(report.get('is_watertight'))}, manifold: {yes_no(report.get('is_manifold'))}, consistent winding: {yes_no(report.get('is_winding_consistent'))}",
f"Holes: {report.get('hole_count', 0)} ({report.get('boundary_edge_count', 0)} open edges), non-manifold edges: {report.get('non_manifold_edge_count', 0)}, degenerate faces: {report.get('degenerate_face_count', 0)}",
f"Self-intersecting faces: {'not checked' if self_hits is None else self_hits}",
f"Dimensions: {dims}, surface area: {report.get('surface_area', 0):.4g}, volume: {'n/a (not closed)' if volume is None else f'{volume:.4g}'}",
f"UVs: {yes_no(report.get('has_uvs'))}, normals: {yes_no(report.get('has_normals'))}",
]
issues = report.get("issues") or []
if issues:
lines.append("Issues: " + "; ".join(issues))
return "\n".join(lines)


def _format_fixes(data: dict) -> str:
"""Render a suggest_fixes result as compact text for the model."""
fixes = data.get("fixes") or []
notes = data.get("notes") or []
if not fixes and not notes:
return "No fixes needed — the mesh has no detected problems."
lines = ["Suggested operations:"] if fixes else ["No operation can fix the detected problems."]
for fix in fixes:
lines.append(f"- {fix['op']} {json.dumps(fix.get('params', {}))}: {fix.get('reason', '')}")
lines.extend(f"Note: {note}" for note in notes)
return "\n".join(lines)


async def execute_tool(name: str, arguments: dict, context: dict) -> tuple[str, dict | None]:
"""Execute a tool and return (result_text, action_payload).
action_payload carries data the frontend needs to react (e.g. new mesh URL).
Expand Down Expand Up @@ -289,6 +362,22 @@ async def execute_tool(name: str, arguments: dict, context: dict) -> tuple[str,
info += f" ({mesh_triangles:,} triangles)"
return info, None

elif name == "inspect_mesh":
r = await client.post(
f"{MODLY_API}/optimize/op/inspect_mesh",
json={"path": arguments["path"]},
)
r.raise_for_status()
return _format_inspection(r.json()), None

elif name == "suggest_fixes":
r = await client.post(
f"{MODLY_API}/optimize/op/suggest_fixes",
json={"path": arguments["path"]},
)
r.raise_for_status()
return _format_fixes(r.json()), None

elif name == "decimate_mesh":
r = await client.post(
f"{MODLY_API}/optimize/mesh",
Expand Down
4 changes: 4 additions & 0 deletions api/routers/optimize.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ def _run_operation(


def _operation_response(result: MeshOpResult) -> dict[str, object]:
if result.file_path is None:
# Analysis ops (inspect_mesh, suggest_fixes) return only their report.
return dict(result.details)

output_path = result.file_path.resolve()
try:
relative_path = output_path.relative_to(registry.WORKSPACE_DIR.resolve()).as_posix()
Expand Down
28 changes: 28 additions & 0 deletions api/services/mesh_ops/builtin.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Built-in mesh operation definitions."""

from .inspection import inspect_mesh, suggest_fixes
from .operations import decimate_mesh, repair_mesh, smooth_mesh
from .types import MeshOp

Expand Down Expand Up @@ -106,6 +107,19 @@
)


INSPECT_PARAMS = (
{
"id": "check_self_intersections",
"label": "Check Self-Intersections",
"type": "boolean",
"default": True,
"tooltip": (
"Detect intersecting faces. The slowest check on very dense meshes."
),
},
)


BUILTIN_MESH_OPS = (
MeshOp(
id="repair",
Expand All @@ -128,4 +142,18 @@
fn=smooth_mesh,
category="optimization",
),
MeshOp(
id="inspect_mesh",
label="Inspect Mesh",
params_schema=INSPECT_PARAMS,
fn=inspect_mesh,
category="analysis",
),
MeshOp(
id="suggest_fixes",
label="Suggest Fixes",
params_schema=INSPECT_PARAMS,
fn=suggest_fixes,
category="analysis",
),
)
Loading
Loading