tests/HumanoidRetargeter.Tests/Formats/GltfImporterTests.cs

Unit tests for the glTF/GLB importer in the Humanoid Retargeter project. Loads fixture GLB/JSON files and synthetic glTF JSON, then asserts skeleton rest pose, animation sampling/resampling, robustness against malformed inputs, and end-to-end conversion to the s&box target format.

File Access
using System.Numerics;
using System.Text;
using System.Text.Json;
using HumanoidRetargeter.Core.Formats.Gltf;
using HumanoidRetargeter.Core.Maths;
using HumanoidRetargeter.Core.Skeleton;
using Xunit;
using Skel = HumanoidRetargeter.Core.Skeleton.Skeleton;
using HumanoidRetargeter.Core;

namespace HumanoidRetargeter.Tests.Formats;

// =============================================================================================
// GROUND TRUTH (fixtures/gltf/freebvh.glb + fixtures/anim_truth/freebvh_gltf.json, both
// generated by dev/tools/gltf_fixture/make_fixture.py): headless Blender 5.1 imports
// dev/corpus/bvh/bvhpython_test_freebvh.bvh at global_scale=0.01 (true meters), exports a
// GLB (animation baked per bone, optimization off), and samples bone world transforms on
// the same 30 fps grid — written ALREADY CONVERTED to glTF space (Blender Z-up → glTF Y-up
// via Rx(-90°) on the left, positions ×100 to centimeters). Our importer preserves native
// glTF axes and converts meters→cm, so imported worlds compare DIRECTLY against the truth.
//
// Alignment notes:
//  1. The exporter bakes keys at scene frames; the first key sits at frame 1 (≈0.0333 s).
//     Our importer's resample grid starts at the earliest key time and the truth sampling
//     starts at the action's first frame, so truth sample i == our clip frame i (the BVH's
//     30.000030 fps vs the exact 30 fps grid drifts ~1e-6 s/frame — negligible over 40).
//  2. Blender bone rest orientations (head→tail + roll) are carried INTO the glTF node
//     rotations, and the truth stores converted pose-bone world rotations — both sides are
//     "the node's world rotation", so rotations compare directly (no rest cancellation).
//  3. The exporter may add nodes ours also keeps (e.g. the armature object node) — the
//     comparison runs on the NAME INTERSECTION and requires it to cover ≥ 80% of the truth
//     bones (Blender glTF export may rename/restructure in future versions).
//
// TOLERANCES: the task gate allows rest pos ≤ 0.5% height, animation rot ≤ 1.5° / pos ≤ 1%
// height, "tighten if achievable". Measured residuals over ALL 40 truth frames × 55 joints:
// 55/55 names matched, max rest pos err 0.0001 cm, max anim pos err 0.0003 cm, max anim rot
// err 0.0005° — pure float noise, so the gates below are tightened to anim rot ≤ 0.5° and
// pos ≤ 0.5% height (~1000× margin kept against future Blender exporter drift).
// =============================================================================================
public class GltfImporterTests
{
    private const float KDegPerRad = 180f / MathF.PI;

    private static byte[] Fixture(string name)
        => File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "fixtures", "gltf", name));

    private static SourceScene ImportGlb(float fps = 30f)
        => GltfImporter.Import(Fixture("freebvh.glb"), new GltfImportOptions { SampleFps = fps });

    // ---- ground truth -----------------------------------------------------------------

    private sealed record TruthBone(
        string Name, string? Parent, Vector3 RestWorldPos, Quaternion RestWorldRot);

    private sealed record TruthFrameBone(Vector3 WorldPos, Quaternion WorldRot);

    private sealed class Truth
    {
        public required int NSamples;
        public required List<TruthBone> Bones;
        public required List<TruthFrameBone[]> Frames;
    }

    private static Truth LoadTruth()
    {
        var path = Path.Combine(AppContext.BaseDirectory, "fixtures", "anim_truth", "freebvh_gltf.json");
        using var doc = JsonDocument.Parse(File.ReadAllBytes(path));
        var r = doc.RootElement;

        static Vector3 V3(JsonElement e) => new(
            e[0].GetSingle(), e[1].GetSingle(), e[2].GetSingle());
        static Quaternion Q(JsonElement e) => new(
            e[0].GetSingle(), e[1].GetSingle(), e[2].GetSingle(), e[3].GetSingle());

        var bones = new List<TruthBone>();
        foreach (var b in r.GetProperty("bones").EnumerateArray())
        {
            bones.Add(new TruthBone(
                b.GetProperty("name").GetString()!,
                b.GetProperty("parent").ValueKind == JsonValueKind.Null
                    ? null : b.GetProperty("parent").GetString(),
                V3(b.GetProperty("rest_world_pos")),
                Q(b.GetProperty("rest_world_rot_xyzw"))));
        }

        var frames = new List<TruthFrameBone[]>();
        foreach (var f in r.GetProperty("frames").EnumerateArray())
        {
            var fb = new TruthFrameBone[bones.Count];
            int i = 0;
            foreach (var e in f.EnumerateArray())
                fb[i++] = new TruthFrameBone(V3(e.GetProperty("wp")), Q(e.GetProperty("wr")));
            frames.Add(fb);
        }

        return new Truth
        {
            NSamples = r.GetProperty("n_samples").GetInt32(),
            Bones = bones,
            Frames = frames,
        };
    }

    private static float SkeletonHeight(Skel skeleton)
    {
        float min = float.MaxValue, max = float.MinValue;
        foreach (var w in skeleton.RestWorld)
        {
            min = MathF.Min(min, w.Pos.Y);
            max = MathF.Max(max, w.Pos.Y);
        }
        return max - min;
    }

    // ---- 1. freebvh.glb vs Blender ground truth -----------------------------------------

    [Fact]
    public void Glb_Skeleton_CoversTruthBonesWithMatchingTopology()
    {
        var scene = ImportGlb();
        var truth = LoadTruth();

        // Name intersection must cover ≥ 80% of the truth bones (exporters may restructure).
        var matched = truth.Bones.Where(b => scene.Skeleton.IndexOf(b.Name) >= 0).ToList();
        Assert.True(matched.Count >= truth.Bones.Count * 0.8,
            $"only {matched.Count}/{truth.Bones.Count} truth bones found in the imported skeleton");

        // Matched bones keep their parents (when the parent is also matched).
        foreach (var b in matched.Where(b => b.Parent is not null))
        {
            int idx = scene.Skeleton.IndexOf(b.Name);
            int parentIdx = scene.Skeleton[idx].ParentIndex;
            if (scene.Skeleton.IndexOf(b.Parent!) < 0)
                continue;
            Assert.True(parentIdx >= 0, $"{b.Name}: lost its parent");
            Assert.Equal(b.Parent, scene.Skeleton[parentIdx].Name);
        }

        // Units/axes policy: meters → cm recorded, glTF Y-up recorded not converted.
        Assert.Equal(100f, scene.UnitScaleCm);
        Assert.Equal(1, scene.UpAxis);
        Assert.Equal(2, scene.FrontAxis);
    }

    [Fact]
    public void Glb_RestPose_MatchesBlenderTruth()
    {
        var scene = ImportGlb();
        var truth = LoadTruth();

        float height = SkeletonHeight(scene.Skeleton);
        Assert.InRange(height, 100f, 400f); // a cm-scale humanoid after ×100
        float posTol = 0.005f * height;

        var failures = new List<string>();
        int compared = 0;
        foreach (var b in truth.Bones)
        {
            int idx = scene.Skeleton.IndexOf(b.Name);
            if (idx < 0)
                continue;
            compared++;
            float posErr = Vector3.Distance(scene.Skeleton.RestWorld[idx].Pos, b.RestWorldPos);
            if (posErr > posTol)
                failures.Add($"  {b.Name}: rest posErr={posErr:F3} cm (tol {posTol:F3})");
        }

        Assert.True(compared >= truth.Bones.Count * 0.8, "intersection too small");
        Assert.True(failures.Count == 0,
            $"{failures.Count}/{compared} rest positions out of tolerance:\n"
            + string.Join("\n", failures.Take(12)));
    }

    [Fact]
    public void Glb_Animation_MatchesBlenderTruth()
    {
        var scene = ImportGlb();
        var truth = LoadTruth();
        var clip = Assert.Single(scene.Clips);
        Assert.Equal(30f, clip.Fps);

        float height = SkeletonHeight(scene.Skeleton);
        float posTol = 0.005f * height;
        const float rotTolDeg = 0.5f;

        // 5 frames spread over the 40 extracted truth samples.
        int[] sampleFrames = { 0, 8, 16, 24, 32 };
        foreach (int f in sampleFrames)
        {
            Assert.True(f < truth.NSamples && f < clip.FrameCount,
                $"frame {f} out of range (truth {truth.NSamples}, clip {clip.FrameCount})");
            var worlds = new Pose(clip.Frames[f]).ToWorld(scene.Skeleton);
            var truthFrame = truth.Frames[f];

            var failures = new List<string>();
            int compared = 0;
            for (int i = 0; i < truth.Bones.Count; i++)
            {
                int idx = scene.Skeleton.IndexOf(truth.Bones[i].Name);
                if (idx < 0)
                    continue;
                compared++;
                float posErr = Vector3.Distance(worlds[idx].Pos, truthFrame[i].WorldPos);
                float rotErr = MathQ.AngleBetween(worlds[idx].Rot, truthFrame[i].WorldRot) * KDegPerRad;
                if (posErr > posTol || rotErr > rotTolDeg)
                    failures.Add(
                        $"  {truth.Bones[i].Name}: posErr={posErr:F3} cm (tol {posTol:F3}), rotErr={rotErr:F3} deg");
            }

            Assert.True(failures.Count == 0,
                $"frame {f}: {failures.Count}/{compared} joints out of tolerance:\n"
                + string.Join("\n", failures.Take(12)));
        }
    }

    // ---- 2. container handling ------------------------------------------------------------

    [Fact]
    public void Glb_MagicSniff_ImportsWithoutExtension()
    {
        // Unknown extension → the facade's content sniff must route GLB bytes to the
        // glTF importer (magic 'glTF' = 0x46546C67).
        var scene = Retargeter.ImportSource(Fixture("freebvh.glb"), "mystery.bin");
        Assert.True(scene.Skeleton.Count > 0);
        Assert.Single(scene.Clips);
    }

    [Fact]
    public void Glb_Truncated_ThrowsFormatException()
    {
        var full = Fixture("freebvh.glb");

        // Cut mid-BIN-chunk and mid-header.
        Assert.Throws<FormatException>(() => GltfImporter.Import(full.AsSpan(0, full.Length / 2).ToArray()));
        Assert.Throws<FormatException>(() => GltfImporter.Import(full.AsSpan(0, 8).ToArray()));
    }

    [Fact]
    public void NonGltfData_ThrowsFormatException()
    {
        Assert.Throws<FormatException>(() => GltfImporter.Import(Encoding.UTF8.GetBytes("not a gltf")));
        Assert.Throws<FormatException>(() => GltfImporter.Import(Encoding.UTF8.GetBytes("{\"foo\": 1}")));
        Assert.Throws<FormatException>(() => GltfImporter.Import(Array.Empty<byte>()));
    }

    // ---- 3. plain-JSON .gltf path (data: URI buffers, synthetic scenes) --------------------

    private static string Base64Floats(params float[] values)
    {
        var bytes = new byte[values.Length * 4];
        Buffer.BlockCopy(values, 0, bytes, 0, bytes.Length);
        return Convert.ToBase64String(bytes);
    }

    /// <summary>Two-node scene (root → child), one animation rotating the child 90° about X
    /// over 1 s; times/values delivered through a base64 data: URI buffer.</summary>
    private static string SyntheticGltf(string interpolation = "LINEAR")
    {
        float sin45 = MathF.Sin(MathF.PI / 4f), cos45 = MathF.Cos(MathF.PI / 4f);
        var times = Base64Floats(0f, 1f);
        var rotations = Base64Floats(0f, 0f, 0f, 1f, sin45, 0f, 0f, cos45);
        return $$"""
            {
                "asset": { "version": "2.0" },
                "scenes": [ { "nodes": [0] } ],
                "nodes": [
                    { "name": "root", "translation": [0, 1, 0], "children": [1] },
                    { "name": "child", "translation": [0, 0.5, 0] }
                ],
                "animations": [ {
                    "name": "spin",
                    "channels": [ { "sampler": 0, "target": { "node": 1, "path": "rotation" } } ],
                    "samplers": [ { "input": 0, "output": 1, "interpolation": "{{interpolation}}" } ]
                } ],
                "buffers": [
                    { "byteLength": 8, "uri": "data:application/octet-stream;base64,{{times}}" },
                    { "byteLength": 32, "uri": "data:application/octet-stream;base64,{{rotations}}" }
                ],
                "bufferViews": [
                    { "buffer": 0, "byteOffset": 0, "byteLength": 8 },
                    { "buffer": 1, "byteOffset": 0, "byteLength": 32 }
                ],
                "accessors": [
                    { "bufferView": 0, "componentType": 5126, "count": 2, "type": "SCALAR" },
                    { "bufferView": 1, "componentType": 5126, "count": 2, "type": "VEC4" }
                ]
            }
            """;
    }

    [Fact]
    public void GltfJson_DataUriBuffers_ImportAndResample()
    {
        var scene = GltfImporter.Import(Encoding.UTF8.GetBytes(SyntheticGltf()));

        // Skeleton = animated child + its root ancestor; meters → centimeters.
        Assert.Equal(2, scene.Skeleton.Count);
        int root = scene.Skeleton.IndexOf("root");
        int child = scene.Skeleton.IndexOf("child");
        Assert.True(root >= 0 && child >= 0);
        TestUtil.AssertVectorEqual(new Vector3(0f, 100f, 0f), scene.Skeleton.RestWorld[root].Pos, 1e-3f);
        TestUtil.AssertVectorEqual(new Vector3(0f, 150f, 0f), scene.Skeleton.RestWorld[child].Pos, 1e-3f);

        // 1 s at 30 fps → 31 frames; LINEAR slerps to 45° at the midpoint, 90° at the end.
        var clip = Assert.Single(scene.Clips);
        Assert.Equal("spin", clip.Name);
        Assert.Equal(31, clip.FrameCount);

        var rx90 = Quaternion.CreateFromAxisAngle(Vector3.UnitX, MathF.PI / 2f);
        var rx45 = Quaternion.CreateFromAxisAngle(Vector3.UnitX, MathF.PI / 4f);
        Assert.True(MathQ.AngleBetween(Quaternion.Identity, clip.Frames[0][child].Rot) * KDegPerRad < 0.01f);
        Assert.True(MathQ.AngleBetween(rx45, clip.Frames[15][child].Rot) * KDegPerRad < 0.01f);
        Assert.True(MathQ.AngleBetween(rx90, clip.Frames[30][child].Rot) * KDegPerRad < 0.01f);

        // The unanimated root keeps its rest TRS on every frame.
        TestUtil.AssertVectorEqual(new Vector3(0f, 100f, 0f), clip.Frames[15][root].Pos, 1e-3f);
    }

    [Fact]
    public void GltfJson_StepInterpolation_HoldsEarlierKey()
    {
        var scene = GltfImporter.Import(Encoding.UTF8.GetBytes(SyntheticGltf("STEP")));
        int child = scene.Skeleton.IndexOf("child");
        var clip = Assert.Single(scene.Clips);

        // Mid-range frames hold key 0; the final frame (exactly at key 1) lands on 90°.
        var rx90 = Quaternion.CreateFromAxisAngle(Vector3.UnitX, MathF.PI / 2f);
        Assert.True(MathQ.AngleBetween(Quaternion.Identity, clip.Frames[15][child].Rot) * KDegPerRad < 0.01f);
        Assert.True(MathQ.AngleBetween(rx90, clip.Frames[30][child].Rot) * KDegPerRad < 0.01f);
    }

    [Fact]
    public void GltfJson_CubicSpline_UsesValueElements()
    {
        // CUBICSPLINE keys are [in-tangent, value, out-tangent] triplets; the documented
        // simplification picks the VALUE element and interpolates linearly — exact at keys.
        var times = Base64Floats(0f, 1f);
        var translations = Base64Floats(
            9f, 9f, 9f, /* in-tangent (ignored) */ 1f, 2f, 3f, /* value */ 9f, 9f, 9f, /* out */
            9f, 9f, 9f, 4f, 5f, 6f, 9f, 9f, 9f);
        var json = $$"""
            {
                "asset": { "version": "2.0" },
                "nodes": [ { "name": "solo" } ],
                "animations": [ {
                    "channels": [ { "sampler": 0, "target": { "node": 0, "path": "translation" } } ],
                    "samplers": [ { "input": 0, "output": 1, "interpolation": "CUBICSPLINE" } ]
                } ],
                "buffers": [
                    { "byteLength": 8, "uri": "data:application/octet-stream;base64,{{times}}" },
                    { "byteLength": 72, "uri": "data:application/octet-stream;base64,{{translations}}" }
                ],
                "bufferViews": [
                    { "buffer": 0, "byteOffset": 0, "byteLength": 8 },
                    { "buffer": 1, "byteOffset": 0, "byteLength": 72 }
                ],
                "accessors": [
                    { "bufferView": 0, "componentType": 5126, "count": 2, "type": "SCALAR" },
                    { "bufferView": 1, "componentType": 5126, "count": 6, "type": "VEC3" }
                ]
            }
            """;

        var scene = GltfImporter.Import(Encoding.UTF8.GetBytes(json));
        var clip = Assert.Single(scene.Clips);
        int solo = scene.Skeleton.IndexOf("solo");

        // Values ×100 (m → cm); tangents (9,9,9) must never leak into the result.
        TestUtil.AssertVectorEqual(new Vector3(100f, 200f, 300f), clip.Frames[0][solo].Pos, 1e-3f);
        TestUtil.AssertVectorEqual(new Vector3(400f, 500f, 600f), clip.Frames[^1][solo].Pos, 1e-3f);
        TestUtil.AssertVectorEqual(new Vector3(250f, 350f, 450f), clip.Frames[15][solo].Pos, 0.5f);
    }

    [Fact]
    public void GltfJson_ExternalBufferUri_ThrowsWithGlbHint()
    {
        var json = """
            {
                "asset": { "version": "2.0" },
                "nodes": [ { "name": "root" } ],
                "skins": [ { "joints": [0] } ],
                "buffers": [ { "byteLength": 4, "uri": "buffer.bin" } ],
                "animations": [ {
                    "channels": [ { "sampler": 0, "target": { "node": 0, "path": "rotation" } } ],
                    "samplers": [ { "input": 0, "output": 1 } ]
                } ],
                "bufferViews": [ { "buffer": 0, "byteOffset": 0, "byteLength": 4 } ],
                "accessors": [
                    { "bufferView": 0, "componentType": 5126, "count": 1, "type": "SCALAR" },
                    { "bufferView": 0, "componentType": 5126, "count": 1, "type": "VEC4" }
                ]
            }
            """;
        var e = Assert.Throws<FormatException>(
            () => GltfImporter.Import(Encoding.UTF8.GetBytes(json)));
        Assert.Contains(".glb", e.Message);
        Assert.Contains("buffer.bin", e.Message);
    }

    [Fact]
    public void GltfJson_NoSkeletonNodes_ThrowsFormatException()
    {
        // Mesh-only scene: no skins, no animations → no skeleton to import.
        var json = """
            {
                "asset": { "version": "2.0" },
                "nodes": [ { "name": "meshNode", "mesh": 0 } ],
                "meshes": [ { "primitives": [] } ]
            }
            """;
        var e = Assert.Throws<FormatException>(
            () => GltfImporter.Import(Encoding.UTF8.GetBytes(json)));
        Assert.Contains("skeleton", e.Message, StringComparison.OrdinalIgnoreCase);
    }

    // ---- 4. end-to-end facade ----------------------------------------------------------------

    /// <summary>Resolves a repo-relative path by walking up to the .sbproj directory.</summary>
    private static string RepoFile(params string[] parts)
    {
        var dir = new DirectoryInfo(AppContext.BaseDirectory);
        while (dir is not null)
        {
            if (File.Exists(Path.Combine(dir.FullName, "humanoid-retargeter.sbproj")))
                return Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
            dir = dir.Parent!;
        }
        throw new InvalidOperationException("Repo root (humanoid-retargeter.sbproj) not found.");
    }

    [Fact]
    public void Facade_ConvertGlbToSboxTarget_SucceedsWithDetectedProfile()
    {
        var target = RetargetTargetSpec.SboxDefault(
            File.ReadAllText(RepoFile("Assets", "data", "humanoid_retargeter", "target_rig_sbox.json")));

        var result = Retargeter.Convert(new RetargetRequest
        {
            SourceData = Fixture("freebvh.glb"),
            SourceFileName = "freebvh.glb",
        }, target);

        Assert.True(result.Success, string.Join("; ", result.Errors));
        var clip = Assert.Single(result.Clips);
        Assert.True(clip.Success, clip.Error);
        Assert.False(string.IsNullOrEmpty(clip.DmxContent));
        Assert.Contains("pelvis_p", clip.DmxContent);

        // The rig keeps its mixamorig names through Blender's glTF export → the shipped
        // mixamo preset must detect at preset-grade confidence.
        Assert.NotNull(clip.Mapping);
        Assert.Equal("mixamo", clip.Mapping!.ProfileName);
        Assert.False(clip.Mapping.NeedsUserDecision);
        Assert.True(clip.Mapping.Confidence >= 0.8f, $"confidence {clip.Mapping.Confidence}");

        // Inspect (UI listing path) sees the same profile and the single take.
        var inspected = Retargeter.Inspect(Fixture("freebvh.glb"), "freebvh.glb");
        Assert.Equal("mixamo", inspected.Mapping.ProfileName);
        Assert.Equal(1, inspected.TakeCount);
    }

    // ---- 5. robustness: hostile / malformed files --------------------------------------------
    // Contract: anything malformed throws FormatException — never StackOverflow (uncatchable,
    // kills the host editor), never OverflowException, never an OOM allocation bomb.

    [Fact]
    public void GltfJson_CyclicChildrenGraph_ThrowsFormatException()
    {
        // B and C list each other as children (a cycle reachable from "root"): the node DFS
        // must detect the revisit instead of recursing unboundedly (StackOverflow).
        var json = """
            {
                "asset": { "version": "2.0" },
                "nodes": [
                    { "name": "B", "children": [1] },
                    { "name": "C", "children": [0] },
                    { "name": "root", "children": [0] }
                ],
                "skins": [ { "joints": [0, 1] } ]
            }
            """;
        var e = Assert.Throws<FormatException>(
            () => GltfImporter.Import(Encoding.UTF8.GetBytes(json)));
        Assert.Contains("cycle", e.Message, StringComparison.OrdinalIgnoreCase);
    }

    [Fact]
    public void GltfJson_SelfParentedAncestorChain_ThrowsFormatException()
    {
        // Z lists ITSELF as a child (self-parent) and also parents the skinned joint B; Z is
        // unreachable from the root, so the nearest-kept-ancestor walk from B (B.Parent = Z,
        // Z.Parent = Z) would loop forever without a visited guard.
        var json = """
            {
                "asset": { "version": "2.0" },
                "nodes": [
                    { "name": "Z", "children": [0, 2] },
                    { "name": "root", "children": [2] },
                    { "name": "B" }
                ],
                "skins": [ { "joints": [2] } ]
            }
            """;
        var e = Assert.Throws<FormatException>(
            () => GltfImporter.Import(Encoding.UTF8.GetBytes(json)));
        Assert.Contains("cycle", e.Message, StringComparison.OrdinalIgnoreCase);
    }

    /// <summary>Synthetic one-node scene whose sampler accessors carry an arbitrary count
    /// (the times buffer really holds 2 keys = 8 bytes).</summary>
    private static string GltfWithAccessorCount(string count, bool withBufferView = true)
    {
        var times = Base64Floats(0f, 1f);
        var rotations = Base64Floats(0f, 0f, 0f, 1f, 0f, 0f, 0f, 1f);
        var timesView = withBufferView ? "\"bufferView\": 0, " : "";
        return $$"""
            {
                "asset": { "version": "2.0" },
                "nodes": [ { "name": "solo" } ],
                "animations": [ {
                    "channels": [ { "sampler": 0, "target": { "node": 0, "path": "rotation" } } ],
                    "samplers": [ { "input": 0, "output": 1, "interpolation": "LINEAR" } ]
                } ],
                "buffers": [
                    { "byteLength": 8, "uri": "data:application/octet-stream;base64,{{times}}" },
                    { "byteLength": 32, "uri": "data:application/octet-stream;base64,{{rotations}}" }
                ],
                "bufferViews": [
                    { "buffer": 0, "byteOffset": 0, "byteLength": 8 },
                    { "buffer": 1, "byteOffset": 0, "byteLength": 32 }
                ],
                "accessors": [
                    { {{timesView}}"componentType": 5126, "count": {{count}}, "type": "SCALAR" },
                    { "bufferView": 1, "componentType": 5126, "count": 2, "type": "VEC4" }
                ]
            }
            """;
    }

    [Fact]
    public void GltfJson_NegativeAccessorCount_ThrowsFormatException()
    {
        // count = -1 would throw OverflowException from the array allocation (breaking the
        // FormatException malformed-file contract) without up-front validation.
        var e = Assert.Throws<FormatException>(() => GltfImporter.Import(
            Encoding.UTF8.GetBytes(GltfWithAccessorCount("-1"))));
        Assert.Contains("count", e.Message, StringComparison.OrdinalIgnoreCase);
    }

    [Fact]
    public void GltfJson_HugeAccessorCount_ThrowsFormatException_BeforeAllocating()
    {
        // count = 4e8 on an 8-byte buffer: the bounds check must run BEFORE the allocation
        // (1.6 GB) — a fast FormatException here is itself the no-OOM proof.
        Assert.Throws<FormatException>(() => GltfImporter.Import(
            Encoding.UTF8.GetBytes(GltfWithAccessorCount("400000000"))));

        // bufferView-less accessors are zero-filled per spec, but the count is then backed
        // by nothing: it must be capped by the file's total buffer payload.
        Assert.Throws<FormatException>(() => GltfImporter.Import(
            Encoding.UTF8.GetBytes(GltfWithAccessorCount("400000000", withBufferView: false))));
    }

    [Fact]
    public void GltfJson_HugeKeyTimeSpan_ThrowsFormatException()
    {
        // Two keys spanning 1e7 s would resample to 3e8 frames at 30 fps (OOM), and the
        // unchecked double→int conversion could wrap and get masked to a 1-frame clip.
        var times = Base64Floats(0f, 1e7f);
        var rotations = Base64Floats(0f, 0f, 0f, 1f, 0f, 0f, 0f, 1f);
        var json = $$"""
            {
                "asset": { "version": "2.0" },
                "nodes": [ { "name": "solo" } ],
                "animations": [ {
                    "channels": [ { "sampler": 0, "target": { "node": 0, "path": "rotation" } } ],
                    "samplers": [ { "input": 0, "output": 1, "interpolation": "LINEAR" } ]
                } ],
                "buffers": [
                    { "byteLength": 8, "uri": "data:application/octet-stream;base64,{{times}}" },
                    { "byteLength": 32, "uri": "data:application/octet-stream;base64,{{rotations}}" }
                ],
                "bufferViews": [
                    { "buffer": 0, "byteOffset": 0, "byteLength": 8 },
                    { "buffer": 1, "byteOffset": 0, "byteLength": 32 }
                ],
                "accessors": [
                    { "bufferView": 0, "componentType": 5126, "count": 2, "type": "SCALAR" },
                    { "bufferView": 1, "componentType": 5126, "count": 2, "type": "VEC4" }
                ]
            }
            """;
        var e = Assert.Throws<FormatException>(
            () => GltfImporter.Import(Encoding.UTF8.GetBytes(json)));
        // The offending span must be named in the message.
        Assert.Contains("span", e.Message, StringComparison.OrdinalIgnoreCase);
        Assert.Contains("10000000", e.Message);
    }

    // ---- 6. resampling ----------------------------------------------------------------------

    [Theory]
    [InlineData(30f)]
    [InlineData(60f)]
    [InlineData(15f)]
    public void Glb_Resampling_FrameCountMatchesGrid(float fps)
    {
        var scene = ImportGlb(fps);
        var clip = Assert.Single(scene.Clips);
        Assert.Equal(fps, clip.Fps);

        // freebvh: 69 baked keys at ~30 fps → duration ≈ 68/30.00003 s.
        double duration = 68.0 / 30.000030;
        Assert.Equal((int)Math.Round(duration * fps) + 1, clip.FrameCount);
    }
}