AutoRig/Formats/Gltf/GltfDocument.cs

glTF importer classes and parser. Defines GltfNode/Primitive/Material/Skin/Mesh and GltfDocument.Parse which reads GLB or .gltf JSON, resolves buffers (embedded base64 or GLB BIN chunk), decodes nodes, meshes, materials and skins into in-memory arrays.

File AccessNetworking
using System.Numerics;
using System.Text;
using System.Text.Json;

namespace AutoRig.Formats.Gltf;

// s&box compat: the engine defines Vector2/Vector3 in the GLOBAL namespace, which
// shadows using-directive imports - alias explicitly to System.Numerics.
using Vector2 = System.Numerics.Vector2;
using Vector3 = System.Numerics.Vector3;


/// <summary>One glTF node: hierarchy + local transform + optional mesh reference.</summary>
internal sealed class GltfNode
{
    public string? Name;
    public int[] Children = [];
    public int Parent = -1;
    public int Mesh = -1;
    public int Skin = -1;

    public Vector3 Translation;
    public Quaternion Rotation = Quaternion.Identity;
    public Vector3 Scale = Vector3.One;

    /// <summary>Local transform as a row-vector matrix (v * M).</summary>
    public Matrix4x4 LocalMatrix =>
        Matrix4x4.CreateScale( Scale )
        * Matrix4x4.CreateFromQuaternion( Rotation )
        * Matrix4x4.CreateTranslation( Translation );
}

/// <summary>One triangle primitive of a glTF mesh, decoded to flat arrays.</summary>
internal sealed class GltfPrimitive
{
    public required float[] Positions;   // xyz per vertex
    public float[]? Normals;             // xyz per vertex
    public float[]? Uvs;                 // uv per vertex
    public required int[] Indices;       // 3 per triangle
    public int[]? Joints;                // 4 skin-local joint ids per vertex (JOINTS_0)
    public float[]? Weights;             // 4 weights per vertex (WEIGHTS_0)
    public int Material = -1;            // index into GltfDocument.Materials
}

/// <summary>One glTF material, reduced to what export carries through.</summary>
internal sealed class GltfMaterial
{
    public string Name = "";
    public byte[]? BaseColorImage;       // png/jpg bytes as stored
    public System.Numerics.Vector3 BaseColorFactor = new( 1f, 1f, 1f );
}

/// <summary>One glTF skin: joint node ids and optional inverse bind matrices.</summary>
internal sealed class GltfSkin
{
    public required int[] Joints;                 // node indices
    public Matrix4x4[]? InverseBindMatrices;      // parallel to Joints (row-vector layout)
}

/// <summary>One glTF mesh: a name and its triangle primitives.</summary>
internal sealed class GltfMesh
{
    public string? Name;
    public List<GltfPrimitive> Primitives { get; } = new();
}

/// <summary>
/// Container + JSON layer of the glTF mesh importer (adapted from humanoid-retargeter's
/// GltfDocument, same author): parses a GLB container or plain .gltf JSON, resolves
/// buffers (GLB BIN chunk and base64 data: URIs; external file URIs throw — no file IO
/// in Code/), and decodes nodes and triangle-mesh primitives (POSITION/NORMAL/TEXCOORD_0
/// + indices). Throws <see cref="FormatException"/> on anything malformed.
/// </summary>
internal sealed class GltfDocument
{
    private const uint GlbMagic = 0x46546C67;     // 'glTF' little-endian
    private const uint ChunkJson = 0x4E4F534A;    // 'JSON'
    private const uint ChunkBin = 0x004E4942;     // 'BIN\0'

    public List<GltfNode> Nodes { get; } = new();
    public List<GltfMesh> Meshes { get; } = new();
    public List<GltfSkin> Skins { get; } = new();
    public List<GltfMaterial> Materials { get; } = new();

    private GltfDocument()
    {
    }

    /// <summary>Parses GLB or plain-JSON glTF bytes.</summary>
    /// <exception cref="FormatException">Truncated/malformed container, invalid JSON,
    /// unresolvable buffers, or unsupported accessor layouts.</exception>
    public static GltfDocument Parse( byte[] data )
    {
        ArgumentNullException.ThrowIfNull( data );

        byte[] json;
        byte[]? bin = null;
        if ( data.Length >= 4 && ReadU32( data, 0 ) == GlbMagic )
            (json, bin) = ParseGlbContainer( data );
        else
            json = data;

        JsonElement root;
        try
        {
            var text = Encoding.UTF8.GetString( json ).TrimStart( '' );
            using var doc = JsonDocument.Parse( text );
            root = doc.RootElement.Clone();
        }
        catch ( JsonException e )
        {
            throw new FormatException( $"glTF: invalid JSON ({e.Message})" );
        }

        if ( root.ValueKind != JsonValueKind.Object || !root.TryGetProperty( "asset", out _ ) )
            throw new FormatException( "glTF: missing required 'asset' object (not a glTF file?)." );

        var document = new GltfDocument();
        var buffers = ResolveBuffers( root, bin );
        document.ReadNodes( root );
        document.ReadMeshes( root, buffers );
        document.ReadSkins( root, buffers );
        document.ReadMaterials( root, buffers );
        return document;
    }

    // ================================================================== GLB container

    private static (byte[] Json, byte[]? Bin) ParseGlbContainer( byte[] data )
    {
        if ( data.Length < 12 )
            throw new FormatException( "GLB: truncated header (need 12 bytes)." );

        var version = ReadU32( data, 4 );
        if ( version != 2 )
            throw new FormatException( $"GLB: unsupported container version {version} (expected 2)." );

        long declared = ReadU32( data, 8 );
        if ( declared > data.Length )
            throw new FormatException(
                $"GLB: truncated file (header declares {declared} bytes, got {data.Length})." );

        byte[]? json = null, bin = null;
        long offset = 12;
        while ( offset + 8 <= declared )
        {
            long length = ReadU32( data, (int)offset );
            var type = ReadU32( data, (int)offset + 4 );
            offset += 8;
            if ( offset + length > data.Length )
                throw new FormatException( "GLB: truncated chunk (declared length exceeds the file)." );

            if ( type == ChunkJson && json is null )
                json = data.AsSpan( (int)offset, (int)length ).ToArray();
            else if ( type == ChunkBin && bin is null )
                bin = data.AsSpan( (int)offset, (int)length ).ToArray();

            offset += length + (length % 4 == 0 ? 0 : 4 - length % 4);
        }

        if ( json is null )
            throw new FormatException( "GLB: no JSON chunk found." );
        return (json, bin);
    }

    private static uint ReadU32( byte[] data, int offset )
        => (uint)(data[offset] | data[offset + 1] << 8 | data[offset + 2] << 16 | data[offset + 3] << 24);

    // ================================================================== buffers

    private static List<byte[]> ResolveBuffers( JsonElement root, byte[]? bin )
    {
        var buffers = new List<byte[]>();
        if ( !root.TryGetProperty( "buffers", out var array ) || array.ValueKind != JsonValueKind.Array )
            return buffers;

        foreach ( var buffer in array.EnumerateArray() )
        {
            if ( !buffer.TryGetProperty( "uri", out var uriProp ) )
            {
                buffers.Add( bin ?? throw new FormatException(
                    "glTF: buffer has no uri but the file has no GLB BIN chunk." ) );
                continue;
            }

            var uri = uriProp.GetString() ?? "";
            if ( uri.StartsWith( "data:", StringComparison.OrdinalIgnoreCase ) )
            {
                var comma = uri.IndexOf( ',' );
                if ( comma < 0 || !uri[..comma].EndsWith( ";base64", StringComparison.OrdinalIgnoreCase ) )
                    throw new FormatException( "glTF: only base64 data: URIs are supported for buffers." );
                try
                {
                    buffers.Add( Convert.FromBase64String( uri[(comma + 1)..] ) );
                }
                catch ( Exception e ) when ( e is FormatException or ArgumentException )
                {
                    throw new FormatException( "glTF: invalid base64 in buffer data: URI." );
                }
            }
            else
            {
                throw new FormatException(
                    $"glTF: buffer references an external file ('{uri}') which this importer cannot "
                    + "read (no file IO). Export as .glb (binary, self-contained) instead." );
            }
        }
        return buffers;
    }

    // ================================================================== nodes

    private void ReadNodes( JsonElement root )
    {
        if ( !root.TryGetProperty( "nodes", out var array ) || array.ValueKind != JsonValueKind.Array )
            return;

        Span<float> m = stackalloc float[16];
        foreach ( var n in array.EnumerateArray() )
        {
            var node = new GltfNode
            {
                Name = n.TryGetProperty( "name", out var name ) ? name.GetString() : null,
                Mesh = n.TryGetProperty( "mesh", out var mesh ) && mesh.TryGetInt32( out var mi ) ? mi : -1,
                Skin = n.TryGetProperty( "skin", out var skin ) && skin.TryGetInt32( out var si ) ? si : -1,
            };

            if ( n.TryGetProperty( "children", out var children ) && children.ValueKind == JsonValueKind.Array )
            {
                var list = new List<int>();
                foreach ( var c in children.EnumerateArray() )
                    list.Add( c.GetInt32() );
                node.Children = list.ToArray();
            }

            if ( n.TryGetProperty( "matrix", out var matrix ) && matrix.ValueKind == JsonValueKind.Array )
            {
                var i = 0;
                foreach ( var v in matrix.EnumerateArray() )
                {
                    if ( i >= 16 ) break;
                    m[i++] = v.GetSingle();
                }
                if ( i < 16 )
                    throw new FormatException( "glTF: node matrix has fewer than 16 elements." );
                var local = new Matrix4x4(
                    m[0], m[1], m[2], m[3],
                    m[4], m[5], m[6], m[7],
                    m[8], m[9], m[10], m[11],
                    m[12], m[13], m[14], m[15] );
                if ( Matrix4x4.Decompose( local, out var scale, out var rot, out var pos ) )
                {
                    node.Translation = pos;
                    node.Rotation = rot;
                    node.Scale = scale;
                }
                else
                {
                    node.Translation = local.Translation;
                }
            }
            else
            {
                node.Translation = ReadVec3( n, "translation", Vector3.Zero );
                node.Scale = ReadVec3( n, "scale", Vector3.One );
                if ( n.TryGetProperty( "rotation", out var r ) && r.ValueKind == JsonValueKind.Array
                    && r.GetArrayLength() >= 4 )
                {
                    node.Rotation = new Quaternion(
                        r[0].GetSingle(), r[1].GetSingle(), r[2].GetSingle(), r[3].GetSingle() );
                }
            }

            Nodes.Add( node );
        }

        for ( var i = 0; i < Nodes.Count; i++ )
        {
            foreach ( var child in Nodes[i].Children )
            {
                if ( child < 0 || child >= Nodes.Count )
                    throw new FormatException( $"glTF: node {i} references nonexistent child {child}." );
                if ( Nodes[child].Parent < 0 )
                    Nodes[child].Parent = i;
            }
        }
    }

    private static Vector3 ReadVec3( JsonElement element, string property, Vector3 fallback )
    {
        if ( !element.TryGetProperty( property, out var v ) || v.ValueKind != JsonValueKind.Array
            || v.GetArrayLength() < 3 )
            return fallback;
        return new Vector3( v[0].GetSingle(), v[1].GetSingle(), v[2].GetSingle() );
    }

    // ================================================================== meshes

    private void ReadMeshes( JsonElement root, List<byte[]> buffers )
    {
        if ( !root.TryGetProperty( "meshes", out var array ) || array.ValueKind != JsonValueKind.Array )
            return;

        root.TryGetProperty( "accessors", out var accessors );
        root.TryGetProperty( "bufferViews", out var views );

        foreach ( var m in array.EnumerateArray() )
        {
            var mesh = new GltfMesh
            {
                Name = m.TryGetProperty( "name", out var name ) ? name.GetString() : null,
            };

            if ( m.TryGetProperty( "primitives", out var primitives )
                && primitives.ValueKind == JsonValueKind.Array )
            {
                foreach ( var p in primitives.EnumerateArray() )
                {
                    var mode = p.TryGetProperty( "mode", out var modeProp ) ? modeProp.GetInt32() : 4;
                    if ( mode != 4 )
                        continue; // only TRIANGLES

                    if ( !p.TryGetProperty( "attributes", out var attributes )
                        || !attributes.TryGetProperty( "POSITION", out var posProp ) )
                        continue;

                    var positions = ReadFloats( accessors, views, buffers, posProp.GetInt32(), 3 );
                    var vertexCount = positions.Length / 3;

                    float[]? normals = null;
                    if ( attributes.TryGetProperty( "NORMAL", out var normProp ) )
                    {
                        normals = ReadFloats( accessors, views, buffers, normProp.GetInt32(), 3 );
                        if ( normals.Length != positions.Length ) normals = null;
                    }

                    float[]? uvs = null;
                    if ( attributes.TryGetProperty( "TEXCOORD_0", out var uvProp )
                        && TryReadFloatUvs( accessors, views, buffers, uvProp.GetInt32(), out var read )
                        && read.Length == vertexCount * 2 )
                    {
                        // glTF UV origin is TOP-left; the rest of the pipeline (FBX
                        // export especially) is bottom-left - flip V here or every
                        // texture maps upside down/garbled on the compiled model.
                        for ( var uv = 1; uv < read.Length; uv += 2 )
                            read[uv] = 1f - read[uv];
                        uvs = read;
                    }

                    int[] indices;
                    if ( p.TryGetProperty( "indices", out var idxProp ) )
                        indices = ReadIndices( accessors, views, buffers, idxProp.GetInt32(), vertexCount );
                    else
                    {
                        indices = new int[vertexCount - vertexCount % 3];
                        for ( var i = 0; i < indices.Length; i++ ) indices[i] = i;
                    }

                    int[]? joints = null;
                    float[]? weights = null;
                    if ( attributes.TryGetProperty( "JOINTS_0", out var jointsProp )
                        && attributes.TryGetProperty( "WEIGHTS_0", out var weightsProp ) )
                    {
                        joints = ReadJoints( accessors, views, buffers, jointsProp.GetInt32() );
                        weights = ReadWeights( accessors, views, buffers, weightsProp.GetInt32() );
                        if ( joints.Length != vertexCount * 4 || weights.Length != vertexCount * 4 )
                        {
                            joints = null;
                            weights = null;
                        }
                    }

                    mesh.Primitives.Add( new GltfPrimitive
                    {
                        Positions = positions,
                        Normals = normals,
                        Uvs = uvs,
                        Indices = indices,
                        Joints = joints,
                        Weights = weights,
                        Material = p.TryGetProperty( "material", out var mat )
                            && mat.TryGetInt32( out var materialIndex ) ? materialIndex : -1,
                    } );
                }
            }

            Meshes.Add( mesh );
        }
    }

    // ================================================================== materials

    /// <summary>materials → base color factor + texture image bytes (via textures[].source
    /// and images[].bufferView). External image URIs are skipped, not fatal.</summary>
    private void ReadMaterials( JsonElement root, List<byte[]> buffers )
    {
        if ( !root.TryGetProperty( "materials", out var array ) || array.ValueKind != JsonValueKind.Array )
            return;
        root.TryGetProperty( "textures", out var textures );
        root.TryGetProperty( "images", out var images );
        root.TryGetProperty( "bufferViews", out var views );

        byte[]? ImageBytes( int textureIndex )
        {
            if ( textures.ValueKind != JsonValueKind.Array || textureIndex < 0
                || textureIndex >= textures.GetArrayLength()
                || !textures[textureIndex].TryGetProperty( "source", out var sourceProp )
                || images.ValueKind != JsonValueKind.Array )
                return null;
            var imageIndex = sourceProp.GetInt32();
            if ( imageIndex < 0 || imageIndex >= images.GetArrayLength()
                || !images[imageIndex].TryGetProperty( "bufferView", out var viewProp )
                || views.ValueKind != JsonValueKind.Array )
                return null;
            var view = views[viewProp.GetInt32()];
            var buffer = buffers[RequiredInt( view, "buffer", "bufferView" )];
            var offset = view.TryGetProperty( "byteOffset", out var vo ) ? vo.GetInt32() : 0;
            var length = RequiredInt( view, "byteLength", "bufferView" );
            if ( offset < 0 || offset + length > buffer.Length )
                return null;
            return buffer.AsSpan( offset, length ).ToArray();
        }

        var index = 0;
        foreach ( var m in array.EnumerateArray() )
        {
            var material = new GltfMaterial
            {
                Name = m.TryGetProperty( "name", out var name )
                    ? name.GetString() ?? $"material{index}" : $"material{index}",
            };
            if ( m.TryGetProperty( "pbrMetallicRoughness", out var pbr ) )
            {
                if ( pbr.TryGetProperty( "baseColorFactor", out var factor )
                    && factor.ValueKind == JsonValueKind.Array && factor.GetArrayLength() >= 3 )
                    material.BaseColorFactor = new System.Numerics.Vector3(
                        factor[0].GetSingle(), factor[1].GetSingle(), factor[2].GetSingle() );
                if ( pbr.TryGetProperty( "baseColorTexture", out var tex )
                    && tex.TryGetProperty( "index", out var texIndex ) )
                    material.BaseColorImage = ImageBytes( texIndex.GetInt32() );
            }
            Materials.Add( material );
            index++;
        }
    }

    // ================================================================== skins

    private void ReadSkins( JsonElement root, List<byte[]> buffers )
    {
        if ( !root.TryGetProperty( "skins", out var array ) || array.ValueKind != JsonValueKind.Array )
            return;

        root.TryGetProperty( "accessors", out var accessors );
        root.TryGetProperty( "bufferViews", out var views );

        foreach ( var s in array.EnumerateArray() )
        {
            if ( !s.TryGetProperty( "joints", out var jointsProp )
                || jointsProp.ValueKind != JsonValueKind.Array )
                throw new FormatException( "glTF: skin has no joints array." );

            var joints = new List<int>();
            foreach ( var j in jointsProp.EnumerateArray() )
            {
                var index = j.GetInt32();
                if ( index < 0 || index >= Nodes.Count )
                    throw new FormatException( $"glTF: skin references nonexistent joint node {index}." );
                joints.Add( index );
            }

            Matrix4x4[]? inverseBind = null;
            if ( s.TryGetProperty( "inverseBindMatrices", out var ibmProp ) )
            {
                var values = ReadFloats( accessors, views, buffers, ibmProp.GetInt32(), 16 );
                if ( values.Length != joints.Count * 16 )
                    throw new FormatException(
                        $"glTF: skin has {joints.Count} joints but {values.Length / 16} inverse bind matrices." );
                inverseBind = new Matrix4x4[joints.Count];
                for ( var i = 0; i < joints.Count; i++ )
                {
                    var o = i * 16;
                    inverseBind[i] = new Matrix4x4(
                        values[o], values[o + 1], values[o + 2], values[o + 3],
                        values[o + 4], values[o + 5], values[o + 6], values[o + 7],
                        values[o + 8], values[o + 9], values[o + 10], values[o + 11],
                        values[o + 12], values[o + 13], values[o + 14], values[o + 15] );
                }
            }

            Skins.Add( new GltfSkin { Joints = joints.ToArray(), InverseBindMatrices = inverseBind } );
        }
    }

    /// <summary>JOINTS_0: u8/u16 VEC4 per vertex.</summary>
    private static int[] ReadJoints( JsonElement accessors, JsonElement views, List<byte[]> buffers,
        int accessorIndex )
    {
        if ( accessors.ValueKind != JsonValueKind.Array || accessorIndex < 0
            || accessorIndex >= accessors.GetArrayLength() )
            throw new FormatException( $"glTF: accessor {accessorIndex} does not exist." );
        var componentType = RequiredInt( accessors[accessorIndex], "componentType", "accessor" );
        var compSize = componentType switch
        {
            5121 => 1, // UNSIGNED_BYTE
            5123 => 2, // UNSIGNED_SHORT
            _ => throw new FormatException(
                $"glTF: unsupported JOINTS_0 componentType {componentType}." ),
        };

        var (_, buffer, start, stride, count) =
            ResolveAccessor( accessors, views, buffers, accessorIndex, compSize * 4 );

        var result = new int[checked(count * 4)];
        for ( var element = 0; element < count; element++ )
        {
            var offset = (int)(start + (long)element * stride);
            for ( var c = 0; c < 4; c++ )
                result[element * 4 + c] = compSize == 1
                    ? buffer[offset + c]
                    : BitConverter.ToUInt16( buffer, offset + c * 2 );
        }
        return result;
    }

    /// <summary>WEIGHTS_0: f32 or normalized u8/u16 VEC4 per vertex.</summary>
    private static float[] ReadWeights( JsonElement accessors, JsonElement views, List<byte[]> buffers,
        int accessorIndex )
    {
        if ( accessors.ValueKind != JsonValueKind.Array || accessorIndex < 0
            || accessorIndex >= accessors.GetArrayLength() )
            throw new FormatException( $"glTF: accessor {accessorIndex} does not exist." );
        var componentType = RequiredInt( accessors[accessorIndex], "componentType", "accessor" );
        if ( componentType == 5126 )
            return ReadFloats( accessors, views, buffers, accessorIndex, 4 );

        var (compSize, divisor) = componentType switch
        {
            5121 => (1, 255f),    // UNSIGNED_BYTE normalized
            5123 => (2, 65535f),  // UNSIGNED_SHORT normalized
            _ => throw new FormatException(
                $"glTF: unsupported WEIGHTS_0 componentType {componentType}." ),
        };

        var (_, buffer, start, stride, count) =
            ResolveAccessor( accessors, views, buffers, accessorIndex, compSize * 4 );

        var result = new float[checked(count * 4)];
        for ( var element = 0; element < count; element++ )
        {
            var offset = (int)(start + (long)element * stride);
            for ( var c = 0; c < 4; c++ )
                result[element * 4 + c] = (compSize == 1
                    ? buffer[offset + c]
                    : BitConverter.ToUInt16( buffer, offset + c * 2 )) / divisor;
        }
        return result;
    }

    // ================================================================== accessors

    private static int RequiredInt( JsonElement element, string property, string context )
    {
        if ( !element.TryGetProperty( property, out var v ) )
            throw new FormatException( $"glTF: {context} is missing '{property}'." );
        return v.GetInt32();
    }

    private static (JsonElement Accessor, byte[] Buffer, long Start, int Stride, int Count)
        ResolveAccessor( JsonElement accessors, JsonElement views, List<byte[]> buffers,
            int accessorIndex, int elementSize )
    {
        if ( accessors.ValueKind != JsonValueKind.Array || accessorIndex < 0
            || accessorIndex >= accessors.GetArrayLength() )
            throw new FormatException( $"glTF: accessor {accessorIndex} does not exist." );
        var accessor = accessors[accessorIndex];

        if ( accessor.TryGetProperty( "sparse", out _ ) )
            throw new FormatException( "glTF: sparse accessors are not supported." );

        var count = RequiredInt( accessor, "count", "accessor" );
        if ( count < 0 )
            throw new FormatException( $"glTF: accessor {accessorIndex} has a negative count ({count})." );

        if ( !accessor.TryGetProperty( "bufferView", out var viewIndexProp ) )
            throw new FormatException( $"glTF: accessor {accessorIndex} has no bufferView (unsupported for meshes)." );

        var viewIndex = viewIndexProp.GetInt32();
        if ( views.ValueKind != JsonValueKind.Array || viewIndex < 0 || viewIndex >= views.GetArrayLength() )
            throw new FormatException( $"glTF: bufferView {viewIndex} does not exist." );
        var view = views[viewIndex];

        var bufferIndex = RequiredInt( view, "buffer", "bufferView" );
        if ( bufferIndex < 0 || bufferIndex >= buffers.Count )
            throw new FormatException( $"glTF: buffer {bufferIndex} does not exist." );
        var buffer = buffers[bufferIndex];

        var viewOffset = view.TryGetProperty( "byteOffset", out var vo ) ? vo.GetInt32() : 0;
        var accessorOffset = accessor.TryGetProperty( "byteOffset", out var ao ) ? ao.GetInt32() : 0;
        var stride = view.TryGetProperty( "byteStride", out var st ) ? st.GetInt32() : elementSize;
        if ( stride < elementSize )
            throw new FormatException( "glTF: bufferView byteStride is smaller than the element size." );

        var start = (long)viewOffset + accessorOffset;
        var end = start + (long)(count - 1) * stride + elementSize;
        if ( count > 0 && (start < 0 || end > buffer.Length) )
            throw new FormatException(
                $"glTF: accessor {accessorIndex} reads past the end of its buffer (truncated file?)." );

        return (accessor, buffer, start, stride, count);
    }

    /// <summary>Reads a float accessor with the given component count (VEC2=2, VEC3=3).</summary>
    private static float[] ReadFloats( JsonElement accessors, JsonElement views, List<byte[]> buffers,
        int accessorIndex, int comps )
    {
        if ( accessors.ValueKind != JsonValueKind.Array || accessorIndex < 0
            || accessorIndex >= accessors.GetArrayLength() )
            throw new FormatException( $"glTF: accessor {accessorIndex} does not exist." );
        var componentType = RequiredInt( accessors[accessorIndex], "componentType", "accessor" );
        if ( componentType != 5126 )
            throw new FormatException( $"glTF: accessor {accessorIndex} must be FLOAT for mesh attributes." );

        var (_, buffer, start, stride, count) =
            ResolveAccessor( accessors, views, buffers, accessorIndex, comps * 4 );

        var result = new float[checked(count * comps)];
        for ( var element = 0; element < count; element++ )
        {
            var offset = (int)(start + (long)element * stride);
            for ( var c = 0; c < comps; c++ )
                result[element * comps + c] = BitConverter.ToSingle( buffer, offset + c * 4 );
        }
        return result;
    }

    private static bool TryReadFloatUvs( JsonElement accessors, JsonElement views, List<byte[]> buffers,
        int accessorIndex, out float[] uvs )
    {
        try
        {
            uvs = ReadFloats( accessors, views, buffers, accessorIndex, 2 );
            return true;
        }
        catch ( FormatException )
        {
            uvs = [];
            return false; // normalized-integer UVs etc. — skip UVs rather than fail the mesh
        }
    }

    /// <summary>Reads an index accessor (u8/u16/u32 SCALAR) and validates the index range.</summary>
    private static int[] ReadIndices( JsonElement accessors, JsonElement views, List<byte[]> buffers,
        int accessorIndex, int vertexCount )
    {
        if ( accessors.ValueKind != JsonValueKind.Array || accessorIndex < 0
            || accessorIndex >= accessors.GetArrayLength() )
            throw new FormatException( $"glTF: accessor {accessorIndex} does not exist." );
        var componentType = RequiredInt( accessors[accessorIndex], "componentType", "accessor" );
        var compSize = componentType switch
        {
            5121 => 1, // UNSIGNED_BYTE
            5123 => 2, // UNSIGNED_SHORT
            5125 => 4, // UNSIGNED_INT
            _ => throw new FormatException(
                $"glTF: unsupported index componentType {componentType}." ),
        };

        var (_, buffer, start, stride, count) =
            ResolveAccessor( accessors, views, buffers, accessorIndex, compSize );

        var result = new int[count - count % 3];
        for ( var i = 0; i < result.Length; i++ )
        {
            var offset = (int)(start + (long)i * stride);
            var value = componentType switch
            {
                5121 => buffer[offset],
                5123 => BitConverter.ToUInt16( buffer, offset ),
                _ => (long)BitConverter.ToUInt32( buffer, offset ),
            };
            if ( value < 0 || value >= vertexCount )
                throw new FormatException( $"glTF: index {value} out of range (have {vertexCount} vertices)." );
            result[i] = (int)value;
        }
        return result;
    }
}