Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.08% covered (warning)
84.08%
396 / 471
43.75% covered (danger)
43.75%
7 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
GLTFParser
84.08% covered (warning)
84.08%
396 / 471
43.75% covered (danger)
43.75%
7 / 16
351.11
0.00% covered (danger)
0.00%
0 / 1
 inferBinary
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 decodeJsonArray
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 __construct
100.00% covered (success)
100.00%
56 / 56
100.00% covered (success)
100.00%
1 / 1
15
 readHeaderGlb
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 readChunkGlb
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
15.17
 validateProperties
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 processBuffers
100.00% covered (success)
100.00%
45 / 45
100.00% covered (success)
100.00%
1 / 1
17
 processImages
83.33% covered (warning)
83.33%
20 / 24
0.00% covered (danger)
0.00%
0 / 1
14.91
 processAccessors
97.98% covered (success)
97.98%
97 / 99
0.00% covered (danger)
0.00%
0 / 1
41
 isMimeAllowed
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
8.14
 resolveURI
78.05% covered (warning)
78.05%
32 / 41
0.00% covered (danger)
0.00%
0 / 1
25.66
 accountResolvedResource
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 calculateNodeTransformationMatrix
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
 computeModelDimensions
93.75% covered (success)
93.75%
45 / 48
0.00% covered (danger)
0.00%
0 / 1
19.09
 computeStats
84.00% covered (warning)
84.00%
21 / 25
0.00% covered (danger)
0.00%
0 / 1
13.69
 exportEmbeddedBinary
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
182
1<?php
2
3namespace MediaWiki\Extension\GLTFHandler\Parser;
4
5use finfo;
6use InvalidArgumentException;
7use JsonException;
8use function array_fill;
9use function array_keys;
10use function array_push;
11use function array_values;
12use function base64_decode;
13use function bin2hex;
14use function count;
15use function dirname;
16use function explode;
17use function fclose;
18use function file_exists;
19use function file_get_contents;
20use function filesize;
21use function fopen;
22use function fread;
23use function fseek;
24use function ftell;
25use function gettype;
26use function implode;
27use function in_array;
28use function intdiv;
29use function is_array;
30use function is_file;
31use function is_infinite;
32use function is_int;
33use function is_nan;
34use function json_decode;
35use function json_encode;
36use function max;
37use function min;
38use function pack;
39use function pathinfo;
40use function str_repeat;
41use function str_starts_with;
42use function strlen;
43use function strpos;
44use function strtolower;
45use function substr;
46use function unpack;
47use function urldecode;
48use const DIRECTORY_SEPARATOR;
49use const FILEINFO_MIME_TYPE;
50use const JSON_THROW_ON_ERROR;
51use const PATHINFO_EXTENSION;
52use const SEEK_CUR;
53
54final class GLTFParser {
55
56    public const ALLOWED_MIME_URI_BUFFER = [
57        "application/gltf-buffer",
58        "application/octet-stream",
59        // Allow all .bin files.
60        [ null, "bin" ],
61    ];
62
63    public const ALLOWED_MIME_URI_IMAGE = [
64        // Allow application/octet-stream only if the file has a .ktx2 extension.
65        [ "application/octet-stream", "ktx2" ],
66        "image/jpeg",
67        "image/jpg",
68        "image/png",
69        "image/webp"
70    ];
71
72    public const ACCESSOR_SIZES = [
73        "SCALAR" => 1,
74        "VEC2" => 2,
75        "VEC3" => 3,
76        "VEC4" => 4,
77        "MAT2" => 2 * 2,
78        "MAT3" => 3 * 3,
79        "MAT4" => 4 * 4
80    ];
81    public const MAX_ACCESSOR_VALUES = 250_000;
82    public const MAX_INPUT_BYTES = 100 * 1024 * 1024;
83    public const MAX_JSON_BYTES = 16 * 1024 * 1024;
84    public const MAX_RESOLVED_RESOURCE_BYTES = 100 * 1024 * 1024;
85    public const MAX_RESOLVED_RESOURCES = 1_024;
86    public const MAX_SCENE_NODE_REFERENCES = 100_000;
87    public const MAX_TRANSFORMED_VERTICES = 1_000_000;
88
89    /** @var int pertains to glTF header - this value is same as unpack("V", "glTF")[1] */
90    public const HEADER_MAGIC = 0x46546C67;
91    /** @var int type of GLB chunk - contains JSON data in payload */
92    public const CHUNK_JSON = 0x4E4F534A;
93    /** @var int type of GLB chunk - contains a binary blob in payload */
94    public const CHUNK_BIN = 0x004E4942;
95
96    /** @var int whether the file type is binary (GLB) */
97    public const FLAG_TYPE_GLB = 1 << 0;
98    /** @var int whether the file type is JSON (GLTF) */
99    public const FLAG_TYPE_GLTF = 1 << 1;
100    /** @var int whether to resolve buffers pointing a local filesystem file */
101    public const FLAG_RESOLVE_LOCAL_URI = 1 << 2;
102    /** @var int whether to resolve buffers pointing a remote URI */
103    public const FLAG_RESOLVE_REMOTE_URI = 1 << 3;
104
105    /** @var int the file has an unsupported GLTF version */
106    public const ERR_UNSUPPORTED_VERSION = 100000;
107    /** @var int file metadata (glTF properties) is improperly formatted */
108    public const ERR_INVALID_SCHEMA = 100001;
109    /** @var int input file could not be opened or read. not thrown when accessing local filesystem URIs */
110    public const ERR_IO = 100002;
111    /** @var int a URI to an embedded resource (e.g., data:application/octet-stream) could not successfully be read */
112    public const ERR_URI_RESOLUTION_EMBEDDED = 100003;
113    /** @var int a URI to a local resource (i.e., a local file) could not successfully be read */
114    public const ERR_URI_RESOLUTION_LOCAL = 100004;
115    /** @var int a URI to a remote resource (e.g., https://...) could not successfully be read */
116    public const ERR_URI_RESOLUTION_REMOTE = 100005;
117
118    /**
119     * Infer file format (GLTF vs. GLB) by reading the first 4 bytes from the file.
120     *
121     * @param string $path
122     * @return bool whether file is binary (.glb)
123     */
124    public static function inferBinary( string $path ): bool {
125        $resource = fopen( $path, "rb" );
126        $resource !== false || throw new InvalidArgumentException( "Could not open file: {$path}", self::ERR_IO );
127        try {
128            $structure = fread( $resource, 4 );
129            $structure !== false || throw new InvalidArgumentException( "Could not read file header: {$path}", self::ERR_IO );
130            strlen( $structure ) === 4 || throw new InvalidArgumentException( "Could not read complete file header: {$path}", self::ERR_IO );
131            $type = unpack( "V", $structure )[1];
132            return $type === self::HEADER_MAGIC;
133        } finally {
134            fclose( $resource );
135        }
136    }
137
138    /**
139     * Returns validated array from JSON string.
140     *
141     * @param string $data a JSON string
142     * @return array
143     */
144    private static function decodeJsonArray( string $data ): array {
145        try {
146            // GLTF nodes are indeed a tree structure but are represented as flat lists. A depth limit of 16 instead of
147            // PHP's default 512 limit should be well more than sufficient for most if not all valid GLTF files.
148            $result = json_decode( $data, true, 16, JSON_THROW_ON_ERROR );
149        } catch ( JsonException $e ) {
150            throw new InvalidArgumentException( "Failed to decode JSON data: {$e->getMessage()}", 0, $e );
151        }
152        is_array( $result ) || throw new InvalidArgumentException( "Expected JSON data to be of type array, got " . gettype( $result ) );
153        return $result;
154    }
155
156    public finfo $mime_checker;
157    public string $path;
158    public string $directory;
159    public bool $binary;
160    public int $version;
161    public int $length;
162    public array $properties;
163
164    /** @var list<GLTFBuffer> */
165    public array $buffers;
166
167    /** @var list<GLTFBufferView> */
168    public array $buffer_views;
169
170    /**
171     * First element is a buffer view index or raw image buffer; second is its MIME type.
172     * @var list<array{int|string, string}>
173     */
174    public array $image_buffers;
175
176    /** @var list<array{GLTFComponentType, int, int, list<int|float>}> */
177    public array $accessor_values;
178
179    public ?string $copyright;
180    public ?string $generator;
181    private int $input_size;
182    private int $resolved_resource_bytes = 0;
183    private int $resolved_resources = 0;
184
185    /**
186     * Parses the structure of a GLB or GLTF file.
187     *
188     * @param string $path path to a GLB or GLTF file
189     * @param int $flags Bitmask of self::FLAG_* constants
190     * @param int $max_accessor_values
191     * @param int $max_resolved_resource_bytes
192     * @param int $max_resolved_resources
193     */
194    public function __construct( string $path, int $flags = self::FLAG_RESOLVE_LOCAL_URI, int $max_accessor_values = self::MAX_ACCESSOR_VALUES, private int $max_resolved_resource_bytes = self::MAX_RESOLVED_RESOURCE_BYTES, private int $max_resolved_resources = self::MAX_RESOLVED_RESOURCES ) {
195        $this->mime_checker = new finfo( FILEINFO_MIME_TYPE );
196        $this->path = $path;
197        $input_size = filesize( $path );
198        $input_size !== false || throw new InvalidArgumentException( "Could not determine file size: {$path}", self::ERR_IO );
199        $input_size <= self::MAX_INPUT_BYTES || throw new InvalidArgumentException( "Input exceeds parser limit of " . self::MAX_INPUT_BYTES . " bytes", self::ERR_INVALID_SCHEMA );
200        $this->input_size = $input_size;
201
202        // Needed for buffer resolution when URIs are encountered.
203        $directory = dirname( $path );
204        $binary = match ( true ) {
205            ( $flags & self::FLAG_TYPE_GLB ) > 0 => true,
206            ( $flags & self::FLAG_TYPE_GLTF ) > 0 => false,
207            default => self::inferBinary( $path )
208        };
209        $version = 0;
210        $length = 0;
211        $properties = [];
212        $buffers = [];
213        if ( $binary ) {
214            $resource = fopen( $path, "rb" );
215            $resource !== false || throw new InvalidArgumentException( "Could not open file: {$path}", self::ERR_IO );
216            try {
217                [ $version, $length ] = $this->readHeaderGlb( $resource );
218                $properties = $this->readChunkGlb( $resource, self::CHUNK_JSON );
219                is_array( $properties ) || throw new InvalidArgumentException( "Expected GLB JSON chunk", self::ERR_INVALID_SCHEMA );
220                if ( ftell( $resource ) < $this->input_size ) {
221                    // a GLB file has only one buffer entry
222                    $buffer = $this->readChunkGlb( $resource, self::CHUNK_BIN );
223                    is_string( $buffer ) || throw new InvalidArgumentException( "Expected GLB binary chunk", self::ERR_INVALID_SCHEMA );
224                    $buffers = [ new GLTFBuffer( $buffer, strlen( $buffer ), null, null, [], [] ) ];
225                }
226            } finally {
227                fclose( $resource );
228            }
229        } else {
230            $input_size <= self::MAX_JSON_BYTES || throw new InvalidArgumentException( "JSON exceeds parser limit of " . self::MAX_JSON_BYTES . " bytes", self::ERR_INVALID_SCHEMA );
231            $contents = file_get_contents( $path );
232            $contents !== false || throw new InvalidArgumentException( "Could not read file: {$path}", self::ERR_IO );
233            $length = strlen( $contents );
234            $contents = self::decodeJsonArray( $contents );
235            $version = (int)$contents["asset"]["version"];
236            $properties = $contents;
237        }
238
239        // TODO: check what the difference between version 1 and version 2 is.
240        // this will likely impact self::computeModelDimensions() and maybe self::getMetadata().
241        $version === 2 || throw new InvalidArgumentException( "Unsupported GLB version ({$version}), expected version 2", self::ERR_UNSUPPORTED_VERSION );
242
243        $this->validateProperties( $properties );
244        [ $buffers, $buffer_views ] = $this->processBuffers( $properties, $directory, $binary, $buffers, $flags );
245        $image_buffers = $this->processImages( $properties, $directory, $buffers, $buffer_views, $flags );
246        $accessor_values = $this->processAccessors( $properties, $buffers, $buffer_views, $max_accessor_values );
247        foreach ( $properties["meshes"] ?? [] as $mesh ) {
248            foreach ( $mesh["primitives"] ?? [] as $primitive ) {
249                if ( isset( $primitive["indices"] ) ) {
250                    $accessor = $accessor_values[$primitive["indices"]] ?? throw new InvalidArgumentException( "Primitive points to an undefined indices accessor", self::ERR_INVALID_SCHEMA );
251                    !in_array( $accessor[0]->max, $accessor[3], true ) || throw new InvalidArgumentException( "Indices accessor contains a primitive restart value", self::ERR_INVALID_SCHEMA );
252                }
253            }
254        }
255
256        $this->directory = $directory;
257        $this->binary = $binary;
258        $this->version = $version;
259        $this->length = $length;
260        $this->properties = $properties;
261        $this->buffers = $buffers;
262        $this->buffer_views = $buffer_views;
263        $this->accessor_values = $accessor_values;
264        $this->image_buffers = $image_buffers;
265
266        // for easy access
267        $this->copyright = $this->properties["asset"]["copyright"] ?? null;
268        $this->generator = $this->properties["asset"]["generator"] ?? null;
269    }
270
271    /**
272     * Reads a GLB header and returns version and length. 'Magic' is not returned, but is instead validated.
273     *
274     * @param resource $resource a file pointer to read the header from
275     * @return array{int, int} a tuple of version and length
276     */
277    private function readHeaderGlb( $resource ): array {
278        $header = fread( $resource, 12 );
279        $header !== false || throw new InvalidArgumentException( "Could not read GLB header: {$this->path}", self::ERR_IO );
280        strlen( $header ) === 12 || throw new InvalidArgumentException( "Could not read complete GLB header: {$this->path}", self::ERR_IO );
281        $decoded = unpack( "V3h/", $header );
282        $magic = $decoded["h1"];
283        $version = $decoded["h2"];
284        $length = $decoded["h3"];
285        $magic === self::HEADER_MAGIC || throw new InvalidArgumentException( "Improperly formatted GLB header: Magic has unexpected value: " . bin2hex( $magic ) );
286        $length === $this->input_size || throw new InvalidArgumentException( "GLB header length ({$length}) does not match file size ({$this->input_size})", self::ERR_INVALID_SCHEMA );
287        return [ $version, $length ];
288    }
289
290    /**
291     * Reads a GLB chunk from the current file position.
292     *
293     * @param resource $resource a file pointer to read the chunk from
294     * @param int $expected_type An expected self::CHUNK_* constant
295     * @return array|string|null chunk data (array for JSON chunks, string for binary chunks, null for unknown chunks)
296     */
297    public function readChunkGlb( $resource, int $expected_type ): array|string|null {
298        $structure = fread( $resource, 8 );
299        $structure !== false || throw new InvalidArgumentException( "Could not read GLB chunk header: {$this->path}", self::ERR_IO );
300        strlen( $structure ) === 8 || throw new InvalidArgumentException( "Could not read complete GLB chunk header: {$this->path}", self::ERR_IO );
301        $decoded = unpack( "V2s/", $structure );
302        $length = $decoded["s1"];
303        $type = $decoded["s2"];
304        $type === $expected_type || throw new InvalidArgumentException( "Unexpected chunk type ({$type}), expected {$expected_type}" );
305        $remaining = $this->input_size - ftell( $resource );
306        $length <= $remaining || throw new InvalidArgumentException( "GLB chunk length ({$length}) exceeds remaining file size ({$remaining})", self::ERR_INVALID_SCHEMA );
307        $type !== self::CHUNK_JSON || $length <= self::MAX_JSON_BYTES || throw new InvalidArgumentException( "JSON chunk exceeds parser limit of " . self::MAX_JSON_BYTES . " bytes", self::ERR_INVALID_SCHEMA );
308        if ( $type === self::CHUNK_JSON ) {
309            $data = $length === 0 ? "" : fread( $resource, $length );
310            $data !== false || throw new InvalidArgumentException( "Could not read GLB JSON chunk: {$this->path}", self::ERR_IO );
311            strlen( $data ) === $length || throw new InvalidArgumentException( "Could not read complete GLB JSON chunk: {$this->path}", self::ERR_IO );
312            return self::decodeJsonArray( $data );
313        }
314        if ( $type === self::CHUNK_BIN ) {
315            $data = $length === 0 ? "" : fread( $resource, $length );
316            $data !== false || throw new InvalidArgumentException( "Could not read GLB binary chunk: {$this->path}", self::ERR_IO );
317            strlen( $data ) === $length || throw new InvalidArgumentException( "Could not read complete GLB binary chunk: {$this->path}", self::ERR_IO );
318            return $data;
319        }
320        // do not read into memory chunk of unknown types, only move offset to the end of chunk.
321        // according to gltf spec: Client implementations MUST ignore chunks with unknown types to enable glTF
322        // extensions to reference additional chunks with new types following the first two chunks.
323        fseek( $resource, $length, SEEK_CUR );
324        return null;
325    }
326
327    /**
328     * Validates schema of GLTF properties. This does not validate accessors, buffers, and images which are validated by
329     * other methods.
330     *
331     * @see GLTFParser::processAccessors()
332     * @see GLTFParser::processBuffers()
333     * @see GLTFParser::processImages()
334     *
335     * @param array $properties
336     */
337    public function validateProperties( array $properties ): void {
338        JSONSchema::validate( $properties, [
339            "version" => "", "copyright" => "", "generator" => "", "minVersion" => "", "extensions" => [], "extras" => []
340        ], [ "asset" ], JSONSchema::FLAG_OPTIONAL | JSONSchema::FLAG_REPORT_UNKNOWN_KEYS );
341
342        // validate animations
343        $required_animations = [ "channels" => [], "samplers" => [] ];
344        $optional_animations = [ "name" => "", "extensions" => [], "extras" => [] ];
345        if ( isset( $properties["animations"] ) ) {
346            foreach ( $properties["animations"] as $index => $entry ) {
347                JSONSchema::validate( $properties, $required_animations, [ "animations", $index ] );
348                JSONSchema::validate( $properties, $required_animations + $optional_animations, [ "animations", $index ], JSONSchema::FLAG_OPTIONAL | JSONSchema::FLAG_REPORT_UNKNOWN_KEYS );
349            }
350        }
351    }
352
353    /**
354     * Validates structure of "buffers" and "bufferViews" sections and returns a list of updated buffers and buffer
355     * views. For GLB, $buffers must be a list of 1 element.
356     *
357     * @param array $properties
358     * @param string $directory
359     * @param bool $binary
360     * @param list<GLTFBuffer> $buffers
361     * @param int $flags Bitmask of self::FLAG_* constants
362     * @return array{list<GLTFBuffer>, list<GLTFBufferView>}
363     */
364    public function processBuffers( array $properties, string $directory, bool $binary, array $buffers, int $flags = 0 ): array {
365        $relative_dir = ( $flags & self::FLAG_RESOLVE_LOCAL_URI ) > 0 ? $directory : null;
366        $resolve_remote = ( $flags & self::FLAG_RESOLVE_REMOTE_URI ) > 0;
367
368        $required = [
369            "accessors" => [],
370            "asset" => [ "version" => "" ]
371        ];
372        $optional = [
373            "buffers" => [], "bufferViews" => [], "materials" => [], "meshes" => [], "nodes" => [], "scene" => 0,
374            "scenes" => [], "extensions" => [], "extensionsRequired" => [], "extensionsUsed" => [], "images" => [],
375            "textures" => [], "cameras" => [], "animations" => [], "samplers" => [], "skins" => []
376        ];
377        JSONSchema::validate( $properties, $required );
378        JSONSchema::validate( $properties, $required + $optional, [], JSONSchema::FLAG_OPTIONAL | JSONSchema::FLAG_REPORT_UNKNOWN_KEYS | JSONSchema::FLAG_NO_NESTING );
379
380        // validate buffers
381        // -- buffers must be validated earliest because bufferViews relies on it
382        $required_buffers = [ "byteLength" => 0 ];
383        $optional_buffers = [ "name" => "", "extensions" => [], "extras" => [] ];
384        if ( !$binary ) {
385            count( $buffers ) === 0 || throw new InvalidArgumentException( "Supplied buffer array must be empty for non-binary specification, got " . count( $buffers ) . " entries", self::ERR_INVALID_SCHEMA );
386            $required_buffers["uri"] = "";
387        }
388        if ( isset( $properties["buffers"] ) ) {
389            foreach ( $properties["buffers"] as $index => $entry ) {
390                JSONSchema::validate( $properties, $required_buffers, [ "buffers", $index ] );
391                JSONSchema::validate( $properties, $required_buffers + $optional_buffers, [ "buffers", $index ], JSONSchema::FLAG_OPTIONAL | JSONSchema::FLAG_REPORT_UNKNOWN_KEYS );
392                $entry["byteLength"] >= 1 || throw new InvalidArgumentException( "Expected 'byteLength' >= 1, got {$entry["byteLength"]}", self::ERR_INVALID_SCHEMA );
393                if ( !$binary ) {
394                    [ $value, $mime ] = $this->resolveURI( $entry["uri"], $relative_dir, $resolve_remote, $entry["byteLength"], self::ALLOWED_MIME_URI_BUFFER );
395                    $buffers[$index] = new GLTFBuffer( $value, $entry["byteLength"], $entry["uri"], $entry["name"] ?? null, $entry["extensions"] ?? [], $entry["extras"] ?? [] );
396                } else {
397                    $index === 0 || throw new InvalidArgumentException( "Binary specification must define only one buffer, got a buffer at index {$index}", self::ERR_INVALID_SCHEMA );
398                    isset( $buffers[$index] ) || throw new InvalidArgumentException( "Binary specification must pre-define buffers", self::ERR_INVALID_SCHEMA );
399                    $buffers[$index]->value ?? throw new InvalidArgumentException( "Expected binary specification buffer to be resolved, got unresolved {$buffers[$index]->uri}", self::ERR_INVALID_SCHEMA );
400                    $actual_length = strlen( $buffers[$index]->value );
401                    $actual_length >= $entry["byteLength"] || throw new InvalidArgumentException( "GLB buffer length ({$actual_length}) does not match declared length ({$entry["byteLength"]})", self::ERR_INVALID_SCHEMA );
402                    $actual_length <= $entry["byteLength"] + 3 || throw new InvalidArgumentException( "GLB buffer length ({$actual_length}) does not match declared length ({$entry["byteLength"]})", self::ERR_INVALID_SCHEMA );
403                    $buffers[$index]->byte_length = $entry["byteLength"];
404                }
405            }
406        }
407
408        // validate bufferViews
409        // -- buffer views need to be validated early because 'accessors' and 'images' rely on them
410        $required_buffer_views = [ "buffer" => 0, "byteLength" => 0 ];
411        $optional_buffer_views = [ "byteOffset" => 0, "byteStride" => 0, "target" => 0, "name" => "", "extensions" => [], "extras" => [] ];
412        $buffer_views = [];
413        if ( isset( $properties["bufferViews"] ) ) {
414            foreach ( $properties["bufferViews"] as $index => $entry ) {
415                JSONSchema::validate( $properties, $required_buffer_views, [ "bufferViews", $index ] );
416                JSONSchema::validate( $properties, $required_buffer_views + $optional_buffer_views, [ "bufferViews", $index ], JSONSchema::FLAG_OPTIONAL | JSONSchema::FLAG_REPORT_UNKNOWN_KEYS );
417                $buffer_views[] = new GLTFBufferView( $entry["buffer"], $entry["byteLength"], $entry["byteOffset"] ?? 0, $entry["byteStride"] ?? null, $entry["target"] ?? null, $entry["name"] ?? null, $entry["extensions"] ?? [], $entry["extras"] ?? [] );
418            }
419        }
420
421        // integrity check
422        foreach ( $buffer_views as $index => $view ) {
423            isset( $buffers[$view->buffer] ) || throw new InvalidArgumentException( "Buffer at index {$index} points to an undefined buffer index {$view->buffer} (have n_buffers=" . count( $buffers ) . ")" );
424            $view->byte_offset <= $buffers[$view->buffer]->byte_length - $view->byte_length || throw new InvalidArgumentException( "Buffer view at index {$index} exceeds buffer length", self::ERR_INVALID_SCHEMA );
425        }
426        return [ $buffers, $buffer_views ];
427    }
428
429    /**
430     * Validates structure of "images" section and returns a list of processed image data.
431     *
432     * @param array $properties
433     * @param string $directory
434     * @param list<GLTFBuffer> $buffers
435     * @param list<GLTFBufferView> $buffer_views
436     * @param int $flags Bitmask of self::FLAG_* constants
437     * @return list<array{int|string, string}> a list of processed image data. The element is array{int, string} for
438     * images that reference a buffer view ([0] is the index of a buffer view). The element is array{string, string} for
439     * images that reference a URI resource ([0] is the binary image data). [1] is the mime type (e.g., image/png).
440     */
441    public function processImages( array $properties, string $directory, array $buffers, array $buffer_views, int $flags = 0 ): array {
442        if ( !isset( $properties["images"] ) ) {
443            return [];
444        }
445
446        $relative_dir = ( $flags & self::FLAG_RESOLVE_LOCAL_URI ) > 0 ? $directory : null;
447        $resolve_remote = ( $flags & self::FLAG_RESOLVE_REMOTE_URI ) > 0;
448        $image_buffers = [];
449
450        $optional_images = [ "uri" => "", "mimeType" => "", "bufferView" => 0, "name" => "", "extensions" => [], "extras" => [] ];
451        foreach ( $properties["images"] as $index => $entry ) {
452            JSONSchema::validate( $properties, $optional_images, [ "images", $index ], JSONSchema::FLAG_OPTIONAL | JSONSchema::FLAG_REPORT_UNKNOWN_KEYS );
453            !( isset( $entry["uri"] ) && isset( $entry["bufferView"] ) ) || throw new InvalidArgumentException( "Expected images to contain one of 'uri' or 'bufferView', got both", self::ERR_INVALID_SCHEMA );
454            isset( $entry["uri"] ) || isset( $entry["bufferView"] ) || throw new InvalidArgumentException( "Expected images to contain one of 'uri' or 'bufferView', got neither", self::ERR_INVALID_SCHEMA );
455            if ( isset( $entry["bufferView"] ) ) {
456                $entry["bufferView"] >= 0 || throw new InvalidArgumentException( "Expected 'bufferView' >= 0, got {$entry["bufferView"]}", self::ERR_INVALID_SCHEMA );
457                isset( $entry["mimeType"] ) || throw new InvalidArgumentException( "Expected 'mimeType' to be defined when 'bufferView' is defined", self::ERR_INVALID_SCHEMA );
458                $view_index = $entry["bufferView"];
459                isset( $buffer_views[$view_index] ) || throw new InvalidArgumentException( "Expected 'bufferView' index to be valid (< " . count( $buffer_views ) . "), got {$view_index}", self::ERR_INVALID_SCHEMA );
460                $view = $buffer_views[$view_index];
461                isset( $buffers[$view->buffer] ) || throw new InvalidArgumentException( "Image buffer view at index {$view_index} points to an undefined buffer index {$view->buffer} (have n_buffers=" . count( $buffers ) . ")" );
462                $buffers[$view->buffer]->value ?? throw new InvalidArgumentException( "Image points to an unresolved buffer ({$view->buffer}): {$buffers[$view->buffer]->uri}", self::ERR_INVALID_SCHEMA );
463                $image_buffers[] = [ $view_index, $entry["mimeType"] ];
464            } else {
465                [ $buffer, $mime ] = $this->resolveURI( $entry["uri"], $relative_dir, $resolve_remote, null, self::ALLOWED_MIME_URI_IMAGE );
466                if ( isset( $entry["mimeType"] ) ) {
467                    $mime = $entry["mimeType"];
468                }
469                $image_buffers[] = [ $buffer, $mime ];
470            }
471        }
472        return $image_buffers;
473    }
474
475    /**
476     * Validates structure of the "accessors" section and returns processed values for every accessor after sparse
477     * substitution.
478     *
479     * @param array $properties
480     * @param list<GLTFBuffer> $buffers
481     * @param list<GLTFBufferView> $buffer_views
482     * @param int $max_accessor_values
483     * @return list<array{GLTFComponentType, int, int, list<int|float>}>
484     */
485    public function processAccessors( array $properties, array $buffers, array $buffer_views, int $max_accessor_values = self::MAX_ACCESSOR_VALUES ): array {
486        $component_registry = GLTFComponentType::registry();
487
488        $accessor_values = [];
489        $n_accessor_values = 0;
490        $required_accessors = [ "componentType" => 0, "count" => 0, "type" => "" ];
491        $optional_accessors = [
492            "bufferView" => 0, "byteOffset" => 0, "normalized" => false, "name" => "", "min" => [], "max" => [],
493            "sparse" => [], "extensions" => [], "extras" => []
494        ];
495        $required_sparse = [ "count" => 0, "indices" => [], "values" => [] ];
496        $optional_sparse = [ "extensions" => [], "extras" => [] ];
497        $required_sparse_indices = [ "bufferView" => 0, "componentType" => 0 ];
498        $optional_sparse_indices = [ "byteOffset" => 0, "extensions" => [], "extras" => [] ];
499        $required_sparse_values = [ "bufferView" => 0 ];
500        $optional_sparse_values = [ "byteOffset" => 0, "extensions" => [], "extras" => [] ];
501        foreach ( $properties["accessors"] as $index => $entry ) {
502            JSONSchema::validate( $properties, $required_accessors, [ "accessors", $index ] );
503            JSONSchema::validate( $properties, $required_accessors + $optional_accessors, [ "accessors", $index ], JSONSchema::FLAG_OPTIONAL | JSONSchema::FLAG_REPORT_UNKNOWN_KEYS );
504
505            $entry["count"] >= 1 || throw new InvalidArgumentException( "Expected 'count' >= 1, got {$entry["count"]}", self::ERR_INVALID_SCHEMA );
506
507            $component_type = $component_registry[$entry["componentType"]] ?? throw new InvalidArgumentException( "Expected 'componentType' to be one of: " . implode( ", ", array_keys( $component_registry ) ) . ", got {$entry["componentType"]}", self::ERR_INVALID_SCHEMA );
508            !isset( $entry["byteOffset"] ) || $entry["byteOffset"] >= 0 || throw new InvalidArgumentException( "Expected 'sparse.count' >= 0, got {$entry["byteOffset"]}", self::ERR_INVALID_SCHEMA );
509            if ( isset( $entry["normalized"] ) && $entry["normalized"] && in_array( $component_type->code, [ GLTFComponentType::FLOAT, GLTFComponentType::UNSIGNED_INT ], true ) ) {
510                throw new InvalidArgumentException( "Expected 'normalized' to be false when component type is {$component_type->name}", self::ERR_INVALID_SCHEMA );
511            }
512            $component_count = self::ACCESSOR_SIZES[$entry["type"]] ?? throw new InvalidArgumentException( "Expected accessor type to be one of: " . implode( ", ", array_keys( self::ACCESSOR_SIZES ) ) . ", got '{$entry["type"]}'", self::ERR_INVALID_SCHEMA );
513            $entry["count"] <= intdiv( $max_accessor_values - $n_accessor_values, $component_count ) || throw new InvalidArgumentException( "Accessor values exceed parser limit of {$max_accessor_values}", self::ERR_INVALID_SCHEMA );
514            $n_accessor_values += $entry["count"] * $component_count;
515
516            // validate min, max
517            if ( isset( $entry["min"] ) || isset( $entry["max"] ) ) {
518                $types = array_fill( 0, $component_count, 0.0 );
519                JSONSchema::validate( $properties, [ "min" => $types, "max" => $types ], [ "accessors", $index ] );
520                foreach ( [ ...$entry["min"], ...$entry["max"] ] as $value ) {
521                    !is_infinite( $value ) || throw new InvalidArgumentException( "Invalid value encountered (inf) in accessor entry", self::ERR_INVALID_SCHEMA );
522                    !is_nan( $value ) || throw new InvalidArgumentException( "Invalid value encountered (inf) in accessor entry", self::ERR_INVALID_SCHEMA );
523                    ( $value >= $component_type->min && $value <= $component_type->max ) || throw new InvalidArgumentException( "Expected accessor entry to fall in range [{$component_type->min}{$component_type->max}], got {$value}", self::ERR_INVALID_SCHEMA );
524                }
525            }
526
527            // validate sparse
528            if ( isset( $entry["sparse"] ) ) {
529                $sparse = $entry["sparse"];
530                JSONSchema::validate( $properties, $required_sparse, [ "accessors", $index, "sparse" ] );
531                JSONSchema::validate( $properties, $required_sparse + $optional_sparse, [ "accessors", $index, "sparse" ], JSONSchema::FLAG_OPTIONAL | JSONSchema::FLAG_REPORT_UNKNOWN_KEYS );
532                JSONSchema::validate( $properties, $required_sparse_indices, [ "accessors", $index, "sparse", "indices" ] );
533                JSONSchema::validate( $properties, $required_sparse_indices + $optional_sparse_indices, [ "accessors", $index, "sparse", "indices" ], JSONSchema::FLAG_OPTIONAL | JSONSchema::FLAG_REPORT_UNKNOWN_KEYS );
534                JSONSchema::validate( $properties, $required_sparse_values, [ "accessors", $index, "sparse", "values" ] );
535                JSONSchema::validate( $properties, $required_sparse_values + $optional_sparse_values, [ "accessors", $index, "sparse", "values" ], JSONSchema::FLAG_OPTIONAL | JSONSchema::FLAG_REPORT_UNKNOWN_KEYS );
536
537                $sparse["count"] >= 1 || throw new InvalidArgumentException( "Expected 'sparse.count' >= 1, got {$sparse["count"]}", self::ERR_INVALID_SCHEMA );
538                $sparse["count"] <= $entry["count"] || throw new InvalidArgumentException( "Expected 'sparse.count' ({$sparse["count"]}) <= base accessor size ({$entry["count"]})", self::ERR_INVALID_SCHEMA );
539
540                // validate indices
541                $sparse_component_type = $component_registry[$sparse["indices"]["componentType"]] ?? throw new InvalidArgumentException( "Expected 'componentType' to be one of: " . implode( ", ", array_keys( $component_registry ) ) . ", got {$sparse["indices"]["componentType"]}", self::ERR_INVALID_SCHEMA );
542                $index_v = $sparse["indices"]["bufferView"];
543                isset( $buffer_views[$index_v] ) || throw new InvalidArgumentException( "Expected 'bufferView' >= 0, < " . count( $buffer_views ) . ", got {$index_v}", self::ERR_INVALID_SCHEMA );
544                $view = $buffer_views[$index_v];
545                $view->byte_stride === null || throw new InvalidArgumentException( "Expected 'byteStride' of buffer view ({$index_v}) accessed from sparse indices to be undefined", self::ERR_INVALID_SCHEMA );
546                $view->target === null || throw new InvalidArgumentException( "Expected 'target' of buffer view ({$index_v}) accessed from sparse indices to be undefined", self::ERR_INVALID_SCHEMA );
547
548                // validate if buffer view and the optional byteOffset align to the componentType byte length
549                $offset_accessor = (int)( $sparse["indices"]["byteOffset"] ?? 0 );
550                $offset_view = $view->byte_offset;
551                ( $offset_accessor + $offset_view ) % $sparse_component_type->size === 0 || throw new InvalidArgumentException( "Expected accessor offset ({$offset_accessor}) + view offset ({$offset_view}) to be a multiple of size of component '{$sparse_component_type->name}' ({$sparse_component_type->size})", self::ERR_INVALID_SCHEMA );
552
553                $buffers[$view->buffer]->value ?? throw new InvalidArgumentException( "Sparse indices points to an unresolved buffer ({$view->buffer}): {$buffers[$view->buffer]->uri}", self::ERR_INVALID_SCHEMA );
554                $indices = unpack( "{$sparse_component_type->format}{$sparse["count"]}/", $buffers[$view->buffer]->value, $offset_accessor + $offset_view );
555                $indices = array_values( $indices );
556                foreach ( $indices as $index2 => $value ) {
557                    $value < $entry["count"] || throw new InvalidArgumentException( "Expected sparse.indices ({$value}) <= base accessor size ({$entry["count"]})", self::ERR_INVALID_SCHEMA );
558                    if ( $index2 > 0 && $value < $indices[$index2 - 1] ) {
559                        throw new InvalidArgumentException( "Expected sparse indices to strictly increase, got {$value} < {$indices[$index2 - 1]}", self::ERR_INVALID_SCHEMA );
560                    }
561                }
562
563                // validate values
564                $index_v = $sparse["values"]["bufferView"];
565                isset( $buffer_views[$index_v] ) || throw new InvalidArgumentException( "Expected 'bufferView' >= 0, < " . count( $buffer_views ) . ", got {$index_v}", self::ERR_INVALID_SCHEMA );
566                $view = $buffer_views[$index_v];
567                $view->byte_stride === null || throw new InvalidArgumentException( "Expected 'byteStride' of buffer view ({$index_v}) accessed from sparse values to be undefined", self::ERR_INVALID_SCHEMA );
568                $view->target === null || throw new InvalidArgumentException( "Expected 'target' of buffer view ({$index_v}) accessed from sparse values to be undefined", self::ERR_INVALID_SCHEMA );
569
570                // validate if buffer view and the optional byteOffset align to the componentType byte length
571                $offset_accessor = (int)( $sparse["values"]["byteOffset"] ?? 0 );
572                $offset_view = $view->byte_offset;
573                ( $offset_accessor + $offset_view ) % $component_type->size === 0 || throw new InvalidArgumentException( "Expected accessor offset ({$offset_accessor}) + view offset ({$offset_view}) to be a multiple of size of component '{$component_type->name}' ({$component_type->size})", self::ERR_INVALID_SCHEMA );
574
575                $buffers[$view->buffer]->value ?? throw new InvalidArgumentException( "Sparse values points to an unresolved buffer ({$view->buffer}): {$buffers[$view->buffer]->uri}", self::ERR_INVALID_SCHEMA );
576                $values = unpack( $component_type->format . ( $sparse["count"] * $component_count ) . "/", $buffers[$view->buffer]->value, $offset_accessor + $offset_view );
577                $values = array_values( $values );
578                $sparse = [ $indices, $values ];
579            } else {
580                // no sparse substitutions needed
581                $sparse = [ [], [] ];
582            }
583
584            if ( isset( $entry["bufferView"] ) ) {
585                $index_v = $entry["bufferView"];
586                isset( $buffer_views[$index_v] ) || throw new InvalidArgumentException( "Expected 'bufferView' >= 0, < " . count( $buffer_views ) . ", got {$index_v}", self::ERR_INVALID_SCHEMA );
587                $view = $buffer_views[$index_v];
588                $buffers[$view->buffer]->value ?? throw new InvalidArgumentException( "Accessor points to an unresolved buffer ({$view->buffer}): {$buffers[$view->buffer]->uri}", self::ERR_INVALID_SCHEMA );
589
590                // validate if buffer view and the optional byteOffset align to the componentType byte length
591                $offset_accessor = (int)( $entry["byteOffset"] ?? 0 );
592                $offset_view = $view->byte_offset;
593                ( $offset_accessor + $offset_view ) % $component_type->size === 0 || throw new InvalidArgumentException( "Expected accessor offset ({$offset_accessor}) + view offset ({$offset_view}) to be a multiple of size of accessor component '{$component_type->name}' ({$component_type->size})", self::ERR_INVALID_SCHEMA );
594                $view->byte_stride === null || $view->byte_stride % $component_type->size === 0 || throw new InvalidArgumentException( "Expected byte stride of view ({$view->byte_stride}) to be a multiple of size of accessor component '{$component_type->name}' ({$component_type->size})", self::ERR_INVALID_SCHEMA );
595
596                $element_size = $component_type->size * $component_count;
597                $stride = $view->byte_stride ?? $element_size;
598                $stride >= $element_size || throw new InvalidArgumentException( "Accessor element size ({$element_size}) exceeds byte stride ({$stride})", self::ERR_INVALID_SCHEMA );
599                $fitness = $offset_accessor + $stride * ( $entry["count"] - 1 ) + $element_size;
600                $fitness <= $view->byte_length || throw new InvalidArgumentException( "Expected accessor fitness ({$fitness}) <= buffer view length ({$view->byte_length})", self::ERR_INVALID_SCHEMA );
601
602                $values = [];
603                $format = $component_type->format . $component_count . "/";
604                $data = $buffers[$view->buffer]->value;
605                $offset = $offset_accessor + $offset_view;
606                for ( $i = 0; $i < $entry["count"]; $i++, $offset += $stride ) {
607                    array_push( $values, ...unpack( $format, $data, $offset ) );
608                }
609            } else {
610                $values = array_fill( 0, $entry["count"] * $component_count, 0 );
611            }
612
613            // perform sparse substitution for $values
614            foreach ( $sparse[0] as $index_s => $index_replace ) {
615                $offset = $index_replace * $component_count;
616                $replacement_offset = $index_s * $component_count;
617                for ( $component = 0; $component < $component_count; $component++ ) {
618                    $values[$offset + $component] = $sparse[1][$replacement_offset + $component];
619                }
620            }
621
622            $accessor_values[] = [ $component_type, $component_count, $entry["count"], $values ];
623        }
624        return $accessor_values;
625    }
626
627    /**
628     * Checks whether a MIME (or a [MIME, file-extension] pair) is allowed as per the parser rules.
629     *
630     * @param string $mime the mime type to test
631     * @param string|null $ext extension of the file, or null if not known
632     * @param list<string|array{string|null, string}>|null $allowed_mimes Allowed MIME types, or null to allow all
633     * @return bool whether the mime is allowed as per the parser rules
634     */
635    public function isMimeAllowed( string $mime, ?string $ext, ?array $allowed_mimes ): bool {
636        if ( $allowed_mimes === null ) {
637            return true;
638        }
639        $ext = $ext === null ? null : strtolower( $ext );
640        foreach ( $allowed_mimes as $entry ) {
641            if ( $entry === $mime || $entry === [ null, $ext ] || $entry === [ $mime, $ext ] ) {
642                return true;
643            }
644        }
645        return false;
646    }
647
648    /**
649     * Resolves a buffer URI based on the given options ($relative_directory, $resolve_remote), or returns null if the
650     * operation is disallowed by the options. Embedded URIs (i.e., data:application/octet-stream;base64,...) will
651     * always be resolved.
652     *
653     * @param string $uri the URI to resolve
654     * @param string|null $base_directory the base directory for relative URI paths
655     * @param bool $resolve_remote whether to resolve remote URIs (e.g., http://, https://, etc.)
656     * @param int|null $length the length of bytes of the resolved buffer, or null to ignore length constraints
657     * @param array|null $allowed_mimes Allowed MIME types, or null to allow all
658     * @return array{string, string} a pair of the returned raw buffer (byte array) and mime type
659     */
660    public function resolveURI( string $uri, ?string $base_directory, bool $resolve_remote, ?int $length = null, ?array $allowed_mimes = null ): array {
661        $this->resolved_resources < $this->max_resolved_resources || throw new InvalidArgumentException( "Resolved resources exceed parser limit of {$this->max_resolved_resources}", self::ERR_INVALID_SCHEMA );
662        $remaining = $this->max_resolved_resource_bytes - $this->resolved_resource_bytes;
663        $remaining > 0 || throw new InvalidArgumentException( "Resolved resource bytes exceed parser limit of {$this->max_resolved_resource_bytes}", self::ERR_INVALID_SCHEMA );
664        $length === null || $length <= $remaining || throw new InvalidArgumentException( "Resolved resource bytes exceed parser limit of {$this->max_resolved_resource_bytes}", self::ERR_INVALID_SCHEMA );
665        if ( str_starts_with( $uri, "data:" ) ) {
666            $token_end = strpos( $uri, ",", 5 );
667            if ( $token_end === false || $token_end > 64 ) {
668                $token_end = 64;
669            }
670            $uri_type = substr( $uri, 5, $token_end - 5 );
671            $uri_data = substr( $uri, $token_end + 1 );
672            $mime = explode( ";", $uri_type, 2 )[0];
673            $this->isMimeAllowed( $mime, null, $allowed_mimes ) || throw new InvalidArgumentException( "Unsupported MIME type {$mime}", self::ERR_URI_RESOLUTION_EMBEDDED );
674            if ( $uri_type === "application/octet-stream" ) {
675                return [ $this->accountResolvedResource( urldecode( $uri_data ), $length ), $mime ];
676            }
677            if ( in_array( $uri_type, [
678                "application/octet-stream;base64",
679                "application/gltf-buffer;base64",
680                "image/png;base64",
681                "image/jpeg;base64",
682            ], true ) ) {
683                $result = base64_decode( $uri_data );
684                $result !== false || throw new InvalidArgumentException( "Improperly encoded base64 data supplied for URI type {$uri_type}", self::ERR_URI_RESOLUTION_EMBEDDED );
685                return [ $this->accountResolvedResource( $result, $length ), $mime ];
686            }
687            throw new InvalidArgumentException( "Expected URI type to be one of: application/octet-stream, application/octet-stream;base64, application/gltf-buffer;base64, got {$uri_type}", self::ERR_URI_RESOLUTION_EMBEDDED );
688        }
689        if ( filter_var( $uri, FILTER_VALIDATE_URL ) ) {
690            $resolve_remote || throw new InvalidArgumentException( "Remote resolution is not allowed", self::ERR_URI_RESOLUTION_REMOTE );
691            // TODO: Validate return type, HTTP response code
692            $data = file_get_contents( $uri, length: $length ?? $remaining + 1 );
693            $data !== false || throw new InvalidArgumentException( "Remote resolution failed for uri: {$uri}", self::ERR_URI_RESOLUTION_REMOTE );
694            $mime = $this->mime_checker->buffer( $data );
695            $this->isMimeAllowed( $mime, null, $allowed_mimes ) || throw new InvalidArgumentException( "Unsupported MIME type {$mime}", self::ERR_URI_RESOLUTION_REMOTE );
696            return [ $this->accountResolvedResource( $data, $length ), $mime ];
697        }
698        $base_directory ?? throw new InvalidArgumentException( "Local resolution is not allowed", self::ERR_URI_RESOLUTION_LOCAL );
699        \FileBackend::isPathTraversalFree( $decoded_uri = urldecode( $uri ) ) || throw new InvalidArgumentException( "Directory traversal is not allowed in local URI: {$uri}", self::ERR_URI_RESOLUTION_LOCAL );
700        $path = $base_directory . DIRECTORY_SEPARATOR . $decoded_uri;
701        ( is_file( $path ) && file_exists( $path ) ) || throw new InvalidArgumentException( "File not found: {$path}", self::ERR_URI_RESOLUTION_LOCAL );
702        $ext = pathinfo( $path, PATHINFO_EXTENSION );
703        $data = file_get_contents( $path, length: $length ?? $remaining + 1 );
704        $data !== false || throw new InvalidArgumentException( "Local resolution failed for uri: {$uri}", self::ERR_URI_RESOLUTION_LOCAL );
705        $mime = $this->mime_checker->buffer( $data );
706        $this->isMimeAllowed( $mime, $ext, $allowed_mimes ) || throw new InvalidArgumentException( "Unsupported MIME type {$mime}", self::ERR_URI_RESOLUTION_LOCAL );
707        return [ $this->accountResolvedResource( $data, $length ), $mime ];
708    }
709
710    private function accountResolvedResource( string $data, ?int $expected_length ): string {
711        $length = strlen( $data );
712        $expected_length === null || $length === $expected_length || throw new InvalidArgumentException( "Resolved resource length ({$length}) does not match declared length ({$expected_length})", self::ERR_INVALID_SCHEMA );
713        $length <= $this->max_resolved_resource_bytes - $this->resolved_resource_bytes || throw new InvalidArgumentException( "Resolved resource bytes exceed parser limit of {$this->max_resolved_resource_bytes}", self::ERR_INVALID_SCHEMA );
714        $this->resolved_resource_bytes += $length;
715        $this->resolved_resources++;
716        return $data;
717    }
718
719    public function calculateNodeTransformationMatrix( array $node ): array {
720        // implemented based on notes from:
721        // https://github.com/KhronosGroup/glTF-Tutorials/blob/bdc3640aad36ec9fe2c20fa262488fab5842f06b/gltfTutorial/gltfTutorial_004_ScenesNodes.md
722        if ( isset( $node["matrix"] ) ) {
723            return Matrix::transpose( $node["matrix"] );
724        }
725
726        // no node found: construct a transformation matrix from TRS values
727        $t = $node["translation"] ?? [ 0, 0, 0 ];
728        $r = $node["rotation"] ?? [ 0, 0, 0, 1 ];
729        $s = $node["scale"] ?? [ 1, 1, 1 ];
730
731        $T = [
732            1, 0, 0, $t[0],
733            0, 1, 0, $t[1],
734            0, 0, 1, $t[2],
735            0, 0, 0, 1
736        ];
737
738        $R = Matrix::quaternionToRotation( $r );
739
740        $S = [
741            $s[0], 0, 0, 0,
742            0, $s[1], 0, 0,
743            0, 0, $s[2], 0,
744            0, 0, 0, 1
745        ];
746        return Matrix::multiply( $T, Matrix::multiply( $R, $S, 4 ), 4 );
747    }
748
749    /**
750     * Computes length of X, Y, and Z planes of the model.
751     *
752     * @param int $max_node_references
753     * @param int $max_transformed_vertices
754     * @return array{float, float, float}|null a tuple of lengths of X, Y, and Z planes respectively, or null if the
755     * model dimensions could not be inferred.
756     */
757    public function computeModelDimensions( int $max_node_references = self::MAX_SCENE_NODE_REFERENCES, int $max_transformed_vertices = self::MAX_TRANSFORMED_VERTICES ): ?array {
758        $nodes = [];
759        $stack = [];
760        $offset = 0;
761        $node_references = 0;
762        $transformed_vertices = 0;
763        $minimum = [ INF, INF, INF ];
764        $maximum = [ -INF, -INF, -INF ];
765        foreach ( $this->properties["scenes"] as $scene ) {
766            foreach ( $scene["nodes"] as $node ) {
767                ++$node_references <= $max_node_references || throw new InvalidArgumentException( "Scene node references exceed parser limit of {$max_node_references}", self::ERR_INVALID_SCHEMA );
768                if ( !isset( $nodes[$node] ) ) {
769                    $nodes[$node] = true;
770                    $stack[] = [ $node, Matrix::IDENTITY4 ];
771                }
772            }
773        }
774
775        while ( isset( $stack[$offset] ) ) {
776            [ $index, $global_transformation ] = $stack[$offset++];
777            $node = $this->properties["nodes"][$index];
778            $transformation = Matrix::multiply( $global_transformation, $this->calculateNodeTransformationMatrix( $node ), 4 );
779
780            if ( isset( $node["children"] ) ) {
781                foreach ( $node["children"] as $child ) {
782                    ++$node_references <= $max_node_references || throw new InvalidArgumentException( "Scene node references exceed parser limit of {$max_node_references}", self::ERR_INVALID_SCHEMA );
783                    if ( !isset( $nodes[$child] ) ) {
784                        $nodes[$child] = true;
785                        $stack[] = [ $child, $transformation ];
786                    }
787                }
788            }
789
790            if ( !isset( $node["mesh"] ) ) {
791                continue;
792            }
793
794            $mesh = $this->properties["meshes"][$node["mesh"]];
795            foreach ( $mesh["primitives"] as $primitive ) {
796                if ( !isset( $primitive["attributes"]["POSITION"] ) ) {
797                    continue;
798                }
799                if ( isset( $primitive["extensions"] ) && count( $primitive["extensions"] ) > 0 ) {
800                    // extensions like KHR_draco_mesh_compression require further handling, otherwise we end up with
801                    // incorrect accessor values.
802                    continue;
803                }
804                [ $comp_type, $comp_size, $n_comp, $comp_values ] = $this->accessor_values[$primitive["attributes"]["POSITION"]];
805                for ( $i = 0, $j = count( $comp_values ); $i < $j; $i += $comp_size ) {
806                    ++$transformed_vertices <= $max_transformed_vertices || throw new InvalidArgumentException( "Transformed vertices exceed parser limit of {$max_transformed_vertices}", self::ERR_INVALID_SCHEMA );
807                    $x = $comp_values[$i];
808                    $y = $comp_values[$i + 1];
809                    $z = $comp_values[$i + 2];
810                    $value = [
811                        ( $transformation[0] * $x ) + ( $transformation[1] * $y ) + ( $transformation[2] * $z ) + $transformation[3],
812                        ( $transformation[4] * $x ) + ( $transformation[5] * $y ) + ( $transformation[6] * $z ) + $transformation[7],
813                        ( $transformation[8] * $x ) + ( $transformation[9] * $y ) + ( $transformation[10] * $z ) + $transformation[11]
814                    ];
815                    for ( $axis = 0; $axis < 3; $axis++ ) {
816                        $minimum[$axis] = min( $minimum[$axis], $value[$axis] );
817                        $maximum[$axis] = max( $maximum[$axis], $value[$axis] );
818                    }
819                }
820            }
821        }
822        if ( $transformed_vertices === 0 ) {
823            return null;
824        }
825        return [ $maximum[0] - $minimum[0], $maximum[1] - $minimum[1], $maximum[2] - $minimum[2] ];
826    }
827
828    /**
829     * Computes glTF stats for report generation and returns attributes as reported by KhronosGroup/glTF-Validator.
830     *
831     * @link https://github.com/KhronosGroup/glTF-Validator/blob/bcd52cc4ba5f333b2999a58f67cc05ddf28b4fb1/lib/src/validation_result.dart
832     * @return array<string, int>
833     */
834    public function computeStats(): array {
835        $vertices = 0;
836        $triangles = 0;
837        $draw_calls = 0;
838        foreach ( $this->properties["meshes"] as $mesh ) {
839            $draw_calls += count( $mesh["primitives"] );
840            foreach ( $mesh["primitives"] as $primitive ) {
841                if ( !isset( $primitive["attributes"]["POSITION"] ) ) {
842                    continue;
843                }
844                if ( isset( $primitive["extensions"] ) && count( $primitive["extensions"] ) > 0 ) {
845                    // extensions like KHR_draco_mesh_compression require further handling, otherwise we end up with
846                    // incorrect accessor values.
847                    continue;
848                }
849                [ $comp_type, $comp_size, $n_comp, $comp_values ] = $this->accessor_values[$primitive["attributes"]["POSITION"]];
850                $vertices += $n_comp;
851                $n_indices = isset( $primitive["indices"] ) ? $this->accessor_values[$primitive["indices"]][2] : $n_comp;
852                $mode = $primitive["mode"] ?? 4;
853                if ( $mode === 4 ) {
854                    // TRIANGLES
855                    $triangles += intdiv( $n_indices, 3 );
856
857                // TRIANGLE_STRIP or TRIANGLE_FAN
858                } elseif ( $mode === 5 || $mode === 6 ) {
859                    $triangles += $n_indices > 2 ? $n_indices - 2 : 0;
860
861                }
862
863            }
864        }
865        return [
866            "animationCount" => isset( $this->properties["animations"] ) ? count( $this->properties["animations"] ) : 0,
867            "drawCallCount" => $draw_calls,
868            "materialCount" => isset( $this->properties["materials"] ) ? count( $this->properties["materials"] ) : 0,
869            "totalTriangleCount" => $triangles,
870            "totalVertexCount" => $vertices
871        ];
872    }
873
874    /**
875     * Returns a raw GLB bytes representation of the glTF file with all resources embedded. This is useful to generate a
876     * 'portable' glTF file. Resolved remote and local filesystem buffers and images are embedded directly.
877     *
878     * Example usage:
879     *   // convert GLTF to GLB
880     *   $parser = new GLTFParser("model.gltf");
881     *   $contents = $parser->exportEmbeddedBinary();
882     *   file_put_contents("model.glb", $contents);
883     *
884     * @return string
885     */
886    public function exportEmbeddedBinary(): string {
887        foreach ( $this->buffers as $index => $buffer ) {
888            $buffer->value ?? throw new InvalidArgumentException( "Buffer at index {$index} is unresolved (" . substr( $buffer->uri, 0, 64 ) . ")" );
889        }
890
891        // process properties
892        $properties = $this->properties;
893        if ( isset( $properties["images"] ) ) {
894            foreach ( $properties["images"] as $index => $property ) {
895                unset( $properties["images"][$index]["uri"] );
896            }
897        }
898
899        // process buffer
900        // -- collect all buffers and concatenate into a single blob.
901        // -- offset corresponding buffer views and image buffers
902        $buffers = [];
903        $offset = 0;
904        foreach ( $this->buffers as $ib => $buffer ) {
905            $buffers[] = $buffer->value;
906            $byte_offset = $offset;
907            foreach ( $properties["bufferViews"] as $iv => $view ) {
908                if ( $ib === 0 || $view["buffer"] !== $ib ) {
909                    continue;
910                }
911                $view["buffer"] = 0;
912                $view["byteOffset"] ??= 0;
913                $view["byteOffset"] += $byte_offset;
914                $properties["bufferViews"][$iv] = $view;
915            }
916            $offset += strlen( $buffer->value );
917        }
918
919        $iv = count( $this->buffer_views );
920        foreach ( $this->image_buffers as $ib => [ $buffer, $mime ] ) {
921            if ( is_int( $buffer ) ) {
922                // We already translated buffer views.
923                continue;
924            }
925
926            // introduce a new buffer view referencing the position of this image
927            // we need to ensure length of buffer is a multiple of 4 per glTF spec
928            // so we pad it with zero bytes
929            $buffer .= str_repeat( "\0", 4 - ( strlen( $buffer ) % 4 ) );
930            $buffers[] = $buffer;
931            $properties["images"][$ib]["bufferView"] = $iv;
932            $properties["images"][$ib]["mimeType"] = $mime;
933            $properties["bufferViews"][$iv] = [ "buffer" => 0, "byteOffset" => $offset, "byteLength" => strlen( $buffer ) ];
934            $iv++;
935            $offset += strlen( $buffer );
936        }
937
938        if ( count( $buffers ) > 0 ) {
939            $chunk1 = implode( $buffers );
940            $properties["buffers"] = [ [ "byteLength" => strlen( $chunk1 ) ] ];
941        } else {
942            $chunk1 = null;
943            unset( $properties["buffers"] );
944        }
945
946        try {
947            $chunk0 = json_encode( $properties, JSON_THROW_ON_ERROR );
948        } catch ( JsonException $e ) {
949            throw new InvalidArgumentException( "Failed to encode chunk0: {$e->getMessage()}", 0, $e );
950        }
951        $data = [];
952        $data[] = pack( "V*", strlen( $chunk0 ), self::CHUNK_JSON );
953        $data[] = $chunk0;
954        if ( $chunk1 !== null ) {
955            $chunk1 = implode( $buffers );
956            $data[] = pack( "V*", strlen( $chunk1 ), self::CHUNK_BIN );
957            $data[] = $chunk1;
958        }
959        $contents = implode( $data );
960        // write header
961        return pack( "V*", self::HEADER_MAGIC, $this->version, strlen( $contents ) + 12 ) . $contents;
962    }
963}