From 17e7554ff138021d6970b31179614dc8ebbc1971 Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 29 May 2026 02:15:17 -0300 Subject: [PATCH] Add `woodshop render ` for headless viewing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saves an off-screen PNG of the scene (labels, grid, isometric) so the model can be inspected without an interactive GUI window — useful over SSH or when woodshop-view can't open a display. 44 tests passing. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/woodshop/cli.py | 14 +++++++++++++- src/woodshop/viewer.py | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/woodshop/cli.py b/src/woodshop/cli.py index 5750394..f8287a7 100644 --- a/src/woodshop/cli.py +++ b/src/woodshop/cli.py @@ -146,6 +146,14 @@ def cmd_cutlist(scene: Scene, args) -> str: return format_cutlist(scene) +def cmd_render(scene: Scene, args) -> str: + from .viewer import render_to_file # lazy: pulls in pyvista + if not scene.parts: + return "Nothing to render — the scene is empty." + path = render_to_file(scene, args.path) + return f"Rendered {len(scene.parts)} part(s) to {path}." + + def _describe_part(p) -> str: bits = [f"{_fmt_len(p.length_in)} {p.stock}"] if p.name: @@ -257,6 +265,10 @@ def build_parser() -> argparse.ArgumentParser: sp.add_argument("path", help="Output file, e.g. table.stl or table.step") sp.set_defaults(func=cmd_export) + sp = sub.add_parser("render", help="Save a PNG image of the scene (works headless)") + sp.add_argument("path", help="Output image, e.g. table.png") + sp.set_defaults(func=cmd_render) + sub.add_parser("cutlist", help="Show the cut list / bill of materials").set_defaults(func=cmd_cutlist) sub.add_parser("undo", help="Undo the last operation").set_defaults(func=cmd_undo) sub.add_parser("clear", help="Clear the scene").set_defaults(func=cmd_clear) @@ -272,7 +284,7 @@ def main(argv: list[str] | None = None) -> int: except (SceneError, ValueError, KeyError) as exc: print(str(exc).strip('"'), file=sys.stderr) return 1 - if args.command not in ("status", "export", "cutlist", "save", "projects"): + if args.command not in ("status", "export", "cutlist", "render", "save", "projects"): scene.save(args.scene) print(message) return 0 diff --git a/src/woodshop/viewer.py b/src/woodshop/viewer.py index 6cb8d30..e132e43 100644 --- a/src/woodshop/viewer.py +++ b/src/woodshop/viewer.py @@ -77,6 +77,22 @@ def _render(plotter, scene: Scene) -> None: plotter.add_axes() +def render_to_file(scene: Scene, path, window_size=(1100, 800)) -> str: + """Render the scene to a PNG (off-screen) — works headless / over SSH.""" + import pyvista as pv + + _quiet_vtk() + pv.OFF_SCREEN = True + plotter = pv.Plotter(off_screen=True, window_size=window_size) + plotter.set_background("#2b2b2b") + plotter.enable_parallel_projection() + _render(plotter, scene) + plotter.view_isometric() + plotter.screenshot(str(path)) + plotter.close() + return str(path) + + def run(scene_path: Path | None = None, poll_s: float = 0.3) -> None: import pyvista as pv