Coverage for trimesh/exchange/load.py: 86%
242 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
1import json
2import os
3import urllib.parse
4from copy import deepcopy
6import numpy as np
8from .. import resolvers, util
9from ..base import Trimesh
10from ..exceptions import ExceptionWrapper
11from ..parent import Geometry, LoadSource
12from ..points import PointCloud
13from ..scene.scene import Scene, append_scenes
14from ..typed import HttpSessionLike, Loadable
15from ..util import log
16from . import misc
17from .binvox import _binvox_loaders
18from .cascade import _cascade_loaders
19from .dae import _collada_loaders
20from .gltf import _gltf_loaders
21from .misc import _misc_loaders
22from .obj import _obj_loaders
23from .off import _off_loaders
24from .ply import _ply_loaders
25from .stl import _stl_loaders
26from .threedxml import _threedxml_loaders
27from .threemf import _three_loaders
28from .xaml import _xaml_loaders
29from .xyz import _xyz_loaders
31try:
32 from ..path.exchange.load import load_path, path_formats
33except BaseException as E:
34 # save a traceback to see why path didn't import
35 load_path = ExceptionWrapper(E)
37 # no path formats available
38 def path_formats() -> set:
39 return set()
42def mesh_formats() -> set[str]:
43 """
44 Get a list of mesh formats available to load.
46 Returns
47 -----------
48 loaders
49 Extensions of available mesh loaders
50 i.e. `{'stl', 'ply'}`
51 """
52 # filter out exceptionmodule loaders
53 return {k for k, v in mesh_loaders.items() if not isinstance(v, ExceptionWrapper)}
56def available_formats() -> set[str]:
57 """
58 Get a list of all available loaders
61 Returns
62 -----------
63 loaders
64 Extensions of all available loaders
65 i.e. `{'stl', 'ply', 'dxf'}`
66 """
67 loaders = mesh_formats()
68 loaders.update(path_formats())
69 loaders.update(compressed_loaders.keys())
71 return loaders
74def load(
75 file_obj: Loadable,
76 file_type: str | None = None,
77 resolver: resolvers.ResolverLike | None = None,
78 force: str | None = None,
79 allow_remote: bool = False,
80 **kwargs,
81) -> Geometry:
82 """
83 THIS FUNCTION IS DEPRECATED but there are no current plans for it to be removed.
85 For new code the typed load functions `trimesh.load_scene` or `trimesh.load_mesh`
86 are recommended over `trimesh.load` which is a backwards-compatibility wrapper
87 that mimics the behavior of the old function and can return any geometry type.
89 Parameters
90 -----------
91 file_obj : str, or file- like object
92 The source of the data to be loadeded
93 file_type: str
94 What kind of file type do we have (eg: 'stl')
95 resolver : trimesh.visual.Resolver
96 Object to load referenced assets like materials and textures
97 force : None or str
98 For 'mesh': try to coerce scenes into a single mesh
99 For 'scene': try to coerce everything into a scene
100 allow_remote
101 If True allow this load call to work on a remote URL.
102 kwargs : dict
103 Passed to geometry __init__
105 Returns
106 ---------
107 geometry : Trimesh, Path2D, Path3D, Scene
108 Loaded geometry as trimesh classes
109 """
111 # call the most general loading case into a `Scene`.
112 loaded = load_scene(
113 file_obj=file_obj,
114 file_type=file_type,
115 resolver=resolver,
116 allow_remote=allow_remote,
117 **kwargs,
118 )
120 if force == "mesh":
121 # new code should use `load_mesh` for this
122 log.debug(
123 "`trimesh.load(force='mesh')` is a compatibility wrapper for `trimesh.load_mesh`"
124 )
125 return loaded.to_mesh()
126 elif force == "scene":
127 # new code should use `load_scene` for this
128 log.debug(
129 "`trimesh.load(force='scene')` is a compatibility wrapper for `trimesh.load_scene`"
130 )
131 return loaded
133 ###########################################
134 # we are matching old, deprecated behavior here!
135 kind = loaded.source.file_type
136 always_scene = {"glb", "gltf", "zip", "3dxml", "tar.gz"}
138 if kind not in always_scene and len(loaded.geometry) == 1:
139 geom = next(iter(loaded.geometry.values()))
140 geom.metadata.update(loaded.metadata)
142 if isinstance(geom, PointCloud) or kind in {
143 "obj",
144 "stl",
145 "ply",
146 "svg",
147 "binvox",
148 "xaml",
149 "dxf",
150 "off",
151 "msh",
152 }:
153 return geom
155 return loaded
158def load_scene(
159 file_obj: Loadable,
160 file_type: str | None = None,
161 resolver: resolvers.ResolverLike | None = None,
162 allow_remote: bool = False,
163 metadata: dict | None = None,
164 session: HttpSessionLike | None = None,
165 **kwargs,
166) -> Scene:
167 """
168 Load geometry into the `trimesh.Scene` container. This may contain
169 any `parent.Geometry` object, including `Trimesh`, `Path2D`, `Path3D`,
170 or a `PointCloud`.
172 Parameters
173 -----------
174 file_obj : str, or file- like object
175 The source of the data to be loadeded
176 file_type: str
177 What kind of file type do we have (eg: 'stl')
178 resolver : trimesh.visual.Resolver
179 Object to load referenced assets like materials and textures
180 force : None or str
181 For 'mesh': try to coerce scenes into a single mesh
182 For 'scene': try to coerce everything into a scene
183 allow_remote
184 If True allow this load call to work on a remote URL.
185 session : HttpSessionLike or None
186 Optional HTTP session passed through to a `WebResolver` when loading
187 from a URL. Accepts `httpx.Client` or `requests.Session`.
188 kwargs : dict
189 Passed to geometry __init__
191 Returns
192 ---------
193 geometry : Trimesh, Path2D, Path3D, Scene
194 Loaded geometry as trimesh classes
195 """
197 # parse all possible values of file objects into simple types
198 arg = _parse_file_args(
199 file_obj=file_obj,
200 file_type=file_type,
201 resolver=resolver,
202 allow_remote=allow_remote,
203 session=session,
204 )
206 try:
207 if isinstance(file_obj, dict):
208 # we've been passed a dictionary so treat them as keyword arguments
209 loaded = _load_kwargs(file_obj)
210 elif arg.file_type in path_formats():
211 # use path loader
212 loaded = load_path(
213 file_obj=arg.file_obj,
214 file_type=arg.file_type,
215 metadata=metadata,
216 **kwargs,
217 )
218 elif arg.file_type in mesh_loaders:
219 # use mesh loader
220 parsed = deepcopy(kwargs)
221 parsed.update(
222 mesh_loaders[arg.file_type](
223 file_obj=arg.file_obj,
224 file_type=arg.file_type,
225 resolver=arg.resolver,
226 metadata=metadata,
227 **kwargs,
228 )
229 )
230 loaded = _load_kwargs(**parsed)
232 elif arg.file_type in compressed_loaders:
233 # for archives, like ZIP files
234 loaded = _load_compressed(arg.file_obj, file_type=arg.file_type, **kwargs)
235 elif arg.file_type in voxel_loaders:
236 loaded = voxel_loaders[arg.file_type](
237 file_obj=arg.file_obj,
238 file_type=arg.file_type,
239 resolver=arg.resolver,
240 **kwargs,
241 )
242 else:
243 raise NotImplementedError(f"file_type '{arg.file_type}' not supported")
245 finally:
246 # if we opened the file ourselves from a file name
247 # close any opened files even if we crashed out
248 if arg.was_opened:
249 arg.file_obj.close()
251 if not isinstance(loaded, Scene):
252 # file name may be used for nodes
253 loaded._source = arg
254 loaded = Scene(loaded)
256 # add on the loading information
257 loaded._source = arg
258 for g in loaded.geometry.values():
259 g._source = arg
261 return loaded
264def load_mesh(*args, **kwargs) -> Trimesh:
265 """
266 Load a file into a Trimesh object.
268 Parameters
269 -----------
270 file_obj : str or file object
271 File name or file with mesh data
272 file_type : str or None
273 Which file type, e.g. 'stl'
274 kwargs : dict
275 Passed to Trimesh constructor
277 Returns
278 ----------
279 mesh
280 Loaded geometry data.
281 """
282 return load_scene(*args, **kwargs).to_mesh()
285def _load_compressed(file_obj, file_type=None, resolver=None, mixed=False, **kwargs):
286 """
287 Given a compressed archive load all the geometry that
288 we can from it.
290 Parameters
291 ----------
292 file_obj : open file-like object
293 Containing compressed data
294 file_type : str
295 Type of the archive file
296 mixed : bool
297 If False, for archives containing both 2D and 3D
298 data will only load the 3D data into the Scene.
300 Returns
301 ----------
302 scene : trimesh.Scene
303 Geometry loaded in to a Scene object
304 """
306 # parse the file arguments into clean loadable form
307 arg = _parse_file_args(file_obj=file_obj, file_type=file_type, resolver=resolver)
309 # store loaded geometries as a list
310 geometries = []
312 # so loaders can access textures/etc
313 archive = util.decompress(file_obj=arg.file_obj, file_type=arg.file_type)
314 resolver = resolvers.ZipResolver(archive)
316 # try to save the files with meaningful metadata
317 # archive_name = arg.file_path or "archive"
318 meta_archive = {}
320 # populate our available formats
321 if mixed:
322 available = available_formats()
323 else:
324 # all types contained in ZIP archive
325 contains = {util.split_extension(n).lower() for n in resolver.keys()}
326 # if there are no mesh formats available
327 if contains.isdisjoint(mesh_formats()):
328 available = path_formats()
329 else:
330 available = mesh_formats()
332 for file_name, file_obj in archive.items():
333 try:
334 # only load formats that we support
335 compressed_type = util.split_extension(file_name).lower()
337 # if file has metadata type include it
338 if compressed_type in ("yaml", "yml"):
339 import yaml
341 continue
342 meta_archive[file_name] = yaml.safe_load(file_obj)
343 elif compressed_type == "json":
344 import json
346 meta_archive[file_name] = json.load(file_obj)
347 continue
348 elif compressed_type not in available:
349 # don't raise an exception, just try the next one
350 continue
352 # load the individual geometry
353 geometries.append(
354 load_scene(
355 file_obj=file_obj,
356 file_type=compressed_type,
357 resolver=resolver,
358 **kwargs,
359 )
360 )
362 except BaseException:
363 log.debug("failed to load file in zip", exc_info=True)
365 # if we opened the file in this function
366 # clean up after ourselves
367 if arg.was_opened:
368 arg.file_obj.close()
370 # append meshes or scenes into a single Scene object
371 result = append_scenes(geometries)
373 # append any archive metadata files
374 if isinstance(result, Scene):
375 result.metadata.update(meta_archive)
377 return result
380def load_remote(url: str, **kwargs) -> Scene:
381 """
382 Load a mesh at a remote URL into a local trimesh object.
384 This is a thin wrapper around:
385 `trimesh.load_scene(file_obj=url, allow_remote=True, **kwargs)`
387 Parameters
388 ------------
389 url
390 URL containing mesh file
391 **kwargs
392 Passed to `load_scene`
394 Returns
395 ------------
396 loaded : Trimesh, Path, Scene
397 Loaded result
398 """
399 return load_scene(file_obj=url, allow_remote=True, **kwargs)
402def _load_kwargs(*args, **kwargs) -> Geometry:
403 """
404 Load geometry from a properly formatted dict or kwargs
405 """
407 def handle_scene() -> Scene:
408 """
409 Load a scene from our kwargs.
411 class: Scene
412 geometry: dict, name: Trimesh kwargs
413 graph: list of dict, kwargs for scene.graph.update
414 base_frame: str, base frame of graph
415 """
416 graph = kwargs.get("graph", None)
417 geometry = {k: _load_kwargs(v) for k, v in kwargs["geometry"].items()}
419 if graph is not None:
420 scene = Scene()
421 scene.geometry.update(geometry)
422 for k in graph:
423 if isinstance(k, dict):
424 scene.graph.update(**k)
425 elif util.is_sequence(k) and len(k) == 3:
426 scene.graph.update(k[1], k[0], **k[2])
427 else:
428 scene = Scene(geometry)
430 # camera, if it exists
431 camera = kwargs.get("camera")
432 if camera:
433 scene.camera = camera
434 scene.camera_transform = kwargs.get("camera_transform")
436 if "base_frame" in kwargs:
437 scene.graph.base_frame = kwargs["base_frame"]
438 metadata = kwargs.get("metadata")
439 if isinstance(metadata, dict):
440 scene.metadata.update(kwargs["metadata"])
441 elif isinstance(metadata, str):
442 # some ways someone might have encoded a string
443 # note that these aren't evaluated until we
444 # actually call the lambda in the loop
445 candidates = [
446 lambda: json.loads(metadata),
447 lambda: json.loads(metadata.replace("'", '"')),
448 ]
449 for c in candidates:
450 try:
451 scene.metadata.update(c())
452 break
453 except BaseException:
454 pass
455 elif metadata is not None:
456 log.warning("unloadable metadata")
458 return scene
460 def handle_mesh() -> Trimesh:
461 """
462 Handle the keyword arguments for a Trimesh object
463 """
464 # if they've been serialized as a dict
465 if isinstance(kwargs["vertices"], dict) or isinstance(kwargs["faces"], dict):
466 return Trimesh(**misc.load_dict(kwargs))
467 # otherwise just load that puppy
468 return Trimesh(**kwargs)
470 def handle_export():
471 """
472 Handle an exported mesh.
473 """
474 data, file_type = kwargs["data"], kwargs["file_type"]
475 if isinstance(data, dict):
476 return _load_kwargs(data)
477 elif file_type in mesh_loaders:
478 return Trimesh(**mesh_loaders[file_type](data, file_type=file_type))
480 raise NotImplementedError(f"`{file_type}` is not supported")
482 def handle_path():
483 from ..path import Path2D, Path3D
485 shape = np.shape(kwargs["vertices"])
486 if len(shape) < 2:
487 return Path2D()
488 if shape[1] == 2:
489 return Path2D(**kwargs)
490 elif shape[1] == 3:
491 return Path3D(**kwargs)
492 else:
493 raise ValueError("Vertices must be 2D or 3D!")
495 def handle_pointcloud():
496 return PointCloud(**kwargs)
498 # if we've been passed a single dict instead of kwargs
499 # substitute the dict for kwargs
500 if len(kwargs) == 0 and len(args) == 1 and isinstance(args[0], dict):
501 kwargs = args[0]
503 # (function, tuple of expected keys)
504 # order is important
505 handlers = (
506 (handle_scene, ("geometry",)),
507 (handle_mesh, ("vertices", "faces")),
508 (handle_path, ("entities", "vertices")),
509 (handle_pointcloud, ("vertices",)),
510 (handle_export, ("file_type", "data")),
511 )
513 # filter out keys with a value of None
514 kwargs = {k: v for k, v in kwargs.items() if v is not None}
515 # loop through handler functions and expected key
516 for func, expected in handlers:
517 if all(i in kwargs for i in expected):
518 # all expected kwargs exist
519 return func()
521 raise ValueError(f"unable to determine type: {kwargs.keys()}")
524def _parse_file_args(
525 file_obj,
526 file_type: str | None,
527 resolver: resolvers.ResolverLike | None = None,
528 allow_remote: bool = False,
529 session: HttpSessionLike | None = None,
530 **kwargs,
531) -> LoadSource:
532 """
533 Given a file_obj and a file_type try to magically convert
534 arguments to a file-like object and a lowercase string of
535 file type.
537 Parameters
538 -----------
539 file_obj : str
540 if string represents a file path, returns:
541 file_obj: an 'rb' opened file object of the path
542 file_type: the extension from the file path
544 if string is NOT a path, but has JSON-like special characters:
545 file_obj: the same string passed as file_obj
546 file_type: set to 'json'
548 if string is a valid-looking URL
549 file_obj: an open 'rb' file object with retrieved data
550 file_type: from the extension
552 if string is none of those:
553 raise ValueError as we can't do anything with input
555 if file like object:
556 ValueError will be raised if file_type is None
557 file_obj: same as input
558 file_type: same as input
560 if other object: like a shapely.geometry.Polygon, etc:
561 file_obj: same as input
562 file_type: if None initially, set to the class name
563 (in lower case), otherwise passed through
565 file_type : str
566 type of file and handled according to above
568 Returns
569 -----------
570 args
571 Populated `_FileArg` message
572 """
573 # try to save a file path from various inputs
574 file_path = None
576 # keep track if we opened a file ourselves and thus are
577 # responsible for closing it at the end of loading
578 was_opened = False
580 if util.is_pathlib(file_obj):
581 # convert pathlib objects to string
582 file_obj = str(file_obj.absolute())
584 if util.is_file(file_obj) and file_type is None:
585 raise ValueError("`file_type` must be set for file objects!")
587 if isinstance(file_obj, str):
588 try:
589 # clean up file path to an absolute location
590 file_path = os.path.abspath(os.path.expanduser(file_obj))
591 # check to see if this path exists
592 exists = os.path.isfile(file_path)
593 except BaseException:
594 exists = False
595 file_path = None
597 # file obj is a string which exists on filesystm
598 if exists:
599 # if not passed create a resolver to find other files
600 if resolver is None:
601 resolver = resolvers.FilePathResolver(file_path)
602 # save the file name and path to metadata
603 # if file_obj is a path that exists use extension as file_type
604 if file_type is None:
605 file_type = util.split_extension(file_path, special=["tar.gz", "tar.bz2"])
606 # actually open the file
607 file_obj = open(file_path, "rb")
608 # save that we opened it so we can cleanup later
609 was_opened = True
610 else:
611 if "{" in file_obj:
612 # if a bracket is in the string it's probably straight JSON
613 file_type = "json"
614 file_obj = util.wrap_as_stream(file_obj)
615 elif urllib.parse.urlparse(file_obj).scheme in ("http", "https"):
616 if not allow_remote:
617 raise ValueError("unable to load URL with `allow_remote=False`")
619 # remove the url-safe encoding and query params
620 file_type = util.split_extension(
621 urllib.parse.unquote(file_obj).split("?", 1)[0].split("/")[-1].strip()
622 )
623 # create a web resolver to do the fetching and whatnot
624 resolver = resolvers.WebResolver(url=file_obj, session=session)
625 # fetch the base file
626 file_obj = util.wrap_as_stream(resolver.get_base())
628 elif file_type is None:
629 raise ValueError(f"string is not a file: `{file_obj}`")
631 if isinstance(file_type, str) and "." in file_type:
632 # if someone has passed the whole filename as the file_type
633 # use the file extension as the file_type
634 path = os.path.abspath(os.path.expanduser(file_type))
635 file_type = util.split_extension(file_type)
636 if os.path.exists(path):
637 file_path = path
638 if resolver is None:
639 resolver = resolvers.FilePathResolver(file_path)
641 # all our stored extensions reference in lower case
642 if file_type is not None:
643 file_type = file_type.lower()
645 # if we still have no resolver try using file_obj name
646 if (
647 resolver is None
648 and hasattr(file_obj, "name")
649 and file_obj.name is not None
650 and len(file_obj.name) > 0
651 ):
652 resolver = resolvers.FilePathResolver(file_obj.name)
654 return LoadSource(
655 file_obj=file_obj,
656 file_type=file_type,
657 file_path=file_path,
658 was_opened=was_opened,
659 resolver=resolver,
660 )
663# loader functions for compressed extensions
664compressed_loaders = {
665 "zip": _load_compressed,
666 "tar.bz2": _load_compressed,
667 "tar.gz": _load_compressed,
668 "bz2": _load_compressed,
669}
671# map file_type to loader function
672mesh_loaders = {}
673mesh_loaders.update(_misc_loaders)
674mesh_loaders.update(_stl_loaders)
675mesh_loaders.update(_ply_loaders)
676mesh_loaders.update(_obj_loaders)
677mesh_loaders.update(_off_loaders)
678mesh_loaders.update(_collada_loaders)
679mesh_loaders.update(_gltf_loaders)
680mesh_loaders.update(_xaml_loaders)
681mesh_loaders.update(_threedxml_loaders)
682mesh_loaders.update(_three_loaders)
683mesh_loaders.update(_xyz_loaders)
684mesh_loaders.update(_cascade_loaders)
686# collect loaders which return voxel types
687voxel_loaders = {}
688voxel_loaders.update(_binvox_loaders)