Skip to main content

oxideav_cli_convert/
op.rs

1//! IM-style operations supported by `oxideav convert`.
2//!
3//! Each variant carries exactly the data the CLI needs to hand to
4//! [`crate::plan_to_job`], nothing more.
5
6/// Dither strategy used when `-colors N` forces a paletted output.
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
8#[non_exhaustive]
9pub enum Dither {
10    None,
11    Bayer,
12    FloydSteinberg,
13}
14
15impl Dither {
16    /// Parse the value after `-dither`. Accepts ImageMagick-ish names
17    /// case-insensitively (`None`, `FloydSteinberg`, `floyd_steinberg`,
18    /// `o8x8` / `ordered` → Bayer).
19    pub fn parse(s: &str) -> Result<Dither, String> {
20        match s.to_ascii_lowercase().as_str() {
21            "none" => Ok(Dither::None),
22            "floyd_steinberg" | "floyd-steinberg" | "floydsteinberg" | "fs" => {
23                Ok(Dither::FloydSteinberg)
24            }
25            "bayer" | "ordered" | "o8x8" => Ok(Dither::Bayer),
26            other => Err(format!(
27                "convert: -dither: unknown strategy '{other}' (expected 'none', 'bayer', or 'floyd_steinberg')"
28            )),
29        }
30    }
31}
32
33/// `-alpha SUBCOMMAND`. ImageMagick exposes a small grammar of alpha-channel
34/// edits — we cover the ones with deterministic semantics on RGBA buffers.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36#[non_exhaustive]
37pub enum AlphaOp {
38    /// `on` / `activate` — enable alpha (no-op when output is already RGBA).
39    On,
40    /// `off` / `deactivate` — strip the alpha channel from the output
41    /// (Rgba → Rgb24).
42    Off,
43    /// `remove` — composite the image over the current `-background`
44    /// colour and force every output pixel's alpha to `255`.
45    Remove,
46    /// `set` / `opaque` — set every alpha sample to `255` without
47    /// compositing.
48    Set,
49    /// `transparent` — set every alpha sample to `0` without touching
50    /// the colour channels.
51    Transparent,
52}
53
54impl AlphaOp {
55    pub fn parse(s: &str) -> Result<AlphaOp, String> {
56        match s.to_ascii_lowercase().as_str() {
57            "on" | "activate" => Ok(AlphaOp::On),
58            "off" | "deactivate" => Ok(AlphaOp::Off),
59            "remove" => Ok(AlphaOp::Remove),
60            "set" | "opaque" => Ok(AlphaOp::Set),
61            "transparent" => Ok(AlphaOp::Transparent),
62            other => Err(format!(
63                "convert: -alpha: unknown subcommand '{other}' (expected 'on'/'off'/'activate'/'deactivate'/'remove'/'set'/'opaque'/'transparent')"
64            )),
65        }
66    }
67}
68
69/// `-resize` / `-thumbnail` geometry-modifier suffix.
70///
71/// ImageMagick's geometry grammar tags the literal `WxH` with a single
72/// trailing character that selects the scaling policy. We honour the
73/// six common ones; everything else (`%@` combos, gravity flags, …)
74/// is a documented follow-up.
75///
76/// The runtime sees the original `WxH` plus this tag and resolves the
77/// final pixel dimensions against the actual source size — most modes
78/// can't be eagerly resolved at parse time because we don't yet know
79/// the source dims.
80#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
81#[non_exhaustive]
82pub enum ResizeMode {
83    /// No suffix — preserve aspect ratio and FIT inside the `WxH` box
84    /// (the larger of the two scale factors wins, so neither output
85    /// dimension exceeds the request). IM's default geometry policy.
86    #[default]
87    Default,
88    /// `WxH!` — IGNORE aspect ratio, force the exact `WxH` dimensions.
89    /// Equivalent to the original `bang = true`.
90    Force,
91    /// `WxH^` — preserve aspect ratio and FILL the `WxH` box (the
92    /// smaller of the two scale factors wins, so both output
93    /// dimensions are at least the request). Common pairing with a
94    /// follow-up `-crop` to land on exact `WxH`.
95    Fill,
96    /// `WxH>` — only resize when the input is LARGER than `WxH`
97    /// (otherwise pass through unchanged). IM's "shrink-only" policy.
98    Shrink,
99    /// `WxH<` — only resize when the input is SMALLER than `WxH`
100    /// (otherwise pass through unchanged). IM's "enlarge-only" policy.
101    Grow,
102    /// `WxH%` — interpret `W` and `H` as percentages of the source
103    /// dimensions (independent X / Y scale). `WxH` are still parsed as
104    /// integer percentages by `parse_wxh`; if only one number is given
105    /// it applies to both axes (handled at parse time).
106    Percent,
107    /// `WxH@` — interpret `W*H` as the TARGET pixel area; both output
108    /// dims are scaled by `sqrt(target_area / source_area)` so the
109    /// aspect ratio is preserved AND the output area matches the
110    /// request.
111    Area,
112}
113
114impl ResizeMode {
115    /// Strip an IM geometry-modifier suffix off `WxH`.
116    /// Returns `(mode, geometry-without-suffix)`. No suffix means
117    /// `Default` plus the input verbatim. Multiple suffix chars
118    /// (e.g. `WxH^!`) are rejected by the args parser layer; here we
119    /// only peel the LAST one and report what we found.
120    pub fn split_suffix(s: &str) -> (ResizeMode, &str) {
121        if let Some(core) = s.strip_suffix('!') {
122            return (ResizeMode::Force, core);
123        }
124        if let Some(core) = s.strip_suffix('^') {
125            return (ResizeMode::Fill, core);
126        }
127        if let Some(core) = s.strip_suffix('>') {
128            return (ResizeMode::Shrink, core);
129        }
130        if let Some(core) = s.strip_suffix('<') {
131            return (ResizeMode::Grow, core);
132        }
133        if let Some(core) = s.strip_suffix('%') {
134            return (ResizeMode::Percent, core);
135        }
136        if let Some(core) = s.strip_suffix('@') {
137            return (ResizeMode::Area, core);
138        }
139        (ResizeMode::Default, s)
140    }
141
142    /// Lowercase tag string used in JSON params handed to the resize
143    /// factory. Stable identifiers — codecs that grow geometry-mode
144    /// awareness can branch off these.
145    pub fn as_tag(self) -> &'static str {
146        match self {
147            ResizeMode::Default => "default",
148            ResizeMode::Force => "force",
149            ResizeMode::Fill => "fill",
150            ResizeMode::Shrink => "shrink",
151            ResizeMode::Grow => "grow",
152            ResizeMode::Percent => "percent",
153            ResizeMode::Area => "area",
154        }
155    }
156
157    /// Resolve the final `(out_w, out_h)` for a request of `(req_w,
158    /// req_h)` against a source size of `(src_w, src_h)`.
159    ///
160    /// Output is always >= 1 on both axes; clipping to source dims for
161    /// `Shrink` / `Grow` modes happens here so the caller can blindly
162    /// hand the result to the resize filter without re-checking.
163    pub fn resolve(self, req_w: u32, req_h: u32, src_w: u32, src_h: u32) -> (u32, u32) {
164        let sw = src_w.max(1) as f64;
165        let sh = src_h.max(1) as f64;
166        let rw = req_w.max(1) as f64;
167        let rh = req_h.max(1) as f64;
168        let (out_w, out_h) = match self {
169            ResizeMode::Default => {
170                // Aspect-preserving fit: pick the smaller scale.
171                let s = (rw / sw).min(rh / sh);
172                ((sw * s).round(), (sh * s).round())
173            }
174            ResizeMode::Force => (rw, rh),
175            ResizeMode::Fill => {
176                let s = (rw / sw).max(rh / sh);
177                ((sw * s).round(), (sh * s).round())
178            }
179            ResizeMode::Shrink => {
180                if (src_w <= req_w) && (src_h <= req_h) {
181                    (sw, sh)
182                } else {
183                    let s = (rw / sw).min(rh / sh);
184                    ((sw * s).round(), (sh * s).round())
185                }
186            }
187            ResizeMode::Grow => {
188                if (src_w >= req_w) && (src_h >= req_h) {
189                    (sw, sh)
190                } else {
191                    let s = (rw / sw).min(rh / sh);
192                    ((sw * s).round(), (sh * s).round())
193                }
194            }
195            ResizeMode::Percent => {
196                // req_w / req_h are percentages: 50 → half, 200 → double.
197                ((sw * rw / 100.0).round(), (sh * rh / 100.0).round())
198            }
199            ResizeMode::Area => {
200                let target_area = rw * rh;
201                let src_area = sw * sh;
202                let s = (target_area / src_area).sqrt();
203                ((sw * s).round(), (sh * s).round())
204            }
205        };
206        ((out_w as i64).max(1) as u32, (out_h as i64).max(1) as u32)
207    }
208}
209
210/// One convert operation.
211///
212/// Operations apply in source order — same as `imagemagick convert`,
213/// even though we don't yet support IM's stack-reset semantics.
214#[derive(Clone, Debug, PartialEq)]
215#[non_exhaustive]
216pub enum Op {
217    /// `-resize WxH[!^<>%@]`. The trailing modifier selects the
218    /// scaling policy via [`ResizeMode`]; absence of any modifier is
219    /// `ResizeMode::Default` (aspect-preserving fit-inside).
220    Resize {
221        width: u32,
222        height: u32,
223        mode: ResizeMode,
224    },
225    /// `-thumbnail WxH[!^<>%@]` — IM convenience flag. Same geometry
226    /// grammar as `-resize`; the runner unrolls it into a Resize plus
227    /// Strip pair (and, eventually, an auto-orient pass) so the
228    /// downstream pipeline doesn't need a dedicated thumbnail node.
229    Thumbnail {
230        width: u32,
231        height: u32,
232        mode: ResizeMode,
233    },
234    /// `-define KEY[=VALUE]` — opaque codec-specific tunable forwarded
235    /// to the sink. Keys keep their literal form (e.g.
236    /// `jpeg:dct-method`); the sink encoder picks up keys it
237    /// recognises and silently ignores the rest, mirroring IM's
238    /// "tolerant of irrelevant options" posture. Bare `-define KEY`
239    /// (no `=VALUE`) sets the key to JSON `true`.
240    Define { key: String, value: Option<String> },
241    /// `-blur RxS`. Sigma defaults to `radius / 2.0` when the `xS`
242    /// portion is omitted (matches IM's convention).
243    Blur { radius: u32, sigma: f32 },
244    /// `-edge R`. Radius ignored by the current Sobel impl but parsed
245    /// for forward-compatibility.
246    Edge { radius: u32 },
247    /// `-colors N` paired with an optional `-dither` preceding it.
248    /// When present, the output is paletted via
249    /// [`oxideav_pixfmt`] before encoding.
250    Colors { count: u32, dither: Dither },
251    /// `-format FMT` — override the container/codec decision
252    /// otherwise derived from the output extension.
253    Format(String),
254    /// `-quality N` — forwarded to the sink codec (e.g. JPEG quality,
255    /// WebP quality). Silently dropped by codecs that don't honour it.
256    Quality(u32),
257    /// `-strip` — request that metadata (EXIF, XMP, ID3, etc.) be
258    /// dropped on write.
259    Strip,
260    /// `-depth N` — output bit depth per channel for still-image
261    /// sinks (`8` or `16`). Caps the encoder input layout the planner
262    /// picks (a 10-bit HEIC → `-depth 8` → 8-bit PNG) or asks for the
263    /// deeper layout (`-depth 16`). Without it the source's own depth
264    /// is kept when the encoder can carry it.
265    Depth(u8),
266    /// `--opt KEY=VALUE` — an encoder option for still-image sinks,
267    /// validated against the target encoder's declared options schema
268    /// (the keys `oxideav info <codec>` lists) and handed to the
269    /// encoder `convert` builds for the output. Repeatable; a later
270    /// value for the same key wins.
271    EncoderOpt { key: String, value: String },
272    /// `-threads N` — thread budget for the pipeline route (the job's
273    /// `threads` key: codec-internal fan-out and the frame-tap
274    /// encoder). `0` means auto (the host's parallelism, the default).
275    Threads(usize),
276    /// `-density N` — DPI for vector→raster conversion. PDF / SVG
277    /// pages are measured in PostScript points (1/72 inch); a page at
278    /// 300 DPI rasterises to `points × (300 / 72)` pixels per axis.
279    /// Ignored on raster-only inputs.
280    Density(u32),
281    /// `-background COLOR` — canvas fill colour used by the
282    /// rasteriser and by `-alpha remove`. CSS L3 named colours and
283    /// `#hex` 3/4/6/8 forms accepted (same parser as `xc:`).
284    Background([u8; 4]),
285    /// `-alpha SUBCOMMAND` — see [`AlphaOp`].
286    Alpha(AlphaOp),
287    /// `-rotate N` — rotate by N degrees. Round-1 supports only
288    /// multiples of 90 (90/180/270 and the negatives). Other angles
289    /// are rejected by the args parser with a "only multiples of 90
290    /// supported" message.
291    Rotate { degrees: i32 },
292    /// `-flip` — vertical flip (reverse row order).
293    Flip,
294    /// `-flop` — horizontal flip (reverse column order within each row).
295    Flop,
296    /// `-crop WxH+X+Y` — extract a `WxH` bounding box at offset `(X,Y)`.
297    /// The args parser accepts the IM grammar; runtime checks that the
298    /// bbox fits inside the input dimensions and errors cleanly when
299    /// it doesn't.
300    Crop { x: u32, y: u32, w: u32, h: u32 },
301    /// `-negate` — per-pixel `out = 255 - in` on the colour channels;
302    /// alpha (when present) is unchanged.
303    Negate,
304    /// `-sharpen RxS` — unsharp-mask sharpening with radius / sigma
305    /// (amount defaults to 1.0 in the factory). Sigma defaults to
306    /// `radius / 2.0` when only `R` is given.
307    Sharpen { radius: u32, sigma: f32 },
308    /// `-unsharp RxS+amount+threshold` — full unsharp-mask grammar.
309    /// Amount and threshold each default to a sensible value when
310    /// omitted (`amount = 1.0`, `threshold = 0`).
311    Unsharp {
312        radius: u32,
313        sigma: f32,
314        amount: f32,
315        threshold: u8,
316    },
317    /// `-gamma G` — power-law gamma correction. `G > 0`.
318    Gamma { value: f32 },
319    /// `-brightness-contrast B[,C]` — brightness in `[-100..=100]`,
320    /// contrast in `[-100..=100]` (both percent-of-range). Either
321    /// argument may be omitted; the IM grammar tolerates `B`,
322    /// `Bx`, `BxC`, `B,C`.
323    BrightnessContrast { brightness: f32, contrast: f32 },
324    /// `-contrast` (no value) — IM applies a tiny per-channel contrast
325    /// step. Multiple `-contrast` flags accumulate; we collapse them
326    /// into a single delta carried on the op (positive for `-contrast`,
327    /// negative for `+contrast`, which IM uses for the inverse). The
328    /// factory wires this to `BrightnessContrast::new(0, 5*delta)` —
329    /// a 5-percent step matching IM's "single contrast bump" feel.
330    Contrast { delta: i32 },
331    /// `-sepia THRESHOLD%` — warm-tint mapping. `threshold` is a
332    /// scalar in `0.0..=1.0` (IM expresses it as a percentage of the
333    /// dynamic range; we accept either form on parse).
334    Sepia { threshold: f32 },
335    /// `-modulate B,S,H` — IM's HSL-style triplet. Each component is
336    /// percent-of-base around 100 (so `100,100,0` is identity, `200`
337    /// doubles, `0` zeros, etc.). The hue field is "hue offset
338    /// percent" but image-filter takes degrees; we translate.
339    Modulate {
340        brightness: f32,
341        saturation: f32,
342        hue: f32,
343    },
344    /// `-level B/G/W` — input black point, gamma, white point. Black
345    /// and white are 0..=255; gamma must be `> 0`.
346    Level { black: u8, gamma: f32, white: u8 },
347    /// `-normalize` — stretch the histogram to fill 0..=255.
348    Normalize,
349    /// `-threshold N%` — binarise: pixels below `value` map to 0,
350    /// pixels at or above map to 255.
351    Threshold { value: u8 },
352    /// `-posterize N` — collapse to `N` levels per channel.
353    Posterize { levels: u32 },
354    /// `-solarize N%` — invert pixels above the threshold.
355    Solarize { value: u8 },
356    /// `-colorspace gray|grey|rgb|srgb` — round-1 covers only the
357    /// grayscale conversion (everything else is treated as a
358    /// pass-through, keeping the input colourspace).
359    Colorspace(String),
360    /// `-vignette R+S+X+Y` — Gaussian radial darkening centred at
361    /// `(x, y)`. `radius`/`sigma` are in pixels (`sigma` defaults to
362    /// `radius / 2.0` when omitted, matching IM); `x`/`y` are
363    /// **normalised** image-relative offsets in `[0.0, 1.0]` (the
364    /// image-filter factory takes them this way to stay
365    /// resolution-independent), defaulting to `0.5 / 0.5` (image
366    /// centre) when omitted.
367    Vignette {
368        radius: f32,
369        sigma: f32,
370        x: f32,
371        y: f32,
372    },
373    /// `-colorize C[xC[xC]]/A%` — linear blend toward a target colour
374    /// by `amount`. `color` is `[R, G, B, A]` (alpha defaults to
375    /// `255`); `amount` is a unit scalar in `[0.0, 1.0]` (IM expresses
376    /// it as a percentage of the dynamic range).
377    Colorize { color: [u8; 4], amount: f32 },
378    /// `-equalize` (no value) — per-channel histogram equalisation via
379    /// CDF mapping.
380    Equalize,
381    /// `-auto-gamma` (no value) — auto-gamma: pick a per-channel gamma
382    /// so the geometric mean lands at `0.5`.
383    AutoGamma,
384    /// `-extent WxH[+X+Y]` — re-window the input onto a fixed `WxH`
385    /// canvas. The source is placed at signed offset `(x, y)` (default
386    /// `(0, 0)`, IM's top-left convention); pixels outside the source
387    /// rectangle are painted with `bg` (RGBA). Offsets may be negative
388    /// (`-extent 100x100-10-10`) to translate the source toward the
389    /// upper-left so its right / bottom edge can land inside the
390    /// window. Unlike `Crop` (only shrinks) and `Resize` (only scales),
391    /// `Extent` can both add canvas and discard content depending on
392    /// the requested geometry.
393    ///
394    /// `bg` is captured at args-parse time from the most recent
395    /// `-background` op seen in source order (defaulting to IM's
396    /// opaque white `[255, 255, 255, 255]` when none was set). Pinning
397    /// the colour onto the op preserves source-order semantics — a
398    /// later `-background` does NOT retroactively repaint an earlier
399    /// `-extent`'s padding.
400    Extent {
401        width: u32,
402        height: u32,
403        x: i32,
404        y: i32,
405        bg: [u8; 4],
406    },
407    /// `-trim` — auto-crop to the bounding box of pixels that differ
408    /// from a reference background colour by more than `fuzz` per
409    /// channel. The reference colour is the input's `(0, 0)` pixel
410    /// (matching IM's behaviour when no `-bordercolor`/`-background`
411    /// override is set). `fuzz` defaults to `0` (exact match); a
412    /// preceding `-fuzz N[%]` flag updates the value carried on this
413    /// op at args-parse time, so source order is preserved (`-fuzz 10
414    /// -trim -fuzz 0 -trim` lands as `Trim { fuzz: 10 }, Trim { fuzz:
415    /// 0 }`).
416    Trim { fuzz: u8 },
417    /// `-roll ±X±Y` — circular pixel shift. `dx` shifts columns
418    /// to the right by `dx` pixels (negative = left); `dy` shifts
419    /// rows down by `dy` pixels (negative = up). Pixels that fall
420    /// off one edge wrap around to the opposite edge — width and
421    /// height stay unchanged, and the total pixel count is
422    /// preserved. The IM grammar requires both X and Y signed
423    /// (`-roll +5+10`, `-roll -3+2`); a single offset (`-roll +5`)
424    /// is rejected at parse time.
425    Roll { dx: i32, dy: i32 },
426}
427
428/// A printf-style multi-output template. Detected by the args parser
429/// when the output filename contains `%[0-9]*d`. Used by Scene-shaped
430/// inputs (PDF) to fan out one file per page.
431///
432/// Examples:
433///   `page-%03d.png` → prefix=`page-`, width=3, suffix=`.png` →
434///   `page-000.png`, `page-001.png`, …
435///   `out%d.jpg`     → prefix=`out`, width=0, suffix=`.jpg` →
436///   `out0.jpg`, `out1.jpg`, …
437#[derive(Clone, Debug, PartialEq, Eq)]
438#[non_exhaustive]
439pub struct PrintfTemplate {
440    pub prefix: String,
441    pub width: u8,
442    pub suffix: String,
443}
444
445impl PrintfTemplate {
446    /// `prefix` + zero-padded index of `width` digits (`0` = unpadded)
447    /// + `suffix` — the parsed form of `prefix%0<width>dsuffix`.
448    pub fn new(prefix: impl Into<String>, width: u8, suffix: impl Into<String>) -> Self {
449        Self {
450            prefix: prefix.into(),
451            width,
452            suffix: suffix.into(),
453        }
454    }
455
456    /// Render the template at the given index.
457    pub fn expand(&self, n: usize) -> String {
458        if self.width == 0 {
459            format!("{}{}{}", self.prefix, n, self.suffix)
460        } else {
461            format!(
462                "{}{:0>width$}{}",
463                self.prefix,
464                n,
465                self.suffix,
466                width = self.width as usize
467            )
468        }
469    }
470}
471
472/// One atomic spec inside a [`PageSelector`].
473///
474/// A single index (`5`, `-1`) or an inclusive range (`2-5`, `5--1`).
475/// Indices are signed so the IM convention `-1` = last page,
476/// `-2` = second-to-last, … can ride through unchanged. Resolution
477/// against an actual page count is `PageAtom::resolve`.
478#[derive(Clone, Debug, PartialEq, Eq)]
479#[non_exhaustive]
480pub enum PageAtom {
481    /// Single page index. Negative values count from the end:
482    /// `-1` = last page, `-2` = second-to-last, ….
483    Single(isize),
484    /// Inclusive range `start..=end`. Either endpoint may be negative
485    /// (`5..=-1` = "page 5 through the last page"). After resolution
486    /// against the actual page count, `start <= end` is required.
487    Range(isize, isize),
488}
489
490impl PageAtom {
491    /// Resolve this atom against a known `total_pages` count, appending
492    /// the resulting zero-based indices to `out` in IM-source order
493    /// (range left-to-right). Negative indices `-k` map to
494    /// `total_pages - k`.
495    fn resolve_into(&self, total_pages: usize, out: &mut Vec<usize>) -> Result<(), String> {
496        match self {
497            PageAtom::Single(n) => {
498                let idx = resolve_signed_index(*n, total_pages)?;
499                out.push(idx);
500                Ok(())
501            }
502            PageAtom::Range(a, b) => {
503                let aa = resolve_signed_index(*a, total_pages)?;
504                let bb = resolve_signed_index(*b, total_pages)?;
505                if aa > bb {
506                    return Err(format!(
507                        "page range [{a}-{b}] resolves to inverted [{aa}-{bb}] (start > end after negative-index resolution)"
508                    ));
509                }
510                out.extend(aa..=bb);
511                Ok(())
512            }
513        }
514    }
515}
516
517fn resolve_signed_index(n: isize, total_pages: usize) -> Result<usize, String> {
518    if n >= 0 {
519        let u = n as usize;
520        if u >= total_pages {
521            Err(format!(
522                "page index {n} out of range (input has {total_pages} page(s))"
523            ))
524        } else {
525            Ok(u)
526        }
527    } else {
528        // Negative — count from the end. `-1` = last page.
529        let off = (-n) as usize;
530        if off > total_pages || off == 0 {
531            Err(format!(
532                "negative page index {n} out of range (input has {total_pages} page(s); valid: -1..=-{total_pages})"
533            ))
534        } else {
535            Ok(total_pages - off)
536        }
537    }
538}
539
540/// ImageMagick-style `[N]` / `[N-M]` / `[A,B,…]` page-selection suffix
541/// on an input path. `input.pdf[0]` selects page 0; `input.pdf[2-5]`
542/// selects pages 2, 3, 4, 5 (inclusive on both ends, like IM);
543/// `input.pdf[-1]` selects the last page; `input.pdf[0,2,4]` selects a
544/// comma-separated list (atoms may themselves be ranges, so
545/// `[0-2,5,-1]` picks pages 0, 1, 2, 5, and the last page).
546///
547/// The single-atom variants ([`Single`](Self::Single) /
548/// [`Range`](Self::Range)) are preserved for ergonomics — most callers
549/// only care about the one-atom case — but every comma-separated
550/// invocation lands as a [`List`](Self::List).
551#[derive(Clone, Debug, PartialEq, Eq)]
552#[non_exhaustive]
553pub enum PageSelector {
554    /// Single page index. Negative values count from the end
555    /// (`-1` = last page).
556    Single(isize),
557    /// Inclusive range `start..=end`. Either endpoint may be negative.
558    Range(isize, isize),
559    /// Comma-separated list of atoms. Order is preserved on resolve;
560    /// duplicates are NOT collapsed (IM convention — `[0,0,0]` writes
561    /// the same page three times). Empty list rejected at parse time.
562    List(Vec<PageAtom>),
563}
564
565impl PageSelector {
566    /// Resolve the selector to a list of zero-based page indices,
567    /// validated against `total_pages`. Negative indices `-k` resolve
568    /// to `total_pages - k`. Order matches the IM source order:
569    /// `[0,2,4]` yields `[0, 2, 4]`; `[-1,0]` yields
570    /// `[total_pages-1, 0]`. Duplicates (e.g. `[0,0]`) are preserved.
571    pub fn resolve(&self, total_pages: usize) -> Result<Vec<usize>, String> {
572        let mut out = Vec::new();
573        match self {
574            PageSelector::Single(n) => {
575                PageAtom::Single(*n).resolve_into(total_pages, &mut out)?;
576            }
577            PageSelector::Range(a, b) => {
578                PageAtom::Range(*a, *b).resolve_into(total_pages, &mut out)?;
579            }
580            PageSelector::List(atoms) => {
581                for atom in atoms {
582                    atom.resolve_into(total_pages, &mut out)?;
583                }
584            }
585        }
586        Ok(out)
587    }
588}
589
590/// `-stl-format` value. Selects which STL-on-disk flavour the encoder
591/// emits. Default is [`Binary`](Self::Binary), matching the registry
592/// factory.
593#[derive(Clone, Copy, Debug, PartialEq, Eq)]
594#[non_exhaustive]
595pub enum StlFormatChoice {
596    /// 80-byte header + `uint32` triangle count + `N × 50`-byte triangle
597    /// records. Compact and binary-stable.
598    Binary,
599    /// `solid … endsolid` ASCII grammar. Larger and floating-point
600    /// formatted but human-readable.
601    Ascii,
602}
603
604impl StlFormatChoice {
605    /// Parse the value after `-stl-format`. Accepts ImageMagick-ish
606    /// tokens case-insensitively (`binary`/`bin` → Binary, `ascii`/`text`
607    /// → Ascii).
608    pub fn parse(s: &str) -> Result<StlFormatChoice, String> {
609        match s.to_ascii_lowercase().as_str() {
610            "binary" | "bin" => Ok(StlFormatChoice::Binary),
611            "ascii" | "text" => Ok(StlFormatChoice::Ascii),
612            other => Err(format!(
613                "convert: -stl-format: unknown flavour '{other}' (expected 'binary' or 'ascii')"
614            )),
615        }
616    }
617}
618
619/// `-gltf-format` value. Selects the glTF on-disk container.
620///
621/// `JsonExternal` is documented but not yet implementable today
622/// (the underlying `OutputFlavour::JsonExternal` is missing in
623/// published `oxideav-gltf` 0.0.0); the args parser accepts the
624/// token now and the encoder will reject it cleanly until the
625/// upstream variant lands. See gltf-rN follow-up.
626#[derive(Clone, Copy, Debug, PartialEq, Eq)]
627#[non_exhaustive]
628pub enum GltfFormatChoice {
629    /// `.glb` self-contained binary container.
630    Glb,
631    /// `.gltf` JSON document with the binary buffer inlined as a
632    /// base64 `data:application/octet-stream;base64,…` URI.
633    JsonEmbedded,
634    /// `.gltf` JSON document referencing a sidecar `.bin` file.
635    /// Reserved — emits an "unsupported" error today (gltf-rN).
636    JsonExternal,
637}
638
639impl GltfFormatChoice {
640    /// Parse the value after `-gltf-format`. Accepts case-insensitive
641    /// `glb`, `embedded`, `external`. `binary` is an alias for `glb`
642    /// (mirrors how IM-style flags often accept short-form synonyms).
643    pub fn parse(s: &str) -> Result<GltfFormatChoice, String> {
644        match s.to_ascii_lowercase().as_str() {
645            "glb" | "binary" => Ok(GltfFormatChoice::Glb),
646            "embedded" | "json" | "json-embedded" => Ok(GltfFormatChoice::JsonEmbedded),
647            "external" | "json-external" => Ok(GltfFormatChoice::JsonExternal),
648            other => Err(format!(
649                "convert: -gltf-format: unknown flavour '{other}' (expected 'glb', 'embedded', or 'external')"
650            )),
651        }
652    }
653}
654
655/// `-render MODE` value. Selects which surface model the 3D→raster
656/// renderer uses when the user pairs a 3D input (`.stl`/`.obj`/
657/// `.gltf`/`.glb`/`.usdz`) with a raster output (`.png`/`.jpg`/
658/// `.bmp`/`.webp`).
659///
660/// Default is [`Flat`](Self::Flat) — one constant colour per primitive
661/// derived from the material's `base_color` (or a fallback grey when
662/// no material is attached). [`Gouraud`](Self::Gouraud) and
663/// [`Phong`](Self::Phong) add per-vertex / per-pixel lighting using
664/// either the primitive's stored vertex normals or face normals
665/// computed from the triangle vertices when none are provided.
666/// [`NormalDebug`](Self::NormalDebug) and [`DepthDebug`](Self::DepthDebug)
667/// are diagnostic visualisations that bypass the lighting equation
668/// entirely — useful when normals look wrong, a Z-fighting glitch
669/// shows up, or the camera framing seems off.
670#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
671#[non_exhaustive]
672pub enum Mesh3DRenderMode {
673    /// Flat-shaded triangles, one colour per primitive (no per-pixel
674    /// lighting). Cheapest mode and the default.
675    #[default]
676    Flat,
677    /// Wireframe — only triangle edges are drawn, interior pixels stay
678    /// at the background. Same colour-per-primitive as [`Flat`](Self::Flat).
679    Wireframe,
680    /// Gouraud — light is evaluated at each vertex using the per-vertex
681    /// normal, then the resulting RGB is bilinearly interpolated across
682    /// the triangle. Requires (or computes) one normal per vertex.
683    Gouraud,
684    /// Phong — the per-vertex normal is interpolated across the
685    /// triangle and the lighting equation is evaluated per pixel.
686    /// Smoother than Gouraud at the cost of one normalise + dot per
687    /// fragment.
688    Phong,
689    /// NormalDebug — paint each pixel with the world-space interpolated
690    /// normal mapped into RGB (`[r,g,b] = (n + 1) / 2 * 255`). Light /
691    /// camera / material settings are ignored. Useful for inspecting
692    /// whether per-vertex normals were loaded correctly or whether the
693    /// face-normal fallback is firing.
694    NormalDebug,
695    /// DepthDebug — paint each pixel a grayscale value derived from the
696    /// interpolated NDC z (near = white, far = black). Light / camera
697    /// orbit / material settings are ignored; useful for spotting
698    /// Z-fighting and for picking sane near/far planes.
699    DepthDebug,
700}
701
702impl Mesh3DRenderMode {
703    /// Parse the value after `-render`. Accepts case-insensitive
704    /// `flat` / `wireframe` / `gouraud` / `phong` / `normal-debug` /
705    /// `depth-debug`. `wire` is a short alias for `wireframe`;
706    /// `shaded` aliases `flat`; `normal` / `normals` alias
707    /// `normal-debug`; `depth` / `z` alias `depth-debug`.
708    pub fn parse(s: &str) -> Result<Mesh3DRenderMode, String> {
709        match s.to_ascii_lowercase().as_str() {
710            "flat" | "shaded" => Ok(Mesh3DRenderMode::Flat),
711            "wireframe" | "wire" => Ok(Mesh3DRenderMode::Wireframe),
712            "gouraud" => Ok(Mesh3DRenderMode::Gouraud),
713            "phong" => Ok(Mesh3DRenderMode::Phong),
714            "normal-debug" | "normaldebug" | "normal" | "normals" => {
715                Ok(Mesh3DRenderMode::NormalDebug)
716            }
717            "depth-debug" | "depthdebug" | "depth" | "z" => Ok(Mesh3DRenderMode::DepthDebug),
718            other => Err(format!(
719                "convert: -render: unknown mode '{other}' (expected 'flat', 'wireframe', 'gouraud', 'phong', 'normal-debug', or 'depth-debug')"
720            )),
721        }
722    }
723}
724
725/// `-projection MODE` value. Selects perspective vs. orthographic
726/// projection for the 3D→raster renderer. Default is
727/// [`Perspective`](Self::Perspective) — the IM convention for vector
728/// rasterisation matches what most 3D viewers ship.
729#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
730#[non_exhaustive]
731pub enum ProjectionMode {
732    /// Perspective projection — objects shrink with distance.
733    #[default]
734    Perspective,
735    /// Orthographic projection — parallel rays, no foreshortening.
736    /// Useful for technical drawings / part diagrams.
737    Orthographic,
738}
739
740impl ProjectionMode {
741    /// Parse the value after `-projection`. Case-insensitive synonyms:
742    /// `perspective` / `persp` / `p`, `orthographic` / `ortho` / `o`.
743    pub fn parse(s: &str) -> Result<ProjectionMode, String> {
744        match s.to_ascii_lowercase().as_str() {
745            "perspective" | "persp" | "p" => Ok(ProjectionMode::Perspective),
746            "orthographic" | "ortho" | "o" => Ok(ProjectionMode::Orthographic),
747            other => Err(format!(
748                "convert: -projection: unknown mode '{other}' (expected 'perspective' or 'orthographic')"
749            )),
750        }
751    }
752}
753
754/// `-light AZIMUTH,ELEVATION,INTENSITY` — directional light override
755/// for the 3D→raster renderer.
756///
757/// `azimuth` and `elevation` are in degrees; `intensity` is a unit
758/// scalar in `[0.0, ~]` multiplied into the diffuse term. The
759/// renderer always also applies a small constant ambient term so
760/// back-faces stay visible.
761#[derive(Clone, Copy, Debug, PartialEq)]
762#[non_exhaustive]
763pub struct LightSpec {
764    pub azimuth_deg: f32,
765    pub elevation_deg: f32,
766    pub intensity: f32,
767}
768
769impl LightSpec {
770    /// A directional light from `azimuth_deg` / `elevation_deg` at
771    /// `intensity` (clamped to `>= 0.0`).
772    pub fn new(azimuth_deg: f32, elevation_deg: f32, intensity: f32) -> Self {
773        Self {
774            azimuth_deg,
775            elevation_deg,
776            intensity: intensity.max(0.0),
777        }
778    }
779
780    /// Default directional light: from the upper-right-front quadrant
781    /// at unit intensity. Matches the renderer baseline so explicit
782    /// `-light` is never required.
783    pub fn default_light() -> Self {
784        Self {
785            azimuth_deg: 45.0,
786            elevation_deg: 45.0,
787            intensity: 1.0,
788        }
789    }
790
791    /// Parse `AZIMUTH,ELEVATION,INTENSITY`. All three components are
792    /// required; intensity is clamped to `>= 0.0`.
793    pub fn parse(s: &str) -> Result<LightSpec, String> {
794        let parts: Vec<&str> = s.split([',', 'x']).collect();
795        if parts.len() != 3 {
796            return Err(format!(
797                "convert: -light: '{s}' must be 'AZIMUTH,ELEVATION,INTENSITY' (e.g. '45,30,1.0')"
798            ));
799        }
800        let az: f32 = parts[0]
801            .trim()
802            .parse()
803            .map_err(|_| format!("convert: -light: azimuth '{}' is not a number", parts[0]))?;
804        let el: f32 = parts[1]
805            .trim()
806            .parse()
807            .map_err(|_| format!("convert: -light: elevation '{}' is not a number", parts[1]))?;
808        let intensity: f32 = parts[2]
809            .trim()
810            .parse()
811            .map_err(|_| format!("convert: -light: intensity '{}' is not a number", parts[2]))?;
812        if !intensity.is_finite() || intensity < 0.0 {
813            return Err(format!(
814                "convert: -light: intensity {intensity} must be >= 0.0 and finite"
815            ));
816        }
817        Ok(LightSpec {
818            azimuth_deg: az,
819            elevation_deg: el,
820            intensity,
821        })
822    }
823}
824
825/// `-camera ELEVATION,AZIMUTH,DISTANCE` — camera override for the
826/// 3D→raster renderer.
827///
828/// `elevation` and `azimuth` are in degrees; `distance` is a positive
829/// multiplier of the scene bounding-sphere radius (`1.0` ≈ scene
830/// touches the framebuffer edge; the auto-framing default is `~1.2`).
831#[derive(Clone, Copy, Debug, PartialEq)]
832#[non_exhaustive]
833pub struct CameraSpec {
834    pub elevation_deg: f32,
835    pub azimuth_deg: f32,
836    pub distance: f32,
837}
838
839impl CameraSpec {
840    /// A camera at `elevation_deg` / `azimuth_deg`, `distance` away
841    /// from the scene's bounding-box centre (callers validate `> 0`;
842    /// [`parse`](Self::parse) does).
843    pub fn new(elevation_deg: f32, azimuth_deg: f32, distance: f32) -> Self {
844        Self {
845            elevation_deg,
846            azimuth_deg,
847            distance,
848        }
849    }
850
851    /// Parse `ELEVATION,AZIMUTH,DISTANCE`. All three components are
852    /// required; distance must be `> 0`.
853    pub fn parse(s: &str) -> Result<CameraSpec, String> {
854        let parts: Vec<&str> = s.split([',', 'x']).collect();
855        if parts.len() != 3 {
856            return Err(format!(
857                "convert: -camera: '{s}' must be 'ELEVATION,AZIMUTH,DISTANCE' (e.g. '20,30,1.5')"
858            ));
859        }
860        let el: f32 = parts[0]
861            .trim()
862            .parse()
863            .map_err(|_| format!("convert: -camera: elevation '{}' is not a number", parts[0]))?;
864        let az: f32 = parts[1]
865            .trim()
866            .parse()
867            .map_err(|_| format!("convert: -camera: azimuth '{}' is not a number", parts[1]))?;
868        let dist: f32 = parts[2]
869            .trim()
870            .parse()
871            .map_err(|_| format!("convert: -camera: distance '{}' is not a number", parts[2]))?;
872        if !dist.is_finite() || dist <= 0.0 {
873            return Err(format!(
874                "convert: -camera: distance {dist} must be > 0.0 and finite"
875            ));
876        }
877        Ok(CameraSpec {
878            elevation_deg: el,
879            azimuth_deg: az,
880            distance: dist,
881        })
882    }
883}
884
885/// Per-format encoder option flags forwarded to the 3D side-channel.
886///
887/// All fields default to `None` meaning "use the format crate's
888/// registry default" — i.e. the existing convert behaviour is
889/// unchanged when no `-foo-format` flag is supplied.
890#[derive(Clone, Debug, Default, PartialEq)]
891#[non_exhaustive]
892pub struct Mesh3DOptions {
893    /// `-stl-format ascii|binary`. `None` → registry default (binary).
894    pub stl_format: Option<StlFormatChoice>,
895    /// `-gltf-format glb|embedded|external`. `None` → infer from
896    /// output extension (`.glb` → Glb, `.gltf` → JsonEmbedded), which
897    /// is exactly what the registry's by-extension lookup already does.
898    pub gltf_format: Option<GltfFormatChoice>,
899    /// `-render flat|wireframe|gouraud|phong`. `None` → default
900    /// ([`Mesh3DRenderMode::Flat`]). Only consulted by the 3D→raster
901    /// side-channel; ignored on every other input/output combination.
902    pub render_mode: Option<Mesh3DRenderMode>,
903    /// `-light AZIMUTH,ELEVATION,INTENSITY`. `None` → renderer
904    /// default ([`LightSpec::default_light`]). Used by Gouraud / Phong
905    /// shading; Flat/Wireframe ignore the light entirely.
906    pub light: Option<LightSpec>,
907    /// `-camera ELEVATION,AZIMUTH,DISTANCE`. `None` → bbox-fit
908    /// auto-frame.
909    pub camera: Option<CameraSpec>,
910    /// `-projection orthographic|perspective`. `None` →
911    /// [`ProjectionMode::Perspective`].
912    pub projection: Option<ProjectionMode>,
913    /// `-fov DEGREES` — vertical field of view for perspective
914    /// projection. `None` → 60°. Ignored for orthographic projection.
915    /// Must be in `(0, 180)`.
916    pub fov_deg: Option<f32>,
917    /// `-bg COLOR` — background fill for the render canvas. `None` →
918    /// transparent black `[0, 0, 0, 0]`. Distinct from `-background`
919    /// (which is the canvas-fill colour for `-alpha remove` and the
920    /// PDF runner) so callers can keep the two paint colours separate.
921    pub bg: Option<[u8; 4]>,
922    /// `-aa N` — supersampling-anti-aliasing factor. The renderer
923    /// rasterises at `N × output_w` by `N × output_h` then box-filters
924    /// the framebuffer back down to the requested output. `None` or
925    /// `Some(1)` → no supersampling (every pixel is point-sampled,
926    /// matching the round-44 baseline). Values >= 2 trade memory and
927    /// CPU for cleaner triangle edges; 2× and 4× are typical. Hard
928    /// cap of 8 because at 8× a 1024² render is a 16M-pixel
929    /// framebuffer + an 8M f32 z-buffer (~80 MB) and that's already
930    /// further than most users want to push a software renderer.
931    pub aa: Option<u32>,
932}
933
934impl Mesh3DOptions {
935    /// `-stl-format`.
936    pub fn with_stl_format(mut self, v: impl Into<Option<StlFormatChoice>>) -> Self {
937        self.stl_format = v.into();
938        self
939    }
940    /// `-gltf-format`.
941    pub fn with_gltf_format(mut self, v: impl Into<Option<GltfFormatChoice>>) -> Self {
942        self.gltf_format = v.into();
943        self
944    }
945    /// `-render`.
946    pub fn with_render_mode(mut self, v: impl Into<Option<Mesh3DRenderMode>>) -> Self {
947        self.render_mode = v.into();
948        self
949    }
950    /// `-light`.
951    pub fn with_light(mut self, v: impl Into<Option<LightSpec>>) -> Self {
952        self.light = v.into();
953        self
954    }
955    /// `-camera`.
956    pub fn with_camera(mut self, v: impl Into<Option<CameraSpec>>) -> Self {
957        self.camera = v.into();
958        self
959    }
960    /// `-projection`.
961    pub fn with_projection(mut self, v: impl Into<Option<ProjectionMode>>) -> Self {
962        self.projection = v.into();
963        self
964    }
965    /// `-fov DEGREES`.
966    pub fn with_fov_deg(mut self, v: impl Into<Option<f32>>) -> Self {
967        self.fov_deg = v.into();
968        self
969    }
970    /// `-bg COLOR` (RGBA).
971    pub fn with_bg(mut self, v: impl Into<Option<[u8; 4]>>) -> Self {
972        self.bg = v.into();
973        self
974    }
975    /// `-aa N`.
976    pub fn with_aa(mut self, v: impl Into<Option<u32>>) -> Self {
977        self.aa = v.into();
978        self
979    }
980}
981
982/// The parsed result of one `oxideav convert` invocation.
983#[derive(Clone, Debug)]
984#[non_exhaustive]
985pub struct ConvertPlan {
986    /// Input URI WITH any `[N]`/`[N-M]` page selector stripped.
987    /// Currently exactly one — IM's multi-input stack is a documented
988    /// follow-up.
989    pub input: String,
990    /// Page selector parsed from the input arg's `[…]` suffix, when
991    /// present. `None` means "all pages" for Scene-shaped inputs and
992    /// is ignored for raster inputs.
993    pub input_pages: Option<PageSelector>,
994    /// Chain of operations in source order.
995    pub ops: Vec<Op>,
996    /// Output path (literal). Empty string when `ping` is on and the
997    /// caller omitted it.
998    pub output: String,
999    /// Parsed printf template if `output` contains `%[0-9]*d`. Used by
1000    /// the convert runner to fan out per-page when the input is a
1001    /// Scene-shaped source (PDF) and the output codec doesn't natively
1002    /// accept Scenes.
1003    pub output_template: Option<PrintfTemplate>,
1004    /// `--help` / `-help` mode — print the usage synopsis (generated
1005    /// from the parser's own flag table, so it cannot drift) to stdout
1006    /// and do nothing else. Outranks every other mode; positional
1007    /// arguments become optional and are not validated.
1008    pub help: bool,
1009    /// `-ping` mode — read only the headers, print one IM-format line
1010    /// per "image" (page / video stream) to stdout, skip pixel decode
1011    /// and any output write. Output positional becomes optional.
1012    pub ping: bool,
1013    /// `--probe` mode — decode the input far enough to extract
1014    /// structural metadata (page count, mesh count, sample rate, …),
1015    /// print a compact summary to stdout, and skip any output write.
1016    /// Mutually exclusive with an output positional: `--probe in.gltf`
1017    /// is the supported shape; `--probe in.gltf out.obj` errors at
1018    /// the parser. The summary defaults to a pretty-printed
1019    /// `key: value` block; pair with [`probe_json`](Self::probe_json)
1020    /// for a single-line JSON object instead.
1021    pub probe: bool,
1022    /// `--json` mode — emit machine-readable JSON instead of the
1023    /// pretty-printed summary. Today this only applies to `--probe`;
1024    /// passing `--json` without `--probe` is a parser-level error
1025    /// since there's nothing else for it to format.
1026    pub probe_json: bool,
1027    /// `--watch` mode (paired with `--probe`) — re-run the probe
1028    /// whenever the input file's mtime changes. Each re-probe emits a
1029    /// fresh summary; in JSON mode each summary is on its own line so
1030    /// `convert --probe --json --watch in.png | jq` is well-formed
1031    /// JSON-lines. Polls the input mtime once per second; the loop
1032    /// runs forever and exits only on Ctrl+C / signal. Useful for
1033    /// live-monitoring a render-in-progress.
1034    pub probe_watch: bool,
1035    /// Per-format encoder options for the 3D side-channel
1036    /// (`-stl-format`, `-gltf-format`, …). `Default` = "use the
1037    /// registry's default factory for each format" so callers who
1038    /// don't pass any of these flags see no behaviour change.
1039    pub mesh3d_options: Mesh3DOptions,
1040}
1041
1042impl ConvertPlan {
1043    /// A plain conversion of `input` to `output`: no ops, no page
1044    /// selector or output template, no mode switch, default 3D options.
1045    /// The arg parser produces the same shape through
1046    /// [`args::parse`](crate::args::parse); use the `with_*` setters
1047    /// for the rest.
1048    pub fn new(input: impl Into<String>, output: impl Into<String>) -> Self {
1049        Self {
1050            input: input.into(),
1051            input_pages: None,
1052            ops: Vec::new(),
1053            output: output.into(),
1054            output_template: None,
1055            help: false,
1056            ping: false,
1057            probe: false,
1058            probe_json: false,
1059            probe_watch: false,
1060            mesh3d_options: Mesh3DOptions::default(),
1061        }
1062    }
1063    /// Operation chain in source order.
1064    pub fn with_ops(mut self, ops: Vec<Op>) -> Self {
1065        self.ops = ops;
1066        self
1067    }
1068    /// `[N]` / `[N-M]` / `[A,B,…]` page selector on the input.
1069    pub fn with_input_pages(mut self, v: impl Into<Option<PageSelector>>) -> Self {
1070        self.input_pages = v.into();
1071        self
1072    }
1073    /// Parsed `%d` template of `output` (the caller keeps the two in
1074    /// sync, as [`args::parse`](crate::args::parse) does).
1075    pub fn with_output_template(mut self, v: impl Into<Option<PrintfTemplate>>) -> Self {
1076        self.output_template = v.into();
1077        self
1078    }
1079    /// `--help` mode.
1080    pub fn with_help(mut self, v: bool) -> Self {
1081        self.help = v;
1082        self
1083    }
1084    /// `-ping` mode.
1085    pub fn with_ping(mut self, v: bool) -> Self {
1086        self.ping = v;
1087        self
1088    }
1089    /// `--probe` mode.
1090    pub fn with_probe(mut self, v: bool) -> Self {
1091        self.probe = v;
1092        self
1093    }
1094    /// `--json` (with `--probe`).
1095    pub fn with_probe_json(mut self, v: bool) -> Self {
1096        self.probe_json = v;
1097        self
1098    }
1099    /// `--watch` (with `--probe`).
1100    pub fn with_probe_watch(mut self, v: bool) -> Self {
1101        self.probe_watch = v;
1102        self
1103    }
1104    /// 3D side-channel options.
1105    pub fn with_mesh3d_options(mut self, v: Mesh3DOptions) -> Self {
1106        self.mesh3d_options = v;
1107        self
1108    }
1109}