Coverage for trimesh/exchange/gltf/__init__.py: 92%

799 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-10-24 01:00 +0000

1""" 

2gltf/__init__.py 

3------------ 

4 

5Provides GLTF 2.0 exports of trimesh.Trimesh objects 

6as GL_TRIANGLES, and trimesh.Path2D/Path3D as GL_LINES 

7""" 

8 

9import base64 

10import json 

11from collections import OrderedDict, defaultdict, deque 

12from copy import deepcopy 

13 

14import numpy as np 

15 

16from ... import rendering, resources, transformations, util, visual 

17from ...caching import hash_fast 

18from ...constants import log, tol 

19from ...iteration import IndexedDict 

20from ...resolvers import ResolverLike, ZipResolver 

21from ...scene.cameras import Camera 

22from ...scene.transforms import DEFAULT_BASE_FRAME 

23from ...typed import NDArray, Stream 

24from ...util import triangle_strips_to_faces, unique_name 

25from .extensions import handle_extensions, unregistered 

26 

27# magic numbers which have meaning in GLTF 

28# most are uint32's of UTF-8 text 

29_magic = {"gltf": 1179937895, "json": 1313821514, "bin": 5130562} 

30 

31# GLTF data type codes: little endian numpy dtypes 

32_dtypes = {5120: "<i1", 5121: "<u1", 5122: "<i2", 5123: "<u2", 5125: "<u4", 5126: "<f4"} 

33# a string we can use to look up numpy dtype : GLTF dtype 

34_dtypes_lookup = {v[1:]: k for k, v in _dtypes.items()} 

35 

36 

37# GLTF data formats: numpy shapes 

38_shapes = { 

39 "SCALAR": 1, 

40 "VEC2": (2), 

41 "VEC3": (3), 

42 "VEC4": (4), 

43 "MAT2": (2, 2), 

44 "MAT3": (3, 3), 

45 "MAT4": (4, 4), 

46} 

47 

48# a default PBR metallic material 

49_default_material = { 

50 "pbrMetallicRoughness": { 

51 "baseColorFactor": [1, 1, 1, 1], 

52 "metallicFactor": 0, 

53 "roughnessFactor": 0, 

54 } 

55} 

56 

57# GL geometry modes 

58_GL_LINES = 1 

59_GL_POINTS = 0 

60_GL_TRIANGLES = 4 

61_GL_STRIP = 5 

62 

63_EYE = np.eye(4) 

64_EYE.flags.writeable = False 

65 

66# schema validators are slow to build so cache them once built 

67_GLTF_VALIDATOR = {} 

68 

69# specify dtypes with forced little endian 

70float32 = np.dtype("<f4") 

71uint32 = np.dtype("<u4") 

72uint8 = np.dtype("<u1") 

73 

74 

75def export_gltf( 

76 scene, 

77 include_normals=None, 

78 merge_buffers=False, 

79 unitize_normals=True, 

80 tree_postprocessor=None, 

81 embed_buffers=False, 

82 extension_webp=False, 

83 extension_draco=False, 

84): 

85 """ 

86 Export a scene object as a GLTF directory. 

87 

88 This puts each mesh into a separate file (i.e. a `buffer`) 

89 as opposed to one larger file. 

90 

91 Parameters 

92 ----------- 

93 scene : trimesh.Scene 

94 Scene to be exported 

95 include_normals : None or bool 

96 Include vertex normals 

97 merge_buffers : bool 

98 Merge buffers into one blob. 

99 unitize_normals 

100 GLTF requires unit normals, however sometimes people 

101 want to include non-unit normals for shading reasons. 

102 resolver : trimesh.resolvers.Resolver 

103 If passed will use to write each file. 

104 tree_postprocesser : None or callable 

105 Run this on the header tree before exiting. 

106 embed_buffers : bool 

107 Embed the buffer into JSON file as a base64 string in the URI 

108 extension_webp : bool 

109 Export textures as webP (using glTF's EXT_texture_webp extension). 

110 extension_draco : bool 

111 Compress mesh data using Draco (KHR_draco_mesh_compression). 

112 Requires the `DracoPy` package to be installed. 

113 

114 Returns 

115 ---------- 

116 export : dict 

117 Format: {file name : file data} 

118 """ 

119 # if we were passed a bare Trimesh or Path3D object 

120 if not util.is_instance_named(scene, "Scene") and hasattr(scene, "scene"): 

121 scene = scene.scene() 

122 

123 # create the header and buffer data 

124 tree, buffer_items = _create_gltf_structure( 

125 scene=scene, 

126 unitize_normals=unitize_normals, 

127 include_normals=include_normals, 

128 extension_webp=extension_webp, 

129 extension_draco=extension_draco, 

130 ) 

131 

132 # allow custom postprocessing 

133 if tree_postprocessor is not None: 

134 tree_postprocessor(tree) 

135 

136 # store files as {name : data} 

137 files = {} 

138 

139 base64_buffer_format = "data:application/octet-stream;base64,{}" 

140 if merge_buffers: 

141 views = _build_views(buffer_items) 

142 buffer_data = b"".join(buffer_items.values()) 

143 if embed_buffers: 

144 buffer_name = base64_buffer_format.format( 

145 base64.b64encode(buffer_data).decode() 

146 ) 

147 else: 

148 buffer_name = "gltf_buffer.bin" 

149 files[buffer_name] = buffer_data 

150 buffers = [{"uri": buffer_name, "byteLength": len(buffer_data)}] 

151 else: 

152 # make one buffer per buffer_items 

153 buffers = [None] * len(buffer_items) 

154 # A bufferView is a slice of a file 

155 views = [None] * len(buffer_items) 

156 # create the buffer views 

157 for i, item in enumerate(buffer_items.values()): 

158 views[i] = {"buffer": i, "byteOffset": 0, "byteLength": len(item)} 

159 if embed_buffers: 

160 buffer_name = base64_buffer_format.format(base64.b64encode(item).decode()) 

161 else: 

162 buffer_name = f"gltf_buffer_{i}.bin" 

163 files[buffer_name] = item 

164 buffers[i] = {"uri": buffer_name, "byteLength": len(item)} 

165 

166 if len(buffers) > 0: 

167 tree["buffers"] = buffers 

168 tree["bufferViews"] = views 

169 # dump tree with compact separators 

170 files["model.gltf"] = util.jsonify(tree, separators=(",", ":")).encode("utf-8") 

171 

172 if tol.strict: 

173 validate(tree) 

174 

175 return files 

176 

177 

178def export_glb( 

179 scene, 

180 include_normals=None, 

181 unitize_normals=True, 

182 tree_postprocessor=None, 

183 buffer_postprocessor=None, 

184 extension_webp=False, 

185 extension_draco=False, 

186): 

187 """ 

188 Export a scene as a binary GLTF (GLB) file. 

189 

190 Parameters 

191 ------------ 

192 scene: trimesh.Scene 

193 Input geometry 

194 extras : JSON serializable 

195 Will be stored in the extras field. 

196 include_normals : bool 

197 Include vertex normals in output file? 

198 tree_postprocessor : func 

199 Custom function to (in-place) post-process the tree 

200 before exporting. 

201 extension_webp : bool 

202 Export textures as webP using EXT_texture_webp extension. 

203 extension_draco : bool 

204 Compress mesh data using Draco (KHR_draco_mesh_compression). 

205 Requires the `DracoPy` package to be installed. 

206 

207 Returns 

208 ---------- 

209 exported : bytes 

210 Exported result in GLB 2.0 

211 """ 

212 # if we were passed a bare Trimesh or Path3D object 

213 if not util.is_instance_named(scene, "Scene") and hasattr(scene, "scene"): 

214 # generate a scene with just that mesh in it 

215 scene = scene.scene() 

216 

217 tree, buffer_items = _create_gltf_structure( 

218 scene=scene, 

219 unitize_normals=unitize_normals, 

220 include_normals=include_normals, 

221 buffer_postprocessor=buffer_postprocessor, 

222 extension_webp=extension_webp, 

223 extension_draco=extension_draco, 

224 ) 

225 

226 # A bufferView is a slice of a file 

227 views = _build_views(buffer_items) 

228 

229 # combine bytes into a single blob 

230 buffer_data = b"".join(buffer_items.values()) 

231 

232 # add the information about the buffer data 

233 if len(buffer_data) > 0: 

234 tree["buffers"] = [{"byteLength": len(buffer_data)}] 

235 tree["bufferViews"] = views 

236 

237 # allow custom postprocessing 

238 if tree_postprocessor is not None: 

239 tree_postprocessor(tree) 

240 

241 # export the tree to JSON for the header 

242 content = util.jsonify(tree, separators=(",", ":")) 

243 # add spaces to content, so the start of the data 

244 # is 4 byte aligned as per spec 

245 content += (4 - ((len(content) + 20) % 4)) * " " 

246 content = content.encode("utf-8") 

247 # make sure we didn't screw it up 

248 assert (len(content) % 4) == 0 

249 

250 # the initial header of the file 

251 header = _byte_pad( 

252 np.array( 

253 [ 

254 _magic["gltf"], # magic, turns into glTF 

255 2, # GLTF version 

256 # length is the total length of the Binary glTF 

257 # including Header and all Chunks, in bytes. 

258 len(content) + len(buffer_data) + 28, 

259 # contentLength is the length, in bytes, 

260 # of the glTF content (JSON) 

261 len(content), 

262 # magic number which is 'JSON' 

263 _magic["json"], 

264 ], 

265 dtype="<u4", 

266 ).tobytes() 

267 ) 

268 

269 # the header of the binary data section 

270 bin_header = _byte_pad( 

271 np.array([len(buffer_data), 0x004E4942], dtype="<u4").tobytes() 

272 ) 

273 

274 exported = b"".join([header, content, bin_header, buffer_data]) 

275 

276 if tol.strict: 

277 validate(tree) 

278 

279 return exported 

280 

281 

282def load_gltf( 

283 file_obj: Stream | None = None, 

284 resolver: ResolverLike | None = None, 

285 ignore_broken: bool = False, 

286 merge_primitives: bool = False, 

287 skip_materials: bool = False, 

288 **mesh_kwargs, 

289): 

290 """ 

291 Load a GLTF file, which consists of a directory structure 

292 with multiple files. 

293 

294 Parameters 

295 ------------- 

296 file_obj : None or file-like 

297 Object containing header JSON, or None 

298 resolver : trimesh.visual.Resolver 

299 Object which can be used to load other files by name 

300 ignore_broken : bool 

301 If there is a mesh we can't load and this 

302 is True don't raise an exception but return 

303 a partial result 

304 merge_primitives : bool 

305 If True, each GLTF 'mesh' will correspond 

306 to a single Trimesh object 

307 skip_materials : bool 

308 If true, will not load materials (if present). 

309 **mesh_kwargs : dict 

310 Passed to mesh constructor 

311 

312 Returns 

313 -------------- 

314 kwargs : dict 

315 Arguments to create scene 

316 """ 

317 try: 

318 # see if we've been passed the GLTF header file 

319 tree = json.loads(util.decode_text(file_obj.read())) 

320 except BaseException: 

321 # otherwise header should be in 'model.gltf' 

322 data = resolver["model.gltf"] 

323 # old versions of python/json need strings 

324 tree = json.loads(util.decode_text(data)) 

325 

326 # gltf 1.0 is a totally different format 

327 # that wasn't widely deployed before they fixed it 

328 version = tree.get("asset", {}).get("version", "2.0") 

329 if isinstance(version, str): 

330 # parse semver like '1.0.1' into just a major integer 

331 major = int(version.split(".", 1)[0]) 

332 else: 

333 major = int(float(version)) 

334 

335 if major < 2: 

336 raise NotImplementedError(f"only GLTF 2 is supported not `{version}`") 

337 

338 # use the URI and resolver to get data from file names 

339 buffers = [ 

340 _uri_to_bytes(uri=b["uri"], resolver=resolver) for b in tree.get("buffers", []) 

341 ] 

342 

343 # turn the layout header and data into kwargs 

344 # that can be used to instantiate a trimesh.Scene object 

345 kwargs = _read_buffers( 

346 header=tree, 

347 buffers=buffers, 

348 ignore_broken=ignore_broken, 

349 merge_primitives=merge_primitives, 

350 mesh_kwargs=mesh_kwargs, 

351 skip_materials=skip_materials, 

352 resolver=resolver, 

353 ) 

354 return kwargs 

355 

356 

357def load_glb( 

358 file_obj: Stream, 

359 resolver: ResolverLike | None = None, 

360 ignore_broken: bool = False, 

361 merge_primitives: bool = False, 

362 skip_materials: bool = False, 

363 **mesh_kwargs, 

364): 

365 """ 

366 Load a GLTF file in the binary GLB format into a trimesh.Scene. 

367 

368 Implemented from specification: 

369 https://github.com/KhronosGroup/glTF/tree/master/specification/2.0 

370 

371 Parameters 

372 ------------ 

373 file_obj : file- like object 

374 Containing GLB data 

375 resolver : trimesh.visual.Resolver 

376 Object which can be used to load other files by name 

377 ignore_broken : bool 

378 If there is a mesh we can't load and this 

379 is True don't raise an exception but return 

380 a partial result 

381 merge_primitives : bool 

382 If True, each GLTF 'mesh' will correspond to a 

383 single Trimesh object. 

384 skip_materials : bool 

385 If true, will not load materials (if present). 

386 

387 Returns 

388 ------------ 

389 kwargs : dict 

390 Kwargs to instantiate a trimesh.Scene 

391 """ 

392 # read the first 20 bytes which contain section lengths 

393 head_data = file_obj.read(20) 

394 head = np.frombuffer(head_data, dtype="<u4") 

395 

396 # check to make sure first index is gltf magic header 

397 if head[0] != _magic["gltf"]: 

398 raise ValueError("incorrect header on GLB file") 

399 

400 # and second value is version: should be 2 for GLTF 2.0 

401 if head[1] != 2: 

402 raise NotImplementedError(f"only GLTF 2 is supported not `{head[1]}`") 

403 

404 # overall file length 

405 # first chunk length 

406 # first chunk type 

407 length, chunk_length, chunk_type = head[2:] 

408 

409 # first chunk should be JSON header 

410 if chunk_type != _magic["json"]: 

411 raise ValueError("no initial JSON header!") 

412 

413 # uint32 causes an error in read, so we convert to native int 

414 # for the length passed to read, for the JSON header 

415 json_data = file_obj.read(int(chunk_length)) 

416 # convert to text 

417 if hasattr(json_data, "decode"): 

418 json_data = util.decode_text(json_data) 

419 # load the json header to native dict 

420 header = json.loads(json_data) 

421 

422 # read the binary data referred to by GLTF as 'buffers' 

423 buffers = [] 

424 start = file_obj.tell() 

425 

426 # header can contain base64 encoded data in the URI field 

427 info = header.get("buffers", []).copy() 

428 

429 while (file_obj.tell() - start) < length: 

430 # if we have buffer infos with URI check it here 

431 try: 

432 # if they have interleaved URI data with GLB data handle it here 

433 uri = info.pop(0)["uri"] 

434 buffers.append(_uri_to_bytes(uri=uri, resolver=resolver)) 

435 continue 

436 except (IndexError, KeyError): 

437 # if there was no buffer info or URI we still need to read 

438 pass 

439 

440 # the last read put us past the JSON chunk 

441 # we now read the chunk header, which is 8 bytes 

442 chunk_head = file_obj.read(8) 

443 if len(chunk_head) != 8: 

444 # double check to make sure we didn't 

445 # read the whole file 

446 break 

447 chunk_length, chunk_type = np.frombuffer(chunk_head, dtype="<u4") 

448 # read the chunk 

449 chunk_data = file_obj.read(int(chunk_length)) 

450 if len(chunk_data) != chunk_length: 

451 raise ValueError("chunk was not expected length!") 

452 # the GLTF spec requires ignoring chunks with unknown types 

453 if chunk_type == _magic["bin"]: 

454 buffers.append(chunk_data) 

455 

456 # turn the layout header and data into kwargs 

457 # that can be used to instantiate a trimesh.Scene object 

458 kwargs = _read_buffers( 

459 header=header, 

460 buffers=buffers, 

461 ignore_broken=ignore_broken, 

462 merge_primitives=merge_primitives, 

463 skip_materials=skip_materials, 

464 mesh_kwargs=mesh_kwargs, 

465 resolver=resolver, 

466 ) 

467 

468 return kwargs 

469 

470 

471def _uri_to_bytes(uri: str, resolver: ResolverLike | None) -> bytes: 

472 """ 

473 Take a URI string and load it as a 

474 a filename or as base64. 

475 

476 Parameters 

477 -------------- 

478 uri 

479 Usually a filename or something like: 

480 "data:object/stuff,base64,AABA112A..." 

481 resolver 

482 A resolver to load referenced assets 

483 

484 Returns 

485 --------------- 

486 data 

487 Loaded data from URI 

488 """ 

489 # see if the URI has base64 data 

490 index = uri.find("base64,") 

491 if index < 0: 

492 # string didn't contain the base64 header 

493 # so return the result from the resolver 

494 return resolver[uri] 

495 # strip the base64 header and decode: note that the decoded result is 

496 # 3/4 the length of the payload which is already in-memory 

497 return base64.b64decode(uri[index + 7 :]) 

498 

499 

500def _buffer_append(ordered: IndexedDict, data: bytes) -> int: 

501 """ 

502 Append data to an existing IndexedDict and 

503 pad it to a 4-byte boundary. 

504 

505 Parameters 

506 ---------- 

507 ordered : IndexedDict 

508 Keyed like { hash : data } 

509 data : bytes 

510 To be stored 

511 

512 Returns 

513 ---------- 

514 index : int 

515 Index of buffer_items stored in 

516 """ 

517 # hash the data to see if we have it already 

518 hashed = hash_fast(data) 

519 if hashed in ordered: 

520 return ordered.index(hashed) 

521 # not in buffer items so append and then return index 

522 ordered[hashed] = _byte_pad(data) 

523 

524 return len(ordered) - 1 

525 

526 

527def _data_append( 

528 acc: IndexedDict, 

529 buff: IndexedDict, 

530 blob: dict, 

531 data: NDArray, 

532 claimed: dict[int, NDArray] | None = None, 

533): 

534 """ 

535 Append a new accessor to an IndexedDict. 

536 

537 Parameters 

538 ------------ 

539 acc 

540 Collection of accessors, will be mutated in-place 

541 buff 

542 Collection of buffer bytes, will be mutated in-place 

543 blob 

544 Candidate accessor 

545 data 

546 Data to fill in details to blob 

547 claimed 

548 If passed store `data` here keyed by the returned accessor index and 

549 don't write it into `buff`, for an extension which stores it itself. 

550 

551 Returns 

552 ---------- 

553 index : int 

554 Index of accessor that was added or reused. 

555 """ 

556 # if we have data include that in the key 

557 as_bytes = data.tobytes() 

558 if hasattr(data, "hash_fast"): 

559 # passed a TrackedArray object 

560 hashed = data.hash_fast() 

561 else: 

562 # someone passed a vanilla numpy array 

563 hashed = hash_fast(as_bytes) 

564 

565 if claimed is not None: 

566 # an extension stores this itself: `byteOffset` is only valid alongside a 

567 # `bufferView` and an accessor with neither can't collide with a stored copy 

568 blob.pop("byteOffset", None) 

569 elif hashed in buff: 

570 blob["bufferView"] = buff.index(hashed) 

571 else: 

572 # not in buffer items so append and then return index 

573 buff[hashed] = _byte_pad(as_bytes) 

574 blob["bufferView"] = len(buff) - 1 

575 

576 # start by hashing the dict blob 

577 # note that this will not work if a value is a list 

578 try: 

579 # simple keys can be hashed as tuples without JSON 

580 key = hash(tuple(blob.items())) 

581 except BaseException: 

582 # if there are list keys that break the simple hash 

583 key = hash(json.dumps(blob, sort_keys=True)) 

584 

585 # xor the hash for the blob to the key 

586 key ^= hashed 

587 

588 # a claim records here too as this is another primitive's accessor 

589 if key in acc: 

590 index = acc.index(key) 

591 if claimed is not None: 

592 claimed[index] = data 

593 return index 

594 

595 # get a numpy dtype for our components 

596 dtype = np.dtype(_dtypes[blob["componentType"]]) 

597 # see if we're an array, matrix, etc 

598 kind = blob["type"] 

599 

600 if tol.strict: 

601 # in unit tests make sure everything we're trying to export 

602 # is finite, which also checks for accidental NaN values 

603 assert np.isfinite(data).all() 

604 

605 if kind == "SCALAR": 

606 # is probably (n, 1) 

607 blob["count"] = int(np.prod(data.shape)) 

608 blob["max"] = np.array([data.max()], dtype=dtype).tolist() 

609 blob["min"] = np.array([data.min()], dtype=dtype).tolist() 

610 elif kind.startswith("MAT"): 

611 # i.e. (n, 4, 4) matrices 

612 blob["count"] = len(data) 

613 else: 

614 # reshape the data into what we're actually exporting 

615 resh = data.reshape((-1, _shapes[kind])) 

616 blob["count"] = len(resh) 

617 blob["max"] = resh.max(axis=0).astype(dtype).tolist() 

618 blob["min"] = resh.min(axis=0).astype(dtype).tolist() 

619 

620 # store the accessor and return the index 

621 acc[key] = blob 

622 index = len(acc) - 1 

623 if claimed is not None: 

624 claimed[index] = data 

625 return index 

626 

627 

628def _jsonify(blob): 

629 """ 

630 Roundtrip a blob through json export-import cycle 

631 skipping any internal keys. 

632 """ 

633 return json.loads( 

634 util.jsonify({k: v for k, v in blob.items() if not k.startswith("_")}) 

635 ) 

636 

637 

638def _create_gltf_structure( 

639 scene, 

640 include_normals=None, 

641 include_metadata=True, 

642 unitize_normals=None, 

643 buffer_postprocessor=None, 

644 extension_webp=False, 

645 extension_draco=False, 

646): 

647 """ 

648 Generate a GLTF header. 

649 

650 Parameters 

651 ------------- 

652 scene : trimesh.Scene 

653 Input scene data 

654 include_metadata : bool 

655 Include `scene.metadata` as `scenes/{idx}/extras/metadata` 

656 include_normals : bool 

657 Include vertex normals in output file? 

658 unitize_normals : bool 

659 Unitize all exported normals so as to pass GLTF validation 

660 extension_webp : bool 

661 Export textures as webP using EXT_texture_webp extension. 

662 extension_draco : bool 

663 Compress mesh data using Draco (KHR_draco_mesh_compression). 

664 

665 Returns 

666 --------------- 

667 tree : dict 

668 Contains required keys for a GLTF scene 

669 buffer_items : list 

670 Contains bytes of data 

671 """ 

672 if extension_draco: 

673 # fail once here rather than warning per mesh and writing an uncompressed file 

674 import DracoPy # noqa: F401 

675 

676 # we are defining a single scene, and will be setting the 

677 # world node to the 0-index 

678 tree = { 

679 "scene": 0, 

680 # the root node indices are filled in from the scene graph 

681 "scenes": [{}], 

682 "asset": {"version": "2.0", "generator": "https://github.com/mikedh/trimesh"}, 

683 "accessors": IndexedDict(), 

684 "meshes": [], 

685 "images": [], 

686 "textures": [], 

687 "materials": [], 

688 } 

689 

690 if scene.has_camera: 

691 tree["cameras"] = [_convert_camera(scene.camera)] 

692 

693 if include_metadata and len(scene.metadata) > 0: 

694 try: 

695 # fail here if data isn't json compatible 

696 # only export the extras if there is something there 

697 tree["scenes"][0]["extras"] = _jsonify(scene.metadata) 

698 extensions = tree["scenes"][0]["extras"].pop("gltf_extensions", None) 

699 if isinstance(extensions, dict): 

700 tree["extensions"] = extensions 

701 except BaseException: 

702 log.debug("failed to export scene metadata!", exc_info=True) 

703 

704 # store materials as {hash : index} to avoid duplicates 

705 mat_hashes = {} 

706 # store data from geometries 

707 buffer_items = IndexedDict() 

708 

709 # map the name of each mesh to the index in tree['meshes'] 

710 mesh_index = {} 

711 previous = len(tree["meshes"]) 

712 

713 # loop through every geometry 

714 for name, geometry in scene.geometry.items(): 

715 if util.is_instance_named(geometry, "Trimesh"): 

716 # add the mesh 

717 _append_mesh( 

718 mesh=geometry, 

719 name=name, 

720 tree=tree, 

721 buffer_items=buffer_items, 

722 include_normals=include_normals, 

723 unitize_normals=unitize_normals, 

724 mat_hashes=mat_hashes, 

725 extension_webp=extension_webp, 

726 extension_draco=extension_draco, 

727 ) 

728 elif util.is_instance_named(geometry, "Path"): 

729 # add Path2D and Path3D objects 

730 _append_path(path=geometry, name=name, tree=tree, buffer_items=buffer_items) 

731 elif util.is_instance_named(geometry, "PointCloud"): 

732 # add PointCloud objects 

733 _append_point( 

734 points=geometry, name=name, tree=tree, buffer_items=buffer_items 

735 ) 

736 

737 # only store the index if the append did anything 

738 if len(tree["meshes"]) != previous: 

739 previous = len(tree["meshes"]) 

740 mesh_index[name] = previous - 1 

741 

742 # grab the flattened scene graph in GLTF's format 

743 nodes = scene.graph.to_gltf(scene=scene, mesh_index=mesh_index) 

744 # set the roots on the existing scene dict — it may already 

745 # hold `extras` with the scene metadata 

746 tree["scenes"][0]["nodes"] = nodes.pop("scene_roots") 

747 tree.update(nodes) 

748 

749 extensions_used = set() 

750 extensions_required = set() 

751 # Add any scene extensions used 

752 if "extensions" in tree: 

753 extensions_used = extensions_used.union(set(tree["extensions"].keys())) 

754 # Add any mesh extensions used 

755 for mesh in tree["meshes"]: 

756 if "extensions" in mesh: 

757 extensions_used = extensions_used.union(set(mesh["extensions"].keys())) 

758 # Check primitives for extensions too 

759 for prim in mesh.get("primitives", []): 

760 if "extensions" in prim: 

761 extensions_used = extensions_used.union(set(prim["extensions"].keys())) 

762 # Add any extensions already in the tree (e.g. node extensions) 

763 if "extensionsUsed" in tree: 

764 extensions_used = extensions_used.union(set(tree["extensionsUsed"])) 

765 # Add WebP if used 

766 if extension_webp: 

767 extensions_used.add("EXT_texture_webp") 

768 extensions_required.add("EXT_texture_webp") 

769 # draco has no fallback so it is required, but only if a primitive really got 

770 # compressed: a file requiring an extension nothing uses is refused by loaders 

771 if "KHR_draco_mesh_compression" in extensions_used: 

772 extensions_required.add("KHR_draco_mesh_compression") 

773 if len(extensions_used) > 0: 

774 tree["extensionsUsed"] = list(extensions_used) 

775 if len(extensions_required) > 0: 

776 tree["extensionsRequired"] = list(extensions_required) 

777 

778 if buffer_postprocessor is not None: 

779 buffer_postprocessor(buffer_items, tree) 

780 

781 # convert accessors back to a flat list 

782 tree["accessors"] = list(tree["accessors"].values()) 

783 

784 # cull empty or unpopulated fields 

785 # check keys that might be empty so we can remove them 

786 check = ["textures", "materials", "images", "accessors", "meshes"] 

787 # remove the keys with nothing stored in them 

788 [tree.pop(key) for key in check if len(tree[key]) == 0] 

789 

790 return tree, buffer_items 

791 

792 

793def _append_mesh( 

794 mesh, 

795 name, 

796 tree, 

797 buffer_items, 

798 include_normals: bool | None, 

799 unitize_normals: bool, 

800 mat_hashes: dict, 

801 extension_webp: bool, 

802 extension_draco: bool = False, 

803): 

804 """ 

805 Append a mesh to the scene structure and put the 

806 data into buffer_items. 

807 

808 Parameters 

809 ------------- 

810 mesh : trimesh.Trimesh 

811 Source geometry 

812 name : str 

813 Name of geometry 

814 tree : dict 

815 Will be updated with data from mesh 

816 buffer_items 

817 Will have buffer appended with mesh data 

818 include_normals : bool 

819 Include vertex normals in export or not 

820 unitize_normals : bool 

821 Transform normals into unit vectors. 

822 May be undesirable but will fail validators without this. 

823 

824 mat_hashes : dict 

825 Which materials have already been added 

826 extension_webp : bool 

827 Export textures as webP (using glTF's EXT_texture_webp extension). 

828 extension_draco : bool 

829 Compress mesh data using Draco (KHR_draco_mesh_compression). 

830 """ 

831 # return early from empty meshes to avoid crashing later 

832 if len(mesh.faces) == 0 or len(mesh.vertices) == 0: 

833 log.debug("skipping empty mesh!") 

834 return 

835 

836 # draco absorbs geometry into a buffer of its own, so collect the arrays as 

837 # they are appended for the handler below rather than storing each one twice 

838 claimed = {} if extension_draco else None 

839 

840 # convert mesh data to the correct dtypes 

841 # faces: 5125 is an unsigned 32 bit integer 

842 # accessors refer to data locations 

843 # mesh faces are stored as flat list of integers 

844 acc_face = _data_append( 

845 acc=tree["accessors"], 

846 buff=buffer_items, 

847 blob={"componentType": 5125, "type": "SCALAR"}, 

848 data=mesh.faces.astype(uint32), 

849 claimed=claimed, 

850 ) 

851 

852 # vertices: 5126 is a float32 

853 # create or reuse an accessor for these vertices 

854 acc_vertex = _data_append( 

855 acc=tree["accessors"], 

856 buff=buffer_items, 

857 blob={"componentType": 5126, "type": "VEC3", "byteOffset": 0}, 

858 data=mesh.vertices.astype(float32), 

859 claimed=claimed, 

860 ) 

861 

862 # meshes reference accessor indexes 

863 current = { 

864 "name": name, 

865 "extras": {}, 

866 "primitives": [ 

867 { 

868 "attributes": {"POSITION": acc_vertex}, 

869 "indices": acc_face, 

870 "mode": _GL_TRIANGLES, 

871 } 

872 ], 

873 } 

874 # if units are defined, store them as an extra 

875 # the GLTF spec says everything is implicit meters 

876 # we're not doing that as our unit conversions are expensive 

877 # although that might be better, implicit works for 3DXML 

878 # https://github.com/KhronosGroup/glTF/tree/master/extensions 

879 try: 

880 # skip jsonify any metadata, skipping internal keys 

881 current["extras"] = _jsonify(mesh.metadata) 

882 

883 # extract extensions if any 

884 extensions = current["extras"].pop("gltf_extensions", None) 

885 if isinstance(extensions, dict): 

886 current["extensions"] = extensions 

887 

888 if mesh.units not in [None, "m", "meters", "meter"]: 

889 current["extras"]["units"] = str(mesh.units) 

890 except BaseException: 

891 log.debug("metadata not serializable, dropping!", exc_info=True) 

892 

893 # check to see if we have vertex or face colors 

894 # or if a TextureVisual has colors included as an attribute 

895 if mesh.visual.kind in ["vertex", "face"]: 

896 vertex_colors = mesh.visual.vertex_colors 

897 elif ( 

898 hasattr(mesh.visual, "vertex_attributes") 

899 and "color" in mesh.visual.vertex_attributes 

900 ): 

901 vertex_colors = mesh.visual.vertex_attributes["color"] 

902 else: 

903 vertex_colors = None 

904 

905 if vertex_colors is not None: 

906 if len(vertex_colors) == len(mesh.vertices): 

907 # convert color data to bytes and append 

908 acc_color = _data_append( 

909 acc=tree["accessors"], 

910 buff=buffer_items, 

911 blob={ 

912 "componentType": 5121, 

913 "normalized": True, 

914 "type": "VEC4", 

915 "byteOffset": 0, 

916 }, 

917 data=vertex_colors.astype(uint8), 

918 claimed=claimed, 

919 ) 

920 

921 # add the reference for vertex color 

922 current["primitives"][0]["attributes"]["COLOR_0"] = acc_color 

923 else: 

924 log.warning( 

925 "Vertex colors have different length than mesh vertices, dropping!" 

926 ) 

927 

928 if hasattr(mesh.visual, "material"): 

929 # append the material and then set from returned index 

930 current_material = _append_material( 

931 mat=mesh.visual.material, 

932 tree=tree, 

933 buffer_items=buffer_items, 

934 mat_hashes=mat_hashes, 

935 extension_webp=extension_webp, 

936 ) 

937 

938 # if mesh has UV coordinates defined export them 

939 has_uv = ( 

940 hasattr(mesh.visual, "uv") 

941 and mesh.visual.uv is not None 

942 and len(mesh.visual.uv) == len(mesh.vertices) 

943 ) 

944 if has_uv: 

945 # slice off W if passed 

946 uv = mesh.visual.uv.copy()[:, :2] 

947 # reverse the Y for GLTF 

948 uv[:, 1] = 1.0 - uv[:, 1] 

949 # add an accessor describing the blob of UV's 

950 acc_uv = _data_append( 

951 acc=tree["accessors"], 

952 buff=buffer_items, 

953 blob={"componentType": 5126, "type": "VEC2", "byteOffset": 0}, 

954 data=uv.astype(float32), 

955 claimed=claimed, 

956 ) 

957 # add the reference for UV coordinates 

958 current["primitives"][0]["attributes"]["TEXCOORD_0"] = acc_uv 

959 

960 # reference the material 

961 current["primitives"][0]["material"] = current_material 

962 

963 if include_normals or ( 

964 include_normals is None and "vertex_normals" in mesh._cache.cache 

965 ): 

966 # store vertex normals if requested 

967 if unitize_normals: 

968 normals = util.unitize(mesh.vertex_normals) 

969 else: 

970 # we don't have to copy them since 

971 # they aren't being altered 

972 normals = mesh.vertex_normals 

973 

974 acc_norm = _data_append( 

975 acc=tree["accessors"], 

976 buff=buffer_items, 

977 blob={ 

978 "componentType": 5126, 

979 "count": len(mesh.vertices), 

980 "type": "VEC3", 

981 "byteOffset": 0, 

982 }, 

983 data=normals.astype(float32), 

984 claimed=claimed, 

985 ) 

986 # add the reference for vertex color 

987 current["primitives"][0]["attributes"]["NORMAL"] = acc_norm 

988 

989 # for each attribute with a leading underscore, assign them to trimesh 

990 # vertex_attributes 

991 for key, attrib in mesh.vertex_attributes.items(): 

992 # make sure vertex attribute length matches vertices 

993 if len(attrib) != len(mesh.vertices): 

994 log.warning( 

995 f"Vertex attribute `{key}` has different length than mesh vertices skipping!" 

996 ) 

997 continue 

998 

999 # application specific attributes must be prefixed with an underscore 

1000 if not key.startswith("_"): 

1001 key = "_" + key 

1002 

1003 # GLTF has no floating point type larger than 32 bits so clip 

1004 # any float64 or larger to float32 

1005 if attrib.dtype.kind == "f" and attrib.dtype.itemsize > 4: 

1006 data = attrib.astype(float32) 

1007 else: 

1008 # force little-endian to match GLTF binary format 

1009 data = attrib.astype(attrib.dtype.newbyteorder("<"), copy=False) 

1010 

1011 if len(data.shape) == 1: 

1012 data = data[:, np.newaxis] 

1013 

1014 # every accessor VALUE must be 4-byte aligned 

1015 row_mod = (data.shape[1] * data.dtype.itemsize) % 4 

1016 # if the row size is not a multiple of 4, pad it 

1017 if row_mod != 0: 

1018 # how many columns of padding for this value 

1019 pad_columns = (4 - row_mod) // data.dtype.itemsize 

1020 # pad this custom attribute with zeros -_- 

1021 data = np.pad(data, ((0, 0), (0, pad_columns)), mode="constant") 

1022 

1023 # store custom vertex attributes 

1024 current["primitives"][0]["attributes"][key] = _data_append( 

1025 acc=tree["accessors"], 

1026 buff=buffer_items, 

1027 blob=_build_accessor(data), 

1028 data=data, 

1029 ) 

1030 

1031 # Handle Draco compression via extension handler 

1032 if extension_draco: 

1033 # Call primitive_export handlers 

1034 compressed = handle_extensions( 

1035 extensions={"KHR_draco_mesh_compression": {}}, 

1036 scope="primitive_export", 

1037 mesh=mesh, 

1038 name=name, 

1039 tree=tree, 

1040 buffer_items=buffer_items, 

1041 primitive=current["primitives"][0], 

1042 arrays=claimed, 

1043 ) 

1044 if not compressed: 

1045 # nothing claimed the arrays so store them after all, keyed by accessor 

1046 # not content: two of these landing on one view would need a `byteStride` 

1047 blobs = list(tree["accessors"].values()) 

1048 for index, data in claimed.items(): 

1049 key = ("accessor", index) 

1050 buffer_items[key] = _byte_pad(data.tobytes()) 

1051 blobs[index]["bufferView"] = buffer_items.index(key) 

1052 blobs[index]["byteOffset"] = 0 

1053 

1054 tree["meshes"].append(current) 

1055 

1056 

1057def _build_views(buffer_items): 

1058 """ 

1059 Create views for buffers that are simply 

1060 based on how many bytes they are long. 

1061 

1062 Parameters 

1063 -------------- 

1064 buffer_items : IndexedDict 

1065 Buffers to build views for 

1066 

1067 Returns 

1068 ---------- 

1069 views : (n,) list of dict 

1070 GLTF views 

1071 """ 

1072 views = [] 

1073 # create the buffer views 

1074 current_pos = 0 

1075 for current_item in buffer_items.values(): 

1076 views.append( 

1077 {"buffer": 0, "byteOffset": current_pos, "byteLength": len(current_item)} 

1078 ) 

1079 assert (current_pos % 4) == 0 

1080 assert (len(current_item) % 4) == 0 

1081 current_pos += len(current_item) 

1082 return views 

1083 

1084 

1085def _build_accessor(array): 

1086 """ 

1087 Build an accessor for an arbitrary array. 

1088 

1089 Parameters 

1090 ----------- 

1091 array : numpy array 

1092 The array to build an accessor for 

1093 

1094 Returns 

1095 ---------- 

1096 accessor : dict 

1097 The accessor for array. 

1098 """ 

1099 shape = array.shape 

1100 data_type = "SCALAR" 

1101 if len(shape) == 2: 

1102 vec_length = shape[1] 

1103 if vec_length > 4: 

1104 raise ValueError("The GLTF spec does not support vectors larger than 4") 

1105 if vec_length > 1: 

1106 data_type = f"VEC{int(vec_length)}" 

1107 else: 

1108 data_type = "SCALAR" 

1109 

1110 if len(shape) == 3: 

1111 if shape[2] not in [2, 3, 4]: 

1112 raise ValueError("Matrix types must have 4, 9 or 16 components") 

1113 data_type = f"MAT{int(shape[2])}" 

1114 

1115 # get the array data type as a str stripping off endian 

1116 lookup = array.dtype.str.lstrip("<>|") 

1117 

1118 if lookup == "u4": 

1119 # spec: UNSIGNED_INT is only allowed when the accessor 

1120 # contains indices i.e. the accessor is only referenced 

1121 # by `primitive.indices` 

1122 log.debug("custom uint32 may cause validation failures") 

1123 

1124 # map the numpy dtype to a GLTF code (i.e. 5121) 

1125 componentType = _dtypes_lookup[lookup] 

1126 accessor = {"componentType": componentType, "type": data_type, "byteOffset": 0} 

1127 

1128 if len(shape) < 3: 

1129 accessor["max"] = array.max(axis=0).tolist() 

1130 accessor["min"] = array.min(axis=0).tolist() 

1131 

1132 return accessor 

1133 

1134 

1135def _byte_pad(data, bound=4): 

1136 """ 

1137 GLTF wants chunks aligned with 4 byte boundaries. 

1138 This function will add padding to the end of a 

1139 chunk of bytes so that it aligns with the passed 

1140 boundary size. 

1141 

1142 Parameters 

1143 -------------- 

1144 data : bytes 

1145 Data to be padded 

1146 bound : int 

1147 Length of desired boundary 

1148 

1149 Returns 

1150 -------------- 

1151 padded : bytes 

1152 Result where: (len(padded) % bound) == 0 

1153 """ 

1154 assert isinstance(data, bytes) 

1155 if len(data) % bound != 0: 

1156 # extra bytes to pad with 

1157 count = bound - (len(data) % bound) 

1158 pad = bytes(count) 

1159 # combine the padding and data 

1160 result = b"".join([data, pad]) 

1161 # we should always divide evenly 

1162 if tol.strict and (len(result) % bound) != 0: 

1163 raise ValueError("byte_pad failed!") 

1164 return result 

1165 return data 

1166 

1167 

1168def _append_path(path, name, tree, buffer_items): 

1169 """ 

1170 Append a 2D or 3D path to the scene structure and put the 

1171 data into buffer_items. 

1172 

1173 Parameters 

1174 ------------- 

1175 path : trimesh.Path2D or trimesh.Path3D 

1176 Source geometry 

1177 name : str 

1178 Name of geometry 

1179 tree : dict 

1180 Will be updated with data from path 

1181 buffer_items 

1182 Will have buffer appended with path data 

1183 """ 

1184 

1185 # convert the path to the unnamed args for 

1186 # a pyglet vertex list 

1187 vxlist = rendering.path_to_vertexlist(path) 

1188 

1189 # of the count of things to export is zero exit early 

1190 if vxlist[0] == 0: 

1191 return 

1192 

1193 # TODO add color support to Path object 

1194 # this is just exporting everying as black 

1195 try: 

1196 material_idx = tree["materials"].index(_default_material) 

1197 except ValueError: 

1198 material_idx = len(tree["materials"]) 

1199 tree["materials"].append(_default_material) 

1200 

1201 # data is the second value of the fifth field 

1202 # which is a (data type, data) tuple 

1203 acc_vertex = _data_append( 

1204 acc=tree["accessors"], 

1205 buff=buffer_items, 

1206 blob={"componentType": 5126, "type": "VEC3", "byteOffset": 0}, 

1207 data=vxlist[4][1].astype(float32), 

1208 ) 

1209 

1210 current = { 

1211 "name": name, 

1212 "primitives": [ 

1213 { 

1214 "attributes": {"POSITION": acc_vertex}, 

1215 "mode": _GL_LINES, # i.e. 1 

1216 "material": material_idx, 

1217 } 

1218 ], 

1219 } 

1220 

1221 # if units are defined, store them as an extra: 

1222 # https://github.com/KhronosGroup/glTF/tree/master/extensions 

1223 try: 

1224 current["extras"] = _jsonify(path.metadata) 

1225 except BaseException: 

1226 log.debug("failed to serialize metadata, dropping!", exc_info=True) 

1227 

1228 if path.colors is not None: 

1229 acc_color = _data_append( 

1230 acc=tree["accessors"], 

1231 buff=buffer_items, 

1232 blob={ 

1233 "componentType": 5121, 

1234 "normalized": True, 

1235 "type": "VEC4", 

1236 "byteOffset": 0, 

1237 }, 

1238 data=np.array(vxlist[5][1]).astype(uint8), 

1239 ) 

1240 # add color to attributes 

1241 current["primitives"][0]["attributes"]["COLOR_0"] = acc_color 

1242 

1243 # for each attribute with a leading underscore, assign them to path 

1244 # vertex_attributes 

1245 for key, attrib in path.vertex_attributes.items(): 

1246 # Application specific attributes must be 

1247 # prefixed with an underscore 

1248 if not key.startswith("_"): 

1249 key = "_" + key 

1250 

1251 # GLTF has no floating point type larger than 32 bits so clip 

1252 # any float64 or larger to float32 

1253 if attrib.dtype.kind == "f" and attrib.dtype.itemsize > 4: 

1254 data = attrib.astype(float32) 

1255 else: 

1256 # force little-endian to match GLTF binary format 

1257 data = attrib.astype(attrib.dtype.newbyteorder("<"), copy=False) 

1258 

1259 if not all(util.is_instance_named(e, "Line") for e in path.entities): 

1260 log.warning( 

1261 f"Vertex attributes are only supported for Line entities, skipping `{key}`" 

1262 ) 

1263 continue 

1264 

1265 data_discretized = np.array( 

1266 [util.stack_lines(e.discrete(data)) for e in path.entities] 

1267 ) 

1268 stacked_data = data_discretized.reshape((-1,)) 

1269 

1270 # store custom vertex attributes 

1271 current["primitives"][0]["attributes"][key] = _data_append( 

1272 acc=tree["accessors"], 

1273 buff=buffer_items, 

1274 blob=_build_accessor(stacked_data), 

1275 data=stacked_data, 

1276 ) 

1277 

1278 tree["meshes"].append(current) 

1279 

1280 

1281def _append_point(points, name, tree, buffer_items): 

1282 """ 

1283 Append a 2D or 3D pointCloud to the scene structure and 

1284 put the data into buffer_items. 

1285 

1286 Parameters 

1287 ------------- 

1288 points : trimesh.PointCloud 

1289 Source geometry 

1290 name : str 

1291 Name of geometry 

1292 tree : dict 

1293 Will be updated with data from points 

1294 buffer_items 

1295 Will have buffer appended with points data 

1296 """ 

1297 

1298 # convert the points to the unnamed args for 

1299 # a pyglet vertex list 

1300 vxlist = rendering.points_to_vertexlist(points=points.vertices, colors=points.colors) 

1301 

1302 # data is the second value of the fifth field 

1303 # which is a (data type, data) tuple 

1304 acc_vertex = _data_append( 

1305 acc=tree["accessors"], 

1306 buff=buffer_items, 

1307 blob={"componentType": 5126, "type": "VEC3", "byteOffset": 0}, 

1308 data=vxlist[4][1].astype(float32), 

1309 ) 

1310 current = { 

1311 "name": name, 

1312 "primitives": [ 

1313 { 

1314 "attributes": {"POSITION": acc_vertex}, 

1315 "mode": _GL_POINTS, 

1316 "material": len(tree["materials"]), 

1317 } 

1318 ], 

1319 } 

1320 

1321 # TODO add color support to Points object 

1322 # this is just exporting everying as black 

1323 tree["materials"].append(_default_material) 

1324 

1325 if len(np.shape(points.colors)) == 2: 

1326 # colors may be returned as "c3f" or other RGBA 

1327 color_type, color_data = vxlist[5] 

1328 if "3" in color_type: 

1329 kind = "VEC3" 

1330 elif "4" in color_type: 

1331 kind = "VEC4" 

1332 else: 

1333 raise ValueError("unknown color: %s", color_type) 

1334 acc_color = _data_append( 

1335 acc=tree["accessors"], 

1336 buff=buffer_items, 

1337 blob={ 

1338 "componentType": 5121, 

1339 "count": vxlist[0], 

1340 "normalized": True, 

1341 "type": kind, 

1342 "byteOffset": 0, 

1343 }, 

1344 data=np.array(color_data).astype(uint8), 

1345 ) 

1346 # add color to attributes 

1347 current["primitives"][0]["attributes"]["COLOR_0"] = acc_color 

1348 tree["meshes"].append(current) 

1349 

1350 

1351def _parse_textures(header, views, resolver=None): 

1352 try: 

1353 import PIL.Image 

1354 except ImportError: 

1355 log.debug("unable to load textures without pillow!") 

1356 return None 

1357 

1358 # load any images 

1359 images = None 

1360 if "images" in header: 

1361 # images are referenced by index 

1362 images = [None] * len(header["images"]) 

1363 # loop through images 

1364 for i, img in enumerate(header["images"]): 

1365 if img.get("mimeType", "") == "image/ktx2": 

1366 log.debug("`image/ktx2` textures are unsupported, skipping!") 

1367 continue 

1368 # get the bytes representing an image 

1369 if "bufferView" in img: 

1370 blob = views[img["bufferView"]] 

1371 elif "uri" in img: 

1372 try: 

1373 # will get bytes from filesystem or base64 URI 

1374 blob = _uri_to_bytes(uri=img["uri"], resolver=resolver) 

1375 except BaseException: 

1376 log.debug(f"unable to load image from: {img.keys()}", exc_info=True) 

1377 continue 

1378 else: 

1379 log.debug(f"unable to load image from: {img.keys()}") 

1380 continue 

1381 # i.e. 'image/jpeg' 

1382 # mime = img['mimeType'] 

1383 try: 

1384 # load the buffer into a PIL image 

1385 images[i] = PIL.Image.open(util.wrap_as_stream(blob)) 

1386 except BaseException: 

1387 log.debug("failed to load image!", exc_info=True) 

1388 return images 

1389 

1390 

1391def _parse_materials(header, views, resolver=None): 

1392 """ 

1393 Convert materials and images stored in a GLTF header 

1394 and buffer views to PBRMaterial objects. 

1395 

1396 Parameters 

1397 ------------ 

1398 header : dict 

1399 Contains layout of file 

1400 views : (n,) bytes 

1401 Raw data 

1402 

1403 Returns 

1404 ------------ 

1405 materials : list 

1406 List of trimesh.visual.texture.Material objects 

1407 """ 

1408 

1409 def parse_textures(*, data): 

1410 result = {} 

1411 for k, v in data.items(): 

1412 if isinstance(v, (list, tuple)): 

1413 # colors are always float 0.0 - 1.0 in GLTF 

1414 result[k] = np.array(v, dtype=np.float64) 

1415 elif not isinstance(v, dict): 

1416 result[k] = v 

1417 elif images is not None and "index" in v: 

1418 try: 

1419 index = None 

1420 texture = header["textures"][v["index"]] 

1421 # Handle texture extensions through registry 

1422 if tex_ext := texture.get("extensions"): 

1423 index = handle_extensions( 

1424 extensions=tex_ext, scope="texture_source" 

1425 ) 

1426 

1427 if index is None: 

1428 # fall back to standard source key 

1429 index = texture.get("source") 

1430 if index is not None: 

1431 result[k] = images[index] 

1432 except BaseException: 

1433 log.debug("unable to store texture", exc_info=True) 

1434 return result 

1435 

1436 images = _parse_textures(header, views, resolver) 

1437 

1438 # store materials which reference images 

1439 materials = [] 

1440 if "materials" in header: 

1441 for mat in header["materials"]: 

1442 # flatten key structure so we can loop it 

1443 loopable = mat.copy() 

1444 # this key stores another dict of crap 

1445 if "pbrMetallicRoughness" in loopable: 

1446 # add keys of keys to top level dict 

1447 loopable.update(loopable.pop("pbrMetallicRoughness")) 

1448 

1449 # Handle material extensions through registry 

1450 if mat_extensions := mat.get("extensions"): 

1451 ext_results = handle_extensions( 

1452 extensions=mat_extensions, 

1453 scope="material", 

1454 parse_textures=parse_textures, 

1455 images=images, 

1456 ) 

1457 # Flatten extension results into the material parameters 

1458 for ext_result in ext_results.values(): 

1459 if isinstance(ext_result, dict): 

1460 loopable.update(ext_result) 

1461 

1462 # save flattened keys we can use for kwargs 

1463 pbr = parse_textures(data=loopable) 

1464 # create a PBR material object for the GLTF material 

1465 materials.append(visual.material.PBRMaterial(**pbr)) 

1466 

1467 return materials 

1468 

1469 

1470def _read_buffers( 

1471 header: dict, 

1472 buffers: list[bytes], 

1473 mesh_kwargs: dict, 

1474 resolver: ResolverLike | None, 

1475 ignore_broken: bool = False, 

1476 merge_primitives: bool = False, 

1477 skip_materials: bool = False, 

1478): 

1479 """ 

1480 Given binary data and a layout return the 

1481 kwargs to create a scene object. 

1482 

1483 Parameters 

1484 ----------- 

1485 header : dict 

1486 With GLTF keys 

1487 buffers : list of bytes 

1488 Stored data 

1489 mesh_kwargs : dict 

1490 To be passed to the mesh constructor. 

1491 ignore_broken : bool 

1492 If there is a mesh we can't load and this 

1493 is True don't raise an exception but return 

1494 a partial result 

1495 merge_primitives : bool 

1496 If true, combine primitives into a single mesh. 

1497 skip_materials : bool 

1498 If true, will not load materials (if present). 

1499 resolver : trimesh.resolvers.Resolver 

1500 Resolver to load referenced assets 

1501 

1502 Returns 

1503 ----------- 

1504 kwargs : dict 

1505 Can be passed to load_kwargs for a trimesh.Scene 

1506 """ 

1507 

1508 if "bufferViews" in header: 

1509 # split buffer data into buffer views 

1510 views = [None] * len(header["bufferViews"]) 

1511 for i, view in enumerate(header["bufferViews"]): 

1512 if "byteOffset" in view: 

1513 start = view["byteOffset"] 

1514 else: 

1515 start = 0 

1516 end = start + view["byteLength"] 

1517 views[i] = buffers[view["buffer"]][start:end] 

1518 assert len(views[i]) == view["byteLength"] 

1519 # load data from buffers into numpy arrays 

1520 # using the layout described by accessors 

1521 access = [None] * len(header["accessors"]) 

1522 # bufferless, non-sparse accessors must be filled by an extension or stay zero 

1523 placeholders = set() 

1524 for index, a in enumerate(header["accessors"]): 

1525 # number of items 

1526 count = a["count"] 

1527 # what is the datatype 

1528 dtype = np.dtype(_dtypes[a["componentType"]]) 

1529 # basically how many columns 

1530 # for types like (4, 4) 

1531 per_item = _shapes[a["type"]] 

1532 # use reported count to generate shape 

1533 shape = np.append(count, per_item) 

1534 # number of items when flattened 

1535 # i.e. a (4, 4) MAT4 has 16 

1536 per_count = np.abs(np.prod(per_item)) 

1537 if "bufferView" in a: 

1538 # data was stored in a buffer view so get raw bytes 

1539 

1540 # load the bytes data into correct dtype and shape 

1541 buffer_view = header["bufferViews"][a["bufferView"]] 

1542 

1543 # is the accessor offset in a buffer 

1544 # will include the start, length, and offset 

1545 # but not the bytestride as that is easier to do 

1546 # in numpy rather than in python looping 

1547 data = views[a["bufferView"]] 

1548 

1549 # both bufferView *and* accessors are allowed 

1550 # to have a byteOffset 

1551 start = a.get("byteOffset", 0) 

1552 

1553 if "byteStride" in buffer_view: 

1554 # how many bytes for each chunk 

1555 stride = buffer_view["byteStride"] 

1556 # we want to get the bytes for every row 

1557 per_row = per_count * dtype.itemsize 

1558 # the total block we're looking at 

1559 length = (count - 1) * stride + per_row 

1560 # apply as_strided for fast construction of strided array 

1561 # and copy to ensure contiguous layout 

1562 assert stride > 0, "byteStride should be positive" 

1563 assert 0 <= start <= start + length <= len(data) 

1564 access[index] = np.array( 

1565 np.lib.stride_tricks.as_strided( 

1566 np.frombuffer( 

1567 data, dtype=np.uint8, offset=start, count=length 

1568 ), 

1569 [count, per_row], 

1570 [stride, 1], 

1571 ) 

1572 .view(dtype) 

1573 .reshape(shape) 

1574 ) 

1575 else: 

1576 # length is the number of bytes per item times total 

1577 length = dtype.itemsize * count * per_count 

1578 access[index] = np.frombuffer( 

1579 data[start : start + length], dtype=dtype 

1580 ).reshape(shape) 

1581 else: 

1582 # zero placeholder a decoder may replace 

1583 if "sparse" not in a: 

1584 placeholders.add(index) 

1585 access[index] = np.zeros(count * per_count, dtype=dtype).reshape(shape) 

1586 

1587 # possibly load images and textures into material objects 

1588 if skip_materials: 

1589 materials = [] 

1590 else: 

1591 materials = _parse_materials(header, views=views, resolver=resolver) 

1592 

1593 mesh_prim = defaultdict(list) 

1594 # load data from accessors into Trimesh objects 

1595 meshes = OrderedDict() 

1596 

1597 # keep track of how many times each name has been attempted to 

1598 # be inserted to avoid a potentially slow search through our 

1599 # dict of names 

1600 name_counts = {} 

1601 # extensions whose geometry we couldn't decode for lack of a handler 

1602 undecoded = set() 

1603 for index, m in enumerate(header.get("meshes", [])): 

1604 try: 

1605 # GLTF spec indicates implicit units are meters 

1606 metadata = { 

1607 "units": "meters", 

1608 "from_gltf_primitive": len(m["primitives"]) > 1, 

1609 } 

1610 

1611 # try to load all mesh metadata 

1612 if isinstance(m.get("extras"), dict): 

1613 metadata.update(m["extras"]) 

1614 

1615 # put any mesh extensions in a field of the metadata 

1616 if "extensions" in m: 

1617 metadata["gltf_extensions"] = m["extensions"] 

1618 

1619 for p in m["primitives"]: 

1620 # preprocessing extensions like draco decompression run 

1621 # before reading accessors as they may modify them 

1622 if prim_extensions := p.get("extensions"): 

1623 # a handler which raised can't have decoded anything 

1624 failed = set() 

1625 handle_extensions( 

1626 extensions=prim_extensions, 

1627 scope="primitive_preprocess", 

1628 failed=failed, 

1629 primitive=p, 

1630 accessors=access, 

1631 views=views, 

1632 ) 

1633 # warn later if an extension left placeholder zeros, whether 

1634 # it had no handler or its handler failed 

1635 if not placeholders.isdisjoint(p.get("attributes", {}).values()): 

1636 undecoded.update(failed) 

1637 undecoded.update( 

1638 unregistered(prim_extensions, "primitive_preprocess") 

1639 ) 

1640 

1641 # if we don't have a triangular mesh continue 

1642 # if not specified assume it is a mesh 

1643 kwargs = deepcopy(mesh_kwargs) 

1644 if kwargs.get("metadata", None) is None: 

1645 kwargs["metadata"] = {} 

1646 if "process" not in kwargs: 

1647 kwargs["process"] = False 

1648 kwargs["metadata"].update(metadata) 

1649 # i.e. GL_LINES, GL_TRIANGLES, etc 

1650 # specification says the default mode is GL_TRIANGLES 

1651 mode = p.get("mode", _GL_TRIANGLES) 

1652 # colors, normals, etc 

1653 attr = p["attributes"] 

1654 # create a unique mesh name per- primitive 

1655 name = m.get("name", "GLTF") 

1656 # make name unique across multiple meshes 

1657 name = unique_name(name, meshes, counts=name_counts) 

1658 

1659 if mode == _GL_LINES: 

1660 # load GL_LINES into a Path object 

1661 from ...path.entities import Line 

1662 

1663 kwargs["vertices"] = access[attr["POSITION"]] 

1664 kwargs["entities"] = [Line(points=np.arange(len(kwargs["vertices"])))] 

1665 

1666 # custom attributes starting with a `_` 

1667 custom = { 

1668 a: access[attr[a]] for a in attr.keys() if a.startswith("_") 

1669 } 

1670 if len(custom) > 0: 

1671 kwargs["vertex_attributes"] = custom 

1672 elif mode == _GL_POINTS: 

1673 kwargs["vertices"] = access[attr["POSITION"]] 

1674 visuals = None 

1675 if "COLOR_0" in attr: 

1676 try: 

1677 # try to load vertex colors from the accessors 

1678 colors = access[attr["COLOR_0"]] 

1679 if len(colors) == len(kwargs["vertices"]): 

1680 if visuals is None: 

1681 # just pass to mesh as vertex color 

1682 kwargs["vertex_colors"] = colors.copy() 

1683 else: 

1684 # we ALSO have texture so save as vertex 

1685 # attribute 

1686 visuals.vertex_attributes["color"] = colors.copy() 

1687 except BaseException: 

1688 # survive failed colors 

1689 log.debug("failed to load colors", exc_info=True) 

1690 if visuals is not None: 

1691 kwargs["visual"] = visuals 

1692 elif mode in (_GL_TRIANGLES, _GL_STRIP): 

1693 # get vertices from accessors 

1694 kwargs["vertices"] = access[attr["POSITION"]] 

1695 # get faces from accessors 

1696 if "indices" in p: 

1697 if mode == _GL_STRIP: 

1698 # this is triangle strips 

1699 flat = access[p["indices"]].reshape(-1) 

1700 kwargs["faces"] = triangle_strips_to_faces([flat]) 

1701 else: 

1702 kwargs["faces"] = access[p["indices"]].reshape((-1, 3)) 

1703 else: 

1704 # indices are apparently optional and we are supposed to 

1705 # do the same thing as webGL drawArrays? 

1706 if mode == _GL_STRIP: 

1707 kwargs["faces"] = triangle_strips_to_faces( 

1708 np.array([np.arange(len(kwargs["vertices"]))]) 

1709 ) 

1710 else: 

1711 # GL_TRIANGLES 

1712 kwargs["faces"] = np.arange( 

1713 len(kwargs["vertices"]), dtype=np.int64 

1714 ).reshape((-1, 3)) 

1715 

1716 if "NORMAL" in attr: 

1717 # vertex normals are specified 

1718 kwargs["vertex_normals"] = access[attr["NORMAL"]] 

1719 # do we have UV coordinates 

1720 visuals = None 

1721 if "material" in p and not skip_materials: 

1722 if materials is None: 

1723 log.debug("no materials! `pip install pillow`") 

1724 else: 

1725 uv = None 

1726 if "TEXCOORD_0" in attr: 

1727 # flip UV's top- bottom to move origin to lower-left: 

1728 # https://github.com/KhronosGroup/glTF/issues/1021 

1729 uv = access[attr["TEXCOORD_0"]].copy() 

1730 uv[:, 1] = 1.0 - uv[:, 1] 

1731 # create a texture visual 

1732 visuals = visual.texture.TextureVisuals( 

1733 uv=uv, material=materials[p["material"]] 

1734 ) 

1735 

1736 if "COLOR_0" in attr: 

1737 try: 

1738 # try to load vertex colors from the accessors 

1739 colors = access[attr["COLOR_0"]] 

1740 if len(colors) == len(kwargs["vertices"]): 

1741 if visuals is None: 

1742 # just pass to mesh as vertex color 

1743 kwargs["vertex_colors"] = colors.copy() 

1744 else: 

1745 # we ALSO have texture so save as vertex 

1746 # attribute 

1747 visuals.vertex_attributes["color"] = colors.copy() 

1748 except BaseException: 

1749 # survive failed colors 

1750 log.debug("failed to load colors", exc_info=True) 

1751 if visuals is not None: 

1752 kwargs["visual"] = visuals 

1753 

1754 # custom attributes starting with a `_` 

1755 custom = { 

1756 a: access[attr[a]] for a in attr.keys() if a.startswith("_") 

1757 } 

1758 if len(custom) > 0: 

1759 kwargs["vertex_attributes"] = custom 

1760 

1761 # Process primitive-level extensions through registry 

1762 if prim_extensions := p.get("extensions"): 

1763 handle_extensions( 

1764 extensions=prim_extensions, 

1765 scope="primitive", 

1766 primitive=p, 

1767 mesh_kwargs=kwargs, 

1768 accessors=access, 

1769 ) 

1770 else: 

1771 log.debug("skipping primitive with mode %s!", mode) 

1772 continue 

1773 # this should absolutely not be stomping on itself 

1774 assert name not in meshes 

1775 meshes[name] = kwargs 

1776 mesh_prim[index].append(name) 

1777 except BaseException as E: 

1778 if ignore_broken: 

1779 log.debug("failed to load mesh", exc_info=True) 

1780 else: 

1781 raise E 

1782 

1783 if undecoded: 

1784 log.warning( 

1785 "`%s` GLTF extension didn't decode, values are placeholder zeros", 

1786 ", ".join(sorted(undecoded)), 

1787 ) 

1788 

1789 # sometimes GLTF "meshes" come with multiple "primitives" 

1790 # by default we return one Trimesh object per "primitive" 

1791 # but if merge_primitives is True we combine the primitives 

1792 # for the "mesh" into a single Trimesh object 

1793 if merge_primitives: 

1794 # if we are only returning one Trimesh object 

1795 # replace `mesh_prim` with updated values 

1796 mesh_prim_replace = {} 

1797 # these are the names of meshes we need to remove 

1798 mesh_pop = set() 

1799 for mesh_index, names in mesh_prim.items(): 

1800 if len(names) <= 1: 

1801 mesh_prim_replace[mesh_index] = names 

1802 continue 

1803 

1804 # just take the shortest name option available 

1805 name = min(names) 

1806 # remove the other meshes after we're done looping 

1807 # since we're reusing the shortest one don't pop 

1808 # that as we'll be overwriting it with the combined 

1809 mesh_pop.update(set(names).difference([name])) 

1810 

1811 # get all meshes for this group 

1812 current = [meshes[n] for n in names] 

1813 v_seq = [p["vertices"] for p in current] 

1814 f_seq = [p["faces"] for p in current] 

1815 v, f = util.append_faces(v_seq, f_seq) 

1816 materials = [p["visual"].material for p in current] 

1817 face_materials = [] 

1818 for i, p in enumerate(current): 

1819 face_materials += [i] * len(p["faces"]) 

1820 visuals = visual.texture.TextureVisuals( 

1821 material=visual.material.MultiMaterial(materials=materials), 

1822 face_materials=face_materials, 

1823 ) 

1824 if "metadata" in meshes[names[0]]: 

1825 metadata = meshes[names[0]]["metadata"] 

1826 else: 

1827 metadata = {} 

1828 meshes[name] = { 

1829 "vertices": v, 

1830 "faces": f, 

1831 "visual": visuals, 

1832 "metadata": metadata, 

1833 "process": False, 

1834 } 

1835 mesh_prim_replace[mesh_index] = [name] 

1836 # avoid altering inside loop 

1837 mesh_prim = mesh_prim_replace 

1838 # remove outdated meshes 

1839 [meshes.pop(p, None) for p in mesh_pop] 

1840 

1841 # make it easier to reference nodes 

1842 nodes = header.get("nodes", []) 

1843 # nodes are referenced by index 

1844 # save their string names if they have one 

1845 # we have to accumulate in a for loop opposed 

1846 # to a dict comprehension as it will be checking 

1847 # the mutated dict in every loop 

1848 name_index = {} 

1849 name_counts = {} 

1850 

1851 # store the mapping of node name to index and the inverse 

1852 # name_index: {name: index} 

1853 for i, n in enumerate(nodes): 

1854 name_index[unique_name(n.get("name", str(i)), name_index, counts=name_counts)] = i 

1855 # names: {index: name} 

1856 names = {v: k for k, v in name_index.items()} 

1857 

1858 # rename any file node that collides with the synthetic base frame 

1859 # so its transform and children survive under their own frame — 

1860 # trimesh's own exports never contain one, #2421 

1861 world = name_index.get(DEFAULT_BASE_FRAME) 

1862 if world is not None: 

1863 names[world] = unique_name(DEFAULT_BASE_FRAME, set(names.values())) 

1864 

1865 # traversal edges are seeded as (DEFAULT_BASE_FRAME, index) so the 

1866 # index-keyed dict intentionally holds one string key for the base 

1867 names[DEFAULT_BASE_FRAME] = DEFAULT_BASE_FRAME 

1868 

1869 # visited, kwargs for scene.graph.update 

1870 graph = deque() 

1871 # unvisited, pairs of node indexes 

1872 queue = deque() 

1873 

1874 # camera(s), if they exist 

1875 camera = None 

1876 camera_transform = None 

1877 

1878 if "scene" in header: 

1879 # specify the index of scenes if specified 

1880 scene_index = header["scene"] 

1881 else: 

1882 # otherwise just use the first index 

1883 scene_index = 0 

1884 

1885 if "scenes" in header: 

1886 # start the traversal from the base frame to the roots 

1887 for root in header["scenes"][scene_index].get("nodes", []): 

1888 # add transform from base frame to these root nodes 

1889 queue.append((DEFAULT_BASE_FRAME, root)) 

1890 

1891 # make sure we don't process an edge multiple times 

1892 consumed = set() 

1893 

1894 # go through the nodes tree to populate 

1895 # kwargs for scene graph loader 

1896 while len(queue) > 0: 

1897 # (int, int) pair of node indexes 

1898 edge = queue.pop() 

1899 

1900 # avoid looping forever if someone specified 

1901 # recursive nodes 

1902 if edge in consumed: 

1903 continue 

1904 

1905 consumed.add(edge) 

1906 a, b = edge 

1907 

1908 # dict of child node 

1909 # parent = nodes[a] 

1910 child = nodes[b] 

1911 # add edges of children to be processed 

1912 if "children" in child: 

1913 queue.extend([(b, i) for i in child["children"]]) 

1914 

1915 # kwargs to be passed to scene.graph.update 

1916 kwargs = {"frame_from": names[a], "frame_to": names[b]} 

1917 

1918 # grab matrix from child 

1919 # parent -> child relationships have matrix stored in child 

1920 # for the transform from parent to child 

1921 if "matrix" in child: 

1922 kwargs["matrix"] = ( 

1923 np.array(child["matrix"], dtype=np.float64).reshape((4, 4)).T 

1924 ) 

1925 else: 

1926 # if no matrix set identity 

1927 kwargs["matrix"] = _EYE 

1928 

1929 # Now apply keyword translations 

1930 # GLTF applies these in order: T * R * S 

1931 if "translation" in child: 

1932 kwargs["matrix"] = np.dot( 

1933 kwargs["matrix"], transformations.translation_matrix(child["translation"]) 

1934 ) 

1935 if "rotation" in child: 

1936 # GLTF rotations are stored as (4,) XYZW unit quaternions 

1937 # we need to re- order to our quaternion style, WXYZ 

1938 quat = np.reshape(child["rotation"], 4)[[3, 0, 1, 2]] 

1939 # add the rotation to the matrix 

1940 kwargs["matrix"] = np.dot( 

1941 kwargs["matrix"], transformations.quaternion_matrix(quat) 

1942 ) 

1943 if "scale" in child: 

1944 # add scale to the matrix 

1945 kwargs["matrix"] = np.dot( 

1946 kwargs["matrix"], np.diag(np.concatenate((child["scale"], [1.0]))) 

1947 ) 

1948 

1949 # If a camera exists, create the camera and dont add the node to the graph 

1950 # TODO only process the first camera, ignore the rest 

1951 # TODO assumes the camera node is child of the world frame 

1952 # TODO will only read perspective camera 

1953 if "camera" in child and camera is None: 

1954 cam_idx = child["camera"] 

1955 try: 

1956 camera = _cam_from_gltf(header["cameras"][cam_idx]) 

1957 except KeyError: 

1958 log.debug("GLTF camera is not fully-defined") 

1959 if camera: 

1960 camera_transform = kwargs["matrix"] 

1961 continue 

1962 

1963 # treat node metadata similarly to mesh metadata 

1964 if isinstance(child.get("extras"), dict): 

1965 kwargs["metadata"] = child["extras"] 

1966 

1967 # put any node extensions in a field of the metadata 

1968 if "extensions" in child: 

1969 if "metadata" not in kwargs: 

1970 kwargs["metadata"] = {} 

1971 kwargs["metadata"]["gltf_extensions"] = child["extensions"] 

1972 

1973 if "mesh" in child: 

1974 geometries = mesh_prim[child["mesh"]] 

1975 

1976 # if the node has a mesh associated with it 

1977 if len(geometries) > 1: 

1978 # append root node 

1979 graph.append(kwargs.copy()) 

1980 # put primitives as children 

1981 for geom_name in geometries: 

1982 # save the name of the geometry 

1983 kwargs["geometry"] = geom_name 

1984 # no transformations 

1985 kwargs["matrix"] = _EYE 

1986 kwargs["frame_from"] = names[b] 

1987 # if we have more than one primitive assign a new UUID 

1988 # frame name for the primitives after the first one 

1989 frame_to = f"{names[b]}_{util.unique_id(length=6)}" 

1990 kwargs["frame_to"] = frame_to 

1991 # append the edge with the mesh frame 

1992 graph.append(kwargs.copy()) 

1993 elif len(geometries) == 1: 

1994 kwargs["geometry"] = geometries[0] 

1995 if "name" in child: 

1996 kwargs["frame_to"] = names[b] 

1997 graph.append(kwargs.copy()) 

1998 else: 

1999 # if the node doesn't have any geometry just add 

2000 graph.append(kwargs) 

2001 

2002 # kwargs for load_kwargs 

2003 result = { 

2004 "class": "Scene", 

2005 "geometry": meshes, 

2006 "graph": graph, 

2007 "base_frame": DEFAULT_BASE_FRAME, 

2008 "camera": camera, 

2009 "camera_transform": camera_transform, 

2010 "metadata": {}, 

2011 } 

2012 

2013 try: 

2014 # load any scene extras into scene.metadata 

2015 # use a try except to avoid nested key checks 

2016 result["metadata"].update(header["scenes"][header["scene"]]["extras"]) 

2017 except BaseException: 

2018 pass 

2019 try: 

2020 # load any scene extensions into a field of scene.metadata 

2021 # use a try except to avoid nested key checks 

2022 result["metadata"]["gltf_extensions"] = header["extensions"] 

2023 except BaseException: 

2024 pass 

2025 

2026 return result 

2027 

2028 

2029def _cam_from_gltf(cam): 

2030 """ 

2031 Convert a gltf perspective camera to trimesh. 

2032 

2033 The retrieved camera will have default resolution, since the gltf specification 

2034 does not contain it. 

2035 

2036 If the camera is not perspective will return None. 

2037 If the camera is perspective but is missing fields, will raise `KeyError` 

2038 

2039 Parameters 

2040 ------------ 

2041 cam : dict 

2042 Camera represented as a dictionary according to glTF 

2043 

2044 Returns 

2045 ------------- 

2046 camera : trimesh.scene.cameras.Camera 

2047 Trimesh camera object 

2048 """ 

2049 if "perspective" not in cam: 

2050 return 

2051 name = cam.get("name") 

2052 znear = cam["perspective"]["znear"] 

2053 aspect_ratio = cam["perspective"]["aspectRatio"] 

2054 yfov = np.degrees(cam["perspective"]["yfov"]) 

2055 

2056 fov = (aspect_ratio * yfov, yfov) 

2057 

2058 return Camera(name=name, fov=fov, z_near=znear) 

2059 

2060 

2061def _convert_camera(camera): 

2062 """ 

2063 Convert a trimesh camera to a GLTF camera. 

2064 

2065 Parameters 

2066 ------------ 

2067 camera : trimesh.scene.cameras.Camera 

2068 Trimesh camera object 

2069 

2070 Returns 

2071 ------------- 

2072 gltf_camera : dict 

2073 Camera represented as a GLTF dict 

2074 """ 

2075 result = { 

2076 "name": camera.name, 

2077 "type": "perspective", 

2078 "perspective": { 

2079 "aspectRatio": camera.fov[0] / camera.fov[1], 

2080 "yfov": np.radians(camera.fov[1]), 

2081 "znear": float(camera.z_near), 

2082 }, 

2083 } 

2084 return result 

2085 

2086 

2087def _append_image(img, tree, buffer_items, extension_webp): 

2088 """ 

2089 Append a PIL image to a GLTF2.0 tree. 

2090 

2091 Parameters 

2092 ------------ 

2093 img : PIL.Image 

2094 Image object 

2095 tree : dict 

2096 GLTF 2.0 format tree 

2097 buffer_items : (n,) bytes 

2098 Binary blobs containing data 

2099 extension_webp : bool 

2100 Export textures as webP (using glTF's EXT_texture_webp extension). 

2101 

2102 Returns 

2103 ----------- 

2104 index : int or None 

2105 The index of the image in the tree 

2106 None if image append failed for any reason 

2107 """ 

2108 # probably not a PIL image so exit 

2109 if not hasattr(img, "format"): 

2110 return None 

2111 

2112 if extension_webp: 

2113 # support WebP if extension is specified 

2114 save_as = "WEBP" 

2115 elif img.format == "JPEG": 

2116 # don't re-encode JPEGs 

2117 save_as = "JPEG" 

2118 else: 

2119 # for everything else just use PNG 

2120 save_as = "png" 

2121 

2122 # get the image data into a bytes object 

2123 with util.BytesIO() as f: 

2124 img.save(f, format=save_as) 

2125 f.seek(0) 

2126 data = f.read() 

2127 

2128 index = _buffer_append(buffer_items, data) 

2129 # append buffer index and the GLTF-acceptable mimetype 

2130 tree["images"].append({"bufferView": index, "mimeType": f"image/{save_as.lower()}"}) 

2131 

2132 # index is length minus one 

2133 return len(tree["images"]) - 1 

2134 

2135 

2136def _append_material(mat, tree, buffer_items, mat_hashes, extension_webp): 

2137 """ 

2138 Add passed PBRMaterial as GLTF 2.0 specification JSON 

2139 serializable data: 

2140 - images are added to `tree['images']` 

2141 - texture is added to `tree['texture']` 

2142 - material is added to `tree['materials']` 

2143 

2144 Parameters 

2145 ------------ 

2146 mat : trimesh.visual.materials.PBRMaterials 

2147 Source material to convert 

2148 tree : dict 

2149 GLTF header blob 

2150 buffer_items : (n,) bytes 

2151 Binary blobs with various data 

2152 mat_hashes : dict 

2153 Which materials have already been added 

2154 Stored as { hashed : material index } 

2155 extension_webp : bool 

2156 Export textures as webP using EXT_texture_webp extension. 

2157 

2158 Returns 

2159 ------------- 

2160 index : int 

2161 Index at which material was added 

2162 """ 

2163 # materials are hashable 

2164 hashed = hash(mat) 

2165 # check stored material indexes to see if material 

2166 # has already been added 

2167 if mat_hashes is not None and hashed in mat_hashes: 

2168 return mat_hashes[hashed] 

2169 

2170 # convert passed input to PBR if necessary 

2171 if hasattr(mat, "to_pbr"): 

2172 as_pbr = mat.to_pbr() 

2173 else: 

2174 as_pbr = mat 

2175 

2176 # a default PBR metallic material 

2177 result = {"pbrMetallicRoughness": {}} 

2178 try: 

2179 # try to convert base color to (4,) float color 

2180 result["baseColorFactor"] = ( 

2181 visual.color.to_float(as_pbr.baseColorFactor).reshape(4).tolist() 

2182 ) 

2183 except BaseException: 

2184 pass 

2185 

2186 try: 

2187 result["emissiveFactor"] = as_pbr.emissiveFactor.reshape(3).tolist() 

2188 except BaseException: 

2189 pass 

2190 

2191 # if name is defined, export 

2192 if isinstance(as_pbr.name, str): 

2193 result["name"] = as_pbr.name 

2194 

2195 # if alphaMode is defined, export 

2196 if isinstance(as_pbr.alphaMode, str): 

2197 result["alphaMode"] = as_pbr.alphaMode 

2198 

2199 # if alphaCutoff is defined, export 

2200 if isinstance(as_pbr.alphaCutoff, float): 

2201 result["alphaCutoff"] = as_pbr.alphaCutoff 

2202 

2203 # if doubleSided is defined, export 

2204 if isinstance(as_pbr.doubleSided, bool): 

2205 result["doubleSided"] = as_pbr.doubleSided 

2206 

2207 # if scalars are defined correctly export 

2208 if isinstance(as_pbr.metallicFactor, float): 

2209 result["metallicFactor"] = as_pbr.metallicFactor 

2210 if isinstance(as_pbr.roughnessFactor, float): 

2211 result["roughnessFactor"] = as_pbr.roughnessFactor 

2212 

2213 # which keys of the PBRMaterial are images 

2214 image_mapping = { 

2215 "baseColorTexture": as_pbr.baseColorTexture, 

2216 "emissiveTexture": as_pbr.emissiveTexture, 

2217 "normalTexture": as_pbr.normalTexture, 

2218 "occlusionTexture": as_pbr.occlusionTexture, 

2219 "metallicRoughnessTexture": as_pbr.metallicRoughnessTexture, 

2220 } 

2221 

2222 for key, img in image_mapping.items(): 

2223 if img is None: 

2224 continue 

2225 # try adding the base image to the export object 

2226 index = _append_image( 

2227 img=img, tree=tree, buffer_items=buffer_items, extension_webp=extension_webp 

2228 ) 

2229 # if the image was added successfully it will return index 

2230 # if it failed for any reason, it will return None 

2231 if index is not None: 

2232 # add a reference to the base color texture 

2233 result[key] = {"index": len(tree["textures"])} 

2234 

2235 # add texture object, optionally using EXT_texture_webp 

2236 if extension_webp: 

2237 tree["textures"].append( 

2238 {"extensions": {"EXT_texture_webp": {"source": index}}} 

2239 ) 

2240 else: 

2241 tree["textures"].append({"source": index}) 

2242 

2243 # for our PBRMaterial object we flatten all keys 

2244 # however GLTF would like some of them under the 

2245 # "pbrMetallicRoughness" key 

2246 pbr_subset = [ 

2247 "baseColorTexture", 

2248 "baseColorFactor", 

2249 "roughnessFactor", 

2250 "metallicFactor", 

2251 "metallicRoughnessTexture", 

2252 ] 

2253 # move keys down a level 

2254 for key in pbr_subset: 

2255 if key in result: 

2256 result["pbrMetallicRoughness"][key] = result.pop(key) 

2257 

2258 # if we didn't have any PBR keys remove the empty key 

2259 if len(result["pbrMetallicRoughness"]) == 0: 

2260 result.pop("pbrMetallicRoughness") 

2261 

2262 # which index are we inserting material at 

2263 index = len(tree["materials"]) 

2264 # add the material to the data structure 

2265 tree["materials"].append(result) 

2266 # add the material index in-place 

2267 mat_hashes[hashed] = index 

2268 

2269 return index 

2270 

2271 

2272def validate(header): 

2273 """ 

2274 Validate a GLTF 2.0 header against the schema. 

2275 

2276 Returns result from validating against `get_schema()`. 

2277 

2278 Parameters 

2279 ------------- 

2280 header : dict 

2281 Populated GLTF 2.0 header 

2282 

2283 Raises 

2284 -------------- 

2285 err : jsonschema.exceptions.ValidationError 

2286 If the tree is an invalid GLTF2.0 header 

2287 """ 

2288 try: 

2289 validator = _GLTF_VALIDATOR["glTF.schema.json"] 

2290 except KeyError: 

2291 # a soft dependency 

2292 import jsonschema 

2293 

2294 # will do the reference replacement 

2295 schema = get_schema() 

2296 # `jsonschema.validate` re-checks the schema against its meta-schema 

2297 # on every call which is most of the cost so `test_schema` does that once 

2298 validator = jsonschema.validators.validator_for(schema)(schema) 

2299 _GLTF_VALIDATOR["glTF.schema.json"] = validator 

2300 

2301 return validator.validate(header) 

2302 

2303 

2304def get_schema(): 

2305 """ 

2306 Get a copy of the GLTF 2.0 schema with references resolved. 

2307 

2308 Returns 

2309 ------------ 

2310 schema : dict 

2311 A copy of the GLTF 2.0 schema without external references. 

2312 """ 

2313 # replace references 

2314 # get zip resolver to access referenced assets 

2315 from ...schemas import resolve 

2316 

2317 # get a blob of a zip file including the GLTF 2.0 schema 

2318 stream = resources.get_stream("schema/gltf2.schema.zip") 

2319 # get the zip file as a dict keyed by file name 

2320 archive = util.decompress(stream, "zip") 

2321 # get a resolver object for accessing the schema 

2322 resolver = ZipResolver(archive) 

2323 # get a loaded dict from the base file 

2324 unresolved = json.loads(util.decode_text(resolver.get("glTF.schema.json"))) 

2325 # resolve `$ref` references to other files in the schema 

2326 schema = resolve(unresolved, resolver=resolver) 

2327 

2328 return schema 

2329 

2330 

2331# exporters 

2332_gltf_loaders = {"glb": load_glb, "gltf": load_gltf}