Skip to main content

khora_io/asset/decoders/
material.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Material decoder: `.kmat` RON bytes → `Box<dyn Material>`.
16//!
17//! A `.kmat` file is RON of the shape
18//! `(type_name: "StandardMaterial", material: ( ...fields... ))` — the same
19//! `{ type_name, material }` split [`material_to_json`] produces, but
20//! RON-encoded. The `type_name` selects the matching `MaterialRegistration`
21//! from the open type-tag registry (re-exported from `khora-data`); that
22//! registration's `deserialize_json` decodes the `material` sub-value into the
23//! concrete material type. Any material type that registers itself (including
24//! plugin types) is therefore loadable from a `.kmat` with no decoder change.
25//!
26//! Decoding goes through `serde_json::Value`: RON is self-describing, so
27//! `ron::de::from_bytes::<serde_json::Value>` yields a serde value tree from
28//! which `type_name` (a string) and `material` (a sub-tree) are pulled, and the
29//! sub-tree is handed straight to `deserialize_json`. This reuses the existing
30//! `serialize_json`/`deserialize_json` machinery the editor inspector already
31//! relies on — no second, RON-specific code path on `MaterialRegistration`.
32//!
33//! Auto-registered via [`inventory::submit!`] under the canonical `"material"`
34//! slot (the extension mapping `kmat|mat → "material"` lives in the index
35//! builder). No fallback material: a missing `type_name`, an unknown type, or a
36//! decode failure is returned as an error and the caller skips the asset.
37
38use std::error::Error;
39
40use khora_core::asset::Material;
41use khora_data::ecs::MaterialRegistration;
42
43use crate::asset::{AssetDecoder, DecoderRegistration};
44
45/// Decodes a `.kmat` RON document into a type-erased [`Material`] via the
46/// `MaterialRegistration` type-tag dispatch.
47#[derive(Clone, Default)]
48pub struct MaterialDecoder;
49
50impl AssetDecoder<Box<dyn Material>> for MaterialDecoder {
51    fn load(
52        &self,
53        bytes: &[u8],
54    ) -> Result<Box<dyn Material>, Box<dyn Error + Send + Sync + 'static>> {
55        decode_material_inner(bytes)
56    }
57}
58
59/// Decodes `.kmat` RON bytes into a [`Material`] without the runtime
60/// [`AssetService`], returning `None` on any failure.
61///
62/// The index builder calls this to read a material's texture references while
63/// scanning a project, where spinning up the asset service would be both
64/// heavyweight and circular. Because [`MaterialDecoder`] is stateless and
65/// dispatches purely through the `inventory` type-tag registry, decoding needs
66/// nothing but the bytes. A decode failure is intentionally swallowed (the
67/// caller logs and continues); use [`MaterialDecoder::load`] when the error
68/// detail matters.
69///
70/// [`AssetService`]: crate::asset::AssetService
71pub fn decode_material(bytes: &[u8]) -> Option<Box<dyn Material>> {
72    decode_material_inner(bytes).ok()
73}
74
75/// Shared `.kmat` RON → [`Material`] decode used by both the [`AssetDecoder`]
76/// impl and the service-free [`decode_material`] helper.
77fn decode_material_inner(
78    bytes: &[u8],
79) -> Result<Box<dyn Material>, Box<dyn Error + Send + Sync + 'static>> {
80    // RON is self-describing, so it deserializes straight into a serde
81    // value tree. From there we read the `{ type_name, material }` split.
82    let doc: serde_json::Value =
83        ron::de::from_bytes(bytes).map_err(|e| format!("failed to parse .kmat RON: {e}"))?;
84
85    let type_name = doc
86        .get("type_name")
87        .and_then(serde_json::Value::as_str)
88        .ok_or("`.kmat` missing string field `type_name`")?;
89
90    let material_value = doc
91        .get("material")
92        .ok_or("`.kmat` missing field `material`")?;
93
94    for reg in inventory::iter::<MaterialRegistration> {
95        if reg.type_name == type_name {
96            let material = (reg.deserialize_json)(material_value)
97                .map_err(|e| format!("failed to decode `{type_name}` material: {e}"))?;
98            return Ok(material);
99        }
100    }
101
102    Err(format!("no MaterialRegistration found for type `{type_name}`").into())
103}
104
105inventory::submit! {
106    DecoderRegistration {
107        type_name: "material",
108        register: |svc| {
109            svc.register_decoder::<Box<dyn Material>>("material", MaterialDecoder);
110        },
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use khora_core::asset::{AssetUUID, EmissiveMaterial, StandardMaterial};
118    use khora_core::math::LinearRgba;
119    use khora_data::ecs::material_to_json;
120
121    /// Renders a `{ type_name, material }` JSON object as a `.kmat` RON
122    /// document — the on-disk form the decoder consumes.
123    fn json_to_kmat_ron(value: &serde_json::Value) -> String {
124        ron::ser::to_string(value).expect("material JSON should encode to RON")
125    }
126
127    #[test]
128    fn decodes_hand_written_standard_kmat_with_texture() {
129        // A hand-authored `.kmat` with distinctive, non-default fields,
130        // including a `base_color_texture` UUID, to prove every field — and
131        // the `Option<AssetUUID>` — survives the RON → value → material bridge.
132        //
133        // The `.kmat` is RON of the `{ type_name, material }` JSON-value tree,
134        // so absent options serialize as `()` (the unit value), unit enum
135        // variants as quoted strings, and UUIDs as quoted strings — exactly
136        // what `material_to_json` → RON produces.
137        let tex_uuid = AssetUUID::new_v5("textures/wood.png");
138        let tex_str = serde_json::to_value(tex_uuid)
139            .ok()
140            .and_then(|v| v.as_str().map(str::to_owned))
141            .expect("AssetUUID serializes to a string");
142        let kmat = format!(
143            r#"{{
144                "type_name": "StandardMaterial",
145                "material": {{
146                    "base_color": {{ "r": 0.1, "g": 0.2, "b": 0.3, "a": 1.0 }},
147                    "base_color_texture": "{tex}",
148                    "metallic": 0.75,
149                    "roughness": 0.25,
150                    "metallic_roughness_texture": (),
151                    "normal_map": (),
152                    "occlusion_map": (),
153                    "emissive": {{ "r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0 }},
154                    "emissive_texture": (),
155                    "alpha_mode": "Opaque",
156                    "alpha_cutoff": 0.5,
157                    "double_sided": false,
158                }},
159            }}"#,
160            tex = tex_str,
161        );
162
163        let material = MaterialDecoder
164            .load(kmat.as_bytes())
165            .expect("hand-written StandardMaterial .kmat should decode");
166        let standard = material
167            .as_any()
168            .downcast_ref::<StandardMaterial>()
169            .expect("decoded material should be a StandardMaterial");
170
171        assert_eq!(standard.base_color, LinearRgba::new(0.1, 0.2, 0.3, 1.0));
172        assert_eq!(standard.metallic, 0.75);
173        assert_eq!(standard.roughness, 0.25);
174        assert_eq!(standard.base_color_texture, Some(tex_uuid));
175    }
176
177    #[test]
178    fn decodes_non_standard_type_via_dispatch() {
179        // A second concrete type proves the `type_name` dispatch, not a
180        // hard-coded StandardMaterial path.
181        let emissive = EmissiveMaterial::default();
182        let json = material_to_json(&emissive).expect("emissive material should serialize to JSON");
183        let kmat = json_to_kmat_ron(&json);
184
185        let material = MaterialDecoder
186            .load(kmat.as_bytes())
187            .expect("EmissiveMaterial .kmat should decode");
188        assert!(
189            material
190                .as_any()
191                .downcast_ref::<EmissiveMaterial>()
192                .is_some(),
193            "decoded material should dispatch to EmissiveMaterial"
194        );
195    }
196
197    #[test]
198    fn serialize_then_decode_round_trips_all_fields() {
199        // Produce the `.kmat` bytes programmatically from a material, feed them
200        // back through the decoder, and assert equality. This guards the
201        // serde_json::Value ↔ RON bridge against any lossy field (the
202        // `AlphaMode::Mask(f32)` tuple variant and `Option<AssetUUID>` are the
203        // sharp edges).
204        let original = StandardMaterial {
205            base_color: LinearRgba::new(0.42, 0.13, 0.87, 0.5),
206            base_color_texture: Some(AssetUUID::new_v5("textures/diffuse.png")),
207            metallic: 0.9,
208            roughness: 0.11,
209            normal_map: Some(AssetUUID::new_v5("textures/normal.png")),
210            occlusion_map: Some(AssetUUID::new_v5("textures/ao.png")),
211            alpha_mode: khora_core::asset::AlphaMode::Mask(0.33),
212            alpha_cutoff: 0.33,
213            double_sided: true,
214            ..Default::default()
215        };
216
217        let json = material_to_json(&original).expect("material should serialize to JSON");
218        let kmat = json_to_kmat_ron(&json);
219
220        let decoded = MaterialDecoder
221            .load(kmat.as_bytes())
222            .expect("round-trip .kmat should decode");
223        let standard = decoded
224            .as_any()
225            .downcast_ref::<StandardMaterial>()
226            .expect("round-trip material should be a StandardMaterial");
227
228        assert_eq!(standard.base_color, original.base_color);
229        assert_eq!(standard.base_color_texture, original.base_color_texture);
230        assert_eq!(standard.metallic, original.metallic);
231        assert_eq!(standard.roughness, original.roughness);
232        assert_eq!(standard.normal_map, original.normal_map);
233        assert_eq!(standard.occlusion_map, original.occlusion_map);
234        assert_eq!(standard.alpha_mode, original.alpha_mode);
235        assert_eq!(standard.alpha_cutoff, original.alpha_cutoff);
236        assert_eq!(standard.double_sided, original.double_sided);
237    }
238
239    #[test]
240    fn unknown_type_name_errors_without_fallback() {
241        let kmat = r#"(type_name: "NoSuchMaterial", material: ())"#;
242        assert!(
243            MaterialDecoder.load(kmat.as_bytes()).is_err(),
244            "unknown material type must error, never fall back to a default"
245        );
246    }
247
248    #[test]
249    fn malformed_ron_errors() {
250        assert!(
251            MaterialDecoder.load(b"this is not ron").is_err(),
252            "malformed RON must surface a decode error"
253        );
254    }
255}