khora_io/asset/decoders/mesh/dispatcher.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//! Mesh format dispatcher.
16//!
17//! The single canonical entry-point that the editor and runtime register
18//! under the `"mesh"` slot. Sniffs the bytes (gltf binary magic, gltf JSON,
19//! or OBJ text) and delegates to the appropriate inner decoder.
20//!
21//! Lives next to the per-format decoders (`gltf.rs`, `obj.rs`) which stay
22//! public — a downstream crate can compose its own dispatcher (for example
23//! to add FBX) and register that instead, without forking `khora-io`.
24
25use std::error::Error;
26use std::sync::Arc;
27
28use khora_core::renderer::api::scene::Mesh;
29
30use crate::asset::AssetDecoder;
31
32use super::{GltfDecoder, GltfResourceResolver, NoOpResourceResolver, ObjDecoder};
33
34/// Default mesh dispatcher: delegates to gltf or obj based on byte sniffing.
35///
36/// Intentionally **not** registered via `inventory::submit!` — this slot has
37/// multiple competing implementations (gltf vs obj vs eventual FBX) and the
38/// choice should be visible at the consumer's call site:
39///
40/// ```ignore
41/// svc.register_decoder::<Mesh>("mesh", MeshDispatcher::default());
42/// ```
43#[derive(Clone)]
44pub struct MeshDispatcher {
45 gltf: GltfDecoder,
46 obj: ObjDecoder,
47}
48
49impl MeshDispatcher {
50 /// Creates a dispatcher with the given gltf resource resolver.
51 pub fn new(resolver: Arc<dyn GltfResourceResolver>) -> Self {
52 Self {
53 gltf: GltfDecoder::new(resolver),
54 obj: ObjDecoder,
55 }
56 }
57
58 /// Returns true when `bytes` looks like binary glTF (`glTF` magic).
59 fn is_glb(bytes: &[u8]) -> bool {
60 bytes.len() >= 4 && &bytes[..4] == b"glTF"
61 }
62
63 /// Returns true when `bytes` parses as JSON starting with a `{` after
64 /// optional whitespace — the standard gltf 2.0 JSON form.
65 fn looks_like_gltf_json(bytes: &[u8]) -> bool {
66 for &b in bytes.iter().take(64) {
67 match b {
68 b' ' | b'\t' | b'\r' | b'\n' => continue,
69 b'{' => return true,
70 _ => return false,
71 }
72 }
73 false
74 }
75}
76
77impl Default for MeshDispatcher {
78 /// Default dispatcher uses [`NoOpResourceResolver`] for gltf. Suitable
79 /// for self-contained `.glb` files (the binary chunk is embedded) and
80 /// `.obj` files (no external resources at all). For `.gltf` files that
81 /// reference external `.bin` / texture buffers, supply a project-aware
82 /// resolver via [`MeshDispatcher::new`] — `ProjectVfs::open` does this
83 /// automatically with a [`FileSystemResolver`] rooted at the project's
84 /// `assets/` directory.
85 ///
86 /// [`FileSystemResolver`]: super::FileSystemResolver
87 fn default() -> Self {
88 Self::new(Arc::new(NoOpResourceResolver))
89 }
90}
91
92impl AssetDecoder<Mesh> for MeshDispatcher {
93 fn load(&self, bytes: &[u8]) -> Result<Mesh, Box<dyn Error + Send + Sync>> {
94 if Self::is_glb(bytes) || Self::looks_like_gltf_json(bytes) {
95 self.gltf.load(bytes)
96 } else {
97 self.obj.load(bytes)
98 }
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn sniffs_glb_magic() {
108 assert!(MeshDispatcher::is_glb(b"glTF\x02\x00\x00\x00"));
109 }
110
111 #[test]
112 fn sniffs_gltf_json() {
113 assert!(MeshDispatcher::looks_like_gltf_json(b" {\"asset\":..."));
114 assert!(MeshDispatcher::looks_like_gltf_json(b"\n\t {\"asset\""));
115 assert!(!MeshDispatcher::looks_like_gltf_json(
116 b"o foo\nv 1.0 0.0 0.0"
117 ));
118 }
119}