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

770 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-24 04:40 +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 ...typed import NDArray, Stream 

22from ...util import triangle_strips_to_faces, unique_name 

23from .extensions import handle_extensions 

24 

25# magic numbers which have meaning in GLTF 

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

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

28 

29# GLTF data type codes: little endian numpy dtypes 

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

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

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

33 

34 

35# GLTF data formats: numpy shapes 

36_shapes = { 

37 "SCALAR": 1, 

38 "VEC2": (2), 

39 "VEC3": (3), 

40 "VEC4": (4), 

41 "MAT2": (2, 2), 

42 "MAT3": (3, 3), 

43 "MAT4": (4, 4), 

44} 

45 

46# a default PBR metallic material 

47_default_material = { 

48 "pbrMetallicRoughness": { 

49 "baseColorFactor": [1, 1, 1, 1], 

50 "metallicFactor": 0, 

51 "roughnessFactor": 0, 

52 } 

53} 

54 

55# GL geometry modes 

56_GL_LINES = 1 

57_GL_POINTS = 0 

58_GL_TRIANGLES = 4 

59_GL_STRIP = 5 

60 

61_EYE = np.eye(4) 

62_EYE.flags.writeable = False 

63 

64# specify dtypes with forced little endian 

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

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

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

68 

69 

70def export_gltf( 

71 scene, 

72 include_normals=None, 

73 merge_buffers=False, 

74 unitize_normals=True, 

75 tree_postprocessor=None, 

76 embed_buffers=False, 

77 extension_webp=False, 

78 extension_draco=False, 

79): 

80 """ 

81 Export a scene object as a GLTF directory. 

82 

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

84 as opposed to one larger file. 

85 

86 Parameters 

87 ----------- 

88 scene : trimesh.Scene 

89 Scene to be exported 

90 include_normals : None or bool 

91 Include vertex normals 

92 merge_buffers : bool 

93 Merge buffers into one blob. 

94 unitize_normals 

95 GLTF requires unit normals, however sometimes people 

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

97 resolver : trimesh.resolvers.Resolver 

98 If passed will use to write each file. 

99 tree_postprocesser : None or callable 

100 Run this on the header tree before exiting. 

101 embed_buffers : bool 

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

103 extension_webp : bool 

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

105 extension_draco : bool 

106 Compress mesh data using Draco (KHR_draco_mesh_compression). 

107 Requires the `dracox` package to be installed. 

108 

109 Returns 

110 ---------- 

111 export : dict 

112 Format: {file name : file data} 

113 """ 

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

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

116 scene = scene.scene() 

117 

118 # create the header and buffer data 

119 tree, buffer_items = _create_gltf_structure( 

120 scene=scene, 

121 unitize_normals=unitize_normals, 

122 include_normals=include_normals, 

123 extension_webp=extension_webp, 

124 extension_draco=extension_draco, 

125 ) 

126 

127 # allow custom postprocessing 

128 if tree_postprocessor is not None: 

129 tree_postprocessor(tree) 

130 

131 # store files as {name : data} 

132 files = {} 

133 

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

135 if merge_buffers: 

136 views = _build_views(buffer_items) 

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

138 if embed_buffers: 

139 buffer_name = base64_buffer_format.format( 

140 base64.b64encode(buffer_data).decode() 

141 ) 

142 else: 

143 buffer_name = "gltf_buffer.bin" 

144 files[buffer_name] = buffer_data 

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

146 else: 

147 # make one buffer per buffer_items 

148 buffers = [None] * len(buffer_items) 

149 # A bufferView is a slice of a file 

150 views = [None] * len(buffer_items) 

151 # create the buffer views 

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

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

154 if embed_buffers: 

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

156 else: 

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

158 files[buffer_name] = item 

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

160 

161 if len(buffers) > 0: 

162 tree["buffers"] = buffers 

163 tree["bufferViews"] = views 

164 # dump tree with compact separators 

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

166 

167 if tol.strict: 

168 validate(tree) 

169 

170 return files 

171 

172 

173def export_glb( 

174 scene, 

175 include_normals=None, 

176 unitize_normals=True, 

177 tree_postprocessor=None, 

178 buffer_postprocessor=None, 

179 extension_webp=False, 

180 extension_draco=False, 

181): 

182 """ 

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

184 

185 Parameters 

186 ------------ 

187 scene: trimesh.Scene 

188 Input geometry 

189 extras : JSON serializable 

190 Will be stored in the extras field. 

191 include_normals : bool 

192 Include vertex normals in output file? 

193 tree_postprocessor : func 

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

195 before exporting. 

196 extension_webp : bool 

197 Export textures as webP using EXT_texture_webp extension. 

198 extension_draco : bool 

199 Compress mesh data using Draco (KHR_draco_mesh_compression). 

200 Requires the `dracox` package to be installed. 

201 

202 Returns 

203 ---------- 

204 exported : bytes 

205 Exported result in GLB 2.0 

206 """ 

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

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

209 # generate a scene with just that mesh in it 

210 scene = scene.scene() 

211 

212 tree, buffer_items = _create_gltf_structure( 

213 scene=scene, 

214 unitize_normals=unitize_normals, 

215 include_normals=include_normals, 

216 buffer_postprocessor=buffer_postprocessor, 

217 extension_webp=extension_webp, 

218 extension_draco=extension_draco, 

219 ) 

220 

221 # A bufferView is a slice of a file 

222 views = _build_views(buffer_items) 

223 

224 # combine bytes into a single blob 

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

226 

227 # add the information about the buffer data 

228 if len(buffer_data) > 0: 

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

230 tree["bufferViews"] = views 

231 

232 # allow custom postprocessing 

233 if tree_postprocessor is not None: 

234 tree_postprocessor(tree) 

235 

236 # export the tree to JSON for the header 

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

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

239 # is 4 byte aligned as per spec 

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

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

242 # make sure we didn't screw it up 

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

244 

245 # the initial header of the file 

246 header = _byte_pad( 

247 np.array( 

248 [ 

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

250 2, # GLTF version 

251 # length is the total length of the Binary glTF 

252 # including Header and all Chunks, in bytes. 

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

254 # contentLength is the length, in bytes, 

255 # of the glTF content (JSON) 

256 len(content), 

257 # magic number which is 'JSON' 

258 _magic["json"], 

259 ], 

260 dtype="<u4", 

261 ).tobytes() 

262 ) 

263 

264 # the header of the binary data section 

265 bin_header = _byte_pad( 

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

267 ) 

268 

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

270 

271 if tol.strict: 

272 validate(tree) 

273 

274 return exported 

275 

276 

277def load_gltf( 

278 file_obj: Stream | None = None, 

279 resolver: ResolverLike | None = None, 

280 ignore_broken: bool = False, 

281 merge_primitives: bool = False, 

282 skip_materials: bool = False, 

283 **mesh_kwargs, 

284): 

285 """ 

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

287 with multiple files. 

288 

289 Parameters 

290 ------------- 

291 file_obj : None or file-like 

292 Object containing header JSON, or None 

293 resolver : trimesh.visual.Resolver 

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

295 ignore_broken : bool 

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

297 is True don't raise an exception but return 

298 a partial result 

299 merge_primitives : bool 

300 If True, each GLTF 'mesh' will correspond 

301 to a single Trimesh object 

302 skip_materials : bool 

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

304 **mesh_kwargs : dict 

305 Passed to mesh constructor 

306 

307 Returns 

308 -------------- 

309 kwargs : dict 

310 Arguments to create scene 

311 """ 

312 try: 

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

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

315 except BaseException: 

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

317 data = resolver["model.gltf"] 

318 # old versions of python/json need strings 

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

320 

321 # gltf 1.0 is a totally different format 

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

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

324 if isinstance(version, str): 

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

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

327 else: 

328 major = int(float(version)) 

329 

330 if major < 2: 

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

332 

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

334 buffers = [ 

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

336 ] 

337 

338 # turn the layout header and data into kwargs 

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

340 kwargs = _read_buffers( 

341 header=tree, 

342 buffers=buffers, 

343 ignore_broken=ignore_broken, 

344 merge_primitives=merge_primitives, 

345 mesh_kwargs=mesh_kwargs, 

346 skip_materials=skip_materials, 

347 resolver=resolver, 

348 ) 

349 return kwargs 

350 

351 

352def load_glb( 

353 file_obj: Stream, 

354 resolver: ResolverLike | None = None, 

355 ignore_broken: bool = False, 

356 merge_primitives: bool = False, 

357 skip_materials: bool = False, 

358 **mesh_kwargs, 

359): 

360 """ 

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

362 

363 Implemented from specification: 

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

365 

366 Parameters 

367 ------------ 

368 file_obj : file- like object 

369 Containing GLB data 

370 resolver : trimesh.visual.Resolver 

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

372 ignore_broken : bool 

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

374 is True don't raise an exception but return 

375 a partial result 

376 merge_primitives : bool 

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

378 single Trimesh object. 

379 skip_materials : bool 

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

381 

382 Returns 

383 ------------ 

384 kwargs : dict 

385 Kwargs to instantiate a trimesh.Scene 

386 """ 

387 # read the first 20 bytes which contain section lengths 

388 head_data = file_obj.read(20) 

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

390 

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

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

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

394 

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

396 if head[1] != 2: 

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

398 

399 # overall file length 

400 # first chunk length 

401 # first chunk type 

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

403 

404 # first chunk should be JSON header 

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

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

407 

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

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

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

411 # convert to text 

412 if hasattr(json_data, "decode"): 

413 json_data = util.decode_text(json_data) 

414 # load the json header to native dict 

415 header = json.loads(json_data) 

416 

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

418 buffers = [] 

419 start = file_obj.tell() 

420 

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

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

423 

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

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

426 try: 

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

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

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

430 continue 

431 except (IndexError, KeyError): 

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

433 pass 

434 

435 # the last read put us past the JSON chunk 

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

437 chunk_head = file_obj.read(8) 

438 if len(chunk_head) != 8: 

439 # double check to make sure we didn't 

440 # read the whole file 

441 break 

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

443 # make sure we have the right data type 

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

445 raise ValueError("not binary GLTF!") 

446 # read the chunk 

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

448 if len(chunk_data) != chunk_length: 

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

450 buffers.append(chunk_data) 

451 

452 # turn the layout header and data into kwargs 

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

454 kwargs = _read_buffers( 

455 header=header, 

456 buffers=buffers, 

457 ignore_broken=ignore_broken, 

458 merge_primitives=merge_primitives, 

459 skip_materials=skip_materials, 

460 mesh_kwargs=mesh_kwargs, 

461 resolver=resolver, 

462 ) 

463 

464 return kwargs 

465 

466 

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

468 """ 

469 Take a URI string and load it as a 

470 a filename or as base64. 

471 

472 Parameters 

473 -------------- 

474 uri 

475 Usually a filename or something like: 

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

477 resolver 

478 A resolver to load referenced assets 

479 

480 Returns 

481 --------------- 

482 data 

483 Loaded data from URI 

484 """ 

485 # see if the URI has base64 data 

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

487 if index < 0: 

488 # string didn't contain the base64 header 

489 # so return the result from the resolver 

490 return resolver[uri] 

491 # strip the base64 header — cap the encoded length against the decompress 

492 # limit (4 b64 chars per 3 raw bytes) to bound the decoded size 

493 payload = uri[index + 7 :] 

494 if len(payload) > util.MAX_ARCHIVE_SIZE * 4 // 3 + 4: 

495 raise ValueError("gltf base64 payload exceeds size cap") 

496 return base64.b64decode(payload) 

497 

498 

499def _buffer_append(ordered, data): 

500 """ 

501 Append data to an existing OrderedDict and 

502 pad it to a 4-byte boundary. 

503 

504 Parameters 

505 ---------- 

506 od : OrderedDict 

507 Keyed like { hash : data } 

508 data : bytes 

509 To be stored 

510 

511 Returns 

512 ---------- 

513 index : int 

514 Index of buffer_items stored in 

515 """ 

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

517 hashed = hash_fast(data) 

518 if hashed in ordered: 

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

520 return list(ordered.keys()).index(hashed) 

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

522 ordered[hashed] = _byte_pad(data) 

523 

524 return len(ordered) - 1 

525 

526 

527def _data_append(acc: OrderedDict, buff: OrderedDict, blob: dict, data: NDArray): 

528 """ 

529 Append a new accessor to an OrderedDict. 

530 

531 Parameters 

532 ------------ 

533 acc 

534 Collection of accessors, will be mutated in-place 

535 buff 

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

537 blob 

538 Candidate accessor 

539 data 

540 Data to fill in details to blob 

541 

542 Returns 

543 ---------- 

544 index : int 

545 Index of accessor that was added or reused. 

546 """ 

547 # if we have data include that in the key 

548 as_bytes = data.tobytes() 

549 if hasattr(data, "hash_fast"): 

550 # passed a TrackedArray object 

551 hashed = data.hash_fast() 

552 else: 

553 # someone passed a vanilla numpy array 

554 hashed = hash_fast(as_bytes) 

555 

556 if hashed in buff: 

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

558 else: 

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

560 buff[hashed] = _byte_pad(as_bytes) 

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

562 

563 # start by hashing the dict blob 

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

565 try: 

566 # simple keys can be hashed as tuples without JSON 

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

568 except BaseException: 

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

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

571 

572 # xor the hash for the blob to the key 

573 key ^= hashed 

574 

575 # if key exists return the index in the OrderedDict 

576 if key in acc: 

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

578 

579 # get a numpy dtype for our components 

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

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

582 kind = blob["type"] 

583 

584 if tol.strict: 

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

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

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

588 

589 if kind == "SCALAR": 

590 # is probably (n, 1) 

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

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

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

594 elif kind.startswith("MAT"): 

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

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

597 else: 

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

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

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

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

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

603 

604 # store the accessor and return the index 

605 acc[key] = blob 

606 return len(acc) - 1 

607 

608 

609def _jsonify(blob): 

610 """ 

611 Roundtrip a blob through json export-import cycle 

612 skipping any internal keys. 

613 """ 

614 return json.loads( 

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

616 ) 

617 

618 

619def _create_gltf_structure( 

620 scene, 

621 include_normals=None, 

622 include_metadata=True, 

623 unitize_normals=None, 

624 buffer_postprocessor=None, 

625 extension_webp=False, 

626 extension_draco=False, 

627): 

628 """ 

629 Generate a GLTF header. 

630 

631 Parameters 

632 ------------- 

633 scene : trimesh.Scene 

634 Input scene data 

635 include_metadata : bool 

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

637 include_normals : bool 

638 Include vertex normals in output file? 

639 unitize_normals : bool 

640 Unitize all exported normals so as to pass GLTF validation 

641 extension_webp : bool 

642 Export textures as webP using EXT_texture_webp extension. 

643 extension_draco : bool 

644 Compress mesh data using Draco (KHR_draco_mesh_compression). 

645 

646 Returns 

647 --------------- 

648 tree : dict 

649 Contains required keys for a GLTF scene 

650 buffer_items : list 

651 Contains bytes of data 

652 """ 

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

654 # world node to the 0-index 

655 tree = { 

656 "scene": 0, 

657 "scenes": [{"nodes": [0]}], 

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

659 "accessors": OrderedDict(), 

660 "meshes": [], 

661 "images": [], 

662 "textures": [], 

663 "materials": [], 

664 } 

665 

666 if scene.has_camera: 

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

668 

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

670 try: 

671 # fail here if data isn't json compatible 

672 # only export the extras if there is something there 

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

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

675 if isinstance(extensions, dict): 

676 tree["extensions"] = extensions 

677 except BaseException: 

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

679 

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

681 mat_hashes = {} 

682 # store data from geometries 

683 buffer_items = OrderedDict() 

684 

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

686 mesh_index = {} 

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

688 

689 # loop through every geometry 

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

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

692 # add the mesh 

693 _append_mesh( 

694 mesh=geometry, 

695 name=name, 

696 tree=tree, 

697 buffer_items=buffer_items, 

698 include_normals=include_normals, 

699 unitize_normals=unitize_normals, 

700 mat_hashes=mat_hashes, 

701 extension_webp=extension_webp, 

702 extension_draco=extension_draco, 

703 ) 

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

705 # add Path2D and Path3D objects 

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

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

708 # add PointCloud objects 

709 _append_point( 

710 points=geometry, name=name, tree=tree, buffer_items=buffer_items 

711 ) 

712 

713 # only store the index if the append did anything 

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

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

716 mesh_index[name] = previous - 1 

717 

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

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

720 tree.update(nodes) 

721 

722 extensions_used = set() 

723 extensions_required = set() 

724 # Add any scene extensions used 

725 if "extensions" in tree: 

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

727 # Add any mesh extensions used 

728 for mesh in tree["meshes"]: 

729 if "extensions" in mesh: 

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

731 # Check primitives for extensions too 

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

733 if "extensions" in prim: 

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

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

736 if "extensionsUsed" in tree: 

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

738 # Add WebP if used 

739 if extension_webp: 

740 extensions_used.add("EXT_texture_webp") 

741 extensions_required.add("EXT_texture_webp") 

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

743 if extension_draco: 

744 extensions_used.add("KHR_draco_mesh_compression") 

745 extensions_required.add("KHR_draco_mesh_compression") 

746 if len(extensions_used) > 0: 

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

748 if len(extensions_required) > 0: 

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

750 

751 if buffer_postprocessor is not None: 

752 buffer_postprocessor(buffer_items, tree) 

753 

754 # convert accessors back to a flat list 

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

756 

757 # cull empty or unpopulated fields 

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

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

760 # remove the keys with nothing stored in them 

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

762 

763 return tree, buffer_items 

764 

765 

766def _append_mesh( 

767 mesh, 

768 name, 

769 tree, 

770 buffer_items, 

771 include_normals: bool | None, 

772 unitize_normals: bool, 

773 mat_hashes: dict, 

774 extension_webp: bool, 

775 extension_draco: bool = False, 

776): 

777 """ 

778 Append a mesh to the scene structure and put the 

779 data into buffer_items. 

780 

781 Parameters 

782 ------------- 

783 mesh : trimesh.Trimesh 

784 Source geometry 

785 name : str 

786 Name of geometry 

787 tree : dict 

788 Will be updated with data from mesh 

789 buffer_items 

790 Will have buffer appended with mesh data 

791 include_normals : bool 

792 Include vertex normals in export or not 

793 unitize_normals : bool 

794 Transform normals into unit vectors. 

795 May be undesirable but will fail validators without this. 

796 

797 mat_hashes : dict 

798 Which materials have already been added 

799 extension_webp : bool 

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

801 extension_draco : bool 

802 Compress mesh data using Draco (KHR_draco_mesh_compression). 

803 """ 

804 # return early from empty meshes to avoid crashing later 

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

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

807 return 

808 # convert mesh data to the correct dtypes 

809 # faces: 5125 is an unsigned 32 bit integer 

810 # accessors refer to data locations 

811 # mesh faces are stored as flat list of integers 

812 acc_face = _data_append( 

813 acc=tree["accessors"], 

814 buff=buffer_items, 

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

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

817 ) 

818 

819 # vertices: 5126 is a float32 

820 # create or reuse an accessor for these vertices 

821 acc_vertex = _data_append( 

822 acc=tree["accessors"], 

823 buff=buffer_items, 

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

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

826 ) 

827 

828 # meshes reference accessor indexes 

829 current = { 

830 "name": name, 

831 "extras": {}, 

832 "primitives": [ 

833 { 

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

835 "indices": acc_face, 

836 "mode": _GL_TRIANGLES, 

837 } 

838 ], 

839 } 

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

841 # the GLTF spec says everything is implicit meters 

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

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

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

845 try: 

846 # skip jsonify any metadata, skipping internal keys 

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

848 

849 # extract extensions if any 

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

851 if isinstance(extensions, dict): 

852 current["extensions"] = extensions 

853 

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

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

856 except BaseException: 

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

858 

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

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

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

862 vertex_colors = mesh.visual.vertex_colors 

863 elif ( 

864 hasattr(mesh.visual, "vertex_attributes") 

865 and "color" in mesh.visual.vertex_attributes 

866 ): 

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

868 else: 

869 vertex_colors = None 

870 

871 if vertex_colors is not None: 

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

873 # convert color data to bytes and append 

874 acc_color = _data_append( 

875 acc=tree["accessors"], 

876 buff=buffer_items, 

877 blob={ 

878 "componentType": 5121, 

879 "normalized": True, 

880 "type": "VEC4", 

881 "byteOffset": 0, 

882 }, 

883 data=vertex_colors.astype(uint8), 

884 ) 

885 

886 # add the reference for vertex color 

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

888 else: 

889 log.warning( 

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

891 ) 

892 

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

894 # append the material and then set from returned index 

895 current_material = _append_material( 

896 mat=mesh.visual.material, 

897 tree=tree, 

898 buffer_items=buffer_items, 

899 mat_hashes=mat_hashes, 

900 extension_webp=extension_webp, 

901 ) 

902 

903 # if mesh has UV coordinates defined export them 

904 has_uv = ( 

905 hasattr(mesh.visual, "uv") 

906 and mesh.visual.uv is not None 

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

908 ) 

909 if has_uv: 

910 # slice off W if passed 

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

912 # reverse the Y for GLTF 

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

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

915 acc_uv = _data_append( 

916 acc=tree["accessors"], 

917 buff=buffer_items, 

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

919 data=uv.astype(float32), 

920 ) 

921 # add the reference for UV coordinates 

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

923 

924 # reference the material 

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

926 

927 if include_normals or ( 

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

929 ): 

930 # store vertex normals if requested 

931 if unitize_normals: 

932 normals = util.unitize(mesh.vertex_normals) 

933 else: 

934 # we don't have to copy them since 

935 # they aren't being altered 

936 normals = mesh.vertex_normals 

937 

938 acc_norm = _data_append( 

939 acc=tree["accessors"], 

940 buff=buffer_items, 

941 blob={ 

942 "componentType": 5126, 

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

944 "type": "VEC3", 

945 "byteOffset": 0, 

946 }, 

947 data=normals.astype(float32), 

948 ) 

949 # add the reference for vertex color 

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

951 

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

953 # vertex_attributes 

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

955 # make sure vertex attribute length matches vertices 

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

957 log.warning( 

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

959 ) 

960 continue 

961 

962 # application specific attributes must be prefixed with an underscore 

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

964 key = "_" + key 

965 

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

967 # any float64 or larger to float32 

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

969 data = attrib.astype(float32) 

970 else: 

971 # force little-endian to match GLTF binary format 

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

973 

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

975 data = data[:, np.newaxis] 

976 

977 # every accessor VALUE must be 4-byte aligned 

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

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

980 if row_mod != 0: 

981 # how many columns of padding for this value 

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

983 # pad this custom attribute with zeros -_- 

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

985 

986 # store custom vertex attributes 

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

988 acc=tree["accessors"], 

989 buff=buffer_items, 

990 blob=_build_accessor(data), 

991 data=data, 

992 ) 

993 

994 # Handle Draco compression via extension handler 

995 if extension_draco: 

996 # Determine if normals should be included 

997 should_include_normals = include_normals or ( 

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

999 ) 

1000 # Call primitive_export handlers 

1001 handle_extensions( 

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

1003 scope="primitive_export", 

1004 mesh=mesh, 

1005 name=name, 

1006 tree=tree, 

1007 buffer_items=buffer_items, 

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

1009 include_normals=should_include_normals, 

1010 ) 

1011 

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

1013 

1014 

1015def _build_views(buffer_items): 

1016 """ 

1017 Create views for buffers that are simply 

1018 based on how many bytes they are long. 

1019 

1020 Parameters 

1021 -------------- 

1022 buffer_items : OrderedDict 

1023 Buffers to build views for 

1024 

1025 Returns 

1026 ---------- 

1027 views : (n,) list of dict 

1028 GLTF views 

1029 """ 

1030 views = [] 

1031 # create the buffer views 

1032 current_pos = 0 

1033 for current_item in buffer_items.values(): 

1034 views.append( 

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

1036 ) 

1037 assert (current_pos % 4) == 0 

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

1039 current_pos += len(current_item) 

1040 return views 

1041 

1042 

1043def _build_accessor(array): 

1044 """ 

1045 Build an accessor for an arbitrary array. 

1046 

1047 Parameters 

1048 ----------- 

1049 array : numpy array 

1050 The array to build an accessor for 

1051 

1052 Returns 

1053 ---------- 

1054 accessor : dict 

1055 The accessor for array. 

1056 """ 

1057 shape = array.shape 

1058 data_type = "SCALAR" 

1059 if len(shape) == 2: 

1060 vec_length = shape[1] 

1061 if vec_length > 4: 

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

1063 if vec_length > 1: 

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

1065 else: 

1066 data_type = "SCALAR" 

1067 

1068 if len(shape) == 3: 

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

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

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

1072 

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

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

1075 

1076 if lookup == "u4": 

1077 # spec: UNSIGNED_INT is only allowed when the accessor 

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

1079 # by `primitive.indices` 

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

1081 

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

1083 componentType = _dtypes_lookup[lookup] 

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

1085 

1086 if len(shape) < 3: 

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

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

1089 

1090 return accessor 

1091 

1092 

1093def _byte_pad(data, bound=4): 

1094 """ 

1095 GLTF wants chunks aligned with 4 byte boundaries. 

1096 This function will add padding to the end of a 

1097 chunk of bytes so that it aligns with the passed 

1098 boundary size. 

1099 

1100 Parameters 

1101 -------------- 

1102 data : bytes 

1103 Data to be padded 

1104 bound : int 

1105 Length of desired boundary 

1106 

1107 Returns 

1108 -------------- 

1109 padded : bytes 

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

1111 """ 

1112 assert isinstance(data, bytes) 

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

1114 # extra bytes to pad with 

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

1116 pad = bytes(count) 

1117 # combine the padding and data 

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

1119 # we should always divide evenly 

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

1121 raise ValueError("byte_pad failed!") 

1122 return result 

1123 return data 

1124 

1125 

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

1127 """ 

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

1129 data into buffer_items. 

1130 

1131 Parameters 

1132 ------------- 

1133 path : trimesh.Path2D or trimesh.Path3D 

1134 Source geometry 

1135 name : str 

1136 Name of geometry 

1137 tree : dict 

1138 Will be updated with data from path 

1139 buffer_items 

1140 Will have buffer appended with path data 

1141 """ 

1142 

1143 # convert the path to the unnamed args for 

1144 # a pyglet vertex list 

1145 vxlist = rendering.path_to_vertexlist(path) 

1146 

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

1148 if vxlist[0] == 0: 

1149 return 

1150 

1151 # TODO add color support to Path object 

1152 # this is just exporting everying as black 

1153 try: 

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

1155 except ValueError: 

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

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

1158 

1159 # data is the second value of the fifth field 

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

1161 acc_vertex = _data_append( 

1162 acc=tree["accessors"], 

1163 buff=buffer_items, 

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

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

1166 ) 

1167 

1168 current = { 

1169 "name": name, 

1170 "primitives": [ 

1171 { 

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

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

1174 "material": material_idx, 

1175 } 

1176 ], 

1177 } 

1178 

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

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

1181 try: 

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

1183 except BaseException: 

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

1185 

1186 if path.colors is not None: 

1187 acc_color = _data_append( 

1188 acc=tree["accessors"], 

1189 buff=buffer_items, 

1190 blob={ 

1191 "componentType": 5121, 

1192 "normalized": True, 

1193 "type": "VEC4", 

1194 "byteOffset": 0, 

1195 }, 

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

1197 ) 

1198 # add color to attributes 

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

1200 

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

1202 # vertex_attributes 

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

1204 # Application specific attributes must be 

1205 # prefixed with an underscore 

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

1207 key = "_" + key 

1208 

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

1210 # any float64 or larger to float32 

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

1212 data = attrib.astype(float32) 

1213 else: 

1214 # force little-endian to match GLTF binary format 

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

1216 

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

1218 log.warning( 

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

1220 ) 

1221 continue 

1222 

1223 data_discretized = np.array( 

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

1225 ) 

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

1227 

1228 # store custom vertex attributes 

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

1230 acc=tree["accessors"], 

1231 buff=buffer_items, 

1232 blob=_build_accessor(stacked_data), 

1233 data=stacked_data, 

1234 ) 

1235 

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

1237 

1238 

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

1240 """ 

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

1242 put the data into buffer_items. 

1243 

1244 Parameters 

1245 ------------- 

1246 points : trimesh.PointCloud 

1247 Source geometry 

1248 name : str 

1249 Name of geometry 

1250 tree : dict 

1251 Will be updated with data from points 

1252 buffer_items 

1253 Will have buffer appended with points data 

1254 """ 

1255 

1256 # convert the points to the unnamed args for 

1257 # a pyglet vertex list 

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

1259 

1260 # data is the second value of the fifth field 

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

1262 acc_vertex = _data_append( 

1263 acc=tree["accessors"], 

1264 buff=buffer_items, 

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

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

1267 ) 

1268 current = { 

1269 "name": name, 

1270 "primitives": [ 

1271 { 

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

1273 "mode": _GL_POINTS, 

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

1275 } 

1276 ], 

1277 } 

1278 

1279 # TODO add color support to Points object 

1280 # this is just exporting everying as black 

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

1282 

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

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

1285 color_type, color_data = vxlist[5] 

1286 if "3" in color_type: 

1287 kind = "VEC3" 

1288 elif "4" in color_type: 

1289 kind = "VEC4" 

1290 else: 

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

1292 acc_color = _data_append( 

1293 acc=tree["accessors"], 

1294 buff=buffer_items, 

1295 blob={ 

1296 "componentType": 5121, 

1297 "count": vxlist[0], 

1298 "normalized": True, 

1299 "type": kind, 

1300 "byteOffset": 0, 

1301 }, 

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

1303 ) 

1304 # add color to attributes 

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

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

1307 

1308 

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

1310 try: 

1311 import PIL.Image 

1312 except ImportError: 

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

1314 return None 

1315 

1316 # load any images 

1317 images = None 

1318 if "images" in header: 

1319 # images are referenced by index 

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

1321 # loop through images 

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

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

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

1325 continue 

1326 # get the bytes representing an image 

1327 if "bufferView" in img: 

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

1329 elif "uri" in img: 

1330 try: 

1331 # will get bytes from filesystem or base64 URI 

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

1333 except BaseException: 

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

1335 continue 

1336 else: 

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

1338 continue 

1339 # i.e. 'image/jpeg' 

1340 # mime = img['mimeType'] 

1341 try: 

1342 # load the buffer into a PIL image 

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

1344 except BaseException: 

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

1346 return images 

1347 

1348 

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

1350 """ 

1351 Convert materials and images stored in a GLTF header 

1352 and buffer views to PBRMaterial objects. 

1353 

1354 Parameters 

1355 ------------ 

1356 header : dict 

1357 Contains layout of file 

1358 views : (n,) bytes 

1359 Raw data 

1360 

1361 Returns 

1362 ------------ 

1363 materials : list 

1364 List of trimesh.visual.texture.Material objects 

1365 """ 

1366 

1367 def parse_textures(*, data): 

1368 result = {} 

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

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

1371 # colors are always float 0.0 - 1.0 in GLTF 

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

1373 elif not isinstance(v, dict): 

1374 result[k] = v 

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

1376 try: 

1377 index = None 

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

1379 # Handle texture extensions through registry 

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

1381 index = handle_extensions( 

1382 extensions=tex_ext, scope="texture_source" 

1383 ) 

1384 

1385 if index is None: 

1386 # fall back to standard source key 

1387 index = texture.get("source") 

1388 if index is not None: 

1389 result[k] = images[index] 

1390 except BaseException: 

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

1392 return result 

1393 

1394 images = _parse_textures(header, views, resolver) 

1395 

1396 # store materials which reference images 

1397 materials = [] 

1398 if "materials" in header: 

1399 for mat in header["materials"]: 

1400 # flatten key structure so we can loop it 

1401 loopable = mat.copy() 

1402 # this key stores another dict of crap 

1403 if "pbrMetallicRoughness" in loopable: 

1404 # add keys of keys to top level dict 

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

1406 

1407 # Handle material extensions through registry 

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

1409 ext_results = handle_extensions( 

1410 extensions=mat_extensions, 

1411 scope="material", 

1412 parse_textures=parse_textures, 

1413 images=images, 

1414 ) 

1415 # Flatten extension results into the material parameters 

1416 for ext_result in ext_results.values(): 

1417 if isinstance(ext_result, dict): 

1418 loopable.update(ext_result) 

1419 

1420 # save flattened keys we can use for kwargs 

1421 pbr = parse_textures(data=loopable) 

1422 # create a PBR material object for the GLTF material 

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

1424 

1425 return materials 

1426 

1427 

1428def _read_buffers( 

1429 header: dict, 

1430 buffers: list[bytes], 

1431 mesh_kwargs: dict, 

1432 resolver: ResolverLike | None, 

1433 ignore_broken: bool = False, 

1434 merge_primitives: bool = False, 

1435 skip_materials: bool = False, 

1436): 

1437 """ 

1438 Given binary data and a layout return the 

1439 kwargs to create a scene object. 

1440 

1441 Parameters 

1442 ----------- 

1443 header : dict 

1444 With GLTF keys 

1445 buffers : list of bytes 

1446 Stored data 

1447 mesh_kwargs : dict 

1448 To be passed to the mesh constructor. 

1449 ignore_broken : bool 

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

1451 is True don't raise an exception but return 

1452 a partial result 

1453 merge_primitives : bool 

1454 If true, combine primitives into a single mesh. 

1455 skip_materials : bool 

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

1457 resolver : trimesh.resolvers.Resolver 

1458 Resolver to load referenced assets 

1459 

1460 Returns 

1461 ----------- 

1462 kwargs : dict 

1463 Can be passed to load_kwargs for a trimesh.Scene 

1464 """ 

1465 

1466 if "bufferViews" in header: 

1467 # split buffer data into buffer views 

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

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

1470 if "byteOffset" in view: 

1471 start = view["byteOffset"] 

1472 else: 

1473 start = 0 

1474 end = start + view["byteLength"] 

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

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

1477 # load data from buffers into numpy arrays 

1478 # using the layout described by accessors 

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

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

1481 # number of items 

1482 count = a["count"] 

1483 # what is the datatype 

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

1485 # basically how many columns 

1486 # for types like (4, 4) 

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

1488 # use reported count to generate shape 

1489 shape = np.append(count, per_item) 

1490 # number of items when flattened 

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

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

1493 if "bufferView" in a: 

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

1495 

1496 # load the bytes data into correct dtype and shape 

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

1498 

1499 # is the accessor offset in a buffer 

1500 # will include the start, length, and offset 

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

1502 # in numpy rather than in python looping 

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

1504 

1505 # both bufferView *and* accessors are allowed 

1506 # to have a byteOffset 

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

1508 

1509 if "byteStride" in buffer_view: 

1510 # how many bytes for each chunk 

1511 stride = buffer_view["byteStride"] 

1512 # we want to get the bytes for every row 

1513 per_row = per_count * dtype.itemsize 

1514 # the total block we're looking at 

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

1516 # apply as_strided for fast construction of strided array 

1517 # and copy to ensure contiguous layout 

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

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

1520 access[index] = np.array( 

1521 np.lib.stride_tricks.as_strided( 

1522 np.frombuffer( 

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

1524 ), 

1525 [count, per_row], 

1526 [stride, 1], 

1527 ) 

1528 .view(dtype) 

1529 .reshape(shape) 

1530 ) 

1531 else: 

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

1533 length = dtype.itemsize * count * per_count 

1534 access[index] = np.frombuffer( 

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

1536 ).reshape(shape) 

1537 else: 

1538 # a "sparse" accessor should be initialized as zeros 

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

1540 

1541 # possibly load images and textures into material objects 

1542 if skip_materials: 

1543 materials = [] 

1544 else: 

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

1546 

1547 mesh_prim = defaultdict(list) 

1548 # load data from accessors into Trimesh objects 

1549 meshes = OrderedDict() 

1550 

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

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

1553 # dict of names 

1554 name_counts = {} 

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

1556 try: 

1557 # GLTF spec indicates implicit units are meters 

1558 metadata = { 

1559 "units": "meters", 

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

1561 } 

1562 

1563 # try to load all mesh metadata 

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

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

1566 

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

1568 if "extensions" in m: 

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

1570 

1571 for p in m["primitives"]: 

1572 # Handle primitive preprocessing extensions (e.g. Draco decompression) 

1573 # These run before reading accessors since they may modify them 

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

1575 handle_extensions( 

1576 extensions=prim_extensions, 

1577 scope="primitive_preprocess", 

1578 primitive=p, 

1579 accessors=access, 

1580 views=views, 

1581 ) 

1582 

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

1584 # if not specified assume it is a mesh 

1585 kwargs = deepcopy(mesh_kwargs) 

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

1587 kwargs["metadata"] = {} 

1588 if "process" not in kwargs: 

1589 kwargs["process"] = False 

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

1591 # i.e. GL_LINES, GL_TRIANGLES, etc 

1592 # specification says the default mode is GL_TRIANGLES 

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

1594 # colors, normals, etc 

1595 attr = p["attributes"] 

1596 # create a unique mesh name per- primitive 

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

1598 # make name unique across multiple meshes 

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

1600 

1601 if mode == _GL_LINES: 

1602 # load GL_LINES into a Path object 

1603 from ...path.entities import Line 

1604 

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

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

1607 

1608 # custom attributes starting with a `_` 

1609 custom = { 

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

1611 } 

1612 if len(custom) > 0: 

1613 kwargs["vertex_attributes"] = custom 

1614 elif mode == _GL_POINTS: 

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

1616 visuals = None 

1617 if "COLOR_0" in attr: 

1618 try: 

1619 # try to load vertex colors from the accessors 

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

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

1622 if visuals is None: 

1623 # just pass to mesh as vertex color 

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

1625 else: 

1626 # we ALSO have texture so save as vertex 

1627 # attribute 

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

1629 except BaseException: 

1630 # survive failed colors 

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

1632 if visuals is not None: 

1633 kwargs["visual"] = visuals 

1634 elif mode in (_GL_TRIANGLES, _GL_STRIP): 

1635 # get vertices from accessors 

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

1637 # get faces from accessors 

1638 if "indices" in p: 

1639 if mode == _GL_STRIP: 

1640 # this is triangle strips 

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

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

1643 else: 

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

1645 else: 

1646 # indices are apparently optional and we are supposed to 

1647 # do the same thing as webGL drawArrays? 

1648 if mode == _GL_STRIP: 

1649 kwargs["faces"] = triangle_strips_to_faces( 

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

1651 ) 

1652 else: 

1653 # GL_TRIANGLES 

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

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

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

1657 

1658 if "NORMAL" in attr: 

1659 # vertex normals are specified 

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

1661 # do we have UV coordinates 

1662 visuals = None 

1663 if "material" in p and not skip_materials: 

1664 if materials is None: 

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

1666 else: 

1667 uv = None 

1668 if "TEXCOORD_0" in attr: 

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

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

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

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

1673 # create a texture visual 

1674 visuals = visual.texture.TextureVisuals( 

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

1676 ) 

1677 

1678 if "COLOR_0" in attr: 

1679 try: 

1680 # try to load vertex colors from the accessors 

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

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

1683 if visuals is None: 

1684 # just pass to mesh as vertex color 

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

1686 else: 

1687 # we ALSO have texture so save as vertex 

1688 # attribute 

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

1690 except BaseException: 

1691 # survive failed colors 

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

1693 if visuals is not None: 

1694 kwargs["visual"] = visuals 

1695 

1696 # custom attributes starting with a `_` 

1697 custom = { 

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

1699 } 

1700 if len(custom) > 0: 

1701 kwargs["vertex_attributes"] = custom 

1702 

1703 # Process primitive-level extensions through registry 

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

1705 handle_extensions( 

1706 extensions=prim_extensions, 

1707 scope="primitive", 

1708 primitive=p, 

1709 mesh_kwargs=kwargs, 

1710 accessors=access, 

1711 ) 

1712 else: 

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

1714 continue 

1715 # this should absolutely not be stomping on itself 

1716 assert name not in meshes 

1717 meshes[name] = kwargs 

1718 mesh_prim[index].append(name) 

1719 except BaseException as E: 

1720 if ignore_broken: 

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

1722 else: 

1723 raise E 

1724 

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

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

1727 # but if merge_primitives is True we combine the primitives 

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

1729 if merge_primitives: 

1730 # if we are only returning one Trimesh object 

1731 # replace `mesh_prim` with updated values 

1732 mesh_prim_replace = {} 

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

1734 mesh_pop = set() 

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

1736 if len(names) <= 1: 

1737 mesh_prim_replace[mesh_index] = names 

1738 continue 

1739 

1740 # just take the shortest name option available 

1741 name = min(names) 

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

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

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

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

1746 

1747 # get all meshes for this group 

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

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

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

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

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

1753 face_materials = [] 

1754 for i, p in enumerate(current): 

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

1756 visuals = visual.texture.TextureVisuals( 

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

1758 face_materials=face_materials, 

1759 ) 

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

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

1762 else: 

1763 metadata = {} 

1764 meshes[name] = { 

1765 "vertices": v, 

1766 "faces": f, 

1767 "visual": visuals, 

1768 "metadata": metadata, 

1769 "process": False, 

1770 } 

1771 mesh_prim_replace[mesh_index] = [name] 

1772 # avoid altering inside loop 

1773 mesh_prim = mesh_prim_replace 

1774 # remove outdated meshes 

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

1776 

1777 # make it easier to reference nodes 

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

1779 # nodes are referenced by index 

1780 # save their string names if they have one 

1781 # we have to accumulate in a for loop opposed 

1782 # to a dict comprehension as it will be checking 

1783 # the mutated dict in every loop 

1784 name_index = {} 

1785 name_counts = {} 

1786 

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

1788 # name_index: {name: index} 

1789 for i, n in enumerate(nodes): 

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

1791 # names: {index: name} 

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

1793 

1794 # the GLTF is allowed to declare a base frame, should we have used that? 

1795 base_frame = "world" 

1796 if base_frame in name_index: 

1797 # todo : handle this? 

1798 log.debug("file contains a `world` node, we may stomp on it") 

1799 names[base_frame] = base_frame 

1800 

1801 # visited, kwargs for scene.graph.update 

1802 graph = deque() 

1803 # unvisited, pairs of node indexes 

1804 queue = deque() 

1805 

1806 # camera(s), if they exist 

1807 camera = None 

1808 camera_transform = None 

1809 

1810 if "scene" in header: 

1811 # specify the index of scenes if specified 

1812 scene_index = header["scene"] 

1813 else: 

1814 # otherwise just use the first index 

1815 scene_index = 0 

1816 

1817 base_frame = "world" 

1818 if "scenes" in header: 

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

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

1821 # add transform from base frame to these root nodes 

1822 queue.append((base_frame, root)) 

1823 

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

1825 consumed = set() 

1826 

1827 # go through the nodes tree to populate 

1828 # kwargs for scene graph loader 

1829 while len(queue) > 0: 

1830 # (int, int) pair of node indexes 

1831 edge = queue.pop() 

1832 

1833 # avoid looping forever if someone specified 

1834 # recursive nodes 

1835 if edge in consumed: 

1836 continue 

1837 

1838 consumed.add(edge) 

1839 a, b = edge 

1840 

1841 # dict of child node 

1842 # parent = nodes[a] 

1843 child = nodes[b] 

1844 # add edges of children to be processed 

1845 if "children" in child: 

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

1847 

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

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

1850 

1851 # grab matrix from child 

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

1853 # for the transform from parent to child 

1854 if "matrix" in child: 

1855 kwargs["matrix"] = ( 

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

1857 ) 

1858 else: 

1859 # if no matrix set identity 

1860 kwargs["matrix"] = _EYE 

1861 

1862 # Now apply keyword translations 

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

1864 if "translation" in child: 

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

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

1867 ) 

1868 if "rotation" in child: 

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

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

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

1872 # add the rotation to the matrix 

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

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

1875 ) 

1876 if "scale" in child: 

1877 # add scale to the matrix 

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

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

1880 ) 

1881 

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

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

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

1885 # TODO will only read perspective camera 

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

1887 cam_idx = child["camera"] 

1888 try: 

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

1890 except KeyError: 

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

1892 if camera: 

1893 camera_transform = kwargs["matrix"] 

1894 continue 

1895 

1896 # treat node metadata similarly to mesh metadata 

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

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

1899 

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

1901 if "extensions" in child: 

1902 if "metadata" not in kwargs: 

1903 kwargs["metadata"] = {} 

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

1905 

1906 if "mesh" in child: 

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

1908 

1909 # if the node has a mesh associated with it 

1910 if len(geometries) > 1: 

1911 # append root node 

1912 graph.append(kwargs.copy()) 

1913 # put primitives as children 

1914 for geom_name in geometries: 

1915 # save the name of the geometry 

1916 kwargs["geometry"] = geom_name 

1917 # no transformations 

1918 kwargs["matrix"] = _EYE 

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

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

1921 # frame name for the primitives after the first one 

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

1923 kwargs["frame_to"] = frame_to 

1924 # append the edge with the mesh frame 

1925 graph.append(kwargs.copy()) 

1926 elif len(geometries) == 1: 

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

1928 if "name" in child: 

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

1930 graph.append(kwargs.copy()) 

1931 else: 

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

1933 graph.append(kwargs) 

1934 

1935 # kwargs for load_kwargs 

1936 result = { 

1937 "class": "Scene", 

1938 "geometry": meshes, 

1939 "graph": graph, 

1940 "base_frame": base_frame, 

1941 "camera": camera, 

1942 "camera_transform": camera_transform, 

1943 "metadata": {}, 

1944 } 

1945 

1946 try: 

1947 # load any scene extras into scene.metadata 

1948 # use a try except to avoid nested key checks 

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

1950 except BaseException: 

1951 pass 

1952 try: 

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

1954 # use a try except to avoid nested key checks 

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

1956 except BaseException: 

1957 pass 

1958 

1959 return result 

1960 

1961 

1962def _cam_from_gltf(cam): 

1963 """ 

1964 Convert a gltf perspective camera to trimesh. 

1965 

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

1967 does not contain it. 

1968 

1969 If the camera is not perspective will return None. 

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

1971 

1972 Parameters 

1973 ------------ 

1974 cam : dict 

1975 Camera represented as a dictionary according to glTF 

1976 

1977 Returns 

1978 ------------- 

1979 camera : trimesh.scene.cameras.Camera 

1980 Trimesh camera object 

1981 """ 

1982 if "perspective" not in cam: 

1983 return 

1984 name = cam.get("name") 

1985 znear = cam["perspective"]["znear"] 

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

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

1988 

1989 fov = (aspect_ratio * yfov, yfov) 

1990 

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

1992 

1993 

1994def _convert_camera(camera): 

1995 """ 

1996 Convert a trimesh camera to a GLTF camera. 

1997 

1998 Parameters 

1999 ------------ 

2000 camera : trimesh.scene.cameras.Camera 

2001 Trimesh camera object 

2002 

2003 Returns 

2004 ------------- 

2005 gltf_camera : dict 

2006 Camera represented as a GLTF dict 

2007 """ 

2008 result = { 

2009 "name": camera.name, 

2010 "type": "perspective", 

2011 "perspective": { 

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

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

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

2015 }, 

2016 } 

2017 return result 

2018 

2019 

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

2021 """ 

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

2023 

2024 Parameters 

2025 ------------ 

2026 img : PIL.Image 

2027 Image object 

2028 tree : dict 

2029 GLTF 2.0 format tree 

2030 buffer_items : (n,) bytes 

2031 Binary blobs containing data 

2032 extension_webp : bool 

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

2034 

2035 Returns 

2036 ----------- 

2037 index : int or None 

2038 The index of the image in the tree 

2039 None if image append failed for any reason 

2040 """ 

2041 # probably not a PIL image so exit 

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

2043 return None 

2044 

2045 if extension_webp: 

2046 # support WebP if extension is specified 

2047 save_as = "WEBP" 

2048 elif img.format == "JPEG": 

2049 # don't re-encode JPEGs 

2050 save_as = "JPEG" 

2051 else: 

2052 # for everything else just use PNG 

2053 save_as = "png" 

2054 

2055 # get the image data into a bytes object 

2056 with util.BytesIO() as f: 

2057 img.save(f, format=save_as) 

2058 f.seek(0) 

2059 data = f.read() 

2060 

2061 index = _buffer_append(buffer_items, data) 

2062 # append buffer index and the GLTF-acceptable mimetype 

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

2064 

2065 # index is length minus one 

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

2067 

2068 

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

2070 """ 

2071 Add passed PBRMaterial as GLTF 2.0 specification JSON 

2072 serializable data: 

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

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

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

2076 

2077 Parameters 

2078 ------------ 

2079 mat : trimesh.visual.materials.PBRMaterials 

2080 Source material to convert 

2081 tree : dict 

2082 GLTF header blob 

2083 buffer_items : (n,) bytes 

2084 Binary blobs with various data 

2085 mat_hashes : dict 

2086 Which materials have already been added 

2087 Stored as { hashed : material index } 

2088 extension_webp : bool 

2089 Export textures as webP using EXT_texture_webp extension. 

2090 

2091 Returns 

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

2093 index : int 

2094 Index at which material was added 

2095 """ 

2096 # materials are hashable 

2097 hashed = hash(mat) 

2098 # check stored material indexes to see if material 

2099 # has already been added 

2100 if mat_hashes is not None and hashed in mat_hashes: 

2101 return mat_hashes[hashed] 

2102 

2103 # convert passed input to PBR if necessary 

2104 if hasattr(mat, "to_pbr"): 

2105 as_pbr = mat.to_pbr() 

2106 else: 

2107 as_pbr = mat 

2108 

2109 # a default PBR metallic material 

2110 result = {"pbrMetallicRoughness": {}} 

2111 try: 

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

2113 result["baseColorFactor"] = ( 

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

2115 ) 

2116 except BaseException: 

2117 pass 

2118 

2119 try: 

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

2121 except BaseException: 

2122 pass 

2123 

2124 # if name is defined, export 

2125 if isinstance(as_pbr.name, str): 

2126 result["name"] = as_pbr.name 

2127 

2128 # if alphaMode is defined, export 

2129 if isinstance(as_pbr.alphaMode, str): 

2130 result["alphaMode"] = as_pbr.alphaMode 

2131 

2132 # if alphaCutoff is defined, export 

2133 if isinstance(as_pbr.alphaCutoff, float): 

2134 result["alphaCutoff"] = as_pbr.alphaCutoff 

2135 

2136 # if doubleSided is defined, export 

2137 if isinstance(as_pbr.doubleSided, bool): 

2138 result["doubleSided"] = as_pbr.doubleSided 

2139 

2140 # if scalars are defined correctly export 

2141 if isinstance(as_pbr.metallicFactor, float): 

2142 result["metallicFactor"] = as_pbr.metallicFactor 

2143 if isinstance(as_pbr.roughnessFactor, float): 

2144 result["roughnessFactor"] = as_pbr.roughnessFactor 

2145 

2146 # which keys of the PBRMaterial are images 

2147 image_mapping = { 

2148 "baseColorTexture": as_pbr.baseColorTexture, 

2149 "emissiveTexture": as_pbr.emissiveTexture, 

2150 "normalTexture": as_pbr.normalTexture, 

2151 "occlusionTexture": as_pbr.occlusionTexture, 

2152 "metallicRoughnessTexture": as_pbr.metallicRoughnessTexture, 

2153 } 

2154 

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

2156 if img is None: 

2157 continue 

2158 # try adding the base image to the export object 

2159 index = _append_image( 

2160 img=img, tree=tree, buffer_items=buffer_items, extension_webp=extension_webp 

2161 ) 

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

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

2164 if index is not None: 

2165 # add a reference to the base color texture 

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

2167 

2168 # add texture object, optionally using EXT_texture_webp 

2169 if extension_webp: 

2170 tree["textures"].append( 

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

2172 ) 

2173 else: 

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

2175 

2176 # for our PBRMaterial object we flatten all keys 

2177 # however GLTF would like some of them under the 

2178 # "pbrMetallicRoughness" key 

2179 pbr_subset = [ 

2180 "baseColorTexture", 

2181 "baseColorFactor", 

2182 "roughnessFactor", 

2183 "metallicFactor", 

2184 "metallicRoughnessTexture", 

2185 ] 

2186 # move keys down a level 

2187 for key in pbr_subset: 

2188 if key in result: 

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

2190 

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

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

2193 result.pop("pbrMetallicRoughness") 

2194 

2195 # which index are we inserting material at 

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

2197 # add the material to the data structure 

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

2199 # add the material index in-place 

2200 mat_hashes[hashed] = index 

2201 

2202 return index 

2203 

2204 

2205def validate(header): 

2206 """ 

2207 Validate a GLTF 2.0 header against the schema. 

2208 

2209 Returns result from: 

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

2211 

2212 Parameters 

2213 ------------- 

2214 header : dict 

2215 Populated GLTF 2.0 header 

2216 

2217 Raises 

2218 -------------- 

2219 err : jsonschema.exceptions.ValidationError 

2220 If the tree is an invalid GLTF2.0 header 

2221 """ 

2222 # a soft dependency 

2223 import jsonschema 

2224 

2225 # will do the reference replacement 

2226 schema = get_schema() 

2227 # validate the passed header against the schema 

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

2229 

2230 return valid 

2231 

2232 

2233def get_schema(): 

2234 """ 

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

2236 

2237 Returns 

2238 ------------ 

2239 schema : dict 

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

2241 """ 

2242 # replace references 

2243 # get zip resolver to access referenced assets 

2244 from ...schemas import resolve 

2245 

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

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

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

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

2250 # get a resolver object for accessing the schema 

2251 resolver = ZipResolver(archive) 

2252 # get a loaded dict from the base file 

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

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

2255 schema = resolve(unresolved, resolver=resolver) 

2256 

2257 return schema 

2258 

2259 

2260# exporters 

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