Skip to main content

oxihuman_export/
material_export.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Material/shader property export to JSON/glTF-compatible format.
5
6/// A single material property.
7#[allow(dead_code)]
8#[derive(Clone, Debug, PartialEq)]
9pub enum MaterialProperty {
10    Float(f32),
11    Color([f32; 4]),
12    TexturePath(String),
13}
14
15/// A material that can be exported.
16#[allow(dead_code)]
17#[derive(Clone, Debug)]
18pub struct ExportMaterial {
19    pub name: String,
20    pub properties: Vec<(String, MaterialProperty)>,
21}
22
23/// Configuration for material export.
24#[allow(dead_code)]
25pub struct MaterialExportConfig {
26    pub pretty_print: bool,
27    pub include_defaults: bool,
28    pub gltf_compatible: bool,
29}
30
31/// A bundle of materials for batch export.
32#[allow(dead_code)]
33pub struct MaterialExportBundle {
34    pub materials: Vec<ExportMaterial>,
35}
36
37/// Type alias for property lookup result.
38#[allow(dead_code)]
39pub type PropertyLookup<'a> = Option<&'a MaterialProperty>;
40
41/// Type alias for validation result.
42#[allow(dead_code)]
43pub type ValidationResult = Vec<String>;
44
45// ── Public API ────────────────────────────────────────────────────────────────
46
47/// Create a default material export configuration.
48#[allow(dead_code)]
49pub fn default_material_export_config() -> MaterialExportConfig {
50    MaterialExportConfig {
51        pretty_print: true,
52        include_defaults: false,
53        gltf_compatible: true,
54    }
55}
56
57/// Create a new empty export material.
58#[allow(dead_code)]
59pub fn new_export_material(name: &str) -> ExportMaterial {
60    ExportMaterial {
61        name: name.to_string(),
62        properties: Vec::new(),
63    }
64}
65
66/// Set a float property on a material.
67#[allow(dead_code)]
68pub fn set_property_float(mat: &mut ExportMaterial, key: &str, value: f32) {
69    remove_property(mat, key);
70    mat.properties
71        .push((key.to_string(), MaterialProperty::Float(value)));
72}
73
74/// Set a color property (RGBA) on a material.
75#[allow(dead_code)]
76pub fn set_property_color(mat: &mut ExportMaterial, key: &str, rgba: [f32; 4]) {
77    remove_property(mat, key);
78    mat.properties
79        .push((key.to_string(), MaterialProperty::Color(rgba)));
80}
81
82/// Set a texture path property on a material.
83#[allow(dead_code)]
84pub fn set_property_texture_path(mat: &mut ExportMaterial, key: &str, path: &str) {
85    remove_property(mat, key);
86    mat.properties.push((
87        key.to_string(),
88        MaterialProperty::TexturePath(path.to_string()),
89    ));
90}
91
92/// Get a property by name.
93#[allow(dead_code)]
94pub fn get_property<'a>(mat: &'a ExportMaterial, key: &str) -> PropertyLookup<'a> {
95    mat.properties
96        .iter()
97        .find(|(k, _)| k == key)
98        .map(|(_, v)| v)
99}
100
101/// Serialize a material to a JSON string.
102#[allow(dead_code)]
103pub fn material_to_json(mat: &ExportMaterial) -> String {
104    let mut out = String::from("{\n");
105    out.push_str(&format!("  \"name\": \"{}\",\n", mat.name));
106    out.push_str("  \"properties\": {\n");
107    for (i, (k, v)) in mat.properties.iter().enumerate() {
108        let comma = if i + 1 < mat.properties.len() {
109            ","
110        } else {
111            ""
112        };
113        match v {
114            MaterialProperty::Float(f) => {
115                out.push_str(&format!("    \"{k}\": {f:.6}{comma}\n"));
116            }
117            MaterialProperty::Color(c) => {
118                out.push_str(&format!(
119                    "    \"{k}\": [{:.4}, {:.4}, {:.4}, {:.4}]{comma}\n",
120                    c[0], c[1], c[2], c[3]
121                ));
122            }
123            MaterialProperty::TexturePath(p) => {
124                out.push_str(&format!("    \"{k}\": \"{p}\"{comma}\n"));
125            }
126        }
127    }
128    out.push_str("  }\n}");
129    out
130}
131
132/// Serialize a material to glTF-compatible PBR JSON.
133#[allow(dead_code)]
134pub fn material_to_gltf_json(mat: &ExportMaterial) -> String {
135    let mut out = String::from("{\n");
136    out.push_str(&format!("  \"name\": \"{}\",\n", mat.name));
137    out.push_str("  \"pbrMetallicRoughness\": {\n");
138
139    let base_color = mat
140        .properties
141        .iter()
142        .find(|(k, _)| k == "baseColor")
143        .and_then(|(_, v)| match v {
144            MaterialProperty::Color(c) => Some(*c),
145            _ => None,
146        })
147        .unwrap_or([1.0, 1.0, 1.0, 1.0]);
148
149    let metallic = mat
150        .properties
151        .iter()
152        .find(|(k, _)| k == "metallic")
153        .and_then(|(_, v)| match v {
154            MaterialProperty::Float(f) => Some(*f),
155            _ => None,
156        })
157        .unwrap_or(0.0);
158
159    let roughness = mat
160        .properties
161        .iter()
162        .find(|(k, _)| k == "roughness")
163        .and_then(|(_, v)| match v {
164            MaterialProperty::Float(f) => Some(*f),
165            _ => None,
166        })
167        .unwrap_or(0.5);
168
169    out.push_str(&format!(
170        "    \"baseColorFactor\": [{:.4}, {:.4}, {:.4}, {:.4}],\n",
171        base_color[0], base_color[1], base_color[2], base_color[3]
172    ));
173    out.push_str(&format!("    \"metallicFactor\": {metallic:.4},\n"));
174    out.push_str(&format!("    \"roughnessFactor\": {roughness:.4}\n"));
175    out.push_str("  }\n}");
176    out
177}
178
179/// Return the number of materials in a bundle.
180#[allow(dead_code)]
181pub fn material_count(bundle: &MaterialExportBundle) -> usize {
182    bundle.materials.len()
183}
184
185/// Add a material to an export bundle.
186#[allow(dead_code)]
187pub fn add_material_to_bundle(bundle: &mut MaterialExportBundle, mat: ExportMaterial) {
188    bundle.materials.push(mat);
189}
190
191/// Return the number of properties on a material.
192#[allow(dead_code)]
193pub fn material_property_count(mat: &ExportMaterial) -> usize {
194    mat.properties.len()
195}
196
197/// Validate a material. Returns a list of warnings (empty means valid).
198#[allow(dead_code)]
199pub fn validate_material(mat: &ExportMaterial) -> ValidationResult {
200    let mut warnings = Vec::new();
201    if mat.name.is_empty() {
202        warnings.push("Material name is empty".to_string());
203    }
204    for (k, v) in &mat.properties {
205        if k.is_empty() {
206            warnings.push("Empty property key".to_string());
207        }
208        if let MaterialProperty::Float(f) = v {
209            if f.is_nan() || f.is_infinite() {
210                warnings.push(format!("Property '{k}' has non-finite value"));
211            }
212        }
213        if let MaterialProperty::Color(c) = v {
214            for (ci, &ch) in c.iter().enumerate() {
215                if ch.is_nan() || ch.is_infinite() {
216                    warnings.push(format!("Property '{k}' color channel {ci} is non-finite"));
217                }
218            }
219        }
220    }
221    warnings
222}
223
224/// Create a default PBR material with standard properties.
225#[allow(dead_code)]
226pub fn default_pbr_material(name: &str) -> ExportMaterial {
227    let mut mat = new_export_material(name);
228    set_property_color(&mut mat, "baseColor", [0.8, 0.8, 0.8, 1.0]);
229    set_property_float(&mut mat, "metallic", 0.0);
230    set_property_float(&mut mat, "roughness", 0.5);
231    set_property_float(&mut mat, "emissive", 0.0);
232    mat
233}
234
235// ── Private helpers ───────────────────────────────────────────────────────────
236
237fn remove_property(mat: &mut ExportMaterial, key: &str) {
238    mat.properties.retain(|(k, _)| k != key);
239}
240
241// ── Tests ─────────────────────────────────────────────────────────────────────
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn default_config() {
249        let cfg = default_material_export_config();
250        assert!(cfg.pretty_print);
251        assert!(!cfg.include_defaults);
252        assert!(cfg.gltf_compatible);
253    }
254
255    #[test]
256    fn new_material_empty() {
257        let mat = new_export_material("Skin");
258        assert_eq!(mat.name, "Skin");
259        assert!(mat.properties.is_empty());
260    }
261
262    #[test]
263    fn set_get_float() {
264        let mut mat = new_export_material("M");
265        set_property_float(&mut mat, "roughness", 0.7);
266        match get_property(&mat, "roughness") {
267            Some(MaterialProperty::Float(f)) => assert!((*f - 0.7).abs() < 1e-6),
268            _ => panic!("expected float property"),
269        }
270    }
271
272    #[test]
273    fn set_get_color() {
274        let mut mat = new_export_material("M");
275        set_property_color(&mut mat, "baseColor", [1.0, 0.5, 0.0, 1.0]);
276        match get_property(&mat, "baseColor") {
277            Some(MaterialProperty::Color(c)) => {
278                assert!((c[0] - 1.0).abs() < 1e-6);
279                assert!((c[1] - 0.5).abs() < 1e-6);
280            }
281            _ => panic!("expected color property"),
282        }
283    }
284
285    #[test]
286    fn set_get_texture() {
287        let mut mat = new_export_material("M");
288        set_property_texture_path(&mut mat, "diffuseMap", "/tex/diffuse.png");
289        match get_property(&mat, "diffuseMap") {
290            Some(MaterialProperty::TexturePath(p)) => assert_eq!(p, "/tex/diffuse.png"),
291            _ => panic!("expected texture property"),
292        }
293    }
294
295    #[test]
296    fn get_missing_property() {
297        let mat = new_export_material("M");
298        assert!(get_property(&mat, "nonexistent").is_none());
299    }
300
301    #[test]
302    fn overwrite_property() {
303        let mut mat = new_export_material("M");
304        set_property_float(&mut mat, "roughness", 0.5);
305        set_property_float(&mut mat, "roughness", 0.9);
306        assert_eq!(material_property_count(&mat), 1);
307        match get_property(&mat, "roughness") {
308            Some(MaterialProperty::Float(f)) => assert!((*f - 0.9).abs() < 1e-6),
309            _ => panic!("expected float"),
310        }
311    }
312
313    #[test]
314    fn material_to_json_contains_name() {
315        let mat = new_export_material("Skin");
316        let json = material_to_json(&mat);
317        assert!(json.contains("\"name\": \"Skin\""));
318    }
319
320    #[test]
321    fn material_to_gltf_json_contains_pbr() {
322        let mat = default_pbr_material("Default");
323        let json = material_to_gltf_json(&mat);
324        assert!(json.contains("pbrMetallicRoughness"));
325        assert!(json.contains("baseColorFactor"));
326        assert!(json.contains("metallicFactor"));
327    }
328
329    #[test]
330    fn bundle_count() {
331        let mut bundle = MaterialExportBundle {
332            materials: Vec::new(),
333        };
334        assert_eq!(material_count(&bundle), 0);
335        add_material_to_bundle(&mut bundle, new_export_material("A"));
336        add_material_to_bundle(&mut bundle, new_export_material("B"));
337        assert_eq!(material_count(&bundle), 2);
338    }
339
340    #[test]
341    fn property_count() {
342        let mat = default_pbr_material("P");
343        assert_eq!(material_property_count(&mat), 4);
344    }
345
346    #[test]
347    fn validate_valid_material() {
348        let mat = default_pbr_material("Valid");
349        let warnings = validate_material(&mat);
350        assert!(warnings.is_empty());
351    }
352
353    #[test]
354    fn validate_empty_name() {
355        let mat = new_export_material("");
356        let warnings = validate_material(&mat);
357        assert!(warnings.iter().any(|w| w.contains("name is empty")));
358    }
359
360    #[test]
361    fn validate_nan_float() {
362        let mut mat = new_export_material("Bad");
363        set_property_float(&mut mat, "roughness", f32::NAN);
364        let warnings = validate_material(&mat);
365        assert!(warnings.iter().any(|w| w.contains("non-finite")));
366    }
367
368    #[test]
369    fn default_pbr_has_expected_properties() {
370        let mat = default_pbr_material("Std");
371        assert!(get_property(&mat, "baseColor").is_some());
372        assert!(get_property(&mat, "metallic").is_some());
373        assert!(get_property(&mat, "roughness").is_some());
374        assert!(get_property(&mat, "emissive").is_some());
375    }
376
377    #[test]
378    fn gltf_json_default_metallic_zero() {
379        let mut mat = new_export_material("M");
380        set_property_color(&mut mat, "baseColor", [1.0, 1.0, 1.0, 1.0]);
381        let json = material_to_gltf_json(&mat);
382        assert!(json.contains("\"metallicFactor\": 0.0000"));
383    }
384}