"""render_stl — render preview PNGs from an STL file using OpenSCAD."""
from __future__ import annotations
import asyncio
import shutil
from pathlib import Path
from .config import Settings
# 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
async def render_stl(
settings: Settings,
session_id: str,
source_path: str,
views: list[str] | None = None,
) -> dict:
"""Render preview PNGs from an STL file.
Returns a dict with:
- success (bool)
- output (str) — human-readable summary
- error (str | None)
- metadata (dict) — generated: list[str]
"""
if not shutil.which(settings.openscad):
return {
"success": False,
"output": "OpenSCAD is not installed on this system.",
"error": "openscad_not_found",
"metadata": {},
}
try:
source = settings.resolve_path(session_id, source_path)
except ValueError as exc:
return {
"success": False,
"output": str(exc),
"error": "invalid_path",
"metadata": {},
}
if not source.exists():
return {
"success": False,
"output": f"STL file not found: {source}",
"error": "stl_not_found",
"metadata": {},
}
if not source.is_file():
return {
"success": False,
"output": f"Path is not a file: {source}",
"error": "not_a_file",
"metadata": {},
}
requested = views or ["iso"]
if len(requested) > _MAX_VIEWS:
return {
"success": False,
"output": f"Too many views: {len(requested)} (max {_MAX_VIEWS}).",
"error": "too_many_views",
"metadata": {},
}
invalid = [v for v in requested if v not in _CAMERA_PRESETS]
if invalid:
return {
"success": False,
"output": (
f"Unknown views: {invalid}. "
f"Available: {list(_CAMERA_PRESETS.keys())}."
),
"error": "invalid_views",
"metadata": {},
}
generated: list[str] = []
errors: list[str] = []
for view in requested:
rot_x, rot_y, rot_z = _CAMERA_PRESETS[view]
out_png = source.with_suffix(f".{view}.png")
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(
settings.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()
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 {
"success": False,
"output": "All renders failed:\n" + "\n".join(errors),
"error": "render_failed",
"metadata": {},
}
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 {
"success": bool(generated),
"output": "\n".join(lines),
"error": None,
"metadata": {"generated": generated},
}