1use std::path::PathBuf;
4
5use argh::FromArgs;
6
7use crate::config::{AnimationRef, Config, DocumentConfig};
8
9const PROGRAM: &str = "unlit3d-cli";
11
12#[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 #[argh(option)]
23 pub config: Option<PathBuf>,
24 #[argh(switch)]
28 pub mcp: bool,
29 #[argh(option)]
32 pub output: Option<PathBuf>,
33 #[argh(option, from_str_fn(parse_size))]
35 pub output_size: Option<(u32, u32)>,
36 #[argh(option, from_str_fn(parse_size))]
38 pub render_size: Option<(u32, u32)>,
39 #[argh(option, from_str_fn(parse_scale))]
41 pub scale: Option<(f32, f32)>,
42 #[argh(option)]
44 pub samples: Option<u32>,
45 #[argh(switch)]
47 pub no_depth: bool,
48 #[argh(option, from_str_fn(parse_clear))]
50 pub clear: Option<[f64; 4]>,
51 #[argh(option, from_str_fn(parse_vec3))]
53 pub eye: Option<[f32; 3]>,
54 #[argh(option, from_str_fn(parse_vec3))]
56 pub target: Option<[f32; 3]>,
57 #[argh(option, from_str_fn(parse_vec3))]
59 pub up: Option<[f32; 3]>,
60 #[argh(option)]
62 pub fov_y: Option<f32>,
63 #[argh(option)]
65 pub z_near: Option<f32>,
66 #[argh(option, from_str_fn(parse_matrix))]
69 pub camera_view_matrix: Option<[f32; 16]>,
70 #[argh(option, from_str_fn(parse_matrix))]
73 pub camera_projection_matrix: Option<[f32; 16]>,
74 #[argh(option)]
76 pub document: Vec<PathBuf>,
77 #[argh(option)]
79 pub animation: Option<String>,
80 #[argh(option)]
82 pub time: Option<f32>,
83}
84
85#[derive(Debug, Clone, PartialEq)]
87pub enum Parsed {
88 Run(Box<Args>),
90 Help(String),
92}
93
94impl Args {
95 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 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
186pub 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#[must_use]
210pub fn usage() -> String {
211 Args::from_args(&[PROGRAM], &["--help"])
212 .expect_err("--help exits early")
213 .output
214}
215
216fn parse_animation(text: &str) -> AnimationRef {
218 text.parse::<usize>()
219 .map_or_else(|_| AnimationRef::Name(text.to_owned()), AnimationRef::Index)
220}
221
222fn 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
234fn 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
244fn 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
253fn 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
262fn 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
280fn 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}