Skip to main content

oxihuman_export/
gltf_ext.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! GLTF 2.0 material extension support (KHR extensions as JSON).
5//!
6//! Produces `serde_json::Value` objects that can be embedded in GLTF material
7//! `extensions` objects.  All KHR extension names follow the GLTF 2.0 spec.
8
9#![allow(dead_code)]
10
11use serde_json::{json, Value};
12
13// ---------------------------------------------------------------------------
14// KHR_materials_unlit
15// ---------------------------------------------------------------------------
16
17/// Return the JSON value for the `KHR_materials_unlit` extension object.
18///
19/// The extension object is empty (`{}`); its presence alone signals unlit
20/// rendering.
21pub fn khr_materials_unlit() -> Value {
22    json!({})
23}
24
25// ---------------------------------------------------------------------------
26// KHR_materials_emissive_strength
27// ---------------------------------------------------------------------------
28
29/// Return the JSON value for the `KHR_materials_emissive_strength` extension.
30///
31/// `emissive_strength` multiplies the emissive colour; values > 1.0 allow
32/// HDR emissive outputs.
33pub fn khr_materials_emissive_strength(emissive_strength: f32) -> Value {
34    json!({ "emissiveStrength": emissive_strength })
35}
36
37// ---------------------------------------------------------------------------
38// KHR_materials_clearcoat
39// ---------------------------------------------------------------------------
40
41/// Parameters for the `KHR_materials_clearcoat` extension.
42#[derive(Debug, Clone, PartialEq)]
43pub struct ClearcoatExt {
44    /// Clear-coat layer intensity (default `0.0`).
45    pub clearcoat_factor: f32,
46    /// Clear-coat layer roughness (default `0.0`).
47    pub clearcoat_roughness_factor: f32,
48}
49
50impl Default for ClearcoatExt {
51    fn default() -> Self {
52        Self {
53            clearcoat_factor: 0.0,
54            clearcoat_roughness_factor: 0.0,
55        }
56    }
57}
58
59/// Return the JSON value for the `KHR_materials_clearcoat` extension.
60pub fn khr_materials_clearcoat(params: &ClearcoatExt) -> Value {
61    json!({
62        "clearcoatFactor":          params.clearcoat_factor,
63        "clearcoatRoughnessFactor": params.clearcoat_roughness_factor,
64    })
65}
66
67// ---------------------------------------------------------------------------
68// KHR_materials_sheen
69// ---------------------------------------------------------------------------
70
71/// Parameters for the `KHR_materials_sheen` extension.
72#[derive(Debug, Clone, PartialEq)]
73pub struct SheenExt {
74    /// RGB sheen colour factor (default `[0, 0, 0]`).
75    pub sheen_color_factor: [f32; 3],
76    /// Sheen roughness (default `0.0`).
77    pub sheen_roughness_factor: f32,
78}
79
80impl Default for SheenExt {
81    fn default() -> Self {
82        Self {
83            sheen_color_factor: [0.0, 0.0, 0.0],
84            sheen_roughness_factor: 0.0,
85        }
86    }
87}
88
89/// Return the JSON value for the `KHR_materials_sheen` extension.
90pub fn khr_materials_sheen(params: &SheenExt) -> Value {
91    let [r, g, b] = params.sheen_color_factor;
92    json!({
93        "sheenColorFactor":     [r, g, b],
94        "sheenRoughnessFactor": params.sheen_roughness_factor,
95    })
96}
97
98// ---------------------------------------------------------------------------
99// KHR_materials_transmission
100// ---------------------------------------------------------------------------
101
102/// Return the JSON value for the `KHR_materials_transmission` extension.
103pub fn khr_materials_transmission(transmission_factor: f32) -> Value {
104    json!({ "transmissionFactor": transmission_factor })
105}
106
107// ---------------------------------------------------------------------------
108// KHR_materials_volume
109// ---------------------------------------------------------------------------
110
111/// Parameters for the `KHR_materials_volume` extension.
112#[derive(Debug, Clone, PartialEq)]
113pub struct VolumeExt {
114    /// Thickness of the volume in meters (default `0.0`).
115    pub thickness_factor: f32,
116    /// Distance at which the attenuation colour becomes dominant
117    /// (default `f32::INFINITY`).
118    pub attenuation_distance: f32,
119    /// Colour of the medium when `attenuation_distance` is reached
120    /// (default `[1, 1, 1]`).
121    pub attenuation_color: [f32; 3],
122}
123
124impl Default for VolumeExt {
125    fn default() -> Self {
126        Self {
127            thickness_factor: 0.0,
128            attenuation_distance: f32::INFINITY,
129            attenuation_color: [1.0, 1.0, 1.0],
130        }
131    }
132}
133
134/// Return the JSON value for the `KHR_materials_volume` extension.
135///
136/// `f32::INFINITY` is serialised as the JSON number that best approximates it.
137/// Most runtimes accept `1.7976931348623157e308` (f64::MAX) as a stand-in for
138/// infinity when the spec says "a very large number".  We use `f64::MAX` here
139/// to remain well-formed JSON.
140pub fn khr_materials_volume(params: &VolumeExt) -> Value {
141    let attn_dist = if params.attenuation_distance.is_infinite() {
142        f64::MAX
143    } else {
144        f64::from(params.attenuation_distance)
145    };
146    let [r, g, b] = params.attenuation_color;
147    json!({
148        "thicknessFactor":      params.thickness_factor,
149        "attenuationDistance":  attn_dist,
150        "attenuationColor":     [r, g, b],
151    })
152}
153
154// ---------------------------------------------------------------------------
155// KHR_materials_ior
156// ---------------------------------------------------------------------------
157
158/// Return the JSON value for the `KHR_materials_ior` extension.
159///
160/// `ior` is the index of refraction (default `1.5`).
161pub fn khr_materials_ior(ior: f32) -> Value {
162    json!({ "ior": ior })
163}
164
165// ---------------------------------------------------------------------------
166// KHR_materials_specular
167// ---------------------------------------------------------------------------
168
169/// Parameters for the `KHR_materials_specular` extension.
170#[derive(Debug, Clone, PartialEq)]
171pub struct SpecularExt {
172    /// Specular intensity (default `1.0`).
173    pub specular_factor: f32,
174    /// Specular tint colour (default `[1, 1, 1]`).
175    pub specular_color_factor: [f32; 3],
176}
177
178impl Default for SpecularExt {
179    fn default() -> Self {
180        Self {
181            specular_factor: 1.0,
182            specular_color_factor: [1.0, 1.0, 1.0],
183        }
184    }
185}
186
187/// Return the JSON value for the `KHR_materials_specular` extension.
188pub fn khr_materials_specular(params: &SpecularExt) -> Value {
189    let [r, g, b] = params.specular_color_factor;
190    json!({
191        "specularFactor":      params.specular_factor,
192        "specularColorFactor": [r, g, b],
193    })
194}
195
196// ---------------------------------------------------------------------------
197// AlphaMode
198// ---------------------------------------------------------------------------
199
200/// GLTF alpha-blending mode for a material.
201#[derive(Debug, Clone, PartialEq)]
202pub enum AlphaMode {
203    /// Fully opaque (default).
204    Opaque,
205    /// Alpha-test with the given cutoff value.
206    Mask(f32),
207    /// Alpha blending.
208    Blend,
209}
210
211impl AlphaMode {
212    fn as_str(&self) -> &'static str {
213        match self {
214            AlphaMode::Opaque => "OPAQUE",
215            AlphaMode::Mask(_) => "MASK",
216            AlphaMode::Blend => "BLEND",
217        }
218    }
219}
220
221// ---------------------------------------------------------------------------
222// GltfMaterialDef
223// ---------------------------------------------------------------------------
224
225/// A complete PBR material definition with optional KHR extensions.
226#[derive(Debug, Clone)]
227pub struct GltfMaterialDef {
228    /// Human-readable name.
229    pub name: String,
230    /// Base colour RGBA in linear space.
231    pub base_color: [f32; 4],
232    /// Metallic factor `[0, 1]`.
233    pub metallic_factor: f32,
234    /// Roughness factor `[0, 1]`.
235    pub roughness_factor: f32,
236    /// Emissive RGB factor.
237    pub emissive_factor: [f32; 3],
238    /// Alpha blending mode.
239    pub alpha_mode: AlphaMode,
240    /// Whether the material is double-sided.
241    pub double_sided: bool,
242    /// Ordered list of `(extensionName, extensionJSON)` pairs.
243    pub extensions: Vec<(String, Value)>,
244}
245
246impl Default for GltfMaterialDef {
247    fn default() -> Self {
248        Self {
249            name: "default".to_string(),
250            base_color: [0.8, 0.8, 0.8, 1.0],
251            metallic_factor: 0.0,
252            roughness_factor: 0.5,
253            emissive_factor: [0.0, 0.0, 0.0],
254            alpha_mode: AlphaMode::Opaque,
255            double_sided: false,
256            extensions: Vec::new(),
257        }
258    }
259}
260
261impl GltfMaterialDef {
262    // ------------------------------------------------------------------
263    // Presets
264    // ------------------------------------------------------------------
265
266    /// Skin material preset — warm pinkish, non-metallic, medium roughness,
267    /// double-sided.
268    pub fn skin() -> Self {
269        Self {
270            name: "skin".to_string(),
271            base_color: [0.94, 0.76, 0.69, 1.0],
272            metallic_factor: 0.0,
273            roughness_factor: 0.5,
274            emissive_factor: [0.0, 0.0, 0.0],
275            alpha_mode: AlphaMode::Opaque,
276            double_sided: true,
277            extensions: Vec::new(),
278        }
279    }
280
281    /// Cloth material preset — uses `KHR_materials_sheen` for fabric look.
282    pub fn cloth() -> Self {
283        let sheen = khr_materials_sheen(&SheenExt {
284            sheen_color_factor: [0.8, 0.6, 0.4],
285            sheen_roughness_factor: 0.7,
286        });
287        Self {
288            name: "cloth".to_string(),
289            base_color: [0.15, 0.15, 0.20, 1.0],
290            metallic_factor: 0.0,
291            roughness_factor: 0.85,
292            emissive_factor: [0.0, 0.0, 0.0],
293            alpha_mode: AlphaMode::Opaque,
294            double_sided: false,
295            extensions: vec![("KHR_materials_sheen".to_string(), sheen)],
296        }
297    }
298
299    /// Glass / transparent preset — uses `KHR_materials_transmission` +
300    /// `KHR_materials_volume` + `KHR_materials_ior`.
301    pub fn glass() -> Self {
302        let transmission = khr_materials_transmission(1.0);
303        let volume = khr_materials_volume(&VolumeExt {
304            thickness_factor: 0.5,
305            attenuation_distance: 5.0,
306            attenuation_color: [0.95, 0.97, 1.0],
307        });
308        let ior = khr_materials_ior(1.5);
309        Self {
310            name: "glass".to_string(),
311            base_color: [1.0, 1.0, 1.0, 0.0],
312            metallic_factor: 0.0,
313            roughness_factor: 0.05,
314            emissive_factor: [0.0, 0.0, 0.0],
315            alpha_mode: AlphaMode::Blend,
316            double_sided: true,
317            extensions: vec![
318                ("KHR_materials_transmission".to_string(), transmission),
319                ("KHR_materials_volume".to_string(), volume),
320                ("KHR_materials_ior".to_string(), ior),
321            ],
322        }
323    }
324
325    /// Metallic preset — uses `KHR_materials_specular` for tinted reflections.
326    pub fn metallic() -> Self {
327        let specular = khr_materials_specular(&SpecularExt {
328            specular_factor: 1.0,
329            specular_color_factor: [0.9, 0.85, 0.8],
330        });
331        Self {
332            name: "metallic".to_string(),
333            base_color: [0.7, 0.7, 0.7, 1.0],
334            metallic_factor: 1.0,
335            roughness_factor: 0.1,
336            emissive_factor: [0.0, 0.0, 0.0],
337            alpha_mode: AlphaMode::Opaque,
338            double_sided: false,
339            extensions: vec![("KHR_materials_specular".to_string(), specular)],
340        }
341    }
342
343    // ------------------------------------------------------------------
344    // Builder
345    // ------------------------------------------------------------------
346
347    /// Add (or replace) an extension by name.
348    pub fn with_extension(mut self, name: &str, value: Value) -> Self {
349        // Replace an existing entry with the same name if present.
350        if let Some(pos) = self.extensions.iter().position(|(n, _)| n == name) {
351            self.extensions[pos].1 = value;
352        } else {
353            self.extensions.push((name.to_string(), value));
354        }
355        self
356    }
357
358    // ------------------------------------------------------------------
359    // Serialisation
360    // ------------------------------------------------------------------
361
362    /// Serialise to a GLTF 2.0 JSON material object.
363    pub fn to_json(&self) -> Value {
364        let [r, g, b, a] = self.base_color;
365        let [er, eg, eb] = self.emissive_factor;
366
367        let mut mat = json!({
368            "name": self.name,
369            "pbrMetallicRoughness": {
370                "baseColorFactor": [r, g, b, a],
371                "metallicFactor":  self.metallic_factor,
372                "roughnessFactor": self.roughness_factor,
373            },
374            "emissiveFactor": [er, eg, eb],
375            "alphaMode":      self.alpha_mode.as_str(),
376            "doubleSided":    self.double_sided,
377        });
378
379        // alphaCutoff is only relevant for MASK mode.
380        if let AlphaMode::Mask(cutoff) = self.alpha_mode {
381            mat["alphaCutoff"] = json!(cutoff);
382        }
383
384        // Embed extensions object if any.
385        if !self.extensions.is_empty() {
386            let mut ext_obj = serde_json::Map::new();
387            for (name, val) in &self.extensions {
388                ext_obj.insert(name.clone(), val.clone());
389            }
390            mat["extensions"] = Value::Object(ext_obj);
391        }
392
393        mat
394    }
395
396    /// Return the names of all extensions attached to this material.
397    pub fn extension_names(&self) -> Vec<&str> {
398        self.extensions.iter().map(|(n, _)| n.as_str()).collect()
399    }
400}
401
402// ---------------------------------------------------------------------------
403// Build a GLTF materials array
404// ---------------------------------------------------------------------------
405
406/// Build a GLTF-style `materials` JSON array from a slice of
407/// [`GltfMaterialDef`] values.
408pub fn build_materials_json(materials: &[GltfMaterialDef]) -> Value {
409    let arr: Vec<Value> = materials.iter().map(|m| m.to_json()).collect();
410    Value::Array(arr)
411}
412
413// ---------------------------------------------------------------------------
414// Validate a material JSON object
415// ---------------------------------------------------------------------------
416
417/// Validate that a JSON value is a well-formed GLTF material object.
418///
419/// Checks for the required `pbrMetallicRoughness` sub-object and that scalar
420/// factors are in the expected ranges.  Returns `Err` with a description on
421/// the first failure.
422pub fn validate_material_json(mat: &Value) -> Result<(), String> {
423    let pbr = mat
424        .get("pbrMetallicRoughness")
425        .ok_or_else(|| "missing 'pbrMetallicRoughness'".to_string())?;
426
427    if !pbr.is_object() {
428        return Err("'pbrMetallicRoughness' must be an object".to_string());
429    }
430
431    // Validate metallic factor.
432    if let Some(mf) = pbr.get("metallicFactor") {
433        let v = mf
434            .as_f64()
435            .ok_or_else(|| "'metallicFactor' must be a number".to_string())?;
436        if !(0.0..=1.0).contains(&v) {
437            return Err(format!("'metallicFactor' out of range [0,1]: {v}"));
438        }
439    }
440
441    // Validate roughness factor.
442    if let Some(rf) = pbr.get("roughnessFactor") {
443        let v = rf
444            .as_f64()
445            .ok_or_else(|| "'roughnessFactor' must be a number".to_string())?;
446        if !(0.0..=1.0).contains(&v) {
447            return Err(format!("'roughnessFactor' out of range [0,1]: {v}"));
448        }
449    }
450
451    // Validate baseColorFactor length if present.
452    if let Some(bcf) = pbr.get("baseColorFactor") {
453        let arr = bcf
454            .as_array()
455            .ok_or_else(|| "'baseColorFactor' must be an array".to_string())?;
456        if arr.len() != 4 {
457            return Err(format!(
458                "'baseColorFactor' must have 4 elements, got {}",
459                arr.len()
460            ));
461        }
462    }
463
464    // Validate alphaMode string if present.
465    if let Some(am) = mat.get("alphaMode") {
466        let s = am
467            .as_str()
468            .ok_or_else(|| "'alphaMode' must be a string".to_string())?;
469        if !matches!(s, "OPAQUE" | "MASK" | "BLEND") {
470            return Err(format!("unknown 'alphaMode': '{s}'"));
471        }
472    }
473
474    Ok(())
475}
476
477// ---------------------------------------------------------------------------
478// Extract extensionsUsed list
479// ---------------------------------------------------------------------------
480
481/// Extract the list of extension name strings from the top-level
482/// `extensionsUsed` array of a GLTF JSON document.
483///
484/// Returns an empty `Vec` when the key is absent or not an array.
485pub fn extract_extensions_used(gltf_json: &Value) -> Vec<String> {
486    gltf_json
487        .get("extensionsUsed")
488        .and_then(|v| v.as_array())
489        .map(|arr| {
490            arr.iter()
491                .filter_map(|v| v.as_str().map(str::to_owned))
492                .collect()
493        })
494        .unwrap_or_default()
495}
496
497// ===========================================================================
498// Tests
499// ===========================================================================
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504    use std::fs;
505
506    // -----------------------------------------------------------------------
507    // 1. khr_materials_unlit returns empty object
508    // -----------------------------------------------------------------------
509    #[test]
510    fn test_unlit_is_empty_object() {
511        let v = khr_materials_unlit();
512        assert!(v.is_object());
513        assert_eq!(v.as_object().expect("should succeed").len(), 0);
514    }
515
516    // -----------------------------------------------------------------------
517    // 2. khr_materials_emissive_strength carries correct value
518    // -----------------------------------------------------------------------
519    #[test]
520    fn test_emissive_strength_value() {
521        let v = khr_materials_emissive_strength(3.5);
522        assert!((v["emissiveStrength"].as_f64().expect("should succeed") - 3.5).abs() < 1e-6);
523    }
524
525    // -----------------------------------------------------------------------
526    // 3. khr_materials_clearcoat fields present
527    // -----------------------------------------------------------------------
528    #[test]
529    fn test_clearcoat_fields() {
530        let p = ClearcoatExt {
531            clearcoat_factor: 0.8,
532            clearcoat_roughness_factor: 0.3,
533        };
534        let v = khr_materials_clearcoat(&p);
535        assert!((v["clearcoatFactor"].as_f64().expect("should succeed") - 0.8).abs() < 1e-6);
536        assert!((v["clearcoatRoughnessFactor"].as_f64().expect("should succeed") - 0.3).abs() < 1e-6);
537    }
538
539    // -----------------------------------------------------------------------
540    // 4. khr_materials_sheen colour array length
541    // -----------------------------------------------------------------------
542    #[test]
543    fn test_sheen_color_array_length() {
544        let p = SheenExt {
545            sheen_color_factor: [0.5, 0.2, 0.8],
546            sheen_roughness_factor: 0.6,
547        };
548        let v = khr_materials_sheen(&p);
549        assert_eq!(v["sheenColorFactor"].as_array().expect("should succeed").len(), 3);
550    }
551
552    // -----------------------------------------------------------------------
553    // 5. khr_materials_transmission value round-trip
554    // -----------------------------------------------------------------------
555    #[test]
556    fn test_transmission_round_trip() {
557        let v = khr_materials_transmission(0.75);
558        assert!((v["transmissionFactor"].as_f64().expect("should succeed") - 0.75).abs() < 1e-6);
559    }
560
561    // -----------------------------------------------------------------------
562    // 6. khr_materials_volume with infinity → large JSON number
563    // -----------------------------------------------------------------------
564    #[test]
565    fn test_volume_infinity_becomes_large_number() {
566        let p = VolumeExt::default(); // attenuation_distance = INFINITY
567        let v = khr_materials_volume(&p);
568        let dist = v["attenuationDistance"].as_f64().expect("should succeed");
569        assert!(dist > 1e300, "expected very large number, got {dist}");
570    }
571
572    // -----------------------------------------------------------------------
573    // 7. khr_materials_ior default value
574    // -----------------------------------------------------------------------
575    #[test]
576    fn test_ior_default() {
577        let v = khr_materials_ior(1.5);
578        assert!((v["ior"].as_f64().expect("should succeed") - 1.5).abs() < 1e-6);
579    }
580
581    // -----------------------------------------------------------------------
582    // 8. khr_materials_specular colour has three components
583    // -----------------------------------------------------------------------
584    #[test]
585    fn test_specular_color_components() {
586        let p = SpecularExt::default();
587        let v = khr_materials_specular(&p);
588        let arr = v["specularColorFactor"].as_array().expect("should succeed");
589        assert_eq!(arr.len(), 3);
590        assert!((arr[0].as_f64().expect("should succeed") - 1.0).abs() < 1e-6);
591    }
592
593    // -----------------------------------------------------------------------
594    // 9. GltfMaterialDef::skin preset serialises correctly
595    // -----------------------------------------------------------------------
596    #[test]
597    fn test_skin_preset_to_json() {
598        let mat = GltfMaterialDef::skin();
599        let j = mat.to_json();
600        assert_eq!(j["name"].as_str().expect("should succeed"), "skin");
601        assert!(j["pbrMetallicRoughness"].is_object());
602        assert!(j["doubleSided"].as_bool().expect("should succeed"));
603        assert_eq!(j["alphaMode"].as_str().expect("should succeed"), "OPAQUE");
604    }
605
606    // -----------------------------------------------------------------------
607    // 10. GltfMaterialDef::glass has extensions
608    // -----------------------------------------------------------------------
609    #[test]
610    fn test_glass_preset_has_extensions() {
611        let mat = GltfMaterialDef::glass();
612        let names = mat.extension_names();
613        assert!(names.contains(&"KHR_materials_transmission"));
614        assert!(names.contains(&"KHR_materials_volume"));
615        assert!(names.contains(&"KHR_materials_ior"));
616        let j = mat.to_json();
617        assert!(j["extensions"].is_object());
618    }
619
620    // -----------------------------------------------------------------------
621    // 11. AlphaMode::Mask serialises alphaCutoff
622    // -----------------------------------------------------------------------
623    #[test]
624    fn test_alpha_mask_cutoff_in_json() {
625        let mat = GltfMaterialDef {
626            alpha_mode: AlphaMode::Mask(0.5),
627            ..Default::default()
628        };
629        let j = mat.to_json();
630        assert_eq!(j["alphaMode"].as_str().expect("should succeed"), "MASK");
631        assert!((j["alphaCutoff"].as_f64().expect("should succeed") - 0.5).abs() < 1e-6);
632    }
633
634    // -----------------------------------------------------------------------
635    // 12. build_materials_json produces correct length array
636    // -----------------------------------------------------------------------
637    #[test]
638    fn test_build_materials_json_length() {
639        let mats = vec![
640            GltfMaterialDef::skin(),
641            GltfMaterialDef::cloth(),
642            GltfMaterialDef::glass(),
643            GltfMaterialDef::metallic(),
644        ];
645        let j = build_materials_json(&mats);
646        assert_eq!(j.as_array().expect("should succeed").len(), 4);
647    }
648
649    // -----------------------------------------------------------------------
650    // 13. validate_material_json accepts valid and rejects invalid
651    // -----------------------------------------------------------------------
652    #[test]
653    fn test_validate_material_json() {
654        let good = GltfMaterialDef::skin().to_json();
655        assert!(validate_material_json(&good).is_ok());
656
657        let bad = json!({ "name": "no_pbr" });
658        assert!(validate_material_json(&bad).is_err());
659
660        let out_of_range = json!({
661            "name": "bad",
662            "pbrMetallicRoughness": {
663                "metallicFactor": 2.5
664            }
665        });
666        assert!(validate_material_json(&out_of_range).is_err());
667    }
668
669    // -----------------------------------------------------------------------
670    // 14. extract_extensions_used happy path
671    // -----------------------------------------------------------------------
672    #[test]
673    fn test_extract_extensions_used() {
674        let gltf = json!({
675            "extensionsUsed": [
676                "KHR_materials_unlit",
677                "KHR_materials_transmission"
678            ]
679        });
680        let list = extract_extensions_used(&gltf);
681        assert_eq!(list.len(), 2);
682        assert!(list.contains(&"KHR_materials_unlit".to_string()));
683    }
684
685    // -----------------------------------------------------------------------
686    // 15. with_extension replaces duplicate and appends new
687    // -----------------------------------------------------------------------
688    #[test]
689    fn test_with_extension_dedup() {
690        let mat = GltfMaterialDef::default()
691            .with_extension("KHR_materials_unlit", khr_materials_unlit())
692            .with_extension("KHR_materials_ior", khr_materials_ior(1.5))
693            // Replace the ior value.
694            .with_extension("KHR_materials_ior", khr_materials_ior(1.8));
695
696        assert_eq!(
697            mat.extensions.len(),
698            2,
699            "duplicate extension should be replaced"
700        );
701        let ior_val = mat
702            .extensions
703            .iter()
704            .find(|(n, _)| n == "KHR_materials_ior")
705            .map(|(_, v)| v["ior"].as_f64().expect("should succeed"))
706            .expect("should succeed");
707        assert!((ior_val - 1.8).abs() < 1e-6);
708    }
709
710    // -----------------------------------------------------------------------
711    // 16. Write materials JSON to /tmp/ and read it back
712    // -----------------------------------------------------------------------
713    #[test]
714    fn test_write_materials_to_tmp() {
715        let mats = vec![GltfMaterialDef::skin(), GltfMaterialDef::glass()];
716        let j = build_materials_json(&mats);
717        let path = "/tmp/oxihuman_gltf_ext_test_materials.json";
718        let s = serde_json::to_string_pretty(&j).expect("should succeed");
719        fs::write(path, &s).expect("should succeed");
720        let raw = fs::read_to_string(path).expect("should succeed");
721        let parsed: Value = serde_json::from_str(&raw).expect("should succeed");
722        assert_eq!(parsed.as_array().expect("should succeed").len(), 2);
723    }
724
725    // -----------------------------------------------------------------------
726    // 17. cloth preset extension names
727    // -----------------------------------------------------------------------
728    #[test]
729    fn test_cloth_preset_sheen_extension() {
730        let mat = GltfMaterialDef::cloth();
731        assert!(mat.extension_names().contains(&"KHR_materials_sheen"));
732    }
733
734    // -----------------------------------------------------------------------
735    // 18. extract_extensions_used returns empty for missing key
736    // -----------------------------------------------------------------------
737    #[test]
738    fn test_extract_extensions_used_missing() {
739        let gltf = json!({ "asset": { "version": "2.0" } });
740        let list = extract_extensions_used(&gltf);
741        assert!(list.is_empty());
742    }
743}