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.
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);
}
}