Skip to main content

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}