"""render_3d — render preview images from an STL file using OpenSCAD.
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
# Camera presets: (rot_x, rot_y, rot_z)
_CAMERA_PRESETS: dict[str, tuple[int, int, int]] = {
"front": (0, 0, 0),
"back": (0, 0, 180),
"top": (90, 0, 0),
"bottom": (-90, 0, 0),
"left": (0, 0, -90),
"right": (0, 0, 90),
"iso": (55, 0, 45),
}
_IMG_W, _IMG_H = 400, 300
_MAX_VIEWS = 3
class Render3DTool(Tool):
name = "render_3d"
description = (
"Render preview images from an STL file using OpenSCAD. "
"Produces one PNG per requested view.\n\n"
"Workflow:\n"
"1. Generate the STL with model_3d.\n"
"2. Call render_3d with the STL path and desired views.\n"
"3. PNG files are saved next to the STL with view suffixes.\n"
"4. Inspect PNG previews with image_view before publishing the final 3D model. "
"PNG previews are usually internal QA artifacts, not user-facing deliverables.\n\n"
"Available views: front, back, top, bottom, left, right, iso."
)
parameters = {
"type": "object",
"properties": {
"source": {
"type": "string",
"description": "Path to the STL file to render. Must exist.",
},
"views": {
"type": "array",
"items": {"type": "string", "enum": list(_CAMERA_PRESETS.keys())},
"description": (
"List of camera views to render. "
f"Maximum {_MAX_VIEWS} views per call. "
"Default: [\"iso\"]"
),
},
},
"required": ["source"],
}
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",
)
source = Path(params["source"]).expanduser().resolve()
if not source.exists():
return ToolResult(
success=False,
output=f"STL file not found: {source}",
error="stl_not_found",
)
if not source.is_file():
return ToolResult(
success=False,
output=f"Path is not a file: {source}",
error="not_a_file",
)
views = params.get("views") or ["iso"]
if len(views) > _MAX_VIEWS:
return ToolResult(
success=False,
output=f"Too many views: {len(views)} (max {_MAX_VIEWS}).",
error="too_many_views",
)
invalid = [v for v in views if v not in _CAMERA_PRESETS]
if invalid:
return ToolResult(
success=False,
output=f"Unknown views: {invalid}. Available: {list(_CAMERA_PRESETS.keys())}.",
error="invalid_views",
)
generated: list[str] = []
errors: list[str] = []
for view in views:
rot_x, rot_y, rot_z = _CAMERA_PRESETS[view]
out_png = source.with_suffix(f".{view}.png")
# Build a temporary SCAD that imports the STL
tmp_scad = source.with_suffix(f".{view}.tmp.scad")
tmp_scad.write_text(f'import("{source}");\n', encoding="utf-8")
proc = await asyncio.create_subprocess_exec(
"openscad",
"--camera", f"0,0,0,{rot_x},{rot_y},{rot_z},500",
"--autocenter",
"--viewall",
"--imgsize", f"{_IMG_W},{_IMG_H}",
"--preview",
"-o", str(out_png),
str(tmp_scad),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
# Clean up temp SCAD immediately
try:
tmp_scad.unlink()
except Exception:
pass
if proc.returncode != 0:
err = stderr.decode(errors="replace").strip() or "OpenSCAD render error"
errors.append(f"{view}: {err}")
continue
if out_png.exists():
generated.append(str(out_png))
else:
errors.append(f"{view}: no PNG produced")
if errors and not generated:
return ToolResult(
success=False,
output="All renders failed:\n" + "\n".join(errors),
error="render_failed",
)
lines = [f"Generated {len(generated)} image(s):"]
for p in generated:
lines.append(f" {Path(p).name}")
if errors:
lines.append("\nErrors:")
for e in errors:
lines.append(f" {e}")
return ToolResult(
success=bool(generated),
output="\n".join(lines),
metadata={"generated": generated},
)