Skip to main content

oxiphysics_io/gltf/
functions.rs

1//! Auto-generated module
2//!
3//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
4
5#![allow(clippy::type_complexity)]
6use super::types::{GltfScene, ValidationIssue};
7
8/// Escape special characters in a JSON string value.
9pub fn escape_json(s: &str) -> String {
10    s.replace('\\', "\\\\").replace('"', "\\\"")
11}
12/// Decode an array of f32 values from a little-endian byte buffer.
13pub fn decode_f32_le(bytes: &[u8]) -> Vec<f32> {
14    let mut out = Vec::with_capacity(bytes.len() / 4);
15    let mut i = 0;
16    while i + 3 < bytes.len() {
17        let v = f32::from_le_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]);
18        out.push(v);
19        i += 4;
20    }
21    out
22}
23/// Decode an array of u16 values from a little-endian byte buffer.
24pub fn decode_u16_le(bytes: &[u8]) -> Vec<u16> {
25    let mut out = Vec::with_capacity(bytes.len() / 2);
26    let mut i = 0;
27    while i + 1 < bytes.len() {
28        let v = u16::from_le_bytes([bytes[i], bytes[i + 1]]);
29        out.push(v);
30        i += 2;
31    }
32    out
33}
34/// Decode an array of u32 values from a little-endian byte buffer.
35pub fn decode_u32_le(bytes: &[u8]) -> Vec<u32> {
36    let mut out = Vec::with_capacity(bytes.len() / 4);
37    let mut i = 0;
38    while i + 3 < bytes.len() {
39        let v = u32::from_le_bytes([bytes[i], bytes[i + 1], bytes[i + 2], bytes[i + 3]]);
40        out.push(v);
41        i += 4;
42    }
43    out
44}
45/// Decode VEC3 data from a buffer of f32 values.
46pub fn decode_vec3(floats: &[f32]) -> Vec<[f32; 3]> {
47    let mut out = Vec::with_capacity(floats.len() / 3);
48    let mut i = 0;
49    while i + 2 < floats.len() {
50        out.push([floats[i], floats[i + 1], floats[i + 2]]);
51        i += 3;
52    }
53    out
54}
55/// Decode VEC4 data from a buffer of f32 values.
56pub fn decode_vec4(floats: &[f32]) -> Vec<[f32; 4]> {
57    let mut out = Vec::with_capacity(floats.len() / 4);
58    let mut i = 0;
59    while i + 3 < floats.len() {
60        out.push([floats[i], floats[i + 1], floats[i + 2], floats[i + 3]]);
61        i += 4;
62    }
63    out
64}
65/// Write a triangle mesh as a Wavefront OBJ string.
66///
67/// Produces `v` lines for positions, `vn` lines for normals, and `f` lines
68/// using the `v//vn` format (1-indexed). Indices are interpreted as flat
69/// per-vertex data (index i -> positions\[i\] and normals\[i\]).
70pub fn write_obj(positions: &[[f32; 3]], normals: &[[f32; 3]], indices: &[u32]) -> String {
71    let mut out = String::new();
72    out.push_str("# OxiPhysics OBJ export\n");
73    for p in positions {
74        out.push_str(&format!("v {} {} {}\n", p[0], p[1], p[2]));
75    }
76    for n in normals {
77        out.push_str(&format!("vn {} {} {}\n", n[0], n[1], n[2]));
78    }
79    let mut i = 0;
80    while i + 2 < indices.len() {
81        let a = indices[i] + 1;
82        let b = indices[i + 1] + 1;
83        let c = indices[i + 2] + 1;
84        out.push_str(&format!("f {a}//{a} {b}//{b} {c}//{c}\n"));
85        i += 3;
86    }
87    out
88}
89/// Parse a Wavefront OBJ string.
90///
91/// Returns `(positions, normals, indices)`. Only `v`, `vn`, and `f` records
92/// are processed. Face records must use the `v//vn` or `v/vt/vn` format;
93/// bare `v` faces use vertex index as normal index.
94pub fn parse_obj(content: &str) -> Result<(Vec<[f32; 3]>, Vec<[f32; 3]>, Vec<u32>), String> {
95    let mut positions: Vec<[f32; 3]> = Vec::new();
96    let mut normals: Vec<[f32; 3]> = Vec::new();
97    let mut indices: Vec<u32> = Vec::new();
98    for line in content.lines() {
99        let line = line.trim();
100        if line.is_empty() || line.starts_with('#') {
101            continue;
102        }
103        if line.starts_with("vn ") {
104            let parts: Vec<&str> = line.split_whitespace().collect();
105            if parts.len() >= 4 {
106                let x = parts[1].parse::<f32>().map_err(|e| e.to_string())?;
107                let y = parts[2].parse::<f32>().map_err(|e| e.to_string())?;
108                let z = parts[3].parse::<f32>().map_err(|e| e.to_string())?;
109                normals.push([x, y, z]);
110            }
111        } else if line.starts_with("v ") {
112            let parts: Vec<&str> = line.split_whitespace().collect();
113            if parts.len() >= 4 {
114                let x = parts[1].parse::<f32>().map_err(|e| e.to_string())?;
115                let y = parts[2].parse::<f32>().map_err(|e| e.to_string())?;
116                let z = parts[3].parse::<f32>().map_err(|e| e.to_string())?;
117                positions.push([x, y, z]);
118            }
119        } else if line.starts_with("f ") {
120            let parts: Vec<&str> = line.split_whitespace().collect();
121            if parts.len() >= 4 {
122                for token in &parts[1..4] {
123                    let vi = token
124                        .split('/')
125                        .next()
126                        .ok_or_else(|| format!("bad face token: {token}"))?
127                        .parse::<u32>()
128                        .map_err(|e| e.to_string())?;
129                    indices.push(vi - 1);
130                }
131            }
132        }
133    }
134    Ok((positions, normals, indices))
135}
136/// Compute flat-shading normals for a triangle mesh.
137pub fn compute_flat_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> {
138    let mut normals = vec![[0.0f32; 3]; positions.len()];
139    let mut i = 0;
140    while i + 2 < indices.len() {
141        let a = indices[i] as usize;
142        let b = indices[i + 1] as usize;
143        let c = indices[i + 2] as usize;
144        if a < positions.len() && b < positions.len() && c < positions.len() {
145            let edge1 = [
146                positions[b][0] - positions[a][0],
147                positions[b][1] - positions[a][1],
148                positions[b][2] - positions[a][2],
149            ];
150            let edge2 = [
151                positions[c][0] - positions[a][0],
152                positions[c][1] - positions[a][1],
153                positions[c][2] - positions[a][2],
154            ];
155            let normal = [
156                edge1[1] * edge2[2] - edge1[2] * edge2[1],
157                edge1[2] * edge2[0] - edge1[0] * edge2[2],
158                edge1[0] * edge2[1] - edge1[1] * edge2[0],
159            ];
160            let len =
161                (normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]).sqrt();
162            let n = if len > 1e-10 {
163                [normal[0] / len, normal[1] / len, normal[2] / len]
164            } else {
165                [0.0, 0.0, 1.0]
166            };
167            normals[a] = n;
168            normals[b] = n;
169            normals[c] = n;
170        }
171        i += 3;
172    }
173    normals
174}
175/// Validate a glTF scene for structural correctness.
176///
177/// Returns `Ok(())` if no errors are found, or `Err(String)` describing the
178/// first error encountered.
179pub fn validate_scene(scene: &GltfScene) -> Result<(), String> {
180    let issues = validate_scene_detailed(scene);
181    if let Some(err) = issues.iter().find(|i| i.is_error) {
182        Err(err.message.clone())
183    } else {
184        Ok(())
185    }
186}
187/// Validate a glTF scene and return all issues found (errors and warnings).
188pub fn validate_scene_detailed(scene: &GltfScene) -> Vec<ValidationIssue> {
189    let mut issues = Vec::new();
190    for (ni, node) in scene.nodes.iter().enumerate() {
191        if let Some(mesh_idx) = node.mesh
192            && mesh_idx >= scene.meshes.len()
193        {
194            issues
195                    .push(
196                        ValidationIssue::error(
197                            format!(
198                                "Node {ni} ('{}') references mesh {mesh_idx} which does not exist (scene has {} meshes)",
199                                node.name, scene.meshes.len()
200                            ),
201                        ),
202                    );
203        }
204        for &child_idx in &node.children {
205            if child_idx >= scene.nodes.len() {
206                issues
207                    .push(
208                        ValidationIssue::error(
209                            format!(
210                                "Node {ni} ('{}') has child index {child_idx} which does not exist (scene has {} nodes)",
211                                node.name, scene.nodes.len()
212                            ),
213                        ),
214                    );
215            }
216        }
217    }
218    for (mi, mesh) in scene.meshes.iter().enumerate() {
219        for (pi, prim) in mesh.primitives.iter().enumerate() {
220            if prim.positions.is_empty() {
221                issues.push(ValidationIssue::warning(format!(
222                    "Mesh {mi} ('{}'), primitive {pi}: has no positions (degenerate primitive)",
223                    mesh.name
224                )));
225            }
226            if !prim.positions.is_empty() {
227                for (ii, &idx) in prim.indices.iter().enumerate() {
228                    if idx as usize >= prim.positions.len() {
229                        issues.push(ValidationIssue::error(format!(
230                            "Mesh {mi}, primitive {pi}, index {ii}: index {idx} >= vertex count {}",
231                            prim.positions.len()
232                        )));
233                        break;
234                    }
235                }
236            }
237            if !prim.normals.is_empty() && prim.normals.len() != prim.positions.len() {
238                issues.push(ValidationIssue::warning(format!(
239                    "Mesh {mi}, primitive {pi}: normal count ({}) != position count ({})",
240                    prim.normals.len(),
241                    prim.positions.len()
242                )));
243            }
244        }
245    }
246    for (mi, mat) in scene.materials.iter().enumerate() {
247        match mat.alpha_mode.as_str() {
248            "OPAQUE" | "MASK" | "BLEND" => {}
249            other => {
250                issues.push(ValidationIssue::error(format!(
251                    "Material {mi} ('{}') has invalid alphaMode '{other}'",
252                    mat.name
253                )));
254            }
255        }
256        for (ci, &c) in mat.base_color_factor.iter().enumerate() {
257            if !(0.0..=1.0).contains(&c) {
258                issues.push(ValidationIssue::warning(format!(
259                    "Material {mi} ('{}') baseColorFactor[{ci}] = {c} is outside [0,1]",
260                    mat.name
261                )));
262            }
263        }
264    }
265    for (ai, anim) in scene.animations.iter().enumerate() {
266        for (ci, ch) in anim.channels.iter().enumerate() {
267            let target_node = ch.target.node;
268            if target_node >= scene.nodes.len() {
269                issues.push(ValidationIssue::error(format!(
270                    "Animation {ai} ('{}'), channel {ci}: target node {target_node} does not exist",
271                    anim.name
272                )));
273            }
274            if ch.input_times.is_empty() {
275                issues.push(ValidationIssue::warning(format!(
276                    "Animation {ai} ('{}'), channel {ci}: no keyframes",
277                    anim.name
278                )));
279            }
280        }
281    }
282    issues
283}
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    use crate::gltf::CameraType;
289
290    use crate::gltf::GlbWriter;
291    use crate::gltf::GltfAccessor;
292    use crate::gltf::GltfAnimation;
293    use crate::gltf::GltfAnimationChannel;
294    use crate::gltf::GltfAnimationTarget;
295    use crate::gltf::GltfBufferView;
296    use crate::gltf::GltfMaterial;
297    use crate::gltf::GltfMesh;
298    use crate::gltf::GltfNode;
299    use crate::gltf::GltfPrimitive;
300
301    use crate::gltf::Joint;
302    use crate::gltf::JointChannel;
303    use crate::gltf::LightType;
304    use crate::gltf::MorphPrimitive;
305    use crate::gltf::MorphTarget;
306
307    use crate::gltf::SceneCamera;
308    use crate::gltf::SceneLight;
309    use crate::gltf::Skeleton;
310    use crate::gltf::SkeletonAnimation;
311
312    #[test]
313    fn test_gltf_scene_to_json_contains_asset_and_meshes() {
314        let mut scene = GltfScene::new();
315        let prim = GltfPrimitive {
316            positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
317            normals: vec![[0.0, 0.0, 1.0]; 3],
318            texcoords: vec![],
319            indices: vec![0, 1, 2],
320        };
321        scene.add_mesh(GltfMesh {
322            name: "TestMesh".to_string(),
323            primitives: vec![prim],
324        });
325        let json = scene.to_json();
326        assert!(json.contains("\"asset\""), "JSON must contain 'asset' key");
327        assert!(
328            json.contains("\"meshes\""),
329            "JSON must contain 'meshes' key"
330        );
331        assert!(json.contains("\"version\""), "JSON must contain version");
332        assert!(json.contains("2.0"), "version must be 2.0");
333    }
334    #[test]
335    fn test_add_mesh_returns_correct_index() {
336        let mut scene = GltfScene::new();
337        let idx0 = scene.add_mesh(GltfMesh {
338            name: "Mesh0".to_string(),
339            primitives: vec![],
340        });
341        let idx1 = scene.add_mesh(GltfMesh {
342            name: "Mesh1".to_string(),
343            primitives: vec![],
344        });
345        assert_eq!(idx0, 0);
346        assert_eq!(idx1, 1);
347        assert_eq!(scene.mesh_count(), 2);
348    }
349    #[test]
350    fn test_write_obj_single_triangle() {
351        let positions = vec![[0.0f32, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
352        let normals = vec![[0.0f32, 0.0, 1.0]; 3];
353        let indices = vec![0u32, 1, 2];
354        let obj = write_obj(&positions, &normals, &indices);
355        let v_lines: Vec<&str> = obj.lines().filter(|l| l.starts_with("v ")).collect();
356        let vn_lines: Vec<&str> = obj.lines().filter(|l| l.starts_with("vn ")).collect();
357        let f_lines: Vec<&str> = obj.lines().filter(|l| l.starts_with("f ")).collect();
358        assert_eq!(v_lines.len(), 3, "expected 3 vertex lines");
359        assert_eq!(vn_lines.len(), 3, "expected 3 normal lines");
360        assert_eq!(f_lines.len(), 1, "expected 1 face line");
361        assert!(f_lines[0].contains("1//1"), "face must use v//vn format");
362    }
363    #[test]
364    fn test_parse_obj_roundtrip() {
365        let positions = vec![[0.0f32, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
366        let normals = vec![[0.0f32, 0.0, 1.0], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0]];
367        let indices = vec![0u32, 1, 2];
368        let obj_str = write_obj(&positions, &normals, &indices);
369        let (parsed_pos, parsed_nrm, parsed_idx) = parse_obj(&obj_str).unwrap();
370        assert_eq!(parsed_pos.len(), 3);
371        assert_eq!(parsed_nrm.len(), 3);
372        assert_eq!(parsed_idx, vec![0, 1, 2]);
373        assert!((parsed_pos[1][0] - 1.0).abs() < 1e-6);
374        assert!((parsed_nrm[0][2] - 1.0).abs() < 1e-6);
375    }
376    #[test]
377    fn test_gltf_node_default_translation() {
378        let node = GltfNode::default();
379        assert_eq!(node.translation, [0.0, 0.0, 0.0]);
380        assert_eq!(node.scale, [1.0, 1.0, 1.0]);
381        assert_eq!(node.rotation, [0.0, 0.0, 0.0, 1.0]);
382        assert!(node.mesh.is_none());
383        assert!(node.children.is_empty());
384    }
385    #[test]
386    fn test_primitive_bounding_box() {
387        let prim = GltfPrimitive {
388            positions: vec![[-1.0, -2.0, -3.0], [4.0, 5.0, 6.0], [0.0, 0.0, 0.0]],
389            normals: vec![[0.0, 0.0, 1.0]; 3],
390            texcoords: vec![],
391            indices: vec![0, 1, 2],
392        };
393        let (min, max) = prim.bounding_box();
394        assert!((min[0] - (-1.0)).abs() < 1e-6);
395        assert!((min[1] - (-2.0)).abs() < 1e-6);
396        assert!((min[2] - (-3.0)).abs() < 1e-6);
397        assert!((max[0] - 4.0).abs() < 1e-6);
398        assert!((max[1] - 5.0).abs() < 1e-6);
399        assert!((max[2] - 6.0).abs() < 1e-6);
400    }
401    #[test]
402    fn test_primitive_triangle_and_vertex_count() {
403        let prim = GltfPrimitive {
404            positions: vec![[0.0; 3]; 6],
405            normals: vec![[0.0, 0.0, 1.0]; 6],
406            texcoords: vec![],
407            indices: vec![0, 1, 2, 3, 4, 5],
408        };
409        assert_eq!(prim.triangle_count(), 2);
410        assert_eq!(prim.vertex_count(), 6);
411    }
412    #[test]
413    fn test_extract_triangles() {
414        let prim = GltfPrimitive {
415            positions: vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
416            normals: vec![[0.0, 0.0, 1.0]; 3],
417            texcoords: vec![],
418            indices: vec![0, 1, 2],
419        };
420        let tris = prim.extract_triangles();
421        assert_eq!(tris.len(), 1);
422        assert!((tris[0][0][0] - 1.0).abs() < 1e-6);
423        assert!((tris[0][1][1] - 1.0).abs() < 1e-6);
424        assert!((tris[0][2][2] - 1.0).abs() < 1e-6);
425    }
426    #[test]
427    fn test_mesh_total_counts() {
428        let mesh = GltfMesh {
429            name: "test".into(),
430            primitives: vec![
431                GltfPrimitive {
432                    positions: vec![[0.0; 3]; 3],
433                    normals: vec![[0.0, 0.0, 1.0]; 3],
434                    texcoords: vec![],
435                    indices: vec![0, 1, 2],
436                },
437                GltfPrimitive {
438                    positions: vec![[0.0; 3]; 4],
439                    normals: vec![[0.0, 0.0, 1.0]; 4],
440                    texcoords: vec![],
441                    indices: vec![0, 1, 2, 1, 2, 3],
442                },
443            ],
444        };
445        assert_eq!(mesh.total_vertex_count(), 7);
446        assert_eq!(mesh.total_triangle_count(), 3);
447    }
448    #[test]
449    fn test_accessor_components_and_size() {
450        let acc = GltfAccessor {
451            buffer_view: 0,
452            byte_offset: 0,
453            component_type: 5126,
454            count: 10,
455            element_type: "VEC3".to_string(),
456        };
457        assert_eq!(acc.components_per_element(), 3);
458        assert_eq!(acc.component_size(), 4);
459        assert_eq!(acc.byte_length(), 10 * 3 * 4);
460    }
461    #[test]
462    fn test_accessor_scalar_u16() {
463        let acc = GltfAccessor {
464            buffer_view: 0,
465            byte_offset: 0,
466            component_type: 5123,
467            count: 5,
468            element_type: "SCALAR".to_string(),
469        };
470        assert_eq!(acc.components_per_element(), 1);
471        assert_eq!(acc.component_size(), 2);
472        assert_eq!(acc.byte_length(), 10);
473    }
474    #[test]
475    fn test_accessor_mat4() {
476        let acc = GltfAccessor {
477            buffer_view: 0,
478            byte_offset: 0,
479            component_type: 5126,
480            count: 1,
481            element_type: "MAT4".to_string(),
482        };
483        assert_eq!(acc.components_per_element(), 16);
484        assert_eq!(acc.byte_length(), 64);
485    }
486    #[test]
487    fn test_decode_f32_le() {
488        let val: f32 = 3.125;
489        let bytes = val.to_le_bytes();
490        let decoded = decode_f32_le(&bytes);
491        assert_eq!(decoded.len(), 1);
492        assert!((decoded[0] - 3.125).abs() < 1e-5);
493    }
494    #[test]
495    fn test_decode_u16_le() {
496        let val: u16 = 12345;
497        let bytes = val.to_le_bytes();
498        let decoded = decode_u16_le(&bytes);
499        assert_eq!(decoded, vec![12345]);
500    }
501    #[test]
502    fn test_decode_u32_le() {
503        let val: u32 = 999999;
504        let bytes = val.to_le_bytes();
505        let decoded = decode_u32_le(&bytes);
506        assert_eq!(decoded, vec![999999]);
507    }
508    #[test]
509    fn test_decode_vec3() {
510        let floats = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
511        let vecs = decode_vec3(&floats);
512        assert_eq!(vecs.len(), 2);
513        assert_eq!(vecs[0], [1.0, 2.0, 3.0]);
514        assert_eq!(vecs[1], [4.0, 5.0, 6.0]);
515    }
516    #[test]
517    fn test_decode_vec4() {
518        let floats = vec![1.0, 2.0, 3.0, 4.0];
519        let vecs = decode_vec4(&floats);
520        assert_eq!(vecs.len(), 1);
521        assert_eq!(vecs[0], [1.0, 2.0, 3.0, 4.0]);
522    }
523    #[test]
524    fn test_animation_duration() {
525        let anim = GltfAnimation {
526            name: "walk".into(),
527            channels: vec![
528                GltfAnimationChannel {
529                    target: GltfAnimationTarget {
530                        node: 0,
531                        path: "translation".into(),
532                    },
533                    interpolation: "LINEAR".into(),
534                    input_times: vec![0.0, 0.5, 1.0],
535                    output_values: vec![0.0; 9],
536                },
537                GltfAnimationChannel {
538                    target: GltfAnimationTarget {
539                        node: 1,
540                        path: "rotation".into(),
541                    },
542                    interpolation: "LINEAR".into(),
543                    input_times: vec![0.0, 2.0],
544                    output_values: vec![0.0; 8],
545                },
546            ],
547        };
548        assert!((anim.duration() - 2.0).abs() < 1e-6);
549        assert_eq!(anim.total_keyframe_count(), 5);
550    }
551    #[test]
552    fn test_animation_empty() {
553        let anim = GltfAnimation {
554            name: "empty".into(),
555            channels: vec![],
556        };
557        assert!((anim.duration()).abs() < 1e-6);
558        assert_eq!(anim.total_keyframe_count(), 0);
559    }
560    #[test]
561    fn test_material_default() {
562        let mat = GltfMaterial::default();
563        assert!(mat.is_opaque());
564        assert!(!mat.is_emissive());
565        assert_eq!(mat.alpha_mode, "OPAQUE");
566        assert!((mat.metallic_factor - 1.0).abs() < 1e-6);
567    }
568    #[test]
569    fn test_material_emissive() {
570        let mat = GltfMaterial {
571            emissive_factor: [1.0, 0.0, 0.0],
572            ..Default::default()
573        };
574        assert!(mat.is_emissive());
575    }
576    #[test]
577    fn test_material_blend() {
578        let mut mat = GltfMaterial {
579            alpha_mode: "BLEND".to_string(),
580            ..Default::default()
581        };
582        mat.base_color_factor[3] = 0.5;
583        assert!(!mat.is_opaque());
584    }
585    #[test]
586    fn test_scene_graph_traversal() {
587        let mut scene = GltfScene::new();
588        scene.add_node(GltfNode {
589            name: "root".into(),
590            translation: [1.0, 0.0, 0.0],
591            children: vec![1],
592            ..GltfNode::default()
593        });
594        scene.add_node(GltfNode {
595            name: "child".into(),
596            translation: [0.0, 2.0, 0.0],
597            children: vec![2],
598            ..GltfNode::default()
599        });
600        scene.add_node(GltfNode {
601            name: "grandchild".into(),
602            translation: [0.0, 0.0, 3.0],
603            ..GltfNode::default()
604        });
605        let mut visited = Vec::new();
606        scene.traverse_depth_first(|idx, depth, acc_trans| {
607            visited.push((idx, depth, acc_trans));
608        });
609        assert_eq!(visited.len(), 3);
610        assert_eq!(visited[0].0, 0);
611        assert_eq!(visited[0].1, 0);
612        assert!((visited[0].2[0] - 1.0).abs() < 1e-12);
613        assert_eq!(visited[1].0, 1);
614        assert_eq!(visited[1].1, 1);
615        assert!((visited[1].2[0] - 1.0).abs() < 1e-12);
616        assert!((visited[1].2[1] - 2.0).abs() < 1e-12);
617        assert_eq!(visited[2].0, 2);
618        assert_eq!(visited[2].1, 2);
619        assert!((visited[2].2[2] - 3.0).abs() < 1e-12);
620    }
621    #[test]
622    fn test_collect_mesh_primitives() {
623        let mut scene = GltfScene::new();
624        let mesh_idx = scene.add_mesh(GltfMesh {
625            name: "m0".into(),
626            primitives: vec![GltfPrimitive {
627                positions: vec![[0.0; 3]; 3],
628                normals: vec![[0.0, 0.0, 1.0]; 3],
629                texcoords: vec![],
630                indices: vec![0, 1, 2],
631            }],
632        });
633        scene.add_node(GltfNode {
634            name: "node0".into(),
635            mesh: Some(mesh_idx),
636            ..GltfNode::default()
637        });
638        let prims = scene.collect_mesh_primitives();
639        assert_eq!(prims.len(), 1);
640        assert_eq!(prims[0].0, "node0");
641        assert_eq!(prims[0].1.vertex_count(), 3);
642    }
643    #[test]
644    fn test_nodes_using_mesh() {
645        let mut scene = GltfScene::new();
646        scene.add_mesh(GltfMesh {
647            name: "m".into(),
648            primitives: vec![],
649        });
650        scene.add_node(GltfNode {
651            name: "a".into(),
652            mesh: Some(0),
653            ..GltfNode::default()
654        });
655        scene.add_node(GltfNode {
656            name: "b".into(),
657            mesh: None,
658            ..GltfNode::default()
659        });
660        scene.add_node(GltfNode {
661            name: "c".into(),
662            mesh: Some(0),
663            ..GltfNode::default()
664        });
665        let users = scene.nodes_using_mesh(0);
666        assert_eq!(users, vec![0, 2]);
667    }
668    #[test]
669    fn test_scene_total_counts() {
670        let mut scene = GltfScene::new();
671        scene.add_mesh(GltfMesh {
672            name: "m".into(),
673            primitives: vec![GltfPrimitive {
674                positions: vec![[0.0; 3]; 4],
675                normals: vec![[0.0, 0.0, 1.0]; 4],
676                texcoords: vec![],
677                indices: vec![0, 1, 2, 1, 2, 3],
678            }],
679        });
680        assert_eq!(scene.total_vertex_count(), 4);
681        assert_eq!(scene.total_triangle_count(), 2);
682    }
683    #[test]
684    fn test_compute_flat_normals() {
685        let positions = vec![[0.0f32, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
686        let indices = vec![0u32, 1, 2];
687        let normals = compute_flat_normals(&positions, &indices);
688        assert_eq!(normals.len(), 3);
689        for n in &normals {
690            assert!((n[2] - 1.0).abs() < 1e-5);
691        }
692    }
693    #[test]
694    fn test_json_with_materials() {
695        let mut scene = GltfScene::new();
696        scene.add_node(GltfNode::default());
697        scene.add_mesh(GltfMesh {
698            name: "m".into(),
699            primitives: vec![],
700        });
701        scene.add_material(GltfMaterial {
702            name: "red".into(),
703            base_color_factor: [1.0, 0.0, 0.0, 1.0],
704            ..GltfMaterial::default()
705        });
706        let json = scene.to_json();
707        assert!(json.contains("\"materials\""));
708        assert!(json.contains("\"red\""));
709        assert!(json.contains("pbrMetallicRoughness"));
710    }
711    #[test]
712    fn test_json_with_children() {
713        let mut scene = GltfScene::new();
714        scene.add_node(GltfNode {
715            name: "parent".into(),
716            children: vec![1],
717            ..GltfNode::default()
718        });
719        scene.add_node(GltfNode {
720            name: "child".into(),
721            ..GltfNode::default()
722        });
723        let json = scene.to_json();
724        assert!(json.contains("\"children\""));
725    }
726    #[test]
727    fn test_buffer_view() {
728        let bv = GltfBufferView {
729            buffer: 0,
730            byte_offset: 100,
731            byte_length: 240,
732            byte_stride: Some(12),
733        };
734        assert_eq!(bv.byte_offset, 100);
735        assert_eq!(bv.byte_stride, Some(12));
736    }
737    #[test]
738    fn test_morph_target_blend() {
739        let base = vec![[0.0f32, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
740        let target = MorphTarget {
741            name: "smile".into(),
742            position_deltas: vec![[0.0, 0.1, 0.0], [0.0, 0.2, 0.0], [0.0, 0.15, 0.0]],
743        };
744        let blended = target.apply(&base, 0.5);
745        assert_eq!(blended.len(), 3);
746        assert!((blended[0][1] - 0.05).abs() < 1e-6, "y should be 0.05");
747        assert!((blended[1][0] - 1.0).abs() < 1e-6, "x unchanged");
748    }
749    #[test]
750    fn test_morph_target_zero_weight() {
751        let base = vec![[1.0f32, 2.0, 3.0]];
752        let target = MorphTarget {
753            name: "t".into(),
754            position_deltas: vec![[10.0, 10.0, 10.0]],
755        };
756        let result = target.apply(&base, 0.0);
757        assert!((result[0][0] - 1.0).abs() < 1e-6);
758    }
759    #[test]
760    fn test_morph_target_full_weight() {
761        let base = vec![[1.0f32, 2.0, 3.0]];
762        let target = MorphTarget {
763            name: "t".into(),
764            position_deltas: vec![[1.0, -1.0, 0.5]],
765        };
766        let result = target.apply(&base, 1.0);
767        assert!((result[0][0] - 2.0).abs() < 1e-6);
768        assert!((result[0][1] - 1.0).abs() < 1e-6);
769        assert!((result[0][2] - 3.5).abs() < 1e-6);
770    }
771    #[test]
772    fn test_primitive_morph_targets_blend() {
773        let mut prim = MorphPrimitive {
774            base: GltfPrimitive {
775                positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
776                normals: vec![[0.0, 0.0, 1.0]; 3],
777                texcoords: vec![],
778                indices: vec![0, 1, 2],
779            },
780            targets: vec![
781                MorphTarget {
782                    name: "A".into(),
783                    position_deltas: vec![[0.0, 1.0, 0.0]; 3],
784                },
785                MorphTarget {
786                    name: "B".into(),
787                    position_deltas: vec![[0.0, 0.0, 2.0]; 3],
788                },
789            ],
790            weights: vec![0.5, 0.0],
791        };
792        let blended = prim.blend();
793        assert!((blended[0][1] - 0.5).abs() < 1e-6);
794        prim.set_weight(1, 1.0);
795        let blended2 = prim.blend();
796        assert!((blended2[0][2] - 2.0).abs() < 1e-6);
797    }
798    #[test]
799    fn test_joint_default() {
800        let j = Joint::default();
801        assert_eq!(j.translation, [0.0, 0.0, 0.0]);
802        assert_eq!(j.rotation, [0.0, 0.0, 0.0, 1.0]);
803        assert_eq!(j.scale, [1.0, 1.0, 1.0]);
804        assert!(j.children.is_empty());
805    }
806    #[test]
807    fn test_skeleton_add_and_count() {
808        let mut skel = Skeleton::new();
809        skel.add_joint(Joint {
810            name: "root".into(),
811            ..Joint::default()
812        });
813        skel.add_joint(Joint {
814            name: "hip".into(),
815            ..Joint::default()
816        });
817        assert_eq!(skel.joint_count(), 2);
818        assert_eq!(skel.joints[0].name, "root");
819    }
820    #[test]
821    fn test_skeleton_animation_export_json() {
822        let mut skel = Skeleton::new();
823        skel.add_joint(Joint {
824            name: "bone0".into(),
825            ..Joint::default()
826        });
827        let anim = SkeletonAnimation {
828            name: "run".into(),
829            joint_channels: vec![JointChannel {
830                joint_index: 0,
831                path: "rotation".into(),
832                times: vec![0.0, 0.5, 1.0],
833                values: vec![
834                    0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.707, 0.707, 0.0, 0.0, 0.0, 1.0,
835                ],
836                interpolation: "LINEAR".into(),
837            }],
838        };
839        let json = skel.export_animation_json(&anim);
840        assert!(json.contains("run"), "should contain animation name");
841        assert!(json.contains("rotation"), "should contain channel path");
842        assert!(json.contains("bone0"), "should contain joint name");
843    }
844    #[test]
845    fn test_skeleton_animation_duration() {
846        let anim = SkeletonAnimation {
847            name: "walk".into(),
848            joint_channels: vec![
849                JointChannel {
850                    joint_index: 0,
851                    path: "translation".into(),
852                    times: vec![0.0, 1.5],
853                    values: vec![0.0; 6],
854                    interpolation: "LINEAR".into(),
855                },
856                JointChannel {
857                    joint_index: 1,
858                    path: "rotation".into(),
859                    times: vec![0.0, 0.5],
860                    values: vec![0.0; 8],
861                    interpolation: "STEP".into(),
862                },
863            ],
864        };
865        assert!((anim.duration() - 1.5).abs() < 1e-6);
866    }
867    #[test]
868    fn test_glb_magic_and_version() {
869        let writer = GlbWriter::new();
870        let scene = GltfScene::new();
871        let bytes = writer.write(&scene);
872        assert!(bytes.len() >= 12, "GLB must be at least 12 bytes");
873        assert_eq!(&bytes[0..4], b"glTF", "magic bytes must be 'glTF'");
874        let version = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
875        assert_eq!(version, 2, "GLB version must be 2");
876    }
877    #[test]
878    fn test_glb_header_length_consistent() {
879        let writer = GlbWriter::new();
880        let scene = GltfScene::new();
881        let bytes = writer.write(&scene);
882        let total_len = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
883        assert_eq!(
884            total_len as usize,
885            bytes.len(),
886            "header length field must equal actual length"
887        );
888    }
889    #[test]
890    fn test_glb_with_mesh_round_trip() {
891        let mut scene = GltfScene::new();
892        scene.add_mesh(GltfMesh {
893            name: "cube".into(),
894            primitives: vec![GltfPrimitive {
895                positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
896                normals: vec![[0.0, 0.0, 1.0]; 3],
897                texcoords: vec![],
898                indices: vec![0, 1, 2],
899            }],
900        });
901        let writer = GlbWriter::new();
902        let bytes = writer.write(&scene);
903        assert!(bytes.len() > 28, "GLB must have JSON chunk");
904        let json_chunk_type = u32::from_le_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
905        assert_eq!(json_chunk_type, 0x4E4F534A, "first chunk must be JSON");
906    }
907    #[test]
908    fn test_camera_node_json() {
909        let mut scene = GltfScene::new();
910        let cam = SceneCamera {
911            name: "main_cam".into(),
912            camera_type: CameraType::Perspective {
913                yfov: 0.785,
914                znear: 0.01,
915                zfar: Some(1000.0),
916                aspect_ratio: Some(1.778),
917            },
918        };
919        scene.add_camera(cam);
920        let json = scene.to_json_with_hierarchy();
921        assert!(json.contains("cameras"), "JSON should contain 'cameras'");
922        assert!(
923            json.contains("perspective"),
924            "JSON should contain 'perspective'"
925        );
926    }
927    #[test]
928    fn test_light_node_json() {
929        let mut scene = GltfScene::new();
930        let light = SceneLight {
931            name: "sun".into(),
932            light_type: LightType::Directional,
933            color: [1.0, 0.98, 0.9],
934            intensity: 100_000.0,
935        };
936        scene.add_light(light);
937        let json = scene.to_json_with_hierarchy();
938        assert!(
939            json.contains("extensions"),
940            "should contain 'extensions' for lights"
941        );
942        assert!(
943            json.contains("KHR_lights_punctual"),
944            "should contain KHR extension"
945        );
946        assert!(json.contains("directional"), "should contain light type");
947    }
948    #[test]
949    fn test_validate_empty_scene_passes() {
950        let scene = GltfScene::new();
951        let result = validate_scene(&scene);
952        assert!(result.is_ok(), "empty scene should pass validation");
953    }
954    #[test]
955    fn test_validate_dangling_node_mesh_reference_fails() {
956        let mut scene = GltfScene::new();
957        scene.add_node(GltfNode {
958            name: "bad".into(),
959            mesh: Some(5),
960            ..GltfNode::default()
961        });
962        let result = validate_scene(&scene);
963        assert!(
964            result.is_err(),
965            "dangling mesh reference should fail validation"
966        );
967    }
968    #[test]
969    fn test_validate_valid_mesh_ref_passes() {
970        let mut scene = GltfScene::new();
971        let idx = scene.add_mesh(GltfMesh {
972            name: "m".into(),
973            primitives: vec![],
974        });
975        scene.add_node(GltfNode {
976            name: "good".into(),
977            mesh: Some(idx),
978            ..GltfNode::default()
979        });
980        let result = validate_scene(&scene);
981        assert!(result.is_ok(), "valid mesh reference should pass");
982    }
983    #[test]
984    fn test_validate_dangling_child_reference_fails() {
985        let mut scene = GltfScene::new();
986        scene.add_node(GltfNode {
987            name: "parent".into(),
988            children: vec![99],
989            ..GltfNode::default()
990        });
991        let result = validate_scene(&scene);
992        assert!(result.is_err(), "dangling child reference should fail");
993    }
994    #[test]
995    fn test_validate_degenerate_primitive_warns() {
996        let mut scene = GltfScene::new();
997        scene.add_mesh(GltfMesh {
998            name: "degen".into(),
999            primitives: vec![GltfPrimitive {
1000                positions: vec![],
1001                normals: vec![],
1002                texcoords: vec![],
1003                indices: vec![],
1004            }],
1005        });
1006        let issues = validate_scene_detailed(&scene);
1007        assert!(
1008            !issues.is_empty(),
1009            "degenerate primitive should produce issues"
1010        );
1011    }
1012    #[test]
1013    fn test_validate_index_out_of_range_fails() {
1014        let scene_with_bad_prim = GltfScene {
1015            nodes: vec![],
1016            meshes: vec![GltfMesh {
1017                name: "m".into(),
1018                primitives: vec![GltfPrimitive {
1019                    positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]],
1020                    normals: vec![[0.0, 0.0, 1.0]; 2],
1021                    texcoords: vec![],
1022                    indices: vec![0, 1, 5],
1023                }],
1024            }],
1025            accessors: vec![],
1026            buffer_views: vec![],
1027            animations: vec![],
1028            materials: vec![],
1029            cameras: vec![],
1030            lights: vec![],
1031        };
1032        let issues = validate_scene_detailed(&scene_with_bad_prim);
1033        assert!(
1034            !issues.is_empty(),
1035            "out-of-range index should produce issues"
1036        );
1037    }
1038}
1039/// Produce a minimal but complete glTF 2.0 JSON document from a [`GltfScene`].
1040///
1041/// The output is a valid JSON string that can be saved as `.gltf`.
1042/// Buffer data is embedded as base64 data URIs when `embed_buffers` is true.
1043pub fn scene_to_gltf_json(scene: &GltfScene, embed_buffers: bool) -> String {
1044    let mut buffer_data: Vec<u8> = Vec::new();
1045    let mut buffer_views_json: Vec<String> = Vec::new();
1046    let mut accessors_json: Vec<String> = Vec::new();
1047    let mut mesh_json_items: Vec<String> = Vec::new();
1048    for mesh in &scene.meshes {
1049        for prim in &mesh.primitives {
1050            let pos_bv_start = buffer_data.len();
1051            for p in &prim.positions {
1052                for c in p {
1053                    buffer_data.extend_from_slice(&c.to_le_bytes());
1054                }
1055            }
1056            let pos_bv_len = buffer_data.len() - pos_bv_start;
1057            let pos_bv_idx = buffer_views_json.len();
1058            buffer_views_json.push(format!(
1059                r#"{{ "buffer": 0, "byteOffset": {start}, "byteLength": {len}, "target": 34962 }}"#,
1060                start = pos_bv_start,
1061                len = pos_bv_len
1062            ));
1063            let pos_acc_idx = accessors_json.len();
1064            accessors_json
1065                .push(
1066                    format!(
1067                        r#"{{ "bufferView": {bv}, "byteOffset": 0, "componentType": 5126, "type": "VEC3", "count": {count} }}"#,
1068                        bv = pos_bv_idx, count = prim.positions.len()
1069                    ),
1070                );
1071            let idx_bv_start = buffer_data.len();
1072            while !buffer_data.len().is_multiple_of(4) {
1073                buffer_data.push(0u8);
1074            }
1075            let idx_bv_start = if buffer_data.len() == idx_bv_start {
1076                idx_bv_start
1077            } else {
1078                buffer_data.len()
1079            };
1080            let _ = idx_bv_start;
1081            let idx_real_start = buffer_data.len();
1082            for &i in &prim.indices {
1083                buffer_data.extend_from_slice(&i.to_le_bytes());
1084            }
1085            let idx_bv_len = buffer_data.len() - idx_real_start;
1086            let idx_bv_idx = buffer_views_json.len();
1087            buffer_views_json.push(format!(
1088                r#"{{ "buffer": 0, "byteOffset": {start}, "byteLength": {len}, "target": 34963 }}"#,
1089                start = idx_real_start,
1090                len = idx_bv_len
1091            ));
1092            let idx_acc_idx = accessors_json.len();
1093            accessors_json
1094                .push(
1095                    format!(
1096                        r#"{{ "bufferView": {bv}, "byteOffset": 0, "componentType": 5125, "type": "SCALAR", "count": {count} }}"#,
1097                        bv = idx_bv_idx, count = prim.indices.len()
1098                    ),
1099                );
1100            let prim_json = format!(
1101                r#"{{ "attributes": {{ "POSITION": {pa} }}, "indices": {ia} }}"#,
1102                pa = pos_acc_idx,
1103                ia = idx_acc_idx
1104            );
1105            mesh_json_items.push(format!(
1106                r#"{{ "name": "{}", "primitives": [{}] }}"#,
1107                mesh.name, prim_json
1108            ));
1109        }
1110    }
1111    let buf_json = if embed_buffers {
1112        let b64 = base64_encode(&buffer_data);
1113        format!(
1114            r#"{{ "uri": "data:application/octet-stream;base64,{}", "byteLength": {} }}"#,
1115            b64,
1116            buffer_data.len()
1117        )
1118    } else {
1119        format!(
1120            r#"{{ "uri": "buffer.bin", "byteLength": {} }}"#,
1121            buffer_data.len()
1122        )
1123    };
1124    let bvs = buffer_views_json.join(", ");
1125    let accs = accessors_json.join(", ");
1126    let meshes = mesh_json_items.join(", ");
1127    format!(
1128        r#"{{
1129  "asset": {{ "version": "2.0", "generator": "OxiPhysics" }},
1130  "scene": 0,
1131  "scenes": [{{ "nodes": [] }}],
1132  "nodes": [],
1133  "meshes": [{meshes}],
1134  "accessors": [{accs}],
1135  "bufferViews": [{bvs}],
1136  "buffers": [{buf}]
1137}}"#,
1138        meshes = meshes,
1139        accs = accs,
1140        bvs = bvs,
1141        buf = buf_json,
1142    )
1143}
1144/// Minimal base64 encoder (no external dep).
1145pub(super) fn base64_encode(data: &[u8]) -> String {
1146    pub(super) const CHARS: &[u8] =
1147        b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1148    let mut out = String::new();
1149    let mut i = 0;
1150    while i < data.len() {
1151        let b0 = data[i] as u32;
1152        let b1 = if i + 1 < data.len() {
1153            data[i + 1] as u32
1154        } else {
1155            0
1156        };
1157        let b2 = if i + 2 < data.len() {
1158            data[i + 2] as u32
1159        } else {
1160            0
1161        };
1162        let n = (b0 << 16) | (b1 << 8) | b2;
1163        out.push(CHARS[((n >> 18) & 0x3F) as usize] as char);
1164        out.push(CHARS[((n >> 12) & 0x3F) as usize] as char);
1165        if i + 1 < data.len() {
1166            out.push(CHARS[((n >> 6) & 0x3F) as usize] as char);
1167        } else {
1168            out.push('=');
1169        }
1170        if i + 2 < data.len() {
1171            out.push(CHARS[(n & 0x3F) as usize] as char);
1172        } else {
1173            out.push('=');
1174        }
1175        i += 3;
1176    }
1177    out
1178}
1179#[cfg(test)]
1180mod tests_gltf_additions {
1181    use super::*;
1182    use crate::gltf::AccessorType;
1183    use crate::gltf::AnimationChannelBuilder;
1184    use crate::gltf::ComponentType;
1185    use crate::gltf::GlbWriter;
1186    use crate::gltf::GltfMesh;
1187    use crate::gltf::GltfPrimitive;
1188    use crate::gltf::Interpolation;
1189    use crate::gltf::PbrMaterialBuilder;
1190    use crate::gltf::TypedAccessor;
1191
1192    #[test]
1193    fn test_component_type_byte_size() {
1194        assert_eq!(ComponentType::UnsignedByte.byte_size(), 1);
1195        assert_eq!(ComponentType::UnsignedShort.byte_size(), 2);
1196        assert_eq!(ComponentType::UnsignedInt.byte_size(), 4);
1197        assert_eq!(ComponentType::Float.byte_size(), 4);
1198    }
1199    #[test]
1200    fn test_accessor_type_num_components() {
1201        assert_eq!(AccessorType::Scalar.num_components(), 1);
1202        assert_eq!(AccessorType::Vec3.num_components(), 3);
1203        assert_eq!(AccessorType::Vec4.num_components(), 4);
1204        assert_eq!(AccessorType::Mat4.num_components(), 16);
1205    }
1206    #[test]
1207    fn test_accessor_type_as_str() {
1208        assert_eq!(AccessorType::Vec3.as_str(), "VEC3");
1209        assert_eq!(AccessorType::Mat4.as_str(), "MAT4");
1210        assert_eq!(AccessorType::Scalar.as_str(), "SCALAR");
1211    }
1212    #[test]
1213    fn test_component_type_code() {
1214        assert_eq!(ComponentType::Float.component_type_code(), 5126);
1215        assert_eq!(ComponentType::UnsignedInt.component_type_code(), 5125);
1216    }
1217    #[test]
1218    fn test_typed_accessor_byte_length() {
1219        let acc = TypedAccessor::new("pos", 0, 0, ComponentType::Float, AccessorType::Vec3, 10);
1220        assert_eq!(acc.byte_length(), 120);
1221    }
1222    #[test]
1223    fn test_typed_accessor_to_json_contains_type() {
1224        let acc = TypedAccessor::new("vel", 1, 0, ComponentType::Float, AccessorType::Vec3, 5);
1225        let json = acc.to_json();
1226        assert!(json.contains("VEC3"), "JSON should contain type");
1227        assert!(json.contains("5126"), "JSON should contain componentType");
1228    }
1229    #[test]
1230    fn test_typed_accessor_decode_f32() {
1231        let values: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
1232        let mut buffer: Vec<u8> = Vec::new();
1233        for v in &values {
1234            buffer.extend_from_slice(&v.to_le_bytes());
1235        }
1236        let acc = TypedAccessor::new("v", 0, 0, ComponentType::Float, AccessorType::Vec3, 2);
1237        let decoded = acc.decode_f32(&buffer).unwrap();
1238        assert_eq!(decoded.len(), 6);
1239        assert!((decoded[0] - 1.0).abs() < 1e-6);
1240        assert!((decoded[5] - 6.0).abs() < 1e-6);
1241    }
1242    #[test]
1243    fn test_typed_accessor_decode_u32() {
1244        let indices: Vec<u32> = vec![0, 1, 2, 3, 4, 5];
1245        let mut buffer: Vec<u8> = Vec::new();
1246        for i in &indices {
1247            buffer.extend_from_slice(&i.to_le_bytes());
1248        }
1249        let acc = TypedAccessor::new(
1250            "idx",
1251            0,
1252            0,
1253            ComponentType::UnsignedInt,
1254            AccessorType::Scalar,
1255            6,
1256        );
1257        let decoded = acc.decode_u32(&buffer).unwrap();
1258        assert_eq!(decoded, indices);
1259    }
1260    #[test]
1261    fn test_typed_accessor_wrong_type_returns_none() {
1262        let buffer = vec![0u8; 24];
1263        let acc = TypedAccessor::new(
1264            "idx",
1265            0,
1266            0,
1267            ComponentType::UnsignedInt,
1268            AccessorType::Scalar,
1269            6,
1270        );
1271        assert!(acc.decode_f32(&buffer).is_none());
1272    }
1273    #[test]
1274    fn test_pbr_builder_default() {
1275        let mat = PbrMaterialBuilder::default();
1276        assert_eq!(mat.metallic_factor, 0.0);
1277        assert_eq!(mat.roughness_factor, 0.5);
1278        assert_eq!(mat.alpha_mode, "OPAQUE");
1279    }
1280    #[test]
1281    fn test_pbr_builder_chain() {
1282        let mat = PbrMaterialBuilder::new("metal")
1283            .base_color(0.8, 0.8, 0.8, 1.0)
1284            .metallic_roughness(0.9, 0.1)
1285            .emissive(0.0, 0.0, 0.0)
1286            .double_sided(true)
1287            .alpha_mode("BLEND");
1288        assert!((mat.metallic_factor - 0.9).abs() < 1e-6);
1289        assert!(mat.double_sided);
1290        assert_eq!(mat.alpha_mode, "BLEND");
1291    }
1292    #[test]
1293    fn test_pbr_to_json_contains_keys() {
1294        let mat = PbrMaterialBuilder::new("glass")
1295            .base_color(0.2, 0.4, 0.8, 0.5)
1296            .alpha_mode("BLEND");
1297        let json = mat.to_json();
1298        assert!(
1299            json.contains("pbrMetallicRoughness"),
1300            "should contain PBR key"
1301        );
1302        assert!(
1303            json.contains("baseColorFactor"),
1304            "should contain baseColorFactor"
1305        );
1306        assert!(json.contains("BLEND"), "should contain alpha mode");
1307        assert!(json.contains("glass"), "should contain name");
1308    }
1309    #[test]
1310    fn test_pbr_build_to_gltf_material() {
1311        let mat = PbrMaterialBuilder::new("copper")
1312            .metallic_roughness(0.8, 0.3)
1313            .build();
1314        assert_eq!(mat.name, "copper");
1315        assert!((mat.metallic_factor - 0.8).abs() < 1e-6);
1316        assert!((mat.roughness_factor - 0.3).abs() < 1e-6);
1317    }
1318    #[test]
1319    fn test_interpolation_as_str() {
1320        assert_eq!(Interpolation::Linear.as_str(), "LINEAR");
1321        assert_eq!(Interpolation::Step.as_str(), "STEP");
1322        assert_eq!(Interpolation::CubicSpline.as_str(), "CUBICSPLINE");
1323    }
1324    #[test]
1325    fn test_animation_channel_builder_push_and_duration() {
1326        let ch = AnimationChannelBuilder::new(0, "translation", Interpolation::Linear)
1327            .push(0.0, vec![0.0, 0.0, 0.0])
1328            .push(0.5, vec![0.0, 1.0, 0.0])
1329            .push(1.0, vec![0.0, 2.0, 0.0]);
1330        assert_eq!(ch.len(), 3);
1331        assert!((ch.duration() - 1.0).abs() < 1e-6);
1332        assert!(!ch.is_empty());
1333    }
1334    #[test]
1335    fn test_animation_channel_times_and_values() {
1336        let ch = AnimationChannelBuilder::new(1, "rotation", Interpolation::Step)
1337            .push(0.0, vec![0.0, 0.0, 0.0, 1.0])
1338            .push(1.0, vec![0.0, 0.707, 0.0, 0.707]);
1339        let times = ch.times();
1340        let vals = ch.values_flat();
1341        assert_eq!(times.len(), 2);
1342        assert_eq!(vals.len(), 8);
1343        assert!((times[0] - 0.0).abs() < 1e-6);
1344        assert!((times[1] - 1.0).abs() < 1e-6);
1345    }
1346    #[test]
1347    fn test_animation_channel_json_fragments() {
1348        let ch = AnimationChannelBuilder::new(0, "scale", Interpolation::Linear)
1349            .push(0.0, vec![1.0, 1.0, 1.0])
1350            .push(1.0, vec![2.0, 2.0, 2.0]);
1351        let (sampler, channel) = ch.to_json_fragments(0, 10, 11);
1352        assert!(
1353            sampler.contains("LINEAR"),
1354            "sampler should contain interpolation"
1355        );
1356        assert!(channel.contains("scale"), "channel should contain path");
1357        assert!(
1358            channel.contains("\"node\": 0"),
1359            "channel should contain node index"
1360        );
1361    }
1362    #[test]
1363    fn test_animation_channel_empty() {
1364        let ch = AnimationChannelBuilder::new(0, "translation", Interpolation::Linear);
1365        assert!(ch.is_empty());
1366        assert_eq!(ch.len(), 0);
1367        assert!((ch.duration() - 0.0).abs() < 1e-6);
1368    }
1369    #[test]
1370    fn test_base64_encode_hello() {
1371        let encoded = base64_encode(b"Hello");
1372        assert_eq!(encoded, "SGVsbG8=");
1373    }
1374    #[test]
1375    fn test_base64_encode_empty() {
1376        assert_eq!(base64_encode(b""), "");
1377    }
1378    #[test]
1379    fn test_base64_encode_length_multiple_3() {
1380        let encoded = base64_encode(b"Man");
1381        assert_eq!(encoded, "TWFu");
1382    }
1383    #[test]
1384    fn test_scene_to_gltf_json_empty_scene() {
1385        let scene = GltfScene::new();
1386        let json = scene_to_gltf_json(&scene, false);
1387        assert!(
1388            json.contains("\"version\": \"2.0\""),
1389            "should contain asset version"
1390        );
1391        assert!(json.contains("OxiPhysics"), "should contain generator");
1392    }
1393    #[test]
1394    fn test_scene_to_gltf_json_with_mesh() {
1395        let mut scene = GltfScene::new();
1396        scene.add_mesh(GltfMesh {
1397            name: "triangle".into(),
1398            primitives: vec![GltfPrimitive {
1399                positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
1400                normals: vec![[0.0, 0.0, 1.0]; 3],
1401                texcoords: vec![],
1402                indices: vec![0, 1, 2],
1403            }],
1404        });
1405        let json = scene_to_gltf_json(&scene, false);
1406        assert!(json.contains("triangle"), "should contain mesh name");
1407        assert!(json.contains("VEC3"), "should contain VEC3 accessor type");
1408        assert!(json.contains("SCALAR"), "should contain SCALAR for indices");
1409    }
1410    #[test]
1411    fn test_scene_to_gltf_json_embed_buffer() {
1412        let mut scene = GltfScene::new();
1413        scene.add_mesh(GltfMesh {
1414            name: "cube".into(),
1415            primitives: vec![GltfPrimitive {
1416                positions: vec![[0.0, 0.0, 0.0]],
1417                normals: vec![[0.0, 1.0, 0.0]],
1418                texcoords: vec![],
1419                indices: vec![0],
1420            }],
1421        });
1422        let json = scene_to_gltf_json(&scene, true);
1423        assert!(
1424            json.contains("data:application/octet-stream;base64,"),
1425            "embedded buffer should use data URI"
1426        );
1427    }
1428
1429    // -----------------------------------------------------------------------
1430    // J1: glTF 2.0 Binary (GLB) buffer-packing tests
1431    // -----------------------------------------------------------------------
1432
1433    /// Build a minimal scene with one triangle mesh for GLB tests.
1434    fn make_triangle_scene() -> GltfScene {
1435        let mut scene = GltfScene::new();
1436        let mesh_idx = scene.add_mesh(GltfMesh {
1437            name: "triangle".into(),
1438            primitives: vec![GltfPrimitive {
1439                positions: vec![[0.0_f32, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
1440                normals: vec![[0.0_f32, 0.0, 1.0]; 3],
1441                texcoords: vec![[0.0_f32, 0.0], [1.0, 0.0], [0.0, 1.0]],
1442                indices: vec![0u32, 1, 2],
1443            }],
1444        });
1445        scene.add_node(crate::gltf::GltfNode {
1446            name: "node0".into(),
1447            mesh: Some(mesh_idx),
1448            ..crate::gltf::GltfNode::default()
1449        });
1450        scene
1451    }
1452
1453    #[test]
1454    fn test_write_glb_header_magic() {
1455        let scene = make_triangle_scene();
1456        let writer = GlbWriter::new();
1457        let bytes = writer.write_glb(&scene);
1458        assert!(bytes.len() >= 12, "GLB must have at least a 12-byte header");
1459        assert_eq!(&bytes[0..4], b"glTF", "magic bytes must be 'glTF'");
1460        let version = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
1461        assert_eq!(version, 2, "GLB version must be 2");
1462    }
1463
1464    #[test]
1465    fn test_write_glb_total_length_consistent() {
1466        let scene = make_triangle_scene();
1467        let writer = GlbWriter::new();
1468        let bytes = writer.write_glb(&scene);
1469        let total_len = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
1470        assert_eq!(
1471            total_len as usize,
1472            bytes.len(),
1473            "total_length field must match actual byte length"
1474        );
1475    }
1476
1477    #[test]
1478    fn test_write_glb_chunk_types() {
1479        let scene = make_triangle_scene();
1480        let writer = GlbWriter::new();
1481        let bytes = writer.write_glb(&scene);
1482        // JSON chunk starts at byte 12
1483        let json_type = u32::from_le_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
1484        assert_eq!(
1485            json_type, 0x4E4F534A,
1486            "first chunk must be JSON (0x4E4F534A)"
1487        );
1488        // JSON chunk length
1489        let json_chunk_len =
1490            u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]) as usize;
1491        // BIN chunk starts after header(12) + json_chunk_header(8) + json_data
1492        let bin_start = 12 + 8 + json_chunk_len;
1493        assert!(
1494            bin_start + 8 <= bytes.len(),
1495            "must have room for BIN chunk header"
1496        );
1497        let bin_type = u32::from_le_bytes([
1498            bytes[bin_start + 4],
1499            bytes[bin_start + 5],
1500            bytes[bin_start + 6],
1501            bytes[bin_start + 7],
1502        ]);
1503        assert_eq!(
1504            bin_type, 0x004E4942,
1505            "second chunk must be BIN\\0 (0x004E4942)"
1506        );
1507    }
1508
1509    #[test]
1510    fn test_write_glb_vertex_positions_roundtrip() {
1511        let expected_positions: [[f32; 3]; 3] = [
1512            [0.0_f32, 0.0, 0.0],
1513            [1.0_f32, 0.0, 0.0],
1514            [0.0_f32, 1.0, 0.0],
1515        ];
1516        let scene = make_triangle_scene();
1517        let writer = GlbWriter::new();
1518        let bytes = writer.write_glb(&scene);
1519
1520        // Extract BIN chunk offset and data.
1521        let json_chunk_len =
1522            u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]) as usize;
1523        let bin_start = 12 + 8 + json_chunk_len;
1524        let bin_chunk_len = u32::from_le_bytes([
1525            bytes[bin_start],
1526            bytes[bin_start + 1],
1527            bytes[bin_start + 2],
1528            bytes[bin_start + 3],
1529        ]) as usize;
1530        let bin_data = &bytes[bin_start + 8..bin_start + 8 + bin_chunk_len];
1531
1532        // Position data starts at byteOffset=0 in the BIN chunk.
1533        let n_verts = expected_positions.len();
1534        let pos_size = n_verts * 12; // 3 × f32 × 4 bytes
1535        assert!(
1536            bin_data.len() >= pos_size,
1537            "BIN buffer must be large enough for position data"
1538        );
1539
1540        for (i, expected) in expected_positions.iter().enumerate() {
1541            let off = i * 12;
1542            let x = f32::from_le_bytes([
1543                bin_data[off],
1544                bin_data[off + 1],
1545                bin_data[off + 2],
1546                bin_data[off + 3],
1547            ]);
1548            let y = f32::from_le_bytes([
1549                bin_data[off + 4],
1550                bin_data[off + 5],
1551                bin_data[off + 6],
1552                bin_data[off + 7],
1553            ]);
1554            let z = f32::from_le_bytes([
1555                bin_data[off + 8],
1556                bin_data[off + 9],
1557                bin_data[off + 10],
1558                bin_data[off + 11],
1559            ]);
1560            assert!(
1561                (x - expected[0]).abs() < f32::EPSILON,
1562                "position[{i}].x mismatch: {x} vs {}",
1563                expected[0]
1564            );
1565            assert!(
1566                (y - expected[1]).abs() < f32::EPSILON,
1567                "position[{i}].y mismatch: {y} vs {}",
1568                expected[1]
1569            );
1570            assert!(
1571                (z - expected[2]).abs() < f32::EPSILON,
1572                "position[{i}].z mismatch: {z} vs {}",
1573                expected[2]
1574            );
1575        }
1576    }
1577
1578    #[test]
1579    fn test_write_glb_bufferview_offsets_layout() {
1580        // Verify that the JSON contains the expected bufferView byteOffset sequence.
1581        // For a triangle (3 verts, 3 indices):
1582        //   pos_size  = 3 * 12 = 36 bytes  → byteOffset=0
1583        //   norm_size = 3 * 12 = 36 bytes  → byteOffset=36
1584        //   uv_size   = 3 * 8  = 24 bytes  → byteOffset=72
1585        //   idx starts at 96 (96 is already 4-byte aligned)
1586        let scene = make_triangle_scene();
1587        let writer = GlbWriter::new();
1588        let bytes = writer.write_glb(&scene);
1589
1590        // Extract JSON chunk string.
1591        let json_chunk_len =
1592            u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]) as usize;
1593        let json_bytes = &bytes[20..20 + json_chunk_len];
1594        // trim trailing spaces (padding)
1595        let json_str = std::str::from_utf8(json_bytes)
1596            .expect("valid UTF-8")
1597            .trim_end();
1598
1599        // Check expected offsets appear in the JSON.
1600        assert!(
1601            json_str.contains("\"byteOffset\": 0"),
1602            "position bufferView byteOffset must be 0"
1603        );
1604        assert!(
1605            json_str.contains("\"byteOffset\": 36"),
1606            "normal bufferView byteOffset must be 36"
1607        );
1608        assert!(
1609            json_str.contains("\"byteOffset\": 72"),
1610            "uv bufferView byteOffset must be 72"
1611        );
1612        assert!(
1613            json_str.contains("\"byteOffset\": 96"),
1614            "index bufferView byteOffset must be 96"
1615        );
1616    }
1617
1618    #[test]
1619    fn test_write_glb_json_contains_accessors() {
1620        let scene = make_triangle_scene();
1621        let writer = GlbWriter::new();
1622        let bytes = writer.write_glb(&scene);
1623        let json_chunk_len =
1624            u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]) as usize;
1625        let json_str = std::str::from_utf8(&bytes[20..20 + json_chunk_len])
1626            .expect("UTF-8")
1627            .trim_end();
1628        assert!(
1629            json_str.contains("\"accessors\""),
1630            "JSON must contain accessors array"
1631        );
1632        assert!(
1633            json_str.contains("\"bufferViews\""),
1634            "JSON must contain bufferViews array"
1635        );
1636        assert!(
1637            json_str.contains("\"buffers\""),
1638            "JSON must contain buffers array"
1639        );
1640        assert!(
1641            json_str.contains("VEC3"),
1642            "accessors must include VEC3 type"
1643        );
1644        assert!(
1645            json_str.contains("VEC2"),
1646            "accessors must include VEC2 type for UV"
1647        );
1648        assert!(
1649            json_str.contains("SCALAR"),
1650            "accessors must include SCALAR type for indices"
1651        );
1652        // POSITION accessor must have min/max bounds
1653        assert!(
1654            json_str.contains("\"min\""),
1655            "POSITION accessor must have min bounds"
1656        );
1657        assert!(
1658            json_str.contains("\"max\""),
1659            "POSITION accessor must have max bounds"
1660        );
1661    }
1662}