Skip to content

Commit e26fa54

Browse files
committed
Add snapshot CLI command for headless rendering
Implements solid <path> snapshot command that renders nodes to PNG images using OpenSCAD CLI. Features: - Animation time support (--time 0.0-1.0) - Camera controls (--camera, --autocenter, --viewall) - Image options (--imgsize, --projection, --colorscheme) - Render modes (--render, --preview) - View helpers (--view axes,edges,etc)
1 parent 91e4d7f commit e26fa54

2 files changed

Lines changed: 236 additions & 0 deletions

File tree

solid_node/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@
1818
import os
1919
from solid_node.manager.develop import Develop
2020
from solid_node.manager.test import Test
21+
from solid_node.manager.snapshot import Snapshot
2122

2223
commands = [
2324
Develop(),
2425
Test(),
26+
Snapshot(),
2527
]
2628

2729
def manage():

solid_node/manager/snapshot.py

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
# Solid Node - A framework for mechanical CAD projects
2+
# Copyright (C) 2023-2026 Luis Henrique Cassis Fagundes
3+
#
4+
# This program is free software: you can redistribute it and/or modify
5+
# it under the terms of the GNU Affero General Public License as published by
6+
# the Free Software Foundation, either version 3 of the License, or
7+
# (at your option) any later version.
8+
#
9+
# This program is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU Affero General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU Affero General Public License
15+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
17+
import sys
18+
import logging
19+
from subprocess import run, CalledProcessError
20+
from solid_node.core.loader import load_node
21+
22+
23+
logger = logging.getLogger('manager.snapshot')
24+
25+
26+
# OpenSCAD color schemes
27+
COLORSCHEMES = [
28+
'Cornfield', 'Metallic', 'Sunset', 'Starnight', 'BeforeDawn',
29+
'Nature', 'DeepOcean', 'Solarized', 'Tomorrow', 'Tomorrow Night', 'Monotone'
30+
]
31+
32+
# View helper options
33+
VIEW_OPTIONS = ['axes', 'crosshairs', 'edges', 'scales', 'wireframe']
34+
35+
36+
class Snapshot:
37+
"""Renders a node to a PNG image using OpenSCAD CLI.
38+
Enables AI agents to visually inspect their work without human intervention."""
39+
40+
def add_arguments(self, parser):
41+
# Output options
42+
parser.add_argument(
43+
'-o', '--output',
44+
type=str,
45+
default='snapshot.png',
46+
help='Output file path (default: snapshot.png)'
47+
)
48+
49+
# Animation time
50+
parser.add_argument(
51+
'--time',
52+
type=float,
53+
default=0.0,
54+
help='Animation time value for AssemblyNode (0.0 to 1.0, default: 0.0)'
55+
)
56+
57+
# Camera options
58+
parser.add_argument(
59+
'--camera',
60+
type=str,
61+
help='Camera specification in OpenSCAD format. '
62+
'Gimbal: translate_x,y,z,rot_x,y,z,dist or '
63+
'Vector: eye_x,y,z,center_x,y,z'
64+
)
65+
parser.add_argument(
66+
'--autocenter',
67+
action='store_true',
68+
help='Adjust camera to look at object center'
69+
)
70+
parser.add_argument(
71+
'--viewall',
72+
action='store_true',
73+
help='Adjust camera to fit object in view'
74+
)
75+
76+
# Image options
77+
parser.add_argument(
78+
'--imgsize',
79+
type=str,
80+
default='1920x1080',
81+
help='Image dimensions as WxH (default: 1920x1080)'
82+
)
83+
parser.add_argument(
84+
'--projection',
85+
type=str,
86+
choices=['ortho', 'perspective'],
87+
default='perspective',
88+
help='Projection mode (default: perspective)'
89+
)
90+
parser.add_argument(
91+
'--colorscheme',
92+
type=str,
93+
choices=COLORSCHEMES,
94+
default='Cornfield',
95+
help='Color scheme (default: Cornfield)'
96+
)
97+
98+
# Render mode
99+
parser.add_argument(
100+
'--render',
101+
action='store_true',
102+
default=True,
103+
help='Full geometry evaluation (default, slower but accurate)'
104+
)
105+
parser.add_argument(
106+
'--preview',
107+
action='store_true',
108+
help='ThrownTogether preview (faster, may show artifacts)'
109+
)
110+
111+
# View helpers
112+
parser.add_argument(
113+
'--view',
114+
type=str,
115+
help=f'Comma-separated view options: {", ".join(VIEW_OPTIONS)}'
116+
)
117+
118+
def handle(self, args):
119+
"""Main entry point for the snapshot command."""
120+
self.path = args.path
121+
self.output = args.output
122+
self.time = args.time
123+
124+
# Validate time parameter
125+
if not 0.0 <= self.time <= 1.0:
126+
sys.stderr.write(f"Error: --time must be between 0.0 and 1.0, got {self.time}\n")
127+
sys.exit(1)
128+
129+
# Validate view options if provided
130+
if args.view:
131+
view_items = [v.strip() for v in args.view.split(',')]
132+
invalid = [v for v in view_items if v not in VIEW_OPTIONS]
133+
if invalid:
134+
sys.stderr.write(f"Error: Invalid view options: {invalid}. "
135+
f"Valid options are: {VIEW_OPTIONS}\n")
136+
sys.exit(1)
137+
138+
# Validate imgsize format
139+
if not self._validate_imgsize(args.imgsize):
140+
sys.stderr.write(f"Error: Invalid --imgsize format '{args.imgsize}'. "
141+
f"Expected WxH (e.g., 1920x1080)\n")
142+
sys.exit(1)
143+
144+
# Load and prepare the node
145+
try:
146+
node = self._load_and_prepare_node()
147+
except Exception as e:
148+
sys.stderr.write(f"Error loading node: {e}\n")
149+
sys.exit(1)
150+
151+
# Build OpenSCAD command
152+
cmd = self._build_openscad_command(node, args)
153+
154+
# Execute OpenSCAD
155+
try:
156+
logger.info(f"Rendering {node.scad_file} to {self.output}")
157+
logger.debug(f"OpenSCAD command: {' '.join(cmd)}")
158+
result = run(cmd, check=True, capture_output=True, text=True)
159+
if result.stdout:
160+
logger.debug(result.stdout)
161+
print(f"Snapshot saved to {self.output}")
162+
except CalledProcessError as e:
163+
sys.stderr.write(f"OpenSCAD rendering failed:\n{e.stderr}\n")
164+
sys.exit(1)
165+
except FileNotFoundError:
166+
sys.stderr.write("Error: OpenSCAD not found in PATH. "
167+
"Please install OpenSCAD and ensure it is accessible.\n")
168+
sys.exit(1)
169+
170+
def _validate_imgsize(self, imgsize):
171+
"""Validate image size format (WxH)."""
172+
try:
173+
parts = imgsize.lower().split('x')
174+
if len(parts) != 2:
175+
return False
176+
width, height = int(parts[0]), int(parts[1])
177+
return width > 0 and height > 0
178+
except (ValueError, IndexError):
179+
return False
180+
181+
def _load_and_prepare_node(self):
182+
"""Load the node and prepare it for rendering."""
183+
node = load_node(self.path)
184+
185+
# Set animation time if this is an AssemblyNode
186+
# set_keyframe is a no-op for non-animated nodes
187+
node.set_keyframe(self.time)
188+
189+
# Generate the SCAD file
190+
node.assemble()
191+
192+
return node
193+
194+
def _build_openscad_command(self, node, args):
195+
"""Build the OpenSCAD CLI command."""
196+
cmd = ['openscad']
197+
198+
# Output file
199+
cmd.extend(['-o', self.output])
200+
201+
# Camera settings
202+
if args.camera:
203+
cmd.extend(['--camera', args.camera])
204+
if args.autocenter:
205+
cmd.append('--autocenter')
206+
if args.viewall:
207+
cmd.append('--viewall')
208+
209+
# Image settings - convert WxH to W,H format for OpenSCAD
210+
imgsize = args.imgsize.lower().replace('x', ',')
211+
cmd.extend(['--imgsize', imgsize])
212+
213+
# Projection
214+
if args.projection == 'ortho':
215+
cmd.extend(['--projection', 'o'])
216+
else:
217+
cmd.extend(['--projection', 'p'])
218+
219+
# Color scheme
220+
cmd.extend(['--colorscheme', args.colorscheme])
221+
222+
# Render mode
223+
if args.preview:
224+
cmd.append('--preview')
225+
# Note: --render is the default in OpenSCAD, no flag needed
226+
227+
# View helpers
228+
if args.view:
229+
cmd.extend(['--view', args.view])
230+
231+
# Input SCAD file
232+
cmd.append(node.scad_file)
233+
234+
return cmd

0 commit comments

Comments
 (0)