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