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