Skip to main content

kvas_engine/
mesh.rs

1//! CPU-side mesh data: procedural primitives and OBJ / glTF importers.
2
3use crate::assets::Assets;
4use bytemuck::{Pod, Zeroable};
5use glam::{Mat4, Vec3};
6use std::collections::HashMap;
7use std::f32::consts::{PI, TAU};
8
9/// Interleaved CPU/GPU vertex layout: position, normal and UV, all 32-bit floats.
10#[repr(C)]
11#[derive(Clone, Copy, Debug, Pod, Zeroable, serde::Serialize, serde::Deserialize)]
12pub struct Vertex {
13    /// Local-space position in metres.
14    pub pos: [f32; 3],
15    /// Local-space unit surface normal.
16    pub normal: [f32; 3],
17    /// Texture coordinates, conventionally in 0..1 but tiling is supported.
18    pub uv: [f32; 2],
19}
20
21/// A decoded base-colour image that came with a model (glTF/GLB).
22#[derive(Clone)]
23pub struct Albedo {
24    /// Image width in pixels.
25    pub width: u32,
26    /// Image height in pixels.
27    pub height: u32,
28    /// Tightly packed row-major RGBA8 bytes (width * height * 4).
29    pub rgba: Vec<u8>,
30}
31
32/// A run of triangles that share one imported material (one glTF primitive group).
33#[derive(Clone)]
34pub struct MeshPart {
35    /// Offset into `MeshData::indices` of the first index of the run.
36    pub first_index: u32,
37    /// Number of indices in the run (a multiple of three).
38    pub index_count: u32,
39    /// Base-colour image of the material, with the material's colour factor already applied.
40    pub albedo: Option<std::sync::Arc<Albedo>>,
41    /// Tangent-space normal map of the material (OpenGL convention), if it has one.
42    pub normal: Option<std::sync::Arc<Albedo>>,
43    /// Occlusion (R) / roughness (G) / metallic (B) image repacked from the material, if it has one.
44    pub orm: Option<std::sync::Arc<Albedo>>,
45}
46
47/// Indexed CPU triangle mesh with optional imported material information.
48/// Indices refer into vertices; call validate before uploading externally generated data.
49#[derive(Clone)]
50pub struct MeshData {
51    /// Interleaved triangle vertices.
52    pub vertices: Vec<Vertex>,
53    /// Triangle-list indices; length must be divisible by three.
54    pub indices: Vec<u32>,
55    /// Base-colour texture of the model's first textured material, if it had one.
56    pub albedo: Option<std::sync::Arc<Albedo>>,
57    /// Base-colour factor of the model's first material (white if none).
58    pub base_color: [f32; 4],
59    /// Material runs for models with several materials (empty = the whole mesh uses `albedo`).
60    pub parts: Vec<MeshPart>,
61    /// Asset-relative source files referenced by an imported mesh (buffers/textures).
62    /// Render caches watch these dependencies as well as the model file itself.
63    pub dependencies: Vec<String>,
64}
65
66impl Default for MeshData {
67    fn default() -> Self {
68        Self { vertices: Vec::new(), indices: Vec::new(), albedo: None, base_color: [1.0; 4], parts: Vec::new(), dependencies: Vec::new() }
69    }
70}
71
72/// Prefix for built-in unit primitive mesh keys.
73pub const PRIMITIVE_PREFIX: &str = "primitive:";
74/// Parameters of a procedural mesh; stored in the `Shape` component and encoded in the mesh key.
75#[derive(Clone, Debug, PartialEq)]
76pub struct ProcParams {
77    /// Lowercase primitive family accepted by build.
78    pub kind: String,
79    /// Full local extents used by cubes, planes and rocks.
80    pub size: Vec3,
81    /// Primary radius in local metres.
82    pub radius: f32,
83    /// Top radius (cylinder / cone) or tube radius (torus).
84    pub radius2: f32,
85    /// Full shape height in local metres.
86    pub height: f32,
87    /// Azimuth segments; build clamps ordinary shapes to 3..256.
88    pub segments: u32,
89    /// Vertical/tube subdivisions; build clamps to 2..128.
90    pub rings: u32,
91    /// Deterministic random seed used by rock generation.
92    pub seed: u32,
93}
94
95/// Prefix for parameterized procedural geometry keys.
96pub const PROC_PREFIX: &str = "proc:";
97
98impl ProcParams {
99    /// Encodes this descriptor as a procedural mesh cache key.
100    pub fn key(&self) -> String {
101        format!(
102            "{PROC_PREFIX}{}:{},{},{},{},{},{},{},{},{}",
103            self.kind, self.size.x, self.size.y, self.size.z, self.radius, self.radius2, self.height, self.segments, self.rings, self.seed
104        )
105    }
106
107    /// Parses a procedural mesh key, returning None for malformed parameters.
108    pub fn parse(key: &str) -> Option<ProcParams> {
109        let (kind, rest) = key.strip_prefix(PROC_PREFIX)?.split_once(':')?;
110        let n: Vec<&str> = rest.split(',').collect();
111        if n.len() != 9 {
112            return None;
113        }
114        let real = |i: usize| n[i].parse::<f32>().ok().filter(|v| v.is_finite());
115        Some(ProcParams {
116            kind: kind.to_string(),
117            size: Vec3::new(real(0)?, real(1)?, real(2)?),
118            radius: real(3)?,
119            radius2: real(4)?,
120            height: real(5)?,
121            segments: n[6].parse().ok()?,
122            rings: n[7].parse().ok()?,
123            seed: n[8].parse().ok()?,
124        })
125    }
126
127    /// Builds a supported shape with bounded tessellation, or None for unknown families.
128    pub fn build(&self) -> Option<MeshData> {
129        if !self.size.is_finite() || !self.radius.is_finite() || !self.radius2.is_finite() || !self.height.is_finite() {
130            return None;
131        }
132        let seg = self.segments.clamp(3, 256);
133        let rings = self.rings.clamp(2, 128);
134        let (r, h) = (self.radius.max(0.001), self.height.max(0.001));
135        Some(match self.kind.as_str() {
136            "cube" => scaled(cube(), self.size),
137            "plane" => scaled(plane(rings.min(128)), Vec3::new(self.size.x, 1.0, self.size.z)),
138            "sphere" => lathe(&arc_rows(0.0, 0.0, PI, rings, r), seg),
139            "capsule" => {
140                let straight = (h * 0.5 - r).max(0.0);
141                let mut rows = arc_rows(straight, 0.0, PI / 2.0, rings.div_ceil(2), r);
142                rows.extend(arc_rows(-straight, PI / 2.0, PI, rings.div_ceil(2), r));
143                lathe(&rows, seg)
144            }
145            "cylinder" | "cone" => {
146                let rt = if self.kind == "cone" { 0.0 } else { self.radius2.max(0.0) };
147                let (nr, ny) = (h, r - rt);
148                let len = (nr * nr + ny * ny).sqrt();
149                let (nr, ny) = (nr / len, ny / len);
150                let hh = h * 0.5;
151                let mut rows = vec![(0.0, hh, 0.0, 1.0)];
152                if rt > 0.0 {
153                    rows.push((rt, hh, 0.0, 1.0));
154                }
155                rows.extend([(rt, hh, nr, ny), (r, -hh, nr, ny), (r, -hh, 0.0, -1.0), (0.0, -hh, 0.0, -1.0)]);
156                lathe(&rows, seg)
157            }
158            "torus" => torus(r, self.radius2.max(0.001), seg, rings),
159            "rock" => rock(self.size, self.radius2.clamp(0.0, 1.5), self.segments.clamp(1, 7), self.seed),
160            _ => return None,
161        })
162    }
163}
164
165/// A detailed boulder: an icosphere displaced by layered ridged / billow / fine noise, flattened at
166/// the bottom so it sits on the ground. `subdivisions` 5 is about 20 000 triangles.
167fn rock(size: Vec3, ruggedness: f32, subdivisions: u32, seed: u32) -> MeshData {
168    use crate::noise::{Noise, NoiseKind};
169    let t = (1.0 + 5.0_f32.sqrt()) / 2.0;
170    let mut verts: Vec<Vec3> = [
171        (-1.0, t, 0.0),
172        (1.0, t, 0.0),
173        (-1.0, -t, 0.0),
174        (1.0, -t, 0.0),
175        (0.0, -1.0, t),
176        (0.0, 1.0, t),
177        (0.0, -1.0, -t),
178        (0.0, 1.0, -t),
179        (t, 0.0, -1.0),
180        (t, 0.0, 1.0),
181        (-t, 0.0, -1.0),
182        (-t, 0.0, 1.0),
183    ]
184    .iter()
185    .map(|&(x, y, z)| Vec3::new(x, y, z).normalize())
186    .collect();
187    let mut faces: Vec<[u32; 3]> = vec![
188        [0, 11, 5],
189        [0, 5, 1],
190        [0, 1, 7],
191        [0, 7, 10],
192        [0, 10, 11],
193        [1, 5, 9],
194        [5, 11, 4],
195        [11, 10, 2],
196        [10, 7, 6],
197        [7, 1, 8],
198        [3, 9, 4],
199        [3, 4, 2],
200        [3, 2, 6],
201        [3, 6, 8],
202        [3, 8, 9],
203        [4, 9, 5],
204        [2, 4, 11],
205        [6, 2, 10],
206        [8, 6, 7],
207        [9, 8, 1],
208    ];
209    for _ in 0..subdivisions {
210        let mut cache: HashMap<(u32, u32), u32> = HashMap::new();
211        let mut mid = |a: u32, b: u32, verts: &mut Vec<Vec3>| -> u32 {
212            let key = (a.min(b), a.max(b));
213            *cache.entry(key).or_insert_with(|| {
214                verts.push(((verts[a as usize] + verts[b as usize]) * 0.5).normalize());
215                verts.len() as u32 - 1
216            })
217        };
218        let mut next = Vec::with_capacity(faces.len() * 4);
219        for [a, b, c] in faces {
220            let (ab, bc, ca) = (mid(a, b, &mut verts), mid(b, c, &mut verts), mid(c, a, &mut verts));
221            next.extend([[a, ab, ca], [b, bc, ab], [c, ca, bc], [ab, bc, ca]]);
222        }
223        faces = next;
224    }
225    let seed = seed.wrapping_mul(2654435761);
226    let ridged = Noise::new(NoiseKind::Ridged).with_seed(seed).with_octaves(4);
227    let billow = Noise::new(NoiseKind::Billow).with_seed(seed ^ 0x5bd1).with_octaves(3);
228    let fine = Noise::new(NoiseKind::Perlin).with_seed(seed ^ 0x9e37).with_octaves(5);
229    let lumps = Noise::new(NoiseKind::Perlin).with_seed(seed ^ 0x1234).with_octaves(2);
230    let mut m = MeshData::default();
231    for v in &verts {
232        let d = *v;
233        let displacement = 0.16 * lumps.sample3(d.x * 1.1, d.y * 1.1, d.z * 1.1)
234            + ruggedness
235                * (0.22 * ridged.sample3(d.x * 1.8, d.y * 1.8, d.z * 1.8)
236                    + 0.10 * billow.sample3(d.x * 3.6, d.y * 3.6, d.z * 3.6)
237                    + 0.035 * fine.sample3(d.x * 11.0, d.y * 11.0, d.z * 11.0));
238        let mut p = d * (1.0 + displacement) * size * 0.5;
239        // Flat-ish underside so rocks rest on terrain.
240        let floor = -size.y * 0.32;
241        if p.y < floor {
242            p.y = floor + (p.y - floor) * 0.15;
243        }
244        // Box-projected UVs (the renderer can triplanar-map instead).
245        let a = d.abs();
246        let uv = if a.y >= a.x && a.y >= a.z {
247            [p.x * 0.5 + 0.5, p.z * 0.5 + 0.5]
248        } else if a.x >= a.z {
249            [p.z * 0.5 + 0.5, p.y * 0.5 + 0.5]
250        } else {
251            [p.x * 0.5 + 0.5, p.y * 0.5 + 0.5]
252        };
253        m.vertices.push(Vertex { pos: p.into(), normal: d.into(), uv });
254    }
255    for [a, b, c] in faces {
256        m.indices.extend([a, b, c]);
257    }
258    compute_normals(&mut m);
259    m
260}
261
262fn scaled(mut m: MeshData, s: Vec3) -> MeshData {
263    for v in &mut m.vertices {
264        v.pos = (Vec3::from(v.pos) * s).into();
265    }
266    m
267}
268
269fn torus(major: f32, minor: f32, segments: u32, rings: u32) -> MeshData {
270    let mut m = MeshData::default();
271    for i in 0..=segments {
272        let u = i as f32 / segments as f32 * TAU;
273        for j in 0..=rings {
274            let v = j as f32 / rings as f32 * TAU;
275            let n = Vec3::new(v.cos() * u.cos(), v.sin(), v.cos() * u.sin());
276            let c = Vec3::new(u.cos(), 0.0, u.sin()) * major;
277            m.vertices.push(Vertex {
278                pos: (c + n * minor).into(),
279                normal: n.into(),
280                uv: [i as f32 / segments as f32, j as f32 / rings as f32],
281            });
282        }
283    }
284    let stride = rings + 1;
285    for i in 0..segments {
286        for j in 0..rings {
287            let a = i * stride + j;
288            let b = a + stride;
289            m.indices.extend([a, a + 1, b, a + 1, b + 1, b]);
290        }
291    }
292    m
293}
294
295/// Lowercase names accepted by MeshData::primitive and primitive mesh keys.
296pub const PRIMITIVES: &[&str] = &["cube", "sphere", "plane", "cylinder", "capsule", "cone"];
297
298impl MeshData {
299    /// Number of complete indexed triangles. Use [`Self::validate`] to reject a trailing index.
300    pub fn triangle_count(&self) -> usize {
301        self.indices.len() / 3
302    }
303
304    /// Whether there are no drawable triangles (vertices alone do not draw anything).
305    pub fn is_empty(&self) -> bool {
306        self.indices.len() < 3 || self.vertices.is_empty()
307    }
308
309    /// Checks triangle topology, finite vertex attributes and embedded image dimensions.
310    /// Zero-length normals and degenerate triangles are allowed (common at primitive poles).
311    pub fn validate(&self) -> Result<(), String> {
312        self.validate_geometry()?;
313        for (i, vertex) in self.vertices.iter().enumerate() {
314            if !Vec3::from(vertex.normal).is_finite() || vertex.uv.iter().any(|v| !v.is_finite()) {
315                return Err(format!("vertex {i} has non-finite normal or UV"));
316            }
317        }
318        if self.base_color.iter().any(|v| !v.is_finite()) {
319            return Err("base colour is non-finite".into());
320        }
321        if let Some(image) = &self.albedo {
322            let expected = (image.width as usize).checked_mul(image.height as usize).and_then(|n| n.checked_mul(4));
323            if image.width == 0 || image.height == 0 || expected != Some(image.rgba.len()) {
324                return Err("embedded albedo dimensions do not match RGBA bytes".into());
325            }
326        }
327        Ok(())
328    }
329
330    fn validate_geometry(&self) -> Result<(), String> {
331        if !self.indices.len().is_multiple_of(3) {
332            return Err("triangle index count is not divisible by three".into());
333        }
334        if let Some(&index) = self.indices.iter().find(|&&i| i as usize >= self.vertices.len()) {
335            return Err(format!("mesh index {index} is outside {} vertices", self.vertices.len()));
336        }
337        if let Some((i, _)) = self.vertices.iter().enumerate().find(|(_, v)| !Vec3::from(v.pos).is_finite()) {
338            return Err(format!("vertex {i} position is non-finite"));
339        }
340        Ok(())
341    }
342
343    /// Recomputes area-weighted smooth vertex normals from indexed triangles.
344    /// Checks topology/positions before mutation; isolated vertices receive zero normals.
345    pub fn recalculate_normals(&mut self) -> Result<(), String> {
346        self.validate_geometry()?;
347        compute_normals(self);
348        Ok(())
349    }
350
351    /// Copies and transforms a mesh by a finite nonsingular affine matrix.
352    /// Normals use the inverse transpose; mirrored transforms reverse triangle winding
353    /// so the front-face convention stays consistent. Texture pixels remain shared.
354    pub fn transformed(&self, matrix: Mat4) -> Result<Self, String> {
355        self.validate()?;
356        if !matrix.is_finite() || matrix.determinant() == 0.0 || !matrix.row(3).abs_diff_eq(glam::Vec4::W, 1e-6) {
357            return Err("mesh transform must be finite, affine and nonsingular".into());
358        }
359        let normals = matrix.inverse().transpose();
360        let mut result = self.clone();
361        for vertex in &mut result.vertices {
362            vertex.pos = matrix.transform_point3(Vec3::from(vertex.pos)).into();
363            vertex.normal = normals.transform_vector3(Vec3::from(vertex.normal)).normalize_or_zero().into();
364        }
365        if matrix.determinant() < 0.0 {
366            for triangle in result.indices.as_chunks_mut::<3>().0.iter_mut() {
367                triangle.swap(1, 2);
368            }
369        }
370        result.validate()?;
371        Ok(result)
372    }
373
374    /// The material runs of this mesh; a mesh without explicit parts is one run with its own albedo.
375    pub fn effective_parts(&self) -> Vec<MeshPart> {
376        if self.parts.is_empty() {
377            vec![MeshPart { first_index: 0, index_count: self.indices.len() as u32, albedo: self.albedo.clone(), normal: None, orm: None }]
378        } else {
379            self.parts.clone()
380        }
381    }
382
383    /// Appends another valid mesh, offsetting indices, while retaining this mesh's material.
384    /// Checks both inputs and index overflow before mutation. Meshes with different
385    /// textures should be drawn separately instead of merged through this operation.
386    pub fn append(&mut self, other: &Self) -> Result<(), String> {
387        self.validate()?;
388        other.validate()?;
389        let total = self.vertices.len().checked_add(other.vertices.len()).ok_or("vertex count overflow")?;
390        if total > u32::MAX as usize {
391            return Err("merged mesh exceeds u32 index space".into());
392        }
393        let offset = self.vertices.len() as u32;
394        let index_offset = self.indices.len() as u32;
395        if !self.parts.is_empty() || !other.parts.is_empty() {
396            let mut parts = self.effective_parts();
397            parts.extend(other.effective_parts().into_iter().map(|p| MeshPart { first_index: p.first_index + index_offset, ..p }));
398            self.parts = merge_parts(parts);
399        }
400        self.vertices.extend_from_slice(&other.vertices);
401        self.indices.extend(other.indices.iter().map(|i| i + offset));
402        self.dependencies.extend(other.dependencies.iter().cloned());
403        self.dependencies.sort();
404        self.dependencies.dedup();
405        Ok(())
406    }
407
408    /// Sum of triangle areas in square local units, including duplicated/double-sided faces.
409    /// Invalid topology or non-finite positions return an error.
410    pub fn surface_area(&self) -> Result<f32, String> {
411        self.validate_geometry()?;
412        Ok(self
413            .indices
414            .as_chunks::<3>()
415            .0
416            .iter()
417            .map(|t| {
418                let a = Vec3::from(self.vertices[t[0] as usize].pos);
419                let b = Vec3::from(self.vertices[t[1] as usize].pos);
420                let c = Vec3::from(self.vertices[t[2] as usize].pos);
421                (b - a).cross(c - a).length() * 0.5
422            })
423            .sum())
424    }
425
426    /// Axis-aligned local bounds (minimum, maximum). Empty meshes return two zero vectors.
427    pub fn bounds(&self) -> (Vec3, Vec3) {
428        let mut min = Vec3::splat(f32::MAX);
429        let mut max = Vec3::splat(f32::MIN);
430        for v in &self.vertices {
431            min = min.min(Vec3::from(v.pos));
432            max = max.max(Vec3::from(v.pos));
433        }
434        if self.vertices.is_empty() {
435            (Vec3::ZERO, Vec3::ZERO)
436        } else {
437            (min, max)
438        }
439    }
440
441    /// Builds a built-in unit primitive by lowercase name, or None for an unknown name.
442    pub fn primitive(name: &str) -> Option<MeshData> {
443        Some(match name {
444            "cube" => cube(),
445            "plane" => plane(32),
446            "sphere" => sphere(),
447            "cylinder" => lathe(
448                &[
449                    (0.0, 0.5, 0.0, 1.0),
450                    (0.5, 0.5, 0.0, 1.0),
451                    (0.5, 0.5, 1.0, 0.0),
452                    (0.5, -0.5, 1.0, 0.0),
453                    (0.5, -0.5, 0.0, -1.0),
454                    (0.0, -0.5, 0.0, -1.0),
455                ],
456                32,
457            ),
458            "cone" => {
459                let (nr, ny) = (0.894_427_2, 0.447_213_6);
460                lathe(&[(0.0, 0.5, nr, ny), (0.5, -0.5, nr, ny), (0.5, -0.5, 0.0, -1.0), (0.0, -0.5, 0.0, -1.0)], 32)
461            }
462            "capsule" => capsule(),
463            _ => return None,
464        })
465    }
466
467    /// Loads `primitive:<name>` or a model file from the asset source.
468    pub fn load(assets: &Assets, path: &str) -> Result<MeshData, String> {
469        if path.starts_with(PROC_PREFIX) {
470            return ProcParams::parse(path).and_then(|p| p.build()).ok_or_else(|| format!("bad procedural mesh '{path}'"));
471        }
472        if let Some(name) = path.strip_prefix(PRIMITIVE_PREFIX) {
473            return Self::primitive(name).ok_or_else(|| format!("unknown primitive '{name}'"));
474        }
475        let bytes = assets.read(path).map_err(|e| format!("{path}: {e}"))?;
476        let lower = path.to_ascii_lowercase();
477        let mesh = if lower.ends_with(".obj") {
478            parse_obj(&String::from_utf8_lossy(&bytes))
479        } else if lower.ends_with(".fbx") {
480            parse_fbx(assets, path, &bytes)
481        } else if lower.ends_with(".gltf") || lower.ends_with(".glb") {
482            parse_gltf(assets, path, &bytes)
483        } else {
484            Err("unsupported model format (use .obj, .fbx, .gltf or .glb)".into())
485        };
486        mesh.and_then(|mesh| {
487            mesh.validate()?;
488            Ok(mesh)
489        })
490        .map_err(|e| format!("{path}: {e}"))
491    }
492}
493
494/// Joins neighbouring runs that use the same image.
495fn merge_parts(parts: Vec<MeshPart>) -> Vec<MeshPart> {
496    let mut out: Vec<MeshPart> = Vec::new();
497    for p in parts {
498        let same = |a: &Option<std::sync::Arc<Albedo>>, b: &Option<std::sync::Arc<Albedo>>| match (a, b) {
499            (Some(x), Some(y)) => std::sync::Arc::ptr_eq(x, y),
500            (None, None) => true,
501            _ => false,
502        };
503        match out.last_mut() {
504            Some(last) if last.first_index + last.index_count == p.first_index && same(&last.albedo, &p.albedo) && same(&last.normal, &p.normal) && same(&last.orm, &p.orm) => last.index_count += p.index_count,
505            _ => out.push(p),
506        }
507    }
508    out
509}
510
511/// The image of a glTF material: its base-colour texture (or a flat colour) times the colour factor.
512pub(crate) fn material_albedo(images: &[Option<std::sync::Arc<Albedo>>], pbr: &gltf::material::PbrMetallicRoughness) -> Option<std::sync::Arc<Albedo>> {
513    let factor = pbr.base_color_factor();
514    let white = factor.iter().take(3).all(|v| (v - 1.0).abs() < 0.01);
515    let texture = pbr.base_color_texture().and_then(|info| images.get(info.texture().source().index()).cloned().flatten());
516    let to_byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
517    match texture {
518        Some(t) if white => Some(t),
519        Some(t) => {
520            let mut copy = (*t).clone();
521            for px in copy.rgba.chunks_exact_mut(4) {
522                for c in 0..3 {
523                    px[c] = (px[c] as f32 * factor[c]).clamp(0.0, 255.0) as u8;
524                }
525            }
526            Some(std::sync::Arc::new(copy))
527        }
528        None if white => None,
529        None => Some(std::sync::Arc::new(Albedo { width: 1, height: 1, rgba: vec![to_byte(factor[0]), to_byte(factor[1]), to_byte(factor[2]), 255] })),
530    }
531}
532
533/// Normal and ORM images of a glTF material. glTF packs roughness in G and metallic in B of one
534/// image; occlusion (R) is kept only when it comes from the same image, and metallic is cleared
535/// because several exporters write the roughness into both channels.
536fn material_maps(images: &[Option<std::sync::Arc<Albedo>>], material: &gltf::Material) -> (Option<std::sync::Arc<Albedo>>, Option<std::sync::Arc<Albedo>>) {
537    let image = |index: usize| images.get(index).cloned().flatten();
538    let normal = material.normal_texture().and_then(|t| image(t.texture().source().index()));
539    let orm = material.pbr_metallic_roughness().metallic_roughness_texture().and_then(|info| {
540        let source = info.texture().source().index();
541        let keep_occlusion = material.occlusion_texture().is_some_and(|o| o.texture().source().index() == source);
542        image(source).map(|img| {
543            let mut copy = (*img).clone();
544            for px in copy.rgba.chunks_exact_mut(4) {
545                if !keep_occlusion {
546                    px[0] = 255;
547                }
548                px[2] = 0;
549            }
550            std::sync::Arc::new(copy)
551        })
552    });
553    (normal, orm)
554}
555
556fn quad_indices(base: u32) -> [u32; 6] {
557    [base, base + 1, base + 2, base, base + 2, base + 3]
558}
559
560fn cube() -> MeshData {
561    let (x, y, z) = (Vec3::X, Vec3::Y, Vec3::Z);
562    // (u, v, normal) with u x v = normal, so the quad is counter-clockwise seen from outside.
563    let faces = [(x, y, z), (y, z, x), (z, x, y), (y, x, -z), (z, y, -x), (x, z, -y)];
564    let mut m = MeshData::default();
565    for (u, v, n) in faces {
566        let base = m.vertices.len() as u32;
567        for (su, sv, uv) in [(-1.0, -1.0, [0.0, 1.0]), (1.0, -1.0, [1.0, 1.0]), (1.0, 1.0, [1.0, 0.0]), (-1.0, 1.0, [0.0, 0.0])] {
568            let p = n * 0.5 + u * (0.5 * su) + v * (0.5 * sv);
569            m.vertices.push(Vertex { pos: p.into(), normal: n.into(), uv });
570        }
571        m.indices.extend(quad_indices(base));
572    }
573    m
574}
575
576/// 1x1 plane facing +Y, subdivided so vertex shaders can displace it.
577fn plane(n: u32) -> MeshData {
578    let mut m = MeshData::default();
579    for iz in 0..=n {
580        for ix in 0..=n {
581            let (u, v) = (ix as f32 / n as f32, iz as f32 / n as f32);
582            m.vertices.push(Vertex { pos: [u - 0.5, 0.0, v - 0.5], normal: [0.0, 1.0, 0.0], uv: [u, v] });
583        }
584    }
585    for iz in 0..n {
586        for ix in 0..n {
587            let a = iz * (n + 1) + ix;
588            let b = a + n + 1;
589            m.indices.extend([a, b, a + 1, a + 1, b, b + 1]);
590        }
591    }
592    m
593}
594
595/// Surface of revolution around Y. Each row is `(radius, y, normal_radial, normal_y)`, top to bottom.
596fn lathe(rows: &[(f32, f32, f32, f32)], segments: u32) -> MeshData {
597    let mut m = MeshData::default();
598    let stride = segments + 1;
599    for (i, &(r, y, nr, ny)) in rows.iter().enumerate() {
600        for j in 0..=segments {
601            let t = j as f32 / segments as f32;
602            let (s, c) = (t * TAU).sin_cos();
603            m.vertices.push(Vertex { pos: [r * c, y, r * s], normal: [nr * c, ny, nr * s], uv: [t, i as f32 / (rows.len() - 1) as f32] });
604        }
605    }
606    for i in 0..rows.len() as u32 - 1 {
607        for j in 0..segments {
608            let a = i * stride + j;
609            let b = a + stride;
610            m.indices.extend([a, a + 1, b, a + 1, b + 1, b]);
611        }
612    }
613    m
614}
615
616fn arc_rows(center_y: f32, from: f32, to: f32, steps: u32, radius: f32) -> Vec<(f32, f32, f32, f32)> {
617    (0..=steps)
618        .map(|k| {
619            let a = from + (to - from) * k as f32 / steps as f32;
620            (radius * a.sin(), center_y + radius * a.cos(), a.sin(), a.cos())
621        })
622        .collect()
623}
624
625fn sphere() -> MeshData {
626    lathe(&arc_rows(0.0, 0.0, PI, 16, 0.5), 32)
627}
628
629/// Radius 0.5, total height 2 (matches the capsule collider defaults).
630fn capsule() -> MeshData {
631    let mut rows = arc_rows(0.5, 0.0, PI / 2.0, 8, 0.5);
632    rows.extend(arc_rows(-0.5, PI / 2.0, PI, 8, 0.5));
633    lathe(&rows, 32)
634}
635
636fn parse_obj(text: &str) -> Result<MeshData, String> {
637    let (mut pos, mut uvs, mut norms) = (Vec::<[f32; 3]>::new(), Vec::<[f32; 2]>::new(), Vec::<[f32; 3]>::new());
638    let mut m = MeshData::default();
639    let mut dedupe: HashMap<(usize, usize, usize), u32> = HashMap::new();
640    let mut has_normals = true;
641    let nums = |s: std::str::SplitWhitespace| -> Vec<f32> { s.filter_map(|t| t.parse().ok()).collect() };
642    for line in text.lines() {
643        let mut it = line.split_whitespace();
644        match it.next() {
645            Some("v") => {
646                let n = nums(it);
647                if n.len() >= 3 {
648                    pos.push([n[0], n[1], n[2]]);
649                }
650            }
651            Some("vt") => {
652                let n = nums(it);
653                if n.len() >= 2 {
654                    uvs.push([n[0], 1.0 - n[1]]);
655                }
656            }
657            Some("vn") => {
658                let n = nums(it);
659                if n.len() >= 3 {
660                    norms.push([n[0], n[1], n[2]]);
661                }
662            }
663            Some("f") => {
664                let mut face = Vec::new();
665                for tok in it {
666                    let mut parts = tok.split('/');
667                    let mut idx = |len: usize| -> usize {
668                        match parts.next().and_then(|s| s.parse::<i64>().ok()) {
669                            Some(i) if i > 0 => i as usize,
670                            Some(i) if i < 0 => (len as i64 + i + 1).max(0) as usize,
671                            _ => 0,
672                        }
673                    };
674                    let key = (idx(pos.len()), idx(uvs.len()), idx(norms.len()));
675                    if key.0 == 0 || key.0 > pos.len() {
676                        return Err(format!("bad face index in '{line}'"));
677                    }
678                    let id = *dedupe.entry(key).or_insert_with(|| {
679                        has_normals &= key.2 != 0 && key.2 <= norms.len();
680                        m.vertices.push(Vertex {
681                            pos: pos[key.0 - 1],
682                            normal: if key.2 != 0 && key.2 <= norms.len() { norms[key.2 - 1] } else { [0.0; 3] },
683                            uv: if key.1 != 0 && key.1 <= uvs.len() { uvs[key.1 - 1] } else { [0.0; 2] },
684                        });
685                        m.vertices.len() as u32 - 1
686                    });
687                    face.push(id);
688                }
689                for k in 1..face.len().saturating_sub(1) {
690                    m.indices.extend([face[0], face[k], face[k + 1]]);
691                }
692            }
693            _ => {}
694        }
695    }
696    if m.indices.is_empty() {
697        return Err("no faces".into());
698    }
699    if !has_normals {
700        compute_normals(&mut m);
701    }
702    Ok(m)
703}
704
705/// FBX through ufbx: every mesh node is triangulated, transformed to world space (y up, metres)
706/// and merged. The first base-colour / diffuse texture that can be found next to the file becomes
707/// the model's albedo.
708fn parse_fbx(assets: &Assets, path: &str, bytes: &[u8]) -> Result<MeshData, String> {
709    use ufbx::{CoordinateAxes, CoordinateAxis};
710    let opts = ufbx::LoadOpts {
711        target_axes: CoordinateAxes { right: CoordinateAxis::PositiveX, up: CoordinateAxis::PositiveY, front: CoordinateAxis::PositiveZ },
712        target_unit_meters: 1.0,
713        generate_missing_normals: true,
714        ..Default::default()
715    };
716    let scene = ufbx::load_memory(bytes, opts).map_err(|e| format!("{e:?}"))?;
717    let dir = path.rsplit_once('/').map_or("", |(d, _)| d);
718    let mut m = MeshData::default();
719    let mut tri = Vec::new();
720    for node in scene.nodes.iter() {
721        let Some(mesh) = &node.mesh else { continue };
722        let g = &node.geometry_to_world;
723        let world = Mat4::from_cols_array(&[
724            g.m00 as f32,
725            g.m10 as f32,
726            g.m20 as f32,
727            0.0,
728            g.m01 as f32,
729            g.m11 as f32,
730            g.m21 as f32,
731            0.0,
732            g.m02 as f32,
733            g.m12 as f32,
734            g.m22 as f32,
735            0.0,
736            g.m03 as f32,
737            g.m13 as f32,
738            g.m23 as f32,
739            1.0,
740        ]);
741        let normal_m = world.inverse().transpose();
742        // One image per material: the texture the file names, or one guessed from the material name.
743        let parent = dir.rsplit_once('/').map_or("", |(d, _)| d);
744        let join = |d: &str, f: &str| if d.is_empty() { f.to_string() } else { format!("{d}/{f}") };
745        let stem = path.rsplit('/').next().unwrap_or(path).rsplit_once('.').map_or(path, |(s, _)| s).to_string();
746        let mut material_images: Vec<Option<std::sync::Arc<Albedo>>> = Vec::new();
747        for mat in mesh.materials.iter() {
748            let tex = mat.pbr.base_color.texture.as_ref().or(mat.fbx.diffuse_color.texture.as_ref());
749            let mut image = None;
750            if let Some(t) = tex {
751                let name = t.filename.rsplit(['/', '\\']).next().unwrap_or("").to_string();
752                // Next to the model, or in a sibling `textures` folder (common for exported packs).
753                let candidates = [join(dir, &name), join(&join(dir, "textures"), &name), join(&join(parent, "textures"), &name), join(&join(parent, "Textures"), &name)];
754                // Missing candidates are dependencies too: creating a missing texture
755                // later must invalidate a model cached with a fallback material.
756                m.dependencies.extend(candidates.iter().cloned());
757                image = candidates.iter().find_map(|c| load_albedo(assets, c));
758            }
759            if image.is_none() {
760                image = guess_texture(assets, dir, parent, &stem, &mat.element.name.to_string());
761            }
762            material_images.push(image);
763        }
764        if m.albedo.is_none() {
765            m.albedo = material_images.iter().flatten().next().cloned();
766        }
767        let base = m.vertices.len() as u32;
768        for i in 0..mesh.num_indices {
769            let p = mesh.vertex_position[i];
770            let n = if mesh.vertex_normal.exists { mesh.vertex_normal[i] } else { ufbx::Vec3 { x: 0.0, y: 1.0, z: 0.0 } };
771            let uv = if mesh.vertex_uv.exists {
772                let uv = mesh.vertex_uv[i];
773                [uv.x as f32, 1.0 - uv.y as f32]
774            } else {
775                [0.0; 2]
776            };
777            m.vertices.push(Vertex {
778                pos: world.transform_point3(Vec3::new(p.x as f32, p.y as f32, p.z as f32)).into(),
779                normal: normal_m.transform_vector3(Vec3::new(n.x as f32, n.y as f32, n.z as f32)).normalize_or_zero().into(),
780                uv,
781            });
782        }
783        // Triangles grouped by material so each group can be drawn with its own image.
784        let groups = mesh.materials.len().max(1);
785        let mut per_material: Vec<Vec<u32>> = vec![Vec::new(); groups];
786        for (face_index, face) in mesh.faces.iter().enumerate() {
787            ufbx::triangulate_face_vec(&mut tri, mesh, *face);
788            let slot = if mesh.face_material.is_empty() { 0 } else { (mesh.face_material[face_index] as usize).min(groups - 1) };
789            per_material[slot].extend(tri.iter().map(|&i| base + i));
790        }
791        for (slot, list) in per_material.into_iter().enumerate() {
792            if list.is_empty() {
793                continue;
794            }
795            let first_index = m.indices.len() as u32;
796            let index_count = list.len() as u32;
797            m.indices.extend(list);
798            let albedo = material_images.get(slot).cloned().flatten();
799            m.parts.push(MeshPart { first_index, index_count, albedo, normal: None, orm: None });
800        }
801    }
802    if m.indices.is_empty() {
803        return Err("no triangles".into());
804    }
805    m.parts = merge_parts(std::mem::take(&mut m.parts));
806    if m.parts.len() <= 1 {
807        m.parts.clear();
808    }
809    Ok(m)
810}
811
812fn load_albedo(assets: &Assets, path: &str) -> Option<std::sync::Arc<Albedo>> {
813    let img = image::load_from_memory(&assets.read(path).ok()?).ok()?;
814    let rgba = img.to_rgba8();
815    Some(std::sync::Arc::new(Albedo { width: rgba.width(), height: rgba.height(), rgba: rgba.into_raw() }))
816}
817
818/// Finds a texture for a material that names none: `Trunk`/`Bark` and `Leaf`/`Leaves` materials look
819/// for an image with that word in its name, preferring the one that also carries the model family
820/// ("STOak3" -> "oak"), e.g. `STOakLeafTexture.png`.
821fn guess_texture(assets: &Assets, dir: &str, parent: &str, stem: &str, material: &str) -> Option<std::sync::Arc<Albedo>> {
822    let material = material.to_ascii_lowercase();
823    let kind = if material.contains("trunk") || material.contains("bark") {
824        "trunk"
825    } else if material.contains("leaf") || material.contains("leaves") {
826        "leaf"
827    } else {
828        return None;
829    };
830    let family: String = stem.chars().filter(|c| c.is_ascii_alphabetic()).collect::<String>().to_ascii_lowercase();
831    let sub = |d: &str| if d.is_empty() { "textures".to_string() } else { format!("{}/textures", d.to_ascii_lowercase()) };
832    let roots = [dir.to_ascii_lowercase(), sub(dir), sub(parent)];
833    let mut best: Option<(i32, String)> = None;
834    for file in assets.list() {
835        let lower = file.to_ascii_lowercase();
836        if !(lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg")) {
837            continue;
838        }
839        let (folder, name) = lower.rsplit_once('/').map_or(("", lower.as_str()), |(f, n)| (f, n));
840        if !roots.iter().any(|r| r.as_str() == folder) || !name.contains(kind) {
841            continue;
842        }
843        let score = if name.contains(&family) { 2 } else { 0 } - (name.contains("alt") as i32);
844        if best.as_ref().is_none_or(|(s, _)| score > *s) {
845            best = Some((score, file));
846        }
847    }
848    best.and_then(|(_, file)| load_albedo(assets, &file))
849}
850
851fn compute_normals(m: &mut MeshData) {
852    for v in &mut m.vertices {
853        v.normal = [0.0; 3];
854    }
855    for tri in m.indices.as_chunks::<3>().0.iter() {
856        let p = |i: u32| Vec3::from(m.vertices[i as usize].pos);
857        let n = (p(tri[1]) - p(tri[0])).cross(p(tri[2]) - p(tri[0]));
858        for &i in tri {
859            let v = &mut m.vertices[i as usize];
860            v.normal = (Vec3::from(v.normal) + n).into();
861        }
862    }
863    for v in &mut m.vertices {
864        v.normal = Vec3::from(v.normal).normalize_or_zero().into();
865    }
866}
867
868fn to_albedo(img: &gltf::image::Data) -> Option<Albedo> {
869    use gltf::image::Format;
870    let n = (img.width * img.height) as usize;
871    let rgba = match img.format {
872        Format::R8G8B8A8 => img.pixels.clone(),
873        Format::R8G8B8 => img.pixels.as_chunks::<3>().0.iter().flat_map(|p| [p[0], p[1], p[2], 255]).collect(),
874        Format::R8 => img.pixels.iter().flat_map(|&v| [v, v, v, 255]).collect(),
875        Format::R8G8 => img.pixels.as_chunks::<2>().0.iter().flat_map(|p| [p[0], p[0], p[0], p[1]]).collect(),
876        _ => return None,
877    };
878    (rgba.len() == n * 4).then_some(Albedo { width: img.width, height: img.height, rgba })
879}
880
881fn decode_uri_path(uri: &str) -> Result<String, String> {
882    let mut decoded = Vec::with_capacity(uri.len());
883    let mut bytes = uri.bytes();
884    while let Some(byte) = bytes.next() {
885        if byte == b'%' {
886            let high = bytes.next().and_then(|b| (b as char).to_digit(16)).ok_or("invalid URI percent escape")?;
887            let low = bytes.next().and_then(|b| (b as char).to_digit(16)).ok_or("invalid URI percent escape")?;
888            decoded.push(((high << 4) | low) as u8);
889        } else {
890            decoded.push(byte);
891        }
892    }
893    String::from_utf8(decoded).map_err(|e| format!("asset URI is not UTF-8: {e}"))
894}
895
896/// glTF documents that reference files (`Textures/colormap.png`) cannot go through `import_slice`;
897/// those buffers and images are read through `Assets`, relative to the model's folder.
898pub(crate) struct ImportedGltf {
899    pub(crate) document: gltf::Document,
900    pub(crate) buffers: Vec<gltf::buffer::Data>,
901    pub(crate) images: Vec<Option<std::sync::Arc<Albedo>>>,
902    pub(crate) dependencies: Vec<String>,
903}
904
905/// Parses a glTF/GLB with its buffers and images (embedded or next to the file).
906pub(crate) fn import_gltf(assets: &Assets, path: &str, bytes: &[u8]) -> Result<ImportedGltf, String> {
907    match gltf::import_slice(bytes) {
908        Ok((document, buffers, images)) => Ok(ImportedGltf {
909            document,
910            buffers,
911            images: images.iter().map(|i| to_albedo(i).map(std::sync::Arc::new)).collect(),
912            dependencies: Vec::new(),
913        }),
914        Err(_) => load_external(assets, path, bytes),
915    }
916}
917
918fn load_external(assets: &Assets, path: &str, bytes: &[u8]) -> Result<ImportedGltf, String> {
919    let gltf::Gltf { document, blob } = gltf::Gltf::from_slice(bytes).map_err(|e| e.to_string())?;
920    let dir = path.rsplit_once('/').map_or("", |(d, _)| d);
921    let dependencies = std::cell::RefCell::new(Vec::new());
922    let read = |uri: &str| -> Result<Vec<u8>, String> {
923        if uri.starts_with("data:") {
924            return gltf::buffer::Data::from_source(gltf::buffer::Source::Uri(uri), None).map(|data| data.0).map_err(|e| e.to_string());
925        }
926        let decoded = decode_uri_path(uri)?;
927        let full = if dir.is_empty() { decoded } else { format!("{dir}/{decoded}") };
928        let full = crate::assets::normalize_path(&full).map_err(|e| e.to_string())?;
929        dependencies.borrow_mut().push(full.clone());
930        assets.read(&full).map_err(|e| format!("{full}: {e}"))
931    };
932    let mut buffers = Vec::new();
933    for b in document.buffers() {
934        let data = match b.source() {
935            gltf::buffer::Source::Bin => blob.clone().ok_or("GLB without a binary chunk")?,
936            gltf::buffer::Source::Uri(uri) => read(uri)?,
937        };
938        if data.len() < b.length() {
939            return Err(format!("glTF buffer {} is shorter than its declared byteLength", b.index()));
940        }
941        buffers.push(gltf::buffer::Data(data));
942    }
943    let mut albedos = Vec::new();
944    for img in document.images() {
945        let encoded = match img.source() {
946            gltf::image::Source::Uri { uri, .. } => read(uri).ok(),
947            gltf::image::Source::View { view, .. } => buffers
948                .get(view.buffer().index())
949                .and_then(|buffer| view.offset().checked_add(view.length()).and_then(|end| buffer.0.get(view.offset()..end)))
950                .map(<[u8]>::to_vec),
951        };
952        albedos.push(encoded.and_then(|e| image::load_from_memory(&e).ok()).map(|i| {
953            let rgba = i.to_rgba8();
954            std::sync::Arc::new(Albedo { width: rgba.width(), height: rgba.height(), rgba: rgba.into_raw() })
955        }));
956    }
957    Ok(ImportedGltf { document, buffers, images: albedos, dependencies: dependencies.into_inner() })
958}
959
960fn parse_gltf(assets: &Assets, path: &str, bytes: &[u8]) -> Result<MeshData, String> {
961    let ImportedGltf { document: doc, buffers, images, dependencies } = match gltf::import_slice(bytes) {
962        Ok((document, buffers, images)) => ImportedGltf {
963            document,
964            buffers,
965            images: images.iter().map(|i| to_albedo(i).map(std::sync::Arc::new)).collect(),
966            dependencies: Vec::new(),
967        },
968        Err(_) => load_external(assets, path, bytes)?,
969    };
970    let scene = doc.default_scene().or_else(|| doc.scenes().next()).ok_or("no scene")?;
971    let mut mesh = MeshData { dependencies, ..Default::default() };
972    // Explicit enter/exit frames avoid stack overflow and detect cycles while
973    // allowing the same node to be instanced under separate parent paths.
974    enum Work<'a> {
975        Enter(gltf::Node<'a>, Mat4),
976        Exit(usize),
977    }
978    let mut stack: Vec<_> = scene.nodes().collect::<Vec<_>>().into_iter().rev().map(|node| Work::Enter(node, Mat4::IDENTITY)).collect();
979    let mut active = std::collections::HashSet::new();
980    while let Some(work) = stack.pop() {
981        let (node, parent) = match work {
982            Work::Exit(index) => {
983                active.remove(&index);
984                continue;
985            }
986            Work::Enter(node, parent) => (node, parent),
987        };
988        if !active.insert(node.index()) {
989            return Err(format!("cyclic glTF node hierarchy at node {}", node.index()));
990        }
991        let world = parent * Mat4::from_cols_array_2d(&node.transform().matrix());
992        if !world.is_finite() {
993            return Err(format!("non-finite glTF node transform {}", node.index()));
994        }
995        let normal_matrix = if world.determinant() == 0.0 { Mat4::IDENTITY } else { world.inverse().transpose() };
996        if let Some(source) = node.mesh() {
997            for primitive in source.primitives() {
998                use gltf::mesh::Mode;
999                if !matches!(primitive.mode(), Mode::Triangles | Mode::TriangleStrip | Mode::TriangleFan) {
1000                    continue;
1001                }
1002                let pbr = primitive.material().pbr_metallic_roughness();
1003                if mesh.albedo.is_none() && mesh.indices.is_empty() {
1004                    mesh.base_color = pbr.base_color_factor();
1005                }
1006                if mesh.albedo.is_none() {
1007                    if let Some(info) = pbr.base_color_texture() {
1008                        mesh.albedo = images.get(info.texture().source().index()).cloned().flatten();
1009                    }
1010                }
1011                let part_albedo = material_albedo(&images, &pbr);
1012                let (part_normal, part_orm) = material_maps(&images, &primitive.material());
1013                let first_index = mesh.indices.len() as u32;
1014                let reader = primitive.reader(|buffer| buffers.get(buffer.index()).map(|data| data.0.as_slice()));
1015                let Some(positions) = reader.read_positions() else {
1016                    continue;
1017                };
1018                let base = u32::try_from(mesh.vertices.len()).map_err(|_| "glTF exceeds u32 vertex space")?;
1019                let normals: Vec<_> = reader.read_normals().map(|values| values.collect()).unwrap_or_default();
1020                let uvs: Vec<_> = reader.read_tex_coords(0).map(|values| values.into_f32().collect()).unwrap_or_default();
1021                for (index, position) in positions.enumerate() {
1022                    mesh.vertices.push(Vertex {
1023                        pos: world.transform_point3(Vec3::from(position)).into(),
1024                        normal: normals
1025                            .get(index)
1026                            .map_or([0.0; 3], |&normal| normal_matrix.transform_vector3(Vec3::from(normal)).normalize_or_zero().into()),
1027                        uv: uvs.get(index).copied().unwrap_or([0.0; 2]),
1028                    });
1029                }
1030                let count = mesh.vertices.len() - base as usize;
1031                let indices: Vec<u32> =
1032                    reader.read_indices().map(|indices| indices.into_u32().collect()).unwrap_or_else(|| (0..count as u32).collect());
1033                if indices.iter().any(|&index| index as usize >= count) {
1034                    return Err("glTF primitive index exceeds its POSITION count".into());
1035                }
1036                let mut triangles = Vec::new();
1037                match primitive.mode() {
1038                    Mode::Triangles => {
1039                        if !indices.len().is_multiple_of(3) {
1040                            return Err("glTF triangle index count is not divisible by three".into());
1041                        }
1042                        triangles.extend(indices.as_chunks::<3>().0.iter().map(|tri| [tri[0], tri[1], tri[2]]));
1043                    }
1044                    Mode::TriangleStrip => {
1045                        for (index, tri) in indices.windows(3).enumerate() {
1046                            triangles.push(if index % 2 == 0 { [tri[0], tri[1], tri[2]] } else { [tri[1], tri[0], tri[2]] });
1047                        }
1048                    }
1049                    Mode::TriangleFan => {
1050                        for tri in indices.get(1..).unwrap_or_default().windows(2) {
1051                            triangles.push([indices[0], tri[0], tri[1]]);
1052                        }
1053                    }
1054                    _ => unreachable!(),
1055                }
1056                for mut triangle in triangles {
1057                    if triangle[0] == triangle[1] || triangle[1] == triangle[2] || triangle[0] == triangle[2] {
1058                        continue;
1059                    }
1060                    if world.determinant() < 0.0 {
1061                        triangle.swap(1, 2);
1062                    }
1063                    for index in triangle {
1064                        mesh.indices.push(base.checked_add(index).ok_or("glTF index overflow")?);
1065                    }
1066                }
1067                let index_count = mesh.indices.len() as u32 - first_index;
1068                if index_count > 0 {
1069                    mesh.parts.push(MeshPart { first_index, index_count, albedo: part_albedo, normal: part_normal, orm: part_orm });
1070                }
1071            }
1072        }
1073        stack.push(Work::Exit(node.index()));
1074        for child in node.children().collect::<Vec<_>>().into_iter().rev() {
1075            stack.push(Work::Enter(child, world));
1076        }
1077    }
1078    if mesh.indices.is_empty() {
1079        return Err("no triangles".into());
1080    }
1081    mesh.parts = merge_parts(std::mem::take(&mut mesh.parts));
1082    // A single material needs no runs; the renderer treats the mesh as one piece.
1083    if mesh.parts.len() <= 1 && mesh.parts.iter().all(|p| p.normal.is_none() && p.orm.is_none()) {
1084        // The run's image already carries the material colour (a 1x1 swatch for an untextured
1085        // material, or the texture tinted by its factor); keep it as the mesh's image.
1086        if let Some(albedo) = mesh.parts.first().and_then(|p| p.albedo.clone()) {
1087            mesh.albedo = Some(albedo);
1088        }
1089        mesh.parts.clear();
1090    }
1091    mesh.validate_geometry()?;
1092    if mesh.vertices.iter().any(|vertex| vertex.normal == [0.0; 3]) {
1093        // Recompute consistently when any primitive lacks normals; mixing valid
1094        // and zero normals would otherwise leave unlit patches in merged models.
1095        compute_normals(&mut mesh);
1096    }
1097    mesh.validate()?;
1098    Ok(mesh)
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103    use super::*;
1104
1105    #[test]
1106    fn primitives_face_outward() {
1107        for name in PRIMITIVES {
1108            let m = MeshData::primitive(name).unwrap();
1109            let mut checked = 0;
1110            for tri in m.indices.as_chunks::<3>().0.iter() {
1111                let v = |i: u32| m.vertices[i as usize];
1112                let (a, b, c) = (v(tri[0]), v(tri[1]), v(tri[2]));
1113                let geo = (Vec3::from(b.pos) - Vec3::from(a.pos)).cross(Vec3::from(c.pos) - Vec3::from(a.pos));
1114                if geo.length() < 1e-6 {
1115                    continue; // degenerate triangle at a pole
1116                }
1117                let n = Vec3::from(a.normal) + Vec3::from(b.normal) + Vec3::from(c.normal);
1118                assert!(geo.dot(n) > 0.0, "{name}: triangle winds against its normals");
1119                checked += 1;
1120            }
1121            assert!(checked > 0, "{name}: no triangles");
1122        }
1123    }
1124
1125    #[test]
1126    fn procedural_shapes_face_outward() {
1127        for kind in ["cube", "plane", "sphere", "capsule", "cylinder", "cone", "torus", "rock"] {
1128            let p = ProcParams {
1129                kind: kind.into(),
1130                size: Vec3::new(2.0, 1.0, 3.0),
1131                radius: 0.7,
1132                radius2: 0.25,
1133                height: 2.0,
1134                segments: 20,
1135                rings: 10,
1136                seed: 3,
1137            };
1138            assert_eq!(ProcParams::parse(&p.key()), Some(p.clone()), "{kind} key roundtrip");
1139            let m = p.build().unwrap();
1140            let mut checked = 0;
1141            for tri in m.indices.as_chunks::<3>().0.iter() {
1142                let v = |i: u32| m.vertices[i as usize];
1143                let (a, b, c) = (v(tri[0]), v(tri[1]), v(tri[2]));
1144                let geo = (Vec3::from(b.pos) - Vec3::from(a.pos)).cross(Vec3::from(c.pos) - Vec3::from(a.pos));
1145                if geo.length() < 1e-6 {
1146                    continue;
1147                }
1148                let n = Vec3::from(a.normal) + Vec3::from(b.normal) + Vec3::from(c.normal);
1149                assert!(geo.dot(n) > 0.0, "{kind}: wrong winding");
1150                checked += 1;
1151            }
1152            assert!(checked > 0, "{kind}");
1153        }
1154    }
1155
1156    #[test]
1157    fn obj_quad_is_triangulated() {
1158        let m = parse_obj("v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nf 1 2 3 4\n").unwrap();
1159        assert_eq!(m.indices.len(), 6);
1160        assert!(m.vertices[0].normal[2] > 0.99);
1161    }
1162}