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 garbage_collect(&mut self) -> usize {
225 let mut freed = 0;
226
227 let initial_meshes = self.mesh_cache.len();
228 self.mesh_cache.retain(|key, mesh| {
229 if key.starts_with("primitive/") { return true; }
230 std::sync::Arc::strong_count(&mesh.vbuf) > 1
231 });
232 freed += initial_meshes - self.mesh_cache.len();
233
234 let initial_textures = self.texture_cache.len();
235 self.texture_cache.retain(|key, tex| {
236 if key.starts_with("primitive/") { return true; }
237 std::sync::Arc::strong_count(tex) > 1
238 });
239 freed += initial_textures - self.texture_cache.len();
240
241 freed
242 }
243
244 pub fn normalize_path(path: &str) -> String {
250 Path::new(path)
251 .components()
252 .map(|c| c.as_os_str().to_string_lossy().into_owned())
253 .collect::<Vec<_>>()
254 .join("/")
255 }
256
257 pub fn get_uuid(&self, path: &str) -> Option<Uuid> {
259 self.path_to_uuid.get(&Self::normalize_path(path)).copied()
260 }
261
262 pub fn get_path(&self, uuid: &Uuid) -> Option<String> {
264 self.uuid_to_path.get(uuid).cloned()
265 }
266
267 pub fn resolve_path_from_meta_source(&self, source: &str) -> Result<String, String> {
272 if let Ok(id) = Uuid::parse_str(source) {
273 self.get_path(&id)
274 .ok_or_else(|| format!("Missing UUID reference: {source}"))
275 } else {
276 Ok(Self::normalize_path(source))
277 }
278 }
279
280 pub fn get_cached_mesh(&self, source_id: &str) -> Option<Mesh> {
282 self.mesh_cache.get(source_id).cloned()
283 }
284
285 pub fn embed_asset(&mut self, path: &str, data: impl Into<std::borrow::Cow<'static, [u8]>>) {
288 self.embedded_assets
289 .insert(Self::normalize_path(path), data.into());
290 }
291
292 pub fn scan_assets_directory(&mut self, dir: &Path) {
300 if !dir.is_dir() {
301 return;
302 }
303
304 let entries = match std::fs::read_dir(dir) {
305 Ok(e) => e,
306 Err(e) => {
307 tracing::error!(
308 "[AssetManager] Cannot read directory {}: {e}",
309 dir.display()
310 );
311 return;
312 }
313 };
314
315 for entry in entries.flatten() {
316 let path = entry.path();
317
318 if path.is_dir() {
319 self.scan_assets_directory(&path);
320 continue;
321 }
322
323 let is_asset = path
324 .extension()
325 .map(|ext| {
326 matches!(
327 ext.to_string_lossy().to_lowercase().as_str(),
328 "obj"
329 | "gltf"
330 | "glb"
331 | "png"
332 | "jpg"
333 | "jpeg"
334 | "hdr"
335 | "wav"
336 | "mp3"
337 | "ogg"
338 | "ttf"
339 | "otf"
340 | "ron"
341 )
342 })
343 .unwrap_or(false);
344
345 if !is_asset {
346 continue;
347 }
348
349 let meta_path = PathBuf::from(format!("{}.meta", path.display()));
350 let uuid = self.read_or_create_meta(&path, &meta_path);
351
352 let normalized = Self::normalize_path(&path.to_string_lossy());
353 self.path_to_uuid.insert(normalized.clone(), uuid);
354 self.uuid_to_path.insert(uuid, normalized);
355 }
356 }
357
358 fn read_or_create_meta(&self, asset_path: &Path, meta_path: &Path) -> Uuid {
360 if meta_path.exists() {
361 match std::fs::read_to_string(meta_path)
362 .map_err(|e| e.to_string())
363 .and_then(|s| ron::from_str::<AssetMeta>(&s).map_err(|e| e.to_string()))
364 {
365 Ok(meta) => return meta.uuid,
366 Err(e) => {
367 tracing::error!(
368 "[AssetManager] WARN: corrupt .meta for '{}' ({e}). \
369 Regenerating UUID — existing scene references to this \
370 asset will break.",
371 asset_path.display()
372 );
373 }
375 }
376 }
377
378 let uuid = Uuid::new_v4();
379 let meta = AssetMeta { uuid };
380
381 match ron::ser::to_string_pretty(&meta, ron::ser::PrettyConfig::default()) {
382 Ok(ron_str) => {
383 if let Err(e) = std::fs::write(meta_path, ron_str) {
384 tracing::error!(
385 "[AssetManager] WARN: could not write .meta for '{}': {e}",
386 asset_path.display()
387 );
388 }
389 }
390 Err(e) => tracing::error!("[AssetManager] WARN: RON serialisation failed: {e}"),
391 }
392
393 uuid
394 }
395
396 pub fn loading_placeholder_mesh(&mut self, device: &wgpu::Device) -> Mesh {
401 if let Some(ref m) = self.placeholder_mesh {
402 return m.clone();
403 }
404 let m = Self::create_loading_placeholder(device);
405 self.placeholder_mesh = Some(m.clone());
406 m
407 }
408
409 fn create_loading_placeholder(device: &wgpu::Device) -> Mesh {
410 const POSITIONS: [[f32; 3]; 6] = [
412 [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], ];
419 const TRIANGLES: [[usize; 3]; 8] = [
420 [0, 2, 4],
421 [2, 1, 4],
422 [1, 3, 4],
423 [3, 0, 4],
424 [2, 0, 5],
425 [1, 2, 5],
426 [3, 1, 5],
427 [0, 3, 5],
428 ];
429 const COLOR: [f32; 3] = [0.95, 0.45, 0.95]; let mut vertices = Vec::with_capacity(TRIANGLES.len() * 3);
432
433 for tri in &TRIANGLES {
434 for &i in tri {
435 let pos = POSITIONS[i];
436 let n = Vec3::new(pos[0], pos[1], pos[2]).normalize();
437 vertices.push(Vertex {
438 position: pos,
439 normal: [n.x, n.y, n.z],
440 tex_coords: [0.0, 0.0],
441 color: COLOR,
442 joint_indices: [0; 4],
443 joint_weights: [0.0; 4],
444 });
445 }
446 }
447
448 let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
449 label: Some("Async loading placeholder"),
450 contents: bytemuck::cast_slice(&vertices),
451 usage: wgpu::BufferUsages::VERTEX,
452 });
453
454 Mesh::new(
455 device,
456 Arc::new(vbuf),
457 &vertices,
458 Vec3::ZERO,
459 "__async_loading__".to_string(),
460 )
461 }
462}