Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
84.08% |
396 / 471 |
|
43.75% |
7 / 16 |
CRAP | |
0.00% |
0 / 1 |
| GLTFParser | |
84.08% |
396 / 471 |
|
43.75% |
7 / 16 |
351.11 | |
0.00% |
0 / 1 |
| inferBinary | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
| decodeJsonArray | |
60.00% |
3 / 5 |
|
0.00% |
0 / 1 |
3.58 | |||
| __construct | |
100.00% |
56 / 56 |
|
100.00% |
1 / 1 |
15 | |||
| readHeaderGlb | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
5 | |||
| readChunkGlb | |
90.91% |
20 / 22 |
|
0.00% |
0 / 1 |
15.17 | |||
| validateProperties | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
| processBuffers | |
100.00% |
45 / 45 |
|
100.00% |
1 / 1 |
17 | |||
| processImages | |
83.33% |
20 / 24 |
|
0.00% |
0 / 1 |
14.91 | |||
| processAccessors | |
97.98% |
97 / 99 |
|
0.00% |
0 / 1 |
41 | |||
| isMimeAllowed | |
71.43% |
5 / 7 |
|
0.00% |
0 / 1 |
8.14 | |||
| resolveURI | |
78.05% |
32 / 41 |
|
0.00% |
0 / 1 |
25.66 | |||
| accountResolvedResource | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
| calculateNodeTransformationMatrix | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
2 | |||
| computeModelDimensions | |
93.75% |
45 / 48 |
|
0.00% |
0 / 1 |
19.09 | |||
| computeStats | |
84.00% |
21 / 25 |
|
0.00% |
0 / 1 |
13.69 | |||
| exportEmbeddedBinary | |
0.00% |
0 / 47 |
|
0.00% |
0 / 1 |
182 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace MediaWiki\Extension\GLTFHandler\Parser; |
| 4 | |
| 5 | use finfo; |
| 6 | use InvalidArgumentException; |
| 7 | use JsonException; |
| 8 | use function array_fill; |
| 9 | use function array_keys; |
| 10 | use function array_push; |
| 11 | use function array_values; |
| 12 | use function base64_decode; |
| 13 | use function bin2hex; |
| 14 | use function count; |
| 15 | use function dirname; |
| 16 | use function explode; |
| 17 | use function fclose; |
| 18 | use function file_exists; |
| 19 | use function file_get_contents; |
| 20 | use function filesize; |
| 21 | use function fopen; |
| 22 | use function fread; |
| 23 | use function fseek; |
| 24 | use function ftell; |
| 25 | use function gettype; |
| 26 | use function implode; |
| 27 | use function in_array; |
| 28 | use function intdiv; |
| 29 | use function is_array; |
| 30 | use function is_file; |
| 31 | use function is_infinite; |
| 32 | use function is_int; |
| 33 | use function is_nan; |
| 34 | use function json_decode; |
| 35 | use function json_encode; |
| 36 | use function max; |
| 37 | use function min; |
| 38 | use function pack; |
| 39 | use function pathinfo; |
| 40 | use function str_repeat; |
| 41 | use function str_starts_with; |
| 42 | use function strlen; |
| 43 | use function strpos; |
| 44 | use function strtolower; |
| 45 | use function substr; |
| 46 | use function unpack; |
| 47 | use function urldecode; |
| 48 | use const DIRECTORY_SEPARATOR; |
| 49 | use const FILEINFO_MIME_TYPE; |
| 50 | use const JSON_THROW_ON_ERROR; |
| 51 | use const PATHINFO_EXTENSION; |
| 52 | use const SEEK_CUR; |
| 53 | |
| 54 | final 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 | } |