khora_io/asset/decoders/
material.rs1use std::error::Error;
39
40use khora_core::asset::Material;
41use khora_data::ecs::MaterialRegistration;
42
43use crate::asset::{AssetDecoder, DecoderRegistration};
44
45#[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
59pub fn decode_material(bytes: &[u8]) -> Option<Box<dyn Material>> {
72 decode_material_inner(bytes).ok()
73}
74
75fn decode_material_inner(
78 bytes: &[u8],
79) -> Result<Box<dyn Material>, Box<dyn Error + Send + Sync + 'static>> {
80 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 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 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 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 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}