"""model_3d — generate an STL file from an OpenSCAD script.
Requires OpenSCAD installed on the system:
Arch: sudo pacman -S openscad
Debian: sudo apt install openscad
"""
import asyncio
import shutil
from pathlib import Path
from navi.config import settings
from navi.session_files import session_dir
from .base import Tool, ToolResult, current_session_id
class Model3DTool(Tool):
name = "model_3d"
description = (
"Generate a binary STL file from an existing OpenSCAD (.scad) script. "
"The script must already be written to disk; this tool only compiles it.\n\n"
"Workflow:\n"
"1. Write your .scad script with the filesystem tool.\n"
"2. Call model_3d with the scad_path and desired output_path.\n"
"3. The resulting STL is saved to output_path.\n\n"
"Session files rule: if you use session_files/<session_id>/..., use the exact current "
"session directory from context or tool output. Do not reconstruct or retype session IDs. "
"Simple filenames without directories are resolved inside the current session directory.\n\n"
"Use content_publish on the STL to show the user an interactive 3D viewer."
)
parameters = {
"type": "object",
"properties": {
"scad_path": {
"type": "string",
"description": (
"Absolute or relative path to the existing .scad file. "
"The file must already exist."
),
},
"output_path": {
"type": "string",
"description": (
"Absolute or relative path where the STL should be written. "
"Parent directories are created automatically."
),
},
},
"required": ["scad_path", "output_path"],
}
async def execute(self, params: dict) -> ToolResult:
session_id = current_session_id.get()
scad_path = self._resolve_model_path(params["scad_path"], session_id)
output_path = self._resolve_model_path(params["output_path"], session_id)
session_error = self._validate_current_session_path(scad_path, "scad_path", session_id)
if session_error:
return session_error
session_error = self._validate_current_session_path(output_path, "output_path", session_id)
if session_error:
return session_error
if not scad_path.exists():
return ToolResult(
success=False,
output=f"SCAD file not found: {scad_path}",
error="scad_not_found",
)
if not scad_path.is_file():
return ToolResult(
success=False,
output=f"Path is not a file: {scad_path}",
error="not_a_file",
)
if not shutil.which("openscad"):
return ToolResult(
success=False,
output="OpenSCAD is not installed on this system.",
error="openscad_not_found",
)
output_path.parent.mkdir(parents=True, exist_ok=True)
proc = await asyncio.create_subprocess_exec(
"openscad",
"--export-format", "binstl",
"-o", str(output_path),
str(scad_path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
err = (stderr.decode(errors="replace") or "OpenSCAD exited with an error.").strip()
return ToolResult(
success=False,
output=f"OpenSCAD failed to compile STL:\n{err}",
error="openscad_compile_error",
)
if not output_path.exists():
return ToolResult(
success=False,
output="OpenSCAD completed but no STL file was produced.",
error="no_output",
)
size_kb = output_path.stat().st_size / 1024
return ToolResult(
success=True,
output=(
f"Generated: {output_path.name}\n"
f"Path: {output_path}\n"
f"Size: {size_kb:.1f} KB"
),
metadata={"output_path": str(output_path), "size_kb": round(size_kb, 1)},
)
@staticmethod
def _resolve_model_path(raw_path: str, session_id: str | None) -> Path:
path = Path(raw_path).expanduser()
if session_id and not path.is_absolute() and len(path.parts) == 1:
return (session_dir(session_id) / path).resolve()
return path.resolve()
@staticmethod
def _validate_current_session_path(
path: Path,
param_name: str,
session_id: str | None,
) -> ToolResult | None:
if not session_id:
return None
session_root = Path(settings.session_files_dir).resolve()
current_dir = session_dir(session_id).resolve()
if path.is_relative_to(session_root) and not path.is_relative_to(current_dir):
return ToolResult(
success=False,
output=(
f"{param_name} points to a different session directory: {path}\n"
f"Current session directory is: {current_dir}\n"
"Use the exact current session directory. Do not manually reconstruct "
"or retype session IDs."
),
error="wrong_session_dir",
)
return None