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}