1use crate::components::Mesh;
2use crate::renderer::Vertex;
3use gizmo_math::Vec3;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use uuid::Uuid;
7use wgpu::util::DeviceExt;
8
9pub mod loaders;
10pub mod primitives;
11pub mod texture;
12
13pub use loaders::GltfNodeData;
14
15#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
24pub struct AssetMeta {
25 pub uuid: Uuid,
26}
27
28pub fn decode_rgba_image_file(path: &str) -> Result<(Vec<u8>, u32, u32), String> {
34 let img = image::open(path)
35 .map_err(|e| format!("Cannot read texture ({path}): {e}"))?
36 .to_rgba8();
37 let (w, h) = img.dimensions();
38 Ok((img.into_raw(), w, h))
39}
40
41pub fn decode_obj_vertices_for_async(
47 file_path: &str,
48) -> Result<(Vec<Vertex>, gizmo_math::Aabb), String> {
49 let (models, _) = tobj::load_obj(
50 file_path,
51 &tobj::LoadOptions {
52 single_index: true,
53 triangulate: true,
54 ignore_points: true,
55 ignore_lines: true,
56 },
57 )
58 .map_err(|e| format!("OBJ load failed ({file_path}): {e}"))?;
59
60 if models.is_empty() {
61 return Err(format!("OBJ file contains no models: {file_path}"));
62 }
63
64 let mut aabb = gizmo_math::Aabb::empty();
65 let mut vertices = Vec::new();
66
67 for model in &models {
68 let m = &model.mesh;
69 let has_normals = !m.normals.is_empty();
70 let has_texcoords = !m.texcoords.is_empty();
71 let model_start = vertices.len(); for &raw_idx in &m.indices {
74 let idx = raw_idx as usize;
75
76 let pos_base = idx * 3;
78 if pos_base + 2 >= m.positions.len() {
79 return Err(format!(
80 "OBJ ({file_path}): position index {idx} out of range \
81 (positions.len={})",
82 m.positions.len()
83 ));
84 }
85 let position = [
86 m.positions[pos_base],
87 m.positions[pos_base + 1],
88 m.positions[pos_base + 2],
89 ];
90 aabb.extend(Vec3::new(position[0], position[1], position[2]));
91
92 let normal = if has_normals {
94 let n_base = idx * 3;
95 if n_base + 2 >= m.normals.len() {
96 return Err(format!(
97 "OBJ ({file_path}): normal index {idx} out of range \
98 (normals.len={})",
99 m.normals.len()
100 ));
101 }
102 [
103 m.normals[n_base],
104 m.normals[n_base + 1],
105 m.normals[n_base + 2],
106 ]
107 } else {
108 [0.0, 1.0, 0.0] };
110
111 let tex_coords = if has_texcoords {
113 let uv_base = idx * 2;
114 if uv_base + 1 >= m.texcoords.len() {
115 return Err(format!(
116 "OBJ ({file_path}): texcoord index {idx} out of range \
117 (texcoords.len={})",
118 m.texcoords.len()
119 ));
120 }
121 [m.texcoords[uv_base], 1.0 - m.texcoords[uv_base + 1]]
123 } else {
124 [0.0, 0.0]
125 };
126
127 vertices.push(Vertex {
128 position,
129 normal,
130 tex_coords,
131 color: [1.0, 1.0, 1.0],
132 joint_indices: [0; 4],
133 joint_weights: [0.0; 4],
134 });
135 }
136
137 if !has_normals {
140 let model_verts = &mut vertices[model_start..];
141 let remainder = compute_flat_normals_inplace(model_verts);
142 if remainder > 0 {
143 tracing::error!(
144 "[AssetManager] WARN: '{file_path}' model '{}' has {remainder} \
145 trailing vertices that don't form a complete triangle — \
146 normals for those vertices left as Y-up.",
147 model.name
148 );
149 }
150 }
151 }
152
153 Ok((vertices, aabb))
154}
155
156fn compute_flat_normals_inplace(vertices: &mut [Vertex]) -> usize {
161 let chunks = vertices.chunks_exact_mut(3);
162 let remainder_len = chunks.into_remainder().len(); for tri in vertices.chunks_exact_mut(3) {
165 let v0 = Vec3::from(tri[0].position);
166 let v1 = Vec3::from(tri[1].position);
167 let v2 = Vec3::from(tri[2].position);
168
169 let cross = (v1 - v0).cross(v2 - v0);
170 let normal = if cross.length_squared() > 1e-10 {
171 cross.normalize()
172 } else {
173 Vec3::Y };
175
176 let n = [normal.x, normal.y, normal.z];
177 tri[0].normal = n;
178 tri[1].normal = n;
179 tri[2].normal = n;
180 }
181
182 remainder_len
183}
184
185pub struct AssetManager {
190 mesh_cache: std::collections::HashMap<String, Mesh>,
191 texture_cache: std::collections::HashMap<String, Arc<wgpu::BindGroup>>,
192 placeholder_mesh: Option<Mesh>,
194
195 pub path_to_uuid: std::collections::HashMap<String, Uuid>,
196 pub uuid_to_path: std::collections::HashMap<Uuid, String>,
197 pub embedded_assets: std::collections::HashMap<String, std::borrow::Cow<'static, [u8]>>,
199}
200
201impl Default for AssetManager {
202 fn default() -> Self {
203 Self::new()
204 }
205}
206
207impl AssetManager {
208 pub fn new() -> Self {
209 let mut manager = Self {
210 mesh_cache: std::collections::HashMap::new(),
211 texture_cache: std::collections::HashMap::new(),
212 placeholder_mesh: None,
213 path_to_uuid: std::collections::HashMap::new(),
214 uuid_to_path: std::collections::HashMap::new(),
215 embedded_assets: std::collections::HashMap::new(),
216 };
217 manager.scan_assets_directory(Path::new("assets"));
218 manager
219 }
220
221 pub fn normalize_path(path: &str) -> String {
227 Path::new(path)
228 .components()
229 .map(|c| c.as_os_str().to_string_lossy().into_owned())
230 .collect::<Vec<_>>()
231 .join("/")
232 }
233
234 pub fn get_uuid(&self, path: &str) -> Option<Uuid> {
236 self.path_to_uuid.get(&Self::normalize_path(path)).copied()
237 }
238
239 pub fn get_path(&self, uuid: &Uuid) -> Option<String> {
241 self.uuid_to_path.get(uuid).cloned()
242 }
243
244 pub fn resolve_path_from_meta_source(&self, source: &str) -> Result<String, String> {
249 if let Ok(id) = Uuid::parse_str(source) {
250 self.get_path(&id)
251 .ok_or_else(|| format!("Missing UUID reference: {source}"))
252 } else {
253 Ok(Self::normalize_path(source))
254 }
255 }
256
257 pub fn get_cached_mesh(&self, source_id: &str) -> Option<Mesh> {
259 self.mesh_cache.get(source_id).cloned()
260 }
261
262 pub fn embed_asset(&mut self, path: &str, data: impl Into<std::borrow::Cow<'static, [u8]>>) {
265 self.embedded_assets
266 .insert(Self::normalize_path(path), data.into());
267 }
268
269 pub fn scan_assets_directory(&mut self, dir: &Path) {
277 if !dir.is_dir() {
278 return;
279 }
280
281 let entries = match std::fs::read_dir(dir) {
282 Ok(e) => e,
283 Err(e) => {
284 tracing::error!(
285 "[AssetManager] Cannot read directory {}: {e}",
286 dir.display()
287 );
288 return;
289 }
290 };
291
292 for entry in entries.flatten() {
293 let path = entry.path();
294
295 if path.is_dir() {
296 self.scan_assets_directory(&path);
297 continue;
298 }
299
300 let is_asset = path
301 .extension()
302 .map(|ext| {
303 matches!(
304 ext.to_string_lossy().to_lowercase().as_str(),
305 "obj"
306 | "gltf"
307 | "glb"
308 | "png"
309 | "jpg"
310 | "jpeg"
311 | "hdr"
312 | "wav"
313 | "mp3"
314 | "ogg"
315 | "ttf"
316 | "otf"
317 | "ron"
318 )
319 })
320 .unwrap_or(false);
321
322 if !is_asset {
323 continue;
324 }
325
326 let meta_path = PathBuf::from(format!("{}.meta", path.display()));
327 let uuid = self.read_or_create_meta(&path, &meta_path);
328
329 let normalized = Self::normalize_path(&path.to_string_lossy());
330 self.path_to_uuid.insert(normalized.clone(), uuid);
331 self.uuid_to_path.insert(uuid, normalized);
332 }
333 }
334
335 fn read_or_create_meta(&self, asset_path: &Path, meta_path: &Path) -> Uuid {
337 if meta_path.exists() {
338 match std::fs::read_to_string(meta_path)
339 .map_err(|e| e.to_string())
340 .and_then(|s| ron::from_str::<AssetMeta>(&s).map_err(|e| e.to_string()))
341 {
342 Ok(meta) => return meta.uuid,
343 Err(e) => {
344 tracing::error!(
345 "[AssetManager] WARN: corrupt .meta for '{}' ({e}). \
346 Regenerating UUID — existing scene references to this \
347 asset will break.",
348 asset_path.display()
349 );
350 }
352 }
353 }
354
355 let uuid = Uuid::new_v4();
356 let meta = AssetMeta { uuid };
357
358 match ron::ser::to_string_pretty(&meta, ron::ser::PrettyConfig::default()) {
359 Ok(ron_str) => {
360 if let Err(e) = std::fs::write(meta_path, ron_str) {
361 tracing::error!(
362 "[AssetManager] WARN: could not write .meta for '{}': {e}",
363 asset_path.display()
364 );
365 }
366 }
367 Err(e) => tracing::error!("[AssetManager] WARN: RON serialisation failed: {e}"),
368 }
369
370 uuid
371 }
372
373 pub fn loading_placeholder_mesh(&mut self, device: &wgpu::Device) -> Mesh {
378 if let Some(ref m) = self.placeholder_mesh {
379 return m.clone();
380 }
381 let m = Self::create_loading_placeholder(device);
382 self.placeholder_mesh = Some(m.clone());
383 m
384 }
385
386 fn create_loading_placeholder(device: &wgpu::Device) -> Mesh {
387 const POSITIONS: [[f32; 3]; 6] = [
389 [1.0, 0.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0, -1.0], ];
396 const TRIANGLES: [[usize; 3]; 8] = [
397 [0, 2, 4],
398 [2, 1, 4],
399 [1, 3, 4],
400 [3, 0, 4],
401 [2, 0, 5],
402 [1, 2, 5],
403 [3, 1, 5],
404 [0, 3, 5],
405 ];
406 const COLOR: [f32; 3] = [0.95, 0.45, 0.95]; let mut vertices = Vec::with_capacity(TRIANGLES.len() * 3);
409
410 for tri in &TRIANGLES {
411 for &i in tri {
412 let pos = POSITIONS[i];
413 let n = Vec3::new(pos[0], pos[1], pos[2]).normalize();
414 vertices.push(Vertex {
415 position: pos,
416 normal: [n.x, n.y, n.z],
417 tex_coords: [0.0, 0.0],
418 color: COLOR,
419 joint_indices: [0; 4],
420 joint_weights: [0.0; 4],
421 });
422 }
423 }
424
425 let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
426 label: Some("Async loading placeholder"),
427 contents: bytemuck::cast_slice(&vertices),
428 usage: wgpu::BufferUsages::VERTEX,
429 });
430
431 Mesh::new(
432 device,
433 Arc::new(vbuf),
434 &vertices,
435 Vec3::ZERO,
436 "__async_loading__".to_string(),
437 )
438 }
439}