Skip to main content

gizmo_renderer/asset/
mod.rs

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// ============================================================================
16//  Asset metadata
17// ============================================================================
18
19/// Persisted alongside every asset file as `<filename>.meta`.
20///
21/// Stable UUIDs let editor tools and serialised scenes reference assets by
22/// identity rather than by path, surviving renames and moves.
23#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
24pub struct AssetMeta {
25    pub uuid: Uuid,
26}
27
28// ============================================================================
29//  Free decode helpers (CPU-only, safe to call from worker threads)
30// ============================================================================
31
32/// Decode an image file to RGBA8 on a background thread (no GPU access).
33pub 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
41/// Decode an OBJ file to a flat vertex buffer + AABB without touching the GPU.
42///
43/// Intended for use with [`crate::async_assets::AsyncAssetLoader`]: call this
44/// on a worker thread, then hand the result to
45/// [`AssetManager::install_obj_mesh`] on the main thread.
46pub 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(); // first vertex of this model
72
73        for &raw_idx in &m.indices {
74            let idx = raw_idx as usize;
75
76            // ── Position ─────────────────────────────────────────────────
77            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            // ── Normal (placeholder when absent; recalculated below) ──────
93            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] // temporary; flat normals computed below
109            };
110
111            // ── UV ────────────────────────────────────────────────────────
112            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                // OBJ UV origin is bottom-left; flip V to match GPU convention.
122                [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        // Compute flat normals per-model, only when the model lacks them.
138        // This ensures models WITH normals are never touched.
139        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
156/// Compute flat (per-face) normals for a triangle-list vertex buffer in place.
157///
158/// Returns the number of leftover vertices that could not form a complete
159/// triangle (should be 0 for well-formed meshes).
160fn compute_flat_normals_inplace(vertices: &mut [Vertex]) -> usize {
161    let chunks = vertices.chunks_exact_mut(3);
162    let remainder_len = chunks.into_remainder().len(); // borrow ends here
163
164    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 // degenerate triangle → default up
174        };
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
185// ============================================================================
186//  AssetManager
187// ============================================================================
188
189pub struct AssetManager {
190    mesh_cache: std::collections::HashMap<String, Mesh>,
191    texture_cache: std::collections::HashMap<String, Arc<wgpu::BindGroup>>,
192    /// Lazily created magenta octahedron used while async loads are in flight.
193    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    /// Assets whose bytes are baked into the binary (e.g. via `include_bytes!`).
198    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    // ── Path / UUID helpers ───────────────────────────────────────────────
222
223    /// Normalise a file-system path to forward-slash form for use as a map key.
224    ///
225    /// Uses [`Path`] to avoid platform-specific separator assumptions.
226    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    /// Return the UUID registered for `path`, if any.
235    pub fn get_uuid(&self, path: &str) -> Option<Uuid> {
236        self.path_to_uuid.get(&Self::normalize_path(path)).copied()
237    }
238
239    /// Return the filesystem path registered for `uuid`, if any.
240    pub fn get_path(&self, uuid: &Uuid) -> Option<String> {
241        self.uuid_to_path.get(uuid).cloned()
242    }
243
244    /// Resolve a load source to a filesystem path.
245    ///
246    /// If `source` parses as a UUID, the registered path is returned.
247    /// Otherwise `source` is normalised and returned as-is.
248    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    /// Return a cached mesh by its source ID without triggering a load.
258    pub fn get_cached_mesh(&self, source_id: &str) -> Option<Mesh> {
259        self.mesh_cache.get(source_id).cloned()
260    }
261
262    /// Embed a raw asset byte slice under `path` so it can be loaded without
263    /// a filesystem read.
264    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    // ── Asset scanning ────────────────────────────────────────────────────
270
271    /// Recursively scan `dir` for known asset extensions, creating or
272    /// reading `.meta` sidecar files to assign stable UUIDs.
273    ///
274    /// Safe to call multiple times — existing entries are updated, not
275    /// duplicated.
276    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    /// Read an existing `.meta` file or create a new one, returning the UUID.
336    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                    // Fall through to generate a fresh UUID.
351                }
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    // ── Placeholder mesh ──────────────────────────────────────────────────
374
375    /// Return (creating if needed) a small magenta octahedron used as a
376    /// stand-in while an async asset load is in flight.
377    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        // Octahedron — recognisable from any angle, low vertex count.
388        const POSITIONS: [[f32; 3]; 6] = [
389            [1.0, 0.0, 0.0],  // +X
390            [-1.0, 0.0, 0.0], // -X
391            [0.0, 1.0, 0.0],  // +Y
392            [0.0, -1.0, 0.0], // -Y
393            [0.0, 0.0, 1.0],  // +Z
394            [0.0, 0.0, -1.0], // -Z
395        ];
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]; // magenta
407
408        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}