Skip to main content

tyt_material/commands/
create_mse.rs

1use crate::{Dependencies, Error, Result};
2use clap::Parser;
3use std::path::{Path, PathBuf};
4
5/// Creates an MSE png from material texture maps. The output png packs:
6///   R = metalness
7///   G = smoothness (1 - roughness), or roughness when `--output-rough` is set
8///   B = emissive (emissive alpha)
9/// Optionally copies the albedo texture alongside.
10///
11/// By default, metalness and roughness are read from separate textures
12/// (`*metalness.png` and `*roughness.png`). Pass `--combine-metal-rough` to
13/// read both from a single legacy texture (R = metal, A = roughness).
14#[derive(Clone, Debug, Parser)]
15pub struct CreateMse {
16    /// The output base path. Output files will be `{out_base}-mse.png` and
17    /// `{out_base}-albedo.png`.
18    #[arg(value_name = "out-base")]
19    out_base: String,
20
21    /// Search prefix for texture files. When set, searches for
22    /// `{prefix}-metalness.png`, `{prefix}-roughness.png`,
23    /// `{prefix}-emission.png`, `{prefix}-albedo.png`.
24    #[arg(value_name = "prefix", long)]
25    prefix: Option<String>,
26
27    /// Explicit path to the metal texture. Cannot be used with
28    /// `--combine-metal-rough`.
29    #[arg(value_name = "metal", long, conflicts_with = "combine_metal_rough")]
30    metal: Option<PathBuf>,
31
32    /// Explicit path to the rough texture. Cannot be used with
33    /// `--combine-metal-rough`.
34    #[arg(value_name = "rough", long, conflicts_with = "combine_metal_rough")]
35    rough: Option<PathBuf>,
36
37    /// Read metalness and roughness from a single combined texture
38    /// (legacy mode: R = metal, A = roughness).
39    #[arg(value_name = "combine-metal-rough", long)]
40    combine_metal_rough: bool,
41
42    /// Explicit path to the combined metal_rough texture. Only valid with
43    /// `--combine-metal-rough`.
44    #[arg(value_name = "metal-rough", long, requires = "combine_metal_rough")]
45    metal_rough: Option<PathBuf>,
46
47    /// Explicit path to the emissive texture.
48    #[arg(value_name = "emissive", long)]
49    emissive: Option<PathBuf>,
50
51    /// Explicit path to the albedo texture.
52    #[arg(value_name = "albedo", long)]
53    albedo: Option<PathBuf>,
54
55    /// Skip the metalness channel (R will be black).
56    #[arg(value_name = "ignore-metal", long)]
57    ignore_metal: bool,
58
59    /// Skip the roughness channel (G will be black).
60    #[arg(value_name = "ignore-rough", long)]
61    ignore_rough: bool,
62
63    /// Skip the emissive channel (B will be black).
64    #[arg(value_name = "ignore-emissive", long)]
65    ignore_emissive: bool,
66
67    /// Skip the albedo pass-through copy.
68    #[arg(value_name = "ignore-albedo", long)]
69    ignore_albedo: bool,
70
71    /// Output roughness directly into the G channel instead of converting it
72    /// to smoothness (1 - roughness).
73    #[arg(value_name = "output-rough", long)]
74    output_rough: bool,
75}
76
77impl CreateMse {
78    pub fn execute(self, dependencies: impl Dependencies) -> Result<()> {
79        let CreateMse {
80            out_base,
81            prefix,
82            metal,
83            rough,
84            combine_metal_rough,
85            metal_rough,
86            emissive,
87            albedo,
88            ignore_metal,
89            ignore_rough,
90            ignore_emissive,
91            ignore_albedo,
92            output_rough,
93        } = self;
94
95        // ----------------------------------------------------------------
96        // Resolve texture paths
97        // ----------------------------------------------------------------
98        let metal_rough_path = if !combine_metal_rough || (ignore_metal && ignore_rough) {
99            None
100        } else {
101            Some(match metal_rough {
102                Some(p) => coerce_png(p),
103                None => match &prefix {
104                    Some(pfx) => dependencies.glob_single_match(&format!("{pfx}-metalness.png"))?,
105                    None => dependencies.glob_single_match("*metalness.png")?,
106                },
107            })
108        };
109
110        let metal_path = if combine_metal_rough || ignore_metal {
111            None
112        } else {
113            Some(match metal {
114                Some(p) => coerce_png(p),
115                None => match &prefix {
116                    Some(pfx) => dependencies.glob_single_match(&format!("{pfx}-metalness.png"))?,
117                    None => dependencies.glob_single_match("*metalness.png")?,
118                },
119            })
120        };
121
122        let rough_path = if combine_metal_rough || ignore_rough {
123            None
124        } else {
125            Some(match rough {
126                Some(p) => coerce_png(p),
127                None => match &prefix {
128                    Some(pfx) => dependencies.glob_single_match(&format!("{pfx}-roughness.png"))?,
129                    None => dependencies.glob_single_match("*roughness.png")?,
130                },
131            })
132        };
133
134        let emissive_path = if ignore_emissive {
135            None
136        } else {
137            Some(match emissive {
138                Some(p) => coerce_png(p),
139                None => match &prefix {
140                    Some(pfx) => dependencies.glob_single_match(&format!("{pfx}-emission.png"))?,
141                    None => dependencies.glob_single_match("*emission.png")?,
142                },
143            })
144        };
145
146        let albedo_path = if ignore_albedo {
147            None
148        } else {
149            Some(match albedo {
150                Some(p) => coerce_png(p),
151                None => match &prefix {
152                    Some(pfx) => dependencies.glob_single_match(&format!("{pfx}-albedo.png"))?,
153                    None => dependencies.glob_single_match("*albedo.png")?,
154                },
155            })
156        };
157
158        // ----------------------------------------------------------------
159        // Determine base image for sizing
160        // ----------------------------------------------------------------
161        let base_img = metal_rough_path
162            .as_ref()
163            .or(metal_path.as_ref())
164            .or(rough_path.as_ref())
165            .or(emissive_path.as_ref())
166            .or(albedo_path.as_ref())
167            .ok_or_else(|| Error::Glob("all channels are ignored; nothing to do".into()))?;
168
169        let size_output = dependencies.exec_magick([
170            "identify".into(),
171            "-ping".into(),
172            "-format".into(),
173            "%wx%h".into(),
174            base_img.to_string_lossy().into_owned(),
175        ])?;
176        let size = String::from_utf8_lossy(&size_output).trim().to_string();
177        if size.is_empty() {
178            return Err(Error::Glob(format!(
179                "couldn't determine image size for: {}",
180                base_img.display()
181            )));
182        }
183
184        // ----------------------------------------------------------------
185        // Create temp dir for intermediate channel PNGs
186        // ----------------------------------------------------------------
187        let tmpdir = dependencies.create_temp_dir()?;
188        let result = self::create_mse_inner(
189            &dependencies,
190            &metal_rough_path,
191            &metal_path,
192            &rough_path,
193            &emissive_path,
194            &albedo_path,
195            output_rough,
196            &out_base,
197            &size,
198            &tmpdir,
199        );
200        dependencies.remove_dir_all(&tmpdir)?;
201        result
202    }
203}
204
205fn create_mse_inner(
206    dependencies: &impl Dependencies,
207    metal_rough_path: &Option<PathBuf>,
208    metal_path: &Option<PathBuf>,
209    rough_path: &Option<PathBuf>,
210    emissive_path: &Option<PathBuf>,
211    albedo_path: &Option<PathBuf>,
212    output_rough: bool,
213    out_base: &str,
214    size: &str,
215    tmpdir: &Path,
216) -> Result<()> {
217    let r_img = tmpdir.join("r.png");
218    let g_img = tmpdir.join("g.png");
219    let b_img = tmpdir.join("b.png");
220
221    let r_str = r_img.to_string_lossy().into_owned();
222    let g_str = g_img.to_string_lossy().into_owned();
223    let b_str = b_img.to_string_lossy().into_owned();
224
225    // R channel: metalness
226    match (metal_rough_path, metal_path) {
227        (Some(mr), _) => {
228            let mr_str = mr.to_string_lossy().into_owned();
229            dependencies.exec_magick([
230                &mr_str,
231                "-colorspace",
232                "sRGB",
233                "-alpha",
234                "on",
235                "-channel",
236                "R",
237                "-separate",
238                "+channel",
239                "-resize",
240                &format!("{size}!"),
241                &r_str,
242            ])?;
243        }
244        (None, Some(m)) => {
245            let metal_str = m.to_string_lossy().into_owned();
246            dependencies.exec_magick([
247                &metal_str,
248                "-colorspace",
249                "sRGB",
250                "-channel",
251                "R",
252                "-separate",
253                "+channel",
254                "-resize",
255                &format!("{size}!"),
256                &r_str,
257            ])?;
258        }
259        (None, None) => {
260            dependencies.exec_magick(["-size", size, "xc:black", &r_str])?;
261        }
262    }
263
264    // G channel: smoothness = 1 - roughness, or roughness if output_rough
265    match (metal_rough_path, rough_path) {
266        (Some(mr), _) => {
267            let mr_str = mr.to_string_lossy().into_owned();
268            if output_rough {
269                dependencies.exec_magick([
270                    &mr_str,
271                    "-colorspace",
272                    "sRGB",
273                    "-alpha",
274                    "on",
275                    "-alpha",
276                    "extract",
277                    "-resize",
278                    &format!("{size}!"),
279                    &g_str,
280                ])?;
281            } else {
282                dependencies.exec_magick([
283                    &mr_str,
284                    "-colorspace",
285                    "sRGB",
286                    "-alpha",
287                    "on",
288                    "-alpha",
289                    "extract",
290                    "-fx",
291                    "u==0 ? 0 : 1-u",
292                    "-resize",
293                    &format!("{size}!"),
294                    &g_str,
295                ])?;
296            }
297        }
298        (None, Some(r)) => {
299            let rough_str = r.to_string_lossy().into_owned();
300            if output_rough {
301                dependencies.exec_magick([
302                    &rough_str,
303                    "-colorspace",
304                    "sRGB",
305                    "-channel",
306                    "R",
307                    "-separate",
308                    "+channel",
309                    "-resize",
310                    &format!("{size}!"),
311                    &g_str,
312                ])?;
313            } else {
314                dependencies.exec_magick([
315                    &rough_str,
316                    "-colorspace",
317                    "sRGB",
318                    "-channel",
319                    "R",
320                    "-separate",
321                    "+channel",
322                    "-fx",
323                    "1-u",
324                    "-resize",
325                    &format!("{size}!"),
326                    &g_str,
327                ])?;
328            }
329        }
330        (None, None) => {
331            dependencies.exec_magick(["-size", size, "xc:black", &g_str])?;
332        }
333    }
334
335    // B channel: emissive (alpha channel of emissive)
336    match emissive_path {
337        None => {
338            dependencies.exec_magick(["-size", size, "xc:black", &b_str])?;
339        }
340        Some(em) => {
341            let em_str = em.to_string_lossy().into_owned();
342            dependencies.exec_magick([
343                &em_str,
344                "-colorspace",
345                "sRGB",
346                "-alpha",
347                "on",
348                "-alpha",
349                "extract",
350                "-resize",
351                &format!("{size}!"),
352                &b_str,
353            ])?;
354        }
355    }
356
357    // Combine R/G/B into MSE
358    let mse_out = format!("{out_base}-mse.png");
359    dependencies.exec_magick([
360        &r_str,
361        &g_str,
362        &b_str,
363        "-combine",
364        "-colorspace",
365        "sRGB",
366        &mse_out,
367    ])?;
368
369    dependencies.write_stdout(format!("Wrote: {mse_out}\n").as_bytes())?;
370
371    // Copy albedo if not ignored
372    if let Some(albedo) = albedo_path {
373        let albedo_out = format!("{out_base}-albedo.png");
374        dependencies.copy_file(albedo, &albedo_out)?;
375        dependencies.write_stdout(format!("Wrote: {albedo_out}\n").as_bytes())?;
376    }
377
378    Ok(())
379}
380
381/// If the path has no extension, assume `.png`.
382fn coerce_png(p: PathBuf) -> PathBuf {
383    if p.extension().is_none() {
384        p.with_extension("png")
385    } else {
386        p
387    }
388}