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

795 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-08-31 18:21 +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# specify dtypes with forced little endian 

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

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

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

70 

71 

72def export_gltf( 

73 scene, 

74 include_normals=None, 

75 merge_buffers=False, 

76 unitize_normals=True, 

77 tree_postprocessor=None, 

78 embed_buffers=False, 

79 extension_webp=False, 

80 extension_draco=False, 

81): 

82 """ 

83 Export a scene object as a GLTF directory. 

84 

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

86 as opposed to one larger file. 

87 

88 Parameters 

89 ----------- 

90 scene : trimesh.Scene 

91 Scene to be exported 

92 include_normals : None or bool 

93 Include vertex normals 

94 merge_buffers : bool 

95 Merge buffers into one blob. 

96 unitize_normals 

97 GLTF requires unit normals, however sometimes people 

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

99 resolver : trimesh.resolvers.Resolver 

100 If passed will use to write each file. 

101 tree_postprocesser : None or callable 

102 Run this on the header tree before exiting. 

103 embed_buffers : bool 

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

105 extension_webp : bool 

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

107 extension_draco : bool 

108 Compress mesh data using Draco (KHR_draco_mesh_compression). 

109 Requires the `DracoPy` package to be installed. 

110 

111 Returns 

112 ---------- 

113 export : dict 

114 Format: {file name : file data} 

115 """ 

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

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

118 scene = scene.scene() 

119 

120 # create the header and buffer data 

121 tree, buffer_items = _create_gltf_structure( 

122 scene=scene, 

123 unitize_normals=unitize_normals, 

124 include_normals=include_normals, 

125 extension_webp=extension_webp, 

126 extension_draco=extension_draco, 

127 ) 

128 

129 # allow custom postprocessing 

130 if tree_postprocessor is not None: 

131 tree_postprocessor(tree) 

132 

133 # store files as {name : data} 

134 files = {} 

135 

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

137 if merge_buffers: 

138 views = _build_views(buffer_items) 

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

140 if embed_buffers: 

141 buffer_name = base64_buffer_format.format( 

142 base64.b64encode(buffer_data).decode() 

143 ) 

144 else: 

145 buffer_name = "gltf_buffer.bin" 

146 files[buffer_name] = buffer_data 

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

148 else: 

149 # make one buffer per buffer_items 

150 buffers = [None] * len(buffer_items) 

151 # A bufferView is a slice of a file 

152 views = [None] * len(buffer_items) 

153 # create the buffer views 

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

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

156 if embed_buffers: 

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

158 else: 

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

160 files[buffer_name] = item 

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

162 

163 if len(buffers) > 0: 

164 tree["buffers"] = buffers 

165 tree["bufferViews"] = views 

166 # dump tree with compact separators 

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

168 

169 if tol.strict: 

170 validate(tree) 

171 

172 return files 

173 

174 

175def export_glb( 

176 scene, 

177 include_normals=None, 

178 unitize_normals=True, 

179 tree_postprocessor=None, 

180 buffer_postprocessor=None, 

181 extension_webp=False, 

182 extension_draco=False, 

183): 

184 """ 

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

186 

187 Parameters 

188 ------------ 

189 scene: trimesh.Scene 

190 Input geometry 

191 extras : JSON serializable 

192 Will be stored in the extras field. 

193 include_normals : bool 

194 Include vertex normals in output file? 

195 tree_postprocessor : func 

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

197 before exporting. 

198 extension_webp : bool 

199 Export textures as webP using EXT_texture_webp extension. 

200 extension_draco : bool 

201 Compress mesh data using Draco (KHR_draco_mesh_compression). 

202 Requires the `DracoPy` package to be installed. 

203 

204 Returns 

205 ---------- 

206 exported : bytes 

207 Exported result in GLB 2.0 

208 """ 

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

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

211 # generate a scene with just that mesh in it 

212 scene = scene.scene() 

213 

214 tree, buffer_items = _create_gltf_structure( 

215 scene=scene, 

216 unitize_normals=unitize_normals, 

217 include_normals=include_normals, 

218 buffer_postprocessor=buffer_postprocessor, 

219 extension_webp=extension_webp, 

220 extension_draco=extension_draco, 

221 ) 

222 

223 # A bufferView is a slice of a file 

224 views = _build_views(buffer_items) 

225 

226 # combine bytes into a single blob 

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

228 

229 # add the information about the buffer data 

230 if len(buffer_data) > 0: 

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

232 tree["bufferViews"] = views 

233 

234 # allow custom postprocessing 

235 if tree_postprocessor is not None: 

236 tree_postprocessor(tree) 

237 

238 # export the tree to JSON for the header 

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

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

241 # is 4 byte aligned as per spec 

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

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

244 # make sure we didn't screw it up 

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

246 

247 # the initial header of the file 

248 header = _byte_pad( 

249 np.array( 

250 [ 

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

252 2, # GLTF version 

253 # length is the total length of the Binary glTF 

254 # including Header and all Chunks, in bytes. 

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

256 # contentLength is the length, in bytes, 

257 # of the glTF content (JSON) 

258 len(content), 

259 # magic number which is 'JSON' 

260 _magic["json"], 

261 ], 

262 dtype="<u4", 

263 ).tobytes() 

264 ) 

265 

266 # the header of the binary data section 

267 bin_header = _byte_pad( 

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

269 ) 

270 

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

272 

273 if tol.strict: 

274 validate(tree) 

275 

276 return exported 

277 

278 

279def load_gltf( 

280 file_obj: Stream | None = None, 

281 resolver: ResolverLike | None = None, 

282 ignore_broken: bool = False, 

283 merge_primitives: bool = False, 

284 skip_materials: bool = False, 

285 **mesh_kwargs, 

286): 

287 """ 

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

289 with multiple files. 

290 

291 Parameters 

292 ------------- 

293 file_obj : None or file-like 

294 Object containing header JSON, or None 

295 resolver : trimesh.visual.Resolver 

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

297 ignore_broken : bool 

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

299 is True don't raise an exception but return 

300 a partial result 

301 merge_primitives : bool 

302 If True, each GLTF 'mesh' will correspond 

303 to a single Trimesh object 

304 skip_materials : bool 

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

306 **mesh_kwargs : dict 

307 Passed to mesh constructor 

308 

309 Returns 

310 -------------- 

311 kwargs : dict 

312 Arguments to create scene 

313 """ 

314 try: 

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

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

317 except BaseException: 

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

319 data = resolver["model.gltf"] 

320 # old versions of python/json need strings 

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

322 

323 # gltf 1.0 is a totally different format 

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

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

326 if isinstance(version, str): 

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

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

329 else: 

330 major = int(float(version)) 

331 

332 if major < 2: 

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

334 

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

336 buffers = [ 

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

338 ] 

339 

340 # turn the layout header and data into kwargs 

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

342 kwargs = _read_buffers( 

343 header=tree, 

344 buffers=buffers, 

345 ignore_broken=ignore_broken, 

346 merge_primitives=merge_primitives, 

347 mesh_kwargs=mesh_kwargs, 

348 skip_materials=skip_materials, 

349 resolver=resolver, 

350 ) 

351 return kwargs 

352 

353 

354def load_glb( 

355 file_obj: Stream, 

356 resolver: ResolverLike | None = None, 

357 ignore_broken: bool = False, 

358 merge_primitives: bool = False, 

359 skip_materials: bool = False, 

360 **mesh_kwargs, 

361): 

362 """ 

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

364 

365 Implemented from specification: 

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

367 

368 Parameters 

369 ------------ 

370 file_obj : file- like object 

371 Containing GLB data 

372 resolver : trimesh.visual.Resolver 

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

374 ignore_broken : bool 

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

376 is True don't raise an exception but return 

377 a partial result 

378 merge_primitives : bool 

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

380 single Trimesh object. 

381 skip_materials : bool 

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

383 

384 Returns 

385 ------------ 

386 kwargs : dict 

387 Kwargs to instantiate a trimesh.Scene 

388 """ 

389 # read the first 20 bytes which contain section lengths 

390 head_data = file_obj.read(20) 

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

392 

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

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

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

396 

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

398 if head[1] != 2: 

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

400 

401 # overall file length 

402 # first chunk length 

403 # first chunk type 

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

405 

406 # first chunk should be JSON header 

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

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

409 

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

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

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

413 # convert to text 

414 if hasattr(json_data, "decode"): 

415 json_data = util.decode_text(json_data) 

416 # load the json header to native dict 

417 header = json.loads(json_data) 

418 

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

420 buffers = [] 

421 start = file_obj.tell() 

422 

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

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

425 

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

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

428 try: 

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

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

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

432 continue 

433 except (IndexError, KeyError): 

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

435 pass 

436 

437 # the last read put us past the JSON chunk 

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

439 chunk_head = file_obj.read(8) 

440 if len(chunk_head) != 8: 

441 # double check to make sure we didn't 

442 # read the whole file 

443 break 

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

445 # make sure we have the right data type 

446 if chunk_type != _magic["bin"]: 

447 raise ValueError("not binary GLTF!") 

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 buffers.append(chunk_data) 

453 

454 # turn the layout header and data into kwargs 

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

456 kwargs = _read_buffers( 

457 header=header, 

458 buffers=buffers, 

459 ignore_broken=ignore_broken, 

460 merge_primitives=merge_primitives, 

461 skip_materials=skip_materials, 

462 mesh_kwargs=mesh_kwargs, 

463 resolver=resolver, 

464 ) 

465 

466 return kwargs 

467 

468 

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

470 """ 

471 Take a URI string and load it as a 

472 a filename or as base64. 

473 

474 Parameters 

475 -------------- 

476 uri 

477 Usually a filename or something like: 

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

479 resolver 

480 A resolver to load referenced assets 

481 

482 Returns 

483 --------------- 

484 data 

485 Loaded data from URI 

486 """ 

487 # see if the URI has base64 data 

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

489 if index < 0: 

490 # string didn't contain the base64 header 

491 # so return the result from the resolver 

492 return resolver[uri] 

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

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

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

496 

497 

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

499 """ 

500 Append data to an existing IndexedDict and 

501 pad it to a 4-byte boundary. 

502 

503 Parameters 

504 ---------- 

505 ordered : IndexedDict 

506 Keyed like { hash : data } 

507 data : bytes 

508 To be stored 

509 

510 Returns 

511 ---------- 

512 index : int 

513 Index of buffer_items stored in 

514 """ 

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

516 hashed = hash_fast(data) 

517 if hashed in ordered: 

518 return ordered.index(hashed) 

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

520 ordered[hashed] = _byte_pad(data) 

521 

522 return len(ordered) - 1 

523 

524 

525def _data_append( 

526 acc: IndexedDict, 

527 buff: IndexedDict, 

528 blob: dict, 

529 data: NDArray, 

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

531): 

532 """ 

533 Append a new accessor to an IndexedDict. 

534 

535 Parameters 

536 ------------ 

537 acc 

538 Collection of accessors, will be mutated in-place 

539 buff 

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

541 blob 

542 Candidate accessor 

543 data 

544 Data to fill in details to blob 

545 claimed 

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

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

548 

549 Returns 

550 ---------- 

551 index : int 

552 Index of accessor that was added or reused. 

553 """ 

554 # if we have data include that in the key 

555 as_bytes = data.tobytes() 

556 if hasattr(data, "hash_fast"): 

557 # passed a TrackedArray object 

558 hashed = data.hash_fast() 

559 else: 

560 # someone passed a vanilla numpy array 

561 hashed = hash_fast(as_bytes) 

562 

563 if claimed is not None: 

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

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

566 blob.pop("byteOffset", None) 

567 elif hashed in buff: 

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

569 else: 

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

571 buff[hashed] = _byte_pad(as_bytes) 

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

573 

574 # start by hashing the dict blob 

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

576 try: 

577 # simple keys can be hashed as tuples without JSON 

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

579 except BaseException: 

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

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

582 

583 # xor the hash for the blob to the key 

584 key ^= hashed 

585 

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

587 if key in acc: 

588 index = acc.index(key) 

589 if claimed is not None: 

590 claimed[index] = data 

591 return index 

592 

593 # get a numpy dtype for our components 

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

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

596 kind = blob["type"] 

597 

598 if tol.strict: 

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

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

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

602 

603 if kind == "SCALAR": 

604 # is probably (n, 1) 

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

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

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

608 elif kind.startswith("MAT"): 

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

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

611 else: 

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

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

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

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

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

617 

618 # store the accessor and return the index 

619 acc[key] = blob 

620 index = len(acc) - 1 

621 if claimed is not None: 

622 claimed[index] = data 

623 return index 

624 

625 

626def _jsonify(blob): 

627 """ 

628 Roundtrip a blob through json export-import cycle 

629 skipping any internal keys. 

630 """ 

631 return json.loads( 

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

633 ) 

634 

635 

636def _create_gltf_structure( 

637 scene, 

638 include_normals=None, 

639 include_metadata=True, 

640 unitize_normals=None, 

641 buffer_postprocessor=None, 

642 extension_webp=False, 

643 extension_draco=False, 

644): 

645 """ 

646 Generate a GLTF header. 

647 

648 Parameters 

649 ------------- 

650 scene : trimesh.Scene 

651 Input scene data 

652 include_metadata : bool 

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

654 include_normals : bool 

655 Include vertex normals in output file? 

656 unitize_normals : bool 

657 Unitize all exported normals so as to pass GLTF validation 

658 extension_webp : bool 

659 Export textures as webP using EXT_texture_webp extension. 

660 extension_draco : bool 

661 Compress mesh data using Draco (KHR_draco_mesh_compression). 

662 

663 Returns 

664 --------------- 

665 tree : dict 

666 Contains required keys for a GLTF scene 

667 buffer_items : list 

668 Contains bytes of data 

669 """ 

670 if extension_draco: 

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

672 import DracoPy # noqa: F401 

673 

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

675 # world node to the 0-index 

676 tree = { 

677 "scene": 0, 

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

679 "scenes": [{}], 

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

681 "accessors": IndexedDict(), 

682 "meshes": [], 

683 "images": [], 

684 "textures": [], 

685 "materials": [], 

686 } 

687 

688 if scene.has_camera: 

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

690 

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

692 try: 

693 # fail here if data isn't json compatible 

694 # only export the extras if there is something there 

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

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

697 if isinstance(extensions, dict): 

698 tree["extensions"] = extensions 

699 except BaseException: 

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

701 

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

703 mat_hashes = {} 

704 # store data from geometries 

705 buffer_items = IndexedDict() 

706 

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

708 mesh_index = {} 

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

710 

711 # loop through every geometry 

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

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

714 # add the mesh 

715 _append_mesh( 

716 mesh=geometry, 

717 name=name, 

718 tree=tree, 

719 buffer_items=buffer_items, 

720 include_normals=include_normals, 

721 unitize_normals=unitize_normals, 

722 mat_hashes=mat_hashes, 

723 extension_webp=extension_webp, 

724 extension_draco=extension_draco, 

725 ) 

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

727 # add Path2D and Path3D objects 

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

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

730 # add PointCloud objects 

731 _append_point( 

732 points=geometry, name=name, tree=tree, buffer_items=buffer_items 

733 ) 

734 

735 # only store the index if the append did anything 

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

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

738 mesh_index[name] = previous - 1 

739 

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

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

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

743 # hold `extras` with the scene metadata 

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

745 tree.update(nodes) 

746 

747 extensions_used = set() 

748 extensions_required = set() 

749 # Add any scene extensions used 

750 if "extensions" in tree: 

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

752 # Add any mesh extensions used 

753 for mesh in tree["meshes"]: 

754 if "extensions" in mesh: 

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

756 # Check primitives for extensions too 

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

758 if "extensions" in prim: 

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

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

761 if "extensionsUsed" in tree: 

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

763 # Add WebP if used 

764 if extension_webp: 

765 extensions_used.add("EXT_texture_webp") 

766 extensions_required.add("EXT_texture_webp") 

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

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

769 if "KHR_draco_mesh_compression" in extensions_used: 

770 extensions_required.add("KHR_draco_mesh_compression") 

771 if len(extensions_used) > 0: 

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

773 if len(extensions_required) > 0: 

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

775 

776 if buffer_postprocessor is not None: 

777 buffer_postprocessor(buffer_items, tree) 

778 

779 # convert accessors back to a flat list 

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

781 

782 # cull empty or unpopulated fields 

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

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

785 # remove the keys with nothing stored in them 

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

787 

788 return tree, buffer_items 

789 

790 

791def _append_mesh( 

792 mesh, 

793 name, 

794 tree, 

795 buffer_items, 

796 include_normals: bool | None, 

797 unitize_normals: bool, 

798 mat_hashes: dict, 

799 extension_webp: bool, 

800 extension_draco: bool = False, 

801): 

802 """ 

803 Append a mesh to the scene structure and put the 

804 data into buffer_items. 

805 

806 Parameters 

807 ------------- 

808 mesh : trimesh.Trimesh 

809 Source geometry 

810 name : str 

811 Name of geometry 

812 tree : dict 

813 Will be updated with data from mesh 

814 buffer_items 

815 Will have buffer appended with mesh data 

816 include_normals : bool 

817 Include vertex normals in export or not 

818 unitize_normals : bool 

819 Transform normals into unit vectors. 

820 May be undesirable but will fail validators without this. 

821 

822 mat_hashes : dict 

823 Which materials have already been added 

824 extension_webp : bool 

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

826 extension_draco : bool 

827 Compress mesh data using Draco (KHR_draco_mesh_compression). 

828 """ 

829 # return early from empty meshes to avoid crashing later 

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

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

832 return 

833 

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

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

836 claimed = {} if extension_draco else None 

837 

838 # convert mesh data to the correct dtypes 

839 # faces: 5125 is an unsigned 32 bit integer 

840 # accessors refer to data locations 

841 # mesh faces are stored as flat list of integers 

842 acc_face = _data_append( 

843 acc=tree["accessors"], 

844 buff=buffer_items, 

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

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

847 claimed=claimed, 

848 ) 

849 

850 # vertices: 5126 is a float32 

851 # create or reuse an accessor for these vertices 

852 acc_vertex = _data_append( 

853 acc=tree["accessors"], 

854 buff=buffer_items, 

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

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

857 claimed=claimed, 

858 ) 

859 

860 # meshes reference accessor indexes 

861 current = { 

862 "name": name, 

863 "extras": {}, 

864 "primitives": [ 

865 { 

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

867 "indices": acc_face, 

868 "mode": _GL_TRIANGLES, 

869 } 

870 ], 

871 } 

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

873 # the GLTF spec says everything is implicit meters 

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

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

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

877 try: 

878 # skip jsonify any metadata, skipping internal keys 

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

880 

881 # extract extensions if any 

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

883 if isinstance(extensions, dict): 

884 current["extensions"] = extensions 

885 

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

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

888 except BaseException: 

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

890 

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

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

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

894 vertex_colors = mesh.visual.vertex_colors 

895 elif ( 

896 hasattr(mesh.visual, "vertex_attributes") 

897 and "color" in mesh.visual.vertex_attributes 

898 ): 

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

900 else: 

901 vertex_colors = None 

902 

903 if vertex_colors is not None: 

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

905 # convert color data to bytes and append 

906 acc_color = _data_append( 

907 acc=tree["accessors"], 

908 buff=buffer_items, 

909 blob={ 

910 "componentType": 5121, 

911 "normalized": True, 

912 "type": "VEC4", 

913 "byteOffset": 0, 

914 }, 

915 data=vertex_colors.astype(uint8), 

916 claimed=claimed, 

917 ) 

918 

919 # add the reference for vertex color 

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

921 else: 

922 log.warning( 

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

924 ) 

925 

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

927 # append the material and then set from returned index 

928 current_material = _append_material( 

929 mat=mesh.visual.material, 

930 tree=tree, 

931 buffer_items=buffer_items, 

932 mat_hashes=mat_hashes, 

933 extension_webp=extension_webp, 

934 ) 

935 

936 # if mesh has UV coordinates defined export them 

937 has_uv = ( 

938 hasattr(mesh.visual, "uv") 

939 and mesh.visual.uv is not None 

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

941 ) 

942 if has_uv: 

943 # slice off W if passed 

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

945 # reverse the Y for GLTF 

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

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

948 acc_uv = _data_append( 

949 acc=tree["accessors"], 

950 buff=buffer_items, 

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

952 data=uv.astype(float32), 

953 claimed=claimed, 

954 ) 

955 # add the reference for UV coordinates 

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

957 

958 # reference the material 

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

960 

961 if include_normals or ( 

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

963 ): 

964 # store vertex normals if requested 

965 if unitize_normals: 

966 normals = util.unitize(mesh.vertex_normals) 

967 else: 

968 # we don't have to copy them since 

969 # they aren't being altered 

970 normals = mesh.vertex_normals 

971 

972 acc_norm = _data_append( 

973 acc=tree["accessors"], 

974 buff=buffer_items, 

975 blob={ 

976 "componentType": 5126, 

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

978 "type": "VEC3", 

979 "byteOffset": 0, 

980 }, 

981 data=normals.astype(float32), 

982 claimed=claimed, 

983 ) 

984 # add the reference for vertex color 

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

986 

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

988 # vertex_attributes 

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

990 # make sure vertex attribute length matches vertices 

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

992 log.warning( 

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

994 ) 

995 continue 

996 

997 # application specific attributes must be prefixed with an underscore 

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

999 key = "_" + key 

1000 

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

1002 # any float64 or larger to float32 

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

1004 data = attrib.astype(float32) 

1005 else: 

1006 # force little-endian to match GLTF binary format 

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

1008 

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

1010 data = data[:, np.newaxis] 

1011 

1012 # every accessor VALUE must be 4-byte aligned 

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

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

1015 if row_mod != 0: 

1016 # how many columns of padding for this value 

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

1018 # pad this custom attribute with zeros -_- 

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

1020 

1021 # store custom vertex attributes 

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

1023 acc=tree["accessors"], 

1024 buff=buffer_items, 

1025 blob=_build_accessor(data), 

1026 data=data, 

1027 ) 

1028 

1029 # Handle Draco compression via extension handler 

1030 if extension_draco: 

1031 # Call primitive_export handlers 

1032 compressed = handle_extensions( 

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

1034 scope="primitive_export", 

1035 mesh=mesh, 

1036 name=name, 

1037 tree=tree, 

1038 buffer_items=buffer_items, 

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

1040 arrays=claimed, 

1041 ) 

1042 if not compressed: 

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

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

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

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

1047 key = ("accessor", index) 

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

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

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

1051 

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

1053 

1054 

1055def _build_views(buffer_items): 

1056 """ 

1057 Create views for buffers that are simply 

1058 based on how many bytes they are long. 

1059 

1060 Parameters 

1061 -------------- 

1062 buffer_items : IndexedDict 

1063 Buffers to build views for 

1064 

1065 Returns 

1066 ---------- 

1067 views : (n,) list of dict 

1068 GLTF views 

1069 """ 

1070 views = [] 

1071 # create the buffer views 

1072 current_pos = 0 

1073 for current_item in buffer_items.values(): 

1074 views.append( 

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

1076 ) 

1077 assert (current_pos % 4) == 0 

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

1079 current_pos += len(current_item) 

1080 return views 

1081 

1082 

1083def _build_accessor(array): 

1084 """ 

1085 Build an accessor for an arbitrary array. 

1086 

1087 Parameters 

1088 ----------- 

1089 array : numpy array 

1090 The array to build an accessor for 

1091 

1092 Returns 

1093 ---------- 

1094 accessor : dict 

1095 The accessor for array. 

1096 """ 

1097 shape = array.shape 

1098 data_type = "SCALAR" 

1099 if len(shape) == 2: 

1100 vec_length = shape[1] 

1101 if vec_length > 4: 

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

1103 if vec_length > 1: 

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

1105 else: 

1106 data_type = "SCALAR" 

1107 

1108 if len(shape) == 3: 

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

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

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

1112 

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

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

1115 

1116 if lookup == "u4": 

1117 # spec: UNSIGNED_INT is only allowed when the accessor 

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

1119 # by `primitive.indices` 

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

1121 

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

1123 componentType = _dtypes_lookup[lookup] 

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

1125 

1126 if len(shape) < 3: 

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

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

1129 

1130 return accessor 

1131 

1132 

1133def _byte_pad(data, bound=4): 

1134 """ 

1135 GLTF wants chunks aligned with 4 byte boundaries. 

1136 This function will add padding to the end of a 

1137 chunk of bytes so that it aligns with the passed 

1138 boundary size. 

1139 

1140 Parameters 

1141 -------------- 

1142 data : bytes 

1143 Data to be padded 

1144 bound : int 

1145 Length of desired boundary 

1146 

1147 Returns 

1148 -------------- 

1149 padded : bytes 

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

1151 """ 

1152 assert isinstance(data, bytes) 

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

1154 # extra bytes to pad with 

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

1156 pad = bytes(count) 

1157 # combine the padding and data 

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

1159 # we should always divide evenly 

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

1161 raise ValueError("byte_pad failed!") 

1162 return result 

1163 return data 

1164 

1165 

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

1167 """ 

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

1169 data into buffer_items. 

1170 

1171 Parameters 

1172 ------------- 

1173 path : trimesh.Path2D or trimesh.Path3D 

1174 Source geometry 

1175 name : str 

1176 Name of geometry 

1177 tree : dict 

1178 Will be updated with data from path 

1179 buffer_items 

1180 Will have buffer appended with path data 

1181 """ 

1182 

1183 # convert the path to the unnamed args for 

1184 # a pyglet vertex list 

1185 vxlist = rendering.path_to_vertexlist(path) 

1186 

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

1188 if vxlist[0] == 0: 

1189 return 

1190 

1191 # TODO add color support to Path object 

1192 # this is just exporting everying as black 

1193 try: 

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

1195 except ValueError: 

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

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

1198 

1199 # data is the second value of the fifth field 

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

1201 acc_vertex = _data_append( 

1202 acc=tree["accessors"], 

1203 buff=buffer_items, 

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

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

1206 ) 

1207 

1208 current = { 

1209 "name": name, 

1210 "primitives": [ 

1211 { 

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

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

1214 "material": material_idx, 

1215 } 

1216 ], 

1217 } 

1218 

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

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

1221 try: 

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

1223 except BaseException: 

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

1225 

1226 if path.colors is not None: 

1227 acc_color = _data_append( 

1228 acc=tree["accessors"], 

1229 buff=buffer_items, 

1230 blob={ 

1231 "componentType": 5121, 

1232 "normalized": True, 

1233 "type": "VEC4", 

1234 "byteOffset": 0, 

1235 }, 

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

1237 ) 

1238 # add color to attributes 

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

1240 

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

1242 # vertex_attributes 

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

1244 # Application specific attributes must be 

1245 # prefixed with an underscore 

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

1247 key = "_" + key 

1248 

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

1250 # any float64 or larger to float32 

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

1252 data = attrib.astype(float32) 

1253 else: 

1254 # force little-endian to match GLTF binary format 

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

1256 

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

1258 log.warning( 

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

1260 ) 

1261 continue 

1262 

1263 data_discretized = np.array( 

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

1265 ) 

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

1267 

1268 # store custom vertex attributes 

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

1270 acc=tree["accessors"], 

1271 buff=buffer_items, 

1272 blob=_build_accessor(stacked_data), 

1273 data=stacked_data, 

1274 ) 

1275 

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

1277 

1278 

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

1280 """ 

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

1282 put the data into buffer_items. 

1283 

1284 Parameters 

1285 ------------- 

1286 points : trimesh.PointCloud 

1287 Source geometry 

1288 name : str 

1289 Name of geometry 

1290 tree : dict 

1291 Will be updated with data from points 

1292 buffer_items 

1293 Will have buffer appended with points data 

1294 """ 

1295 

1296 # convert the points to the unnamed args for 

1297 # a pyglet vertex list 

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

1299 

1300 # data is the second value of the fifth field 

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

1302 acc_vertex = _data_append( 

1303 acc=tree["accessors"], 

1304 buff=buffer_items, 

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

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

1307 ) 

1308 current = { 

1309 "name": name, 

1310 "primitives": [ 

1311 { 

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

1313 "mode": _GL_POINTS, 

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

1315 } 

1316 ], 

1317 } 

1318 

1319 # TODO add color support to Points object 

1320 # this is just exporting everying as black 

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

1322 

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

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

1325 color_type, color_data = vxlist[5] 

1326 if "3" in color_type: 

1327 kind = "VEC3" 

1328 elif "4" in color_type: 

1329 kind = "VEC4" 

1330 else: 

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

1332 acc_color = _data_append( 

1333 acc=tree["accessors"], 

1334 buff=buffer_items, 

1335 blob={ 

1336 "componentType": 5121, 

1337 "count": vxlist[0], 

1338 "normalized": True, 

1339 "type": kind, 

1340 "byteOffset": 0, 

1341 }, 

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

1343 ) 

1344 # add color to attributes 

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

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

1347 

1348 

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

1350 try: 

1351 import PIL.Image 

1352 except ImportError: 

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

1354 return None 

1355 

1356 # load any images 

1357 images = None 

1358 if "images" in header: 

1359 # images are referenced by index 

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

1361 # loop through images 

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

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

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

1365 continue 

1366 # get the bytes representing an image 

1367 if "bufferView" in img: 

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

1369 elif "uri" in img: 

1370 try: 

1371 # will get bytes from filesystem or base64 URI 

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

1373 except BaseException: 

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

1375 continue 

1376 else: 

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

1378 continue 

1379 # i.e. 'image/jpeg' 

1380 # mime = img['mimeType'] 

1381 try: 

1382 # load the buffer into a PIL image 

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

1384 except BaseException: 

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

1386 return images 

1387 

1388 

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

1390 """ 

1391 Convert materials and images stored in a GLTF header 

1392 and buffer views to PBRMaterial objects. 

1393 

1394 Parameters 

1395 ------------ 

1396 header : dict 

1397 Contains layout of file 

1398 views : (n,) bytes 

1399 Raw data 

1400 

1401 Returns 

1402 ------------ 

1403 materials : list 

1404 List of trimesh.visual.texture.Material objects 

1405 """ 

1406 

1407 def parse_textures(*, data): 

1408 result = {} 

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

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

1411 # colors are always float 0.0 - 1.0 in GLTF 

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

1413 elif not isinstance(v, dict): 

1414 result[k] = v 

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

1416 try: 

1417 index = None 

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

1419 # Handle texture extensions through registry 

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

1421 index = handle_extensions( 

1422 extensions=tex_ext, scope="texture_source" 

1423 ) 

1424 

1425 if index is None: 

1426 # fall back to standard source key 

1427 index = texture.get("source") 

1428 if index is not None: 

1429 result[k] = images[index] 

1430 except BaseException: 

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

1432 return result 

1433 

1434 images = _parse_textures(header, views, resolver) 

1435 

1436 # store materials which reference images 

1437 materials = [] 

1438 if "materials" in header: 

1439 for mat in header["materials"]: 

1440 # flatten key structure so we can loop it 

1441 loopable = mat.copy() 

1442 # this key stores another dict of crap 

1443 if "pbrMetallicRoughness" in loopable: 

1444 # add keys of keys to top level dict 

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

1446 

1447 # Handle material extensions through registry 

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

1449 ext_results = handle_extensions( 

1450 extensions=mat_extensions, 

1451 scope="material", 

1452 parse_textures=parse_textures, 

1453 images=images, 

1454 ) 

1455 # Flatten extension results into the material parameters 

1456 for ext_result in ext_results.values(): 

1457 if isinstance(ext_result, dict): 

1458 loopable.update(ext_result) 

1459 

1460 # save flattened keys we can use for kwargs 

1461 pbr = parse_textures(data=loopable) 

1462 # create a PBR material object for the GLTF material 

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

1464 

1465 return materials 

1466 

1467 

1468def _read_buffers( 

1469 header: dict, 

1470 buffers: list[bytes], 

1471 mesh_kwargs: dict, 

1472 resolver: ResolverLike | None, 

1473 ignore_broken: bool = False, 

1474 merge_primitives: bool = False, 

1475 skip_materials: bool = False, 

1476): 

1477 """ 

1478 Given binary data and a layout return the 

1479 kwargs to create a scene object. 

1480 

1481 Parameters 

1482 ----------- 

1483 header : dict 

1484 With GLTF keys 

1485 buffers : list of bytes 

1486 Stored data 

1487 mesh_kwargs : dict 

1488 To be passed to the mesh constructor. 

1489 ignore_broken : bool 

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

1491 is True don't raise an exception but return 

1492 a partial result 

1493 merge_primitives : bool 

1494 If true, combine primitives into a single mesh. 

1495 skip_materials : bool 

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

1497 resolver : trimesh.resolvers.Resolver 

1498 Resolver to load referenced assets 

1499 

1500 Returns 

1501 ----------- 

1502 kwargs : dict 

1503 Can be passed to load_kwargs for a trimesh.Scene 

1504 """ 

1505 

1506 if "bufferViews" in header: 

1507 # split buffer data into buffer views 

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

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

1510 if "byteOffset" in view: 

1511 start = view["byteOffset"] 

1512 else: 

1513 start = 0 

1514 end = start + view["byteLength"] 

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

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

1517 # load data from buffers into numpy arrays 

1518 # using the layout described by accessors 

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

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

1521 placeholders = set() 

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

1523 # number of items 

1524 count = a["count"] 

1525 # what is the datatype 

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

1527 # basically how many columns 

1528 # for types like (4, 4) 

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

1530 # use reported count to generate shape 

1531 shape = np.append(count, per_item) 

1532 # number of items when flattened 

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

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

1535 if "bufferView" in a: 

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

1537 

1538 # load the bytes data into correct dtype and shape 

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

1540 

1541 # is the accessor offset in a buffer 

1542 # will include the start, length, and offset 

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

1544 # in numpy rather than in python looping 

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

1546 

1547 # both bufferView *and* accessors are allowed 

1548 # to have a byteOffset 

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

1550 

1551 if "byteStride" in buffer_view: 

1552 # how many bytes for each chunk 

1553 stride = buffer_view["byteStride"] 

1554 # we want to get the bytes for every row 

1555 per_row = per_count * dtype.itemsize 

1556 # the total block we're looking at 

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

1558 # apply as_strided for fast construction of strided array 

1559 # and copy to ensure contiguous layout 

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

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

1562 access[index] = np.array( 

1563 np.lib.stride_tricks.as_strided( 

1564 np.frombuffer( 

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

1566 ), 

1567 [count, per_row], 

1568 [stride, 1], 

1569 ) 

1570 .view(dtype) 

1571 .reshape(shape) 

1572 ) 

1573 else: 

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

1575 length = dtype.itemsize * count * per_count 

1576 access[index] = np.frombuffer( 

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

1578 ).reshape(shape) 

1579 else: 

1580 # zero placeholder a decoder may replace 

1581 if "sparse" not in a: 

1582 placeholders.add(index) 

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

1584 

1585 # possibly load images and textures into material objects 

1586 if skip_materials: 

1587 materials = [] 

1588 else: 

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

1590 

1591 mesh_prim = defaultdict(list) 

1592 # load data from accessors into Trimesh objects 

1593 meshes = OrderedDict() 

1594 

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

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

1597 # dict of names 

1598 name_counts = {} 

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

1600 undecoded = set() 

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

1602 try: 

1603 # GLTF spec indicates implicit units are meters 

1604 metadata = { 

1605 "units": "meters", 

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

1607 } 

1608 

1609 # try to load all mesh metadata 

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

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

1612 

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

1614 if "extensions" in m: 

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

1616 

1617 for p in m["primitives"]: 

1618 # preprocessing extensions like draco decompression run 

1619 # before reading accessors as they may modify them 

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

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

1622 failed = set() 

1623 handle_extensions( 

1624 extensions=prim_extensions, 

1625 scope="primitive_preprocess", 

1626 failed=failed, 

1627 primitive=p, 

1628 accessors=access, 

1629 views=views, 

1630 ) 

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

1632 # it had no handler or its handler failed 

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

1634 undecoded.update(failed) 

1635 undecoded.update( 

1636 unregistered(prim_extensions, "primitive_preprocess") 

1637 ) 

1638 

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

1640 # if not specified assume it is a mesh 

1641 kwargs = deepcopy(mesh_kwargs) 

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

1643 kwargs["metadata"] = {} 

1644 if "process" not in kwargs: 

1645 kwargs["process"] = False 

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

1647 # i.e. GL_LINES, GL_TRIANGLES, etc 

1648 # specification says the default mode is GL_TRIANGLES 

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

1650 # colors, normals, etc 

1651 attr = p["attributes"] 

1652 # create a unique mesh name per- primitive 

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

1654 # make name unique across multiple meshes 

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

1656 

1657 if mode == _GL_LINES: 

1658 # load GL_LINES into a Path object 

1659 from ...path.entities import Line 

1660 

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

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

1663 

1664 # custom attributes starting with a `_` 

1665 custom = { 

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

1667 } 

1668 if len(custom) > 0: 

1669 kwargs["vertex_attributes"] = custom 

1670 elif mode == _GL_POINTS: 

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

1672 visuals = None 

1673 if "COLOR_0" in attr: 

1674 try: 

1675 # try to load vertex colors from the accessors 

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

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

1678 if visuals is None: 

1679 # just pass to mesh as vertex color 

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

1681 else: 

1682 # we ALSO have texture so save as vertex 

1683 # attribute 

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

1685 except BaseException: 

1686 # survive failed colors 

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

1688 if visuals is not None: 

1689 kwargs["visual"] = visuals 

1690 elif mode in (_GL_TRIANGLES, _GL_STRIP): 

1691 # get vertices from accessors 

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

1693 # get faces from accessors 

1694 if "indices" in p: 

1695 if mode == _GL_STRIP: 

1696 # this is triangle strips 

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

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

1699 else: 

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

1701 else: 

1702 # indices are apparently optional and we are supposed to 

1703 # do the same thing as webGL drawArrays? 

1704 if mode == _GL_STRIP: 

1705 kwargs["faces"] = triangle_strips_to_faces( 

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

1707 ) 

1708 else: 

1709 # GL_TRIANGLES 

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

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

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

1713 

1714 if "NORMAL" in attr: 

1715 # vertex normals are specified 

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

1717 # do we have UV coordinates 

1718 visuals = None 

1719 if "material" in p and not skip_materials: 

1720 if materials is None: 

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

1722 else: 

1723 uv = None 

1724 if "TEXCOORD_0" in attr: 

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

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

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

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

1729 # create a texture visual 

1730 visuals = visual.texture.TextureVisuals( 

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

1732 ) 

1733 

1734 if "COLOR_0" in attr: 

1735 try: 

1736 # try to load vertex colors from the accessors 

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

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

1739 if visuals is None: 

1740 # just pass to mesh as vertex color 

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

1742 else: 

1743 # we ALSO have texture so save as vertex 

1744 # attribute 

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

1746 except BaseException: 

1747 # survive failed colors 

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

1749 if visuals is not None: 

1750 kwargs["visual"] = visuals 

1751 

1752 # custom attributes starting with a `_` 

1753 custom = { 

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

1755 } 

1756 if len(custom) > 0: 

1757 kwargs["vertex_attributes"] = custom 

1758 

1759 # Process primitive-level extensions through registry 

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

1761 handle_extensions( 

1762 extensions=prim_extensions, 

1763 scope="primitive", 

1764 primitive=p, 

1765 mesh_kwargs=kwargs, 

1766 accessors=access, 

1767 ) 

1768 else: 

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

1770 continue 

1771 # this should absolutely not be stomping on itself 

1772 assert name not in meshes 

1773 meshes[name] = kwargs 

1774 mesh_prim[index].append(name) 

1775 except BaseException as E: 

1776 if ignore_broken: 

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

1778 else: 

1779 raise E 

1780 

1781 if undecoded: 

1782 log.warning( 

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

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

1785 ) 

1786 

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

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

1789 # but if merge_primitives is True we combine the primitives 

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

1791 if merge_primitives: 

1792 # if we are only returning one Trimesh object 

1793 # replace `mesh_prim` with updated values 

1794 mesh_prim_replace = {} 

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

1796 mesh_pop = set() 

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

1798 if len(names) <= 1: 

1799 mesh_prim_replace[mesh_index] = names 

1800 continue 

1801 

1802 # just take the shortest name option available 

1803 name = min(names) 

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

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

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

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

1808 

1809 # get all meshes for this group 

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

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

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

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

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

1815 face_materials = [] 

1816 for i, p in enumerate(current): 

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

1818 visuals = visual.texture.TextureVisuals( 

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

1820 face_materials=face_materials, 

1821 ) 

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

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

1824 else: 

1825 metadata = {} 

1826 meshes[name] = { 

1827 "vertices": v, 

1828 "faces": f, 

1829 "visual": visuals, 

1830 "metadata": metadata, 

1831 "process": False, 

1832 } 

1833 mesh_prim_replace[mesh_index] = [name] 

1834 # avoid altering inside loop 

1835 mesh_prim = mesh_prim_replace 

1836 # remove outdated meshes 

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

1838 

1839 # make it easier to reference nodes 

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

1841 # nodes are referenced by index 

1842 # save their string names if they have one 

1843 # we have to accumulate in a for loop opposed 

1844 # to a dict comprehension as it will be checking 

1845 # the mutated dict in every loop 

1846 name_index = {} 

1847 name_counts = {} 

1848 

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

1850 # name_index: {name: index} 

1851 for i, n in enumerate(nodes): 

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

1853 # names: {index: name} 

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

1855 

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

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

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

1859 world = name_index.get(DEFAULT_BASE_FRAME) 

1860 if world is not None: 

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

1862 

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

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

1865 names[DEFAULT_BASE_FRAME] = DEFAULT_BASE_FRAME 

1866 

1867 # visited, kwargs for scene.graph.update 

1868 graph = deque() 

1869 # unvisited, pairs of node indexes 

1870 queue = deque() 

1871 

1872 # camera(s), if they exist 

1873 camera = None 

1874 camera_transform = None 

1875 

1876 if "scene" in header: 

1877 # specify the index of scenes if specified 

1878 scene_index = header["scene"] 

1879 else: 

1880 # otherwise just use the first index 

1881 scene_index = 0 

1882 

1883 if "scenes" in header: 

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

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

1886 # add transform from base frame to these root nodes 

1887 queue.append((DEFAULT_BASE_FRAME, root)) 

1888 

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

1890 consumed = set() 

1891 

1892 # go through the nodes tree to populate 

1893 # kwargs for scene graph loader 

1894 while len(queue) > 0: 

1895 # (int, int) pair of node indexes 

1896 edge = queue.pop() 

1897 

1898 # avoid looping forever if someone specified 

1899 # recursive nodes 

1900 if edge in consumed: 

1901 continue 

1902 

1903 consumed.add(edge) 

1904 a, b = edge 

1905 

1906 # dict of child node 

1907 # parent = nodes[a] 

1908 child = nodes[b] 

1909 # add edges of children to be processed 

1910 if "children" in child: 

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

1912 

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

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

1915 

1916 # grab matrix from child 

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

1918 # for the transform from parent to child 

1919 if "matrix" in child: 

1920 kwargs["matrix"] = ( 

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

1922 ) 

1923 else: 

1924 # if no matrix set identity 

1925 kwargs["matrix"] = _EYE 

1926 

1927 # Now apply keyword translations 

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

1929 if "translation" in child: 

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

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

1932 ) 

1933 if "rotation" in child: 

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

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

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

1937 # add the rotation to the matrix 

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

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

1940 ) 

1941 if "scale" in child: 

1942 # add scale to the matrix 

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

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

1945 ) 

1946 

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

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

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

1950 # TODO will only read perspective camera 

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

1952 cam_idx = child["camera"] 

1953 try: 

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

1955 except KeyError: 

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

1957 if camera: 

1958 camera_transform = kwargs["matrix"] 

1959 continue 

1960 

1961 # treat node metadata similarly to mesh metadata 

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

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

1964 

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

1966 if "extensions" in child: 

1967 if "metadata" not in kwargs: 

1968 kwargs["metadata"] = {} 

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

1970 

1971 if "mesh" in child: 

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

1973 

1974 # if the node has a mesh associated with it 

1975 if len(geometries) > 1: 

1976 # append root node 

1977 graph.append(kwargs.copy()) 

1978 # put primitives as children 

1979 for geom_name in geometries: 

1980 # save the name of the geometry 

1981 kwargs["geometry"] = geom_name 

1982 # no transformations 

1983 kwargs["matrix"] = _EYE 

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

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

1986 # frame name for the primitives after the first one 

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

1988 kwargs["frame_to"] = frame_to 

1989 # append the edge with the mesh frame 

1990 graph.append(kwargs.copy()) 

1991 elif len(geometries) == 1: 

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

1993 if "name" in child: 

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

1995 graph.append(kwargs.copy()) 

1996 else: 

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

1998 graph.append(kwargs) 

1999 

2000 # kwargs for load_kwargs 

2001 result = { 

2002 "class": "Scene", 

2003 "geometry": meshes, 

2004 "graph": graph, 

2005 "base_frame": DEFAULT_BASE_FRAME, 

2006 "camera": camera, 

2007 "camera_transform": camera_transform, 

2008 "metadata": {}, 

2009 } 

2010 

2011 try: 

2012 # load any scene extras into scene.metadata 

2013 # use a try except to avoid nested key checks 

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

2015 except BaseException: 

2016 pass 

2017 try: 

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

2019 # use a try except to avoid nested key checks 

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

2021 except BaseException: 

2022 pass 

2023 

2024 return result 

2025 

2026 

2027def _cam_from_gltf(cam): 

2028 """ 

2029 Convert a gltf perspective camera to trimesh. 

2030 

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

2032 does not contain it. 

2033 

2034 If the camera is not perspective will return None. 

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

2036 

2037 Parameters 

2038 ------------ 

2039 cam : dict 

2040 Camera represented as a dictionary according to glTF 

2041 

2042 Returns 

2043 ------------- 

2044 camera : trimesh.scene.cameras.Camera 

2045 Trimesh camera object 

2046 """ 

2047 if "perspective" not in cam: 

2048 return 

2049 name = cam.get("name") 

2050 znear = cam["perspective"]["znear"] 

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

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

2053 

2054 fov = (aspect_ratio * yfov, yfov) 

2055 

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

2057 

2058 

2059def _convert_camera(camera): 

2060 """ 

2061 Convert a trimesh camera to a GLTF camera. 

2062 

2063 Parameters 

2064 ------------ 

2065 camera : trimesh.scene.cameras.Camera 

2066 Trimesh camera object 

2067 

2068 Returns 

2069 ------------- 

2070 gltf_camera : dict 

2071 Camera represented as a GLTF dict 

2072 """ 

2073 result = { 

2074 "name": camera.name, 

2075 "type": "perspective", 

2076 "perspective": { 

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

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

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

2080 }, 

2081 } 

2082 return result 

2083 

2084 

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

2086 """ 

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

2088 

2089 Parameters 

2090 ------------ 

2091 img : PIL.Image 

2092 Image object 

2093 tree : dict 

2094 GLTF 2.0 format tree 

2095 buffer_items : (n,) bytes 

2096 Binary blobs containing data 

2097 extension_webp : bool 

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

2099 

2100 Returns 

2101 ----------- 

2102 index : int or None 

2103 The index of the image in the tree 

2104 None if image append failed for any reason 

2105 """ 

2106 # probably not a PIL image so exit 

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

2108 return None 

2109 

2110 if extension_webp: 

2111 # support WebP if extension is specified 

2112 save_as = "WEBP" 

2113 elif img.format == "JPEG": 

2114 # don't re-encode JPEGs 

2115 save_as = "JPEG" 

2116 else: 

2117 # for everything else just use PNG 

2118 save_as = "png" 

2119 

2120 # get the image data into a bytes object 

2121 with util.BytesIO() as f: 

2122 img.save(f, format=save_as) 

2123 f.seek(0) 

2124 data = f.read() 

2125 

2126 index = _buffer_append(buffer_items, data) 

2127 # append buffer index and the GLTF-acceptable mimetype 

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

2129 

2130 # index is length minus one 

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

2132 

2133 

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

2135 """ 

2136 Add passed PBRMaterial as GLTF 2.0 specification JSON 

2137 serializable data: 

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

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

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

2141 

2142 Parameters 

2143 ------------ 

2144 mat : trimesh.visual.materials.PBRMaterials 

2145 Source material to convert 

2146 tree : dict 

2147 GLTF header blob 

2148 buffer_items : (n,) bytes 

2149 Binary blobs with various data 

2150 mat_hashes : dict 

2151 Which materials have already been added 

2152 Stored as { hashed : material index } 

2153 extension_webp : bool 

2154 Export textures as webP using EXT_texture_webp extension. 

2155 

2156 Returns 

2157 ------------- 

2158 index : int 

2159 Index at which material was added 

2160 """ 

2161 # materials are hashable 

2162 hashed = hash(mat) 

2163 # check stored material indexes to see if material 

2164 # has already been added 

2165 if mat_hashes is not None and hashed in mat_hashes: 

2166 return mat_hashes[hashed] 

2167 

2168 # convert passed input to PBR if necessary 

2169 if hasattr(mat, "to_pbr"): 

2170 as_pbr = mat.to_pbr() 

2171 else: 

2172 as_pbr = mat 

2173 

2174 # a default PBR metallic material 

2175 result = {"pbrMetallicRoughness": {}} 

2176 try: 

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

2178 result["baseColorFactor"] = ( 

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

2180 ) 

2181 except BaseException: 

2182 pass 

2183 

2184 try: 

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

2186 except BaseException: 

2187 pass 

2188 

2189 # if name is defined, export 

2190 if isinstance(as_pbr.name, str): 

2191 result["name"] = as_pbr.name 

2192 

2193 # if alphaMode is defined, export 

2194 if isinstance(as_pbr.alphaMode, str): 

2195 result["alphaMode"] = as_pbr.alphaMode 

2196 

2197 # if alphaCutoff is defined, export 

2198 if isinstance(as_pbr.alphaCutoff, float): 

2199 result["alphaCutoff"] = as_pbr.alphaCutoff 

2200 

2201 # if doubleSided is defined, export 

2202 if isinstance(as_pbr.doubleSided, bool): 

2203 result["doubleSided"] = as_pbr.doubleSided 

2204 

2205 # if scalars are defined correctly export 

2206 if isinstance(as_pbr.metallicFactor, float): 

2207 result["metallicFactor"] = as_pbr.metallicFactor 

2208 if isinstance(as_pbr.roughnessFactor, float): 

2209 result["roughnessFactor"] = as_pbr.roughnessFactor 

2210 

2211 # which keys of the PBRMaterial are images 

2212 image_mapping = { 

2213 "baseColorTexture": as_pbr.baseColorTexture, 

2214 "emissiveTexture": as_pbr.emissiveTexture, 

2215 "normalTexture": as_pbr.normalTexture, 

2216 "occlusionTexture": as_pbr.occlusionTexture, 

2217 "metallicRoughnessTexture": as_pbr.metallicRoughnessTexture, 

2218 } 

2219 

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

2221 if img is None: 

2222 continue 

2223 # try adding the base image to the export object 

2224 index = _append_image( 

2225 img=img, tree=tree, buffer_items=buffer_items, extension_webp=extension_webp 

2226 ) 

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

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

2229 if index is not None: 

2230 # add a reference to the base color texture 

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

2232 

2233 # add texture object, optionally using EXT_texture_webp 

2234 if extension_webp: 

2235 tree["textures"].append( 

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

2237 ) 

2238 else: 

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

2240 

2241 # for our PBRMaterial object we flatten all keys 

2242 # however GLTF would like some of them under the 

2243 # "pbrMetallicRoughness" key 

2244 pbr_subset = [ 

2245 "baseColorTexture", 

2246 "baseColorFactor", 

2247 "roughnessFactor", 

2248 "metallicFactor", 

2249 "metallicRoughnessTexture", 

2250 ] 

2251 # move keys down a level 

2252 for key in pbr_subset: 

2253 if key in result: 

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

2255 

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

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

2258 result.pop("pbrMetallicRoughness") 

2259 

2260 # which index are we inserting material at 

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

2262 # add the material to the data structure 

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

2264 # add the material index in-place 

2265 mat_hashes[hashed] = index 

2266 

2267 return index 

2268 

2269 

2270def validate(header): 

2271 """ 

2272 Validate a GLTF 2.0 header against the schema. 

2273 

2274 Returns result from: 

2275 `jsonschema.validate(header, schema=get_schema())` 

2276 

2277 Parameters 

2278 ------------- 

2279 header : dict 

2280 Populated GLTF 2.0 header 

2281 

2282 Raises 

2283 -------------- 

2284 err : jsonschema.exceptions.ValidationError 

2285 If the tree is an invalid GLTF2.0 header 

2286 """ 

2287 # a soft dependency 

2288 import jsonschema 

2289 

2290 # will do the reference replacement 

2291 schema = get_schema() 

2292 # validate the passed header against the schema 

2293 valid = jsonschema.validate(header, schema=schema) 

2294 

2295 return valid 

2296 

2297 

2298def get_schema(): 

2299 """ 

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

2301 

2302 Returns 

2303 ------------ 

2304 schema : dict 

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

2306 """ 

2307 # replace references 

2308 # get zip resolver to access referenced assets 

2309 from ...schemas import resolve 

2310 

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

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

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

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

2315 # get a resolver object for accessing the schema 

2316 resolver = ZipResolver(archive) 

2317 # get a loaded dict from the base file 

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

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

2320 schema = resolve(unresolved, resolver=resolver) 

2321 

2322 return schema 

2323 

2324 

2325# exporters 

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