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