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

776 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-31 23:55 +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 ...resolvers import ResolverLike, ZipResolver 

20from ...scene.cameras import Camera 

21from ...scene.transforms import DEFAULT_BASE_FRAME 

22from ...typed import NDArray, Stream 

23from ...util import triangle_strips_to_faces, unique_name 

24from .extensions import handle_extensions, unregistered 

25 

26# magic numbers which have meaning in GLTF 

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

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

29 

30# GLTF data type codes: little endian numpy dtypes 

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

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

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

34 

35 

36# GLTF data formats: numpy shapes 

37_shapes = { 

38 "SCALAR": 1, 

39 "VEC2": (2), 

40 "VEC3": (3), 

41 "VEC4": (4), 

42 "MAT2": (2, 2), 

43 "MAT3": (3, 3), 

44 "MAT4": (4, 4), 

45} 

46 

47# a default PBR metallic material 

48_default_material = { 

49 "pbrMetallicRoughness": { 

50 "baseColorFactor": [1, 1, 1, 1], 

51 "metallicFactor": 0, 

52 "roughnessFactor": 0, 

53 } 

54} 

55 

56# GL geometry modes 

57_GL_LINES = 1 

58_GL_POINTS = 0 

59_GL_TRIANGLES = 4 

60_GL_STRIP = 5 

61 

62_EYE = np.eye(4) 

63_EYE.flags.writeable = False 

64 

65# specify dtypes with forced little endian 

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

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

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

69 

70 

71def export_gltf( 

72 scene, 

73 include_normals=None, 

74 merge_buffers=False, 

75 unitize_normals=True, 

76 tree_postprocessor=None, 

77 embed_buffers=False, 

78 extension_webp=False, 

79 extension_draco=False, 

80): 

81 """ 

82 Export a scene object as a GLTF directory. 

83 

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

85 as opposed to one larger file. 

86 

87 Parameters 

88 ----------- 

89 scene : trimesh.Scene 

90 Scene to be exported 

91 include_normals : None or bool 

92 Include vertex normals 

93 merge_buffers : bool 

94 Merge buffers into one blob. 

95 unitize_normals 

96 GLTF requires unit normals, however sometimes people 

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

98 resolver : trimesh.resolvers.Resolver 

99 If passed will use to write each file. 

100 tree_postprocesser : None or callable 

101 Run this on the header tree before exiting. 

102 embed_buffers : bool 

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

104 extension_webp : bool 

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

106 extension_draco : bool 

107 Compress mesh data using Draco (KHR_draco_mesh_compression). 

108 Requires the `dracox` package to be installed. 

109 

110 Returns 

111 ---------- 

112 export : dict 

113 Format: {file name : file data} 

114 """ 

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

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

117 scene = scene.scene() 

118 

119 # create the header and buffer data 

120 tree, buffer_items = _create_gltf_structure( 

121 scene=scene, 

122 unitize_normals=unitize_normals, 

123 include_normals=include_normals, 

124 extension_webp=extension_webp, 

125 extension_draco=extension_draco, 

126 ) 

127 

128 # allow custom postprocessing 

129 if tree_postprocessor is not None: 

130 tree_postprocessor(tree) 

131 

132 # store files as {name : data} 

133 files = {} 

134 

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

136 if merge_buffers: 

137 views = _build_views(buffer_items) 

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

139 if embed_buffers: 

140 buffer_name = base64_buffer_format.format( 

141 base64.b64encode(buffer_data).decode() 

142 ) 

143 else: 

144 buffer_name = "gltf_buffer.bin" 

145 files[buffer_name] = buffer_data 

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

147 else: 

148 # make one buffer per buffer_items 

149 buffers = [None] * len(buffer_items) 

150 # A bufferView is a slice of a file 

151 views = [None] * len(buffer_items) 

152 # create the buffer views 

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

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

155 if embed_buffers: 

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

157 else: 

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

159 files[buffer_name] = item 

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

161 

162 if len(buffers) > 0: 

163 tree["buffers"] = buffers 

164 tree["bufferViews"] = views 

165 # dump tree with compact separators 

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

167 

168 if tol.strict: 

169 validate(tree) 

170 

171 return files 

172 

173 

174def export_glb( 

175 scene, 

176 include_normals=None, 

177 unitize_normals=True, 

178 tree_postprocessor=None, 

179 buffer_postprocessor=None, 

180 extension_webp=False, 

181 extension_draco=False, 

182): 

183 """ 

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

185 

186 Parameters 

187 ------------ 

188 scene: trimesh.Scene 

189 Input geometry 

190 extras : JSON serializable 

191 Will be stored in the extras field. 

192 include_normals : bool 

193 Include vertex normals in output file? 

194 tree_postprocessor : func 

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

196 before exporting. 

197 extension_webp : bool 

198 Export textures as webP using EXT_texture_webp extension. 

199 extension_draco : bool 

200 Compress mesh data using Draco (KHR_draco_mesh_compression). 

201 Requires the `dracox` package to be installed. 

202 

203 Returns 

204 ---------- 

205 exported : bytes 

206 Exported result in GLB 2.0 

207 """ 

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

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

210 # generate a scene with just that mesh in it 

211 scene = scene.scene() 

212 

213 tree, buffer_items = _create_gltf_structure( 

214 scene=scene, 

215 unitize_normals=unitize_normals, 

216 include_normals=include_normals, 

217 buffer_postprocessor=buffer_postprocessor, 

218 extension_webp=extension_webp, 

219 extension_draco=extension_draco, 

220 ) 

221 

222 # A bufferView is a slice of a file 

223 views = _build_views(buffer_items) 

224 

225 # combine bytes into a single blob 

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

227 

228 # add the information about the buffer data 

229 if len(buffer_data) > 0: 

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

231 tree["bufferViews"] = views 

232 

233 # allow custom postprocessing 

234 if tree_postprocessor is not None: 

235 tree_postprocessor(tree) 

236 

237 # export the tree to JSON for the header 

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

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

240 # is 4 byte aligned as per spec 

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

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

243 # make sure we didn't screw it up 

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

245 

246 # the initial header of the file 

247 header = _byte_pad( 

248 np.array( 

249 [ 

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

251 2, # GLTF version 

252 # length is the total length of the Binary glTF 

253 # including Header and all Chunks, in bytes. 

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

255 # contentLength is the length, in bytes, 

256 # of the glTF content (JSON) 

257 len(content), 

258 # magic number which is 'JSON' 

259 _magic["json"], 

260 ], 

261 dtype="<u4", 

262 ).tobytes() 

263 ) 

264 

265 # the header of the binary data section 

266 bin_header = _byte_pad( 

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

268 ) 

269 

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

271 

272 if tol.strict: 

273 validate(tree) 

274 

275 return exported 

276 

277 

278def load_gltf( 

279 file_obj: Stream | None = None, 

280 resolver: ResolverLike | None = None, 

281 ignore_broken: bool = False, 

282 merge_primitives: bool = False, 

283 skip_materials: bool = False, 

284 **mesh_kwargs, 

285): 

286 """ 

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

288 with multiple files. 

289 

290 Parameters 

291 ------------- 

292 file_obj : None or file-like 

293 Object containing header JSON, or None 

294 resolver : trimesh.visual.Resolver 

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

296 ignore_broken : bool 

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

298 is True don't raise an exception but return 

299 a partial result 

300 merge_primitives : bool 

301 If True, each GLTF 'mesh' will correspond 

302 to a single Trimesh object 

303 skip_materials : bool 

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

305 **mesh_kwargs : dict 

306 Passed to mesh constructor 

307 

308 Returns 

309 -------------- 

310 kwargs : dict 

311 Arguments to create scene 

312 """ 

313 try: 

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

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

316 except BaseException: 

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

318 data = resolver["model.gltf"] 

319 # old versions of python/json need strings 

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

321 

322 # gltf 1.0 is a totally different format 

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

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

325 if isinstance(version, str): 

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

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

328 else: 

329 major = int(float(version)) 

330 

331 if major < 2: 

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

333 

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

335 buffers = [ 

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

337 ] 

338 

339 # turn the layout header and data into kwargs 

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

341 kwargs = _read_buffers( 

342 header=tree, 

343 buffers=buffers, 

344 ignore_broken=ignore_broken, 

345 merge_primitives=merge_primitives, 

346 mesh_kwargs=mesh_kwargs, 

347 skip_materials=skip_materials, 

348 resolver=resolver, 

349 ) 

350 return kwargs 

351 

352 

353def load_glb( 

354 file_obj: Stream, 

355 resolver: ResolverLike | None = None, 

356 ignore_broken: bool = False, 

357 merge_primitives: bool = False, 

358 skip_materials: bool = False, 

359 **mesh_kwargs, 

360): 

361 """ 

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

363 

364 Implemented from specification: 

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

366 

367 Parameters 

368 ------------ 

369 file_obj : file- like object 

370 Containing GLB data 

371 resolver : trimesh.visual.Resolver 

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

373 ignore_broken : bool 

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

375 is True don't raise an exception but return 

376 a partial result 

377 merge_primitives : bool 

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

379 single Trimesh object. 

380 skip_materials : bool 

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

382 

383 Returns 

384 ------------ 

385 kwargs : dict 

386 Kwargs to instantiate a trimesh.Scene 

387 """ 

388 # read the first 20 bytes which contain section lengths 

389 head_data = file_obj.read(20) 

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

391 

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

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

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

395 

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

397 if head[1] != 2: 

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

399 

400 # overall file length 

401 # first chunk length 

402 # first chunk type 

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

404 

405 # first chunk should be JSON header 

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

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

408 

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

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

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

412 # convert to text 

413 if hasattr(json_data, "decode"): 

414 json_data = util.decode_text(json_data) 

415 # load the json header to native dict 

416 header = json.loads(json_data) 

417 

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

419 buffers = [] 

420 start = file_obj.tell() 

421 

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

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

424 

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

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

427 try: 

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

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

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

431 continue 

432 except (IndexError, KeyError): 

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

434 pass 

435 

436 # the last read put us past the JSON chunk 

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

438 chunk_head = file_obj.read(8) 

439 if len(chunk_head) != 8: 

440 # double check to make sure we didn't 

441 # read the whole file 

442 break 

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

444 # make sure we have the right data type 

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

446 raise ValueError("not binary GLTF!") 

447 # read the chunk 

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

449 if len(chunk_data) != chunk_length: 

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

451 buffers.append(chunk_data) 

452 

453 # turn the layout header and data into kwargs 

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

455 kwargs = _read_buffers( 

456 header=header, 

457 buffers=buffers, 

458 ignore_broken=ignore_broken, 

459 merge_primitives=merge_primitives, 

460 skip_materials=skip_materials, 

461 mesh_kwargs=mesh_kwargs, 

462 resolver=resolver, 

463 ) 

464 

465 return kwargs 

466 

467 

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

469 """ 

470 Take a URI string and load it as a 

471 a filename or as base64. 

472 

473 Parameters 

474 -------------- 

475 uri 

476 Usually a filename or something like: 

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

478 resolver 

479 A resolver to load referenced assets 

480 

481 Returns 

482 --------------- 

483 data 

484 Loaded data from URI 

485 """ 

486 # see if the URI has base64 data 

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

488 if index < 0: 

489 # string didn't contain the base64 header 

490 # so return the result from the resolver 

491 return resolver[uri] 

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

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

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

495 

496 

497def _buffer_append(ordered, data): 

498 """ 

499 Append data to an existing OrderedDict and 

500 pad it to a 4-byte boundary. 

501 

502 Parameters 

503 ---------- 

504 od : OrderedDict 

505 Keyed like { hash : data } 

506 data : bytes 

507 To be stored 

508 

509 Returns 

510 ---------- 

511 index : int 

512 Index of buffer_items stored in 

513 """ 

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

515 hashed = hash_fast(data) 

516 if hashed in ordered: 

517 # apparently they never implemented keys().index -_- 

518 return list(ordered.keys()).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(acc: OrderedDict, buff: OrderedDict, blob: dict, data: NDArray): 

526 """ 

527 Append a new accessor to an OrderedDict. 

528 

529 Parameters 

530 ------------ 

531 acc 

532 Collection of accessors, will be mutated in-place 

533 buff 

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

535 blob 

536 Candidate accessor 

537 data 

538 Data to fill in details to blob 

539 

540 Returns 

541 ---------- 

542 index : int 

543 Index of accessor that was added or reused. 

544 """ 

545 # if we have data include that in the key 

546 as_bytes = data.tobytes() 

547 if hasattr(data, "hash_fast"): 

548 # passed a TrackedArray object 

549 hashed = data.hash_fast() 

550 else: 

551 # someone passed a vanilla numpy array 

552 hashed = hash_fast(as_bytes) 

553 

554 if hashed in buff: 

555 blob["bufferView"] = list(buff.keys()).index(hashed) 

556 else: 

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

558 buff[hashed] = _byte_pad(as_bytes) 

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

560 

561 # start by hashing the dict blob 

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

563 try: 

564 # simple keys can be hashed as tuples without JSON 

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

566 except BaseException: 

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

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

569 

570 # xor the hash for the blob to the key 

571 key ^= hashed 

572 

573 # if key exists return the index in the OrderedDict 

574 if key in acc: 

575 return list(acc.keys()).index(key) 

576 

577 # get a numpy dtype for our components 

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

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

580 kind = blob["type"] 

581 

582 if tol.strict: 

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

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

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

586 

587 if kind == "SCALAR": 

588 # is probably (n, 1) 

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

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

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

592 elif kind.startswith("MAT"): 

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

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

595 else: 

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

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

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

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

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

601 

602 # store the accessor and return the index 

603 acc[key] = blob 

604 return len(acc) - 1 

605 

606 

607def _jsonify(blob): 

608 """ 

609 Roundtrip a blob through json export-import cycle 

610 skipping any internal keys. 

611 """ 

612 return json.loads( 

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

614 ) 

615 

616 

617def _create_gltf_structure( 

618 scene, 

619 include_normals=None, 

620 include_metadata=True, 

621 unitize_normals=None, 

622 buffer_postprocessor=None, 

623 extension_webp=False, 

624 extension_draco=False, 

625): 

626 """ 

627 Generate a GLTF header. 

628 

629 Parameters 

630 ------------- 

631 scene : trimesh.Scene 

632 Input scene data 

633 include_metadata : bool 

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

635 include_normals : bool 

636 Include vertex normals in output file? 

637 unitize_normals : bool 

638 Unitize all exported normals so as to pass GLTF validation 

639 extension_webp : bool 

640 Export textures as webP using EXT_texture_webp extension. 

641 extension_draco : bool 

642 Compress mesh data using Draco (KHR_draco_mesh_compression). 

643 

644 Returns 

645 --------------- 

646 tree : dict 

647 Contains required keys for a GLTF scene 

648 buffer_items : list 

649 Contains bytes of data 

650 """ 

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

652 # world node to the 0-index 

653 tree = { 

654 "scene": 0, 

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

656 "scenes": [{}], 

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

658 "accessors": OrderedDict(), 

659 "meshes": [], 

660 "images": [], 

661 "textures": [], 

662 "materials": [], 

663 } 

664 

665 if scene.has_camera: 

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

667 

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

669 try: 

670 # fail here if data isn't json compatible 

671 # only export the extras if there is something there 

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

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

674 if isinstance(extensions, dict): 

675 tree["extensions"] = extensions 

676 except BaseException: 

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

678 

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

680 mat_hashes = {} 

681 # store data from geometries 

682 buffer_items = OrderedDict() 

683 

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

685 mesh_index = {} 

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

687 

688 # loop through every geometry 

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

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

691 # add the mesh 

692 _append_mesh( 

693 mesh=geometry, 

694 name=name, 

695 tree=tree, 

696 buffer_items=buffer_items, 

697 include_normals=include_normals, 

698 unitize_normals=unitize_normals, 

699 mat_hashes=mat_hashes, 

700 extension_webp=extension_webp, 

701 extension_draco=extension_draco, 

702 ) 

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

704 # add Path2D and Path3D objects 

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

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

707 # add PointCloud objects 

708 _append_point( 

709 points=geometry, name=name, tree=tree, buffer_items=buffer_items 

710 ) 

711 

712 # only store the index if the append did anything 

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

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

715 mesh_index[name] = previous - 1 

716 

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

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

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

720 # hold `extras` with the scene metadata 

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

722 tree.update(nodes) 

723 

724 extensions_used = set() 

725 extensions_required = set() 

726 # Add any scene extensions used 

727 if "extensions" in tree: 

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

729 # Add any mesh extensions used 

730 for mesh in tree["meshes"]: 

731 if "extensions" in mesh: 

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

733 # Check primitives for extensions too 

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

735 if "extensions" in prim: 

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

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

738 if "extensionsUsed" in tree: 

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

740 # Add WebP if used 

741 if extension_webp: 

742 extensions_used.add("EXT_texture_webp") 

743 extensions_required.add("EXT_texture_webp") 

744 # Add Draco if used (no fallback, so required) 

745 if extension_draco: 

746 extensions_used.add("KHR_draco_mesh_compression") 

747 extensions_required.add("KHR_draco_mesh_compression") 

748 if len(extensions_used) > 0: 

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

750 if len(extensions_required) > 0: 

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

752 

753 if buffer_postprocessor is not None: 

754 buffer_postprocessor(buffer_items, tree) 

755 

756 # convert accessors back to a flat list 

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

758 

759 # cull empty or unpopulated fields 

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

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

762 # remove the keys with nothing stored in them 

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

764 

765 return tree, buffer_items 

766 

767 

768def _append_mesh( 

769 mesh, 

770 name, 

771 tree, 

772 buffer_items, 

773 include_normals: bool | None, 

774 unitize_normals: bool, 

775 mat_hashes: dict, 

776 extension_webp: bool, 

777 extension_draco: bool = False, 

778): 

779 """ 

780 Append a mesh to the scene structure and put the 

781 data into buffer_items. 

782 

783 Parameters 

784 ------------- 

785 mesh : trimesh.Trimesh 

786 Source geometry 

787 name : str 

788 Name of geometry 

789 tree : dict 

790 Will be updated with data from mesh 

791 buffer_items 

792 Will have buffer appended with mesh data 

793 include_normals : bool 

794 Include vertex normals in export or not 

795 unitize_normals : bool 

796 Transform normals into unit vectors. 

797 May be undesirable but will fail validators without this. 

798 

799 mat_hashes : dict 

800 Which materials have already been added 

801 extension_webp : bool 

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

803 extension_draco : bool 

804 Compress mesh data using Draco (KHR_draco_mesh_compression). 

805 """ 

806 # return early from empty meshes to avoid crashing later 

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

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

809 return 

810 # convert mesh data to the correct dtypes 

811 # faces: 5125 is an unsigned 32 bit integer 

812 # accessors refer to data locations 

813 # mesh faces are stored as flat list of integers 

814 acc_face = _data_append( 

815 acc=tree["accessors"], 

816 buff=buffer_items, 

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

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

819 ) 

820 

821 # vertices: 5126 is a float32 

822 # create or reuse an accessor for these vertices 

823 acc_vertex = _data_append( 

824 acc=tree["accessors"], 

825 buff=buffer_items, 

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

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

828 ) 

829 

830 # meshes reference accessor indexes 

831 current = { 

832 "name": name, 

833 "extras": {}, 

834 "primitives": [ 

835 { 

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

837 "indices": acc_face, 

838 "mode": _GL_TRIANGLES, 

839 } 

840 ], 

841 } 

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

843 # the GLTF spec says everything is implicit meters 

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

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

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

847 try: 

848 # skip jsonify any metadata, skipping internal keys 

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

850 

851 # extract extensions if any 

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

853 if isinstance(extensions, dict): 

854 current["extensions"] = extensions 

855 

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

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

858 except BaseException: 

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

860 

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

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

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

864 vertex_colors = mesh.visual.vertex_colors 

865 elif ( 

866 hasattr(mesh.visual, "vertex_attributes") 

867 and "color" in mesh.visual.vertex_attributes 

868 ): 

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

870 else: 

871 vertex_colors = None 

872 

873 if vertex_colors is not None: 

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

875 # convert color data to bytes and append 

876 acc_color = _data_append( 

877 acc=tree["accessors"], 

878 buff=buffer_items, 

879 blob={ 

880 "componentType": 5121, 

881 "normalized": True, 

882 "type": "VEC4", 

883 "byteOffset": 0, 

884 }, 

885 data=vertex_colors.astype(uint8), 

886 ) 

887 

888 # add the reference for vertex color 

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

890 else: 

891 log.warning( 

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

893 ) 

894 

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

896 # append the material and then set from returned index 

897 current_material = _append_material( 

898 mat=mesh.visual.material, 

899 tree=tree, 

900 buffer_items=buffer_items, 

901 mat_hashes=mat_hashes, 

902 extension_webp=extension_webp, 

903 ) 

904 

905 # if mesh has UV coordinates defined export them 

906 has_uv = ( 

907 hasattr(mesh.visual, "uv") 

908 and mesh.visual.uv is not None 

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

910 ) 

911 if has_uv: 

912 # slice off W if passed 

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

914 # reverse the Y for GLTF 

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

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

917 acc_uv = _data_append( 

918 acc=tree["accessors"], 

919 buff=buffer_items, 

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

921 data=uv.astype(float32), 

922 ) 

923 # add the reference for UV coordinates 

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

925 

926 # reference the material 

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

928 

929 if include_normals or ( 

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

931 ): 

932 # store vertex normals if requested 

933 if unitize_normals: 

934 normals = util.unitize(mesh.vertex_normals) 

935 else: 

936 # we don't have to copy them since 

937 # they aren't being altered 

938 normals = mesh.vertex_normals 

939 

940 acc_norm = _data_append( 

941 acc=tree["accessors"], 

942 buff=buffer_items, 

943 blob={ 

944 "componentType": 5126, 

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

946 "type": "VEC3", 

947 "byteOffset": 0, 

948 }, 

949 data=normals.astype(float32), 

950 ) 

951 # add the reference for vertex color 

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

953 

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

955 # vertex_attributes 

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

957 # make sure vertex attribute length matches vertices 

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

959 log.warning( 

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

961 ) 

962 continue 

963 

964 # application specific attributes must be prefixed with an underscore 

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

966 key = "_" + key 

967 

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

969 # any float64 or larger to float32 

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

971 data = attrib.astype(float32) 

972 else: 

973 # force little-endian to match GLTF binary format 

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

975 

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

977 data = data[:, np.newaxis] 

978 

979 # every accessor VALUE must be 4-byte aligned 

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

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

982 if row_mod != 0: 

983 # how many columns of padding for this value 

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

985 # pad this custom attribute with zeros -_- 

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

987 

988 # store custom vertex attributes 

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

990 acc=tree["accessors"], 

991 buff=buffer_items, 

992 blob=_build_accessor(data), 

993 data=data, 

994 ) 

995 

996 # Handle Draco compression via extension handler 

997 if extension_draco: 

998 # Determine if normals should be included 

999 should_include_normals = include_normals or ( 

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

1001 ) 

1002 # Call primitive_export handlers 

1003 handle_extensions( 

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

1005 scope="primitive_export", 

1006 mesh=mesh, 

1007 name=name, 

1008 tree=tree, 

1009 buffer_items=buffer_items, 

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

1011 include_normals=should_include_normals, 

1012 ) 

1013 

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

1015 

1016 

1017def _build_views(buffer_items): 

1018 """ 

1019 Create views for buffers that are simply 

1020 based on how many bytes they are long. 

1021 

1022 Parameters 

1023 -------------- 

1024 buffer_items : OrderedDict 

1025 Buffers to build views for 

1026 

1027 Returns 

1028 ---------- 

1029 views : (n,) list of dict 

1030 GLTF views 

1031 """ 

1032 views = [] 

1033 # create the buffer views 

1034 current_pos = 0 

1035 for current_item in buffer_items.values(): 

1036 views.append( 

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

1038 ) 

1039 assert (current_pos % 4) == 0 

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

1041 current_pos += len(current_item) 

1042 return views 

1043 

1044 

1045def _build_accessor(array): 

1046 """ 

1047 Build an accessor for an arbitrary array. 

1048 

1049 Parameters 

1050 ----------- 

1051 array : numpy array 

1052 The array to build an accessor for 

1053 

1054 Returns 

1055 ---------- 

1056 accessor : dict 

1057 The accessor for array. 

1058 """ 

1059 shape = array.shape 

1060 data_type = "SCALAR" 

1061 if len(shape) == 2: 

1062 vec_length = shape[1] 

1063 if vec_length > 4: 

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

1065 if vec_length > 1: 

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

1067 else: 

1068 data_type = "SCALAR" 

1069 

1070 if len(shape) == 3: 

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

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

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

1074 

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

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

1077 

1078 if lookup == "u4": 

1079 # spec: UNSIGNED_INT is only allowed when the accessor 

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

1081 # by `primitive.indices` 

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

1083 

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

1085 componentType = _dtypes_lookup[lookup] 

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

1087 

1088 if len(shape) < 3: 

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

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

1091 

1092 return accessor 

1093 

1094 

1095def _byte_pad(data, bound=4): 

1096 """ 

1097 GLTF wants chunks aligned with 4 byte boundaries. 

1098 This function will add padding to the end of a 

1099 chunk of bytes so that it aligns with the passed 

1100 boundary size. 

1101 

1102 Parameters 

1103 -------------- 

1104 data : bytes 

1105 Data to be padded 

1106 bound : int 

1107 Length of desired boundary 

1108 

1109 Returns 

1110 -------------- 

1111 padded : bytes 

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

1113 """ 

1114 assert isinstance(data, bytes) 

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

1116 # extra bytes to pad with 

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

1118 pad = bytes(count) 

1119 # combine the padding and data 

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

1121 # we should always divide evenly 

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

1123 raise ValueError("byte_pad failed!") 

1124 return result 

1125 return data 

1126 

1127 

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

1129 """ 

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

1131 data into buffer_items. 

1132 

1133 Parameters 

1134 ------------- 

1135 path : trimesh.Path2D or trimesh.Path3D 

1136 Source geometry 

1137 name : str 

1138 Name of geometry 

1139 tree : dict 

1140 Will be updated with data from path 

1141 buffer_items 

1142 Will have buffer appended with path data 

1143 """ 

1144 

1145 # convert the path to the unnamed args for 

1146 # a pyglet vertex list 

1147 vxlist = rendering.path_to_vertexlist(path) 

1148 

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

1150 if vxlist[0] == 0: 

1151 return 

1152 

1153 # TODO add color support to Path object 

1154 # this is just exporting everying as black 

1155 try: 

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

1157 except ValueError: 

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

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

1160 

1161 # data is the second value of the fifth field 

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

1163 acc_vertex = _data_append( 

1164 acc=tree["accessors"], 

1165 buff=buffer_items, 

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

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

1168 ) 

1169 

1170 current = { 

1171 "name": name, 

1172 "primitives": [ 

1173 { 

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

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

1176 "material": material_idx, 

1177 } 

1178 ], 

1179 } 

1180 

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

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

1183 try: 

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

1185 except BaseException: 

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

1187 

1188 if path.colors is not None: 

1189 acc_color = _data_append( 

1190 acc=tree["accessors"], 

1191 buff=buffer_items, 

1192 blob={ 

1193 "componentType": 5121, 

1194 "normalized": True, 

1195 "type": "VEC4", 

1196 "byteOffset": 0, 

1197 }, 

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

1199 ) 

1200 # add color to attributes 

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

1202 

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

1204 # vertex_attributes 

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

1206 # Application specific attributes must be 

1207 # prefixed with an underscore 

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

1209 key = "_" + key 

1210 

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

1212 # any float64 or larger to float32 

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

1214 data = attrib.astype(float32) 

1215 else: 

1216 # force little-endian to match GLTF binary format 

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

1218 

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

1220 log.warning( 

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

1222 ) 

1223 continue 

1224 

1225 data_discretized = np.array( 

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

1227 ) 

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

1229 

1230 # store custom vertex attributes 

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

1232 acc=tree["accessors"], 

1233 buff=buffer_items, 

1234 blob=_build_accessor(stacked_data), 

1235 data=stacked_data, 

1236 ) 

1237 

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

1239 

1240 

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

1242 """ 

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

1244 put the data into buffer_items. 

1245 

1246 Parameters 

1247 ------------- 

1248 points : trimesh.PointCloud 

1249 Source geometry 

1250 name : str 

1251 Name of geometry 

1252 tree : dict 

1253 Will be updated with data from points 

1254 buffer_items 

1255 Will have buffer appended with points data 

1256 """ 

1257 

1258 # convert the points to the unnamed args for 

1259 # a pyglet vertex list 

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

1261 

1262 # data is the second value of the fifth field 

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

1264 acc_vertex = _data_append( 

1265 acc=tree["accessors"], 

1266 buff=buffer_items, 

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

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

1269 ) 

1270 current = { 

1271 "name": name, 

1272 "primitives": [ 

1273 { 

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

1275 "mode": _GL_POINTS, 

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

1277 } 

1278 ], 

1279 } 

1280 

1281 # TODO add color support to Points object 

1282 # this is just exporting everying as black 

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

1284 

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

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

1287 color_type, color_data = vxlist[5] 

1288 if "3" in color_type: 

1289 kind = "VEC3" 

1290 elif "4" in color_type: 

1291 kind = "VEC4" 

1292 else: 

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

1294 acc_color = _data_append( 

1295 acc=tree["accessors"], 

1296 buff=buffer_items, 

1297 blob={ 

1298 "componentType": 5121, 

1299 "count": vxlist[0], 

1300 "normalized": True, 

1301 "type": kind, 

1302 "byteOffset": 0, 

1303 }, 

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

1305 ) 

1306 # add color to attributes 

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

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

1309 

1310 

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

1312 try: 

1313 import PIL.Image 

1314 except ImportError: 

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

1316 return None 

1317 

1318 # load any images 

1319 images = None 

1320 if "images" in header: 

1321 # images are referenced by index 

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

1323 # loop through images 

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

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

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

1327 continue 

1328 # get the bytes representing an image 

1329 if "bufferView" in img: 

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

1331 elif "uri" in img: 

1332 try: 

1333 # will get bytes from filesystem or base64 URI 

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

1335 except BaseException: 

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

1337 continue 

1338 else: 

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

1340 continue 

1341 # i.e. 'image/jpeg' 

1342 # mime = img['mimeType'] 

1343 try: 

1344 # load the buffer into a PIL image 

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

1346 except BaseException: 

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

1348 return images 

1349 

1350 

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

1352 """ 

1353 Convert materials and images stored in a GLTF header 

1354 and buffer views to PBRMaterial objects. 

1355 

1356 Parameters 

1357 ------------ 

1358 header : dict 

1359 Contains layout of file 

1360 views : (n,) bytes 

1361 Raw data 

1362 

1363 Returns 

1364 ------------ 

1365 materials : list 

1366 List of trimesh.visual.texture.Material objects 

1367 """ 

1368 

1369 def parse_textures(*, data): 

1370 result = {} 

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

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

1373 # colors are always float 0.0 - 1.0 in GLTF 

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

1375 elif not isinstance(v, dict): 

1376 result[k] = v 

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

1378 try: 

1379 index = None 

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

1381 # Handle texture extensions through registry 

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

1383 index = handle_extensions( 

1384 extensions=tex_ext, scope="texture_source" 

1385 ) 

1386 

1387 if index is None: 

1388 # fall back to standard source key 

1389 index = texture.get("source") 

1390 if index is not None: 

1391 result[k] = images[index] 

1392 except BaseException: 

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

1394 return result 

1395 

1396 images = _parse_textures(header, views, resolver) 

1397 

1398 # store materials which reference images 

1399 materials = [] 

1400 if "materials" in header: 

1401 for mat in header["materials"]: 

1402 # flatten key structure so we can loop it 

1403 loopable = mat.copy() 

1404 # this key stores another dict of crap 

1405 if "pbrMetallicRoughness" in loopable: 

1406 # add keys of keys to top level dict 

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

1408 

1409 # Handle material extensions through registry 

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

1411 ext_results = handle_extensions( 

1412 extensions=mat_extensions, 

1413 scope="material", 

1414 parse_textures=parse_textures, 

1415 images=images, 

1416 ) 

1417 # Flatten extension results into the material parameters 

1418 for ext_result in ext_results.values(): 

1419 if isinstance(ext_result, dict): 

1420 loopable.update(ext_result) 

1421 

1422 # save flattened keys we can use for kwargs 

1423 pbr = parse_textures(data=loopable) 

1424 # create a PBR material object for the GLTF material 

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

1426 

1427 return materials 

1428 

1429 

1430def _read_buffers( 

1431 header: dict, 

1432 buffers: list[bytes], 

1433 mesh_kwargs: dict, 

1434 resolver: ResolverLike | None, 

1435 ignore_broken: bool = False, 

1436 merge_primitives: bool = False, 

1437 skip_materials: bool = False, 

1438): 

1439 """ 

1440 Given binary data and a layout return the 

1441 kwargs to create a scene object. 

1442 

1443 Parameters 

1444 ----------- 

1445 header : dict 

1446 With GLTF keys 

1447 buffers : list of bytes 

1448 Stored data 

1449 mesh_kwargs : dict 

1450 To be passed to the mesh constructor. 

1451 ignore_broken : bool 

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

1453 is True don't raise an exception but return 

1454 a partial result 

1455 merge_primitives : bool 

1456 If true, combine primitives into a single mesh. 

1457 skip_materials : bool 

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

1459 resolver : trimesh.resolvers.Resolver 

1460 Resolver to load referenced assets 

1461 

1462 Returns 

1463 ----------- 

1464 kwargs : dict 

1465 Can be passed to load_kwargs for a trimesh.Scene 

1466 """ 

1467 

1468 if "bufferViews" in header: 

1469 # split buffer data into buffer views 

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

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

1472 if "byteOffset" in view: 

1473 start = view["byteOffset"] 

1474 else: 

1475 start = 0 

1476 end = start + view["byteLength"] 

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

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

1479 # load data from buffers into numpy arrays 

1480 # using the layout described by accessors 

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

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

1483 placeholders = set() 

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

1485 # number of items 

1486 count = a["count"] 

1487 # what is the datatype 

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

1489 # basically how many columns 

1490 # for types like (4, 4) 

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

1492 # use reported count to generate shape 

1493 shape = np.append(count, per_item) 

1494 # number of items when flattened 

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

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

1497 if "bufferView" in a: 

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

1499 

1500 # load the bytes data into correct dtype and shape 

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

1502 

1503 # is the accessor offset in a buffer 

1504 # will include the start, length, and offset 

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

1506 # in numpy rather than in python looping 

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

1508 

1509 # both bufferView *and* accessors are allowed 

1510 # to have a byteOffset 

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

1512 

1513 if "byteStride" in buffer_view: 

1514 # how many bytes for each chunk 

1515 stride = buffer_view["byteStride"] 

1516 # we want to get the bytes for every row 

1517 per_row = per_count * dtype.itemsize 

1518 # the total block we're looking at 

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

1520 # apply as_strided for fast construction of strided array 

1521 # and copy to ensure contiguous layout 

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

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

1524 access[index] = np.array( 

1525 np.lib.stride_tricks.as_strided( 

1526 np.frombuffer( 

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

1528 ), 

1529 [count, per_row], 

1530 [stride, 1], 

1531 ) 

1532 .view(dtype) 

1533 .reshape(shape) 

1534 ) 

1535 else: 

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

1537 length = dtype.itemsize * count * per_count 

1538 access[index] = np.frombuffer( 

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

1540 ).reshape(shape) 

1541 else: 

1542 # zero placeholder a decoder may replace 

1543 if "sparse" not in a: 

1544 placeholders.add(index) 

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

1546 

1547 # possibly load images and textures into material objects 

1548 if skip_materials: 

1549 materials = [] 

1550 else: 

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

1552 

1553 mesh_prim = defaultdict(list) 

1554 # load data from accessors into Trimesh objects 

1555 meshes = OrderedDict() 

1556 

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

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

1559 # dict of names 

1560 name_counts = {} 

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

1562 undecoded = set() 

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

1564 try: 

1565 # GLTF spec indicates implicit units are meters 

1566 metadata = { 

1567 "units": "meters", 

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

1569 } 

1570 

1571 # try to load all mesh metadata 

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

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

1574 

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

1576 if "extensions" in m: 

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

1578 

1579 for p in m["primitives"]: 

1580 # preprocessing extensions like draco decompression run 

1581 # before reading accessors as they may modify them 

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

1583 handle_extensions( 

1584 extensions=prim_extensions, 

1585 scope="primitive_preprocess", 

1586 primitive=p, 

1587 accessors=access, 

1588 views=views, 

1589 ) 

1590 # warn later if an unhandled extension left placeholder zeros 

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

1592 undecoded.update( 

1593 unregistered(prim_extensions, "primitive_preprocess") 

1594 ) 

1595 

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

1597 # if not specified assume it is a mesh 

1598 kwargs = deepcopy(mesh_kwargs) 

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

1600 kwargs["metadata"] = {} 

1601 if "process" not in kwargs: 

1602 kwargs["process"] = False 

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

1604 # i.e. GL_LINES, GL_TRIANGLES, etc 

1605 # specification says the default mode is GL_TRIANGLES 

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

1607 # colors, normals, etc 

1608 attr = p["attributes"] 

1609 # create a unique mesh name per- primitive 

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

1611 # make name unique across multiple meshes 

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

1613 

1614 if mode == _GL_LINES: 

1615 # load GL_LINES into a Path object 

1616 from ...path.entities import Line 

1617 

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

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

1620 

1621 # custom attributes starting with a `_` 

1622 custom = { 

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

1624 } 

1625 if len(custom) > 0: 

1626 kwargs["vertex_attributes"] = custom 

1627 elif mode == _GL_POINTS: 

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

1629 visuals = None 

1630 if "COLOR_0" in attr: 

1631 try: 

1632 # try to load vertex colors from the accessors 

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

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

1635 if visuals is None: 

1636 # just pass to mesh as vertex color 

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

1638 else: 

1639 # we ALSO have texture so save as vertex 

1640 # attribute 

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

1642 except BaseException: 

1643 # survive failed colors 

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

1645 if visuals is not None: 

1646 kwargs["visual"] = visuals 

1647 elif mode in (_GL_TRIANGLES, _GL_STRIP): 

1648 # get vertices from accessors 

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

1650 # get faces from accessors 

1651 if "indices" in p: 

1652 if mode == _GL_STRIP: 

1653 # this is triangle strips 

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

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

1656 else: 

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

1658 else: 

1659 # indices are apparently optional and we are supposed to 

1660 # do the same thing as webGL drawArrays? 

1661 if mode == _GL_STRIP: 

1662 kwargs["faces"] = triangle_strips_to_faces( 

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

1664 ) 

1665 else: 

1666 # GL_TRIANGLES 

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

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

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

1670 

1671 if "NORMAL" in attr: 

1672 # vertex normals are specified 

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

1674 # do we have UV coordinates 

1675 visuals = None 

1676 if "material" in p and not skip_materials: 

1677 if materials is None: 

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

1679 else: 

1680 uv = None 

1681 if "TEXCOORD_0" in attr: 

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

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

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

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

1686 # create a texture visual 

1687 visuals = visual.texture.TextureVisuals( 

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

1689 ) 

1690 

1691 if "COLOR_0" in attr: 

1692 try: 

1693 # try to load vertex colors from the accessors 

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

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

1696 if visuals is None: 

1697 # just pass to mesh as vertex color 

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

1699 else: 

1700 # we ALSO have texture so save as vertex 

1701 # attribute 

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

1703 except BaseException: 

1704 # survive failed colors 

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

1706 if visuals is not None: 

1707 kwargs["visual"] = visuals 

1708 

1709 # custom attributes starting with a `_` 

1710 custom = { 

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

1712 } 

1713 if len(custom) > 0: 

1714 kwargs["vertex_attributes"] = custom 

1715 

1716 # Process primitive-level extensions through registry 

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

1718 handle_extensions( 

1719 extensions=prim_extensions, 

1720 scope="primitive", 

1721 primitive=p, 

1722 mesh_kwargs=kwargs, 

1723 accessors=access, 

1724 ) 

1725 else: 

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

1727 continue 

1728 # this should absolutely not be stomping on itself 

1729 assert name not in meshes 

1730 meshes[name] = kwargs 

1731 mesh_prim[index].append(name) 

1732 except BaseException as E: 

1733 if ignore_broken: 

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

1735 else: 

1736 raise E 

1737 

1738 if undecoded: 

1739 log.warning( 

1740 "`%s` GLTF extension has no handler, values are placeholder zeros", 

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

1742 ) 

1743 

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

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

1746 # but if merge_primitives is True we combine the primitives 

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

1748 if merge_primitives: 

1749 # if we are only returning one Trimesh object 

1750 # replace `mesh_prim` with updated values 

1751 mesh_prim_replace = {} 

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

1753 mesh_pop = set() 

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

1755 if len(names) <= 1: 

1756 mesh_prim_replace[mesh_index] = names 

1757 continue 

1758 

1759 # just take the shortest name option available 

1760 name = min(names) 

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

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

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

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

1765 

1766 # get all meshes for this group 

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

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

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

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

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

1772 face_materials = [] 

1773 for i, p in enumerate(current): 

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

1775 visuals = visual.texture.TextureVisuals( 

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

1777 face_materials=face_materials, 

1778 ) 

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

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

1781 else: 

1782 metadata = {} 

1783 meshes[name] = { 

1784 "vertices": v, 

1785 "faces": f, 

1786 "visual": visuals, 

1787 "metadata": metadata, 

1788 "process": False, 

1789 } 

1790 mesh_prim_replace[mesh_index] = [name] 

1791 # avoid altering inside loop 

1792 mesh_prim = mesh_prim_replace 

1793 # remove outdated meshes 

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

1795 

1796 # make it easier to reference nodes 

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

1798 # nodes are referenced by index 

1799 # save their string names if they have one 

1800 # we have to accumulate in a for loop opposed 

1801 # to a dict comprehension as it will be checking 

1802 # the mutated dict in every loop 

1803 name_index = {} 

1804 name_counts = {} 

1805 

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

1807 # name_index: {name: index} 

1808 for i, n in enumerate(nodes): 

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

1810 # names: {index: name} 

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

1812 

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

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

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

1816 world = name_index.get(DEFAULT_BASE_FRAME) 

1817 if world is not None: 

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

1819 

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

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

1822 names[DEFAULT_BASE_FRAME] = DEFAULT_BASE_FRAME 

1823 

1824 # visited, kwargs for scene.graph.update 

1825 graph = deque() 

1826 # unvisited, pairs of node indexes 

1827 queue = deque() 

1828 

1829 # camera(s), if they exist 

1830 camera = None 

1831 camera_transform = None 

1832 

1833 if "scene" in header: 

1834 # specify the index of scenes if specified 

1835 scene_index = header["scene"] 

1836 else: 

1837 # otherwise just use the first index 

1838 scene_index = 0 

1839 

1840 if "scenes" in header: 

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

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

1843 # add transform from base frame to these root nodes 

1844 queue.append((DEFAULT_BASE_FRAME, root)) 

1845 

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

1847 consumed = set() 

1848 

1849 # go through the nodes tree to populate 

1850 # kwargs for scene graph loader 

1851 while len(queue) > 0: 

1852 # (int, int) pair of node indexes 

1853 edge = queue.pop() 

1854 

1855 # avoid looping forever if someone specified 

1856 # recursive nodes 

1857 if edge in consumed: 

1858 continue 

1859 

1860 consumed.add(edge) 

1861 a, b = edge 

1862 

1863 # dict of child node 

1864 # parent = nodes[a] 

1865 child = nodes[b] 

1866 # add edges of children to be processed 

1867 if "children" in child: 

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

1869 

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

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

1872 

1873 # grab matrix from child 

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

1875 # for the transform from parent to child 

1876 if "matrix" in child: 

1877 kwargs["matrix"] = ( 

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

1879 ) 

1880 else: 

1881 # if no matrix set identity 

1882 kwargs["matrix"] = _EYE 

1883 

1884 # Now apply keyword translations 

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

1886 if "translation" in child: 

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

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

1889 ) 

1890 if "rotation" in child: 

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

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

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

1894 # add the rotation to the matrix 

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

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

1897 ) 

1898 if "scale" in child: 

1899 # add scale to the matrix 

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

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

1902 ) 

1903 

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

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

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

1907 # TODO will only read perspective camera 

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

1909 cam_idx = child["camera"] 

1910 try: 

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

1912 except KeyError: 

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

1914 if camera: 

1915 camera_transform = kwargs["matrix"] 

1916 continue 

1917 

1918 # treat node metadata similarly to mesh metadata 

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

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

1921 

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

1923 if "extensions" in child: 

1924 if "metadata" not in kwargs: 

1925 kwargs["metadata"] = {} 

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

1927 

1928 if "mesh" in child: 

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

1930 

1931 # if the node has a mesh associated with it 

1932 if len(geometries) > 1: 

1933 # append root node 

1934 graph.append(kwargs.copy()) 

1935 # put primitives as children 

1936 for geom_name in geometries: 

1937 # save the name of the geometry 

1938 kwargs["geometry"] = geom_name 

1939 # no transformations 

1940 kwargs["matrix"] = _EYE 

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

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

1943 # frame name for the primitives after the first one 

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

1945 kwargs["frame_to"] = frame_to 

1946 # append the edge with the mesh frame 

1947 graph.append(kwargs.copy()) 

1948 elif len(geometries) == 1: 

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

1950 if "name" in child: 

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

1952 graph.append(kwargs.copy()) 

1953 else: 

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

1955 graph.append(kwargs) 

1956 

1957 # kwargs for load_kwargs 

1958 result = { 

1959 "class": "Scene", 

1960 "geometry": meshes, 

1961 "graph": graph, 

1962 "base_frame": DEFAULT_BASE_FRAME, 

1963 "camera": camera, 

1964 "camera_transform": camera_transform, 

1965 "metadata": {}, 

1966 } 

1967 

1968 try: 

1969 # load any scene extras into scene.metadata 

1970 # use a try except to avoid nested key checks 

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

1972 except BaseException: 

1973 pass 

1974 try: 

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

1976 # use a try except to avoid nested key checks 

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

1978 except BaseException: 

1979 pass 

1980 

1981 return result 

1982 

1983 

1984def _cam_from_gltf(cam): 

1985 """ 

1986 Convert a gltf perspective camera to trimesh. 

1987 

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

1989 does not contain it. 

1990 

1991 If the camera is not perspective will return None. 

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

1993 

1994 Parameters 

1995 ------------ 

1996 cam : dict 

1997 Camera represented as a dictionary according to glTF 

1998 

1999 Returns 

2000 ------------- 

2001 camera : trimesh.scene.cameras.Camera 

2002 Trimesh camera object 

2003 """ 

2004 if "perspective" not in cam: 

2005 return 

2006 name = cam.get("name") 

2007 znear = cam["perspective"]["znear"] 

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

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

2010 

2011 fov = (aspect_ratio * yfov, yfov) 

2012 

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

2014 

2015 

2016def _convert_camera(camera): 

2017 """ 

2018 Convert a trimesh camera to a GLTF camera. 

2019 

2020 Parameters 

2021 ------------ 

2022 camera : trimesh.scene.cameras.Camera 

2023 Trimesh camera object 

2024 

2025 Returns 

2026 ------------- 

2027 gltf_camera : dict 

2028 Camera represented as a GLTF dict 

2029 """ 

2030 result = { 

2031 "name": camera.name, 

2032 "type": "perspective", 

2033 "perspective": { 

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

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

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

2037 }, 

2038 } 

2039 return result 

2040 

2041 

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

2043 """ 

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

2045 

2046 Parameters 

2047 ------------ 

2048 img : PIL.Image 

2049 Image object 

2050 tree : dict 

2051 GLTF 2.0 format tree 

2052 buffer_items : (n,) bytes 

2053 Binary blobs containing data 

2054 extension_webp : bool 

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

2056 

2057 Returns 

2058 ----------- 

2059 index : int or None 

2060 The index of the image in the tree 

2061 None if image append failed for any reason 

2062 """ 

2063 # probably not a PIL image so exit 

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

2065 return None 

2066 

2067 if extension_webp: 

2068 # support WebP if extension is specified 

2069 save_as = "WEBP" 

2070 elif img.format == "JPEG": 

2071 # don't re-encode JPEGs 

2072 save_as = "JPEG" 

2073 else: 

2074 # for everything else just use PNG 

2075 save_as = "png" 

2076 

2077 # get the image data into a bytes object 

2078 with util.BytesIO() as f: 

2079 img.save(f, format=save_as) 

2080 f.seek(0) 

2081 data = f.read() 

2082 

2083 index = _buffer_append(buffer_items, data) 

2084 # append buffer index and the GLTF-acceptable mimetype 

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

2086 

2087 # index is length minus one 

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

2089 

2090 

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

2092 """ 

2093 Add passed PBRMaterial as GLTF 2.0 specification JSON 

2094 serializable data: 

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

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

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

2098 

2099 Parameters 

2100 ------------ 

2101 mat : trimesh.visual.materials.PBRMaterials 

2102 Source material to convert 

2103 tree : dict 

2104 GLTF header blob 

2105 buffer_items : (n,) bytes 

2106 Binary blobs with various data 

2107 mat_hashes : dict 

2108 Which materials have already been added 

2109 Stored as { hashed : material index } 

2110 extension_webp : bool 

2111 Export textures as webP using EXT_texture_webp extension. 

2112 

2113 Returns 

2114 ------------- 

2115 index : int 

2116 Index at which material was added 

2117 """ 

2118 # materials are hashable 

2119 hashed = hash(mat) 

2120 # check stored material indexes to see if material 

2121 # has already been added 

2122 if mat_hashes is not None and hashed in mat_hashes: 

2123 return mat_hashes[hashed] 

2124 

2125 # convert passed input to PBR if necessary 

2126 if hasattr(mat, "to_pbr"): 

2127 as_pbr = mat.to_pbr() 

2128 else: 

2129 as_pbr = mat 

2130 

2131 # a default PBR metallic material 

2132 result = {"pbrMetallicRoughness": {}} 

2133 try: 

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

2135 result["baseColorFactor"] = ( 

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

2137 ) 

2138 except BaseException: 

2139 pass 

2140 

2141 try: 

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

2143 except BaseException: 

2144 pass 

2145 

2146 # if name is defined, export 

2147 if isinstance(as_pbr.name, str): 

2148 result["name"] = as_pbr.name 

2149 

2150 # if alphaMode is defined, export 

2151 if isinstance(as_pbr.alphaMode, str): 

2152 result["alphaMode"] = as_pbr.alphaMode 

2153 

2154 # if alphaCutoff is defined, export 

2155 if isinstance(as_pbr.alphaCutoff, float): 

2156 result["alphaCutoff"] = as_pbr.alphaCutoff 

2157 

2158 # if doubleSided is defined, export 

2159 if isinstance(as_pbr.doubleSided, bool): 

2160 result["doubleSided"] = as_pbr.doubleSided 

2161 

2162 # if scalars are defined correctly export 

2163 if isinstance(as_pbr.metallicFactor, float): 

2164 result["metallicFactor"] = as_pbr.metallicFactor 

2165 if isinstance(as_pbr.roughnessFactor, float): 

2166 result["roughnessFactor"] = as_pbr.roughnessFactor 

2167 

2168 # which keys of the PBRMaterial are images 

2169 image_mapping = { 

2170 "baseColorTexture": as_pbr.baseColorTexture, 

2171 "emissiveTexture": as_pbr.emissiveTexture, 

2172 "normalTexture": as_pbr.normalTexture, 

2173 "occlusionTexture": as_pbr.occlusionTexture, 

2174 "metallicRoughnessTexture": as_pbr.metallicRoughnessTexture, 

2175 } 

2176 

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

2178 if img is None: 

2179 continue 

2180 # try adding the base image to the export object 

2181 index = _append_image( 

2182 img=img, tree=tree, buffer_items=buffer_items, extension_webp=extension_webp 

2183 ) 

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

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

2186 if index is not None: 

2187 # add a reference to the base color texture 

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

2189 

2190 # add texture object, optionally using EXT_texture_webp 

2191 if extension_webp: 

2192 tree["textures"].append( 

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

2194 ) 

2195 else: 

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

2197 

2198 # for our PBRMaterial object we flatten all keys 

2199 # however GLTF would like some of them under the 

2200 # "pbrMetallicRoughness" key 

2201 pbr_subset = [ 

2202 "baseColorTexture", 

2203 "baseColorFactor", 

2204 "roughnessFactor", 

2205 "metallicFactor", 

2206 "metallicRoughnessTexture", 

2207 ] 

2208 # move keys down a level 

2209 for key in pbr_subset: 

2210 if key in result: 

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

2212 

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

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

2215 result.pop("pbrMetallicRoughness") 

2216 

2217 # which index are we inserting material at 

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

2219 # add the material to the data structure 

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

2221 # add the material index in-place 

2222 mat_hashes[hashed] = index 

2223 

2224 return index 

2225 

2226 

2227def validate(header): 

2228 """ 

2229 Validate a GLTF 2.0 header against the schema. 

2230 

2231 Returns result from: 

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

2233 

2234 Parameters 

2235 ------------- 

2236 header : dict 

2237 Populated GLTF 2.0 header 

2238 

2239 Raises 

2240 -------------- 

2241 err : jsonschema.exceptions.ValidationError 

2242 If the tree is an invalid GLTF2.0 header 

2243 """ 

2244 # a soft dependency 

2245 import jsonschema 

2246 

2247 # will do the reference replacement 

2248 schema = get_schema() 

2249 # validate the passed header against the schema 

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

2251 

2252 return valid 

2253 

2254 

2255def get_schema(): 

2256 """ 

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

2258 

2259 Returns 

2260 ------------ 

2261 schema : dict 

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

2263 """ 

2264 # replace references 

2265 # get zip resolver to access referenced assets 

2266 from ...schemas import resolve 

2267 

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

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

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

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

2272 # get a resolver object for accessing the schema 

2273 resolver = ZipResolver(archive) 

2274 # get a loaded dict from the base file 

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

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

2277 schema = resolve(unresolved, resolver=resolver) 

2278 

2279 return schema 

2280 

2281 

2282# exporters 

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