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