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
205#[allow(clippy::too_many_arguments)]
206fn create_mse_inner(
207    dependencies: &impl Dependencies,
208    metal_rough_path: &Option<PathBuf>,
209    metal_path: &Option<PathBuf>,
210    rough_path: &Option<PathBuf>,
211    emissive_path: &Option<PathBuf>,
212    albedo_path: &Option<PathBuf>,
213    output_rough: bool,
214    out_base: &str,
215    size: &str,
216    tmpdir: &Path,
217) -> Result<()> {
218    let r_img = tmpdir.join("r.png");
219    let g_img = tmpdir.join("g.png");
220    let b_img = tmpdir.join("b.png");
221
222    let r_str = r_img.to_string_lossy().into_owned();
223    let g_str = g_img.to_string_lossy().into_owned();
224    let b_str = b_img.to_string_lossy().into_owned();
225
226    // R channel: metalness
227    match (metal_rough_path, metal_path) {
228        (Some(mr), _) => {
229            let mr_str = mr.to_string_lossy().into_owned();
230            dependencies.exec_magick([
231                &mr_str,
232                "-colorspace",
233                "sRGB",
234                "-alpha",
235                "on",
236                "-channel",
237                "R",
238                "-separate",
239                "+channel",
240                "-resize",
241                &format!("{size}!"),
242                &r_str,
243            ])?;
244        }
245        (None, Some(m)) => {
246            let metal_str = m.to_string_lossy().into_owned();
247            dependencies.exec_magick([
248                &metal_str,
249                "-colorspace",
250                "sRGB",
251                "-channel",
252                "R",
253                "-separate",
254                "+channel",
255                "-resize",
256                &format!("{size}!"),
257                &r_str,
258            ])?;
259        }
260        (None, None) => {
261            dependencies.exec_magick(["-size", size, "xc:black", &r_str])?;
262        }
263    }
264
265    // G channel: smoothness = 1 - roughness, or roughness if output_rough
266    match (metal_rough_path, rough_path) {
267        (Some(mr), _) => {
268            let mr_str = mr.to_string_lossy().into_owned();
269            if output_rough {
270                dependencies.exec_magick([
271                    &mr_str,
272                    "-colorspace",
273                    "sRGB",
274                    "-alpha",
275                    "on",
276                    "-alpha",
277                    "extract",
278                    "-resize",
279                    &format!("{size}!"),
280                    &g_str,
281                ])?;
282            } else {
283                dependencies.exec_magick([
284                    &mr_str,
285                    "-colorspace",
286                    "sRGB",
287                    "-alpha",
288                    "on",
289                    "-alpha",
290                    "extract",
291                    "-fx",
292                    "u==0 ? 0 : 1-u",
293                    "-resize",
294                    &format!("{size}!"),
295                    &g_str,
296                ])?;
297            }
298        }
299        (None, Some(r)) => {
300            let rough_str = r.to_string_lossy().into_owned();
301            if output_rough {
302                dependencies.exec_magick([
303                    &rough_str,
304                    "-colorspace",
305                    "sRGB",
306                    "-channel",
307                    "R",
308                    "-separate",
309                    "+channel",
310                    "-resize",
311                    &format!("{size}!"),
312                    &g_str,
313                ])?;
314            } else {
315                dependencies.exec_magick([
316                    &rough_str,
317                    "-colorspace",
318                    "sRGB",
319                    "-channel",
320                    "R",
321                    "-separate",
322                    "+channel",
323                    "-fx",
324                    "1-u",
325                    "-resize",
326                    &format!("{size}!"),
327                    &g_str,
328                ])?;
329            }
330        }
331        (None, None) => {
332            dependencies.exec_magick(["-size", size, "xc:black", &g_str])?;
333        }
334    }
335
336    // B channel: emissive (alpha channel of emissive)
337    match emissive_path {
338        None => {
339            dependencies.exec_magick(["-size", size, "xc:black", &b_str])?;
340        }
341        Some(em) => {
342            let em_str = em.to_string_lossy().into_owned();
343            dependencies.exec_magick([
344                &em_str,
345                "-colorspace",
346                "sRGB",
347                "-alpha",
348                "on",
349                "-alpha",
350                "extract",
351                "-resize",
352                &format!("{size}!"),
353                &b_str,
354            ])?;
355        }
356    }
357
358    // Combine R/G/B into MSE
359    let mse_out = format!("{out_base}-mse.png");
360    dependencies.exec_magick([
361        &r_str,
362        &g_str,
363        &b_str,
364        "-combine",
365        "-colorspace",
366        "sRGB",
367        &mse_out,
368    ])?;
369
370    dependencies.write_stdout(format!("Wrote: {mse_out}\n").as_bytes())?;
371
372    // Copy albedo if not ignored
373    if let Some(albedo) = albedo_path {
374        let albedo_out = format!("{out_base}-albedo.png");
375        dependencies.copy_file(albedo, &albedo_out)?;
376        dependencies.write_stdout(format!("Wrote: {albedo_out}\n").as_bytes())?;
377    }
378
379    Ok(())
380}
381
382/// If the path has no extension, assume `.png`.
383fn coerce_png(p: PathBuf) -> PathBuf {
384    if p.extension().is_none() {
385        p.with_extension("png")
386    } else {
387        p
388    }
389}