Skip to main content

oxiphysics_io/gltf/
types.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5#![allow(clippy::needless_range_loop)]
6use super::functions::escape_json;
7#[allow(unused_imports)]
8use super::functions::*;
9
10/// Light type for the `KHR_lights_punctual` extension.
11#[derive(Debug, Clone, PartialEq)]
12pub enum LightType {
13    /// Omnidirectional point light.
14    Point,
15    /// Directional parallel light.
16    Directional,
17    /// Spot light with cone angles.
18    Spot {
19        /// Inner cone half-angle (radians).
20        inner_cone_angle: f32,
21        /// Outer cone half-angle (radians).
22        outer_cone_angle: f32,
23    },
24}
25/// Camera projection type.
26#[derive(Debug, Clone)]
27pub enum CameraType {
28    /// Perspective projection.
29    Perspective {
30        /// Vertical field of view in radians.
31        yfov: f32,
32        /// Near clip plane.
33        znear: f32,
34        /// Far clip plane (None = infinite).
35        zfar: Option<f32>,
36        /// Aspect ratio (width / height). None = use viewport.
37        aspect_ratio: Option<f32>,
38    },
39    /// Orthographic projection.
40    Orthographic {
41        /// Half-width of the view volume.
42        xmag: f32,
43        /// Half-height of the view volume.
44        ymag: f32,
45        /// Near clip plane.
46        znear: f32,
47        /// Far clip plane.
48        zfar: f32,
49    },
50}
51/// A validation issue found in a glTF scene.
52#[derive(Debug, Clone)]
53pub struct ValidationIssue {
54    /// Human-readable description of the issue.
55    pub message: String,
56    /// True for errors (must fix), false for warnings (should fix).
57    pub is_error: bool,
58}
59impl ValidationIssue {
60    pub(crate) fn error(msg: impl Into<String>) -> Self {
61        ValidationIssue {
62            message: msg.into(),
63            is_error: true,
64        }
65    }
66    pub(crate) fn warning(msg: impl Into<String>) -> Self {
67        ValidationIssue {
68            message: msg.into(),
69            is_error: false,
70        }
71    }
72}
73/// A single animation channel.
74#[derive(Debug, Clone)]
75pub struct GltfAnimationChannel {
76    /// The channel target.
77    pub target: GltfAnimationTarget,
78    /// Interpolation method: "LINEAR", "STEP", "CUBICSPLINE".
79    pub interpolation: String,
80    /// Input (time) keyframe values.
81    pub input_times: Vec<f32>,
82    /// Output keyframe values (flattened; e.g., 3 floats per VEC3 keyframe).
83    pub output_values: Vec<f32>,
84}
85/// A single keyframe channel for a joint.
86#[derive(Debug, Clone)]
87pub struct JointChannel {
88    /// Index into the skeleton's joints array.
89    pub joint_index: usize,
90    /// Target path: "translation", "rotation", or "scale".
91    pub path: String,
92    /// Input (time) values.
93    pub times: Vec<f32>,
94    /// Output values (flattened; e.g., 3 floats/VEC3 for translation).
95    pub values: Vec<f32>,
96    /// Interpolation: "LINEAR", "STEP", or "CUBICSPLINE".
97    pub interpolation: String,
98}
99/// Material properties for a glTF primitive.
100#[derive(Debug, Clone)]
101pub struct GltfMaterial {
102    /// Material name.
103    pub name: String,
104    /// Base color factor \[r, g, b, a\] (PBR metallic-roughness).
105    pub base_color_factor: [f32; 4],
106    /// Metallic factor (0.0 = dielectric, 1.0 = metal).
107    pub metallic_factor: f32,
108    /// Roughness factor (0.0 = smooth, 1.0 = rough).
109    pub roughness_factor: f32,
110    /// Emissive factor \[r, g, b\].
111    pub emissive_factor: [f32; 3],
112    /// Alpha mode: "OPAQUE", "MASK", or "BLEND".
113    pub alpha_mode: String,
114    /// Alpha cutoff (only used when alpha_mode is "MASK").
115    pub alpha_cutoff: f32,
116    /// Whether the material is double-sided.
117    pub double_sided: bool,
118}
119impl GltfMaterial {
120    /// Check if the material is fully opaque (alpha = 1.0 and OPAQUE mode).
121    pub fn is_opaque(&self) -> bool {
122        self.alpha_mode == "OPAQUE" && (self.base_color_factor[3] - 1.0).abs() < 1e-6
123    }
124    /// Check if the material is emissive (any emissive factor > 0).
125    pub fn is_emissive(&self) -> bool {
126        self.emissive_factor.iter().any(|&v| v > 0.0)
127    }
128}
129/// A joint (bone) in a skeleton hierarchy.
130#[derive(Debug, Clone)]
131pub struct Joint {
132    /// Human-readable name.
133    pub name: String,
134    /// Translation \[x, y, z\].
135    pub translation: [f64; 3],
136    /// Rotation quaternion \[x, y, z, w\].
137    pub rotation: [f64; 4],
138    /// Scale \[x, y, z\].
139    pub scale: [f64; 3],
140    /// Child joint indices.
141    pub children: Vec<usize>,
142    /// Inverse bind matrix stored in column-major order (16 floats).
143    pub inverse_bind_matrix: [f32; 16],
144}
145/// A light source (KHR_lights_punctual extension).
146#[derive(Debug, Clone)]
147pub struct SceneLight {
148    /// Light name.
149    pub name: String,
150    /// Light type.
151    pub light_type: LightType,
152    /// Linear RGB color.
153    pub color: [f32; 3],
154    /// Intensity (candela for point/spot, lux for directional).
155    pub intensity: f32,
156}
157impl SceneLight {
158    /// Return the light type string as used in glTF JSON.
159    pub fn type_string(&self) -> &str {
160        match &self.light_type {
161            LightType::Point => "point",
162            LightType::Directional => "directional",
163            LightType::Spot { .. } => "spot",
164        }
165    }
166}
167/// A single draw primitive: positions, normals, and indices.
168pub struct GltfPrimitive {
169    /// Per-vertex positions.
170    pub positions: Vec<[f32; 3]>,
171    /// Per-vertex normals.
172    pub normals: Vec<[f32; 3]>,
173    /// Per-vertex UV texture coordinates (TEXCOORD_0).
174    pub texcoords: Vec<[f32; 2]>,
175    /// Triangle indices.
176    pub indices: Vec<u32>,
177}
178impl GltfPrimitive {
179    /// Compute the axis-aligned bounding box of positions.
180    /// Returns `(min, max)` as `[f32; 3]` arrays.
181    pub fn bounding_box(&self) -> ([f32; 3], [f32; 3]) {
182        let mut min = [f32::INFINITY; 3];
183        let mut max = [f32::NEG_INFINITY; 3];
184        for p in &self.positions {
185            for i in 0..3 {
186                if p[i] < min[i] {
187                    min[i] = p[i];
188                }
189                if p[i] > max[i] {
190                    max[i] = p[i];
191                }
192            }
193        }
194        (min, max)
195    }
196    /// Return the number of triangles (indices / 3).
197    pub fn triangle_count(&self) -> usize {
198        self.indices.len() / 3
199    }
200    /// Return the number of vertices.
201    pub fn vertex_count(&self) -> usize {
202        self.positions.len()
203    }
204    /// Extract triangle vertex positions as triplets.
205    pub fn extract_triangles(&self) -> Vec<[[f32; 3]; 3]> {
206        let mut tris = Vec::new();
207        let mut i = 0;
208        while i + 2 < self.indices.len() {
209            let a = self.indices[i] as usize;
210            let b = self.indices[i + 1] as usize;
211            let c = self.indices[i + 2] as usize;
212            if a < self.positions.len() && b < self.positions.len() && c < self.positions.len() {
213                tris.push([self.positions[a], self.positions[b], self.positions[c]]);
214            }
215            i += 3;
216        }
217        tris
218    }
219}
220/// A named mesh containing one or more primitives.
221pub struct GltfMesh {
222    /// Human-readable mesh name.
223    pub name: String,
224    /// List of draw primitives.
225    pub primitives: Vec<GltfPrimitive>,
226}
227impl GltfMesh {
228    /// Total vertex count across all primitives.
229    pub fn total_vertex_count(&self) -> usize {
230        self.primitives.iter().map(|p| p.vertex_count()).sum()
231    }
232    /// Total triangle count across all primitives.
233    pub fn total_triangle_count(&self) -> usize {
234        self.primitives.iter().map(|p| p.triangle_count()).sum()
235    }
236}
237/// Element type (SCALAR, VEC2, VEC3, VEC4, MAT4, …).
238#[allow(dead_code)]
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub enum AccessorType {
241    /// Single scalar value.
242    Scalar,
243    /// 2-component vector.
244    Vec2,
245    /// 3-component vector.
246    Vec3,
247    /// 4-component vector.
248    Vec4,
249    /// 2×2 column-major matrix.
250    Mat2,
251    /// 3×3 column-major matrix.
252    Mat3,
253    /// 4×4 column-major matrix.
254    Mat4,
255}
256impl AccessorType {
257    /// Number of components per element.
258    pub fn num_components(self) -> usize {
259        match self {
260            AccessorType::Scalar => 1,
261            AccessorType::Vec2 => 2,
262            AccessorType::Vec3 => 3,
263            AccessorType::Vec4 => 4,
264            AccessorType::Mat2 => 4,
265            AccessorType::Mat3 => 9,
266            AccessorType::Mat4 => 16,
267        }
268    }
269    /// glTF JSON string representation.
270    pub fn as_str(self) -> &'static str {
271        match self {
272            AccessorType::Scalar => "SCALAR",
273            AccessorType::Vec2 => "VEC2",
274            AccessorType::Vec3 => "VEC3",
275            AccessorType::Vec4 => "VEC4",
276            AccessorType::Mat2 => "MAT2",
277            AccessorType::Mat3 => "MAT3",
278            AccessorType::Mat4 => "MAT4",
279        }
280    }
281}
282/// A skeleton animation: name + per-joint channels.
283#[derive(Debug, Clone)]
284pub struct SkeletonAnimation {
285    /// Animation name.
286    pub name: String,
287    /// Per-joint keyframe channels.
288    pub joint_channels: Vec<JointChannel>,
289}
290impl SkeletonAnimation {
291    /// Duration of the animation (max time across all channels).
292    pub fn duration(&self) -> f32 {
293        self.joint_channels
294            .iter()
295            .filter_map(|c| c.times.last().copied())
296            .fold(0.0f32, f32::max)
297    }
298    /// Total keyframe count across all channels.
299    pub fn total_keyframes(&self) -> usize {
300        self.joint_channels.iter().map(|c| c.times.len()).sum()
301    }
302}
303/// A buffer view defines a slice of a binary buffer.
304#[derive(Debug, Clone)]
305pub struct GltfBufferView {
306    /// Index into the buffers array.
307    pub buffer: usize,
308    /// Byte offset into the buffer.
309    pub byte_offset: usize,
310    /// Length in bytes.
311    pub byte_length: usize,
312    /// Optional byte stride (for interleaved data).
313    pub byte_stride: Option<usize>,
314}
315/// A typed array view over a raw byte buffer.
316///
317/// Mirrors the glTF `accessor` concept — a window into a `bufferView` with
318/// a specific element type and component type.
319#[allow(dead_code)]
320pub struct TypedAccessor {
321    /// Human-readable label.
322    pub name: String,
323    /// Index of the owning buffer view.
324    pub buffer_view_index: usize,
325    /// Byte offset within the buffer view.
326    pub byte_offset: usize,
327    /// Component type (FLOAT, UNSIGNED_INT, …).
328    pub component_type: ComponentType,
329    /// Element type (SCALAR, VEC3, …).
330    pub accessor_type: AccessorType,
331    /// Number of elements (not bytes, not components).
332    pub count: usize,
333    /// Optional min values per component (length = num_components).
334    pub min_values: Vec<f64>,
335    /// Optional max values per component.
336    pub max_values: Vec<f64>,
337}
338impl TypedAccessor {
339    /// Create a new accessor.
340    #[allow(clippy::too_many_arguments)]
341    pub fn new(
342        name: impl Into<String>,
343        buffer_view_index: usize,
344        byte_offset: usize,
345        component_type: ComponentType,
346        accessor_type: AccessorType,
347        count: usize,
348    ) -> Self {
349        Self {
350            name: name.into(),
351            buffer_view_index,
352            byte_offset,
353            component_type,
354            accessor_type,
355            count,
356            min_values: Vec::new(),
357            max_values: Vec::new(),
358        }
359    }
360    /// Total byte length of this accessor's data.
361    pub fn byte_length(&self) -> usize {
362        self.count * self.accessor_type.num_components() * self.component_type.byte_size()
363    }
364    /// Serialize to a glTF JSON object fragment (returns a JSON object string).
365    pub fn to_json(&self) -> String {
366        format!(
367            r#"{{ "bufferView": {bv}, "byteOffset": {bo}, "componentType": {ct}, "type": "{at}", "count": {c} }}"#,
368            bv = self.buffer_view_index,
369            bo = self.byte_offset,
370            ct = self.component_type.component_type_code(),
371            at = self.accessor_type.as_str(),
372            c = self.count,
373        )
374    }
375    /// Decode this accessor's data as Vec`f32` from a raw byte buffer.
376    ///
377    /// Only valid when `component_type == Float`.
378    pub fn decode_f32(&self, buffer: &[u8]) -> Option<Vec<f32>> {
379        if self.component_type != ComponentType::Float {
380            return None;
381        }
382        let start = self.byte_offset;
383        let len = self.byte_length();
384        let end = start + len;
385        if end > buffer.len() {
386            return None;
387        }
388        let slice = &buffer[start..end];
389        let floats: Vec<f32> = slice
390            .chunks_exact(4)
391            .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
392            .collect();
393        Some(floats)
394    }
395    /// Decode as Vec`u32` indices (only valid for UNSIGNED_INT accessors).
396    pub fn decode_u32(&self, buffer: &[u8]) -> Option<Vec<u32>> {
397        if self.component_type != ComponentType::UnsignedInt {
398            return None;
399        }
400        let start = self.byte_offset;
401        let len = self.byte_length();
402        let end = start + len;
403        if end > buffer.len() {
404            return None;
405        }
406        let slice = &buffer[start..end];
407        let indices: Vec<u32> = slice
408            .chunks_exact(4)
409            .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
410            .collect();
411        Some(indices)
412    }
413}
414/// An animation channel target.
415#[derive(Debug, Clone)]
416pub struct GltfAnimationTarget {
417    /// Index of the target node.
418    pub node: usize,
419    /// Path: "translation", "rotation", "scale", or "weights".
420    pub path: String,
421}
422/// A node in a glTF scene graph.
423pub struct GltfNode {
424    /// Human-readable name of the node.
425    pub name: String,
426    /// Optional index into the meshes array.
427    pub mesh: Option<usize>,
428    /// Translation vector \[x, y, z\].
429    pub translation: [f64; 3],
430    /// Rotation quaternion \[x, y, z, w\].
431    pub rotation: [f64; 4],
432    /// Scale vector \[x, y, z\].
433    pub scale: [f64; 3],
434    /// Child node indices for scene graph traversal.
435    pub children: Vec<usize>,
436}
437/// Interpolation mode for animation channels.
438#[allow(dead_code)]
439#[derive(Debug, Clone, PartialEq, Eq)]
440pub enum Interpolation {
441    /// Linear interpolation between keyframes.
442    Linear,
443    /// Hold value until next keyframe.
444    Step,
445    /// Cubic spline (requires tangents).
446    CubicSpline,
447}
448impl Interpolation {
449    /// glTF JSON string.
450    pub fn as_str(&self) -> &'static str {
451        match self {
452            Interpolation::Linear => "LINEAR",
453            Interpolation::Step => "STEP",
454            Interpolation::CubicSpline => "CUBICSPLINE",
455        }
456    }
457}
458/// A skeleton: a collection of joints.
459pub struct Skeleton {
460    /// List of joints.
461    pub joints: Vec<Joint>,
462}
463impl Skeleton {
464    /// Create an empty skeleton.
465    pub fn new() -> Self {
466        Skeleton { joints: Vec::new() }
467    }
468    /// Add a joint and return its index.
469    pub fn add_joint(&mut self, joint: Joint) -> usize {
470        let idx = self.joints.len();
471        self.joints.push(joint);
472        idx
473    }
474    /// Return the number of joints.
475    pub fn joint_count(&self) -> usize {
476        self.joints.len()
477    }
478    /// Export a `SkeletonAnimation` as a simplified glTF animation JSON object.
479    ///
480    /// Produces a JSON string suitable for embedding in a `"animations"` array.
481    pub fn export_animation_json(&self, anim: &SkeletonAnimation) -> String {
482        let mut out = String::new();
483        out.push_str("{\n");
484        out.push_str(&format!("  \"name\": \"{}\",\n", escape_json(&anim.name)));
485        out.push_str("  \"channels\": [\n");
486        for (ci, ch) in anim.joint_channels.iter().enumerate() {
487            let joint_name = self
488                .joints
489                .get(ch.joint_index)
490                .map(|j| j.name.as_str())
491                .unwrap_or("unknown");
492            out.push_str("    {\n");
493            out.push_str(&format!(
494                "      \"joint\": \"{}\",\n",
495                escape_json(joint_name)
496            ));
497            out.push_str(&format!("      \"path\": \"{}\",\n", escape_json(&ch.path)));
498            out.push_str(&format!(
499                "      \"interpolation\": \"{}\",\n",
500                escape_json(&ch.interpolation)
501            ));
502            let times_str: Vec<String> = ch.times.iter().map(|t| format!("{t}")).collect();
503            out.push_str(&format!("      \"times\": [{}]\n", times_str.join(", ")));
504            if ci + 1 < anim.joint_channels.len() {
505                out.push_str("    },\n");
506            } else {
507                out.push_str("    }\n");
508            }
509        }
510        out.push_str("  ]\n");
511        out.push('}');
512        out
513    }
514}
515/// A camera in the scene.
516#[derive(Debug, Clone)]
517pub struct SceneCamera {
518    /// Camera name.
519    pub name: String,
520    /// Projection type.
521    pub camera_type: CameraType,
522}
523/// Component type codes used in glTF accessors.
524#[allow(dead_code)]
525#[derive(Debug, Clone, Copy, PartialEq, Eq)]
526pub enum ComponentType {
527    /// Unsigned byte (1 byte).
528    UnsignedByte = 5121,
529    /// Unsigned short (2 bytes).
530    UnsignedShort = 5123,
531    /// Unsigned int (4 bytes).
532    UnsignedInt = 5125,
533    /// Float (4 bytes).
534    Float = 5126,
535}
536impl ComponentType {
537    /// Byte size of one component.
538    pub fn byte_size(self) -> usize {
539        match self {
540            ComponentType::UnsignedByte => 1,
541            ComponentType::UnsignedShort => 2,
542            ComponentType::UnsignedInt => 4,
543            ComponentType::Float => 4,
544        }
545    }
546    /// glTF JSON numeric value.
547    pub fn component_type_code(self) -> u32 {
548        self as u32
549    }
550}
551/// Builder for a PBR metallic-roughness material description.
552///
553/// Generates a glTF-compatible JSON material object fragment.
554#[derive(Debug, Clone)]
555pub struct PbrMaterialBuilder {
556    /// Material name.
557    pub name: String,
558    /// Base colour factor \[R, G, B, A\] in linear sRGB.
559    pub base_color_factor: [f32; 4],
560    /// Metallic factor in \[0.0, 1.0\].
561    pub metallic_factor: f32,
562    /// Roughness factor in \[0.0, 1.0\].
563    pub roughness_factor: f32,
564    /// Emissive colour factor \[R, G, B\].
565    pub emissive_factor: [f32; 3],
566    /// Double-sided rendering.
567    pub double_sided: bool,
568    /// Alpha mode: "OPAQUE", "MASK", or "BLEND".
569    pub alpha_mode: String,
570    /// Alpha cutoff (for MASK mode).
571    pub alpha_cutoff: f32,
572}
573impl PbrMaterialBuilder {
574    /// Create a new builder with default values.
575    pub fn new(name: impl Into<String>) -> Self {
576        Self {
577            name: name.into(),
578            ..Default::default()
579        }
580    }
581    /// Set the base colour factor.
582    pub fn base_color(mut self, r: f32, g: f32, b: f32, a: f32) -> Self {
583        self.base_color_factor = [r, g, b, a];
584        self
585    }
586    /// Set metallic and roughness factors.
587    pub fn metallic_roughness(mut self, metallic: f32, roughness: f32) -> Self {
588        self.metallic_factor = metallic;
589        self.roughness_factor = roughness;
590        self
591    }
592    /// Set emissive factor.
593    pub fn emissive(mut self, r: f32, g: f32, b: f32) -> Self {
594        self.emissive_factor = [r, g, b];
595        self
596    }
597    /// Enable double-sided rendering.
598    pub fn double_sided(mut self, ds: bool) -> Self {
599        self.double_sided = ds;
600        self
601    }
602    /// Set alpha mode.
603    pub fn alpha_mode(mut self, mode: impl Into<String>) -> Self {
604        self.alpha_mode = mode.into();
605        self
606    }
607    /// Build as a JSON string (glTF material object).
608    pub fn to_json(&self) -> String {
609        let bc = &self.base_color_factor;
610        let em = &self.emissive_factor;
611        format!(
612            r#"{{
613  "name": "{name}",
614  "pbrMetallicRoughness": {{
615    "baseColorFactor": [{r:.6}, {g:.6}, {b:.6}, {a:.6}],
616    "metallicFactor": {mf:.6},
617    "roughnessFactor": {rf:.6}
618  }},
619  "emissiveFactor": [{er:.6}, {eg:.6}, {eb:.6}],
620  "doubleSided": {ds},
621  "alphaMode": "{am}",
622  "alphaCutoff": {ac:.6}
623}}"#,
624            name = self.name,
625            r = bc[0],
626            g = bc[1],
627            b = bc[2],
628            a = bc[3],
629            mf = self.metallic_factor,
630            rf = self.roughness_factor,
631            er = em[0],
632            eg = em[1],
633            eb = em[2],
634            ds = self.double_sided,
635            am = self.alpha_mode,
636            ac = self.alpha_cutoff,
637        )
638    }
639    /// Convert to a [`GltfMaterial`] instance (for use in a [`GltfScene`]).
640    pub fn build(&self) -> GltfMaterial {
641        GltfMaterial {
642            name: self.name.clone(),
643            base_color_factor: self.base_color_factor,
644            metallic_factor: self.metallic_factor,
645            roughness_factor: self.roughness_factor,
646            emissive_factor: self.emissive_factor,
647            double_sided: self.double_sided,
648            alpha_mode: self.alpha_mode.clone(),
649            alpha_cutoff: self.alpha_cutoff,
650        }
651    }
652}
653/// Builder for a single animation channel (translation / rotation / scale).
654#[allow(dead_code)]
655pub struct AnimationChannelBuilder {
656    /// Target node index.
657    pub node_index: usize,
658    /// Target path: "translation", "rotation", or "scale".
659    pub path: String,
660    /// Interpolation mode.
661    pub interpolation: Interpolation,
662    /// Ordered keyframes (should be sorted by time).
663    pub keyframes: Vec<Keyframe>,
664}
665impl AnimationChannelBuilder {
666    /// Create a new channel builder.
667    pub fn new(node_index: usize, path: impl Into<String>, interpolation: Interpolation) -> Self {
668        Self {
669            node_index,
670            path: path.into(),
671            interpolation,
672            keyframes: Vec::new(),
673        }
674    }
675    /// Append a keyframe.
676    pub fn push(mut self, time: f32, value: Vec<f32>) -> Self {
677        self.keyframes.push(Keyframe::new(time, value));
678        self
679    }
680    /// Duration of the channel (time of the last keyframe, or 0 if empty).
681    pub fn duration(&self) -> f32 {
682        self.keyframes.last().map(|k| k.time).unwrap_or(0.0)
683    }
684    /// Number of keyframes.
685    pub fn len(&self) -> usize {
686        self.keyframes.len()
687    }
688    /// True if there are no keyframes.
689    pub fn is_empty(&self) -> bool {
690        self.keyframes.is_empty()
691    }
692    /// Collect all time stamps.
693    pub fn times(&self) -> Vec<f32> {
694        self.keyframes.iter().map(|k| k.time).collect()
695    }
696    /// Collect all value payloads flattened into one Vec.
697    pub fn values_flat(&self) -> Vec<f32> {
698        self.keyframes
699            .iter()
700            .flat_map(|k| k.value.iter().copied())
701            .collect()
702    }
703    /// Serialize sampler + channel pair to JSON fragments.
704    pub fn to_json_fragments(
705        &self,
706        sampler_index: usize,
707        times_accessor: usize,
708        values_accessor: usize,
709    ) -> (String, String) {
710        let sampler = format!(
711            r#"{{ "input": {ti}, "interpolation": "{interp}", "output": {vi} }}"#,
712            ti = times_accessor,
713            interp = self.interpolation.as_str(),
714            vi = values_accessor,
715        );
716        let channel = format!(
717            r#"{{ "sampler": {si}, "target": {{ "node": {ni}, "path": "{path}" }} }}"#,
718            si = sampler_index,
719            ni = self.node_index,
720            path = self.path,
721        );
722        (sampler, channel)
723    }
724}
725/// A named animation containing multiple channels.
726#[derive(Debug, Clone)]
727pub struct GltfAnimation {
728    /// Animation name.
729    pub name: String,
730    /// Animation channels.
731    pub channels: Vec<GltfAnimationChannel>,
732}
733impl GltfAnimation {
734    /// Total duration of the animation (max input time across all channels).
735    pub fn duration(&self) -> f32 {
736        self.channels
737            .iter()
738            .filter_map(|ch| ch.input_times.last().copied())
739            .fold(0.0f32, f32::max)
740    }
741    /// Total number of keyframes across all channels.
742    pub fn total_keyframe_count(&self) -> usize {
743        self.channels.iter().map(|ch| ch.input_times.len()).sum()
744    }
745}
746/// A single keyframe with a time stamp and a value payload.
747#[allow(dead_code)]
748#[derive(Debug, Clone)]
749pub struct Keyframe {
750    /// Time in seconds.
751    pub time: f32,
752    /// Value payload (length depends on the target path).
753    pub value: Vec<f32>,
754}
755impl Keyframe {
756    /// Create a new keyframe.
757    pub fn new(time: f32, value: Vec<f32>) -> Self {
758        Self { time, value }
759    }
760}
761/// An accessor describes how to read typed data from a buffer view.
762#[derive(Debug, Clone)]
763pub struct GltfAccessor {
764    /// Index into the buffer views array.
765    pub buffer_view: usize,
766    /// Byte offset within the buffer view.
767    pub byte_offset: usize,
768    /// Component type (5126 = FLOAT, 5123 = UNSIGNED_SHORT, 5125 = UNSIGNED_INT).
769    pub component_type: u32,
770    /// Number of elements.
771    pub count: usize,
772    /// Type string ("SCALAR", "VEC2", "VEC3", "VEC4", "MAT4").
773    pub element_type: String,
774}
775impl GltfAccessor {
776    /// Number of components per element.
777    pub fn components_per_element(&self) -> usize {
778        match self.element_type.as_str() {
779            "SCALAR" => 1,
780            "VEC2" => 2,
781            "VEC3" => 3,
782            "VEC4" => 4,
783            "MAT2" => 4,
784            "MAT3" => 9,
785            "MAT4" => 16,
786            _ => 1,
787        }
788    }
789    /// Size of a single component in bytes.
790    pub fn component_size(&self) -> usize {
791        match self.component_type {
792            5120 => 1,
793            5121 => 1,
794            5122 => 2,
795            5123 => 2,
796            5125 => 4,
797            5126 => 4,
798            _ => 4,
799        }
800    }
801    /// Total byte length of data described by this accessor.
802    pub fn byte_length(&self) -> usize {
803        self.count * self.components_per_element() * self.component_size()
804    }
805}
806/// A morph target (blend shape) that stores position deltas.
807#[derive(Debug, Clone)]
808pub struct MorphTarget {
809    /// Human-readable name of the morph target.
810    pub name: String,
811    /// Per-vertex position deltas applied at weight = 1.0.
812    pub position_deltas: Vec<[f32; 3]>,
813}
814impl MorphTarget {
815    /// Apply this morph target to a set of base positions with the given weight.
816    ///
817    /// Returns a new `Vec` with blended positions.
818    pub fn apply(&self, base_positions: &[[f32; 3]], weight: f32) -> Vec<[f32; 3]> {
819        let n = base_positions.len().min(self.position_deltas.len());
820        let mut out = base_positions.to_vec();
821        for i in 0..n {
822            out[i][0] += self.position_deltas[i][0] * weight;
823            out[i][1] += self.position_deltas[i][1] * weight;
824            out[i][2] += self.position_deltas[i][2] * weight;
825        }
826        out
827    }
828}
829/// A glTF 2.0 scene: a collection of nodes and meshes.
830pub struct GltfScene {
831    /// Scene nodes.
832    pub nodes: Vec<GltfNode>,
833    /// Scene meshes.
834    pub meshes: Vec<GltfMesh>,
835    /// Accessors.
836    pub accessors: Vec<GltfAccessor>,
837    /// Buffer views.
838    pub buffer_views: Vec<GltfBufferView>,
839    /// Animations.
840    pub animations: Vec<GltfAnimation>,
841    /// Materials.
842    pub materials: Vec<GltfMaterial>,
843    /// Cameras.
844    pub cameras: Vec<SceneCamera>,
845    /// Lights (KHR_lights_punctual).
846    pub lights: Vec<SceneLight>,
847}
848impl GltfScene {
849    /// Create an empty scene.
850    pub fn new() -> Self {
851        GltfScene {
852            nodes: Vec::new(),
853            meshes: Vec::new(),
854            accessors: Vec::new(),
855            buffer_views: Vec::new(),
856            animations: Vec::new(),
857            materials: Vec::new(),
858            cameras: Vec::new(),
859            lights: Vec::new(),
860        }
861    }
862    /// Add a camera and return its index.
863    pub fn add_camera(&mut self, cam: SceneCamera) -> usize {
864        let idx = self.cameras.len();
865        self.cameras.push(cam);
866        idx
867    }
868    /// Add a light and return its index.
869    pub fn add_light(&mut self, light: SceneLight) -> usize {
870        let idx = self.lights.len();
871        self.lights.push(light);
872        idx
873    }
874    /// Serialize the scene as a glTF 2.0 JSON string including cameras and lights.
875    ///
876    /// Cameras are written in the `cameras` array.
877    /// Lights use the `KHR_lights_punctual` extension.
878    pub fn to_json_with_hierarchy(&self) -> String {
879        let mut out = self.to_json();
880        if out.ends_with('}') {
881            out.pop();
882        }
883        out.push_str(",\n");
884        if !self.cameras.is_empty() {
885            out.push_str("  \"cameras\": [\n");
886            for (ci, cam) in self.cameras.iter().enumerate() {
887                out.push_str("    {\n");
888                out.push_str(&format!(
889                    "      \"name\": \"{}\",\n",
890                    escape_json(&cam.name)
891                ));
892                match &cam.camera_type {
893                    CameraType::Perspective {
894                        yfov,
895                        znear,
896                        zfar,
897                        aspect_ratio,
898                    } => {
899                        out.push_str("      \"type\": \"perspective\",\n");
900                        out.push_str("      \"perspective\": {\n");
901                        out.push_str(&format!("        \"yfov\": {},\n", yfov));
902                        out.push_str(&format!("        \"znear\": {}", znear));
903                        if let Some(zf) = zfar {
904                            out.push_str(&format!(",\n        \"zfar\": {}", zf));
905                        }
906                        if let Some(ar) = aspect_ratio {
907                            out.push_str(&format!(",\n        \"aspectRatio\": {}", ar));
908                        }
909                        out.push_str("\n      }\n");
910                    }
911                    CameraType::Orthographic {
912                        xmag,
913                        ymag,
914                        znear,
915                        zfar,
916                    } => {
917                        out.push_str("      \"type\": \"orthographic\",\n");
918                        out.push_str("      \"orthographic\": {\n");
919                        out.push_str(&format!("        \"xmag\": {},\n", xmag));
920                        out.push_str(&format!("        \"ymag\": {},\n", ymag));
921                        out.push_str(&format!("        \"znear\": {},\n", znear));
922                        out.push_str(&format!("        \"zfar\": {}\n", zfar));
923                        out.push_str("      }\n");
924                    }
925                }
926                if ci + 1 < self.cameras.len() {
927                    out.push_str("    },\n");
928                } else {
929                    out.push_str("    }\n");
930                }
931            }
932            out.push_str("  ],\n");
933        }
934        if !self.lights.is_empty() {
935            out.push_str("  \"extensions\": {\n");
936            out.push_str("    \"KHR_lights_punctual\": {\n");
937            out.push_str("      \"lights\": [\n");
938            for (li, light) in self.lights.iter().enumerate() {
939                out.push_str("        {\n");
940                out.push_str(&format!(
941                    "          \"name\": \"{}\",\n",
942                    escape_json(&light.name)
943                ));
944                out.push_str(&format!(
945                    "          \"type\": \"{}\",\n",
946                    light.type_string()
947                ));
948                out.push_str(&format!(
949                    "          \"color\": [{}, {}, {}],\n",
950                    light.color[0], light.color[1], light.color[2]
951                ));
952                out.push_str(&format!("          \"intensity\": {}", light.intensity));
953                if let LightType::Spot {
954                    inner_cone_angle,
955                    outer_cone_angle,
956                } = &light.light_type
957                {
958                    out.push_str(
959                        &format!(
960                            ",\n          \"spot\": {{ \"innerConeAngle\": {}, \"outerConeAngle\": {} }}",
961                            inner_cone_angle, outer_cone_angle
962                        ),
963                    );
964                }
965                out.push('\n');
966                if li + 1 < self.lights.len() {
967                    out.push_str("        },\n");
968                } else {
969                    out.push_str("        }\n");
970                }
971            }
972            out.push_str("      ]\n");
973            out.push_str("    }\n");
974            out.push_str("  }\n");
975        } else if out.ends_with(",\n") {
976            out.truncate(out.len() - 2);
977            out.push('\n');
978        }
979        out.push('}');
980        out
981    }
982    /// Add a mesh to the scene and return its index.
983    pub fn add_mesh(&mut self, mesh: GltfMesh) -> usize {
984        let idx = self.meshes.len();
985        self.meshes.push(mesh);
986        idx
987    }
988    /// Add a node to the scene.
989    pub fn add_node(&mut self, node: GltfNode) {
990        self.nodes.push(node);
991    }
992    /// Return the number of meshes in the scene.
993    pub fn mesh_count(&self) -> usize {
994        self.meshes.len()
995    }
996    /// Return the number of nodes in the scene.
997    pub fn node_count(&self) -> usize {
998        self.nodes.len()
999    }
1000    /// Traverse the scene graph depth-first, calling `visitor` with
1001    /// `(node_index, depth, accumulated_translation)` for each node.
1002    pub fn traverse_depth_first<F>(&self, mut visitor: F)
1003    where
1004        F: FnMut(usize, usize, [f64; 3]),
1005    {
1006        let mut is_child = vec![false; self.nodes.len()];
1007        for node in &self.nodes {
1008            for &child_idx in &node.children {
1009                if child_idx < is_child.len() {
1010                    is_child[child_idx] = true;
1011                }
1012            }
1013        }
1014        let mut stack: Vec<(usize, usize, [f64; 3])> = Vec::new();
1015        for i in (0..self.nodes.len()).rev() {
1016            if !is_child[i] {
1017                stack.push((i, 0, [0.0, 0.0, 0.0]));
1018            }
1019        }
1020        while let Some((idx, depth, parent_translation)) = stack.pop() {
1021            if idx >= self.nodes.len() {
1022                continue;
1023            }
1024            let node = &self.nodes[idx];
1025            let accumulated = [
1026                parent_translation[0] + node.translation[0],
1027                parent_translation[1] + node.translation[1],
1028                parent_translation[2] + node.translation[2],
1029            ];
1030            visitor(idx, depth, accumulated);
1031            for &child_idx in node.children.iter().rev() {
1032                stack.push((child_idx, depth + 1, accumulated));
1033            }
1034        }
1035    }
1036    /// Collect all mesh primitives in the scene with their associated node names.
1037    pub fn collect_mesh_primitives(&self) -> Vec<(&str, &GltfPrimitive)> {
1038        let mut result = Vec::new();
1039        for node in &self.nodes {
1040            if let Some(mesh_idx) = node.mesh
1041                && let Some(mesh) = self.meshes.get(mesh_idx)
1042            {
1043                for prim in &mesh.primitives {
1044                    result.push((node.name.as_str(), prim));
1045                }
1046            }
1047        }
1048        result
1049    }
1050    /// Add a material and return its index.
1051    pub fn add_material(&mut self, mat: GltfMaterial) -> usize {
1052        let idx = self.materials.len();
1053        self.materials.push(mat);
1054        idx
1055    }
1056    /// Add an animation and return its index.
1057    pub fn add_animation(&mut self, anim: GltfAnimation) -> usize {
1058        let idx = self.animations.len();
1059        self.animations.push(anim);
1060        idx
1061    }
1062    /// Find all nodes that reference a given mesh index.
1063    pub fn nodes_using_mesh(&self, mesh_idx: usize) -> Vec<usize> {
1064        self.nodes
1065            .iter()
1066            .enumerate()
1067            .filter_map(|(i, n)| {
1068                if n.mesh == Some(mesh_idx) {
1069                    Some(i)
1070                } else {
1071                    None
1072                }
1073            })
1074            .collect()
1075    }
1076    /// Return the total vertex count across all meshes.
1077    pub fn total_vertex_count(&self) -> usize {
1078        self.meshes.iter().map(|m| m.total_vertex_count()).sum()
1079    }
1080    /// Return the total triangle count across all meshes.
1081    pub fn total_triangle_count(&self) -> usize {
1082        self.meshes.iter().map(|m| m.total_triangle_count()).sum()
1083    }
1084    /// Serialize the scene as a glTF 2.0 JSON string.
1085    ///
1086    /// Accessor and bufferView entries are written as stubs (no binary buffer).
1087    pub fn to_json(&self) -> String {
1088        let mut out = String::new();
1089        out.push_str("{\n");
1090        out.push_str("  \"asset\": {\n");
1091        out.push_str("    \"version\": \"2.0\",\n");
1092        out.push_str("    \"generator\": \"OxiPhysics glTF writer\"\n");
1093        out.push_str("  },\n");
1094        out.push_str("  \"scene\": 0,\n");
1095        let node_indices: Vec<String> = (0..self.nodes.len()).map(|i| i.to_string()).collect();
1096        out.push_str("  \"scenes\": [\n    {\n      \"nodes\": [");
1097        out.push_str(&node_indices.join(", "));
1098        out.push_str("]\n    }\n  ],\n");
1099        out.push_str("  \"nodes\": [\n");
1100        for (i, node) in self.nodes.iter().enumerate() {
1101            out.push_str("    {\n");
1102            out.push_str(&format!(
1103                "      \"name\": \"{}\",\n",
1104                escape_json(&node.name)
1105            ));
1106            if let Some(mesh_idx) = node.mesh {
1107                out.push_str(&format!("      \"mesh\": {},\n", mesh_idx));
1108            }
1109            if !node.children.is_empty() {
1110                let children_str: Vec<String> =
1111                    node.children.iter().map(|c| c.to_string()).collect();
1112                out.push_str(&format!(
1113                    "      \"children\": [{}],\n",
1114                    children_str.join(", ")
1115                ));
1116            }
1117            out.push_str(&format!(
1118                "      \"translation\": [{}, {}, {}],\n",
1119                node.translation[0], node.translation[1], node.translation[2]
1120            ));
1121            out.push_str(&format!(
1122                "      \"rotation\": [{}, {}, {}, {}],\n",
1123                node.rotation[0], node.rotation[1], node.rotation[2], node.rotation[3]
1124            ));
1125            out.push_str(&format!(
1126                "      \"scale\": [{}, {}, {}]\n",
1127                node.scale[0], node.scale[1], node.scale[2]
1128            ));
1129            if i + 1 < self.nodes.len() {
1130                out.push_str("    },\n");
1131            } else {
1132                out.push_str("    }\n");
1133            }
1134        }
1135        out.push_str("  ],\n");
1136        out.push_str("  \"meshes\": [\n");
1137        for (mi, mesh) in self.meshes.iter().enumerate() {
1138            out.push_str("    {\n");
1139            out.push_str(&format!(
1140                "      \"name\": \"{}\",\n",
1141                escape_json(&mesh.name)
1142            ));
1143            out.push_str("      \"primitives\": [\n");
1144            for (pi, _prim) in mesh.primitives.iter().enumerate() {
1145                let pos_acc = pi * 3;
1146                let nrm_acc = pi * 3 + 1;
1147                let idx_acc = pi * 3 + 2;
1148                out.push_str("        {\n");
1149                out.push_str("          \"attributes\": {\n");
1150                out.push_str(&format!("            \"POSITION\": {},\n", pos_acc));
1151                out.push_str(&format!("            \"NORMAL\": {}\n", nrm_acc));
1152                out.push_str("          },\n");
1153                out.push_str(&format!("          \"indices\": {}\n", idx_acc));
1154                if pi + 1 < mesh.primitives.len() {
1155                    out.push_str("        },\n");
1156                } else {
1157                    out.push_str("        }\n");
1158                }
1159            }
1160            out.push_str("      ]\n");
1161            if mi + 1 < self.meshes.len() {
1162                out.push_str("    },\n");
1163            } else {
1164                out.push_str("    }\n");
1165            }
1166        }
1167        out.push_str("  ],\n");
1168        if !self.materials.is_empty() {
1169            out.push_str("  \"materials\": [\n");
1170            for (mi, mat) in self.materials.iter().enumerate() {
1171                out.push_str("    {\n");
1172                out.push_str(&format!(
1173                    "      \"name\": \"{}\",\n",
1174                    escape_json(&mat.name)
1175                ));
1176                out.push_str("      \"pbrMetallicRoughness\": {\n");
1177                out.push_str(&format!(
1178                    "        \"baseColorFactor\": [{}, {}, {}, {}],\n",
1179                    mat.base_color_factor[0],
1180                    mat.base_color_factor[1],
1181                    mat.base_color_factor[2],
1182                    mat.base_color_factor[3]
1183                ));
1184                out.push_str(&format!(
1185                    "        \"metallicFactor\": {},\n",
1186                    mat.metallic_factor
1187                ));
1188                out.push_str(&format!(
1189                    "        \"roughnessFactor\": {}\n",
1190                    mat.roughness_factor
1191                ));
1192                out.push_str("      },\n");
1193                out.push_str(&format!(
1194                    "      \"emissiveFactor\": [{}, {}, {}],\n",
1195                    mat.emissive_factor[0], mat.emissive_factor[1], mat.emissive_factor[2]
1196                ));
1197                out.push_str(&format!("      \"alphaMode\": \"{}\",\n", mat.alpha_mode));
1198                out.push_str(&format!("      \"doubleSided\": {}\n", mat.double_sided));
1199                if mi + 1 < self.materials.len() {
1200                    out.push_str("    },\n");
1201                } else {
1202                    out.push_str("    }\n");
1203                }
1204            }
1205            out.push_str("  ],\n");
1206        }
1207        out.push_str("  \"accessors\": [],\n");
1208        out.push_str("  \"bufferViews\": [],\n");
1209        out.push_str("  \"buffers\": []\n");
1210        out.push('}');
1211        out
1212    }
1213}
1214/// A mesh primitive extended with morph targets and blend weights.
1215pub struct MorphPrimitive {
1216    /// The base geometry.
1217    pub base: GltfPrimitive,
1218    /// List of morph targets.
1219    pub targets: Vec<MorphTarget>,
1220    /// Current blend weights (one per target).
1221    pub weights: Vec<f32>,
1222}
1223impl MorphPrimitive {
1224    /// Set the blend weight for a given target index (clamped to \[0, 1\]).
1225    pub fn set_weight(&mut self, target_idx: usize, weight: f32) {
1226        if target_idx < self.weights.len() {
1227            self.weights[target_idx] = weight.clamp(0.0, 1.0);
1228        }
1229    }
1230    /// Compute the blended positions using all target weights.
1231    pub fn blend(&self) -> Vec<[f32; 3]> {
1232        let mut result = self.base.positions.clone();
1233        for (target, &w) in self.targets.iter().zip(self.weights.iter()) {
1234            let n = result.len().min(target.position_deltas.len());
1235            for i in 0..n {
1236                result[i][0] += target.position_deltas[i][0] * w;
1237                result[i][1] += target.position_deltas[i][1] * w;
1238                result[i][2] += target.position_deltas[i][2] * w;
1239            }
1240        }
1241        result
1242    }
1243}
1244/// Writer that serialises a `GltfScene` into the GLB binary format.
1245///
1246/// GLB layout:
1247/// - 12-byte header: magic (4), version (4), total_length (4)
1248/// - JSON chunk:     chunk_length (4), chunk_type "JSON" (4), JSON payload (aligned to 4 bytes)
1249/// - BIN chunk (if any binary data): chunk_length (4), chunk_type "BIN\0" (4), data
1250pub struct GlbWriter {
1251    /// Whether to include a (empty) BIN chunk even when there is no binary data.
1252    pub include_empty_bin: bool,
1253}
1254impl GlbWriter {
1255    /// Create a new `GlbWriter`.
1256    pub fn new() -> Self {
1257        GlbWriter {
1258            include_empty_bin: false,
1259        }
1260    }
1261
1262    /// Serialise the scene into GLB bytes (JSON-only, no vertex binary data).
1263    ///
1264    /// For a full binary GLB with packed vertex data use [`write_glb`](Self::write_glb).
1265    pub fn write(&self, scene: &GltfScene) -> Vec<u8> {
1266        let json = scene.to_json();
1267        let json_bytes = json.as_bytes();
1268        let json_len = json_bytes.len();
1269        let json_padded_len = (json_len + 3) & !3;
1270        let json_padding = json_padded_len - json_len;
1271        let chunk_header_size = 8usize;
1272        let header_size = 12usize;
1273        let total_len = header_size
1274            + chunk_header_size
1275            + json_padded_len
1276            + if self.include_empty_bin {
1277                chunk_header_size
1278            } else {
1279                0
1280            };
1281        let mut out = Vec::with_capacity(total_len);
1282        out.extend_from_slice(b"glTF");
1283        out.extend_from_slice(&2u32.to_le_bytes());
1284        out.extend_from_slice(&(total_len as u32).to_le_bytes());
1285        out.extend_from_slice(&(json_padded_len as u32).to_le_bytes());
1286        out.extend_from_slice(&0x4E4F534Au32.to_le_bytes());
1287        out.extend_from_slice(json_bytes);
1288        #[allow(clippy::same_item_push)]
1289        for _ in 0..json_padding {
1290            out.push(0x20);
1291        }
1292        if self.include_empty_bin {
1293            out.extend_from_slice(&0u32.to_le_bytes());
1294            out.extend_from_slice(&0x004E4942u32.to_le_bytes());
1295        }
1296        out
1297    }
1298
1299    /// Serialise the first mesh primitive in the scene as a proper glTF 2.0 Binary (GLB) file.
1300    ///
1301    /// GLB binary layout:
1302    /// 1. **12-byte header**: magic `glTF`, version=2, total_length
1303    /// 2. **JSON chunk**: chunk_length (u32 LE), chunk_type=`JSON` (0x4E4F534A), padded UTF-8 JSON
1304    /// 3. **BIN chunk**: chunk_length (u32 LE), chunk_type=`BIN\0` (0x004E4942), vertex/index data
1305    ///
1306    /// Binary buffer layout (flat, tightly packed, index buffer 4-byte aligned):
1307    /// - `positions` : `[f32; 3]` × n_vertices
1308    /// - `normals`   : `[f32; 3]` × n_vertices
1309    /// - `texcoords` : `[f32; 2]` × n_vertices (zero-filled if `primitive.texcoords` is empty)
1310    /// - `indices`   : `u32` × n_indices (offset aligned to 4 bytes)
1311    ///
1312    /// Returns an empty `Vec<u8>` if the scene has no meshes.
1313    pub fn write_glb(&self, scene: &GltfScene) -> Vec<u8> {
1314        // Collect all primitives across all meshes.
1315        let primitives: Vec<&GltfPrimitive> = scene
1316            .meshes
1317            .iter()
1318            .flat_map(|m| m.primitives.iter())
1319            .collect();
1320
1321        if primitives.is_empty() {
1322            return Vec::new();
1323        }
1324
1325        // Build flat binary buffer: positions | normals | texcoords | (align) | indices
1326        let mut bin_buf: Vec<u8> = Vec::new();
1327        let mut prim_meta: Vec<PrimMeta> = Vec::with_capacity(primitives.len());
1328
1329        for prim in &primitives {
1330            let n_verts = prim.positions.len();
1331            let n_indices = prim.indices.len();
1332
1333            // Compute bounding box for POSITION accessor.
1334            let mut pos_min = [f32::INFINITY; 3];
1335            let mut pos_max = [f32::NEG_INFINITY; 3];
1336            for p in &prim.positions {
1337                for i in 0..3 {
1338                    if p[i] < pos_min[i] {
1339                        pos_min[i] = p[i];
1340                    }
1341                    if p[i] > pos_max[i] {
1342                        pos_max[i] = p[i];
1343                    }
1344                }
1345            }
1346            // Fallback for empty primitives.
1347            if n_verts == 0 {
1348                pos_min = [0.0; 3];
1349                pos_max = [0.0; 3];
1350            }
1351
1352            let pos_byte_offset = bin_buf.len();
1353            for p in &prim.positions {
1354                for &v in p {
1355                    bin_buf.extend_from_slice(&v.to_le_bytes());
1356                }
1357            }
1358
1359            let norm_byte_offset = bin_buf.len();
1360            for n in &prim.normals {
1361                for &v in n {
1362                    bin_buf.extend_from_slice(&v.to_le_bytes());
1363                }
1364            }
1365            // Pad with zero normals if fewer normals than positions.
1366            let norm_count = prim.normals.len();
1367            for _ in norm_count..n_verts {
1368                bin_buf.extend_from_slice(&[0u8; 12]);
1369            }
1370
1371            let uv_byte_offset = bin_buf.len();
1372            if prim.texcoords.is_empty() {
1373                // Write zero UVs.
1374                for _ in 0..n_verts {
1375                    bin_buf.extend_from_slice(&[0u8; 8]);
1376                }
1377            } else {
1378                for uv in &prim.texcoords {
1379                    for &v in uv {
1380                        bin_buf.extend_from_slice(&v.to_le_bytes());
1381                    }
1382                }
1383                // Pad with zeros if fewer UVs than vertices.
1384                let uv_count = prim.texcoords.len();
1385                for _ in uv_count..n_verts {
1386                    bin_buf.extend_from_slice(&[0u8; 8]);
1387                }
1388            }
1389
1390            // Align index buffer to 4-byte boundary.
1391            let pad_to_align = (4 - bin_buf.len() % 4) % 4;
1392            bin_buf.extend(std::iter::repeat_n(0u8, pad_to_align));
1393            let idx_byte_offset = bin_buf.len();
1394            for &idx in &prim.indices {
1395                bin_buf.extend_from_slice(&idx.to_le_bytes());
1396            }
1397
1398            prim_meta.push(PrimMeta {
1399                n_verts,
1400                n_indices,
1401                pos_byte_offset,
1402                norm_byte_offset,
1403                uv_byte_offset,
1404                idx_byte_offset,
1405                pos_min,
1406                pos_max,
1407            });
1408        }
1409
1410        // Pad binary buffer to 4-byte boundary.
1411        let bin_tail_pad = (4 - bin_buf.len() % 4) % 4;
1412        bin_buf.extend(std::iter::repeat_n(0u8, bin_tail_pad));
1413        let total_bin_len = bin_buf.len();
1414
1415        // Build JSON.
1416        let json = Self::build_glb_json(scene, &prim_meta, total_bin_len);
1417        let json_bytes = json.as_bytes();
1418        let json_raw_len = json_bytes.len();
1419        let json_padded_len = (json_raw_len + 3) & !3;
1420        let json_padding = json_padded_len - json_raw_len;
1421
1422        // GLB: 12-byte header + 8-byte JSON chunk header + JSON + 8-byte BIN chunk header + BIN
1423        let total_len = 12 + 8 + json_padded_len + 8 + total_bin_len;
1424
1425        let mut out = Vec::with_capacity(total_len);
1426        // Header
1427        out.extend_from_slice(b"glTF");
1428        out.extend_from_slice(&2u32.to_le_bytes());
1429        out.extend_from_slice(&(total_len as u32).to_le_bytes());
1430        // JSON chunk: pad with spaces (0x20) per glTF spec
1431        out.extend_from_slice(&(json_padded_len as u32).to_le_bytes());
1432        out.extend_from_slice(&0x4E4F534Au32.to_le_bytes()); // "JSON"
1433        out.extend_from_slice(json_bytes);
1434        out.extend(std::iter::repeat_n(0x20u8, json_padding));
1435        // BIN chunk
1436        out.extend_from_slice(&(total_bin_len as u32).to_le_bytes());
1437        out.extend_from_slice(&0x004E4942u32.to_le_bytes()); // "BIN\0"
1438        out.extend_from_slice(&bin_buf);
1439
1440        out
1441    }
1442
1443    /// Build the glTF JSON for a GLB with a binary buffer.
1444    fn build_glb_json(scene: &GltfScene, prim_meta: &[PrimMeta], total_bin_len: usize) -> String {
1445        use std::fmt::Write as FmtWrite;
1446        let mut out = String::new();
1447
1448        let _ = writeln!(out, "{{");
1449        let _ = writeln!(
1450            out,
1451            "  \"asset\": {{\"version\": \"2.0\", \"generator\": \"OxiPhysics glTF writer\"}},"
1452        );
1453        let _ = writeln!(out, "  \"scene\": 0,");
1454
1455        // Scenes
1456        let node_indices: Vec<String> = (0..scene.nodes.len()).map(|i| i.to_string()).collect();
1457        let _ = writeln!(
1458            out,
1459            "  \"scenes\": [{{\"nodes\": [{}]}}],",
1460            node_indices.join(", ")
1461        );
1462
1463        // Nodes
1464        let _ = writeln!(out, "  \"nodes\": [");
1465        for (i, node) in scene.nodes.iter().enumerate() {
1466            let mesh_str = node
1467                .mesh
1468                .map(|m| format!(", \"mesh\": {m}"))
1469                .unwrap_or_default();
1470            let comma = if i + 1 < scene.nodes.len() { "," } else { "" };
1471            let _ = writeln!(
1472                out,
1473                "    {{\"name\": \"{}\"{mesh_str}}}{comma}",
1474                escape_json(&node.name)
1475            );
1476        }
1477        let _ = writeln!(out, "  ],");
1478
1479        // Meshes
1480        let _ = writeln!(out, "  \"meshes\": [");
1481        let mut acc_idx = 0usize;
1482        for (mi, mesh) in scene.meshes.iter().enumerate() {
1483            let _ = writeln!(
1484                out,
1485                "    {{\"name\": \"{}\", \"primitives\": [",
1486                escape_json(&mesh.name)
1487            );
1488            for (pi, _prim) in mesh.primitives.iter().enumerate() {
1489                let pos_acc = acc_idx;
1490                let nrm_acc = acc_idx + 1;
1491                let uv_acc = acc_idx + 2;
1492                let idx_acc = acc_idx + 3;
1493                acc_idx += 4;
1494                let prim_comma = if pi + 1 < mesh.primitives.len() {
1495                    ","
1496                } else {
1497                    ""
1498                };
1499                let _ = writeln!(
1500                    out,
1501                    "      {{\"attributes\": {{\"POSITION\": {pos_acc}, \"NORMAL\": {nrm_acc}, \"TEXCOORD_0\": {uv_acc}}}, \"indices\": {idx_acc}}}{prim_comma}"
1502                );
1503            }
1504            let mesh_comma = if mi + 1 < scene.meshes.len() { "," } else { "" };
1505            let _ = writeln!(out, "    ]}}{mesh_comma}");
1506        }
1507        let _ = writeln!(out, "  ],");
1508
1509        // Buffers
1510        let _ = writeln!(out, "  \"buffers\": [{{\"byteLength\": {total_bin_len}}}],");
1511
1512        // BufferViews: collect all as strings then join
1513        let n_meta = prim_meta.len();
1514        let total_bv = n_meta * 4;
1515        let mut bv_entries: Vec<String> = Vec::with_capacity(total_bv);
1516        for meta in prim_meta.iter() {
1517            let pos_size = meta.n_verts * 12; // [f32;3] = 12 bytes
1518            let norm_size = meta.n_verts * 12;
1519            let uv_size = meta.n_verts * 8; // [f32;2] = 8 bytes
1520            let idx_size = meta.n_indices * 4; // u32 = 4 bytes
1521            bv_entries.push(format!(
1522                "    {{\"buffer\": 0, \"byteOffset\": {}, \"byteLength\": {pos_size}, \"target\": 34962}}",
1523                meta.pos_byte_offset
1524            ));
1525            bv_entries.push(format!(
1526                "    {{\"buffer\": 0, \"byteOffset\": {}, \"byteLength\": {norm_size}, \"target\": 34962}}",
1527                meta.norm_byte_offset
1528            ));
1529            bv_entries.push(format!(
1530                "    {{\"buffer\": 0, \"byteOffset\": {}, \"byteLength\": {uv_size}, \"target\": 34962}}",
1531                meta.uv_byte_offset
1532            ));
1533            bv_entries.push(format!(
1534                "    {{\"buffer\": 0, \"byteOffset\": {}, \"byteLength\": {idx_size}, \"target\": 34963}}",
1535                meta.idx_byte_offset
1536            ));
1537        }
1538        let _ = writeln!(out, "  \"bufferViews\": [");
1539        let _ = writeln!(out, "{}", bv_entries.join(",\n"));
1540        let _ = writeln!(out, "  ],");
1541
1542        // Accessors: collect then join
1543        let total_acc = n_meta * 4;
1544        let mut acc_entries: Vec<String> = Vec::with_capacity(total_acc);
1545        let mut bv_idx = 0usize;
1546        for meta in prim_meta.iter() {
1547            let pos_min = meta.pos_min;
1548            let pos_max = meta.pos_max;
1549            // POSITION accessor (with min/max bounds)
1550            acc_entries.push(format!(
1551                "    {{\"bufferView\": {bv_idx}, \"componentType\": 5126, \"count\": {}, \"type\": \"VEC3\", \
1552\"min\": [{}, {}, {}], \"max\": [{}, {}, {}]}}",
1553                meta.n_verts,
1554                pos_min[0], pos_min[1], pos_min[2],
1555                pos_max[0], pos_max[1], pos_max[2]
1556            ));
1557            bv_idx += 1;
1558            // NORMAL accessor
1559            acc_entries.push(format!(
1560                "    {{\"bufferView\": {bv_idx}, \"componentType\": 5126, \"count\": {}, \"type\": \"VEC3\"}}",
1561                meta.n_verts
1562            ));
1563            bv_idx += 1;
1564            // TEXCOORD_0 accessor
1565            acc_entries.push(format!(
1566                "    {{\"bufferView\": {bv_idx}, \"componentType\": 5126, \"count\": {}, \"type\": \"VEC2\"}}",
1567                meta.n_verts
1568            ));
1569            bv_idx += 1;
1570            // INDEX accessor
1571            acc_entries.push(format!(
1572                "    {{\"bufferView\": {bv_idx}, \"componentType\": 5125, \"count\": {}, \"type\": \"SCALAR\"}}",
1573                meta.n_indices
1574            ));
1575            bv_idx += 1;
1576        }
1577        let _ = writeln!(out, "  \"accessors\": [");
1578        let _ = writeln!(out, "{}", acc_entries.join(",\n"));
1579        let _ = writeln!(out, "  ]");
1580
1581        let _ = write!(out, "}}");
1582        out
1583    }
1584}
1585
1586/// Metadata for a single primitive used during GLB JSON generation.
1587struct PrimMeta {
1588    n_verts: usize,
1589    n_indices: usize,
1590    pos_byte_offset: usize,
1591    norm_byte_offset: usize,
1592    uv_byte_offset: usize,
1593    idx_byte_offset: usize,
1594    pos_min: [f32; 3],
1595    pos_max: [f32; 3],
1596}