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 (metal_rough red channel)
7///   G = smoothness (1 - metal_rough alpha)
8///   B = emissive (emissive alpha)
9/// Optionally copies the albedo texture alongside.
10#[derive(Clone, Debug, Parser)]
11pub struct CreateMse {
12    /// The output base path. Output files will be `{out_base}-mse.png` and
13    /// `{out_base}-albedo.png`.
14    #[arg(value_name = "out-base")]
15    out_base: String,
16
17    /// Search prefix for texture files. When set, searches for
18    /// `{prefix}-metalness.png`, `{prefix}-emission.png`, `{prefix}-albedo.png`.
19    #[arg(value_name = "prefix", long)]
20    prefix: Option<String>,
21
22    /// Explicit path to the metal_rough texture.
23    #[arg(value_name = "metal-rough", long)]
24    metal_rough: Option<PathBuf>,
25
26    /// Explicit path to the emissive texture.
27    #[arg(value_name = "emissive", long)]
28    emissive: Option<PathBuf>,
29
30    /// Explicit path to the albedo texture.
31    #[arg(value_name = "albedo", long)]
32    albedo: Option<PathBuf>,
33
34    /// Skip the metal_rough channel (metalness and smoothness will be black).
35    #[arg(
36        value_name = "ignore-metal-rough",
37        long,
38        conflicts_with = "metal_rough"
39    )]
40    ignore_metal_rough: bool,
41
42    /// Skip the emissive channel (emission will be black).
43    #[arg(value_name = "ignore-emissive", long, conflicts_with = "emissive")]
44    ignore_emissive: bool,
45
46    /// Skip the albedo pass-through copy.
47    #[arg(value_name = "ignore-albedo", long, conflicts_with = "albedo")]
48    ignore_albedo: bool,
49}
50
51impl CreateMse {
52    pub fn execute(self, dependencies: impl Dependencies) -> Result<()> {
53        let CreateMse {
54            out_base,
55            prefix,
56            metal_rough,
57            emissive,
58            albedo,
59            ignore_metal_rough,
60            ignore_emissive,
61            ignore_albedo,
62        } = self;
63
64        // ----------------------------------------------------------------
65        // Resolve texture paths
66        // ----------------------------------------------------------------
67        let metal_rough_path = if ignore_metal_rough {
68            None
69        } else {
70            Some(match metal_rough {
71                Some(p) => coerce_png(p),
72                None => match &prefix {
73                    Some(pfx) => dependencies.glob_single_match(&format!("{pfx}-metalness.png"))?,
74                    None => dependencies.glob_single_match("*metalness.png")?,
75                },
76            })
77        };
78
79        let emissive_path = if ignore_emissive {
80            None
81        } else {
82            Some(match emissive {
83                Some(p) => coerce_png(p),
84                None => match &prefix {
85                    Some(pfx) => dependencies.glob_single_match(&format!("{pfx}-emission.png"))?,
86                    None => dependencies.glob_single_match("*emission.png")?,
87                },
88            })
89        };
90
91        let albedo_path = if ignore_albedo {
92            None
93        } else {
94            Some(match albedo {
95                Some(p) => coerce_png(p),
96                None => match &prefix {
97                    Some(pfx) => dependencies.glob_single_match(&format!("{pfx}-albedo.png"))?,
98                    None => dependencies.glob_single_match("*albedo.png")?,
99                },
100            })
101        };
102
103        // ----------------------------------------------------------------
104        // Determine base image for sizing
105        // ----------------------------------------------------------------
106        let base_img = metal_rough_path
107            .as_ref()
108            .or(emissive_path.as_ref())
109            .or(albedo_path.as_ref())
110            .ok_or_else(|| Error::Glob("all channels are ignored; nothing to do".into()))?;
111
112        let size_output = dependencies.exec_magick([
113            "identify".into(),
114            "-ping".into(),
115            "-format".into(),
116            "%wx%h".into(),
117            base_img.to_string_lossy().into_owned(),
118        ])?;
119        let size = String::from_utf8_lossy(&size_output).trim().to_string();
120        if size.is_empty() {
121            return Err(Error::Glob(format!(
122                "couldn't determine image size for: {}",
123                base_img.display()
124            )));
125        }
126
127        // ----------------------------------------------------------------
128        // Create temp dir for intermediate channel PNGs
129        // ----------------------------------------------------------------
130        let tmpdir = dependencies.create_temp_dir()?;
131        let result = self::create_mse_inner(
132            &dependencies,
133            &metal_rough_path,
134            &emissive_path,
135            &albedo_path,
136            &out_base,
137            &size,
138            &tmpdir,
139        );
140        dependencies.remove_dir_all(&tmpdir)?;
141        result
142    }
143}
144
145fn create_mse_inner(
146    dependencies: &impl Dependencies,
147    metal_rough_path: &Option<PathBuf>,
148    emissive_path: &Option<PathBuf>,
149    albedo_path: &Option<PathBuf>,
150    out_base: &str,
151    size: &str,
152    tmpdir: &Path,
153) -> Result<()> {
154    let r_img = tmpdir.join("r.png");
155    let g_img = tmpdir.join("g.png");
156    let b_img = tmpdir.join("b.png");
157
158    let r_str = r_img.to_string_lossy().into_owned();
159    let g_str = g_img.to_string_lossy().into_owned();
160    let b_str = b_img.to_string_lossy().into_owned();
161
162    // R channel: metalness (red channel of metal_rough)
163    match metal_rough_path {
164        None => {
165            dependencies.exec_magick(["-size", size, "xc:black", &r_str])?;
166        }
167        Some(mr) => {
168            let mr_str = mr.to_string_lossy().into_owned();
169            dependencies.exec_magick([
170                &mr_str,
171                "-colorspace",
172                "sRGB",
173                "-alpha",
174                "on",
175                "-channel",
176                "R",
177                "-separate",
178                "+channel",
179                "-resize",
180                &format!("{size}!"),
181                &r_str,
182            ])?;
183        }
184    }
185
186    // G channel: smoothness = 1 - roughness (alpha channel of metal_rough, inverted)
187    match metal_rough_path {
188        None => {
189            dependencies.exec_magick(["-size", size, "xc:black", &g_str])?;
190        }
191        Some(mr) => {
192            let mr_str = mr.to_string_lossy().into_owned();
193            dependencies.exec_magick([
194                &mr_str,
195                "-colorspace",
196                "sRGB",
197                "-alpha",
198                "on",
199                "-alpha",
200                "extract",
201                "-fx",
202                "u==0 ? 0 : 1-u",
203                "-resize",
204                &format!("{size}!"),
205                &g_str,
206            ])?;
207        }
208    }
209
210    // B channel: emissive (alpha channel of emissive)
211    match emissive_path {
212        None => {
213            dependencies.exec_magick(["-size", size, "xc:black", &b_str])?;
214        }
215        Some(em) => {
216            let em_str = em.to_string_lossy().into_owned();
217            dependencies.exec_magick([
218                &em_str,
219                "-colorspace",
220                "sRGB",
221                "-alpha",
222                "on",
223                "-alpha",
224                "extract",
225                "-resize",
226                &format!("{size}!"),
227                &b_str,
228            ])?;
229        }
230    }
231
232    // Combine R/G/B into MSE
233    let mse_out = format!("{out_base}-mse.png");
234    dependencies.exec_magick([
235        &r_str,
236        &g_str,
237        &b_str,
238        "-combine",
239        "-colorspace",
240        "sRGB",
241        &mse_out,
242    ])?;
243
244    dependencies.write_stdout(format!("Wrote: {mse_out}\n").as_bytes())?;
245
246    // Copy albedo if not ignored
247    if let Some(albedo) = albedo_path {
248        let albedo_out = format!("{out_base}-albedo.png");
249        dependencies.copy_file(albedo, &albedo_out)?;
250        dependencies.write_stdout(format!("Wrote: {albedo_out}\n").as_bytes())?;
251    }
252
253    Ok(())
254}
255
256/// If the path has no extension, assume `.png`.
257fn coerce_png(p: PathBuf) -> PathBuf {
258    if p.extension().is_none() {
259        p.with_extension("png")
260    } else {
261        p
262    }
263}