#!/usr/bin/env python3 """Render a preview PNG for an OpenSCAD project folder. Prefers OpenSCAD's native PNG export (needs a display or xvfb-run). Falls back to PyVista offscreen rendering from an exported STL (no display required). Usage: python3 scripts/render-preview.py ring-solar-siding-adapter python3 scripts/render-preview.py ring-solar-siding-adapter --output custom.png Dependencies (fallback path only): pip install pyvista sudo apt install xvfb # optional, enables native OpenSCAD PNG export """ from __future__ import annotations import argparse import shutil import subprocess import sys import tempfile from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] def find_scad(project_dir: Path) -> Path: matches = sorted(project_dir.glob("*.scad")) matches = [p for p in matches if not p.name.endswith(".backup.scad")] if len(matches) != 1: names = ", ".join(p.name for p in matches) or "(none)" raise SystemExit(f"Expected exactly one .scad in {project_dir}, found: {names}") return matches[0] def export_stl(scad: Path, stl: Path) -> None: cmd = ["openscad", "--export-format=binstl", "-o", str(stl), str(scad)] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: sys.stderr.write(result.stderr or result.stdout) raise SystemExit(f"openscad STL export failed ({result.returncode})") def render_openscad_png(scad: Path, png: Path, width: int, height: int) -> bool: """Return True if OpenSCAD wrote a PNG (requires display or xvfb-run).""" openscad = ["openscad", "--render", "--viewall", f"--imgsize={width},{height}"] xvfb = shutil.which("xvfb-run") cmd = ([xvfb, "-a"] if xvfb else []) + openscad + ["-o", str(png), str(scad)] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode == 0 and png.is_file() and png.stat().st_size > 0: return True if result.stderr: sys.stderr.write(result.stderr) return False def render_pyvista_png(stl: Path, png: Path, width: int, height: int) -> None: try: import pyvista as pv except ImportError as exc: raise SystemExit( "No display for OpenSCAD PNG export and PyVista is not installed.\n" "Install one of:\n" " sudo apt install xvfb # native OpenSCAD previews\n" " pip install pyvista # headless fallback" ) from exc pv.OFF_SCREEN = True mesh = pv.read(stl) plotter = pv.Plotter(off_screen=True, window_size=(width, height)) plotter.set_background("#1a2332") plotter.add_mesh(mesh, color="#6bb3dc", smooth_shading=True, show_edges=False) plotter.camera_position = "iso" png.parent.mkdir(parents=True, exist_ok=True) plotter.show(screenshot=str(png), auto_close=True) def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "project", type=Path, help="Project folder name or path (e.g. ring-solar-siding-adapter)", ) parser.add_argument( "--output", type=Path, help="Output PNG path (default: /preview.png)", ) parser.add_argument("--width", type=int, default=1200) parser.add_argument("--height", type=int, default=900) parser.add_argument( "--force-fallback", action="store_true", help="Skip OpenSCAD PNG and use PyVista STL render", ) args = parser.parse_args() project_dir = args.project if not project_dir.is_absolute(): project_dir = REPO_ROOT / project_dir project_dir = project_dir.resolve() if not project_dir.is_dir(): raise SystemExit(f"Project directory not found: {project_dir}") scad = find_scad(project_dir) png = args.output or (project_dir / "preview.png") if not png.is_absolute(): png = project_dir / png if not args.force_fallback and render_openscad_png(scad, png, args.width, args.height): print(f"Wrote {png} (OpenSCAD)") return with tempfile.TemporaryDirectory() as tmp: stl = Path(tmp) / "preview.stl" export_stl(scad, stl) render_pyvista_png(stl, png, args.width, args.height) print(f"Wrote {png} (PyVista fallback)") if __name__ == "__main__": main()