Skip to main content

unlit3d_cli/
cli.rs

1//! The command line the binary parses.
2
3use std::path::PathBuf;
4
5use argh::FromArgs;
6
7use crate::config::{AnimationRef, Config, DocumentConfig};
8
9/// The program name argh prints in its usage.
10const PROGRAM: &str = "unlit3d-cli";
11
12/// The command-line arguments.
13///
14/// Every option overrides the same-named field of the JSON configuration.
15#[derive(FromArgs, Debug, Clone, PartialEq)]
16#[argh(
17    help_triggers("-h", "--help"),
18    description = "render glTF documents to an image"
19)]
20pub struct Args {
21    /// a JSON configuration file. Command-line options override its fields.
22    #[argh(option)]
23    pub config: Option<PathBuf>,
24    /// serve the Model Context Protocol over stdio instead of rendering once.
25    /// The host starts from an empty offscreen world and the tool calls build
26    /// the scene; every other option is ignored.
27    #[argh(switch)]
28    pub mcp: bool,
29    /// the output image path; its extension chooses the format (png, webp or jpeg).
30    /// Required unless --mcp is given.
31    #[argh(option)]
32    pub output: Option<PathBuf>,
33    /// the output image size, as WxH.
34    #[argh(option, from_str_fn(parse_size))]
35    pub output_size: Option<(u32, u32)>,
36    /// the render size the camera and viewport are based on, as WxH.
37    #[argh(option, from_str_fn(parse_size))]
38    pub render_size: Option<(u32, u32)>,
39    /// the factors scaling the render size inside the output size, as S or SX,SY.
40    #[argh(option, from_str_fn(parse_scale))]
41    pub scale: Option<(f32, f32)>,
42    /// the multisample count: 1, 2, 4 or 8.
43    #[argh(option)]
44    pub samples: Option<u32>,
45    /// render without a depth buffer.
46    #[argh(switch)]
47    pub no_depth: bool,
48    /// the clear colour, as R,G,B or R,G,B,A, each in 0..=1.
49    #[argh(option, from_str_fn(parse_clear))]
50    pub clear: Option<[f64; 4]>,
51    /// the camera eye position, as X,Y,Z.
52    #[argh(option, from_str_fn(parse_vec3))]
53    pub eye: Option<[f32; 3]>,
54    /// the camera target position, as X,Y,Z.
55    #[argh(option, from_str_fn(parse_vec3))]
56    pub target: Option<[f32; 3]>,
57    /// the camera up vector, as X,Y,Z.
58    #[argh(option, from_str_fn(parse_vec3))]
59    pub up: Option<[f32; 3]>,
60    /// the vertical field of view in degrees.
61    #[argh(option)]
62    pub fov_y: Option<f32>,
63    /// the near plane distance.
64    #[argh(option)]
65    pub z_near: Option<f32>,
66    /// a view (world-to-view) matrix, column-major, as 16 comma-separated
67    /// numbers; give it together with --camera-projection-matrix.
68    #[argh(option, from_str_fn(parse_matrix))]
69    pub camera_view_matrix: Option<[f32; 16]>,
70    /// a projection (view-to-clip) matrix, column-major, as 16 comma-separated
71    /// numbers; give it together with --camera-view-matrix.
72    #[argh(option, from_str_fn(parse_matrix))]
73    pub camera_projection_matrix: Option<[f32; 16]>,
74    /// a glTF document to render; repeatable. Replaces the configuration list.
75    #[argh(option)]
76    pub document: Vec<PathBuf>,
77    /// the animation to play, by name or index; applies to every document.
78    #[argh(option)]
79    pub animation: Option<String>,
80    /// the animation time in seconds.
81    #[argh(option)]
82    pub time: Option<f32>,
83}
84
85/// What parse decided the process should do.
86#[derive(Debug, Clone, PartialEq)]
87pub enum Parsed {
88    /// Render with these arguments.
89    Run(Box<Args>),
90    /// Print this help text and exit successfully.
91    Help(String),
92}
93
94impl Args {
95    /// Read the configuration file, if any, and apply the overrides.
96    ///
97    /// # Errors
98    ///
99    /// Fails when the configuration file cannot be read or parsed.
100    pub fn config(&self) -> Result<Config, String> {
101        let mut config = match &self.config {
102            Some(path) => {
103                let text = std::fs::read_to_string(path)
104                    .map_err(|error| format!("cannot read {}: {error}", path.display()))?;
105                serde_json::from_str(&text)
106                    .map_err(|error| format!("cannot parse {}: {error}", path.display()))?
107            }
108            None => Config::default(),
109        };
110        self.apply(&mut config);
111        Ok(config)
112    }
113
114    /// Apply every command-line override to config.
115    fn apply(&self, config: &mut Config) {
116        if let Some(size) = self.output_size {
117            config.output.size = Some(size);
118        }
119        if let Some(size) = self.render_size {
120            config.output.render_size = Some(size);
121        }
122        if let Some(scale) = self.scale {
123            config.output.scale = scale;
124        }
125        if let Some(samples) = self.samples {
126            config.output.samples = samples;
127        }
128        if self.no_depth {
129            config.output.depth = false;
130        }
131        if let Some(clear) = self.clear {
132            config.output.clear = clear;
133        }
134        if let Some(eye) = self.eye {
135            config.camera.eye = eye;
136        }
137        if let Some(target) = self.target {
138            config.camera.target = target;
139        }
140        if let Some(up) = self.up {
141            config.camera.up = up;
142        }
143        if let Some(fov_y) = self.fov_y {
144            config.camera.fov_y = fov_y;
145        }
146        if let Some(z_near) = self.z_near {
147            config.camera.z_near = z_near;
148        }
149        let named_camera_field = self.eye.is_some()
150            || self.target.is_some()
151            || self.up.is_some()
152            || self.fov_y.is_some()
153            || self.z_near.is_some();
154        if self.camera_view_matrix.is_some() || self.camera_projection_matrix.is_some() {
155            config.camera.view = self.camera_view_matrix;
156            config.camera.projection = self.camera_projection_matrix;
157        } else if named_camera_field {
158            config.camera.view = None;
159            config.camera.projection = None;
160        }
161        let animation = self.animation.as_deref().map(parse_animation);
162        if self.document.is_empty() {
163            for document in &mut config.documents {
164                if let Some(animation) = &animation {
165                    document.animation = Some(animation.clone());
166                }
167                if let Some(time) = self.time {
168                    document.time = time;
169                }
170            }
171        } else {
172            config.documents = self
173                .document
174                .iter()
175                .map(|path| DocumentConfig {
176                    path: path.to_string_lossy().into_owned(),
177                    placement: None,
178                    animation: animation.clone(),
179                    time: self.time.unwrap_or(0.0),
180                })
181                .collect();
182        }
183    }
184}
185
186/// Parse the command line into the process's outcome.
187///
188/// # Errors
189///
190/// Returns the error text when the arguments are invalid.
191pub fn parse<I>(args: I) -> Result<Parsed, String>
192where
193    I: IntoIterator,
194    I::Item: Into<String>,
195{
196    let args: Vec<String> = args.into_iter().map(Into::into).collect();
197    let refs: Vec<&str> = args.iter().map(String::as_str).collect();
198    match Args::from_args(&[PROGRAM], &refs) {
199        Ok(parsed) if parsed.output.is_none() && !parsed.mcp => Err(String::from(
200            "--output is required unless --mcp is given, because a render needs somewhere to write the image",
201        )),
202        Ok(parsed) => Ok(Parsed::Run(Box::new(parsed))),
203        Err(exit) if exit.status.is_ok() => Ok(Parsed::Help(usage())),
204        Err(exit) => Err(exit.output),
205    }
206}
207
208/// The full usage text, as argh prints it.
209#[must_use]
210pub fn usage() -> String {
211    Args::from_args(&[PROGRAM], &["--help"])
212        .expect_err("--help exits early")
213        .output
214}
215
216/// Read an animation reference: a plain integer is an index, anything else a name.
217fn parse_animation(text: &str) -> AnimationRef {
218    text.parse::<usize>()
219        .map_or_else(|_| AnimationRef::Name(text.to_owned()), AnimationRef::Index)
220}
221
222/// Parse a WxH size.
223fn parse_size(text: &str) -> Result<(u32, u32), String> {
224    let invalid = || format!("{text} is not a WxH size");
225    let (width, height) = text.split_once(['x', 'X']).ok_or_else(invalid)?;
226    let width = width.trim().parse::<u32>().map_err(|_| invalid())?;
227    let height = height.trim().parse::<u32>().map_err(|_| invalid())?;
228    if width == 0 || height == 0 {
229        return Err(format!("{text} has a zero dimension"));
230    }
231    Ok((width, height))
232}
233
234/// Parse one or two comma-separated factors; one factor applies to both axes.
235fn parse_scale(text: &str) -> Result<(f32, f32), String> {
236    let values = parse_floats(text)?;
237    match values.as_slice() {
238        [scale] => Ok((*scale, *scale)),
239        [x, y] => Ok((*x, *y)),
240        values => Err(format!("{text} needs 1 or 2 numbers, got {}", values.len())),
241    }
242}
243
244/// Parse three comma-separated numbers.
245fn parse_vec3(text: &str) -> Result<[f32; 3], String> {
246    let values = parse_floats(text)?;
247    let count = values.len();
248    values
249        .try_into()
250        .map_err(|_| format!("{text} needs 3 numbers, got {count}"))
251}
252
253/// Parse sixteen comma-separated numbers.
254fn parse_matrix(text: &str) -> Result<[f32; 16], String> {
255    let values = parse_floats(text)?;
256    let count = values.len();
257    values
258        .try_into()
259        .map_err(|_| format!("{text} needs 16 numbers, got {count}"))
260}
261
262/// Parse a clear colour: three or four comma-separated numbers.
263fn parse_clear(text: &str) -> Result<[f64; 4], String> {
264    let values: Vec<f64> = text
265        .split(',')
266        .map(|value| {
267            value
268                .trim()
269                .parse::<f64>()
270                .map_err(|_| format!("{text} is not a comma-separated colour"))
271        })
272        .collect::<Result<_, _>>()?;
273    match values.len() {
274        3 => Ok([values[0], values[1], values[2], 1.0]),
275        4 => Ok([values[0], values[1], values[2], values[3]]),
276        count => Err(format!("{text} needs 3 or 4 numbers, got {count}")),
277    }
278}
279
280/// Parse a comma-separated list of numbers.
281fn parse_floats(text: &str) -> Result<Vec<f32>, String> {
282    text.split(',')
283        .map(|value| {
284            value
285                .trim()
286                .parse::<f32>()
287                .map_err(|_| format!("{text} is not a comma-separated number list"))
288        })
289        .collect()
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    fn args(arguments: &[&str]) -> Args {
297        Args::from_args(&[PROGRAM], arguments).expect("the arguments parse")
298    }
299
300    #[test]
301    fn the_output_path_is_required_unless_serving_mcp() {
302        assert!(parse(Vec::<String>::new()).is_err());
303        assert_eq!(
304            args(&["--output", "out.png"]).output,
305            Some(PathBuf::from("out.png"))
306        );
307        assert!(args(&["--mcp"]).mcp);
308        assert_eq!(args(&["--mcp"]).output, None);
309        assert!(parse(["--mcp"]).is_ok());
310    }
311
312    #[test]
313    fn sizes_are_parsed_and_rejected() {
314        assert_eq!(parse_size("256x192"), Ok((256, 192)));
315        assert_eq!(parse_size("256X192"), Ok((256, 192)));
316        assert!(parse_size("256").is_err());
317        assert!(parse_size("0x192").is_err());
318    }
319
320    #[test]
321    fn colours_accept_three_or_four_components() {
322        assert_eq!(parse_clear("1,0.5,0"), Ok([1.0, 0.5, 0.0, 1.0]));
323        assert_eq!(parse_clear("1,0.5,0,0.25"), Ok([1.0, 0.5, 0.0, 0.25]));
324        assert!(parse_clear("1,0.5").is_err());
325    }
326
327    #[test]
328    fn scale_accepts_one_or_two_factors() {
329        assert_eq!(parse_scale("2"), Ok((2.0, 2.0)));
330        assert_eq!(parse_scale("0.75,0.5"), Ok((0.75, 0.5)));
331        assert!(parse_scale("1,2,3").is_err());
332    }
333
334    #[test]
335    fn animation_references_distinguish_names_from_indices() {
336        assert_eq!(parse_animation("2"), AnimationRef::Index(2));
337        assert_eq!(
338            parse_animation("Walk"),
339            AnimationRef::Name("Walk".to_owned())
340        );
341    }
342
343    #[test]
344    fn command_line_options_override_the_configuration() {
345        let arguments = args(&[
346            "--output",
347            "out.png",
348            "--output-size",
349            "512x384",
350            "--scale",
351            "2,3",
352            "--eye",
353            "1,2,3",
354        ]);
355        let mut config = Config::default();
356        config.camera.view = Some([0.0; 16]);
357        config.camera.projection = Some([0.0; 16]);
358        arguments.apply(&mut config);
359        assert_eq!(config.output.size, Some((512, 384)));
360        assert_eq!(config.output.scale, (2.0, 3.0));
361        assert_eq!(config.camera.eye, [1.0, 2.0, 3.0]);
362        assert_eq!(config.camera.view, None);
363        assert_eq!(config.camera.projection, None);
364    }
365
366    #[test]
367    fn documents_replace_the_configuration_list() {
368        let arguments = args(&[
369            "--output",
370            "out.png",
371            "--document",
372            "a.glb",
373            "--time",
374            "1.5",
375        ]);
376        let mut config = Config::default();
377        config.documents.push(DocumentConfig {
378            path: "ignored.glb".to_owned(),
379            ..DocumentConfig::default()
380        });
381        arguments.apply(&mut config);
382        assert_eq!(config.documents.len(), 1);
383        assert_eq!(config.documents[0].path, "a.glb");
384        assert_eq!(config.documents[0].time, 1.5);
385    }
386}