Skip to main content

oxideav_mesh3d/
scene.rs

1//! Scene-graph root, nodes, transforms, ID newtypes, and coordinate
2//! metadata.
3//!
4//! The container is [`Scene3D`]. Every collection it owns
5//! ([`Node`], [`Mesh`](crate::Mesh), [`Material`](crate::Material),
6//! ...) is addressed by an `IdT(u32)` newtype that indexes into the
7//! corresponding `Vec`. This keeps the model arena-friendly — clones
8//! are cheap, identity is comparable, and serde round-tripping works
9//! without back-references — while still letting decoders bulk-load
10//! every mesh first and then point nodes at them.
11//!
12//! Coordinate convention defaults to **glTF 2.0**: right-handed,
13//! Y-up, -Z forward, metres. Format crates that consume Z-up content
14//! (STL, OBJ Wavefront) set [`Scene3D::up_axis`] to [`Axis::PosZ`]
15//! and leave geometry untouched — the orientation metadata is
16//! authoritative, no implicit rotation is applied.
17
18use std::collections::{HashMap, HashSet};
19
20use crate::{
21    animation::Animation,
22    audio::{AudioEmitter, AudioEmitterId, AudioSource, AudioSourceId},
23    camera::Camera,
24    light::Light,
25    material::Material,
26    mesh::Mesh,
27    skin::Skeleton,
28    skin::Skin,
29    texture::Texture,
30};
31
32macro_rules! id_newtype {
33    ($(#[$meta:meta])* $name:ident) => {
34        $(#[$meta])*
35        #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
36        pub struct $name(pub u32);
37    };
38}
39
40id_newtype!(
41    /// Index into [`Scene3D::nodes`].
42    NodeId
43);
44id_newtype!(
45    /// Index into [`Scene3D::meshes`].
46    MeshId
47);
48id_newtype!(
49    /// Index into [`Scene3D::materials`].
50    MaterialId
51);
52id_newtype!(
53    /// Index into [`Scene3D::textures`].
54    TextureId
55);
56id_newtype!(
57    /// Index into [`Scene3D::material_variants`].
58    MaterialVariantId
59);
60id_newtype!(
61    /// Index into [`Scene3D::skeletons`].
62    SkeletonId
63);
64id_newtype!(
65    /// Index into [`Scene3D::skins`].
66    SkinId
67);
68id_newtype!(
69    /// Index into [`Scene3D::cameras`].
70    CameraId
71);
72id_newtype!(
73    /// Index into [`Scene3D::lights`].
74    LightId
75);
76
77/// Axis-aligned bounding box over a set of 3D points.
78///
79/// `min` is the componentwise minimum corner, `max` the componentwise
80/// maximum corner. Both are inclusive; for an empty point set this
81/// type returns [`None`] from its constructors rather than carrying a
82/// degenerate `[inf; 3]` / `[-inf; 3]` sentinel.
83///
84/// Use [`BoundingBox::from_points`] to build one from an iterator of
85/// `[f32; 3]`, [`BoundingBox::union`] to merge two boxes, and
86/// [`BoundingBox::transform`] to rotate / translate / scale the box
87/// by a 4x4 row-major-column-vector matrix (the eight corners are
88/// transformed and a new AABB is fitted around them — the rotated
89/// box's tight bound).
90#[derive(Clone, Copy, Debug, PartialEq)]
91pub struct BoundingBox {
92    pub min: [f32; 3],
93    pub max: [f32; 3],
94}
95
96impl BoundingBox {
97    /// Bounding box of exactly one point. Both corners coincide.
98    pub fn from_point(p: [f32; 3]) -> Self {
99        Self { min: p, max: p }
100    }
101
102    /// Bounding box over a stream of points. Returns `None` if the
103    /// iterator yields zero finite points (NaN coordinates are
104    /// skipped on a per-component basis).
105    pub fn from_points<I: IntoIterator<Item = [f32; 3]>>(points: I) -> Option<Self> {
106        let mut acc: Option<Self> = None;
107        for p in points {
108            if p[0].is_nan() || p[1].is_nan() || p[2].is_nan() {
109                continue;
110            }
111            acc = Some(match acc {
112                None => Self::from_point(p),
113                Some(b) => b.expand(p),
114            });
115        }
116        acc
117    }
118
119    /// Grow the box to include `p`. Returns a new box; the input is
120    /// left unchanged. NaN components are kept as-is on the
121    /// existing box (they are not propagated by [`from_points`] either).
122    pub fn expand(self, p: [f32; 3]) -> Self {
123        Self {
124            min: [
125                self.min[0].min(p[0]),
126                self.min[1].min(p[1]),
127                self.min[2].min(p[2]),
128            ],
129            max: [
130                self.max[0].max(p[0]),
131                self.max[1].max(p[1]),
132                self.max[2].max(p[2]),
133            ],
134        }
135    }
136
137    /// Componentwise union of two boxes — the smallest AABB
138    /// containing both.
139    pub fn union(self, other: Self) -> Self {
140        Self {
141            min: [
142                self.min[0].min(other.min[0]),
143                self.min[1].min(other.min[1]),
144                self.min[2].min(other.min[2]),
145            ],
146            max: [
147                self.max[0].max(other.max[0]),
148                self.max[1].max(other.max[1]),
149                self.max[2].max(other.max[2]),
150            ],
151        }
152    }
153
154    /// Centre of the box (average of `min` and `max`).
155    pub fn center(self) -> [f32; 3] {
156        [
157            0.5 * (self.min[0] + self.max[0]),
158            0.5 * (self.min[1] + self.max[1]),
159            0.5 * (self.min[2] + self.max[2]),
160        ]
161    }
162
163    /// Componentwise size of the box (`max - min`).
164    pub fn size(self) -> [f32; 3] {
165        [
166            self.max[0] - self.min[0],
167            self.max[1] - self.min[1],
168            self.max[2] - self.min[2],
169        ]
170    }
171
172    /// `true` if every component of `min` is less than or equal to the
173    /// corresponding component of `max` (i.e. the box is non-empty
174    /// and well-formed).
175    pub fn is_valid(self) -> bool {
176        self.min[0] <= self.max[0] && self.min[1] <= self.max[1] && self.min[2] <= self.max[2]
177    }
178
179    /// Tight AABB around the box transformed by a row-major
180    /// column-vector 4x4 matrix (`out = M * v`, same convention as
181    /// [`Transform::Matrix`]).
182    ///
183    /// Returns the AABB of the eight transformed corners. For
184    /// non-affine matrices (perspective `w != 1`) the result may not
185    /// be physically meaningful — this method is intended for the
186    /// scene-graph TRS / matrix chain composing every ancestor node's
187    /// local transform.
188    pub fn transform(self, m: [[f32; 4]; 4]) -> Self {
189        let corners = [
190            [self.min[0], self.min[1], self.min[2]],
191            [self.max[0], self.min[1], self.min[2]],
192            [self.min[0], self.max[1], self.min[2]],
193            [self.max[0], self.max[1], self.min[2]],
194            [self.min[0], self.min[1], self.max[2]],
195            [self.max[0], self.min[1], self.max[2]],
196            [self.min[0], self.max[1], self.max[2]],
197            [self.max[0], self.max[1], self.max[2]],
198        ];
199        let xf = corners.map(|c| {
200            [
201                m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2] + m[0][3],
202                m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2] + m[1][3],
203                m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2] + m[2][3],
204            ]
205        });
206        Self::from_points(xf).expect("eight corners always yield a finite AABB")
207    }
208
209    /// Slab-method ray-AABB intersection — returns the entry / exit
210    /// parametric distances along the ray clamped to `[0, t_max]`, or
211    /// `None` if the ray misses.
212    ///
213    /// `t_enter == 0.0` indicates the ray's origin lies inside the
214    /// box; `t_exit` is the parameter at which the ray leaves through
215    /// the far face. Both values are along the (not-necessarily-unit)
216    /// `ray.direction`, so the actual world-space point at the
217    /// intersection is `ray.point_at(t)`.
218    ///
219    /// Delegates to [`crate::ray::intersect_aabb`]; see its docs for
220    /// the axis-parallel-ray + NaN / Inf handling.
221    pub fn intersect_ray(self, ray: crate::ray::Ray, t_max: f32) -> Option<(f32, f32)> {
222        crate::ray::intersect_aabb(ray, self.min, self.max, t_max)
223    }
224}
225
226/// Coordinate-system principal axis. Stored on [`Scene3D`] so a
227/// renderer can apply (or skip) a global rotation when the file
228/// convention disagrees with its own.
229#[derive(Clone, Copy, Debug, PartialEq, Eq)]
230pub enum Axis {
231    PosX,
232    NegX,
233    PosY,
234    NegY,
235    PosZ,
236    NegZ,
237}
238
239/// Linear unit a single coordinate-space-1.0 represents in the file.
240/// glTF defaults to metres; CAD/STL files often ship in millimetres
241/// or inches. Renderers that mix scenes from different unit systems
242/// scale by the ratio of [`Unit::to_metres`] values.
243#[derive(Clone, Copy, Debug, PartialEq, Eq)]
244pub enum Unit {
245    Metres,
246    Centimetres,
247    Millimetres,
248    Inches,
249    Feet,
250    Yards,
251}
252
253impl Unit {
254    /// Multiplier from this unit to metres, e.g. `Inches.to_metres() == 0.0254`.
255    pub fn to_metres(self) -> f32 {
256        match self {
257            Self::Metres => 1.0,
258            Self::Centimetres => 0.01,
259            Self::Millimetres => 0.001,
260            Self::Inches => 0.0254,
261            Self::Feet => 0.3048,
262            Self::Yards => 0.9144,
263        }
264    }
265}
266
267/// Per-node local-to-parent transform. Decoders can store the raw
268/// matrix as-is or decompose into translation/rotation/scale; the
269/// [`Transform::to_matrix`] / [`Transform::from_matrix`] helpers
270/// convert in either direction within float tolerance.
271#[derive(Clone, Copy, Debug, PartialEq)]
272pub enum Transform {
273    /// Row-major column-vector 4x4 transform — pre-multiplied
274    /// (`out = M * v`). Layout matches glTF's `node.matrix` field.
275    Matrix([[f32; 4]; 4]),
276    /// Decomposed translation + rotation (xyzw quaternion) + scale.
277    /// glTF's TRS form; preferred for animation since each channel is
278    /// independent.
279    Trs {
280        translation: [f32; 3],
281        rotation: [f32; 4],
282        scale: [f32; 3],
283    },
284}
285
286impl Transform {
287    /// Identity TRS — `(0,0,0)` translation, identity quaternion,
288    /// `(1,1,1)` scale.
289    pub fn identity() -> Self {
290        Self::Trs {
291            translation: [0.0; 3],
292            rotation: [0.0, 0.0, 0.0, 1.0],
293            scale: [1.0, 1.0, 1.0],
294        }
295    }
296
297    /// Compose this transform into a single 4x4 matrix.
298    ///
299    /// For `Matrix(m)` this is the identity passthrough; for
300    /// `Trs { t, r, s }` the build order is `T * R * S`.
301    pub fn to_matrix(&self) -> [[f32; 4]; 4] {
302        match *self {
303            Self::Matrix(m) => m,
304            Self::Trs {
305                translation,
306                rotation,
307                scale,
308            } => trs_to_matrix(translation, rotation, scale),
309        }
310    }
311
312    /// Best-effort decomposition of a 4x4 affine transform into TRS.
313    ///
314    /// Assumes the input is `T * R * S` with no shear and no negative
315    /// scale; under that assumption the recovery is exact within
316    /// float epsilon. For matrices with shear the output is the
317    /// closest pure TRS (scales are column lengths, rotation is the
318    /// orthonormalised basis).
319    pub fn from_matrix(m: [[f32; 4]; 4]) -> Self {
320        let translation = [m[0][3], m[1][3], m[2][3]];
321        let cx = [m[0][0], m[1][0], m[2][0]];
322        let cy = [m[0][1], m[1][1], m[2][1]];
323        let cz = [m[0][2], m[1][2], m[2][2]];
324        let sx = vec3_len(cx);
325        let sy = vec3_len(cy);
326        let sz = vec3_len(cz);
327        // Avoid div-by-zero if a column was zero — fall back to a sentinel
328        // axis; this lets the from_matrix(to_matrix(t)) round-trip remain
329        // total even for pathological inputs.
330        let inv_sx = if sx > f32::EPSILON { 1.0 / sx } else { 1.0 };
331        let inv_sy = if sy > f32::EPSILON { 1.0 / sy } else { 1.0 };
332        let inv_sz = if sz > f32::EPSILON { 1.0 / sz } else { 1.0 };
333        let r00 = cx[0] * inv_sx;
334        let r10 = cx[1] * inv_sx;
335        let r20 = cx[2] * inv_sx;
336        let r01 = cy[0] * inv_sy;
337        let r11 = cy[1] * inv_sy;
338        let r21 = cy[2] * inv_sy;
339        let r02 = cz[0] * inv_sz;
340        let r12 = cz[1] * inv_sz;
341        let r22 = cz[2] * inv_sz;
342        let rotation = rot_matrix_to_quat([[r00, r01, r02], [r10, r11, r12], [r20, r21, r22]]);
343        Self::Trs {
344            translation,
345            rotation,
346            scale: [sx, sy, sz],
347        }
348    }
349}
350
351fn vec3_len(v: [f32; 3]) -> f32 {
352    (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
353}
354
355/// Row-major column-vector 4x4 matrix multiply `a * b`.
356pub(crate) fn mat4_mul(a: [[f32; 4]; 4], b: [[f32; 4]; 4]) -> [[f32; 4]; 4] {
357    let mut out = [[0.0f32; 4]; 4];
358    for (i, row) in out.iter_mut().enumerate() {
359        for (j, slot) in row.iter_mut().enumerate() {
360            *slot = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j] + a[i][3] * b[3][j];
361        }
362    }
363    out
364}
365
366/// Signed determinant of the upper-left 3x3 of a row-major
367/// column-vector 4x4 matrix, returned as `f64` for accumulator-safe
368/// volume scaling. The translation column does not enter the result.
369fn mat3_det_of_world(m: [[f32; 4]; 4]) -> f64 {
370    let a = m[0][0] as f64;
371    let b = m[0][1] as f64;
372    let c = m[0][2] as f64;
373    let d = m[1][0] as f64;
374    let e = m[1][1] as f64;
375    let f = m[1][2] as f64;
376    let g = m[2][0] as f64;
377    let h = m[2][1] as f64;
378    let i = m[2][2] as f64;
379    a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g)
380}
381
382/// Inverse of an affine row-major column-vector 4x4 whose bottom row
383/// is `[0, 0, 0, 1]`. Returns `None` when the upper-left 3x3 is
384/// singular (zero or non-finite determinant), when any input entry
385/// is non-finite, or when the bottom row deviates from
386/// `[0, 0, 0, 1]` (the matrix is non-affine and not handled here —
387/// `world_node_transforms` only produces affines from TRS, so a
388/// non-affine input is a malformed user transform).
389///
390/// The inverse uses the classical adjugate / determinant of the
391/// 3x3 linear part, then applies the inverse linear to the negated
392/// translation column. Computed in `f64` so very-different-scale
393/// matrices (e.g. `1e-3` cm-scale child of a `1e3` km-scale parent)
394/// round-trip without precision collapse, then cast back to `f32`.
395pub(crate) fn mat4_affine_inverse(m: [[f32; 4]; 4]) -> Option<[[f32; 4]; 4]> {
396    for row in &m {
397        for v in row {
398            if !v.is_finite() {
399                return None;
400            }
401        }
402    }
403    // Affinity guard: bottom row must be [0, 0, 0, 1] within a tight
404    // tolerance (TRS-derived matrices satisfy this exactly).
405    let bot_eps = 1e-6_f32;
406    if m[3][0].abs() > bot_eps
407        || m[3][1].abs() > bot_eps
408        || m[3][2].abs() > bot_eps
409        || (m[3][3] - 1.0).abs() > bot_eps
410    {
411        return None;
412    }
413    let a = m[0][0] as f64;
414    let b = m[0][1] as f64;
415    let c = m[0][2] as f64;
416    let d = m[1][0] as f64;
417    let e = m[1][1] as f64;
418    let f = m[1][2] as f64;
419    let g = m[2][0] as f64;
420    let h = m[2][1] as f64;
421    let i = m[2][2] as f64;
422    // Cofactors of the 3x3 linear part.
423    let c00 = e * i - f * h;
424    let c01 = -(d * i - f * g);
425    let c02 = d * h - e * g;
426    let c10 = -(b * i - c * h);
427    let c11 = a * i - c * g;
428    let c12 = -(a * h - b * g);
429    let c20 = b * f - c * e;
430    let c21 = -(a * f - c * d);
431    let c22 = a * e - b * d;
432    let det = a * c00 + b * c01 + c * c02;
433    if !det.is_finite() || det == 0.0 {
434        return None;
435    }
436    let inv = 1.0 / det;
437    // Adjugate-transpose: inv_linear[row][col] = cofactor[col][row] / det.
438    let l00 = c00 * inv;
439    let l01 = c10 * inv;
440    let l02 = c20 * inv;
441    let l10 = c01 * inv;
442    let l11 = c11 * inv;
443    let l12 = c21 * inv;
444    let l20 = c02 * inv;
445    let l21 = c12 * inv;
446    let l22 = c22 * inv;
447    // Translation: t_inv = -L^-1 * t.
448    let tx = m[0][3] as f64;
449    let ty = m[1][3] as f64;
450    let tz = m[2][3] as f64;
451    let ix = -(l00 * tx + l01 * ty + l02 * tz);
452    let iy = -(l10 * tx + l11 * ty + l12 * tz);
453    let iz = -(l20 * tx + l21 * ty + l22 * tz);
454    // Finite-check on the assembled inverse — a near-singular det can
455    // produce inf / NaN entries; reject so callers fall through to the
456    // "skip this instance" branch.
457    let out = [
458        [l00 as f32, l01 as f32, l02 as f32, ix as f32],
459        [l10 as f32, l11 as f32, l12 as f32, iy as f32],
460        [l20 as f32, l21 as f32, l22 as f32, iz as f32],
461        [0.0, 0.0, 0.0, 1.0],
462    ];
463    for row in &out {
464        for v in row {
465            if !v.is_finite() {
466                return None;
467            }
468        }
469    }
470    Some(out)
471}
472
473/// Transform an [`crate::ray::Ray`] into mesh-local space by an affine
474/// 4x4 inverse. The translation column moves the origin; the 3x3
475/// linear part rotates / scales the direction (but is not normalised —
476/// the same ray parameter `t` resolves to the same world-space point
477/// before and after the change of frame).
478pub(crate) fn ray_into_local(world_inv: [[f32; 4]; 4], ray: crate::ray::Ray) -> crate::ray::Ray {
479    let o = ray.origin;
480    let d = ray.direction;
481    let lo = [
482        world_inv[0][0] * o[0] + world_inv[0][1] * o[1] + world_inv[0][2] * o[2] + world_inv[0][3],
483        world_inv[1][0] * o[0] + world_inv[1][1] * o[1] + world_inv[1][2] * o[2] + world_inv[1][3],
484        world_inv[2][0] * o[0] + world_inv[2][1] * o[1] + world_inv[2][2] * o[2] + world_inv[2][3],
485    ];
486    let ld = [
487        world_inv[0][0] * d[0] + world_inv[0][1] * d[1] + world_inv[0][2] * d[2],
488        world_inv[1][0] * d[0] + world_inv[1][1] * d[1] + world_inv[1][2] * d[2],
489        world_inv[2][0] * d[0] + world_inv[2][1] * d[1] + world_inv[2][2] * d[2],
490    ];
491    crate::ray::Ray::new(lo, ld)
492}
493
494fn trs_to_matrix(t: [f32; 3], r: [f32; 4], s: [f32; 3]) -> [[f32; 4]; 4] {
495    // Quaternion (x, y, z, w) → 3x3 rotation matrix (Shoemake).
496    let (x, y, z, w) = (r[0], r[1], r[2], r[3]);
497    let xx = x * x;
498    let yy = y * y;
499    let zz = z * z;
500    let xy = x * y;
501    let xz = x * z;
502    let yz = y * z;
503    let wx = w * x;
504    let wy = w * y;
505    let wz = w * z;
506    let r00 = 1.0 - 2.0 * (yy + zz);
507    let r01 = 2.0 * (xy - wz);
508    let r02 = 2.0 * (xz + wy);
509    let r10 = 2.0 * (xy + wz);
510    let r11 = 1.0 - 2.0 * (xx + zz);
511    let r12 = 2.0 * (yz - wx);
512    let r20 = 2.0 * (xz - wy);
513    let r21 = 2.0 * (yz + wx);
514    let r22 = 1.0 - 2.0 * (xx + yy);
515    [
516        [r00 * s[0], r01 * s[1], r02 * s[2], t[0]],
517        [r10 * s[0], r11 * s[1], r12 * s[2], t[1]],
518        [r20 * s[0], r21 * s[1], r22 * s[2], t[2]],
519        [0.0, 0.0, 0.0, 1.0],
520    ]
521}
522
523fn rot_matrix_to_quat(m: [[f32; 3]; 3]) -> [f32; 4] {
524    // Shepperd's branchless variant — picks the column with the
525    // largest diagonal to avoid catastrophic cancellation near
526    // 180-degree rotations. Returns (x, y, z, w).
527    let trace = m[0][0] + m[1][1] + m[2][2];
528    if trace > 0.0 {
529        let s = (trace + 1.0).sqrt() * 2.0;
530        let w = 0.25 * s;
531        let x = (m[2][1] - m[1][2]) / s;
532        let y = (m[0][2] - m[2][0]) / s;
533        let z = (m[1][0] - m[0][1]) / s;
534        [x, y, z, w]
535    } else if m[0][0] > m[1][1] && m[0][0] > m[2][2] {
536        let s = (1.0 + m[0][0] - m[1][1] - m[2][2]).sqrt() * 2.0;
537        let w = (m[2][1] - m[1][2]) / s;
538        let x = 0.25 * s;
539        let y = (m[0][1] + m[1][0]) / s;
540        let z = (m[0][2] + m[2][0]) / s;
541        [x, y, z, w]
542    } else if m[1][1] > m[2][2] {
543        let s = (1.0 + m[1][1] - m[0][0] - m[2][2]).sqrt() * 2.0;
544        let w = (m[0][2] - m[2][0]) / s;
545        let x = (m[0][1] + m[1][0]) / s;
546        let y = 0.25 * s;
547        let z = (m[1][2] + m[2][1]) / s;
548        [x, y, z, w]
549    } else {
550        let s = (1.0 + m[2][2] - m[0][0] - m[1][1]).sqrt() * 2.0;
551        let w = (m[1][0] - m[0][1]) / s;
552        let x = (m[0][2] + m[2][0]) / s;
553        let y = (m[1][2] + m[2][1]) / s;
554        let z = 0.25 * s;
555        [x, y, z, w]
556    }
557}
558
559/// A single scene-graph node.
560///
561/// Nodes form a forest rooted at [`Scene3D::roots`]. Each node has at
562/// most one parent (enforced by walking children top-down only — the
563/// `parent` back-pointer isn't stored; decoders that need it should
564/// build a side-table).
565#[derive(Clone, Debug)]
566pub struct Node {
567    pub name: Option<String>,
568    pub transform: Transform,
569    pub children: Vec<NodeId>,
570    pub mesh: Option<MeshId>,
571    pub camera: Option<CameraId>,
572    pub light: Option<LightId>,
573    pub skin: Option<SkinId>,
574    /// Node-level morph-weight override (glTF 2.0 `node.weights`).
575    ///
576    /// When non-empty, this vector replaces the instantiated mesh's
577    /// default [`Mesh::weights`](crate::Mesh::weights) for **this
578    /// instance** — two nodes sharing one mesh can hold different
579    /// static blend states. Empty means "no override": the mesh's own
580    /// defaults apply. An animated
581    /// [`MorphWeights`](crate::AnimationProperty::MorphWeights)
582    /// channel targeting the node beats both — the §3.7.4 weight
583    /// precedence chain is *animation > node > mesh*.
584    /// [`Scene3D::effective_morph_weights`] resolves the static
585    /// (node > mesh) half of that chain;
586    /// [`Scene3D::world_mesh`](crate::Scene3D::world_mesh) and its
587    /// animated variants honour the whole chain.
588    ///
589    /// When non-empty, the node must carry a mesh and the length must
590    /// match the morph-target count of every primitive of that mesh
591    /// ([`Scene3D::validate`] reports both).
592    pub weights: Vec<f32>,
593    /// Optional audio emitter attached to this node. The emitter's
594    /// position + orientation come from this node's world transform
595    /// when [`AudioEmitter::spatial`](crate::AudioEmitter::spatial)
596    /// is `Some`; non-spatial emitters ignore the transform and play
597    /// globally.
598    pub audio_emitter: Option<AudioEmitterId>,
599    pub extras: HashMap<String, serde_json::Value>,
600}
601
602impl Node {
603    /// Construct an empty node with identity transform.
604    pub fn new() -> Self {
605        Self {
606            name: None,
607            transform: Transform::identity(),
608            children: Vec::new(),
609            mesh: None,
610            camera: None,
611            light: None,
612            skin: None,
613            weights: Vec::new(),
614            audio_emitter: None,
615            extras: HashMap::new(),
616        }
617    }
618
619    /// Builder-style name setter.
620    pub fn with_name(mut self, name: impl Into<String>) -> Self {
621        self.name = Some(name.into());
622        self
623    }
624
625    /// Builder-style transform setter.
626    pub fn with_transform(mut self, transform: Transform) -> Self {
627        self.transform = transform;
628        self
629    }
630
631    /// Builder-style mesh attachment.
632    pub fn with_mesh(mut self, mesh: MeshId) -> Self {
633        self.mesh = Some(mesh);
634        self
635    }
636
637    /// Builder-style node-level morph-weight override (glTF 2.0
638    /// `node.weights` — see [`Node::weights`]). The vector length
639    /// should match the morph-target count of every primitive of the
640    /// mesh this node instantiates.
641    pub fn with_weights(mut self, weights: impl Into<Vec<f32>>) -> Self {
642        self.weights = weights.into();
643        self
644    }
645
646    /// Builder-style audio-emitter attachment.
647    pub fn with_audio_emitter(mut self, emitter: AudioEmitterId) -> Self {
648        self.audio_emitter = Some(emitter);
649        self
650    }
651}
652
653impl Default for Node {
654    fn default() -> Self {
655        Self::new()
656    }
657}
658
659/// Closest-hit record produced by [`Scene3D::intersect_ray`].
660///
661/// Pairs a world-space ray query with the scene-graph location of
662/// the hit:
663///
664/// * `node` — the [`NodeId`] of the reachable node whose attached
665///   mesh produced the hit. Look up `nodes[node]` for the node's
666///   transform / parenting; pass the same index into
667///   [`Scene3D::world_node_transforms`]`[node.0 as usize]` for the
668///   world matrix that maps mesh-local coordinates back to world
669///   space.
670/// * `primitive_index` — the index into `nodes[node].mesh`'s
671///   `Mesh::primitives` array identifying which primitive within the
672///   mesh was struck. The inner `hit.triangle_index` then names the
673///   triangle inside *that* primitive's
674///   [`crate::Primitive::triangle_indices`] enumeration.
675/// * `hit` — the underlying [`crate::ray::RayHit`] in mesh-local
676///   coordinates (barycentric, triangle index, front-face flag) but
677///   with `t` already in world-space units: affine change-of-frame
678///   leaves the ray-parameter scalar invariant, so the same `t`
679///   reconstructs the world hit point via
680///   `world_ray.point_at(scene_hit.hit.t)`.
681#[derive(Clone, Copy, Debug, PartialEq)]
682pub struct SceneRayHit {
683    pub node: NodeId,
684    pub primitive_index: usize,
685    pub hit: crate::ray::RayHit,
686}
687
688/// Top-level container for a 3D scene.
689///
690/// Owns every resource referenced by the scene graph. Add resources
691/// with the `add_*` helpers — they push into the corresponding `Vec`
692/// and return the freshly-issued ID. Roots are explicit: a node added
693/// with [`Scene3D::add_node`] is not automatically a root, so that
694/// child nodes added later can be re-parented without re-shuffling.
695#[derive(Clone, Debug)]
696pub struct Scene3D {
697    pub nodes: Vec<Node>,
698    pub roots: Vec<NodeId>,
699    pub meshes: Vec<Mesh>,
700    pub materials: Vec<Material>,
701    /// `KHR_materials_variants` variant names, addressed by
702    /// [`MaterialVariantId`]. The asset-level roster of switchable
703    /// material configurations (e.g. product colourways); primitives
704    /// opt in via
705    /// [`Primitive::variant_mappings`](crate::Primitive::variant_mappings).
706    /// Empty means the scene has no material variants.
707    pub material_variants: Vec<String>,
708    pub textures: Vec<Texture>,
709    pub skeletons: Vec<Skeleton>,
710    pub skins: Vec<Skin>,
711    pub animations: Vec<Animation>,
712    pub cameras: Vec<Camera>,
713    pub lights: Vec<Light>,
714    /// Audio assets owned by the scene; addressed by [`AudioSourceId`].
715    pub audio_sources: Vec<AudioSource>,
716    /// In-scene audio-emitter instances; addressed by [`AudioEmitterId`].
717    pub audio_emitters: Vec<AudioEmitter>,
718    pub up_axis: Axis,
719    pub front_axis: Axis,
720    pub unit: Unit,
721    pub extras: HashMap<String, serde_json::Value>,
722}
723
724impl Scene3D {
725    /// Empty scene with glTF-default orientation (Y-up, -Z forward,
726    /// metres) and no resources.
727    pub fn new() -> Self {
728        Self {
729            nodes: Vec::new(),
730            roots: Vec::new(),
731            meshes: Vec::new(),
732            materials: Vec::new(),
733            material_variants: Vec::new(),
734            textures: Vec::new(),
735            skeletons: Vec::new(),
736            skins: Vec::new(),
737            animations: Vec::new(),
738            cameras: Vec::new(),
739            lights: Vec::new(),
740            audio_sources: Vec::new(),
741            audio_emitters: Vec::new(),
742            up_axis: Axis::PosY,
743            front_axis: Axis::NegZ,
744            unit: Unit::Metres,
745            extras: HashMap::new(),
746        }
747    }
748
749    /// Push a node and return its id.
750    pub fn add_node(&mut self, node: Node) -> NodeId {
751        let id = NodeId(self.nodes.len() as u32);
752        self.nodes.push(node);
753        id
754    }
755
756    /// Push a mesh and return its id.
757    pub fn add_mesh(&mut self, mesh: Mesh) -> MeshId {
758        let id = MeshId(self.meshes.len() as u32);
759        self.meshes.push(mesh);
760        id
761    }
762
763    /// Push a material and return its id.
764    pub fn add_material(&mut self, material: Material) -> MaterialId {
765        let id = MaterialId(self.materials.len() as u32);
766        self.materials.push(material);
767        id
768    }
769
770    /// Push a `KHR_materials_variants` variant name and return its id.
771    /// No name dedup is performed — use
772    /// [`find_or_add_material_variant`](Self::find_or_add_material_variant)
773    /// when merging rosters.
774    pub fn add_material_variant(&mut self, name: impl Into<String>) -> MaterialVariantId {
775        let id = MaterialVariantId(self.material_variants.len() as u32);
776        self.material_variants.push(name.into());
777        id
778    }
779
780    /// Id of the variant named `name`, adding it to the roster if
781    /// absent. This is the name-unification primitive used by
782    /// [`append`](Self::append) so two scenes sharing variant names
783    /// (e.g. "Red" in both) end up with one merged roster entry.
784    pub fn find_or_add_material_variant(&mut self, name: &str) -> MaterialVariantId {
785        match self.material_variants.iter().position(|v| v == name) {
786            Some(i) => MaterialVariantId(i as u32),
787            None => self.add_material_variant(name),
788        }
789    }
790
791    /// Push a texture and return its id.
792    pub fn add_texture(&mut self, texture: Texture) -> TextureId {
793        let id = TextureId(self.textures.len() as u32);
794        self.textures.push(texture);
795        id
796    }
797
798    /// Push a skeleton and return its id.
799    pub fn add_skeleton(&mut self, skeleton: Skeleton) -> SkeletonId {
800        let id = SkeletonId(self.skeletons.len() as u32);
801        self.skeletons.push(skeleton);
802        id
803    }
804
805    /// Push a skin and return its id.
806    pub fn add_skin(&mut self, skin: Skin) -> SkinId {
807        let id = SkinId(self.skins.len() as u32);
808        self.skins.push(skin);
809        id
810    }
811
812    /// Push an animation and return its id (animations are
813    /// list-ordered, no separate id type — reference by index).
814    pub fn add_animation(&mut self, animation: Animation) -> usize {
815        let idx = self.animations.len();
816        self.animations.push(animation);
817        idx
818    }
819
820    /// Push a camera and return its id.
821    pub fn add_camera(&mut self, camera: Camera) -> CameraId {
822        let id = CameraId(self.cameras.len() as u32);
823        self.cameras.push(camera);
824        id
825    }
826
827    /// Push a light and return its id.
828    pub fn add_light(&mut self, light: Light) -> LightId {
829        let id = LightId(self.lights.len() as u32);
830        self.lights.push(light);
831        id
832    }
833
834    /// Push an [`AudioSource`] and return its id.
835    pub fn add_audio_source(&mut self, source: AudioSource) -> AudioSourceId {
836        let id = AudioSourceId(self.audio_sources.len() as u32);
837        self.audio_sources.push(source);
838        id
839    }
840
841    /// Push an [`AudioEmitter`] and return its id.
842    pub fn add_audio_emitter(&mut self, emitter: AudioEmitter) -> AudioEmitterId {
843        let id = AudioEmitterId(self.audio_emitters.len() as u32);
844        self.audio_emitters.push(emitter);
845        id
846    }
847
848    /// Borrow an audio source by id, if it exists.
849    pub fn audio_source(&self, id: AudioSourceId) -> Option<&AudioSource> {
850        self.audio_sources.get(id.0 as usize)
851    }
852
853    /// Borrow an audio emitter by id, if it exists.
854    pub fn audio_emitter(&self, id: AudioEmitterId) -> Option<&AudioEmitter> {
855        self.audio_emitters.get(id.0 as usize)
856    }
857
858    /// Promote a node to a root of the scene-graph forest.
859    pub fn add_root(&mut self, node: NodeId) {
860        self.roots.push(node);
861    }
862
863    /// Borrow a node by id, if it exists.
864    pub fn node(&self, id: NodeId) -> Option<&Node> {
865        self.nodes.get(id.0 as usize)
866    }
867
868    /// Mutably borrow a node by id, if it exists.
869    pub fn node_mut(&mut self, id: NodeId) -> Option<&mut Node> {
870        self.nodes.get_mut(id.0 as usize)
871    }
872
873    /// Borrow a mesh by id, if it exists.
874    pub fn mesh(&self, id: MeshId) -> Option<&Mesh> {
875        self.meshes.get(id.0 as usize)
876    }
877
878    /// World-space 4x4 transform per scene node, indexed by `NodeId.0`.
879    ///
880    /// Walks every root in [`Scene3D::roots`] in order, composing each
881    /// node's local [`Transform`] (via [`Transform::to_matrix`]) onto
882    /// its parent's already-composed world transform. The returned
883    /// vector has length `nodes.len()`; each slot holds:
884    ///
885    /// * `Some([[f32; 4]; 4])` — the row-major column-vector world
886    ///   transform of that node, i.e. the matrix that takes a position
887    ///   in the node's local frame to world space (`p_world = M *
888    ///   p_local`, treating `p_local` as `[x, y, z, 1]ᵀ`).
889    /// * `None` — the node is not reachable from any root in
890    ///   [`Scene3D::roots`] (detached). Detached nodes are common
891    ///   during incremental scene construction; the caller can detect
892    ///   them without a separate reachability pass.
893    ///
894    /// The walk is depth-first iterative on an explicit stack, matching
895    /// [`Scene3D::bounding_box`]'s traversal. Re-entry through a cycle
896    /// (a node listed as its own descendant) is guarded against — each
897    /// node receives **exactly one** world transform, the first one
898    /// encountered on the depth-first walk. Out-of-range `NodeId`
899    /// entries in `roots` / `children` are silently skipped.
900    ///
901    /// A node referenced by two parents (shared-instance pattern) is
902    /// visited only once, so `world_node_transforms()[id.0 as usize]`
903    /// resolves to a single matrix — the one obtained via the first
904    /// parent on the DFS path. Decoders that need per-instance world
905    /// transforms (mesh-instancing) should keep an explicit
906    /// instance-list side-channel rather than relying on this helper.
907    ///
908    /// **What this does NOT include:**
909    ///
910    /// * Skin pose deformation — the static scene-graph transform is
911    ///   reported, not the skinned-pose transform at any particular
912    ///   animation time. Apply animation channels separately to obtain
913    ///   pose-time transforms.
914    /// * Camera / projection transforms.
915    /// * Up-axis or unit conversion. [`Scene3D::up_axis`] and
916    ///   [`Scene3D::unit`] are metadata; the returned matrices live in
917    ///   whatever coordinate system the scene stored.
918    ///
919    /// ## Use cases
920    ///
921    /// * Transform-aware aggregate metrics (multiply each primitive's
922    ///   `surface_area` by `|det(scale_part)|` or its `signed_volume`
923    ///   by `sign(det) * |det|` to obtain a transform-folded total —
924    ///   the per-component scales fall out of the upper-left 3x3 of
925    ///   the world matrix).
926    /// * Renderer-side world-matrix prep (one DFS pass at scene load,
927    ///   then constant-time lookup per node when issuing draw calls).
928    /// * Authoring-tool node inspection ("show me the world position
929    ///   of `nodes[7]`" without re-walking the ancestor chain).
930    ///
931    /// Cost: `O(nodes.len() + total_children)`; allocates one
932    /// `Vec<Option<...>>` of length `nodes.len()` plus the DFS stack.
933    pub fn world_node_transforms(&self) -> Vec<Option<[[f32; 4]; 4]>> {
934        let n_nodes = self.nodes.len();
935        let mut out: Vec<Option<[[f32; 4]; 4]>> = vec![None; n_nodes];
936        if n_nodes == 0 {
937            return out;
938        }
939        let identity: [[f32; 4]; 4] = [
940            [1.0, 0.0, 0.0, 0.0],
941            [0.0, 1.0, 0.0, 0.0],
942            [0.0, 0.0, 1.0, 0.0],
943            [0.0, 0.0, 0.0, 1.0],
944        ];
945        // Iterative depth-first walk; stack carries (node_id, ancestor_matrix).
946        // Roots are pushed in order so that, after the LIFO pop order,
947        // the leftmost root is visited first — matching `bounding_box`'s
948        // determinism contract.
949        let mut stack: Vec<(NodeId, [[f32; 4]; 4])> =
950            self.roots.iter().rev().map(|r| (*r, identity)).collect();
951        while let Some((nid, parent)) = stack.pop() {
952            let idx = nid.0 as usize;
953            if idx >= n_nodes || out[idx].is_some() {
954                continue;
955            }
956            let node = &self.nodes[idx];
957            let world = mat4_mul(parent, node.transform.to_matrix());
958            out[idx] = Some(world);
959            // Walk children in reverse so leftmost child is popped first
960            // (deterministic ordering for snapshot consumers).
961            for child in node.children.iter().rev() {
962                stack.push((*child, world));
963            }
964        }
965        out
966    }
967
968    /// World-space axis-aligned bounding box per scene node, indexed by
969    /// `NodeId.0`.
970    ///
971    /// Walks the [`Scene3D::roots`] forest with the same depth-first
972    /// shape as [`Scene3D::world_node_transforms`] / [`Scene3D::bounding_box`].
973    /// For every reachable node that carries a mesh (`Node::mesh ==
974    /// Some(id)`), the contained mesh's local AABB
975    /// ([`crate::Mesh::bounding_box`]) is transformed through the
976    /// node's full ancestor-chain world matrix via
977    /// [`BoundingBox::transform`] (eight-corner refit). The returned
978    /// vector has length `nodes.len()`; each slot holds:
979    ///
980    /// * `Some(BoundingBox)` — the world-space tight AABB of the
981    ///   node's attached mesh, transformed by the node's world matrix.
982    /// * `None` — the node is not reachable from any root, the node
983    ///   carries no mesh, the referenced mesh is empty, or the mesh
984    ///   reference is out of range. The four `None` reasons are not
985    ///   distinguished; callers needing the distinction can pair this
986    ///   with [`Scene3D::world_node_transforms`] (whose `None` slots
987    ///   are reachability-only).
988    ///
989    /// The output is the per-instance complement to
990    /// [`Scene3D::bounding_box`], which collapses every reachable
991    /// instance into a single scene-wide union. Each slot here is the
992    /// tight bound of one instance, fit around the eight transformed
993    /// corners of its mesh's local AABB — orientation-aware (an
994    /// arbitrary rotation widens the AABB to wrap the rotated content)
995    /// the same way [`BoundingBox::transform`] documents.
996    ///
997    /// ## Use cases
998    ///
999    /// * **Per-instance frustum / view-volume culling.** A renderer
1000    ///   tests each slot's AABB against the view frustum before
1001    ///   issuing the instance's draw call.
1002    /// * **Scene-level ray AABB pre-pass.** A ray query walks the
1003    ///   slots once and only descends into
1004    ///   [`crate::Mesh::intersect_ray`] for instances whose AABB the
1005    ///   ray actually pierces (via [`BoundingBox::intersect_ray`]).
1006    ///   For triangle-budget-dominated scenes this reduces the ray
1007    ///   walk from `Σ triangle_count` to `Σ triangle_count over hit instances` —
1008    ///   the same kind of pruning [`crate::Bvh::intersect_ray`] applies
1009    ///   at the leaf level, lifted to the per-instance level.
1010    /// * **BVH-of-instances seed.** A future scene-level BVH builder
1011    ///   feeds each slot's AABB + the slot index (a `NodeId`) into
1012    ///   the same median-split AABB-tree construction
1013    ///   [`crate::Bvh::build`] already runs per primitive — see
1014    ///   the round-210 docs gesture toward this layered acceleration.
1015    ///
1016    /// ## What this does NOT include
1017    ///
1018    /// * Skin pose deformation — the rest-pose vertices are used
1019    ///   verbatim. A rigged mesh reports the rest-pose extent, not
1020    ///   the skinned-pose extent at any particular animation time.
1021    /// * Morph targets — only base
1022    ///   [`crate::Primitive::positions`] are folded into each mesh's
1023    ///   local AABB.
1024    /// * Animation channels — the static scene-graph transform is
1025    ///   reported, not the post-animation transform.
1026    /// * Up-axis or unit conversion. [`Scene3D::up_axis`] and
1027    ///   [`Scene3D::unit`] are metadata; the returned boxes live in
1028    ///   whatever coordinate system the scene stored.
1029    ///
1030    /// ## Determinism + cycle contract
1031    ///
1032    /// The walk is depth-first iterative on an explicit stack, with
1033    /// roots visited in `roots`-order and children in source order —
1034    /// identical to [`Scene3D::world_node_transforms`]. A node listed
1035    /// as its own descendant (cycle) is visited once via the
1036    /// first-arrival DFS path; out-of-range `NodeId` entries in
1037    /// `roots` / `children` are silently skipped. A node referenced
1038    /// by two parents (shared-instance) resolves to the first
1039    /// parent's chain — per-instance world AABBs for the
1040    /// shared-instance pattern need an explicit instance-list
1041    /// side-channel.
1042    ///
1043    /// Cost: `O(nodes.len() + total_children + Σ mesh_vertex_count_for_reachable_nodes)`,
1044    /// where the per-mesh cost is the one
1045    /// [`crate::Mesh::bounding_box`] iteration. Allocates one
1046    /// `Vec<Option<BoundingBox>>` of length `nodes.len()` plus the
1047    /// DFS stack.
1048    pub fn world_node_bounds(&self) -> Vec<Option<BoundingBox>> {
1049        let n_nodes = self.nodes.len();
1050        let mut out: Vec<Option<BoundingBox>> = vec![None; n_nodes];
1051        if n_nodes == 0 {
1052            return out;
1053        }
1054        let n_meshes = self.meshes.len();
1055        let identity: [[f32; 4]; 4] = [
1056            [1.0, 0.0, 0.0, 0.0],
1057            [0.0, 1.0, 0.0, 0.0],
1058            [0.0, 0.0, 1.0, 0.0],
1059            [0.0, 0.0, 0.0, 1.0],
1060        ];
1061        // `visited` is keyed on reachability so a node-with-no-mesh
1062        // (returning `None` in `out`) is still detected as visited and
1063        // not re-walked through a cycle.
1064        let mut visited = vec![false; n_nodes];
1065        // Iterative depth-first walk; stack carries (node_id, ancestor_matrix).
1066        // Roots pushed in reverse so the LIFO pop visits the leftmost
1067        // root first — matching `world_node_transforms`'s ordering.
1068        let mut stack: Vec<(NodeId, [[f32; 4]; 4])> =
1069            self.roots.iter().rev().map(|r| (*r, identity)).collect();
1070        while let Some((nid, parent)) = stack.pop() {
1071            let idx = nid.0 as usize;
1072            if idx >= n_nodes || visited[idx] {
1073                continue;
1074            }
1075            visited[idx] = true;
1076            let node = &self.nodes[idx];
1077            let world = mat4_mul(parent, node.transform.to_matrix());
1078            if let Some(m) = node.mesh {
1079                if n_meshes > 0 {
1080                    if let Some(mesh) = self.meshes.get(m.0 as usize) {
1081                        if let Some(local) = mesh.bounding_box() {
1082                            out[idx] = Some(local.transform(world));
1083                        }
1084                    }
1085                }
1086            }
1087            // Walk children in reverse so leftmost child is popped first
1088            // (deterministic ordering for snapshot consumers).
1089            for child in node.children.iter().rev() {
1090                stack.push((*child, world));
1091            }
1092        }
1093        out
1094    }
1095
1096    /// Convenience wrapper around [`crate::InstanceBvh::build`].
1097    ///
1098    /// Builds a scene-level bounding-volume hierarchy over every
1099    /// reachable node-mesh instance — the next acceleration layer
1100    /// above [`crate::Bvh::intersect_ray`] / per-instance
1101    /// [`Mesh::intersect_ray`]. The same per-instance walk
1102    /// [`Scene3D::intersect_ray`] performs becomes a
1103    /// `O(log reachable_instance_count)` median-split traversal once
1104    /// the tree is built. Cache the build alongside the scene; rebuild
1105    /// when any node transform or mesh AABB changes.
1106    pub fn build_instance_bvh(&self) -> Option<crate::InstanceBvh> {
1107        crate::InstanceBvh::build(self)
1108    }
1109
1110    /// Axis-aligned bounding box over every mesh referenced by a node
1111    /// reachable from [`Scene3D::roots`], with each mesh's vertices
1112    /// projected through its node's full ancestor transform chain.
1113    ///
1114    /// Returns `None` when no reachable node carries a mesh, or every
1115    /// reachable mesh is empty.
1116    ///
1117    /// **What this does NOT include:**
1118    ///
1119    /// * Skin pose deformation — the rest-pose vertices are used
1120    ///   verbatim. A bound mesh whose vertices are rigged to a
1121    ///   skeleton will report the *rest-pose* extent, not the
1122    ///   skinned-pose extent at any particular animation time.
1123    /// * Morph targets — only base [`Primitive::positions`](crate::Primitive::positions)
1124    ///   are folded in.
1125    /// * Meshes referenced by `nodes` not reachable from any root —
1126    ///   detached resources are ignored. Use [`Scene3D::meshes`] +
1127    ///   [`Mesh::bounding_box`](crate::Mesh::bounding_box) directly if
1128    ///   you need every resource regardless of scene-graph reachability.
1129    ///
1130    /// Re-entry through a cycle (a node listed as its own descendant)
1131    /// is guarded against — each node is visited at most once.
1132    pub fn bounding_box(&self) -> Option<BoundingBox> {
1133        let n_nodes = self.nodes.len();
1134        let n_meshes = self.meshes.len();
1135        if n_nodes == 0 || n_meshes == 0 {
1136            return None;
1137        }
1138        let mut visited = vec![false; n_nodes];
1139        let identity: [[f32; 4]; 4] = [
1140            [1.0, 0.0, 0.0, 0.0],
1141            [0.0, 1.0, 0.0, 0.0],
1142            [0.0, 0.0, 1.0, 0.0],
1143            [0.0, 0.0, 0.0, 1.0],
1144        ];
1145        let mut acc: Option<BoundingBox> = None;
1146        // Iterative depth-first walk; stack carries (node_id, ancestor_matrix).
1147        let mut stack: Vec<(NodeId, [[f32; 4]; 4])> =
1148            self.roots.iter().map(|r| (*r, identity)).collect();
1149        while let Some((nid, parent)) = stack.pop() {
1150            let idx = nid.0 as usize;
1151            if idx >= n_nodes || visited[idx] {
1152                continue;
1153            }
1154            visited[idx] = true;
1155            let node = &self.nodes[idx];
1156            let world = mat4_mul(parent, node.transform.to_matrix());
1157            if let Some(m) = node.mesh {
1158                if let Some(mesh) = self.meshes.get(m.0 as usize) {
1159                    if let Some(b) = mesh.bounding_box() {
1160                        let xf = b.transform(world);
1161                        acc = Some(match acc {
1162                            None => xf,
1163                            Some(a) => a.union(xf),
1164                        });
1165                    }
1166                }
1167            }
1168            // Walk children in reverse so leftmost child is popped first
1169            // (deterministic for the deterministic-debug-output use case).
1170            for child in node.children.iter().rev() {
1171                stack.push((*child, world));
1172            }
1173        }
1174        acc
1175    }
1176
1177    /// Sum of triangles across every mesh primitive.
1178    ///
1179    /// Lists / strips / fans contribute as if tessellated:
1180    /// - `Triangles` → `vertex_count / 3` (or `index_count / 3`)
1181    /// - `TriangleStrip` / `TriangleFan` → `max(0, n - 2)` triangles
1182    /// - non-triangle topologies contribute 0.
1183    pub fn triangle_count(&self) -> usize {
1184        self.meshes
1185            .iter()
1186            .flat_map(|m| m.primitives.iter())
1187            .map(|p| p.triangle_count())
1188            .sum()
1189    }
1190
1191    /// Sum of `positions.len()` across every mesh primitive.
1192    pub fn vertex_count(&self) -> usize {
1193        self.meshes
1194            .iter()
1195            .flat_map(|m| m.primitives.iter())
1196            .map(|p| p.positions.len())
1197            .sum()
1198    }
1199
1200    /// Sum of every mesh primitive's [`Primitive::surface_area`] in the
1201    /// scene's local unit-squared (matching [`Scene3D::unit`]). This
1202    /// does *not* apply node transforms — primitives instanced by
1203    /// multiple nodes contribute their unscaled area once per mesh,
1204    /// not once per node. For a transform-aware total, walk
1205    /// [`Scene3D::world_node_transforms`] and apply the per-node
1206    /// scale's determinant per primitive instance.
1207    pub fn surface_area(&self) -> f64 {
1208        self.meshes.iter().map(|m| m.surface_area()).sum()
1209    }
1210
1211    /// Area-weighted surface centroid across every mesh in the scene
1212    /// — the area-weighted combination of every mesh's own
1213    /// [`crate::Mesh::surface_centroid`]. This walks meshes once, not
1214    /// node instances: a mesh instanced by multiple reachable nodes
1215    /// contributes its centroid (with its area as the weight) once,
1216    /// not once per node. For a transform-aware, per-instance
1217    /// centroid total, walk [`Scene3D::world_node_transforms`]
1218    /// alongside [`crate::Primitive::surface_centroid`] and combine
1219    /// the per-instance centroids with their post-transform areas
1220    /// (`|(M_3·E1) × (M_3·E2)|/2`) as weights — the local centroid
1221    /// transforms by `world * [c, 1]` so each per-instance numerator
1222    /// is `(world * centroid) * post_transform_area`.
1223    ///
1224    /// Returns `None` when every contained mesh returns `None` (or
1225    /// the scene holds zero meshes). Coordinates are in the scene's
1226    /// local frame ([`Scene3D::unit`]); contract matches
1227    /// [`crate::Primitive::surface_centroid`] for finiteness,
1228    /// degenerate-skipping, and out-of-range / NaN handling.
1229    pub fn surface_centroid(&self) -> Option<[f64; 3]> {
1230        let mut sum_x = 0.0_f64;
1231        let mut sum_y = 0.0_f64;
1232        let mut sum_z = 0.0_f64;
1233        let mut sum_area = 0.0_f64;
1234        for m in &self.meshes {
1235            let area = m.surface_area();
1236            if area == 0.0 || !area.is_finite() {
1237                continue;
1238            }
1239            if let Some(c) = m.surface_centroid() {
1240                sum_x += c[0] * area;
1241                sum_y += c[1] * area;
1242                sum_z += c[2] * area;
1243                sum_area += area;
1244            }
1245        }
1246        if sum_area == 0.0 || !sum_area.is_finite() {
1247            return None;
1248        }
1249        let inv = 1.0 / sum_area;
1250        Some([sum_x * inv, sum_y * inv, sum_z * inv])
1251    }
1252
1253    /// Sum of every mesh primitive's
1254    /// [`crate::Primitive::signed_volume`] in the scene's local
1255    /// unit-cubed (matching [`Scene3D::unit`]). This does *not* apply
1256    /// node transforms — primitives instanced by multiple nodes
1257    /// contribute their unscaled volume once per mesh, not once per
1258    /// node. For a transform-aware total, walk
1259    /// [`Scene3D::world_node_transforms`] and apply the per-node
1260    /// scale's signed determinant per primitive instance (a negative
1261    /// scale flips winding and so flips the sign of the enclosed
1262    /// volume).
1263    ///
1264    /// **Only physically meaningful when each contained mesh is a
1265    /// closed two-manifold surface.** See
1266    /// [`crate::Primitive::is_closed_manifold`] /
1267    /// [`crate::Primitive::edge_manifold_report`].
1268    pub fn signed_volume(&self) -> f64 {
1269        self.meshes.iter().map(|m| m.signed_volume()).sum()
1270    }
1271
1272    /// Unsigned `|signed_volume()|` across the scene. Same
1273    /// shell-cancellation caveat as [`crate::Mesh::volume`]: this is
1274    /// `|Σ signed|`, not `Σ |signed|`. For a multi-shell scene where
1275    /// individual shells may differ in sign, prefer summing each mesh's
1276    /// [`crate::Mesh::volume`] separately.
1277    pub fn volume(&self) -> f64 {
1278        self.signed_volume().abs()
1279    }
1280
1281    /// Volume-weighted centroid (centre of mass) across every mesh in
1282    /// the scene — the signed-volume-weighted combination of every
1283    /// mesh's own [`crate::Mesh::volume_centroid`]. This walks meshes
1284    /// once, not node instances: a mesh instanced by multiple reachable
1285    /// nodes contributes its centroid (with its signed volume as the
1286    /// weight) once, not once per node. For a transform-aware
1287    /// per-instance centroid total, walk
1288    /// [`Scene3D::world_node_transforms`] alongside
1289    /// [`crate::Primitive::volume_centroid`] and combine the
1290    /// per-instance centroids with their post-transform signed volumes
1291    /// (`det(M_3x3) · V_local` for each closed-mesh instance) as
1292    /// weights.
1293    ///
1294    /// **Only physically meaningful when each contained mesh is a
1295    /// closed two-manifold surface.** See
1296    /// [`crate::Primitive::is_closed_manifold`] /
1297    /// [`crate::Primitive::edge_manifold_report`]. An open patch (a
1298    /// hemisphere, a plane) gives an answer that depends on where the
1299    /// origin sits because the surface-cancellation argument no longer
1300    /// applies; for those callers should use
1301    /// [`Scene3D::surface_centroid`].
1302    ///
1303    /// Returns `None` when every contained mesh returns `None` (or the
1304    /// scene holds zero meshes), or when the accumulated signed volume
1305    /// is `0.0` / non-finite (a flat sheet, or perfectly cancelling
1306    /// inside-out shells). Coordinates are in the scene's local frame
1307    /// ([`Scene3D::unit`]); contract matches
1308    /// [`crate::Primitive::volume_centroid`] for finiteness,
1309    /// degenerate-skipping, and out-of-range / NaN handling.
1310    pub fn volume_centroid(&self) -> Option<[f64; 3]> {
1311        let mut sum_x = 0.0_f64;
1312        let mut sum_y = 0.0_f64;
1313        let mut sum_z = 0.0_f64;
1314        let mut sum_v = 0.0_f64;
1315        for m in &self.meshes {
1316            let v = m.signed_volume();
1317            if v == 0.0 || !v.is_finite() {
1318                continue;
1319            }
1320            if let Some(c) = m.volume_centroid() {
1321                sum_x += c[0] * v;
1322                sum_y += c[1] * v;
1323                sum_z += c[2] * v;
1324                sum_v += v;
1325            }
1326        }
1327        if sum_v == 0.0 || !sum_v.is_finite() {
1328            return None;
1329        }
1330        let inv = 1.0 / sum_v;
1331        Some([sum_x * inv, sum_y * inv, sum_z * inv])
1332    }
1333
1334    /// Unit-density inertia tensor across every mesh in the scene —
1335    /// the element-wise sum of every contained mesh's
1336    /// [`crate::Mesh::inertia_tensor`].
1337    ///
1338    /// This walks meshes once, **not node instances**: a mesh
1339    /// instantiated by multiple reachable nodes contributes its
1340    /// inertia tensor once, not once per node. The result is in the
1341    /// scene's local frame (no node transforms applied); to fold in
1342    /// per-instance transforms, walk [`Scene3D::world_node_transforms`]
1343    /// alongside [`crate::Primitive::inertia_tensor`] and apply the
1344    /// rigid-body transform rule (`I_world = M_3 · I_local · M_3ᵀ +
1345    /// parallel-axis correction`).
1346    ///
1347    /// **Only physically meaningful when each contained mesh is a
1348    /// closed two-manifold surface.** See
1349    /// [`crate::Primitive::is_closed_manifold`] /
1350    /// [`crate::Primitive::edge_manifold_report`]. An open patch yields
1351    /// a tensor that depends on where the origin sits in the mesh's
1352    /// frame because the closed-mesh boundary-term cancellation no
1353    /// longer applies.
1354    ///
1355    /// Returns `None` when every contained mesh returns `None` (or the
1356    /// scene holds zero meshes). Coordinates are in the scene's local
1357    /// frame ([`Scene3D::unit`]); contract matches
1358    /// [`crate::Primitive::inertia_tensor`] for finiteness,
1359    /// degenerate-skipping, and out-of-range / NaN handling.
1360    pub fn inertia_tensor(&self) -> Option<[[f64; 3]; 3]> {
1361        let mut total = [[0.0_f64; 3]; 3];
1362        let mut any = false;
1363        for m in &self.meshes {
1364            if let Some(t) = m.inertia_tensor() {
1365                for r in 0..3 {
1366                    for c in 0..3 {
1367                        total[r][c] += t[r][c];
1368                    }
1369                }
1370                any = true;
1371            }
1372        }
1373        if !any {
1374            None
1375        } else {
1376            Some(total)
1377        }
1378    }
1379
1380    /// Transform-aware total surface area across every node-instantiated
1381    /// mesh in the scene, in world units squared (matching
1382    /// [`Scene3D::unit`]² when the scene's root has identity transform).
1383    ///
1384    /// Whereas [`Scene3D::surface_area`] sums each *mesh resource* once
1385    /// regardless of how many nodes carry it (the geometric-content
1386    /// total), `world_surface_area` walks the [`Scene3D::roots`] forest
1387    /// the same way [`Scene3D::bounding_box`] does, applies each
1388    /// reachable node's full ancestor-chain world matrix to its
1389    /// primitive's triangle vertices, and sums the post-transform
1390    /// triangle areas. A mesh instanced under two nodes therefore
1391    /// contributes twice (once per instance), and each instance's
1392    /// contribution reflects the world-space scale (and any
1393    /// non-uniform skew) on the path to that node.
1394    ///
1395    /// # Derivation
1396    ///
1397    /// For a triangle `(P_a, P_b, P_c)` mapped through the affine world
1398    /// matrix `M`, the post-transform edge vectors are
1399    /// `M_3·(P_b - P_a)` and `M_3·(P_c - P_a)` (the translation row
1400    /// cancels in the difference; `M_3` is the upper-left 3x3). The
1401    /// transformed triangle's area is
1402    ///
1403    /// ```text
1404    /// A_world = |(M_3·E1) × (M_3·E2)| / 2.
1405    /// ```
1406    ///
1407    /// Under a uniform scale `s` the factor collapses to `s²`. Under a
1408    /// non-uniform diagonal scale `(sx, sy, sz)` the factor depends on
1409    /// the triangle's facing axis, so per-triangle evaluation — rather
1410    /// than a single det-based scale — is required for correctness.
1411    /// The translation column of `M` does not enter the area
1412    /// computation, so the result is translation-invariant per
1413    /// triangle (as expected for an intrinsic area metric).
1414    ///
1415    /// # Contract
1416    ///
1417    /// * Topology handling, degenerate-triangle skipping, NaN-guarding,
1418    ///   and out-of-range-index skipping all mirror
1419    ///   [`crate::Primitive::surface_area`]. Non-triangle topologies
1420    ///   contribute 0.0. Result is finite and non-negative for any
1421    ///   finite input.
1422    /// * Mesh resources not reachable from any [`Scene3D::roots`] node
1423    ///   contribute 0.0 — the count is per-instance over the
1424    ///   scene-graph, not per-resource. For a resource-level total see
1425    ///   [`Scene3D::surface_area`].
1426    /// * Cycles in the scene-graph are guarded the same way as
1427    ///   [`Scene3D::bounding_box`] / [`Scene3D::world_node_transforms`]:
1428    ///   each node is visited at most once. A node instanced under two
1429    ///   parents resolves to one world matrix (the first parent on the
1430    ///   DFS path); use an explicit instance side-table if your decoder
1431    ///   needs both.
1432    /// * Skin pose deformation, morph targets, and unit-axis conversion
1433    ///   are *not* applied — the static scene-graph transform is the
1434    ///   only thing folded in. For a pose-time area, apply the
1435    ///   animation pose before calling.
1436    /// * Cost `O(reachable_nodes + Σ triangle_count_per_reachable_mesh)`.
1437    ///   Allocates the DFS stack only; the per-triangle math is in
1438    ///   `f64` to avoid `f32` drift on dense meshes.
1439    pub fn world_surface_area(&self) -> f64 {
1440        let n_nodes = self.nodes.len();
1441        let n_meshes = self.meshes.len();
1442        if n_nodes == 0 || n_meshes == 0 {
1443            return 0.0;
1444        }
1445        let mut visited = vec![false; n_nodes];
1446        let identity: [[f32; 4]; 4] = [
1447            [1.0, 0.0, 0.0, 0.0],
1448            [0.0, 1.0, 0.0, 0.0],
1449            [0.0, 0.0, 1.0, 0.0],
1450            [0.0, 0.0, 0.0, 1.0],
1451        ];
1452        let mut total = 0.0_f64;
1453        // Push roots in reverse so the LIFO pop visits the leftmost
1454        // root first — matching `world_node_transforms`'s documented
1455        // single-resolution policy (a shared instance reachable from
1456        // two parents resolves via the first parent on the DFS path).
1457        let mut stack: Vec<(NodeId, [[f32; 4]; 4])> =
1458            self.roots.iter().rev().map(|r| (*r, identity)).collect();
1459        while let Some((nid, parent)) = stack.pop() {
1460            let idx = nid.0 as usize;
1461            if idx >= n_nodes || visited[idx] {
1462                continue;
1463            }
1464            visited[idx] = true;
1465            let node = &self.nodes[idx];
1466            let world = mat4_mul(parent, node.transform.to_matrix());
1467            if let Some(m) = node.mesh {
1468                if let Some(mesh) = self.meshes.get(m.0 as usize) {
1469                    for prim in &mesh.primitives {
1470                        total += prim.world_surface_area(world);
1471                    }
1472                }
1473            }
1474            // Walk children in reverse so leftmost child is popped first.
1475            for child in node.children.iter().rev() {
1476                stack.push((*child, world));
1477            }
1478        }
1479        total
1480    }
1481
1482    /// Transform-aware total signed volume across every
1483    /// node-instantiated mesh, in world units cubed.
1484    ///
1485    /// Whereas [`Scene3D::signed_volume`] sums each *mesh resource* once
1486    /// in its local frame, `world_signed_volume` walks the
1487    /// [`Scene3D::roots`] forest, applies each reachable node's
1488    /// world-space transform to the underlying primitives, and
1489    /// accumulates the per-instance signed enclosed volume.
1490    ///
1491    /// # Derivation
1492    ///
1493    /// For a primitive with local signed volume
1494    /// `V_local = (1/6) Σ P_a · (P_b × P_c)` and an affine world
1495    /// transform `M` whose upper-left 3x3 is `M_3` with translation
1496    /// column `t`, every transformed corner is `M_3·P + t`. Expanding
1497    /// the per-triangle scalar triple product:
1498    ///
1499    /// ```text
1500    /// (M_3·P_a + t) · ((M_3·P_b + t) × (M_3·P_c + t))
1501    ///   = det(M_3) · (P_a · (P_b × P_c)) + boundary_terms(t).
1502    /// ```
1503    ///
1504    /// The `boundary_terms(t)` involve only the open-mesh boundary and
1505    /// vanish for a closed two-manifold (the same origin-cancellation
1506    /// that makes the local signed volume translation-invariant). For
1507    /// such a mesh the world signed volume reduces to
1508    ///
1509    /// ```text
1510    /// V_world = det(M_3) · V_local.
1511    /// ```
1512    ///
1513    /// `det(M_3)` is the *signed* 3x3 determinant: a uniform scale of
1514    /// `s` gives `s³`; a single-axis mirror (`-1` on one axis) gives
1515    /// `-1`, correctly flipping the enclosed-volume sign because the
1516    /// triangle winding flips with the mirror. For an open mesh, the
1517    /// translation-dependent boundary term means this scaling identity
1518    /// is only an approximation; the helper still returns the
1519    /// closed-form `det(M_3) · V_local` because that is the
1520    /// physically-meaningful summand whenever the per-instance mesh is
1521    /// itself a closed surface (the usual case for which the
1522    /// volume reduction is defined).
1523    ///
1524    /// # Contract
1525    ///
1526    /// * Reachability, cycle-guarding, and per-instance accumulation
1527    ///   match [`Scene3D::world_surface_area`].
1528    /// * Each node's world matrix is reduced to its upper-left 3x3
1529    ///   determinant; non-finite determinants (matrix corruption,
1530    ///   inf/NaN entries) skip the contribution.
1531    /// * Each mesh resource contributes once per reachable node that
1532    ///   references it. A two-node instance with mirrored scale
1533    ///   `[-1, 1, 1]` and an unmirrored sibling cancel each other in
1534    ///   the signed sum — that is the geometric truth.
1535    /// * Skin pose, morph targets, and unit-axis conversion are not
1536    ///   applied.
1537    /// * Returns `0.0` for an empty scene or one with no
1538    ///   reachable meshes.
1539    /// * Result is finite for any finite input; the accumulator is
1540    ///   `f64`.
1541    /// * Cost `O(reachable_nodes + Σ triangle_count_per_reachable_mesh)`.
1542    pub fn world_signed_volume(&self) -> f64 {
1543        let n_nodes = self.nodes.len();
1544        let n_meshes = self.meshes.len();
1545        if n_nodes == 0 || n_meshes == 0 {
1546            return 0.0;
1547        }
1548        let mut visited = vec![false; n_nodes];
1549        let identity: [[f32; 4]; 4] = [
1550            [1.0, 0.0, 0.0, 0.0],
1551            [0.0, 1.0, 0.0, 0.0],
1552            [0.0, 0.0, 1.0, 0.0],
1553            [0.0, 0.0, 0.0, 1.0],
1554        ];
1555        let mut total = 0.0_f64;
1556        // Push roots in reverse so the LIFO pop visits the leftmost
1557        // root first — matching `world_node_transforms`'s
1558        // single-resolution policy.
1559        let mut stack: Vec<(NodeId, [[f32; 4]; 4])> =
1560            self.roots.iter().rev().map(|r| (*r, identity)).collect();
1561        while let Some((nid, parent)) = stack.pop() {
1562            let idx = nid.0 as usize;
1563            if idx >= n_nodes || visited[idx] {
1564                continue;
1565            }
1566            visited[idx] = true;
1567            let node = &self.nodes[idx];
1568            let world = mat4_mul(parent, node.transform.to_matrix());
1569            if let Some(m) = node.mesh {
1570                if let Some(mesh) = self.meshes.get(m.0 as usize) {
1571                    let det = mat3_det_of_world(world);
1572                    if det.is_finite() {
1573                        let local = mesh.signed_volume();
1574                        let scaled = det * local;
1575                        if scaled.is_finite() {
1576                            total += scaled;
1577                        }
1578                    }
1579                }
1580            }
1581            for child in node.children.iter().rev() {
1582                stack.push((*child, world));
1583            }
1584        }
1585        total
1586    }
1587
1588    /// Unsigned `|world_signed_volume()|` across the scene.
1589    ///
1590    /// Same shell-cancellation caveat as
1591    /// [`Scene3D::volume`] / [`crate::Mesh::volume`]: this is
1592    /// `|Σ signed_world|`, not `Σ |signed_world|`. For a scene where
1593    /// instances may carry mirrored scales (producing per-instance
1594    /// negative signed volumes), prefer summing each instance's
1595    /// `|det(M_3) · signed_volume|` separately.
1596    pub fn world_volume(&self) -> f64 {
1597        self.world_signed_volume().abs()
1598    }
1599
1600    /// Transform-aware area-weighted surface centroid across every
1601    /// node-instantiated mesh in the scene, in world units.
1602    ///
1603    /// Whereas [`Scene3D::surface_centroid`] recombines each *mesh
1604    /// resource* once regardless of how many nodes carry it,
1605    /// `world_surface_centroid` walks the [`Scene3D::roots`] forest
1606    /// the same way [`Scene3D::world_surface_area`] does, applies each
1607    /// reachable node's full ancestor-chain world matrix to its
1608    /// primitive's triangle vertices, and recombines the post-
1609    /// transform per-instance centroids weighted by the per-instance
1610    /// post-transform surface area. A mesh instanced under two nodes
1611    /// therefore contributes twice (once per instance), and each
1612    /// instance's contribution reflects the world-space scale and skew
1613    /// on the path to that node.
1614    ///
1615    /// # Derivation
1616    ///
1617    /// Picking up where [`Scene3D::world_surface_area`] leaves off:
1618    /// for a triangle `(P_a, P_b, P_c)` mapped through the affine
1619    /// world matrix `M`, the post-transform centroid is `(M·P_a +
1620    /// M·P_b + M·P_c) / 3` and the post-transform area is
1621    /// `|(M_3·E1) × (M_3·E2)| / 2`. Substituting into the continuous
1622    /// identity `C = (Σ area_i · centroid_i) / Σ area_i` and
1623    /// accumulating across every reachable node's every primitive
1624    /// gives the world-frame centroid. The recombination across
1625    /// primitives (and across instances) is additivity of the surface
1626    /// integral over a union of patches — the same reasoning that
1627    /// makes [`Mesh::surface_centroid`] / [`Scene3D::surface_centroid`]
1628    /// well-defined.
1629    ///
1630    /// # Contract
1631    ///
1632    /// * Topology handling, degenerate-triangle skipping, NaN guards,
1633    ///   and out-of-range-index skipping all mirror
1634    ///   [`crate::Primitive::world_surface_centroid`].
1635    /// * Mesh resources not reachable from any [`Scene3D::roots`] node
1636    ///   contribute nothing — the count is per-instance over the
1637    ///   scene-graph, not per-resource. For a resource-level total see
1638    ///   [`Scene3D::surface_centroid`].
1639    /// * Cycles in the scene-graph are guarded the same way as
1640    ///   [`Scene3D::bounding_box`] / [`Scene3D::world_node_transforms`]
1641    ///   / [`Scene3D::world_surface_area`]: each node is visited at
1642    ///   most once. A node instanced under two parents resolves to one
1643    ///   world matrix (the first parent on the DFS path).
1644    /// * Returns `None` when no reachable triangle survives — empty
1645    ///   scene, no reachable mesh, every reachable mesh degenerate, or
1646    ///   every world transform collapsing the surface to zero area
1647    ///   under the transform.
1648    /// * Skin pose deformation, morph targets, and unit-axis conversion
1649    ///   are *not* applied — the static scene-graph transform is the
1650    ///   only thing folded in. For a pose-time centroid, apply the
1651    ///   animation pose before calling.
1652    /// * Cost `O(reachable_nodes + Σ triangle_count_per_reachable_mesh)`.
1653    ///   Allocates the DFS stack only; per-triangle math is in `f64` to
1654    ///   avoid `f32` drift on dense meshes.
1655    pub fn world_surface_centroid(&self) -> Option<[f64; 3]> {
1656        let n_nodes = self.nodes.len();
1657        let n_meshes = self.meshes.len();
1658        if n_nodes == 0 || n_meshes == 0 {
1659            return None;
1660        }
1661        let mut visited = vec![false; n_nodes];
1662        let identity: [[f32; 4]; 4] = [
1663            [1.0, 0.0, 0.0, 0.0],
1664            [0.0, 1.0, 0.0, 0.0],
1665            [0.0, 0.0, 1.0, 0.0],
1666            [0.0, 0.0, 0.0, 1.0],
1667        ];
1668        let mut sum_x = 0.0_f64;
1669        let mut sum_y = 0.0_f64;
1670        let mut sum_z = 0.0_f64;
1671        let mut sum_area = 0.0_f64;
1672        // Push roots in reverse so the LIFO pop visits the leftmost
1673        // root first — matching `world_node_transforms`'s documented
1674        // single-resolution policy (a shared instance reachable from
1675        // two parents resolves via the first parent on the DFS path).
1676        let mut stack: Vec<(NodeId, [[f32; 4]; 4])> =
1677            self.roots.iter().rev().map(|r| (*r, identity)).collect();
1678        while let Some((nid, parent)) = stack.pop() {
1679            let idx = nid.0 as usize;
1680            if idx >= n_nodes || visited[idx] {
1681                continue;
1682            }
1683            visited[idx] = true;
1684            let node = &self.nodes[idx];
1685            let world = mat4_mul(parent, node.transform.to_matrix());
1686            if let Some(m) = node.mesh {
1687                if let Some(mesh) = self.meshes.get(m.0 as usize) {
1688                    for prim in &mesh.primitives {
1689                        let area = prim.world_surface_area(world);
1690                        if area == 0.0 || !area.is_finite() {
1691                            continue;
1692                        }
1693                        if let Some(c) = prim.world_surface_centroid(world) {
1694                            sum_x += c[0] * area;
1695                            sum_y += c[1] * area;
1696                            sum_z += c[2] * area;
1697                            sum_area += area;
1698                        }
1699                    }
1700                }
1701            }
1702            // Walk children in reverse so leftmost child is popped first.
1703            for child in node.children.iter().rev() {
1704                stack.push((*child, world));
1705            }
1706        }
1707        if sum_area == 0.0 || !sum_area.is_finite() {
1708            return None;
1709        }
1710        let inv = 1.0 / sum_area;
1711        Some([sum_x * inv, sum_y * inv, sum_z * inv])
1712    }
1713
1714    /// Transform-aware volume-weighted centroid (centre of mass) across
1715    /// every node-instantiated mesh in the scene, in world units.
1716    ///
1717    /// Whereas [`Scene3D::volume_centroid`] recombines each *mesh
1718    /// resource* once regardless of how many nodes carry it,
1719    /// `world_volume_centroid` walks the [`Scene3D::roots`] forest the
1720    /// same way [`Scene3D::world_signed_volume`] /
1721    /// [`Scene3D::world_surface_centroid`] do, applies each reachable
1722    /// node's full ancestor-chain world matrix to its primitive's
1723    /// triangle vertices, and recombines the post-transform per-instance
1724    /// centroids weighted by the per-instance post-transform signed
1725    /// volume. A mesh instanced under two nodes therefore contributes
1726    /// twice (once per instance), and each instance's contribution
1727    /// reflects the world-space scale, skew, *and* translation on the
1728    /// path to that node — unlike the surface variants, the per-
1729    /// instance volume integral picks up the translation column too
1730    /// (the origin-anchored tet sum is not translation-invariant).
1731    ///
1732    /// # Derivation
1733    ///
1734    /// Picking up where [`Scene3D::world_volume`] leaves off: for a
1735    /// closed mesh under affine `M = [M_3 | t]`, the per-instance
1736    /// signed volume is `det(M_3) · V_local` and the per-instance
1737    /// centroid is `M · C_local = M_3 · C_local + t`. The
1738    /// signed-volume-weighted recombination across instances is then
1739    /// additivity of the volume integral over a union of solid bodies
1740    /// — the same reasoning that fixes [`Mesh::volume_centroid`] /
1741    /// [`Scene3D::volume_centroid`] in the local frame. For an open
1742    /// patch the recombination still goes through, but the per-
1743    /// instance signed volume is no longer `det(M_3) · V_local` — the
1744    /// origin-anchored tet sum picks up a translation-dependent
1745    /// boundary term — so the helper computes both the per-primitive
1746    /// centroid and signed volume in the transformed frame
1747    /// independently and feeds them through the
1748    /// `Σ V_i · C_i / Σ V_i` recombination directly.
1749    ///
1750    /// # Contract
1751    ///
1752    /// * Topology handling, degenerate / NaN guards, and out-of-range-
1753    ///   index skipping all mirror
1754    ///   [`crate::Primitive::world_volume_centroid`].
1755    /// * Mesh resources not reachable from any [`Scene3D::roots`] node
1756    ///   contribute nothing — the count is per-instance over the
1757    ///   scene-graph, not per-resource. For a resource-level total see
1758    ///   [`Scene3D::volume_centroid`].
1759    /// * Cycles in the scene-graph are guarded the same way as
1760    ///   [`Scene3D::bounding_box`] / [`Scene3D::world_node_transforms`]
1761    ///   / [`Scene3D::world_surface_centroid`]: each node is visited at
1762    ///   most once. A node instanced under two parents resolves to one
1763    ///   world matrix (the first parent on the DFS path).
1764    /// * Returns `None` when no reachable instance contributes —
1765    ///   empty scene, no reachable mesh, every reachable mesh
1766    ///   non-triangle / degenerate, or every world transform
1767    ///   collapsing every tet to zero signed volume.
1768    /// * Skin pose deformation, morph targets, and unit-axis conversion
1769    ///   are *not* applied — the static scene-graph transform is the
1770    ///   only thing folded in. For a pose-time centroid, apply the
1771    ///   animation pose before calling.
1772    /// * Only physically meaningful when each reachable mesh is a
1773    ///   closed two-manifold surface (see
1774    ///   [`crate::Primitive::edge_manifold_report`]). For an open patch
1775    ///   the result depends on where the origin sits in the
1776    ///   transformed frame — same caveat as
1777    ///   [`crate::Primitive::world_volume_centroid`].
1778    /// * Cost `O(reachable_nodes + Σ triangle_count_per_reachable_mesh)`.
1779    ///   Allocates the DFS stack only; per-triangle math is in `f64`.
1780    pub fn world_volume_centroid(&self) -> Option<[f64; 3]> {
1781        let n_nodes = self.nodes.len();
1782        let n_meshes = self.meshes.len();
1783        if n_nodes == 0 || n_meshes == 0 {
1784            return None;
1785        }
1786        let mut visited = vec![false; n_nodes];
1787        let identity: [[f32; 4]; 4] = [
1788            [1.0, 0.0, 0.0, 0.0],
1789            [0.0, 1.0, 0.0, 0.0],
1790            [0.0, 0.0, 1.0, 0.0],
1791            [0.0, 0.0, 0.0, 1.0],
1792        ];
1793        let mut sum_x = 0.0_f64;
1794        let mut sum_y = 0.0_f64;
1795        let mut sum_z = 0.0_f64;
1796        let mut sum_v = 0.0_f64;
1797        // Push roots in reverse so the LIFO pop visits the leftmost
1798        // root first — matching `world_node_transforms`'s documented
1799        // single-resolution policy (a shared instance reachable from
1800        // two parents resolves via the first parent on the DFS path).
1801        let mut stack: Vec<(NodeId, [[f32; 4]; 4])> =
1802            self.roots.iter().rev().map(|r| (*r, identity)).collect();
1803        while let Some((nid, parent)) = stack.pop() {
1804            let idx = nid.0 as usize;
1805            if idx >= n_nodes || visited[idx] {
1806                continue;
1807            }
1808            visited[idx] = true;
1809            let node = &self.nodes[idx];
1810            let world = mat4_mul(parent, node.transform.to_matrix());
1811            if let Some(m) = node.mesh {
1812                if let Some(mesh) = self.meshes.get(m.0 as usize) {
1813                    for prim in &mesh.primitives {
1814                        let v = prim.world_signed_volume(world);
1815                        if v == 0.0 || !v.is_finite() {
1816                            continue;
1817                        }
1818                        if let Some(c) = prim.world_volume_centroid(world) {
1819                            sum_x += c[0] * v;
1820                            sum_y += c[1] * v;
1821                            sum_z += c[2] * v;
1822                            sum_v += v;
1823                        }
1824                    }
1825                }
1826            }
1827            // Walk children in reverse so leftmost child is popped first.
1828            for child in node.children.iter().rev() {
1829                stack.push((*child, world));
1830            }
1831        }
1832        if sum_v == 0.0 || !sum_v.is_finite() {
1833            return None;
1834        }
1835        let inv = 1.0 / sum_v;
1836        Some([sum_x * inv, sum_y * inv, sum_z * inv])
1837    }
1838
1839    /// Transform-aware unit-density inertia tensor across every
1840    /// node-instantiated mesh in the scene, about the **world origin**,
1841    /// returned as a row-major symmetric `[[f64; 3]; 3]`.
1842    ///
1843    /// Closes the per-instance world-frame gap that
1844    /// [`Scene3D::inertia_tensor`]'s prose (round 259) flagged as the
1845    /// next-round candidate. Whereas [`Scene3D::inertia_tensor`] sums
1846    /// each *mesh resource* once in the scene's local frame regardless of
1847    /// how many nodes carry it, `world_inertia_tensor` walks the
1848    /// [`Scene3D::roots`] forest the same way
1849    /// [`Scene3D::world_volume_centroid`] /
1850    /// [`Scene3D::world_surface_centroid`] /
1851    /// [`Scene3D::world_node_transforms`] do, applies each reachable
1852    /// node's full ancestor-chain world matrix to its primitive's
1853    /// triangle vertices, and sums the per-instance world-frame tensors
1854    /// element-wise. A mesh instanced under two nodes therefore
1855    /// contributes **twice** (once per instance), and each instance
1856    /// carries the world-space rotation, scale, skew, *and* translation
1857    /// on the path to that node.
1858    ///
1859    /// # Derivation
1860    ///
1861    /// Each reachable node-mesh instance contributes
1862    /// [`crate::Mesh::world_inertia_tensor`] of its mesh under the
1863    /// composed world matrix `M` — the same per-corner mapping
1864    /// [`crate::Primitive::world_inertia_tensor`] performs, so rotation,
1865    /// non-uniform scale, skew, and the translation column of `M` are all
1866    /// folded in. Element-wise summation across instances is additivity
1867    /// of the second-moment integral over a union of disjoint solids
1868    /// (the same argument [`Scene3D::world_signed_volume`] /
1869    /// [`Scene3D::world_volume_centroid`] rest on). A mirrored instance
1870    /// (`det(M_3) < 0`) contributes a negated tensor, matching the
1871    /// sign-flip [`crate::Primitive::world_signed_volume`] carries.
1872    ///
1873    /// # Contract
1874    ///
1875    /// * Topology / degenerate / NaN / out-of-range skipping all mirror
1876    ///   [`crate::Primitive::inertia_tensor`].
1877    /// * Mesh resources not reachable from any [`Scene3D::roots`] node
1878    ///   contribute nothing — the count is per-instance over the
1879    ///   scene-graph. For a resource-level local-frame total see
1880    ///   [`Scene3D::inertia_tensor`].
1881    /// * Cycles are guarded the same way as
1882    ///   [`Scene3D::world_node_transforms`] / [`Scene3D::bounding_box`]:
1883    ///   each node is visited at most once; a node instanced under two
1884    ///   parents resolves to one world matrix (the first parent on the
1885    ///   DFS path).
1886    /// * Returns `None` when no reachable mesh contributes a finite
1887    ///   tensor — empty scene, no reachable mesh node, or every reachable
1888    ///   primitive degenerate / non-triangle under its world transform.
1889    /// * Skin pose deformation, morph targets, and unit-axis conversion
1890    ///   are *not* applied — the static scene-graph transform is the only
1891    ///   thing folded in.
1892    /// * The result is the inertia tensor **about the world origin**; for
1893    ///   the tensor about the scene's centre of mass apply the parallel-
1894    ///   axis theorem with [`Scene3D::world_volume_centroid`] as the
1895    ///   reference point (`I_about_C = I_about_O - M_total · D`,
1896    ///   `D_αβ = c_α·c_β - δ_αβ·|c|²`).
1897    /// * Pure; cost
1898    ///   `O(reachable_nodes + Σ triangle_count_per_reachable_mesh)`.
1899    ///   Allocates the DFS stack only; per-triangle math is in `f64`.
1900    pub fn world_inertia_tensor(&self) -> Option<[[f64; 3]; 3]> {
1901        let n_nodes = self.nodes.len();
1902        let n_meshes = self.meshes.len();
1903        if n_nodes == 0 || n_meshes == 0 {
1904            return None;
1905        }
1906        let mut visited = vec![false; n_nodes];
1907        let identity: [[f32; 4]; 4] = [
1908            [1.0, 0.0, 0.0, 0.0],
1909            [0.0, 1.0, 0.0, 0.0],
1910            [0.0, 0.0, 1.0, 0.0],
1911            [0.0, 0.0, 0.0, 1.0],
1912        ];
1913        let mut total = [[0.0_f64; 3]; 3];
1914        let mut any = false;
1915        // Push roots in reverse so the LIFO pop visits the leftmost
1916        // root first — matching `world_node_transforms`'s documented
1917        // single-resolution policy (a shared instance reachable from
1918        // two parents resolves via the first parent on the DFS path).
1919        let mut stack: Vec<(NodeId, [[f32; 4]; 4])> =
1920            self.roots.iter().rev().map(|r| (*r, identity)).collect();
1921        while let Some((nid, parent)) = stack.pop() {
1922            let idx = nid.0 as usize;
1923            if idx >= n_nodes || visited[idx] {
1924                continue;
1925            }
1926            visited[idx] = true;
1927            let node = &self.nodes[idx];
1928            let world = mat4_mul(parent, node.transform.to_matrix());
1929            if let Some(m) = node.mesh {
1930                if let Some(mesh) = self.meshes.get(m.0 as usize) {
1931                    if let Some(t) = mesh.world_inertia_tensor(world) {
1932                        for r in 0..3 {
1933                            for c in 0..3 {
1934                                total[r][c] += t[r][c];
1935                            }
1936                        }
1937                        any = true;
1938                    }
1939                }
1940            }
1941            // Walk children in reverse so leftmost child is popped first.
1942            for child in node.children.iter().rev() {
1943                stack.push((*child, world));
1944            }
1945        }
1946        if any {
1947            Some(total)
1948        } else {
1949            None
1950        }
1951    }
1952
1953    /// Closest-hit ray query across every reachable node-mesh
1954    /// instance in world space.
1955    ///
1956    /// Walks the [`Scene3D::roots`] forest with the same DFS shape as
1957    /// [`Scene3D::world_node_transforms`] / [`Scene3D::world_surface_area`].
1958    /// At each reachable node carrying a mesh, the world ray is
1959    /// transformed into the mesh's local frame via the inverse of the
1960    /// node's world matrix, [`crate::Mesh::intersect_ray`] runs in that
1961    /// frame, and the returned ray-parameter `t` is reported back
1962    /// verbatim — affine change-of-frame leaves the `t` value
1963    /// invariant (`P_world = M · P_local = M · (O_local + t · D_local) =
1964    /// O_world + t · D_world`).
1965    ///
1966    /// Each hit shrinks the search bound (`t_max`) before the next
1967    /// node is tested, so a scene with many instances pays the
1968    /// per-instance test only until the closest hit is fixed; later
1969    /// instances behind that hit do triangle-level work only if their
1970    /// transformed bound still satisfies the surviving `t_max`. That
1971    /// pruning matches the per-primitive shrinking inside
1972    /// [`crate::Mesh::intersect_ray`] and the
1973    /// per-leaf shrinking inside [`crate::Bvh::intersect_ray`].
1974    ///
1975    /// Returns `None` when the scene has no reachable mesh node, or
1976    /// when no triangle on any reachable mesh is struck within
1977    /// `t_max`.
1978    ///
1979    /// # Returned hit
1980    ///
1981    /// The [`SceneRayHit`] carries the `NodeId` that produced the hit,
1982    /// the primitive index within that node's mesh, and the
1983    /// mesh-local [`crate::ray::RayHit`] (barycentric, triangle index,
1984    /// front-face flag, and the world-space `t`). The triangle index
1985    /// indexes [`crate::Primitive::triangle_indices`] of the named
1986    /// primitive — callers needing world-space corner positions
1987    /// look up the local positions, then push them through
1988    /// [`Scene3D::world_node_transforms`]`[node]`.
1989    ///
1990    /// # Cycle / reachability contract
1991    ///
1992    /// Each reachable node is visited at most once; a node listed as
1993    /// its own descendant resolves only via the first DFS arrival
1994    /// (same convention as [`Scene3D::world_node_transforms`]).
1995    /// Detached mesh resources (not referenced from any root-reachable
1996    /// node) are not queried; the caller drives those directly through
1997    /// [`crate::Mesh::intersect_ray`] if needed.
1998    ///
1999    /// # Singular instance transforms
2000    ///
2001    /// If a node's world matrix is non-affine, contains non-finite
2002    /// entries, or has a singular linear part (zero determinant —
2003    /// e.g. a degenerate scale collapsing one axis to zero), that
2004    /// instance is silently skipped. The surrounding scene still
2005    /// produces hits where it can. The skip is the geometrically
2006    /// honest answer — a degenerate transform projects the mesh onto
2007    /// a sub-plane / sub-line whose ray intersection is undefined
2008    /// without a regularised limit.
2009    ///
2010    /// # Cost
2011    ///
2012    /// `O(reachable_nodes + Σ instance_triangle_tests)`. For ray
2013    /// budgets dominated by triangle-level work, pair this with a
2014    /// per-primitive [`crate::Bvh`] cached on each instance for the
2015    /// `O(log triangle_count)` per ray narrowing — see the
2016    /// `Bvh::build` builder. Scene-level BVH-of-instances is a
2017    /// candidate for a later round; the current walk is the
2018    /// reference brute-force baseline.
2019    ///
2020    /// # Degenerate ray
2021    ///
2022    /// A zero-direction or non-finite ray reaches
2023    /// [`crate::ray::intersect_triangle`] / [`crate::ray::intersect_aabb`]
2024    /// unchanged after the local-frame transform; both helpers reject
2025    /// such inputs with `None` (the slab test's `1/0` produces `Inf`,
2026    /// the cross-product `det` collapses to zero or `NaN`, and the
2027    /// existing finite-check guards short-circuit the test).
2028    pub fn intersect_ray(&self, ray: crate::ray::Ray, t_max: f32) -> Option<SceneRayHit> {
2029        let n_nodes = self.nodes.len();
2030        let n_meshes = self.meshes.len();
2031        if n_nodes == 0 || n_meshes == 0 {
2032            return None;
2033        }
2034        let mut visited = vec![false; n_nodes];
2035        let identity: [[f32; 4]; 4] = [
2036            [1.0, 0.0, 0.0, 0.0],
2037            [0.0, 1.0, 0.0, 0.0],
2038            [0.0, 0.0, 1.0, 0.0],
2039            [0.0, 0.0, 0.0, 1.0],
2040        ];
2041        let mut best: Option<SceneRayHit> = None;
2042        let mut best_t = t_max;
2043        // Push roots in reverse so the LIFO pop visits the leftmost
2044        // root first — matching world_node_transforms's deterministic
2045        // ordering. The deterministic walk order matters when two
2046        // instances tie on `t` exactly (e.g. two coincident mirrored
2047        // copies); the leftmost-first convention picks the same
2048        // winner across runs.
2049        let mut stack: Vec<(NodeId, [[f32; 4]; 4])> =
2050            self.roots.iter().rev().map(|r| (*r, identity)).collect();
2051        while let Some((nid, parent)) = stack.pop() {
2052            let idx = nid.0 as usize;
2053            if idx >= n_nodes || visited[idx] {
2054                continue;
2055            }
2056            visited[idx] = true;
2057            let node = &self.nodes[idx];
2058            let world = mat4_mul(parent, node.transform.to_matrix());
2059            if let Some(m) = node.mesh {
2060                if let Some(mesh) = self.meshes.get(m.0 as usize) {
2061                    if let Some(world_inv) = mat4_affine_inverse(world) {
2062                        let local_ray = ray_into_local(world_inv, ray);
2063                        if let Some((prim_idx, hit)) = mesh.intersect_ray(local_ray, best_t) {
2064                            // hit.t is in the local-frame ray
2065                            // parameter, which equals the world-frame
2066                            // ray parameter (affine change of frame is
2067                            // parameter-preserving). Shrink best_t.
2068                            // A later instance whose hit ties exactly
2069                            // (`hit.t == best_t`) is not allowed to
2070                            // override the existing winner — the
2071                            // earlier-visited (leftmost-first DFS)
2072                            // instance is the deterministic winner.
2073                            if best.is_none() || hit.t < best_t {
2074                                best_t = hit.t;
2075                                best = Some(SceneRayHit {
2076                                    node: nid,
2077                                    primitive_index: prim_idx,
2078                                    hit,
2079                                });
2080                            }
2081                        }
2082                    }
2083                }
2084            }
2085            for child in node.children.iter().rev() {
2086                stack.push((*child, world));
2087            }
2088        }
2089        best
2090    }
2091
2092    /// Any-hit (shadow-ray) world-space query over the same reachable
2093    /// node-mesh instances as [`Scene3D::intersect_ray`].
2094    ///
2095    /// Returns `true` as soon as **any** reachable node-mesh instance
2096    /// reports a hit within `t_max` for the ray, transformed into
2097    /// each instance's local frame the same way
2098    /// [`Scene3D::intersect_ray`] does. Returns `false` only after
2099    /// exhausting every reachable instance without a hit.
2100    ///
2101    /// Used for shadow rays / occlusion queries: the caller needs to
2102    /// know whether *something* blocks the segment from the surface
2103    /// hit point to the light, not which thing or where. The
2104    /// short-circuit lets the walk skip the rest of the scene as soon
2105    /// as the answer is decided.
2106    ///
2107    /// Reachability, cycle-guarding, singular-transform skipping, and
2108    /// degenerate-ray handling match [`Scene3D::intersect_ray`].
2109    ///
2110    /// # Determinism
2111    ///
2112    /// The walk visits instances in the same DFS order as
2113    /// [`Scene3D::intersect_ray`], but the answer (`true` / `false`)
2114    /// does not depend on visit order — the existence of a blocker
2115    /// is order-invariant. Visit order only changes which instance
2116    /// is the *first* blocker discovered, never the return value.
2117    pub fn any_ray_intersection(&self, ray: crate::ray::Ray, t_max: f32) -> bool {
2118        let n_nodes = self.nodes.len();
2119        let n_meshes = self.meshes.len();
2120        if n_nodes == 0 || n_meshes == 0 {
2121            return false;
2122        }
2123        let mut visited = vec![false; n_nodes];
2124        let identity: [[f32; 4]; 4] = [
2125            [1.0, 0.0, 0.0, 0.0],
2126            [0.0, 1.0, 0.0, 0.0],
2127            [0.0, 0.0, 1.0, 0.0],
2128            [0.0, 0.0, 0.0, 1.0],
2129        ];
2130        let mut stack: Vec<(NodeId, [[f32; 4]; 4])> =
2131            self.roots.iter().rev().map(|r| (*r, identity)).collect();
2132        while let Some((nid, parent)) = stack.pop() {
2133            let idx = nid.0 as usize;
2134            if idx >= n_nodes || visited[idx] {
2135                continue;
2136            }
2137            visited[idx] = true;
2138            let node = &self.nodes[idx];
2139            let world = mat4_mul(parent, node.transform.to_matrix());
2140            if let Some(m) = node.mesh {
2141                if let Some(mesh) = self.meshes.get(m.0 as usize) {
2142                    if let Some(world_inv) = mat4_affine_inverse(world) {
2143                        let local_ray = ray_into_local(world_inv, ray);
2144                        // We reuse the closest-hit primitive walk —
2145                        // it has the same finite-time termination
2146                        // contract and short-circuits at the first
2147                        // primitive hit within `t_max`. A dedicated
2148                        // any-hit `Mesh::any_ray_intersection`
2149                        // wouldn't change the answer; the closest-hit
2150                        // walk still examines every primitive in
2151                        // `mesh` because each primitive's hit might
2152                        // be closer than the last, but it returns
2153                        // `Some(_)` whenever any does.
2154                        if mesh.intersect_ray(local_ray, t_max).is_some() {
2155                            return true;
2156                        }
2157                    }
2158                }
2159            }
2160            for child in node.children.iter().rev() {
2161                stack.push((*child, world));
2162            }
2163        }
2164        false
2165    }
2166
2167    /// Walk every cross-collection reference and report dangling
2168    /// indices + inconsistent buffer lengths. Returns `Ok(())` when
2169    /// the scene is internally consistent, or `Err` carrying every
2170    /// problem found (the walk does not short-circuit, so callers see
2171    /// the full set in one pass).
2172    ///
2173    /// Currently checks:
2174    ///
2175    /// * `roots` reference live `nodes`.
2176    /// * Every `Node::children`, `Node::mesh`, `Node::camera`,
2177    ///   `Node::light`, `Node::skin`, `Node::audio_emitter` references
2178    ///   a live entry in the corresponding arena.
2179    /// * Every primitive's optional attribute buffer (`normals`,
2180    ///   `tangents`, `uvs[i]`, `colors[i]`, `joints`, `weights`)
2181    ///   matches `positions.len()`.
2182    /// * `Primitive::indices` values stay within `positions.len()`.
2183    /// * `Primitive::material` indices are live.
2184    /// * Each `MorphTarget` slot length matches the corresponding
2185    ///   base attribute on the parent `Primitive`.
2186    /// * `Mesh::weights.len()` matches the morph-target count of
2187    ///   every contained primitive (or every primitive has zero
2188    ///   targets and `weights` is empty).
2189    /// * `Mesh::target_names.len()`, when non-empty, matches the
2190    ///   morph-target count of every contained primitive (glTF 2.0
2191    ///   §3.7.2.2 implementation note — the `targetNames` array and
2192    ///   all primitive `targets` arrays must have the same length).
2193    /// * Every [`Inbetween`](crate::Inbetween) declares a legal,
2194    ///   unique weight station (finite, not `0`/`1`, no duplicates
2195    ///   within one target) and its delta arrays match the base
2196    ///   `positions` length.
2197    /// * A non-empty `Node::weights` override sits on a node that
2198    ///   instantiates a mesh, and its length matches the morph-target
2199    ///   count of every primitive of that mesh (glTF 2.0 `node.weights`
2200    ///   count/`mesh`-presence requirements).
2201    /// * Every `Skeleton::inverse_bind_matrices` entry has its fourth
2202    ///   row set to `[0, 0, 0, 1]` (glTF 2.0 §5.28.1 affine-IBM
2203    ///   constraint), and at least as many entries as joints exist
2204    ///   when the list is non-empty (§3.7.3.1 count >= joints; extra
2205    ///   trailing entries are conforming).
2206    /// * Per-vertex joint weights are finite and non-negative
2207    ///   (§3.7.3.3), and — for every node binding a mesh to a skin —
2208    ///   every joint index stays within the bound skeleton's joint
2209    ///   count.
2210    /// * `MorphWeights` animation samplers carry exactly one weight
2211    ///   per morph target of the mesh their target node instantiates
2212    ///   (§3.6), when that node → mesh chain resolves.
2213    ///
2214    /// This is a defensive check for fuzzers and codec authors —
2215    /// production decoders are expected to produce valid scenes
2216    /// already; the runtime cost is `O(N)` over every typed buffer.
2217    pub fn validate(&self) -> std::result::Result<(), Vec<ValidationError>> {
2218        let mut errors = Vec::new();
2219        let n_nodes = self.nodes.len();
2220        let n_meshes = self.meshes.len();
2221        let n_materials = self.materials.len();
2222        let n_textures = self.textures.len();
2223        let n_cameras = self.cameras.len();
2224        let n_lights = self.lights.len();
2225        let n_skeletons = self.skeletons.len();
2226        let n_skins = self.skins.len();
2227        let n_emitters = self.audio_emitters.len();
2228        let n_audio_sources = self.audio_sources.len();
2229
2230        for (i, root) in self.roots.iter().enumerate() {
2231            if (root.0 as usize) >= n_nodes {
2232                errors.push(ValidationError::DanglingId {
2233                    location: format!("roots[{i}]"),
2234                    id: root.0,
2235                    arena: "nodes",
2236                });
2237            }
2238        }
2239        for (i, node) in self.nodes.iter().enumerate() {
2240            for (j, child) in node.children.iter().enumerate() {
2241                if (child.0 as usize) >= n_nodes {
2242                    errors.push(ValidationError::DanglingId {
2243                        location: format!("nodes[{i}].children[{j}]"),
2244                        id: child.0,
2245                        arena: "nodes",
2246                    });
2247                }
2248            }
2249            if let Some(m) = node.mesh {
2250                if (m.0 as usize) >= n_meshes {
2251                    errors.push(ValidationError::DanglingId {
2252                        location: format!("nodes[{i}].mesh"),
2253                        id: m.0,
2254                        arena: "meshes",
2255                    });
2256                }
2257            }
2258            if let Some(c) = node.camera {
2259                if (c.0 as usize) >= n_cameras {
2260                    errors.push(ValidationError::DanglingId {
2261                        location: format!("nodes[{i}].camera"),
2262                        id: c.0,
2263                        arena: "cameras",
2264                    });
2265                }
2266            }
2267            if let Some(l) = node.light {
2268                if (l.0 as usize) >= n_lights {
2269                    errors.push(ValidationError::DanglingId {
2270                        location: format!("nodes[{i}].light"),
2271                        id: l.0,
2272                        arena: "lights",
2273                    });
2274                }
2275            }
2276            if let Some(s) = node.skin {
2277                if (s.0 as usize) >= n_skins {
2278                    errors.push(ValidationError::DanglingId {
2279                        location: format!("nodes[{i}].skin"),
2280                        id: s.0,
2281                        arena: "skins",
2282                    });
2283                }
2284            }
2285            if let Some(e) = node.audio_emitter {
2286                if (e.0 as usize) >= n_emitters {
2287                    errors.push(ValidationError::DanglingId {
2288                        location: format!("nodes[{i}].audio_emitter"),
2289                        id: e.0,
2290                        arena: "audio_emitters",
2291                    });
2292                }
2293            }
2294            // Node-level morph-weight overrides (glTF 2.0
2295            // `node.weights`): only meaningful on a node that
2296            // instantiates a mesh, and the vector must carry one
2297            // weight per morph target of every contained primitive —
2298            // the per-instance mirror of the `Mesh::weights` parity
2299            // check below.
2300            if !node.weights.is_empty() {
2301                match node.mesh {
2302                    None => errors.push(ValidationError::NodeMorphWeightsWithoutMesh {
2303                        location: format!("nodes[{i}].weights"),
2304                    }),
2305                    Some(m) => {
2306                        // A dangling mesh id is already reported above;
2307                        // the count check only applies when it resolves.
2308                        if let Some(mesh) = self.meshes.get(m.0 as usize) {
2309                            for (pi, prim) in mesh.primitives.iter().enumerate() {
2310                                if prim.targets.len() != node.weights.len() {
2311                                    errors.push(ValidationError::NodeMorphWeightCountMismatch {
2312                                        location: format!(
2313                                            "nodes[{i}].weights -> meshes[{mi}].primitives[{pi}]",
2314                                            mi = m.0
2315                                        ),
2316                                        node_weights: node.weights.len(),
2317                                        primitive_targets: prim.targets.len(),
2318                                    });
2319                                }
2320                            }
2321                        }
2322                    }
2323                }
2324            }
2325        }
2326
2327        for (mi, mesh) in self.meshes.iter().enumerate() {
2328            let mesh_weights = mesh.weights.len();
2329            for (pi, prim) in mesh.primitives.iter().enumerate() {
2330                let n_pos = prim.positions.len();
2331                let here = |field: &str| format!("meshes[{mi}].primitives[{pi}].{field}");
2332                if let Some(v) = &prim.normals {
2333                    if v.len() != n_pos {
2334                        errors.push(ValidationError::AttributeLengthMismatch {
2335                            location: here("normals"),
2336                            expected: n_pos,
2337                            actual: v.len(),
2338                        });
2339                    }
2340                }
2341                if let Some(v) = &prim.tangents {
2342                    if v.len() != n_pos {
2343                        errors.push(ValidationError::AttributeLengthMismatch {
2344                            location: here("tangents"),
2345                            expected: n_pos,
2346                            actual: v.len(),
2347                        });
2348                    }
2349                }
2350                for (k, set) in prim.uvs.iter().enumerate() {
2351                    if set.len() != n_pos {
2352                        errors.push(ValidationError::AttributeLengthMismatch {
2353                            location: here(&format!("uvs[{k}]")),
2354                            expected: n_pos,
2355                            actual: set.len(),
2356                        });
2357                    }
2358                }
2359                for (k, set) in prim.colors.iter().enumerate() {
2360                    if set.len() != n_pos {
2361                        errors.push(ValidationError::AttributeLengthMismatch {
2362                            location: here(&format!("colors[{k}]")),
2363                            expected: n_pos,
2364                            actual: set.len(),
2365                        });
2366                    }
2367                }
2368                if let Some(v) = &prim.joints {
2369                    if v.len() != n_pos {
2370                        errors.push(ValidationError::AttributeLengthMismatch {
2371                            location: here("joints"),
2372                            expected: n_pos,
2373                            actual: v.len(),
2374                        });
2375                    }
2376                }
2377                if let Some(v) = &prim.weights {
2378                    if v.len() != n_pos {
2379                        errors.push(ValidationError::AttributeLengthMismatch {
2380                            location: here("weights"),
2381                            expected: n_pos,
2382                            actual: v.len(),
2383                        });
2384                    }
2385                    // glTF 2.0 §3.7.3.3: joint weights MUST NOT be
2386                    // negative (and NaN/Inf would poison the blend).
2387                    // Report the first offending component only — one
2388                    // corrupt buffer would otherwise flood the report.
2389                    'weights: for (vi, row) in v.iter().enumerate() {
2390                        for (ci, w) in row.iter().enumerate() {
2391                            if !w.is_finite() || *w < 0.0 {
2392                                errors.push(ValidationError::JointWeightInvalid {
2393                                    location: here(&format!("weights[{vi}][{ci}]")),
2394                                    value: *w,
2395                                });
2396                                break 'weights;
2397                            }
2398                        }
2399                    }
2400                }
2401                if let Some(idx) = &prim.indices {
2402                    let max_ok = n_pos as u32;
2403                    let bad = match idx {
2404                        crate::mesh::Indices::U16(v) => v.iter().any(|i| (*i as u32) >= max_ok),
2405                        crate::mesh::Indices::U32(v) => v.iter().any(|i| *i >= max_ok),
2406                    };
2407                    if bad {
2408                        errors.push(ValidationError::IndexOutOfRange {
2409                            location: here("indices"),
2410                            vertex_count: n_pos,
2411                        });
2412                    }
2413                }
2414                if let Some(m) = prim.material {
2415                    if (m.0 as usize) >= n_materials {
2416                        errors.push(ValidationError::DanglingId {
2417                            location: here("material"),
2418                            id: m.0,
2419                            arena: "materials",
2420                        });
2421                    }
2422                }
2423                // KHR_materials_variants mappings: material + variant
2424                // ids must be live, and across the whole mappings list
2425                // each variant may be claimed by at most one mapping.
2426                let n_variants = self.material_variants.len();
2427                let mut seen_variants: HashSet<u32> = HashSet::new();
2428                for (ki, mapping) in prim.variant_mappings.iter().enumerate() {
2429                    if (mapping.material.0 as usize) >= n_materials {
2430                        errors.push(ValidationError::DanglingId {
2431                            location: here(&format!("variant_mappings[{ki}].material")),
2432                            id: mapping.material.0,
2433                            arena: "materials",
2434                        });
2435                    }
2436                    for (vi, v) in mapping.variants.iter().enumerate() {
2437                        if (v.0 as usize) >= n_variants {
2438                            errors.push(ValidationError::DanglingId {
2439                                location: here(&format!("variant_mappings[{ki}].variants[{vi}]")),
2440                                id: v.0,
2441                                arena: "material_variants",
2442                            });
2443                        }
2444                        if !seen_variants.insert(v.0) {
2445                            errors.push(ValidationError::DuplicateVariantMapping {
2446                                location: here(&format!("variant_mappings[{ki}].variants[{vi}]")),
2447                                variant: v.0,
2448                            });
2449                        }
2450                    }
2451                }
2452                // Texture-coordinate coverage: every texture slot of
2453                // every material this primitive can draw with (the
2454                // base material plus each variant-mapping override)
2455                // must sample a UV channel the primitive actually
2456                // carries — glTF 2.0 requires the corresponding
2457                // TEXCOORD attribute for the material to be
2458                // applicable. The checked set is the *effective* one:
2459                // a `KHR_texture_transform` `texCoord` override wins
2460                // over the reference's own `uv_set`.
2461                {
2462                    let mut checked: HashSet<u32> = HashSet::new();
2463                    let base = prim.material.iter().map(|m| (*m, None));
2464                    let mapped = prim
2465                        .variant_mappings
2466                        .iter()
2467                        .enumerate()
2468                        .map(|(ki, mapping)| (mapping.material, Some(ki)));
2469                    for (mid, mapping_idx) in base.chain(mapped) {
2470                        if !checked.insert(mid.0) {
2471                            continue; // shared material: report once
2472                        }
2473                        let Some(mat) = self.materials.get(mid.0 as usize) else {
2474                            continue; // dangling id already reported
2475                        };
2476                        for (field, r) in mat.texture_refs() {
2477                            let uv_set = r.effective_uv_set();
2478                            if (uv_set as usize) >= prim.uvs.len() {
2479                                let via = match mapping_idx {
2480                                    None => String::new(),
2481                                    Some(ki) => format!(".variant_mappings[{ki}]"),
2482                                };
2483                                errors.push(ValidationError::UvSetOutOfRange {
2484                                    location: format!(
2485                                        "meshes[{mi}].primitives[{pi}]{via} -> materials[{id}].{field}",
2486                                        id = mid.0
2487                                    ),
2488                                    uv_set,
2489                                    available: prim.uvs.len(),
2490                                });
2491                            }
2492                        }
2493                    }
2494                }
2495                for (ti, tgt) in prim.targets.iter().enumerate() {
2496                    let tgt_loc = |field: &str| here(&format!("targets[{ti}].{field}"));
2497                    if let Some(v) = &tgt.position {
2498                        if v.len() != n_pos {
2499                            errors.push(ValidationError::AttributeLengthMismatch {
2500                                location: tgt_loc("position"),
2501                                expected: n_pos,
2502                                actual: v.len(),
2503                            });
2504                        }
2505                    }
2506                    if let Some(v) = &tgt.normal {
2507                        if v.len() != n_pos {
2508                            errors.push(ValidationError::AttributeLengthMismatch {
2509                                location: tgt_loc("normal"),
2510                                expected: n_pos,
2511                                actual: v.len(),
2512                            });
2513                        }
2514                    }
2515                    if let Some(v) = &tgt.tangent {
2516                        if v.len() != n_pos {
2517                            errors.push(ValidationError::AttributeLengthMismatch {
2518                                location: tgt_loc("tangent"),
2519                                expected: n_pos,
2520                                actual: v.len(),
2521                            });
2522                        }
2523                    }
2524                    // In-between shapes (USD blend-shape §1.4.1
2525                    // authoring rules): the endpoint weights 0 and 1
2526                    // are implicitly defined and must not be
2527                    // authored, weights must be finite, and no two
2528                    // in-betweens of one target may share a weight.
2529                    // Delta arrays are per-vertex parallel like the
2530                    // primary slots.
2531                    for (ii, ib) in tgt.inbetweens.iter().enumerate() {
2532                        let ib_loc =
2533                            |field: &str| here(&format!("targets[{ti}].inbetweens[{ii}]{field}"));
2534                        if !ib.is_valid_weight() {
2535                            errors.push(ValidationError::InbetweenWeightInvalid {
2536                                location: ib_loc(""),
2537                                weight: ib.weight,
2538                            });
2539                        } else if tgt.inbetweens[..ii].iter().any(|o| o.weight == ib.weight) {
2540                            errors.push(ValidationError::InbetweenDuplicateWeight {
2541                                location: ib_loc(""),
2542                                weight: ib.weight,
2543                            });
2544                        }
2545                        if let Some(v) = &ib.position {
2546                            if v.len() != n_pos {
2547                                errors.push(ValidationError::AttributeLengthMismatch {
2548                                    location: ib_loc(".position"),
2549                                    expected: n_pos,
2550                                    actual: v.len(),
2551                                });
2552                            }
2553                        }
2554                        if let Some(v) = &ib.normal {
2555                            if v.len() != n_pos {
2556                                errors.push(ValidationError::AttributeLengthMismatch {
2557                                    location: ib_loc(".normal"),
2558                                    expected: n_pos,
2559                                    actual: v.len(),
2560                                });
2561                            }
2562                        }
2563                    }
2564                }
2565                if mesh_weights != 0 && prim.targets.len() != mesh_weights {
2566                    errors.push(ValidationError::MorphWeightCountMismatch {
2567                        location: format!("meshes[{mi}].primitives[{pi}].targets"),
2568                        mesh_weights,
2569                        primitive_targets: prim.targets.len(),
2570                    });
2571                }
2572                // Morph-target names: glTF 2.0 §3.7.2.2 implementation
2573                // note — the `targetNames` array and all primitive
2574                // `targets` arrays must have the same length. Empty
2575                // means unnamed and is always fine.
2576                if !mesh.target_names.is_empty() && prim.targets.len() != mesh.target_names.len() {
2577                    errors.push(ValidationError::MorphTargetNameCountMismatch {
2578                        location: format!("meshes[{mi}].primitives[{pi}].targets"),
2579                        target_names: mesh.target_names.len(),
2580                        primitive_targets: prim.targets.len(),
2581                    });
2582                }
2583            }
2584        }
2585
2586        // Materials → textures. `Material::texture_refs` enumerates
2587        // every slot — the five core maps plus every extension map on
2588        // `MaterialExt` — so a newly added slot is validated the day
2589        // it exists rather than needing a matching edit here.
2590        for (mi, mat) in self.materials.iter().enumerate() {
2591            for (field, r) in mat.texture_refs() {
2592                if (r.texture.0 as usize) >= n_textures {
2593                    errors.push(ValidationError::DanglingId {
2594                        location: format!("materials[{mi}].{field}"),
2595                        id: r.texture.0,
2596                        arena: "textures",
2597                    });
2598                }
2599                // A `KHR_texture_transform` with a non-finite affine
2600                // component would poison every coordinate it maps.
2601                if let Some(t) = r.transform {
2602                    if !t.is_finite() {
2603                        errors.push(ValidationError::TextureTransformNotFinite {
2604                            location: format!("materials[{mi}].{field}.transform"),
2605                        });
2606                    }
2607                }
2608            }
2609        }
2610
2611        // Skeletons → nodes + inverse-bind-matrix parity.
2612        for (si, skel) in self.skeletons.iter().enumerate() {
2613            for (ji, joint) in skel.joints.iter().enumerate() {
2614                if (joint.0 as usize) >= n_nodes {
2615                    errors.push(ValidationError::DanglingId {
2616                        location: format!("skeletons[{si}].joints[{ji}]"),
2617                        id: joint.0,
2618                        arena: "nodes",
2619                    });
2620                }
2621            }
2622            // glTF 2.0 §3.7.3.1: the inverse-bind element count MUST
2623            // be greater than *or equal to* the joint count — extra
2624            // trailing matrices are conforming (the skinning math
2625            // ignores them); only a shortfall is an error. Empty stays
2626            // the "identity for every joint" escape hatch (§5.28's
2627            // documented default when the accessor is omitted).
2628            if !skel.inverse_bind_matrices.is_empty()
2629                && skel.inverse_bind_matrices.len() < skel.joints.len()
2630            {
2631                errors.push(ValidationError::SkeletonBindMatrixCountMismatch {
2632                    location: format!("skeletons[{si}]"),
2633                    joints: skel.joints.len(),
2634                    inverse_bind_matrices: skel.inverse_bind_matrices.len(),
2635                });
2636            }
2637            // glTF 2.0 §5.28.1: an accessor referenced by
2638            // `inverseBindMatrices` MUST have its fourth row set to
2639            // `[0.0, 0.0, 0.0, 1.0]` (the matrix is affine — a pure
2640            // composition of rotations/translations/scales/shears,
2641            // never projective). Our matrix is row-major
2642            // column-vector, so the "fourth row" of the math matrix
2643            // is the row at index 3.
2644            for (ji, ibm) in skel.inverse_bind_matrices.iter().enumerate() {
2645                let last = ibm[3];
2646                if last[0] != 0.0 || last[1] != 0.0 || last[2] != 0.0 || last[3] != 1.0 {
2647                    errors.push(ValidationError::SkeletonBindMatrixNotAffine {
2648                        location: format!("skeletons[{si}].inverse_bind_matrices[{ji}]"),
2649                        last_row: last,
2650                    });
2651                }
2652            }
2653        }
2654
2655        // Skins → skeletons + optional root node.
2656        for (si, skin) in self.skins.iter().enumerate() {
2657            if (skin.skeleton.0 as usize) >= n_skeletons {
2658                errors.push(ValidationError::DanglingId {
2659                    location: format!("skins[{si}].skeleton"),
2660                    id: skin.skeleton.0,
2661                    arena: "skeletons",
2662                });
2663            }
2664            if let Some(r) = skin.root_node {
2665                if (r.0 as usize) >= n_nodes {
2666                    errors.push(ValidationError::DanglingId {
2667                        location: format!("skins[{si}].root_node"),
2668                        id: r.0,
2669                        arena: "nodes",
2670                    });
2671                }
2672            }
2673        }
2674
2675        // Skinned nodes: every joint index used by the mesh's
2676        // primitives must stay within the bound skeleton's joint list
2677        // (glTF 2.0 §3.7.3.3: "All joint values MUST be within the
2678        // range of joints in the skin"). This is the only check that
2679        // needs the node → skin → skeleton binding, since the same
2680        // mesh could be bound to differently-sized skeletons by
2681        // different nodes.
2682        for (ni, node) in self.nodes.iter().enumerate() {
2683            let (Some(mesh_id), Some(skin_id)) = (node.mesh, node.skin) else {
2684                continue;
2685            };
2686            let Some(mesh) = self.meshes.get(mesh_id.0 as usize) else {
2687                continue; // dangling mesh already reported above
2688            };
2689            let Some(skin) = self.skins.get(skin_id.0 as usize) else {
2690                continue; // dangling skin already reported above
2691            };
2692            let Some(skel) = self.skeletons.get(skin.skeleton.0 as usize) else {
2693                continue; // dangling skeleton already reported above
2694            };
2695            let joint_count = skel.joints.len();
2696            for (pi, prim) in mesh.primitives.iter().enumerate() {
2697                let Some(joints) = &prim.joints else {
2698                    continue;
2699                };
2700                // First offender per primitive, same anti-flood shape
2701                // as the weight scan.
2702                'joints: for (vi, row) in joints.iter().enumerate() {
2703                    for (ci, j) in row.iter().enumerate() {
2704                        if (*j as usize) >= joint_count {
2705                            errors.push(ValidationError::JointIndexOutOfRange {
2706                                location: format!(
2707                                    "nodes[{ni}] -> meshes[{mi}].primitives[{pi}].joints[{vi}][{ci}]",
2708                                    mi = mesh_id.0
2709                                ),
2710                                joint: *j,
2711                                joint_count,
2712                            });
2713                            break 'joints;
2714                        }
2715                    }
2716                }
2717            }
2718        }
2719
2720        // Audio emitters → audio sources.
2721        for (ei, em) in self.audio_emitters.iter().enumerate() {
2722            if (em.source.0 as usize) >= n_audio_sources {
2723                errors.push(ValidationError::DanglingId {
2724                    location: format!("audio_emitters[{ei}].source"),
2725                    id: em.source.0,
2726                    arena: "audio_sources",
2727                });
2728            }
2729        }
2730
2731        // Animations: channel target nodes + sampler parity.
2732        for (ai, anim) in self.animations.iter().enumerate() {
2733            for (ci, ch) in anim.channels.iter().enumerate() {
2734                let loc = |suffix: &str| format!("animations[{ai}].channels[{ci}]{suffix}");
2735                if (ch.target.node.0 as usize) >= n_nodes {
2736                    errors.push(ValidationError::DanglingId {
2737                        location: loc(".target.node"),
2738                        id: ch.target.node.0,
2739                        arena: "nodes",
2740                    });
2741                }
2742                let k = ch.sampler.keyframes.len();
2743                if k == 0 {
2744                    errors.push(ValidationError::AnimationSamplerEmpty {
2745                        location: loc(".sampler"),
2746                    });
2747                } else {
2748                    let mut prev = f32::NEG_INFINITY;
2749                    for (ki, t) in ch.sampler.keyframes.iter().enumerate() {
2750                        if t.partial_cmp(&prev) != Some(std::cmp::Ordering::Greater) {
2751                            errors.push(ValidationError::AnimationKeyframesNotStrictlyIncreasing {
2752                                location: loc(&format!(".sampler.keyframes[{ki}]")),
2753                                at: *t,
2754                                previous: prev,
2755                            });
2756                            break;
2757                        }
2758                        prev = *t;
2759                    }
2760                }
2761
2762                use crate::animation::{AnimationProperty as P, AnimationValues as V};
2763                let variant_ok = matches!(
2764                    (ch.target.property, &ch.sampler.values),
2765                    (P::Translation | P::Scale, V::Vec3(_))
2766                        | (P::Rotation, V::Quat(_))
2767                        | (P::MorphWeights, V::Scalar(_))
2768                );
2769                if !variant_ok {
2770                    let expected: &'static str = match ch.target.property {
2771                        P::Translation | P::Scale => "Vec3",
2772                        P::Rotation => "Quat",
2773                        P::MorphWeights => "Scalar",
2774                    };
2775                    let actual: &'static str = match ch.sampler.values {
2776                        V::Vec3(_) => "Vec3",
2777                        V::Quat(_) => "Quat",
2778                        V::Scalar(_) => "Scalar",
2779                    };
2780                    errors.push(ValidationError::AnimationValueVariantMismatch {
2781                        location: loc(""),
2782                        property: match ch.target.property {
2783                            P::Translation => "Translation",
2784                            P::Rotation => "Rotation",
2785                            P::Scale => "Scale",
2786                            P::MorphWeights => "MorphWeights",
2787                        },
2788                        expected_variant: expected,
2789                        actual_variant: actual,
2790                    });
2791                }
2792
2793                if k != 0 {
2794                    let v = ch.sampler.values.len();
2795                    let expected_factor = match ch.sampler.interpolation {
2796                        crate::animation::Interpolation::CubicSpline => 3,
2797                        _ => 1,
2798                    };
2799                    let ok = match (ch.target.property, &ch.sampler.values) {
2800                        (P::MorphWeights, V::Scalar(_)) => {
2801                            let denom = k * expected_factor;
2802                            denom != 0 && v % denom == 0 && v >= denom
2803                        }
2804                        _ => v == k * expected_factor,
2805                    };
2806                    if !ok {
2807                        errors.push(ValidationError::AnimationSamplerLengthMismatch {
2808                            location: loc(".sampler"),
2809                            keyframes: k,
2810                            values: v,
2811                            interpolation: match ch.sampler.interpolation {
2812                                crate::animation::Interpolation::Step => "Step",
2813                                crate::animation::Interpolation::Linear => "Linear",
2814                                crate::animation::Interpolation::CubicSpline => "CubicSpline",
2815                            },
2816                        });
2817                    } else if ch.target.property == P::MorphWeights
2818                        && matches!(ch.sampler.values, V::Scalar(_))
2819                    {
2820                        // The per-frame weight-vector stride must equal
2821                        // the morph-target count of the mesh the target
2822                        // node instantiates (glTF 2.0 §3.6: a weights
2823                        // sampler carries count(targets) floats per
2824                        // keyframe). Only checkable when the node →
2825                        // mesh chain resolves; dangling links are
2826                        // already reported above.
2827                        let stride = v / (k * expected_factor);
2828                        let targets = self
2829                            .nodes
2830                            .get(ch.target.node.0 as usize)
2831                            .and_then(|n| n.mesh)
2832                            .and_then(|m| self.meshes.get(m.0 as usize))
2833                            .and_then(|mesh| mesh.primitives.first())
2834                            .map(|prim| prim.targets.len());
2835                        if let Some(targets) = targets {
2836                            if stride != targets {
2837                                errors.push(ValidationError::AnimationMorphStrideMismatch {
2838                                    location: loc(".sampler"),
2839                                    stride,
2840                                    targets,
2841                                });
2842                            }
2843                        }
2844                    }
2845                }
2846            }
2847        }
2848
2849        if errors.is_empty() {
2850            Ok(())
2851        } else {
2852            Err(errors)
2853        }
2854    }
2855}
2856
2857/// One issue surfaced by [`Scene3D::validate`]. The variants intentionally
2858/// carry breadcrumb strings (`"meshes[3].primitives[0].normals"`) so a
2859/// caller can render a usable diagnostic without re-walking the scene.
2860///
2861/// `Eq` is not implemented because
2862/// [`AnimationKeyframesNotStrictlyIncreasing`](Self::AnimationKeyframesNotStrictlyIncreasing)
2863/// carries `f32` keyframe values; use `PartialEq` or pattern-match on
2864/// the variant fields when asserting in tests.
2865#[derive(Clone, Debug, PartialEq)]
2866#[non_exhaustive]
2867pub enum ValidationError {
2868    /// A typed `IdT(u32)` field points outside its arena.
2869    DanglingId {
2870        location: String,
2871        id: u32,
2872        arena: &'static str,
2873    },
2874    /// An optional attribute buffer is present but its length disagrees
2875    /// with the parent primitive's `positions.len()`.
2876    AttributeLengthMismatch {
2877        location: String,
2878        expected: usize,
2879        actual: usize,
2880    },
2881    /// A primitive's index buffer references a vertex past
2882    /// `positions.len()`.
2883    IndexOutOfRange {
2884        location: String,
2885        vertex_count: usize,
2886    },
2887    /// `Mesh::weights` is non-empty and disagrees with one of the
2888    /// child primitives' morph-target count.
2889    MorphWeightCountMismatch {
2890        location: String,
2891        mesh_weights: usize,
2892        primitive_targets: usize,
2893    },
2894    /// [`Mesh::target_names`](crate::Mesh::target_names) is non-empty
2895    /// and its length disagrees with one of the child primitives'
2896    /// morph-target count (glTF 2.0 §3.7.2.2 implementation note:
2897    /// the `targetNames` array and all primitive `targets` arrays
2898    /// must have the same length).
2899    MorphTargetNameCountMismatch {
2900        location: String,
2901        target_names: usize,
2902        primitive_targets: usize,
2903    },
2904    /// An [`Inbetween`](crate::Inbetween) declares an illegal weight
2905    /// station: non-finite, or exactly `0.0` / `1.0` (the USD
2906    /// blend-shape schema defines those endpoints implicitly — the
2907    /// null shape and the primary deltas — and forbids authoring
2908    /// them). [`MorphTarget::at_weight`](crate::MorphTarget::at_weight)
2909    /// ignores the shape.
2910    InbetweenWeightInvalid { location: String, weight: f32 },
2911    /// Two in-betweens of one [`MorphTarget`](crate::MorphTarget)
2912    /// share a weight station (forbidden — averaging colliding shapes
2913    /// would leave the result unnamed and non-round-trippable).
2914    /// Reported on the second and later claimants;
2915    /// [`MorphTarget::at_weight`](crate::MorphTarget::at_weight)
2916    /// ignores every shape at the duplicated weight.
2917    InbetweenDuplicateWeight { location: String, weight: f32 },
2918    /// A [`Node::weights`](crate::Node::weights) override is non-empty
2919    /// and its length disagrees with the morph-target count of one of
2920    /// the instantiated mesh's primitives (glTF 2.0 `node.weights`:
2921    /// the element count MUST match the referenced mesh's morph-target
2922    /// count).
2923    NodeMorphWeightCountMismatch {
2924        location: String,
2925        node_weights: usize,
2926        primitive_targets: usize,
2927    },
2928    /// A [`Node::weights`](crate::Node::weights) override is non-empty
2929    /// on a node that carries no mesh (glTF 2.0 `node.weights`: when
2930    /// defined, `mesh` MUST also be defined) — there is nothing to
2931    /// blend.
2932    NodeMorphWeightsWithoutMesh { location: String },
2933    /// [`Skeleton::inverse_bind_matrices`](crate::Skeleton::inverse_bind_matrices)
2934    /// is non-empty and its length disagrees with
2935    /// [`Skeleton::joints`](crate::Skeleton::joints).
2936    SkeletonBindMatrixCountMismatch {
2937        location: String,
2938        joints: usize,
2939        inverse_bind_matrices: usize,
2940    },
2941    /// One of [`Skeleton::inverse_bind_matrices`](crate::Skeleton::inverse_bind_matrices)
2942    /// has a non-affine fourth row. The glTF 2.0 spec §5.28.1
2943    /// requires every IBM's last row to be `[0.0, 0.0, 0.0, 1.0]`;
2944    /// any other value implies a projective component that the
2945    /// skinning math `(weight_i * joint_world_i * IBM_i * pos)` would
2946    /// silently corrupt.
2947    SkeletonBindMatrixNotAffine {
2948        location: String,
2949        last_row: [f32; 4],
2950    },
2951    /// An animation channel's sampler has zero keyframes; no
2952    /// keyframe-time table to interpolate against.
2953    AnimationSamplerEmpty { location: String },
2954    /// An animation sampler's keyframe times are not strictly
2955    /// increasing — the renderer would search ambiguously.
2956    AnimationKeyframesNotStrictlyIncreasing {
2957        location: String,
2958        at: f32,
2959        previous: f32,
2960    },
2961    /// An animation sampler's value variant disagrees with the
2962    /// channel target's property kind (e.g. `Rotation` channel
2963    /// fed `Vec3` values).
2964    AnimationValueVariantMismatch {
2965        location: String,
2966        property: &'static str,
2967        expected_variant: &'static str,
2968        actual_variant: &'static str,
2969    },
2970    /// An animation sampler's value count doesn't match the expected
2971    /// `keyframes.len() * factor` (`factor = 1` for Step/Linear,
2972    /// `factor = 3` for CubicSpline; MorphWeights additionally
2973    /// multiplies by per-mesh morph-target count, so we only check
2974    /// divisibility there).
2975    AnimationSamplerLengthMismatch {
2976        location: String,
2977        keyframes: usize,
2978        values: usize,
2979        interpolation: &'static str,
2980    },
2981    /// A `MorphWeights` sampler's per-keyframe weight-vector stride
2982    /// disagrees with the morph-target count of the mesh instantiated
2983    /// by the channel's target node (glTF 2.0 §3.6: one weight per
2984    /// morph target per keyframe).
2985    AnimationMorphStrideMismatch {
2986        location: String,
2987        stride: usize,
2988        targets: usize,
2989    },
2990    /// A primitive bound to a skin (via a node carrying both `mesh`
2991    /// and `skin`) references a joint index at or beyond the bound
2992    /// skeleton's joint count (glTF 2.0 §3.7.3.3: all joint values
2993    /// MUST be within the range of joints in the skin). Only the
2994    /// first offending component per primitive is reported.
2995    JointIndexOutOfRange {
2996        location: String,
2997        joint: u16,
2998        joint_count: usize,
2999    },
3000    /// A vertex joint weight is negative or non-finite (glTF 2.0
3001    /// §3.7.3.3: weights MUST NOT be negative; NaN/Inf would poison
3002    /// the linear blend). Only the first offending component per
3003    /// primitive is reported.
3004    JointWeightInvalid { location: String, value: f32 },
3005    /// A `KHR_materials_variants` variant index appears in more than
3006    /// one mapping of the same primitive's
3007    /// [`variant_mappings`](crate::Primitive::variant_mappings) list.
3008    /// The extension requires each variant index to be used at most
3009    /// once across the whole list, so the active-variant lookup is
3010    /// unambiguous.
3011    DuplicateVariantMapping { location: String, variant: u32 },
3012    /// A material applied by a primitive (directly or through a
3013    /// `KHR_materials_variants` mapping) references a texture through
3014    /// a UV set the primitive does not carry. glTF 2.0 requires the
3015    /// corresponding `TEXCOORD_<set>` attribute to be present for the
3016    /// material to be applicable; the checked value is
3017    /// [`TextureRef::effective_uv_set`](crate::TextureRef::effective_uv_set),
3018    /// so a `KHR_texture_transform` `texCoord` override is honoured.
3019    UvSetOutOfRange {
3020        location: String,
3021        uv_set: u32,
3022        available: usize,
3023    },
3024    /// A [`TextureTransform`](crate::TextureTransform) on one of a
3025    /// material's texture references carries a non-finite offset,
3026    /// rotation, or scale component — every UV coordinate mapped
3027    /// through it would be poisoned.
3028    TextureTransformNotFinite { location: String },
3029}
3030
3031impl std::fmt::Display for ValidationError {
3032    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3033        match self {
3034            Self::DanglingId {
3035                location,
3036                id,
3037                arena,
3038            } => write!(f, "{location}: id {id} is out of bounds for {arena}"),
3039            Self::AttributeLengthMismatch {
3040                location,
3041                expected,
3042                actual,
3043            } => write!(
3044                f,
3045                "{location}: length {actual} disagrees with positions length {expected}"
3046            ),
3047            Self::IndexOutOfRange {
3048                location,
3049                vertex_count,
3050            } => write!(
3051                f,
3052                "{location}: index buffer references vertex >= {vertex_count}"
3053            ),
3054            Self::MorphWeightCountMismatch {
3055                location,
3056                mesh_weights,
3057                primitive_targets,
3058            } => write!(
3059                f,
3060                "{location}: mesh has {mesh_weights} weights but primitive carries {primitive_targets} morph targets"
3061            ),
3062            Self::MorphTargetNameCountMismatch {
3063                location,
3064                target_names,
3065                primitive_targets,
3066            } => write!(
3067                f,
3068                "{location}: mesh names {target_names} morph targets but primitive carries {primitive_targets}"
3069            ),
3070            Self::InbetweenWeightInvalid { location, weight } => write!(
3071                f,
3072                "{location}: in-between weight {weight} is not a legal station (finite, not 0 or 1)"
3073            ),
3074            Self::InbetweenDuplicateWeight { location, weight } => write!(
3075                f,
3076                "{location}: duplicate in-between weight station {weight}"
3077            ),
3078            Self::NodeMorphWeightCountMismatch {
3079                location,
3080                node_weights,
3081                primitive_targets,
3082            } => write!(
3083                f,
3084                "{location}: node overrides {node_weights} weights but primitive carries {primitive_targets} morph targets"
3085            ),
3086            Self::NodeMorphWeightsWithoutMesh { location } => write!(
3087                f,
3088                "{location}: node carries morph-weight overrides but no mesh"
3089            ),
3090            Self::SkeletonBindMatrixCountMismatch {
3091                location,
3092                joints,
3093                inverse_bind_matrices,
3094            } => write!(
3095                f,
3096                "{location}: skeleton has {joints} joints but {inverse_bind_matrices} inverse-bind matrices"
3097            ),
3098            Self::SkeletonBindMatrixNotAffine { location, last_row } => write!(
3099                f,
3100                "{location}: inverse-bind matrix last row {last_row:?} is not [0, 0, 0, 1]"
3101            ),
3102            Self::AnimationSamplerEmpty { location } => {
3103                write!(f, "{location}: sampler has no keyframes")
3104            }
3105            Self::AnimationKeyframesNotStrictlyIncreasing {
3106                location,
3107                at,
3108                previous,
3109            } => write!(
3110                f,
3111                "{location}: keyframe time {at} is not greater than previous {previous}"
3112            ),
3113            Self::AnimationValueVariantMismatch {
3114                location,
3115                property,
3116                expected_variant,
3117                actual_variant,
3118            } => write!(
3119                f,
3120                "{location}: property {property} expects {expected_variant} values but sampler carries {actual_variant}"
3121            ),
3122            Self::AnimationSamplerLengthMismatch {
3123                location,
3124                keyframes,
3125                values,
3126                interpolation,
3127            } => write!(
3128                f,
3129                "{location}: interpolation {interpolation} with {keyframes} keyframes expects matching values, got {values}"
3130            ),
3131            Self::AnimationMorphStrideMismatch {
3132                location,
3133                stride,
3134                targets,
3135            } => write!(
3136                f,
3137                "{location}: sampler carries {stride} weights per keyframe but the target mesh has {targets} morph targets"
3138            ),
3139            Self::JointIndexOutOfRange {
3140                location,
3141                joint,
3142                joint_count,
3143            } => write!(
3144                f,
3145                "{location}: joint index {joint} is out of range for a {joint_count}-joint skeleton"
3146            ),
3147            Self::JointWeightInvalid { location, value } => {
3148                write!(f, "{location}: joint weight {value} is negative or non-finite")
3149            }
3150            Self::DuplicateVariantMapping { location, variant } => {
3151                write!(
3152                    f,
3153                    "{location}: material variant {variant} is claimed by more than one mapping"
3154                )
3155            }
3156            Self::UvSetOutOfRange {
3157                location,
3158                uv_set,
3159                available,
3160            } => write!(
3161                f,
3162                "{location}: texture samples UV set {uv_set} but the primitive carries {available} UV channel(s)"
3163            ),
3164            Self::TextureTransformNotFinite { location } => {
3165                write!(f, "{location}: texture transform has non-finite components")
3166            }
3167        }
3168    }
3169}
3170
3171impl std::error::Error for ValidationError {}
3172
3173impl Default for Scene3D {
3174    fn default() -> Self {
3175        Self::new()
3176    }
3177}