Organize projects into folders with READMEs and preview renders.
Move the Ring solar siding adapter into its own directory, add repo and project documentation, and introduce a preview script (OpenSCAD+xvfb or PyVista fallback) for catalog images.
This commit is contained in:
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/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: <project>/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()
|
||||
Reference in New Issue
Block a user