khora_io/asset/dependencies.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//! Asset dependency extraction for the index builder.
16//!
17//! The index builder records, for each asset, the UUIDs of the other assets it
18//! directly references (a material's textures, a scene's prefabs, …). Those
19//! references populate [`AssetMetadata::dependencies`], which the asset agent
20//! uses to load an asset's prerequisites without first decoding it.
21//!
22//! [`extract_dependencies`] is the single dispatch seam: it switches on the
23//! canonical asset type name (the same names [`asset_type_for_extension`]
24//! produces) and delegates to a per-format extractor. A type with no extractor
25//! returns an empty list — that is the historical behaviour and the extension
26//! point: supporting a new format means adding one match arm, not rewiring the
27//! builder.
28//!
29//! # Determinism
30//!
31//! Extracted UUID lists are deduplicated and sorted. The index builder's
32//! contract is byte-determinism (two builds of the same project produce a
33//! byte-identical index); unsorted or duplicated dependencies would break it.
34//!
35//! [`AssetMetadata::dependencies`]: khora_core::asset::AssetMetadata::dependencies
36//! [`asset_type_for_extension`]: crate::asset::asset_type_for_extension
37
38use khora_core::asset::AssetUUID;
39
40use crate::asset::decoders::decode_material;
41
42/// `true` if [`extract_dependencies`] can extract references from this asset
43/// type, i.e. the type has a real extractor rather than the empty fallback.
44///
45/// The index builder uses this to read file contents **only** for types it can
46/// actually parse — textures, audio, meshes, etc. are never read, so adding
47/// dependency extraction costs nothing for the assets that have no outbound
48/// references. Keep this in sync with the populated arms of
49/// [`extract_dependencies`].
50pub fn type_has_dependency_extractor(asset_type: &str) -> bool {
51 matches!(asset_type, "material")
52}
53
54/// Extracts the direct asset dependencies encoded in `bytes`, dispatching on
55/// the canonical `asset_type` name.
56///
57/// Returns a deduplicated, sorted list so the index builder stays
58/// byte-deterministic. Unknown or not-yet-handled types return an empty list —
59/// this is the extensible seam (see the module docs). A decode failure on a
60/// handled type also yields an empty list (logged at `warn`): a single corrupt
61/// asset must never abort the whole index build.
62pub fn extract_dependencies(asset_type: &str, bytes: &[u8]) -> Vec<AssetUUID> {
63 let mut deps = match asset_type {
64 "material" => material_dependencies(bytes),
65 // Scenes reference entities/prefabs/meshes, but the scene schema is
66 // still evolving; their references are not extracted yet.
67 "scene" => Vec::new(),
68 // Prefabs reference component assets; not extracted yet.
69 "prefab" => Vec::new(),
70 // Meshes (glTF/glb/obj) embed or reference textures and materials;
71 // format-specific parsing is not done yet.
72 "mesh" => Vec::new(),
73 // Leaf assets (textures, audio, shaders, fonts, scripts, blobs) have no
74 // outbound asset references.
75 _ => Vec::new(),
76 };
77 deps.sort();
78 deps.dedup();
79 deps
80}
81
82/// Collects the texture UUIDs referenced by a `.kmat` material.
83///
84/// Decodes the bytes via [`decode_material`] (no runtime asset service needed)
85/// and gathers every `Some(uuid)` the [`Material`] trait's texture accessors
86/// expose. A material that references the same texture in several slots
87/// contributes that UUID once (the caller dedups). On decode failure the
88/// material is skipped with a `warn` and contributes nothing.
89///
90/// [`Material`]: khora_core::asset::Material
91fn material_dependencies(bytes: &[u8]) -> Vec<AssetUUID> {
92 let Some(material) = decode_material(bytes) else {
93 log::warn!("asset index: skipping unreadable material while extracting dependencies");
94 return Vec::new();
95 };
96
97 [
98 material.base_color_texture(),
99 material.metallic_roughness_texture(),
100 material.normal_map(),
101 material.emissive_texture(),
102 ]
103 .into_iter()
104 .flatten()
105 .collect()
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111 use khora_core::asset::{AssetUUID, StandardMaterial};
112 use khora_data::ecs::material_to_json;
113
114 /// Renders a `StandardMaterial` as the `.kmat` RON bytes the decoder
115 /// consumes (the `{ type_name, material }` value tree, RON-encoded).
116 fn material_to_kmat(material: &StandardMaterial) -> Vec<u8> {
117 let json = material_to_json(material).expect("material should serialize to JSON");
118 ron::ser::to_string(&json)
119 .expect("material JSON should encode to RON")
120 .into_bytes()
121 }
122
123 #[test]
124 fn material_with_two_textures_yields_both_sorted_deduped() {
125 let base = AssetUUID::new_v5("textures/base.png");
126 let normal = AssetUUID::new_v5("textures/normal.png");
127 let material = StandardMaterial {
128 base_color_texture: Some(base),
129 // Reuse the base-color texture in a second slot to exercise dedup.
130 metallic_roughness_texture: Some(base),
131 normal_map: Some(normal),
132 ..Default::default()
133 };
134
135 let deps = extract_dependencies("material", &material_to_kmat(&material));
136
137 let mut expected = vec![base, normal];
138 expected.sort();
139 assert_eq!(deps, expected, "deps must contain both textures, deduped");
140 assert_eq!(deps.len(), 2, "the reused texture appears once");
141 assert!(deps.windows(2).all(|w| w[0] <= w[1]), "deps must be sorted");
142 }
143
144 #[test]
145 fn material_without_textures_yields_empty() {
146 let material = StandardMaterial::default();
147 let deps = extract_dependencies("material", &material_to_kmat(&material));
148 assert!(
149 deps.is_empty(),
150 "an untextured material has no dependencies"
151 );
152 }
153
154 #[test]
155 fn corrupt_material_yields_empty_without_panic() {
156 let deps = extract_dependencies("material", b"this is not a valid kmat");
157 assert!(deps.is_empty(), "a corrupt material contributes no deps");
158 }
159
160 #[test]
161 fn unhandled_type_yields_empty() {
162 // Textures, scenes, etc. are not parsed for outbound references.
163 assert!(extract_dependencies("texture", b"\x89PNG\r\n").is_empty());
164 assert!(extract_dependencies("scene", b"(entities: [])").is_empty());
165 assert!(extract_dependencies("mesh", b"glTF binary").is_empty());
166 }
167}