Coverage for trimesh/exchange/gltf/__init__.py: 92%
795 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-08-31 18:21 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-08-31 18:21 +0000
1"""
2gltf/__init__.py
3------------
5Provides GLTF 2.0 exports of trimesh.Trimesh objects
6as GL_TRIANGLES, and trimesh.Path2D/Path3D as GL_LINES
7"""
9import base64
10import json
11from collections import OrderedDict, defaultdict, deque
12from copy import deepcopy
14import numpy as np
16from ... import rendering, resources, transformations, util, visual
17from ...caching import hash_fast
18from ...constants import log, tol
19from ...iteration import IndexedDict
20from ...resolvers import ResolverLike, ZipResolver
21from ...scene.cameras import Camera
22from ...scene.transforms import DEFAULT_BASE_FRAME
23from ...typed import NDArray, Stream
24from ...util import triangle_strips_to_faces, unique_name
25from .extensions import handle_extensions, unregistered
27# magic numbers which have meaning in GLTF
28# most are uint32's of UTF-8 text
29_magic = {"gltf": 1179937895, "json": 1313821514, "bin": 5130562}
31# GLTF data type codes: little endian numpy dtypes
32_dtypes = {5120: "<i1", 5121: "<u1", 5122: "<i2", 5123: "<u2", 5125: "<u4", 5126: "<f4"}
33# a string we can use to look up numpy dtype : GLTF dtype
34_dtypes_lookup = {v[1:]: k for k, v in _dtypes.items()}
37# GLTF data formats: numpy shapes
38_shapes = {
39 "SCALAR": 1,
40 "VEC2": (2),
41 "VEC3": (3),
42 "VEC4": (4),
43 "MAT2": (2, 2),
44 "MAT3": (3, 3),
45 "MAT4": (4, 4),
46}
48# a default PBR metallic material
49_default_material = {
50 "pbrMetallicRoughness": {
51 "baseColorFactor": [1, 1, 1, 1],
52 "metallicFactor": 0,
53 "roughnessFactor": 0,
54 }
55}
57# GL geometry modes
58_GL_LINES = 1
59_GL_POINTS = 0
60_GL_TRIANGLES = 4
61_GL_STRIP = 5
63_EYE = np.eye(4)
64_EYE.flags.writeable = False
66# specify dtypes with forced little endian
67float32 = np.dtype("<f4")
68uint32 = np.dtype("<u4")
69uint8 = np.dtype("<u1")
72def export_gltf(
73 scene,
74 include_normals=None,
75 merge_buffers=False,
76 unitize_normals=True,
77 tree_postprocessor=None,
78 embed_buffers=False,
79 extension_webp=False,
80 extension_draco=False,
81):
82 """
83 Export a scene object as a GLTF directory.
85 This puts each mesh into a separate file (i.e. a `buffer`)
86 as opposed to one larger file.
88 Parameters
89 -----------
90 scene : trimesh.Scene
91 Scene to be exported
92 include_normals : None or bool
93 Include vertex normals
94 merge_buffers : bool
95 Merge buffers into one blob.
96 unitize_normals
97 GLTF requires unit normals, however sometimes people
98 want to include non-unit normals for shading reasons.
99 resolver : trimesh.resolvers.Resolver
100 If passed will use to write each file.
101 tree_postprocesser : None or callable
102 Run this on the header tree before exiting.
103 embed_buffers : bool
104 Embed the buffer into JSON file as a base64 string in the URI
105 extension_webp : bool
106 Export textures as webP (using glTF's EXT_texture_webp extension).
107 extension_draco : bool
108 Compress mesh data using Draco (KHR_draco_mesh_compression).
109 Requires the `DracoPy` package to be installed.
111 Returns
112 ----------
113 export : dict
114 Format: {file name : file data}
115 """
116 # if we were passed a bare Trimesh or Path3D object
117 if not util.is_instance_named(scene, "Scene") and hasattr(scene, "scene"):
118 scene = scene.scene()
120 # create the header and buffer data
121 tree, buffer_items = _create_gltf_structure(
122 scene=scene,
123 unitize_normals=unitize_normals,
124 include_normals=include_normals,
125 extension_webp=extension_webp,
126 extension_draco=extension_draco,
127 )
129 # allow custom postprocessing
130 if tree_postprocessor is not None:
131 tree_postprocessor(tree)
133 # store files as {name : data}
134 files = {}
136 base64_buffer_format = "data:application/octet-stream;base64,{}"
137 if merge_buffers:
138 views = _build_views(buffer_items)
139 buffer_data = b"".join(buffer_items.values())
140 if embed_buffers:
141 buffer_name = base64_buffer_format.format(
142 base64.b64encode(buffer_data).decode()
143 )
144 else:
145 buffer_name = "gltf_buffer.bin"
146 files[buffer_name] = buffer_data
147 buffers = [{"uri": buffer_name, "byteLength": len(buffer_data)}]
148 else:
149 # make one buffer per buffer_items
150 buffers = [None] * len(buffer_items)
151 # A bufferView is a slice of a file
152 views = [None] * len(buffer_items)
153 # create the buffer views
154 for i, item in enumerate(buffer_items.values()):
155 views[i] = {"buffer": i, "byteOffset": 0, "byteLength": len(item)}
156 if embed_buffers:
157 buffer_name = base64_buffer_format.format(base64.b64encode(item).decode())
158 else:
159 buffer_name = f"gltf_buffer_{i}.bin"
160 files[buffer_name] = item
161 buffers[i] = {"uri": buffer_name, "byteLength": len(item)}
163 if len(buffers) > 0:
164 tree["buffers"] = buffers
165 tree["bufferViews"] = views
166 # dump tree with compact separators
167 files["model.gltf"] = util.jsonify(tree, separators=(",", ":")).encode("utf-8")
169 if tol.strict:
170 validate(tree)
172 return files
175def export_glb(
176 scene,
177 include_normals=None,
178 unitize_normals=True,
179 tree_postprocessor=None,
180 buffer_postprocessor=None,
181 extension_webp=False,
182 extension_draco=False,
183):
184 """
185 Export a scene as a binary GLTF (GLB) file.
187 Parameters
188 ------------
189 scene: trimesh.Scene
190 Input geometry
191 extras : JSON serializable
192 Will be stored in the extras field.
193 include_normals : bool
194 Include vertex normals in output file?
195 tree_postprocessor : func
196 Custom function to (in-place) post-process the tree
197 before exporting.
198 extension_webp : bool
199 Export textures as webP using EXT_texture_webp extension.
200 extension_draco : bool
201 Compress mesh data using Draco (KHR_draco_mesh_compression).
202 Requires the `DracoPy` package to be installed.
204 Returns
205 ----------
206 exported : bytes
207 Exported result in GLB 2.0
208 """
209 # if we were passed a bare Trimesh or Path3D object
210 if not util.is_instance_named(scene, "Scene") and hasattr(scene, "scene"):
211 # generate a scene with just that mesh in it
212 scene = scene.scene()
214 tree, buffer_items = _create_gltf_structure(
215 scene=scene,
216 unitize_normals=unitize_normals,
217 include_normals=include_normals,
218 buffer_postprocessor=buffer_postprocessor,
219 extension_webp=extension_webp,
220 extension_draco=extension_draco,
221 )
223 # A bufferView is a slice of a file
224 views = _build_views(buffer_items)
226 # combine bytes into a single blob
227 buffer_data = b"".join(buffer_items.values())
229 # add the information about the buffer data
230 if len(buffer_data) > 0:
231 tree["buffers"] = [{"byteLength": len(buffer_data)}]
232 tree["bufferViews"] = views
234 # allow custom postprocessing
235 if tree_postprocessor is not None:
236 tree_postprocessor(tree)
238 # export the tree to JSON for the header
239 content = util.jsonify(tree, separators=(",", ":"))
240 # add spaces to content, so the start of the data
241 # is 4 byte aligned as per spec
242 content += (4 - ((len(content) + 20) % 4)) * " "
243 content = content.encode("utf-8")
244 # make sure we didn't screw it up
245 assert (len(content) % 4) == 0
247 # the initial header of the file
248 header = _byte_pad(
249 np.array(
250 [
251 _magic["gltf"], # magic, turns into glTF
252 2, # GLTF version
253 # length is the total length of the Binary glTF
254 # including Header and all Chunks, in bytes.
255 len(content) + len(buffer_data) + 28,
256 # contentLength is the length, in bytes,
257 # of the glTF content (JSON)
258 len(content),
259 # magic number which is 'JSON'
260 _magic["json"],
261 ],
262 dtype="<u4",
263 ).tobytes()
264 )
266 # the header of the binary data section
267 bin_header = _byte_pad(
268 np.array([len(buffer_data), 0x004E4942], dtype="<u4").tobytes()
269 )
271 exported = b"".join([header, content, bin_header, buffer_data])
273 if tol.strict:
274 validate(tree)
276 return exported
279def load_gltf(
280 file_obj: Stream | None = None,
281 resolver: ResolverLike | None = None,
282 ignore_broken: bool = False,
283 merge_primitives: bool = False,
284 skip_materials: bool = False,
285 **mesh_kwargs,
286):
287 """
288 Load a GLTF file, which consists of a directory structure
289 with multiple files.
291 Parameters
292 -------------
293 file_obj : None or file-like
294 Object containing header JSON, or None
295 resolver : trimesh.visual.Resolver
296 Object which can be used to load other files by name
297 ignore_broken : bool
298 If there is a mesh we can't load and this
299 is True don't raise an exception but return
300 a partial result
301 merge_primitives : bool
302 If True, each GLTF 'mesh' will correspond
303 to a single Trimesh object
304 skip_materials : bool
305 If true, will not load materials (if present).
306 **mesh_kwargs : dict
307 Passed to mesh constructor
309 Returns
310 --------------
311 kwargs : dict
312 Arguments to create scene
313 """
314 try:
315 # see if we've been passed the GLTF header file
316 tree = json.loads(util.decode_text(file_obj.read()))
317 except BaseException:
318 # otherwise header should be in 'model.gltf'
319 data = resolver["model.gltf"]
320 # old versions of python/json need strings
321 tree = json.loads(util.decode_text(data))
323 # gltf 1.0 is a totally different format
324 # that wasn't widely deployed before they fixed it
325 version = tree.get("asset", {}).get("version", "2.0")
326 if isinstance(version, str):
327 # parse semver like '1.0.1' into just a major integer
328 major = int(version.split(".", 1)[0])
329 else:
330 major = int(float(version))
332 if major < 2:
333 raise NotImplementedError(f"only GLTF 2 is supported not `{version}`")
335 # use the URI and resolver to get data from file names
336 buffers = [
337 _uri_to_bytes(uri=b["uri"], resolver=resolver) for b in tree.get("buffers", [])
338 ]
340 # turn the layout header and data into kwargs
341 # that can be used to instantiate a trimesh.Scene object
342 kwargs = _read_buffers(
343 header=tree,
344 buffers=buffers,
345 ignore_broken=ignore_broken,
346 merge_primitives=merge_primitives,
347 mesh_kwargs=mesh_kwargs,
348 skip_materials=skip_materials,
349 resolver=resolver,
350 )
351 return kwargs
354def load_glb(
355 file_obj: Stream,
356 resolver: ResolverLike | None = None,
357 ignore_broken: bool = False,
358 merge_primitives: bool = False,
359 skip_materials: bool = False,
360 **mesh_kwargs,
361):
362 """
363 Load a GLTF file in the binary GLB format into a trimesh.Scene.
365 Implemented from specification:
366 https://github.com/KhronosGroup/glTF/tree/master/specification/2.0
368 Parameters
369 ------------
370 file_obj : file- like object
371 Containing GLB data
372 resolver : trimesh.visual.Resolver
373 Object which can be used to load other files by name
374 ignore_broken : bool
375 If there is a mesh we can't load and this
376 is True don't raise an exception but return
377 a partial result
378 merge_primitives : bool
379 If True, each GLTF 'mesh' will correspond to a
380 single Trimesh object.
381 skip_materials : bool
382 If true, will not load materials (if present).
384 Returns
385 ------------
386 kwargs : dict
387 Kwargs to instantiate a trimesh.Scene
388 """
389 # read the first 20 bytes which contain section lengths
390 head_data = file_obj.read(20)
391 head = np.frombuffer(head_data, dtype="<u4")
393 # check to make sure first index is gltf magic header
394 if head[0] != _magic["gltf"]:
395 raise ValueError("incorrect header on GLB file")
397 # and second value is version: should be 2 for GLTF 2.0
398 if head[1] != 2:
399 raise NotImplementedError(f"only GLTF 2 is supported not `{head[1]}`")
401 # overall file length
402 # first chunk length
403 # first chunk type
404 length, chunk_length, chunk_type = head[2:]
406 # first chunk should be JSON header
407 if chunk_type != _magic["json"]:
408 raise ValueError("no initial JSON header!")
410 # uint32 causes an error in read, so we convert to native int
411 # for the length passed to read, for the JSON header
412 json_data = file_obj.read(int(chunk_length))
413 # convert to text
414 if hasattr(json_data, "decode"):
415 json_data = util.decode_text(json_data)
416 # load the json header to native dict
417 header = json.loads(json_data)
419 # read the binary data referred to by GLTF as 'buffers'
420 buffers = []
421 start = file_obj.tell()
423 # header can contain base64 encoded data in the URI field
424 info = header.get("buffers", []).copy()
426 while (file_obj.tell() - start) < length:
427 # if we have buffer infos with URI check it here
428 try:
429 # if they have interleaved URI data with GLB data handle it here
430 uri = info.pop(0)["uri"]
431 buffers.append(_uri_to_bytes(uri=uri, resolver=resolver))
432 continue
433 except (IndexError, KeyError):
434 # if there was no buffer info or URI we still need to read
435 pass
437 # the last read put us past the JSON chunk
438 # we now read the chunk header, which is 8 bytes
439 chunk_head = file_obj.read(8)
440 if len(chunk_head) != 8:
441 # double check to make sure we didn't
442 # read the whole file
443 break
444 chunk_length, chunk_type = np.frombuffer(chunk_head, dtype="<u4")
445 # make sure we have the right data type
446 if chunk_type != _magic["bin"]:
447 raise ValueError("not binary GLTF!")
448 # read the chunk
449 chunk_data = file_obj.read(int(chunk_length))
450 if len(chunk_data) != chunk_length:
451 raise ValueError("chunk was not expected length!")
452 buffers.append(chunk_data)
454 # turn the layout header and data into kwargs
455 # that can be used to instantiate a trimesh.Scene object
456 kwargs = _read_buffers(
457 header=header,
458 buffers=buffers,
459 ignore_broken=ignore_broken,
460 merge_primitives=merge_primitives,
461 skip_materials=skip_materials,
462 mesh_kwargs=mesh_kwargs,
463 resolver=resolver,
464 )
466 return kwargs
469def _uri_to_bytes(uri: str, resolver: ResolverLike | None) -> bytes:
470 """
471 Take a URI string and load it as a
472 a filename or as base64.
474 Parameters
475 --------------
476 uri
477 Usually a filename or something like:
478 "data:object/stuff,base64,AABA112A..."
479 resolver
480 A resolver to load referenced assets
482 Returns
483 ---------------
484 data
485 Loaded data from URI
486 """
487 # see if the URI has base64 data
488 index = uri.find("base64,")
489 if index < 0:
490 # string didn't contain the base64 header
491 # so return the result from the resolver
492 return resolver[uri]
493 # strip the base64 header and decode: note that the decoded result is
494 # 3/4 the length of the payload which is already in-memory
495 return base64.b64decode(uri[index + 7 :])
498def _buffer_append(ordered: IndexedDict, data: bytes) -> int:
499 """
500 Append data to an existing IndexedDict and
501 pad it to a 4-byte boundary.
503 Parameters
504 ----------
505 ordered : IndexedDict
506 Keyed like { hash : data }
507 data : bytes
508 To be stored
510 Returns
511 ----------
512 index : int
513 Index of buffer_items stored in
514 """
515 # hash the data to see if we have it already
516 hashed = hash_fast(data)
517 if hashed in ordered:
518 return ordered.index(hashed)
519 # not in buffer items so append and then return index
520 ordered[hashed] = _byte_pad(data)
522 return len(ordered) - 1
525def _data_append(
526 acc: IndexedDict,
527 buff: IndexedDict,
528 blob: dict,
529 data: NDArray,
530 claimed: dict[int, NDArray] | None = None,
531):
532 """
533 Append a new accessor to an IndexedDict.
535 Parameters
536 ------------
537 acc
538 Collection of accessors, will be mutated in-place
539 buff
540 Collection of buffer bytes, will be mutated in-place
541 blob
542 Candidate accessor
543 data
544 Data to fill in details to blob
545 claimed
546 If passed store `data` here keyed by the returned accessor index and
547 don't write it into `buff`, for an extension which stores it itself.
549 Returns
550 ----------
551 index : int
552 Index of accessor that was added or reused.
553 """
554 # if we have data include that in the key
555 as_bytes = data.tobytes()
556 if hasattr(data, "hash_fast"):
557 # passed a TrackedArray object
558 hashed = data.hash_fast()
559 else:
560 # someone passed a vanilla numpy array
561 hashed = hash_fast(as_bytes)
563 if claimed is not None:
564 # an extension stores this itself: `byteOffset` is only valid alongside a
565 # `bufferView` and an accessor with neither can't collide with a stored copy
566 blob.pop("byteOffset", None)
567 elif hashed in buff:
568 blob["bufferView"] = buff.index(hashed)
569 else:
570 # not in buffer items so append and then return index
571 buff[hashed] = _byte_pad(as_bytes)
572 blob["bufferView"] = len(buff) - 1
574 # start by hashing the dict blob
575 # note that this will not work if a value is a list
576 try:
577 # simple keys can be hashed as tuples without JSON
578 key = hash(tuple(blob.items()))
579 except BaseException:
580 # if there are list keys that break the simple hash
581 key = hash(json.dumps(blob, sort_keys=True))
583 # xor the hash for the blob to the key
584 key ^= hashed
586 # a claim records here too as this is another primitive's accessor
587 if key in acc:
588 index = acc.index(key)
589 if claimed is not None:
590 claimed[index] = data
591 return index
593 # get a numpy dtype for our components
594 dtype = np.dtype(_dtypes[blob["componentType"]])
595 # see if we're an array, matrix, etc
596 kind = blob["type"]
598 if tol.strict:
599 # in unit tests make sure everything we're trying to export
600 # is finite, which also checks for accidental NaN values
601 assert np.isfinite(data).all()
603 if kind == "SCALAR":
604 # is probably (n, 1)
605 blob["count"] = int(np.prod(data.shape))
606 blob["max"] = np.array([data.max()], dtype=dtype).tolist()
607 blob["min"] = np.array([data.min()], dtype=dtype).tolist()
608 elif kind.startswith("MAT"):
609 # i.e. (n, 4, 4) matrices
610 blob["count"] = len(data)
611 else:
612 # reshape the data into what we're actually exporting
613 resh = data.reshape((-1, _shapes[kind]))
614 blob["count"] = len(resh)
615 blob["max"] = resh.max(axis=0).astype(dtype).tolist()
616 blob["min"] = resh.min(axis=0).astype(dtype).tolist()
618 # store the accessor and return the index
619 acc[key] = blob
620 index = len(acc) - 1
621 if claimed is not None:
622 claimed[index] = data
623 return index
626def _jsonify(blob):
627 """
628 Roundtrip a blob through json export-import cycle
629 skipping any internal keys.
630 """
631 return json.loads(
632 util.jsonify({k: v for k, v in blob.items() if not k.startswith("_")})
633 )
636def _create_gltf_structure(
637 scene,
638 include_normals=None,
639 include_metadata=True,
640 unitize_normals=None,
641 buffer_postprocessor=None,
642 extension_webp=False,
643 extension_draco=False,
644):
645 """
646 Generate a GLTF header.
648 Parameters
649 -------------
650 scene : trimesh.Scene
651 Input scene data
652 include_metadata : bool
653 Include `scene.metadata` as `scenes/{idx}/extras/metadata`
654 include_normals : bool
655 Include vertex normals in output file?
656 unitize_normals : bool
657 Unitize all exported normals so as to pass GLTF validation
658 extension_webp : bool
659 Export textures as webP using EXT_texture_webp extension.
660 extension_draco : bool
661 Compress mesh data using Draco (KHR_draco_mesh_compression).
663 Returns
664 ---------------
665 tree : dict
666 Contains required keys for a GLTF scene
667 buffer_items : list
668 Contains bytes of data
669 """
670 if extension_draco:
671 # fail once here rather than warning per mesh and writing an uncompressed file
672 import DracoPy # noqa: F401
674 # we are defining a single scene, and will be setting the
675 # world node to the 0-index
676 tree = {
677 "scene": 0,
678 # the root node indices are filled in from the scene graph
679 "scenes": [{}],
680 "asset": {"version": "2.0", "generator": "https://github.com/mikedh/trimesh"},
681 "accessors": IndexedDict(),
682 "meshes": [],
683 "images": [],
684 "textures": [],
685 "materials": [],
686 }
688 if scene.has_camera:
689 tree["cameras"] = [_convert_camera(scene.camera)]
691 if include_metadata and len(scene.metadata) > 0:
692 try:
693 # fail here if data isn't json compatible
694 # only export the extras if there is something there
695 tree["scenes"][0]["extras"] = _jsonify(scene.metadata)
696 extensions = tree["scenes"][0]["extras"].pop("gltf_extensions", None)
697 if isinstance(extensions, dict):
698 tree["extensions"] = extensions
699 except BaseException:
700 log.debug("failed to export scene metadata!", exc_info=True)
702 # store materials as {hash : index} to avoid duplicates
703 mat_hashes = {}
704 # store data from geometries
705 buffer_items = IndexedDict()
707 # map the name of each mesh to the index in tree['meshes']
708 mesh_index = {}
709 previous = len(tree["meshes"])
711 # loop through every geometry
712 for name, geometry in scene.geometry.items():
713 if util.is_instance_named(geometry, "Trimesh"):
714 # add the mesh
715 _append_mesh(
716 mesh=geometry,
717 name=name,
718 tree=tree,
719 buffer_items=buffer_items,
720 include_normals=include_normals,
721 unitize_normals=unitize_normals,
722 mat_hashes=mat_hashes,
723 extension_webp=extension_webp,
724 extension_draco=extension_draco,
725 )
726 elif util.is_instance_named(geometry, "Path"):
727 # add Path2D and Path3D objects
728 _append_path(path=geometry, name=name, tree=tree, buffer_items=buffer_items)
729 elif util.is_instance_named(geometry, "PointCloud"):
730 # add PointCloud objects
731 _append_point(
732 points=geometry, name=name, tree=tree, buffer_items=buffer_items
733 )
735 # only store the index if the append did anything
736 if len(tree["meshes"]) != previous:
737 previous = len(tree["meshes"])
738 mesh_index[name] = previous - 1
740 # grab the flattened scene graph in GLTF's format
741 nodes = scene.graph.to_gltf(scene=scene, mesh_index=mesh_index)
742 # set the roots on the existing scene dict — it may already
743 # hold `extras` with the scene metadata
744 tree["scenes"][0]["nodes"] = nodes.pop("scene_roots")
745 tree.update(nodes)
747 extensions_used = set()
748 extensions_required = set()
749 # Add any scene extensions used
750 if "extensions" in tree:
751 extensions_used = extensions_used.union(set(tree["extensions"].keys()))
752 # Add any mesh extensions used
753 for mesh in tree["meshes"]:
754 if "extensions" in mesh:
755 extensions_used = extensions_used.union(set(mesh["extensions"].keys()))
756 # Check primitives for extensions too
757 for prim in mesh.get("primitives", []):
758 if "extensions" in prim:
759 extensions_used = extensions_used.union(set(prim["extensions"].keys()))
760 # Add any extensions already in the tree (e.g. node extensions)
761 if "extensionsUsed" in tree:
762 extensions_used = extensions_used.union(set(tree["extensionsUsed"]))
763 # Add WebP if used
764 if extension_webp:
765 extensions_used.add("EXT_texture_webp")
766 extensions_required.add("EXT_texture_webp")
767 # draco has no fallback so it is required, but only if a primitive really got
768 # compressed: a file requiring an extension nothing uses is refused by loaders
769 if "KHR_draco_mesh_compression" in extensions_used:
770 extensions_required.add("KHR_draco_mesh_compression")
771 if len(extensions_used) > 0:
772 tree["extensionsUsed"] = list(extensions_used)
773 if len(extensions_required) > 0:
774 tree["extensionsRequired"] = list(extensions_required)
776 if buffer_postprocessor is not None:
777 buffer_postprocessor(buffer_items, tree)
779 # convert accessors back to a flat list
780 tree["accessors"] = list(tree["accessors"].values())
782 # cull empty or unpopulated fields
783 # check keys that might be empty so we can remove them
784 check = ["textures", "materials", "images", "accessors", "meshes"]
785 # remove the keys with nothing stored in them
786 [tree.pop(key) for key in check if len(tree[key]) == 0]
788 return tree, buffer_items
791def _append_mesh(
792 mesh,
793 name,
794 tree,
795 buffer_items,
796 include_normals: bool | None,
797 unitize_normals: bool,
798 mat_hashes: dict,
799 extension_webp: bool,
800 extension_draco: bool = False,
801):
802 """
803 Append a mesh to the scene structure and put the
804 data into buffer_items.
806 Parameters
807 -------------
808 mesh : trimesh.Trimesh
809 Source geometry
810 name : str
811 Name of geometry
812 tree : dict
813 Will be updated with data from mesh
814 buffer_items
815 Will have buffer appended with mesh data
816 include_normals : bool
817 Include vertex normals in export or not
818 unitize_normals : bool
819 Transform normals into unit vectors.
820 May be undesirable but will fail validators without this.
822 mat_hashes : dict
823 Which materials have already been added
824 extension_webp : bool
825 Export textures as webP (using glTF's EXT_texture_webp extension).
826 extension_draco : bool
827 Compress mesh data using Draco (KHR_draco_mesh_compression).
828 """
829 # return early from empty meshes to avoid crashing later
830 if len(mesh.faces) == 0 or len(mesh.vertices) == 0:
831 log.debug("skipping empty mesh!")
832 return
834 # draco absorbs geometry into a buffer of its own, so collect the arrays as
835 # they are appended for the handler below rather than storing each one twice
836 claimed = {} if extension_draco else None
838 # convert mesh data to the correct dtypes
839 # faces: 5125 is an unsigned 32 bit integer
840 # accessors refer to data locations
841 # mesh faces are stored as flat list of integers
842 acc_face = _data_append(
843 acc=tree["accessors"],
844 buff=buffer_items,
845 blob={"componentType": 5125, "type": "SCALAR"},
846 data=mesh.faces.astype(uint32),
847 claimed=claimed,
848 )
850 # vertices: 5126 is a float32
851 # create or reuse an accessor for these vertices
852 acc_vertex = _data_append(
853 acc=tree["accessors"],
854 buff=buffer_items,
855 blob={"componentType": 5126, "type": "VEC3", "byteOffset": 0},
856 data=mesh.vertices.astype(float32),
857 claimed=claimed,
858 )
860 # meshes reference accessor indexes
861 current = {
862 "name": name,
863 "extras": {},
864 "primitives": [
865 {
866 "attributes": {"POSITION": acc_vertex},
867 "indices": acc_face,
868 "mode": _GL_TRIANGLES,
869 }
870 ],
871 }
872 # if units are defined, store them as an extra
873 # the GLTF spec says everything is implicit meters
874 # we're not doing that as our unit conversions are expensive
875 # although that might be better, implicit works for 3DXML
876 # https://github.com/KhronosGroup/glTF/tree/master/extensions
877 try:
878 # skip jsonify any metadata, skipping internal keys
879 current["extras"] = _jsonify(mesh.metadata)
881 # extract extensions if any
882 extensions = current["extras"].pop("gltf_extensions", None)
883 if isinstance(extensions, dict):
884 current["extensions"] = extensions
886 if mesh.units not in [None, "m", "meters", "meter"]:
887 current["extras"]["units"] = str(mesh.units)
888 except BaseException:
889 log.debug("metadata not serializable, dropping!", exc_info=True)
891 # check to see if we have vertex or face colors
892 # or if a TextureVisual has colors included as an attribute
893 if mesh.visual.kind in ["vertex", "face"]:
894 vertex_colors = mesh.visual.vertex_colors
895 elif (
896 hasattr(mesh.visual, "vertex_attributes")
897 and "color" in mesh.visual.vertex_attributes
898 ):
899 vertex_colors = mesh.visual.vertex_attributes["color"]
900 else:
901 vertex_colors = None
903 if vertex_colors is not None:
904 if len(vertex_colors) == len(mesh.vertices):
905 # convert color data to bytes and append
906 acc_color = _data_append(
907 acc=tree["accessors"],
908 buff=buffer_items,
909 blob={
910 "componentType": 5121,
911 "normalized": True,
912 "type": "VEC4",
913 "byteOffset": 0,
914 },
915 data=vertex_colors.astype(uint8),
916 claimed=claimed,
917 )
919 # add the reference for vertex color
920 current["primitives"][0]["attributes"]["COLOR_0"] = acc_color
921 else:
922 log.warning(
923 "Vertex colors have different length than mesh vertices, dropping!"
924 )
926 if hasattr(mesh.visual, "material"):
927 # append the material and then set from returned index
928 current_material = _append_material(
929 mat=mesh.visual.material,
930 tree=tree,
931 buffer_items=buffer_items,
932 mat_hashes=mat_hashes,
933 extension_webp=extension_webp,
934 )
936 # if mesh has UV coordinates defined export them
937 has_uv = (
938 hasattr(mesh.visual, "uv")
939 and mesh.visual.uv is not None
940 and len(mesh.visual.uv) == len(mesh.vertices)
941 )
942 if has_uv:
943 # slice off W if passed
944 uv = mesh.visual.uv.copy()[:, :2]
945 # reverse the Y for GLTF
946 uv[:, 1] = 1.0 - uv[:, 1]
947 # add an accessor describing the blob of UV's
948 acc_uv = _data_append(
949 acc=tree["accessors"],
950 buff=buffer_items,
951 blob={"componentType": 5126, "type": "VEC2", "byteOffset": 0},
952 data=uv.astype(float32),
953 claimed=claimed,
954 )
955 # add the reference for UV coordinates
956 current["primitives"][0]["attributes"]["TEXCOORD_0"] = acc_uv
958 # reference the material
959 current["primitives"][0]["material"] = current_material
961 if include_normals or (
962 include_normals is None and "vertex_normals" in mesh._cache.cache
963 ):
964 # store vertex normals if requested
965 if unitize_normals:
966 normals = util.unitize(mesh.vertex_normals)
967 else:
968 # we don't have to copy them since
969 # they aren't being altered
970 normals = mesh.vertex_normals
972 acc_norm = _data_append(
973 acc=tree["accessors"],
974 buff=buffer_items,
975 blob={
976 "componentType": 5126,
977 "count": len(mesh.vertices),
978 "type": "VEC3",
979 "byteOffset": 0,
980 },
981 data=normals.astype(float32),
982 claimed=claimed,
983 )
984 # add the reference for vertex color
985 current["primitives"][0]["attributes"]["NORMAL"] = acc_norm
987 # for each attribute with a leading underscore, assign them to trimesh
988 # vertex_attributes
989 for key, attrib in mesh.vertex_attributes.items():
990 # make sure vertex attribute length matches vertices
991 if len(attrib) != len(mesh.vertices):
992 log.warning(
993 f"Vertex attribute `{key}` has different length than mesh vertices skipping!"
994 )
995 continue
997 # application specific attributes must be prefixed with an underscore
998 if not key.startswith("_"):
999 key = "_" + key
1001 # GLTF has no floating point type larger than 32 bits so clip
1002 # any float64 or larger to float32
1003 if attrib.dtype.kind == "f" and attrib.dtype.itemsize > 4:
1004 data = attrib.astype(float32)
1005 else:
1006 # force little-endian to match GLTF binary format
1007 data = attrib.astype(attrib.dtype.newbyteorder("<"), copy=False)
1009 if len(data.shape) == 1:
1010 data = data[:, np.newaxis]
1012 # every accessor VALUE must be 4-byte aligned
1013 row_mod = (data.shape[1] * data.dtype.itemsize) % 4
1014 # if the row size is not a multiple of 4, pad it
1015 if row_mod != 0:
1016 # how many columns of padding for this value
1017 pad_columns = (4 - row_mod) // data.dtype.itemsize
1018 # pad this custom attribute with zeros -_-
1019 data = np.pad(data, ((0, 0), (0, pad_columns)), mode="constant")
1021 # store custom vertex attributes
1022 current["primitives"][0]["attributes"][key] = _data_append(
1023 acc=tree["accessors"],
1024 buff=buffer_items,
1025 blob=_build_accessor(data),
1026 data=data,
1027 )
1029 # Handle Draco compression via extension handler
1030 if extension_draco:
1031 # Call primitive_export handlers
1032 compressed = handle_extensions(
1033 extensions={"KHR_draco_mesh_compression": {}},
1034 scope="primitive_export",
1035 mesh=mesh,
1036 name=name,
1037 tree=tree,
1038 buffer_items=buffer_items,
1039 primitive=current["primitives"][0],
1040 arrays=claimed,
1041 )
1042 if not compressed:
1043 # nothing claimed the arrays so store them after all, keyed by accessor
1044 # not content: two of these landing on one view would need a `byteStride`
1045 blobs = list(tree["accessors"].values())
1046 for index, data in claimed.items():
1047 key = ("accessor", index)
1048 buffer_items[key] = _byte_pad(data.tobytes())
1049 blobs[index]["bufferView"] = buffer_items.index(key)
1050 blobs[index]["byteOffset"] = 0
1052 tree["meshes"].append(current)
1055def _build_views(buffer_items):
1056 """
1057 Create views for buffers that are simply
1058 based on how many bytes they are long.
1060 Parameters
1061 --------------
1062 buffer_items : IndexedDict
1063 Buffers to build views for
1065 Returns
1066 ----------
1067 views : (n,) list of dict
1068 GLTF views
1069 """
1070 views = []
1071 # create the buffer views
1072 current_pos = 0
1073 for current_item in buffer_items.values():
1074 views.append(
1075 {"buffer": 0, "byteOffset": current_pos, "byteLength": len(current_item)}
1076 )
1077 assert (current_pos % 4) == 0
1078 assert (len(current_item) % 4) == 0
1079 current_pos += len(current_item)
1080 return views
1083def _build_accessor(array):
1084 """
1085 Build an accessor for an arbitrary array.
1087 Parameters
1088 -----------
1089 array : numpy array
1090 The array to build an accessor for
1092 Returns
1093 ----------
1094 accessor : dict
1095 The accessor for array.
1096 """
1097 shape = array.shape
1098 data_type = "SCALAR"
1099 if len(shape) == 2:
1100 vec_length = shape[1]
1101 if vec_length > 4:
1102 raise ValueError("The GLTF spec does not support vectors larger than 4")
1103 if vec_length > 1:
1104 data_type = f"VEC{int(vec_length)}"
1105 else:
1106 data_type = "SCALAR"
1108 if len(shape) == 3:
1109 if shape[2] not in [2, 3, 4]:
1110 raise ValueError("Matrix types must have 4, 9 or 16 components")
1111 data_type = f"MAT{int(shape[2])}"
1113 # get the array data type as a str stripping off endian
1114 lookup = array.dtype.str.lstrip("<>|")
1116 if lookup == "u4":
1117 # spec: UNSIGNED_INT is only allowed when the accessor
1118 # contains indices i.e. the accessor is only referenced
1119 # by `primitive.indices`
1120 log.debug("custom uint32 may cause validation failures")
1122 # map the numpy dtype to a GLTF code (i.e. 5121)
1123 componentType = _dtypes_lookup[lookup]
1124 accessor = {"componentType": componentType, "type": data_type, "byteOffset": 0}
1126 if len(shape) < 3:
1127 accessor["max"] = array.max(axis=0).tolist()
1128 accessor["min"] = array.min(axis=0).tolist()
1130 return accessor
1133def _byte_pad(data, bound=4):
1134 """
1135 GLTF wants chunks aligned with 4 byte boundaries.
1136 This function will add padding to the end of a
1137 chunk of bytes so that it aligns with the passed
1138 boundary size.
1140 Parameters
1141 --------------
1142 data : bytes
1143 Data to be padded
1144 bound : int
1145 Length of desired boundary
1147 Returns
1148 --------------
1149 padded : bytes
1150 Result where: (len(padded) % bound) == 0
1151 """
1152 assert isinstance(data, bytes)
1153 if len(data) % bound != 0:
1154 # extra bytes to pad with
1155 count = bound - (len(data) % bound)
1156 pad = bytes(count)
1157 # combine the padding and data
1158 result = b"".join([data, pad])
1159 # we should always divide evenly
1160 if tol.strict and (len(result) % bound) != 0:
1161 raise ValueError("byte_pad failed!")
1162 return result
1163 return data
1166def _append_path(path, name, tree, buffer_items):
1167 """
1168 Append a 2D or 3D path to the scene structure and put the
1169 data into buffer_items.
1171 Parameters
1172 -------------
1173 path : trimesh.Path2D or trimesh.Path3D
1174 Source geometry
1175 name : str
1176 Name of geometry
1177 tree : dict
1178 Will be updated with data from path
1179 buffer_items
1180 Will have buffer appended with path data
1181 """
1183 # convert the path to the unnamed args for
1184 # a pyglet vertex list
1185 vxlist = rendering.path_to_vertexlist(path)
1187 # of the count of things to export is zero exit early
1188 if vxlist[0] == 0:
1189 return
1191 # TODO add color support to Path object
1192 # this is just exporting everying as black
1193 try:
1194 material_idx = tree["materials"].index(_default_material)
1195 except ValueError:
1196 material_idx = len(tree["materials"])
1197 tree["materials"].append(_default_material)
1199 # data is the second value of the fifth field
1200 # which is a (data type, data) tuple
1201 acc_vertex = _data_append(
1202 acc=tree["accessors"],
1203 buff=buffer_items,
1204 blob={"componentType": 5126, "type": "VEC3", "byteOffset": 0},
1205 data=vxlist[4][1].astype(float32),
1206 )
1208 current = {
1209 "name": name,
1210 "primitives": [
1211 {
1212 "attributes": {"POSITION": acc_vertex},
1213 "mode": _GL_LINES, # i.e. 1
1214 "material": material_idx,
1215 }
1216 ],
1217 }
1219 # if units are defined, store them as an extra:
1220 # https://github.com/KhronosGroup/glTF/tree/master/extensions
1221 try:
1222 current["extras"] = _jsonify(path.metadata)
1223 except BaseException:
1224 log.debug("failed to serialize metadata, dropping!", exc_info=True)
1226 if path.colors is not None:
1227 acc_color = _data_append(
1228 acc=tree["accessors"],
1229 buff=buffer_items,
1230 blob={
1231 "componentType": 5121,
1232 "normalized": True,
1233 "type": "VEC4",
1234 "byteOffset": 0,
1235 },
1236 data=np.array(vxlist[5][1]).astype(uint8),
1237 )
1238 # add color to attributes
1239 current["primitives"][0]["attributes"]["COLOR_0"] = acc_color
1241 # for each attribute with a leading underscore, assign them to path
1242 # vertex_attributes
1243 for key, attrib in path.vertex_attributes.items():
1244 # Application specific attributes must be
1245 # prefixed with an underscore
1246 if not key.startswith("_"):
1247 key = "_" + key
1249 # GLTF has no floating point type larger than 32 bits so clip
1250 # any float64 or larger to float32
1251 if attrib.dtype.kind == "f" and attrib.dtype.itemsize > 4:
1252 data = attrib.astype(float32)
1253 else:
1254 # force little-endian to match GLTF binary format
1255 data = attrib.astype(attrib.dtype.newbyteorder("<"), copy=False)
1257 if not all(util.is_instance_named(e, "Line") for e in path.entities):
1258 log.warning(
1259 f"Vertex attributes are only supported for Line entities, skipping `{key}`"
1260 )
1261 continue
1263 data_discretized = np.array(
1264 [util.stack_lines(e.discrete(data)) for e in path.entities]
1265 )
1266 stacked_data = data_discretized.reshape((-1,))
1268 # store custom vertex attributes
1269 current["primitives"][0]["attributes"][key] = _data_append(
1270 acc=tree["accessors"],
1271 buff=buffer_items,
1272 blob=_build_accessor(stacked_data),
1273 data=stacked_data,
1274 )
1276 tree["meshes"].append(current)
1279def _append_point(points, name, tree, buffer_items):
1280 """
1281 Append a 2D or 3D pointCloud to the scene structure and
1282 put the data into buffer_items.
1284 Parameters
1285 -------------
1286 points : trimesh.PointCloud
1287 Source geometry
1288 name : str
1289 Name of geometry
1290 tree : dict
1291 Will be updated with data from points
1292 buffer_items
1293 Will have buffer appended with points data
1294 """
1296 # convert the points to the unnamed args for
1297 # a pyglet vertex list
1298 vxlist = rendering.points_to_vertexlist(points=points.vertices, colors=points.colors)
1300 # data is the second value of the fifth field
1301 # which is a (data type, data) tuple
1302 acc_vertex = _data_append(
1303 acc=tree["accessors"],
1304 buff=buffer_items,
1305 blob={"componentType": 5126, "type": "VEC3", "byteOffset": 0},
1306 data=vxlist[4][1].astype(float32),
1307 )
1308 current = {
1309 "name": name,
1310 "primitives": [
1311 {
1312 "attributes": {"POSITION": acc_vertex},
1313 "mode": _GL_POINTS,
1314 "material": len(tree["materials"]),
1315 }
1316 ],
1317 }
1319 # TODO add color support to Points object
1320 # this is just exporting everying as black
1321 tree["materials"].append(_default_material)
1323 if len(np.shape(points.colors)) == 2:
1324 # colors may be returned as "c3f" or other RGBA
1325 color_type, color_data = vxlist[5]
1326 if "3" in color_type:
1327 kind = "VEC3"
1328 elif "4" in color_type:
1329 kind = "VEC4"
1330 else:
1331 raise ValueError("unknown color: %s", color_type)
1332 acc_color = _data_append(
1333 acc=tree["accessors"],
1334 buff=buffer_items,
1335 blob={
1336 "componentType": 5121,
1337 "count": vxlist[0],
1338 "normalized": True,
1339 "type": kind,
1340 "byteOffset": 0,
1341 },
1342 data=np.array(color_data).astype(uint8),
1343 )
1344 # add color to attributes
1345 current["primitives"][0]["attributes"]["COLOR_0"] = acc_color
1346 tree["meshes"].append(current)
1349def _parse_textures(header, views, resolver=None):
1350 try:
1351 import PIL.Image
1352 except ImportError:
1353 log.debug("unable to load textures without pillow!")
1354 return None
1356 # load any images
1357 images = None
1358 if "images" in header:
1359 # images are referenced by index
1360 images = [None] * len(header["images"])
1361 # loop through images
1362 for i, img in enumerate(header["images"]):
1363 if img.get("mimeType", "") == "image/ktx2":
1364 log.debug("`image/ktx2` textures are unsupported, skipping!")
1365 continue
1366 # get the bytes representing an image
1367 if "bufferView" in img:
1368 blob = views[img["bufferView"]]
1369 elif "uri" in img:
1370 try:
1371 # will get bytes from filesystem or base64 URI
1372 blob = _uri_to_bytes(uri=img["uri"], resolver=resolver)
1373 except BaseException:
1374 log.debug(f"unable to load image from: {img.keys()}", exc_info=True)
1375 continue
1376 else:
1377 log.debug(f"unable to load image from: {img.keys()}")
1378 continue
1379 # i.e. 'image/jpeg'
1380 # mime = img['mimeType']
1381 try:
1382 # load the buffer into a PIL image
1383 images[i] = PIL.Image.open(util.wrap_as_stream(blob))
1384 except BaseException:
1385 log.debug("failed to load image!", exc_info=True)
1386 return images
1389def _parse_materials(header, views, resolver=None):
1390 """
1391 Convert materials and images stored in a GLTF header
1392 and buffer views to PBRMaterial objects.
1394 Parameters
1395 ------------
1396 header : dict
1397 Contains layout of file
1398 views : (n,) bytes
1399 Raw data
1401 Returns
1402 ------------
1403 materials : list
1404 List of trimesh.visual.texture.Material objects
1405 """
1407 def parse_textures(*, data):
1408 result = {}
1409 for k, v in data.items():
1410 if isinstance(v, (list, tuple)):
1411 # colors are always float 0.0 - 1.0 in GLTF
1412 result[k] = np.array(v, dtype=np.float64)
1413 elif not isinstance(v, dict):
1414 result[k] = v
1415 elif images is not None and "index" in v:
1416 try:
1417 index = None
1418 texture = header["textures"][v["index"]]
1419 # Handle texture extensions through registry
1420 if tex_ext := texture.get("extensions"):
1421 index = handle_extensions(
1422 extensions=tex_ext, scope="texture_source"
1423 )
1425 if index is None:
1426 # fall back to standard source key
1427 index = texture.get("source")
1428 if index is not None:
1429 result[k] = images[index]
1430 except BaseException:
1431 log.debug("unable to store texture", exc_info=True)
1432 return result
1434 images = _parse_textures(header, views, resolver)
1436 # store materials which reference images
1437 materials = []
1438 if "materials" in header:
1439 for mat in header["materials"]:
1440 # flatten key structure so we can loop it
1441 loopable = mat.copy()
1442 # this key stores another dict of crap
1443 if "pbrMetallicRoughness" in loopable:
1444 # add keys of keys to top level dict
1445 loopable.update(loopable.pop("pbrMetallicRoughness"))
1447 # Handle material extensions through registry
1448 if mat_extensions := mat.get("extensions"):
1449 ext_results = handle_extensions(
1450 extensions=mat_extensions,
1451 scope="material",
1452 parse_textures=parse_textures,
1453 images=images,
1454 )
1455 # Flatten extension results into the material parameters
1456 for ext_result in ext_results.values():
1457 if isinstance(ext_result, dict):
1458 loopable.update(ext_result)
1460 # save flattened keys we can use for kwargs
1461 pbr = parse_textures(data=loopable)
1462 # create a PBR material object for the GLTF material
1463 materials.append(visual.material.PBRMaterial(**pbr))
1465 return materials
1468def _read_buffers(
1469 header: dict,
1470 buffers: list[bytes],
1471 mesh_kwargs: dict,
1472 resolver: ResolverLike | None,
1473 ignore_broken: bool = False,
1474 merge_primitives: bool = False,
1475 skip_materials: bool = False,
1476):
1477 """
1478 Given binary data and a layout return the
1479 kwargs to create a scene object.
1481 Parameters
1482 -----------
1483 header : dict
1484 With GLTF keys
1485 buffers : list of bytes
1486 Stored data
1487 mesh_kwargs : dict
1488 To be passed to the mesh constructor.
1489 ignore_broken : bool
1490 If there is a mesh we can't load and this
1491 is True don't raise an exception but return
1492 a partial result
1493 merge_primitives : bool
1494 If true, combine primitives into a single mesh.
1495 skip_materials : bool
1496 If true, will not load materials (if present).
1497 resolver : trimesh.resolvers.Resolver
1498 Resolver to load referenced assets
1500 Returns
1501 -----------
1502 kwargs : dict
1503 Can be passed to load_kwargs for a trimesh.Scene
1504 """
1506 if "bufferViews" in header:
1507 # split buffer data into buffer views
1508 views = [None] * len(header["bufferViews"])
1509 for i, view in enumerate(header["bufferViews"]):
1510 if "byteOffset" in view:
1511 start = view["byteOffset"]
1512 else:
1513 start = 0
1514 end = start + view["byteLength"]
1515 views[i] = buffers[view["buffer"]][start:end]
1516 assert len(views[i]) == view["byteLength"]
1517 # load data from buffers into numpy arrays
1518 # using the layout described by accessors
1519 access = [None] * len(header["accessors"])
1520 # bufferless, non-sparse accessors must be filled by an extension or stay zero
1521 placeholders = set()
1522 for index, a in enumerate(header["accessors"]):
1523 # number of items
1524 count = a["count"]
1525 # what is the datatype
1526 dtype = np.dtype(_dtypes[a["componentType"]])
1527 # basically how many columns
1528 # for types like (4, 4)
1529 per_item = _shapes[a["type"]]
1530 # use reported count to generate shape
1531 shape = np.append(count, per_item)
1532 # number of items when flattened
1533 # i.e. a (4, 4) MAT4 has 16
1534 per_count = np.abs(np.prod(per_item))
1535 if "bufferView" in a:
1536 # data was stored in a buffer view so get raw bytes
1538 # load the bytes data into correct dtype and shape
1539 buffer_view = header["bufferViews"][a["bufferView"]]
1541 # is the accessor offset in a buffer
1542 # will include the start, length, and offset
1543 # but not the bytestride as that is easier to do
1544 # in numpy rather than in python looping
1545 data = views[a["bufferView"]]
1547 # both bufferView *and* accessors are allowed
1548 # to have a byteOffset
1549 start = a.get("byteOffset", 0)
1551 if "byteStride" in buffer_view:
1552 # how many bytes for each chunk
1553 stride = buffer_view["byteStride"]
1554 # we want to get the bytes for every row
1555 per_row = per_count * dtype.itemsize
1556 # the total block we're looking at
1557 length = (count - 1) * stride + per_row
1558 # apply as_strided for fast construction of strided array
1559 # and copy to ensure contiguous layout
1560 assert stride > 0, "byteStride should be positive"
1561 assert 0 <= start <= start + length <= len(data)
1562 access[index] = np.array(
1563 np.lib.stride_tricks.as_strided(
1564 np.frombuffer(
1565 data, dtype=np.uint8, offset=start, count=length
1566 ),
1567 [count, per_row],
1568 [stride, 1],
1569 )
1570 .view(dtype)
1571 .reshape(shape)
1572 )
1573 else:
1574 # length is the number of bytes per item times total
1575 length = dtype.itemsize * count * per_count
1576 access[index] = np.frombuffer(
1577 data[start : start + length], dtype=dtype
1578 ).reshape(shape)
1579 else:
1580 # zero placeholder a decoder may replace
1581 if "sparse" not in a:
1582 placeholders.add(index)
1583 access[index] = np.zeros(count * per_count, dtype=dtype).reshape(shape)
1585 # possibly load images and textures into material objects
1586 if skip_materials:
1587 materials = []
1588 else:
1589 materials = _parse_materials(header, views=views, resolver=resolver)
1591 mesh_prim = defaultdict(list)
1592 # load data from accessors into Trimesh objects
1593 meshes = OrderedDict()
1595 # keep track of how many times each name has been attempted to
1596 # be inserted to avoid a potentially slow search through our
1597 # dict of names
1598 name_counts = {}
1599 # extensions whose geometry we couldn't decode for lack of a handler
1600 undecoded = set()
1601 for index, m in enumerate(header.get("meshes", [])):
1602 try:
1603 # GLTF spec indicates implicit units are meters
1604 metadata = {
1605 "units": "meters",
1606 "from_gltf_primitive": len(m["primitives"]) > 1,
1607 }
1609 # try to load all mesh metadata
1610 if isinstance(m.get("extras"), dict):
1611 metadata.update(m["extras"])
1613 # put any mesh extensions in a field of the metadata
1614 if "extensions" in m:
1615 metadata["gltf_extensions"] = m["extensions"]
1617 for p in m["primitives"]:
1618 # preprocessing extensions like draco decompression run
1619 # before reading accessors as they may modify them
1620 if prim_extensions := p.get("extensions"):
1621 # a handler which raised can't have decoded anything
1622 failed = set()
1623 handle_extensions(
1624 extensions=prim_extensions,
1625 scope="primitive_preprocess",
1626 failed=failed,
1627 primitive=p,
1628 accessors=access,
1629 views=views,
1630 )
1631 # warn later if an extension left placeholder zeros, whether
1632 # it had no handler or its handler failed
1633 if not placeholders.isdisjoint(p.get("attributes", {}).values()):
1634 undecoded.update(failed)
1635 undecoded.update(
1636 unregistered(prim_extensions, "primitive_preprocess")
1637 )
1639 # if we don't have a triangular mesh continue
1640 # if not specified assume it is a mesh
1641 kwargs = deepcopy(mesh_kwargs)
1642 if kwargs.get("metadata", None) is None:
1643 kwargs["metadata"] = {}
1644 if "process" not in kwargs:
1645 kwargs["process"] = False
1646 kwargs["metadata"].update(metadata)
1647 # i.e. GL_LINES, GL_TRIANGLES, etc
1648 # specification says the default mode is GL_TRIANGLES
1649 mode = p.get("mode", _GL_TRIANGLES)
1650 # colors, normals, etc
1651 attr = p["attributes"]
1652 # create a unique mesh name per- primitive
1653 name = m.get("name", "GLTF")
1654 # make name unique across multiple meshes
1655 name = unique_name(name, meshes, counts=name_counts)
1657 if mode == _GL_LINES:
1658 # load GL_LINES into a Path object
1659 from ...path.entities import Line
1661 kwargs["vertices"] = access[attr["POSITION"]]
1662 kwargs["entities"] = [Line(points=np.arange(len(kwargs["vertices"])))]
1664 # custom attributes starting with a `_`
1665 custom = {
1666 a: access[attr[a]] for a in attr.keys() if a.startswith("_")
1667 }
1668 if len(custom) > 0:
1669 kwargs["vertex_attributes"] = custom
1670 elif mode == _GL_POINTS:
1671 kwargs["vertices"] = access[attr["POSITION"]]
1672 visuals = None
1673 if "COLOR_0" in attr:
1674 try:
1675 # try to load vertex colors from the accessors
1676 colors = access[attr["COLOR_0"]]
1677 if len(colors) == len(kwargs["vertices"]):
1678 if visuals is None:
1679 # just pass to mesh as vertex color
1680 kwargs["vertex_colors"] = colors.copy()
1681 else:
1682 # we ALSO have texture so save as vertex
1683 # attribute
1684 visuals.vertex_attributes["color"] = colors.copy()
1685 except BaseException:
1686 # survive failed colors
1687 log.debug("failed to load colors", exc_info=True)
1688 if visuals is not None:
1689 kwargs["visual"] = visuals
1690 elif mode in (_GL_TRIANGLES, _GL_STRIP):
1691 # get vertices from accessors
1692 kwargs["vertices"] = access[attr["POSITION"]]
1693 # get faces from accessors
1694 if "indices" in p:
1695 if mode == _GL_STRIP:
1696 # this is triangle strips
1697 flat = access[p["indices"]].reshape(-1)
1698 kwargs["faces"] = triangle_strips_to_faces([flat])
1699 else:
1700 kwargs["faces"] = access[p["indices"]].reshape((-1, 3))
1701 else:
1702 # indices are apparently optional and we are supposed to
1703 # do the same thing as webGL drawArrays?
1704 if mode == _GL_STRIP:
1705 kwargs["faces"] = triangle_strips_to_faces(
1706 np.array([np.arange(len(kwargs["vertices"]))])
1707 )
1708 else:
1709 # GL_TRIANGLES
1710 kwargs["faces"] = np.arange(
1711 len(kwargs["vertices"]), dtype=np.int64
1712 ).reshape((-1, 3))
1714 if "NORMAL" in attr:
1715 # vertex normals are specified
1716 kwargs["vertex_normals"] = access[attr["NORMAL"]]
1717 # do we have UV coordinates
1718 visuals = None
1719 if "material" in p and not skip_materials:
1720 if materials is None:
1721 log.debug("no materials! `pip install pillow`")
1722 else:
1723 uv = None
1724 if "TEXCOORD_0" in attr:
1725 # flip UV's top- bottom to move origin to lower-left:
1726 # https://github.com/KhronosGroup/glTF/issues/1021
1727 uv = access[attr["TEXCOORD_0"]].copy()
1728 uv[:, 1] = 1.0 - uv[:, 1]
1729 # create a texture visual
1730 visuals = visual.texture.TextureVisuals(
1731 uv=uv, material=materials[p["material"]]
1732 )
1734 if "COLOR_0" in attr:
1735 try:
1736 # try to load vertex colors from the accessors
1737 colors = access[attr["COLOR_0"]]
1738 if len(colors) == len(kwargs["vertices"]):
1739 if visuals is None:
1740 # just pass to mesh as vertex color
1741 kwargs["vertex_colors"] = colors.copy()
1742 else:
1743 # we ALSO have texture so save as vertex
1744 # attribute
1745 visuals.vertex_attributes["color"] = colors.copy()
1746 except BaseException:
1747 # survive failed colors
1748 log.debug("failed to load colors", exc_info=True)
1749 if visuals is not None:
1750 kwargs["visual"] = visuals
1752 # custom attributes starting with a `_`
1753 custom = {
1754 a: access[attr[a]] for a in attr.keys() if a.startswith("_")
1755 }
1756 if len(custom) > 0:
1757 kwargs["vertex_attributes"] = custom
1759 # Process primitive-level extensions through registry
1760 if prim_extensions := p.get("extensions"):
1761 handle_extensions(
1762 extensions=prim_extensions,
1763 scope="primitive",
1764 primitive=p,
1765 mesh_kwargs=kwargs,
1766 accessors=access,
1767 )
1768 else:
1769 log.debug("skipping primitive with mode %s!", mode)
1770 continue
1771 # this should absolutely not be stomping on itself
1772 assert name not in meshes
1773 meshes[name] = kwargs
1774 mesh_prim[index].append(name)
1775 except BaseException as E:
1776 if ignore_broken:
1777 log.debug("failed to load mesh", exc_info=True)
1778 else:
1779 raise E
1781 if undecoded:
1782 log.warning(
1783 "`%s` GLTF extension didn't decode, values are placeholder zeros",
1784 ", ".join(sorted(undecoded)),
1785 )
1787 # sometimes GLTF "meshes" come with multiple "primitives"
1788 # by default we return one Trimesh object per "primitive"
1789 # but if merge_primitives is True we combine the primitives
1790 # for the "mesh" into a single Trimesh object
1791 if merge_primitives:
1792 # if we are only returning one Trimesh object
1793 # replace `mesh_prim` with updated values
1794 mesh_prim_replace = {}
1795 # these are the names of meshes we need to remove
1796 mesh_pop = set()
1797 for mesh_index, names in mesh_prim.items():
1798 if len(names) <= 1:
1799 mesh_prim_replace[mesh_index] = names
1800 continue
1802 # just take the shortest name option available
1803 name = min(names)
1804 # remove the other meshes after we're done looping
1805 # since we're reusing the shortest one don't pop
1806 # that as we'll be overwriting it with the combined
1807 mesh_pop.update(set(names).difference([name]))
1809 # get all meshes for this group
1810 current = [meshes[n] for n in names]
1811 v_seq = [p["vertices"] for p in current]
1812 f_seq = [p["faces"] for p in current]
1813 v, f = util.append_faces(v_seq, f_seq)
1814 materials = [p["visual"].material for p in current]
1815 face_materials = []
1816 for i, p in enumerate(current):
1817 face_materials += [i] * len(p["faces"])
1818 visuals = visual.texture.TextureVisuals(
1819 material=visual.material.MultiMaterial(materials=materials),
1820 face_materials=face_materials,
1821 )
1822 if "metadata" in meshes[names[0]]:
1823 metadata = meshes[names[0]]["metadata"]
1824 else:
1825 metadata = {}
1826 meshes[name] = {
1827 "vertices": v,
1828 "faces": f,
1829 "visual": visuals,
1830 "metadata": metadata,
1831 "process": False,
1832 }
1833 mesh_prim_replace[mesh_index] = [name]
1834 # avoid altering inside loop
1835 mesh_prim = mesh_prim_replace
1836 # remove outdated meshes
1837 [meshes.pop(p, None) for p in mesh_pop]
1839 # make it easier to reference nodes
1840 nodes = header.get("nodes", [])
1841 # nodes are referenced by index
1842 # save their string names if they have one
1843 # we have to accumulate in a for loop opposed
1844 # to a dict comprehension as it will be checking
1845 # the mutated dict in every loop
1846 name_index = {}
1847 name_counts = {}
1849 # store the mapping of node name to index and the inverse
1850 # name_index: {name: index}
1851 for i, n in enumerate(nodes):
1852 name_index[unique_name(n.get("name", str(i)), name_index, counts=name_counts)] = i
1853 # names: {index: name}
1854 names = {v: k for k, v in name_index.items()}
1856 # rename any file node that collides with the synthetic base frame
1857 # so its transform and children survive under their own frame —
1858 # trimesh's own exports never contain one, #2421
1859 world = name_index.get(DEFAULT_BASE_FRAME)
1860 if world is not None:
1861 names[world] = unique_name(DEFAULT_BASE_FRAME, set(names.values()))
1863 # traversal edges are seeded as (DEFAULT_BASE_FRAME, index) so the
1864 # index-keyed dict intentionally holds one string key for the base
1865 names[DEFAULT_BASE_FRAME] = DEFAULT_BASE_FRAME
1867 # visited, kwargs for scene.graph.update
1868 graph = deque()
1869 # unvisited, pairs of node indexes
1870 queue = deque()
1872 # camera(s), if they exist
1873 camera = None
1874 camera_transform = None
1876 if "scene" in header:
1877 # specify the index of scenes if specified
1878 scene_index = header["scene"]
1879 else:
1880 # otherwise just use the first index
1881 scene_index = 0
1883 if "scenes" in header:
1884 # start the traversal from the base frame to the roots
1885 for root in header["scenes"][scene_index].get("nodes", []):
1886 # add transform from base frame to these root nodes
1887 queue.append((DEFAULT_BASE_FRAME, root))
1889 # make sure we don't process an edge multiple times
1890 consumed = set()
1892 # go through the nodes tree to populate
1893 # kwargs for scene graph loader
1894 while len(queue) > 0:
1895 # (int, int) pair of node indexes
1896 edge = queue.pop()
1898 # avoid looping forever if someone specified
1899 # recursive nodes
1900 if edge in consumed:
1901 continue
1903 consumed.add(edge)
1904 a, b = edge
1906 # dict of child node
1907 # parent = nodes[a]
1908 child = nodes[b]
1909 # add edges of children to be processed
1910 if "children" in child:
1911 queue.extend([(b, i) for i in child["children"]])
1913 # kwargs to be passed to scene.graph.update
1914 kwargs = {"frame_from": names[a], "frame_to": names[b]}
1916 # grab matrix from child
1917 # parent -> child relationships have matrix stored in child
1918 # for the transform from parent to child
1919 if "matrix" in child:
1920 kwargs["matrix"] = (
1921 np.array(child["matrix"], dtype=np.float64).reshape((4, 4)).T
1922 )
1923 else:
1924 # if no matrix set identity
1925 kwargs["matrix"] = _EYE
1927 # Now apply keyword translations
1928 # GLTF applies these in order: T * R * S
1929 if "translation" in child:
1930 kwargs["matrix"] = np.dot(
1931 kwargs["matrix"], transformations.translation_matrix(child["translation"])
1932 )
1933 if "rotation" in child:
1934 # GLTF rotations are stored as (4,) XYZW unit quaternions
1935 # we need to re- order to our quaternion style, WXYZ
1936 quat = np.reshape(child["rotation"], 4)[[3, 0, 1, 2]]
1937 # add the rotation to the matrix
1938 kwargs["matrix"] = np.dot(
1939 kwargs["matrix"], transformations.quaternion_matrix(quat)
1940 )
1941 if "scale" in child:
1942 # add scale to the matrix
1943 kwargs["matrix"] = np.dot(
1944 kwargs["matrix"], np.diag(np.concatenate((child["scale"], [1.0])))
1945 )
1947 # If a camera exists, create the camera and dont add the node to the graph
1948 # TODO only process the first camera, ignore the rest
1949 # TODO assumes the camera node is child of the world frame
1950 # TODO will only read perspective camera
1951 if "camera" in child and camera is None:
1952 cam_idx = child["camera"]
1953 try:
1954 camera = _cam_from_gltf(header["cameras"][cam_idx])
1955 except KeyError:
1956 log.debug("GLTF camera is not fully-defined")
1957 if camera:
1958 camera_transform = kwargs["matrix"]
1959 continue
1961 # treat node metadata similarly to mesh metadata
1962 if isinstance(child.get("extras"), dict):
1963 kwargs["metadata"] = child["extras"]
1965 # put any node extensions in a field of the metadata
1966 if "extensions" in child:
1967 if "metadata" not in kwargs:
1968 kwargs["metadata"] = {}
1969 kwargs["metadata"]["gltf_extensions"] = child["extensions"]
1971 if "mesh" in child:
1972 geometries = mesh_prim[child["mesh"]]
1974 # if the node has a mesh associated with it
1975 if len(geometries) > 1:
1976 # append root node
1977 graph.append(kwargs.copy())
1978 # put primitives as children
1979 for geom_name in geometries:
1980 # save the name of the geometry
1981 kwargs["geometry"] = geom_name
1982 # no transformations
1983 kwargs["matrix"] = _EYE
1984 kwargs["frame_from"] = names[b]
1985 # if we have more than one primitive assign a new UUID
1986 # frame name for the primitives after the first one
1987 frame_to = f"{names[b]}_{util.unique_id(length=6)}"
1988 kwargs["frame_to"] = frame_to
1989 # append the edge with the mesh frame
1990 graph.append(kwargs.copy())
1991 elif len(geometries) == 1:
1992 kwargs["geometry"] = geometries[0]
1993 if "name" in child:
1994 kwargs["frame_to"] = names[b]
1995 graph.append(kwargs.copy())
1996 else:
1997 # if the node doesn't have any geometry just add
1998 graph.append(kwargs)
2000 # kwargs for load_kwargs
2001 result = {
2002 "class": "Scene",
2003 "geometry": meshes,
2004 "graph": graph,
2005 "base_frame": DEFAULT_BASE_FRAME,
2006 "camera": camera,
2007 "camera_transform": camera_transform,
2008 "metadata": {},
2009 }
2011 try:
2012 # load any scene extras into scene.metadata
2013 # use a try except to avoid nested key checks
2014 result["metadata"].update(header["scenes"][header["scene"]]["extras"])
2015 except BaseException:
2016 pass
2017 try:
2018 # load any scene extensions into a field of scene.metadata
2019 # use a try except to avoid nested key checks
2020 result["metadata"]["gltf_extensions"] = header["extensions"]
2021 except BaseException:
2022 pass
2024 return result
2027def _cam_from_gltf(cam):
2028 """
2029 Convert a gltf perspective camera to trimesh.
2031 The retrieved camera will have default resolution, since the gltf specification
2032 does not contain it.
2034 If the camera is not perspective will return None.
2035 If the camera is perspective but is missing fields, will raise `KeyError`
2037 Parameters
2038 ------------
2039 cam : dict
2040 Camera represented as a dictionary according to glTF
2042 Returns
2043 -------------
2044 camera : trimesh.scene.cameras.Camera
2045 Trimesh camera object
2046 """
2047 if "perspective" not in cam:
2048 return
2049 name = cam.get("name")
2050 znear = cam["perspective"]["znear"]
2051 aspect_ratio = cam["perspective"]["aspectRatio"]
2052 yfov = np.degrees(cam["perspective"]["yfov"])
2054 fov = (aspect_ratio * yfov, yfov)
2056 return Camera(name=name, fov=fov, z_near=znear)
2059def _convert_camera(camera):
2060 """
2061 Convert a trimesh camera to a GLTF camera.
2063 Parameters
2064 ------------
2065 camera : trimesh.scene.cameras.Camera
2066 Trimesh camera object
2068 Returns
2069 -------------
2070 gltf_camera : dict
2071 Camera represented as a GLTF dict
2072 """
2073 result = {
2074 "name": camera.name,
2075 "type": "perspective",
2076 "perspective": {
2077 "aspectRatio": camera.fov[0] / camera.fov[1],
2078 "yfov": np.radians(camera.fov[1]),
2079 "znear": float(camera.z_near),
2080 },
2081 }
2082 return result
2085def _append_image(img, tree, buffer_items, extension_webp):
2086 """
2087 Append a PIL image to a GLTF2.0 tree.
2089 Parameters
2090 ------------
2091 img : PIL.Image
2092 Image object
2093 tree : dict
2094 GLTF 2.0 format tree
2095 buffer_items : (n,) bytes
2096 Binary blobs containing data
2097 extension_webp : bool
2098 Export textures as webP (using glTF's EXT_texture_webp extension).
2100 Returns
2101 -----------
2102 index : int or None
2103 The index of the image in the tree
2104 None if image append failed for any reason
2105 """
2106 # probably not a PIL image so exit
2107 if not hasattr(img, "format"):
2108 return None
2110 if extension_webp:
2111 # support WebP if extension is specified
2112 save_as = "WEBP"
2113 elif img.format == "JPEG":
2114 # don't re-encode JPEGs
2115 save_as = "JPEG"
2116 else:
2117 # for everything else just use PNG
2118 save_as = "png"
2120 # get the image data into a bytes object
2121 with util.BytesIO() as f:
2122 img.save(f, format=save_as)
2123 f.seek(0)
2124 data = f.read()
2126 index = _buffer_append(buffer_items, data)
2127 # append buffer index and the GLTF-acceptable mimetype
2128 tree["images"].append({"bufferView": index, "mimeType": f"image/{save_as.lower()}"})
2130 # index is length minus one
2131 return len(tree["images"]) - 1
2134def _append_material(mat, tree, buffer_items, mat_hashes, extension_webp):
2135 """
2136 Add passed PBRMaterial as GLTF 2.0 specification JSON
2137 serializable data:
2138 - images are added to `tree['images']`
2139 - texture is added to `tree['texture']`
2140 - material is added to `tree['materials']`
2142 Parameters
2143 ------------
2144 mat : trimesh.visual.materials.PBRMaterials
2145 Source material to convert
2146 tree : dict
2147 GLTF header blob
2148 buffer_items : (n,) bytes
2149 Binary blobs with various data
2150 mat_hashes : dict
2151 Which materials have already been added
2152 Stored as { hashed : material index }
2153 extension_webp : bool
2154 Export textures as webP using EXT_texture_webp extension.
2156 Returns
2157 -------------
2158 index : int
2159 Index at which material was added
2160 """
2161 # materials are hashable
2162 hashed = hash(mat)
2163 # check stored material indexes to see if material
2164 # has already been added
2165 if mat_hashes is not None and hashed in mat_hashes:
2166 return mat_hashes[hashed]
2168 # convert passed input to PBR if necessary
2169 if hasattr(mat, "to_pbr"):
2170 as_pbr = mat.to_pbr()
2171 else:
2172 as_pbr = mat
2174 # a default PBR metallic material
2175 result = {"pbrMetallicRoughness": {}}
2176 try:
2177 # try to convert base color to (4,) float color
2178 result["baseColorFactor"] = (
2179 visual.color.to_float(as_pbr.baseColorFactor).reshape(4).tolist()
2180 )
2181 except BaseException:
2182 pass
2184 try:
2185 result["emissiveFactor"] = as_pbr.emissiveFactor.reshape(3).tolist()
2186 except BaseException:
2187 pass
2189 # if name is defined, export
2190 if isinstance(as_pbr.name, str):
2191 result["name"] = as_pbr.name
2193 # if alphaMode is defined, export
2194 if isinstance(as_pbr.alphaMode, str):
2195 result["alphaMode"] = as_pbr.alphaMode
2197 # if alphaCutoff is defined, export
2198 if isinstance(as_pbr.alphaCutoff, float):
2199 result["alphaCutoff"] = as_pbr.alphaCutoff
2201 # if doubleSided is defined, export
2202 if isinstance(as_pbr.doubleSided, bool):
2203 result["doubleSided"] = as_pbr.doubleSided
2205 # if scalars are defined correctly export
2206 if isinstance(as_pbr.metallicFactor, float):
2207 result["metallicFactor"] = as_pbr.metallicFactor
2208 if isinstance(as_pbr.roughnessFactor, float):
2209 result["roughnessFactor"] = as_pbr.roughnessFactor
2211 # which keys of the PBRMaterial are images
2212 image_mapping = {
2213 "baseColorTexture": as_pbr.baseColorTexture,
2214 "emissiveTexture": as_pbr.emissiveTexture,
2215 "normalTexture": as_pbr.normalTexture,
2216 "occlusionTexture": as_pbr.occlusionTexture,
2217 "metallicRoughnessTexture": as_pbr.metallicRoughnessTexture,
2218 }
2220 for key, img in image_mapping.items():
2221 if img is None:
2222 continue
2223 # try adding the base image to the export object
2224 index = _append_image(
2225 img=img, tree=tree, buffer_items=buffer_items, extension_webp=extension_webp
2226 )
2227 # if the image was added successfully it will return index
2228 # if it failed for any reason, it will return None
2229 if index is not None:
2230 # add a reference to the base color texture
2231 result[key] = {"index": len(tree["textures"])}
2233 # add texture object, optionally using EXT_texture_webp
2234 if extension_webp:
2235 tree["textures"].append(
2236 {"extensions": {"EXT_texture_webp": {"source": index}}}
2237 )
2238 else:
2239 tree["textures"].append({"source": index})
2241 # for our PBRMaterial object we flatten all keys
2242 # however GLTF would like some of them under the
2243 # "pbrMetallicRoughness" key
2244 pbr_subset = [
2245 "baseColorTexture",
2246 "baseColorFactor",
2247 "roughnessFactor",
2248 "metallicFactor",
2249 "metallicRoughnessTexture",
2250 ]
2251 # move keys down a level
2252 for key in pbr_subset:
2253 if key in result:
2254 result["pbrMetallicRoughness"][key] = result.pop(key)
2256 # if we didn't have any PBR keys remove the empty key
2257 if len(result["pbrMetallicRoughness"]) == 0:
2258 result.pop("pbrMetallicRoughness")
2260 # which index are we inserting material at
2261 index = len(tree["materials"])
2262 # add the material to the data structure
2263 tree["materials"].append(result)
2264 # add the material index in-place
2265 mat_hashes[hashed] = index
2267 return index
2270def validate(header):
2271 """
2272 Validate a GLTF 2.0 header against the schema.
2274 Returns result from:
2275 `jsonschema.validate(header, schema=get_schema())`
2277 Parameters
2278 -------------
2279 header : dict
2280 Populated GLTF 2.0 header
2282 Raises
2283 --------------
2284 err : jsonschema.exceptions.ValidationError
2285 If the tree is an invalid GLTF2.0 header
2286 """
2287 # a soft dependency
2288 import jsonschema
2290 # will do the reference replacement
2291 schema = get_schema()
2292 # validate the passed header against the schema
2293 valid = jsonschema.validate(header, schema=schema)
2295 return valid
2298def get_schema():
2299 """
2300 Get a copy of the GLTF 2.0 schema with references resolved.
2302 Returns
2303 ------------
2304 schema : dict
2305 A copy of the GLTF 2.0 schema without external references.
2306 """
2307 # replace references
2308 # get zip resolver to access referenced assets
2309 from ...schemas import resolve
2311 # get a blob of a zip file including the GLTF 2.0 schema
2312 stream = resources.get_stream("schema/gltf2.schema.zip")
2313 # get the zip file as a dict keyed by file name
2314 archive = util.decompress(stream, "zip")
2315 # get a resolver object for accessing the schema
2316 resolver = ZipResolver(archive)
2317 # get a loaded dict from the base file
2318 unresolved = json.loads(util.decode_text(resolver.get("glTF.schema.json")))
2319 # resolve `$ref` references to other files in the schema
2320 schema = resolve(unresolved, resolver=resolver)
2322 return schema
2325# exporters
2326_gltf_loaders = {"glb": load_glb, "gltf": load_gltf}