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

1import json 

2import os 

3import urllib.parse 

4from copy import deepcopy 

5 

6import numpy as np 

7 

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 

30 

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) 

36 

37 # no path formats available 

38 def path_formats() -> set: 

39 return set() 

40 

41 

42def mesh_formats() -> set[str]: 

43 """ 

44 Get a list of mesh formats available to load. 

45 

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)} 

54 

55 

56def available_formats() -> set[str]: 

57 """ 

58 Get a list of all available loaders 

59 

60 

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()) 

70 

71 return loaders 

72 

73 

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. 

84 

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. 

88 

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__ 

104 

105 Returns 

106 --------- 

107 geometry : Trimesh, Path2D, Path3D, Scene 

108 Loaded geometry as trimesh classes 

109 """ 

110 

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 ) 

119 

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 

132 

133 ########################################### 

134 # we are matching old, deprecated behavior here! 

135 kind = loaded.source.file_type 

136 always_scene = {"glb", "gltf", "zip", "3dxml", "tar.gz"} 

137 

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) 

141 

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 

154 

155 return loaded 

156 

157 

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`. 

171 

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__ 

190 

191 Returns 

192 --------- 

193 geometry : Trimesh, Path2D, Path3D, Scene 

194 Loaded geometry as trimesh classes 

195 """ 

196 

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 ) 

205 

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) 

231 

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") 

244 

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() 

250 

251 if not isinstance(loaded, Scene): 

252 # file name may be used for nodes 

253 loaded._source = arg 

254 loaded = Scene(loaded) 

255 

256 # add on the loading information 

257 loaded._source = arg 

258 for g in loaded.geometry.values(): 

259 g._source = arg 

260 

261 return loaded 

262 

263 

264def load_mesh(*args, **kwargs) -> Trimesh: 

265 """ 

266 Load a file into a Trimesh object. 

267 

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 

276 

277 Returns 

278 ---------- 

279 mesh 

280 Loaded geometry data. 

281 """ 

282 return load_scene(*args, **kwargs).to_mesh() 

283 

284 

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. 

289 

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. 

299 

300 Returns 

301 ---------- 

302 scene : trimesh.Scene 

303 Geometry loaded in to a Scene object 

304 """ 

305 

306 # parse the file arguments into clean loadable form 

307 arg = _parse_file_args(file_obj=file_obj, file_type=file_type, resolver=resolver) 

308 

309 # store loaded geometries as a list 

310 geometries = [] 

311 

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) 

315 

316 # try to save the files with meaningful metadata 

317 # archive_name = arg.file_path or "archive" 

318 meta_archive = {} 

319 

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() 

331 

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() 

336 

337 # if file has metadata type include it 

338 if compressed_type in ("yaml", "yml"): 

339 import yaml 

340 

341 continue 

342 meta_archive[file_name] = yaml.safe_load(file_obj) 

343 elif compressed_type == "json": 

344 import json 

345 

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 

351 

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 ) 

361 

362 except BaseException: 

363 log.debug("failed to load file in zip", exc_info=True) 

364 

365 # if we opened the file in this function 

366 # clean up after ourselves 

367 if arg.was_opened: 

368 arg.file_obj.close() 

369 

370 # append meshes or scenes into a single Scene object 

371 result = append_scenes(geometries) 

372 

373 # append any archive metadata files 

374 if isinstance(result, Scene): 

375 result.metadata.update(meta_archive) 

376 

377 return result 

378 

379 

380def load_remote(url: str, **kwargs) -> Scene: 

381 """ 

382 Load a mesh at a remote URL into a local trimesh object. 

383 

384 This is a thin wrapper around: 

385 `trimesh.load_scene(file_obj=url, allow_remote=True, **kwargs)` 

386 

387 Parameters 

388 ------------ 

389 url 

390 URL containing mesh file 

391 **kwargs 

392 Passed to `load_scene` 

393 

394 Returns 

395 ------------ 

396 loaded : Trimesh, Path, Scene 

397 Loaded result 

398 """ 

399 return load_scene(file_obj=url, allow_remote=True, **kwargs) 

400 

401 

402def _load_kwargs(*args, **kwargs) -> Geometry: 

403 """ 

404 Load geometry from a properly formatted dict or kwargs 

405 """ 

406 

407 def handle_scene() -> Scene: 

408 """ 

409 Load a scene from our kwargs. 

410 

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()} 

418 

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) 

429 

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") 

435 

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") 

457 

458 return scene 

459 

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) 

469 

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)) 

479 

480 raise NotImplementedError(f"`{file_type}` is not supported") 

481 

482 def handle_path(): 

483 from ..path import Path2D, Path3D 

484 

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!") 

494 

495 def handle_pointcloud(): 

496 return PointCloud(**kwargs) 

497 

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] 

502 

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 ) 

512 

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() 

520 

521 raise ValueError(f"unable to determine type: {kwargs.keys()}") 

522 

523 

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. 

536 

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 

543 

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' 

547 

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 

551 

552 if string is none of those: 

553 raise ValueError as we can't do anything with input 

554 

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 

559 

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 

564 

565 file_type : str 

566 type of file and handled according to above 

567 

568 Returns 

569 ----------- 

570 args 

571 Populated `_FileArg` message 

572 """ 

573 # try to save a file path from various inputs 

574 file_path = None 

575 

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 

579 

580 if util.is_pathlib(file_obj): 

581 # convert pathlib objects to string 

582 file_obj = str(file_obj.absolute()) 

583 

584 if util.is_file(file_obj) and file_type is None: 

585 raise ValueError("`file_type` must be set for file objects!") 

586 

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 

596 

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`") 

618 

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()) 

627 

628 elif file_type is None: 

629 raise ValueError(f"string is not a file: `{file_obj}`") 

630 

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) 

640 

641 # all our stored extensions reference in lower case 

642 if file_type is not None: 

643 file_type = file_type.lower() 

644 

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) 

653 

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 ) 

661 

662 

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} 

670 

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) 

685 

686# collect loaders which return voxel types 

687voxel_loaders = {} 

688voxel_loaders.update(_binvox_loaders)