Newer
Older
navi-1 / navi / tools / model_3d.py
"""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 .base import Tool, ToolResult


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"
        "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:
        if not shutil.which("openscad"):
            return ToolResult(
                success=False,
                output="OpenSCAD is not installed on this system.",
                error="openscad_not_found",
            )

        scad_path = Path(params["scad_path"]).expanduser().resolve()
        output_path = Path(params["output_path"]).expanduser().resolve()

        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",
            )

        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)},
        )