lib3mf_converters/
mtl.rs

1//! Wavefront MTL material library parser.
2//!
3//! Parses `.mtl` files referenced by OBJ `mtllib` directives, extracting diffuse color
4//! (`Kd`) into [`MtlMaterial`] structs that can be mapped to 3MF [`BaseMaterial`] resources.
5//!
6//! ## Supported Directives
7//!
8//! - `newmtl <name>` - Define a new material
9//! - `Kd <r> <g> <b>` - Diffuse color (floats 0.0-1.0, clamped)
10//!
11//! ## Ignored Directives
12//!
13//! - `Ka`, `Ks`, `Ns`, `Ke`, `illum`, `Ni`, `d`, `Tr` - Silently skipped
14//! - `map_Kd` and other `map_*` - Warning printed to stderr, skipped
15//!
16//! ## Defaults
17//!
18//! Materials without a `Kd` line default to gray (#808080FF).
19//!
20//! [`BaseMaterial`]: lib3mf_core::model::BaseMaterial
21
22use lib3mf_core::model::Color;
23use std::collections::HashMap;
24use std::io::{BufRead, BufReader, Read};
25use std::path::Path;
26
27/// Default gray color for materials without a Kd directive.
28const DEFAULT_GRAY: Color = Color {
29    r: 128,
30    g: 128,
31    b: 128,
32    a: 255,
33};
34
35/// A parsed MTL material with a name and display color.
36#[derive(Debug, Clone)]
37pub struct MtlMaterial {
38    /// Material name from the `newmtl` directive.
39    pub name: String,
40    /// Display color derived from the `Kd` directive, or gray (#808080FF) if absent.
41    pub display_color: Color,
42}
43
44/// Parses an MTL material library from a reader.
45///
46/// Reads line-by-line, extracting `newmtl` and `Kd` directives. Materials without
47/// a `Kd` line get a default gray color (#808080FF). Texture map references (`map_Kd`
48/// and other `map_*` directives) emit a warning to stderr and are skipped.
49///
50/// Bad or unparseable lines are silently skipped without aborting.
51///
52/// # Returns
53///
54/// A `HashMap` mapping material name to [`MtlMaterial`].
55pub fn parse_mtl<R: Read>(reader: R) -> HashMap<String, MtlMaterial> {
56    let buf = BufReader::new(reader);
57    let mut materials: HashMap<String, MtlMaterial> = HashMap::new();
58    let mut current_name: Option<String> = None;
59    let mut current_color: Option<Color> = None;
60
61    for line_result in buf.lines() {
62        let line = match line_result {
63            Ok(l) => l,
64            Err(_) => continue,
65        };
66        let trimmed = line.trim();
67        if trimmed.is_empty() || trimmed.starts_with('#') {
68            continue;
69        }
70
71        let parts: Vec<&str> = trimmed.split_whitespace().collect();
72        if parts.is_empty() {
73            continue;
74        }
75
76        match parts[0] {
77            "newmtl" => {
78                // Flush previous material
79                if let Some(name) = current_name.take() {
80                    let color = current_color.take().unwrap_or(DEFAULT_GRAY);
81                    materials.insert(
82                        name.clone(),
83                        MtlMaterial {
84                            name,
85                            display_color: color,
86                        },
87                    );
88                }
89                // Start new material -- name is everything after "newmtl "
90                if parts.len() >= 2 {
91                    current_name = Some(parts[1..].join(" "));
92                }
93                current_color = None;
94            }
95            "Kd" => {
96                if parts.len() >= 4
97                    && let (Ok(r), Ok(g), Ok(b)) = (
98                        parts[1].parse::<f32>(),
99                        parts[2].parse::<f32>(),
100                        parts[3].parse::<f32>(),
101                    )
102                {
103                    current_color = Some(Color::new(
104                        (r.clamp(0.0, 1.0) * 255.0).round() as u8,
105                        (g.clamp(0.0, 1.0) * 255.0).round() as u8,
106                        (b.clamp(0.0, 1.0) * 255.0).round() as u8,
107                        255,
108                    ));
109                }
110                // If parsing fails or not enough parts, skip the line (bad Kd)
111            }
112            directive if directive.starts_with("map_") => {
113                // Warn about texture maps
114                let texture_path = if parts.len() >= 2 {
115                    parts[1..].join(" ")
116                } else {
117                    String::new()
118                };
119                let mat_name = current_name.as_deref().unwrap_or("unknown");
120                eprintln!(
121                    "Warning: texture map '{}' skipped for material '{}' (texture import not supported)",
122                    texture_path, mat_name
123                );
124            }
125            // Silently ignore: Ka, Ks, Ns, Ke, illum, Ni, d, Tr, and anything else
126            _ => {}
127        }
128    }
129
130    // Flush last material
131    if let Some(name) = current_name.take() {
132        let color = current_color.take().unwrap_or(DEFAULT_GRAY);
133        materials.insert(
134            name.clone(),
135            MtlMaterial {
136                name,
137                display_color: color,
138            },
139        );
140    }
141
142    materials
143}
144
145/// Parses an MTL file from a filesystem path.
146///
147/// If the file does not exist or cannot be opened, a warning is printed to stderr
148/// and an empty `HashMap` is returned. This allows OBJ import to proceed with
149/// geometry only when the MTL file is missing.
150pub fn parse_mtl_file(path: &Path) -> HashMap<String, MtlMaterial> {
151    let file = match std::fs::File::open(path) {
152        Ok(f) => f,
153        Err(e) => {
154            if e.kind() == std::io::ErrorKind::NotFound {
155                eprintln!("Warning: MTL file not found: {}", path.display());
156            } else {
157                eprintln!(
158                    "Warning: failed to open MTL file '{}': {}",
159                    path.display(),
160                    e
161                );
162            }
163            return HashMap::new();
164        }
165    };
166    parse_mtl(file)
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn test_basic_kd_parsing() {
175        let mtl = b"newmtl Red\nKd 1.0 0.0 0.0\n";
176        let materials = parse_mtl(&mtl[..]);
177        assert_eq!(materials.len(), 1);
178        let red = &materials["Red"];
179        assert_eq!(red.name, "Red");
180        assert_eq!(red.display_color, Color::new(255, 0, 0, 255));
181    }
182
183    #[test]
184    fn test_missing_kd_defaults_to_gray() {
185        let mtl = b"newmtl NoColor\n";
186        let materials = parse_mtl(&mtl[..]);
187        assert_eq!(materials.len(), 1);
188        let mat = &materials["NoColor"];
189        assert_eq!(mat.display_color, Color::new(128, 128, 128, 255));
190    }
191
192    #[test]
193    fn test_kd_clamping() {
194        let mtl = b"newmtl Bright\nKd 1.5 -0.5 0.5\n";
195        let materials = parse_mtl(&mtl[..]);
196        let mat = &materials["Bright"];
197        assert_eq!(mat.display_color.r, 255); // 1.5 clamped to 1.0
198        assert_eq!(mat.display_color.g, 0); // -0.5 clamped to 0.0
199        assert_eq!(mat.display_color.b, 128); // 0.5 * 255 = 127.5 -> 128
200    }
201
202    #[test]
203    fn test_map_kd_warning_falls_back() {
204        // Material with map_Kd but also Kd should keep the Kd color
205        let mtl = b"newmtl Textured\nKd 0.0 1.0 0.0\nmap_Kd texture.png\n";
206        let materials = parse_mtl(&mtl[..]);
207        let mat = &materials["Textured"];
208        // Kd should be preserved (green)
209        assert_eq!(mat.display_color, Color::new(0, 255, 0, 255));
210    }
211
212    #[test]
213    fn test_map_kd_without_kd_gets_gray() {
214        // Material with map_Kd but no Kd should default to gray
215        let mtl = b"newmtl TexturedOnly\nmap_Kd texture.png\n";
216        let materials = parse_mtl(&mtl[..]);
217        let mat = &materials["TexturedOnly"];
218        assert_eq!(mat.display_color, Color::new(128, 128, 128, 255));
219    }
220
221    #[test]
222    fn test_multiple_materials() {
223        let mtl = b"newmtl Red\nKd 1.0 0.0 0.0\nnewmtl Blue\nKd 0.0 0.0 1.0\nnewmtl Green\nKd 0.0 1.0 0.0\n";
224        let materials = parse_mtl(&mtl[..]);
225        assert_eq!(materials.len(), 3);
226        assert_eq!(materials["Red"].display_color, Color::new(255, 0, 0, 255));
227        assert_eq!(materials["Blue"].display_color, Color::new(0, 0, 255, 255));
228        assert_eq!(materials["Green"].display_color, Color::new(0, 255, 0, 255));
229    }
230
231    #[test]
232    fn test_bad_lines_are_skipped() {
233        let mtl = b"newmtl Good\nKd 1.0 0.0 0.0\ngarbage line here\nKd bad bad bad\n";
234        let materials = parse_mtl(&mtl[..]);
235        assert_eq!(materials.len(), 1);
236        // The first valid Kd should have been applied before the bad Kd
237        // The bad Kd line is skipped (parse failure), so the first Kd sticks
238        assert_eq!(materials["Good"].display_color, Color::new(255, 0, 0, 255));
239    }
240
241    #[test]
242    fn test_empty_input() {
243        let mtl = b"";
244        let materials = parse_mtl(&mtl[..]);
245        assert!(materials.is_empty());
246    }
247
248    #[test]
249    fn test_comments_and_blank_lines() {
250        let mtl = b"# comment\n\nnewmtl Mat\n# another comment\nKd 0.5 0.5 0.5\n\n";
251        let materials = parse_mtl(&mtl[..]);
252        assert_eq!(materials.len(), 1);
253        assert_eq!(
254            materials["Mat"].display_color,
255            Color::new(128, 128, 128, 255)
256        );
257    }
258
259    #[test]
260    fn test_ignored_directives() {
261        let mtl =
262            b"newmtl Fancy\nKa 0.1 0.1 0.1\nKd 1.0 0.0 0.0\nKs 1.0 1.0 1.0\nNs 100.0\nNi 1.5\nillum 2\nd 0.5\nTr 0.5\nKe 0.0 0.0 0.0\n";
263        let materials = parse_mtl(&mtl[..]);
264        assert_eq!(materials.len(), 1);
265        // Only Kd matters
266        assert_eq!(materials["Fancy"].display_color, Color::new(255, 0, 0, 255));
267    }
268
269    #[test]
270    fn test_material_name_with_spaces() {
271        let mtl = b"newmtl My Material Name\nKd 0.0 0.0 1.0\n";
272        let materials = parse_mtl(&mtl[..]);
273        assert_eq!(materials.len(), 1);
274        assert!(materials.contains_key("My Material Name"));
275    }
276
277    #[test]
278    fn test_parse_mtl_file_nonexistent() {
279        let materials = parse_mtl_file(Path::new("/tmp/nonexistent_test_mtl_file_12345.mtl"));
280        assert!(materials.is_empty());
281    }
282}