Coverage for trimesh/scene/scene.py: 90%

508 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-24 04:40 +0000

1import collections 

2import uuid 

3import warnings 

4from copy import deepcopy 

5from hashlib import sha256 

6from typing import TypeAlias 

7 

8# ruff doesn't recognize this correctly when we re-import it from trimesh.typed -_- 

9import numpy as np 

10 

11from .. import caching, convex, grouping, inertia, transformations, units, util 

12from ..constants import log 

13from ..exchange import export 

14from ..parent import Geometry, Geometry3D 

15from ..registration import procrustes 

16from ..typed import ( 

17 ArrayLike, 

18 Floating, 

19 Integer, 

20 Iterable, 

21 NDArray, 

22 Sequence, 

23 ViewerType, 

24 float64, 

25 int64, 

26) 

27from ..util import unique_name 

28from . import cameras, lighting 

29from .transforms import SceneGraph 

30 

31# the types of objects we can create a scene from 

32GeometryInput: TypeAlias = Geometry | Iterable[Geometry] | dict[str, Geometry] | ArrayLike 

33 

34 

35class Scene(Geometry3D): 

36 """ 

37 A simple scene graph which can be rendered directly via 

38 pyglet/openGL or through other endpoints such as a 

39 raytracer. Meshes are added by name, which can then be 

40 moved by updating transform in the transform tree. 

41 """ 

42 

43 def __init__( 

44 self, 

45 geometry: GeometryInput | None = None, 

46 base_frame: str = "world", 

47 metadata: dict | None = None, 

48 graph: SceneGraph | None = None, 

49 camera: cameras.Camera | None = None, 

50 lights: Sequence[lighting.Light] | None = None, 

51 camera_transform: NDArray | None = None, 

52 ): 

53 """ 

54 Create a new Scene object. 

55 

56 Parameters 

57 ------------- 

58 geometry : Trimesh, Path2D, Path3D PointCloud or list 

59 Geometry to initially add to the scene 

60 base_frame 

61 Name of base frame 

62 metadata 

63 Any metadata about the scene 

64 graph 

65 A passed transform graph to use 

66 camera : Camera or None 

67 A passed camera to use 

68 lights : [trimesh.scene.lighting.Light] or None 

69 A passed lights to use 

70 camera_transform 

71 Homogeneous (4, 4) camera transform in the base frame 

72 """ 

73 # mesh name : Trimesh object 

74 self.geometry = collections.OrderedDict() 

75 

76 # create a new graph 

77 self.graph = SceneGraph(base_frame=base_frame) 

78 

79 # create our cache 

80 self._cache = caching.Cache(id_function=self.__hash__) 

81 

82 if geometry is not None: 

83 # add passed geometry to scene 

84 self.add_geometry(geometry) 

85 

86 # hold metadata about the scene 

87 self.metadata = {} 

88 if isinstance(metadata, dict): 

89 self.metadata.update(metadata) 

90 

91 if graph is not None: 

92 # if we've been passed a graph override the default 

93 self.graph = graph 

94 

95 if lights is not None: 

96 self.lights = lights 

97 if camera is not None: 

98 self.camera = camera 

99 if camera_transform is not None: 

100 self.camera_transform = camera_transform 

101 

102 def apply_transform(self, transform): 

103 """ 

104 Apply a transform to all children of the base frame 

105 without modifying any geometry. 

106 

107 Parameters 

108 -------------- 

109 transform : (4, 4) 

110 Homogeneous transformation matrix. 

111 """ 

112 base = self.graph.base_frame 

113 for child in self.graph.transforms.children[base]: 

114 combined = np.dot(transform, self.graph[child][0]) 

115 self.graph.update(frame_from=base, frame_to=child, matrix=combined) 

116 return self 

117 

118 def add_geometry( 

119 self, 

120 geometry: GeometryInput, 

121 node_name: str | None = None, 

122 geom_name: str | None = None, 

123 parent_node_name: str | None = None, 

124 transform: NDArray | None = None, 

125 metadata: dict | None = None, 

126 ): 

127 """ 

128 Add a geometry to the scene. 

129 

130 If the mesh has multiple transforms defined in its 

131 metadata, they will all be copied into the 

132 TransformForest of the current scene automatically. 

133 

134 Parameters 

135 ---------- 

136 geometry : Trimesh, Path2D, Path3D PointCloud or list 

137 Geometry to initially add to the scene 

138 node_name : None or str 

139 Name of the added node. 

140 geom_name : None or str 

141 Name of the added geometry. 

142 parent_node_name : None or str 

143 Name of the parent node in the graph. 

144 transform : None or (4, 4) float 

145 Transform that applies to the added node. 

146 metadata : None or dict 

147 Optional metadata for the node. 

148 

149 Returns 

150 ---------- 

151 node_name : str 

152 Name of single node in self.graph (passed in) or None if 

153 node was not added (eg. geometry was null or a Scene). 

154 """ 

155 

156 if geometry is None: 

157 return 

158 # PointCloud objects will look like a sequence 

159 elif util.is_sequence(geometry): 

160 # if passed a sequence add all elements 

161 return [ 

162 self.add_geometry( 

163 geometry=value, 

164 node_name=node_name, 

165 geom_name=geom_name, 

166 parent_node_name=parent_node_name, 

167 transform=transform, 

168 metadata=metadata, 

169 ) 

170 for value in geometry # type: ignore 

171 ] 

172 elif isinstance(geometry, dict): 

173 # if someone passed us a dict of geometry 

174 return { 

175 k: self.add_geometry(geometry=v, geom_name=k, metadata=metadata) 

176 for k, v in geometry.items() 

177 } 

178 

179 elif isinstance(geometry, Scene): 

180 # concatenate current scene with passed scene 

181 concat = self + geometry 

182 # replace geometry in-place 

183 self.geometry.clear() 

184 self.geometry.update(concat.geometry) 

185 # replace graph data with concatenated graph 

186 self.graph.transforms = concat.graph.transforms 

187 return 

188 

189 # get or create a name to reference the geometry by 

190 if geom_name is not None: 

191 # if name is passed use it 

192 name = geom_name 

193 elif "name" in geometry.metadata: 

194 # if name is in metadata use it 

195 name = geometry.metadata["name"] 

196 elif geometry.source.file_name is not None: 

197 name = geometry.source.file_name 

198 else: 

199 # try to create a simple name 

200 name = "geometry_" + str(len(self.geometry)) 

201 

202 # if its already taken use our unique name logic 

203 name = unique_name(start=name, contains=self.geometry.keys()) 

204 # save the geometry reference 

205 self.geometry[name] = geometry 

206 

207 # create a unique node name if not passed 

208 if node_name is None: 

209 # if the name of the geometry is also a transform node 

210 # which graph nodes already exist 

211 existing = self.graph.transforms.node_data.keys() 

212 # find a name that isn't contained already starting 

213 # at the name we have 

214 node_name = unique_name(name, existing) 

215 assert node_name not in existing 

216 

217 if transform is None: 

218 # create an identity transform from parent_node 

219 transform = np.eye(4) 

220 

221 self.graph.update( 

222 frame_to=node_name, 

223 frame_from=parent_node_name, 

224 matrix=transform, 

225 geometry=name, 

226 geometry_flags={"visible": True}, 

227 metadata=metadata, 

228 ) 

229 

230 return node_name 

231 

232 def delete_geometry(self, names: set | str | Sequence) -> None: 

233 """ 

234 Delete one more multiple geometries from the scene and also 

235 remove any node in the transform graph which references it. 

236 

237 Parameters 

238 -------------- 

239 name : hashable 

240 Name that references self.geometry 

241 """ 

242 # make sure we have a set we can check 

243 if isinstance(names, str): 

244 names = [names] 

245 names = set(names) 

246 

247 # remove the geometry reference from relevant nodes 

248 self.graph.remove_geometries(names) 

249 # remove the geometries from our geometry store 

250 [self.geometry.pop(name, None) for name in names] 

251 

252 def strip_visuals(self) -> None: 

253 """ 

254 Strip visuals from every Trimesh geometry 

255 and set them to an empty `ColorVisuals`. 

256 """ 

257 from ..visual.color import ColorVisuals 

258 

259 for geometry in self.geometry.values(): 

260 if util.is_instance_named(geometry, "Trimesh"): 

261 geometry.visual = ColorVisuals(mesh=geometry) 

262 

263 def simplify_quadric_decimation( 

264 self, 

265 percent: Floating | None = None, 

266 face_count: Integer | None = None, 

267 aggression: Integer | None = None, 

268 ) -> None: 

269 """ 

270 Apply in-place `mesh.simplify_quadric_decimation` to any meshes 

271 in the scene. 

272 

273 Parameters 

274 ----------- 

275 percent 

276 A number between 0.0 and 1.0 for how much 

277 face_count 

278 Target number of faces desired in the resulting mesh. 

279 aggression 

280 An integer between `0` and `10`, the scale being roughly 

281 `0` is "slow and good" and `10` being "fast and bad." 

282 

283 """ 

284 # save the updates for after the loop 

285 updates = {} 

286 for k, v in self.geometry.items(): 

287 if hasattr(v, "simplify_quadric_decimation"): 

288 updates[k] = v.simplify_quadric_decimation( 

289 percent=percent, face_count=face_count, aggression=aggression 

290 ) 

291 self.geometry.update(updates) 

292 

293 def __hash__(self) -> int: 

294 """ 

295 Return information about scene which is hashable. 

296 

297 Returns 

298 --------- 

299 hashed 

300 String hashing scene. 

301 """ 

302 # avoid accessing attribute in tight loop 

303 geometry = self.geometry 

304 # hash of geometry and transforms 

305 # start with the last modified time of the scene graph 

306 hashable = [hex(self.graph.transforms.__hash__())] 

307 # take the re-hex string of the hash 

308 hashable.extend(hex(geometry[k].__hash__()) for k in geometry.keys()) 

309 return caching.hash_fast("".join(hashable).encode("utf-8")) 

310 

311 @property 

312 def is_empty(self) -> bool: 

313 """ 

314 Does the scene have anything in it. 

315 

316 Returns 

317 ---------- 

318 is_empty 

319 True if nothing is in the scene 

320 """ 

321 

322 return len(self.geometry) == 0 

323 

324 @property 

325 def is_valid(self) -> bool: 

326 """ 

327 Is every geometry connected to the root node. 

328 

329 Returns 

330 ----------- 

331 is_valid : bool 

332 Does every geometry have a transform 

333 """ 

334 if len(self.geometry) == 0: 

335 return True 

336 

337 try: 

338 referenced = {self.graph[i][1] for i in self.graph.nodes_geometry} 

339 except BaseException: 

340 # if connectivity to world frame is broken return false 

341 return False 

342 

343 # every geometry is referenced 

344 return referenced == set(self.geometry.keys()) 

345 

346 @caching.cache_decorator 

347 def bounds_corners(self) -> dict[str, NDArray[float64]]: 

348 """ 

349 Get the post-transform AABB for each node 

350 which has geometry defined. 

351 

352 Returns 

353 ----------- 

354 corners 

355 Bounds for each node with vertices: 

356 {node_name : (2, 3) float} 

357 """ 

358 # collect AABB for each geometry 

359 corners = {} 

360 # collect vertices for every mesh 

361 vertices = { 

362 k: m.vertices if hasattr(m, "vertices") and len(m.vertices) > 0 else m.bounds 

363 for k, m in self.geometry.items() 

364 } 

365 # handle 2D geometries 

366 vertices.update( 

367 { 

368 k: np.column_stack((v, np.zeros(len(v)))) 

369 for k, v in vertices.items() 

370 if v is not None and v.shape[1] == 2 

371 } 

372 ) 

373 

374 # loop through every node with geometry 

375 for node_name in self.graph.nodes_geometry: 

376 # access the transform and geometry name from node 

377 transform, geometry_name = self.graph[node_name] 

378 # will be None if no vertices for this node 

379 points = vertices.get(geometry_name) 

380 # skip empty geometries 

381 if points is None: 

382 continue 

383 # apply just the rotation to skip N multiplies 

384 dot = np.dot(transform[:3, :3], points.T) 

385 # append the AABB with translation applied after 

386 corners[node_name] = np.array( 

387 [dot.min(axis=1) + transform[:3, 3], dot.max(axis=1) + transform[:3, 3]] 

388 ) 

389 return corners 

390 

391 @caching.cache_decorator 

392 def bounds(self) -> NDArray[float64] | None: 

393 """ 

394 Return the overall bounding box of the scene. 

395 

396 Returns 

397 -------- 

398 bounds : (2, 3) float or None 

399 Position of [min, max] bounding box 

400 Returns None if no valid bounds exist 

401 """ 

402 bounds_corners = self.bounds_corners 

403 if len(bounds_corners) == 0: 

404 return None 

405 # combine each geometry node AABB into a larger list 

406 corners = np.vstack(list(self.bounds_corners.values())) 

407 return np.array([corners.min(axis=0), corners.max(axis=0)], dtype=np.float64) 

408 

409 @caching.cache_decorator 

410 def extents(self) -> NDArray[float64] | None: 

411 """ 

412 Return the axis aligned box size of the current scene 

413 or None if the scene is empty. 

414 

415 Returns 

416 ---------- 

417 extents 

418 Bounding box sides length or None for empty scene. 

419 """ 

420 bounds = self.bounds 

421 if bounds is None: 

422 return None 

423 return np.diff(bounds, axis=0).reshape(-1) 

424 

425 @caching.cache_decorator 

426 def scale(self) -> float: 

427 """ 

428 The approximate scale of the mesh 

429 

430 Returns 

431 ----------- 

432 scale : float 

433 The mean of the bounding box edge lengths 

434 """ 

435 extents = self.extents 

436 if extents is None: 

437 return 1.0 

438 return float((extents**2).sum() ** 0.5) 

439 

440 @caching.cache_decorator 

441 def centroid(self) -> NDArray[float64] | None: 

442 """ 

443 Return the center of the bounding box for the scene. 

444 

445 Returns 

446 -------- 

447 centroid : (3) float 

448 Point for center of bounding box 

449 """ 

450 bounds = self.bounds 

451 if bounds is None: 

452 return None 

453 centroid = np.mean(self.bounds, axis=0) 

454 return centroid 

455 

456 @caching.cache_decorator 

457 def center_mass(self) -> NDArray: 

458 """ 

459 Find the center of mass for every instance in the scene. 

460 

461 Returns 

462 ------------ 

463 center_mass : (3,) float 

464 The center of mass of the scene 

465 """ 

466 # get the center of mass and volume for each geometry 

467 center_mass = { 

468 k: m.center_mass 

469 for k, m in self.geometry.items() 

470 if hasattr(m, "center_mass") 

471 } 

472 mass = {k: m.mass for k, m in self.geometry.items() if hasattr(m, "mass")} 

473 

474 # get the geometry name and transform for each instance 

475 graph = self.graph 

476 instance = [graph[n] for n in graph.nodes_geometry] 

477 

478 # get the transformed center of mass for each instance 

479 transformed = np.array( 

480 [ 

481 np.dot(mat, np.append(center_mass[g], 1))[:3] 

482 for mat, g in instance 

483 if g in center_mass 

484 ], 

485 dtype=np.float64, 

486 ) 

487 # weight the center of mass locations by volume 

488 weights = np.array([mass[g] for _, g in instance], dtype=np.float64) 

489 weights /= weights.sum() 

490 return (transformed * weights.reshape((-1, 1))).sum(axis=0) 

491 

492 @caching.cache_decorator 

493 def moment_inertia(self): 

494 """ 

495 Return the moment of inertia of the current scene with 

496 respect to the center of mass of the current scene. 

497 

498 Returns 

499 ------------ 

500 inertia : (3, 3) float 

501 Inertia with respect to cartesian axis at `scene.center_mass` 

502 """ 

503 return inertia.scene_inertia( 

504 scene=self, transform=transformations.translation_matrix(self.center_mass) 

505 ) 

506 

507 def moment_inertia_frame(self, transform): 

508 """ 

509 Return the moment of inertia of the current scene relative 

510 to a transform from the base frame. 

511 

512 Parameters 

513 transform : (4, 4) float 

514 Homogeneous transformation matrix. 

515 

516 Returns 

517 ------------- 

518 inertia : (3, 3) float 

519 Inertia tensor at requested frame. 

520 """ 

521 return inertia.scene_inertia(scene=self, transform=transform) 

522 

523 @caching.cache_decorator 

524 def area(self) -> float: 

525 """ 

526 What is the summed area of every geometry which 

527 has area. 

528 

529 Returns 

530 ------------ 

531 area : float 

532 Summed area of every instanced geometry 

533 """ 

534 # get the area of every geometry that has an area property 

535 areas = {n: g.area for n, g in self.geometry.items() if hasattr(g, "area")} 

536 # sum the area including instancing 

537 return sum( 

538 (areas.get(self.graph[n][1], 0.0) for n in self.graph.nodes_geometry), 0.0 

539 ) 

540 

541 @caching.cache_decorator 

542 def volume(self) -> float64: 

543 """ 

544 What is the summed volume of every geometry which 

545 has volume 

546 

547 Returns 

548 ------------ 

549 volume : float 

550 Summed area of every instanced geometry 

551 """ 

552 # get the area of every geometry that has a volume attribute 

553 volume = {n: g.volume for n, g in self.geometry.items() if hasattr(g, "area")} 

554 # sum the area including instancing 

555 return sum( 

556 (volume.get(self.graph[n][1], 0.0) for n in self.graph.nodes_geometry), 0.0 

557 ) 

558 

559 @caching.cache_decorator 

560 def triangles(self) -> NDArray[float64]: 

561 """ 

562 Return a correctly transformed polygon soup of the 

563 current scene. 

564 

565 Returns 

566 ---------- 

567 triangles : (n, 3, 3) float 

568 Triangles in space 

569 """ 

570 triangles = [] 

571 triangles_node = [] 

572 for node_name in self.graph.nodes_geometry: 

573 # which geometry does this node refer to 

574 transform, geometry_name = self.graph[node_name] 

575 

576 # get the actual potential mesh instance 

577 geometry = self.geometry[geometry_name] 

578 if not hasattr(geometry, "triangles"): 

579 continue 

580 # append the (n, 3, 3) triangles to a sequence 

581 triangles.append( 

582 transformations.transform_points( 

583 geometry.triangles.copy().reshape((-1, 3)), matrix=transform 

584 ) 

585 ) 

586 # save the node names for each triangle 

587 triangles_node.append(np.tile(node_name, len(geometry.triangles))) 

588 # save the resulting nodes to the cache 

589 self._cache["triangles_node"] = np.hstack(triangles_node) 

590 return np.vstack(triangles).reshape((-1, 3, 3)) 

591 

592 @caching.cache_decorator 

593 def triangles_node(self): 

594 """ 

595 Which node of self.graph does each triangle come from. 

596 

597 Returns 

598 --------- 

599 triangles_index : (len(self.triangles),) 

600 Node name for each triangle 

601 """ 

602 populate = self.triangles # NOQA 

603 return self._cache["triangles_node"] 

604 

605 @caching.cache_decorator 

606 def geometry_identifiers(self) -> dict[str, str]: 

607 """ 

608 Look up geometries by identifier hash. 

609 

610 Returns 

611 --------- 

612 identifiers 

613 {Identifier hash: key in self.geometry} 

614 """ 

615 return {mesh.identifier_hash: name for name, mesh in self.geometry.items()} 

616 

617 @caching.cache_decorator 

618 def identifier_hash(self) -> str: 

619 """ 

620 Get a unique identifier for the scene. 

621 """ 

622 dump = "".join(g.identifier_hash for g in self.geometry.values()) + str( 

623 hash(self.graph) 

624 ) 

625 return sha256(dump.encode()).hexdigest() 

626 

627 @caching.cache_decorator 

628 def duplicate_nodes(self) -> list[list[str]]: 

629 """ 

630 Return a sequence of node keys of identical meshes. 

631 

632 Will include meshes with different geometry but identical 

633 spatial hashes as well as meshes repeated by self.nodes. 

634 

635 Returns 

636 ----------- 

637 duplicates 

638 Keys of self.graph that represent identical geometry 

639 """ 

640 # if there is no geometry we can have no duplicate nodes 

641 if len(self.geometry) == 0: 

642 return [] 

643 

644 # geometry name : hash of mesh 

645 hashes = { 

646 k: int(m.identifier_hash, 16) 

647 for k, m in self.geometry.items() 

648 if hasattr(m, "identifier_hash") 

649 } 

650 

651 # bring into local scope for loop 

652 graph = self.graph 

653 # get a hash for each node name 

654 # scene.graph node name : hashed geometry 

655 node_hash = {node: hashes.get(graph[node][1]) for node in graph.nodes_geometry} 

656 

657 # collect node names for each hash key 

658 duplicates = collections.defaultdict(list) 

659 # use a slightly off-label list comprehension 

660 # for debatable function call overhead avoidance 

661 [ 

662 duplicates[hashed].append(node) 

663 for node, hashed in node_hash.items() 

664 if hashed is not None 

665 ] 

666 

667 # we only care about the values keys are garbage 

668 return list(duplicates.values()) 

669 

670 def reconstruct_instances(self, cost_threshold: Floating = 1e-5) -> "Scene": 

671 """ 

672 If a scene has been "baked" with meshes it means that 

673 the duplicate nodes have *corresponding vertices* but are 

674 rigidly transformed to different places. 

675 

676 This means the problem of finding ab instance transform can 

677 use the `procrustes` analysis which is *very* fast relative 

678 to more complicated registration problems that require ICP 

679 and nearest-point-on-surface calculations. 

680 

681 TODO : construct a parent non-geometry node for containing every group. 

682 

683 Parameters 

684 ---------- 

685 scene 

686 The scene to handle. 

687 cost_threshold 

688 The maximum value for `procrustes` cost which is "squared mean 

689 vertex distance between pair". If the fit is above this value 

690 the instance will be left even if it is a duplicate. 

691 

692 Returns 

693 --------- 

694 dedupe 

695 A copy of the scene de-duplicated as much as possible. 

696 """ 

697 return reconstruct_instances(self, cost_threshold=cost_threshold) 

698 

699 def set_camera( 

700 self, angles=None, distance=None, center=None, resolution=None, fov=None 

701 ) -> cameras.Camera: 

702 """ 

703 Create a camera object for self.camera, and add 

704 a transform to self.graph for it. 

705 

706 If arguments are not passed sane defaults will be figured 

707 out which show the mesh roughly centered. 

708 

709 Parameters 

710 ----------- 

711 angles : (3,) float 

712 Initial euler angles in radians 

713 distance : float 

714 Distance from centroid 

715 center : (3,) float 

716 Point camera should be center on 

717 camera : Camera object 

718 Object that stores camera parameters 

719 """ 

720 

721 if fov is None: 

722 fov = np.array([60, 45]) 

723 

724 # if no geometry nothing to set camera to 

725 if len(self.geometry) == 0: 

726 self._camera = cameras.Camera(fov=fov) 

727 self.graph[self._camera.name] = np.eye(4) 

728 return self._camera 

729 # set with no rotation by default 

730 if angles is None: 

731 angles = np.zeros(3) 

732 

733 rotation = transformations.euler_matrix(*angles) 

734 transform = cameras.look_at( 

735 self.bounds, fov=fov, rotation=rotation, distance=distance, center=center 

736 ) 

737 

738 if hasattr(self, "_camera") and self._camera is not None: 

739 self._camera.fov = fov 

740 if resolution is not None: 

741 self._camera.resolution = resolution 

742 else: 

743 # create a new camera object 

744 self._camera = cameras.Camera(fov=fov, resolution=resolution) 

745 

746 self.graph[self._camera.name] = transform 

747 

748 return self._camera 

749 

750 @property 

751 def camera_transform(self): 

752 """ 

753 Get camera transform in the base frame. 

754 

755 Returns 

756 ------- 

757 camera_transform : (4, 4) float 

758 Camera transform in the base frame 

759 """ 

760 return self.graph[self.camera.name][0] 

761 

762 @camera_transform.setter 

763 def camera_transform(self, matrix: ArrayLike): 

764 """ 

765 Set the camera transform in the base frame 

766 

767 Parameters 

768 ---------- 

769 camera_transform : (4, 4) float 

770 Camera transform in the base frame 

771 """ 

772 self.graph[self.camera.name] = matrix 

773 

774 def camera_rays(self) -> tuple[NDArray[float64], NDArray[float64], NDArray[int64]]: 

775 """ 

776 Calculate the trimesh.scene.Camera origin and ray 

777 direction vectors. Returns one ray per pixel as set 

778 in camera.resolution 

779 

780 Returns 

781 -------------- 

782 origin: (n, 3) float 

783 Ray origins in space 

784 vectors: (n, 3) float 

785 Ray direction unit vectors in world coordinates 

786 pixels : (n, 2) int 

787 Which pixel does each ray correspond to in an image 

788 """ 

789 # get the unit vectors of the camera 

790 vectors, pixels = self.camera.to_rays() 

791 # find our scene's transform for the camera 

792 transform = self.camera_transform 

793 # apply the rotation to the unit ray direction vectors 

794 vectors = transformations.transform_points(vectors, transform, translate=False) 

795 # camera origin is single point so extract from 

796 origins = np.ones_like(vectors) * transformations.translation_from_matrix( 

797 transform 

798 ) 

799 return origins, vectors, pixels 

800 

801 @property 

802 def camera(self) -> cameras.Camera: 

803 """ 

804 Get the single camera for the scene. If not manually 

805 set one will abe automatically generated. 

806 

807 Returns 

808 ---------- 

809 camera : trimesh.scene.Camera 

810 Camera object defined for the scene 

811 """ 

812 # no camera set for the scene yet 

813 if not self.has_camera: 

814 # will create a camera with everything in view 

815 return self.set_camera() 

816 assert self._camera is not None 

817 

818 return self._camera 

819 

820 @camera.setter 

821 def camera(self, camera: cameras.Camera | None): 

822 """ 

823 Set a camera object for the Scene. 

824 

825 Parameters 

826 ----------- 

827 camera : trimesh.scene.Camera 

828 Camera object for the scene 

829 """ 

830 if camera is None: 

831 return 

832 self._camera = camera 

833 

834 @property 

835 def has_camera(self) -> bool: 

836 return hasattr(self, "_camera") and self._camera is not None 

837 

838 @property 

839 def lights(self) -> list[lighting.Light]: 

840 """ 

841 Get a list of the lights in the scene. If nothing is 

842 set it will generate some automatically. 

843 

844 Returns 

845 ------------- 

846 lights : [trimesh.scene.lighting.Light] 

847 Lights in the scene. 

848 """ 

849 if not hasattr(self, "_lights") or self._lights is None: 

850 # do some automatic lighting 

851 lights, transforms = lighting.autolight(self) 

852 # assign the transforms to the scene graph 

853 for L, T in zip(lights, transforms): 

854 self.graph[L.name] = T 

855 # set the lights 

856 self._lights = lights 

857 return self._lights 

858 

859 @lights.setter 

860 def lights(self, lights: Sequence[lighting.Light]): 

861 """ 

862 Assign a list of light objects to the scene 

863 

864 Parameters 

865 -------------- 

866 lights : [trimesh.scene.lighting.Light] 

867 Lights in the scene. 

868 """ 

869 self._lights = lights 

870 

871 def rezero(self) -> None: 

872 """ 

873 Move the current scene so that the AABB of the whole 

874 scene is centered at the origin. 

875 

876 Does this by changing the base frame to a new, offset 

877 base frame. 

878 """ 

879 if self.is_empty or np.allclose(self.centroid, 0.0): 

880 # early exit since what we want already exists 

881 return 

882 

883 # the transformation to move the overall scene to AABB centroid 

884 matrix = np.eye(4) 

885 matrix[:3, 3] = -self.centroid 

886 

887 # we are going to change the base frame 

888 new_base = str(self.graph.base_frame) + "_I" 

889 self.graph.update( 

890 frame_from=new_base, frame_to=self.graph.base_frame, matrix=matrix 

891 ) 

892 self.graph.base_frame = new_base 

893 

894 def dump(self, concatenate: bool = False) -> list[Geometry]: 

895 """ 

896 Get a list of every geometry moved to its instance position, 

897 i.e. freezing or "baking" transforms. 

898 

899 Parameters 

900 ------------ 

901 concatenate 

902 KWARG IS DEPRECATED FOR REMOVAL APRIL 2025 

903 Concatenate results into single geometry. 

904 This keyword argument will make the type hint incorrect and 

905 you should replace `Scene.dump(concatenate=True)` with: 

906 - `Scene.to_geometry()` for a Trimesh, Path2D or Path3D 

907 - `Scene.to_mesh()` for only `Trimesh` components. 

908 

909 Returns 

910 ---------- 

911 dumped 

912 Copies of `Scene.geometry` transformed to their instance position. 

913 """ 

914 

915 result = [] 

916 for node_name in self.graph.nodes_geometry: 

917 transform, geometry_name = self.graph[node_name] 

918 # get a copy of the geometry 

919 current = self.geometry[geometry_name].copy() 

920 

921 # if the geometry is 2D see if we have to upgrade to 3D 

922 if hasattr(current, "to_3D"): 

923 # check to see if the scene is transforming the path out of plane 

924 check = util.isclose(transform, util._IDENTITY, atol=1e-8) 

925 check[:2, :3] = True 

926 if not check.all(): 

927 # transform moves in 3D so we put this on the Z=0 plane 

928 current = current.to_3D() 

929 else: 

930 # transform moves in 2D so clip off the last row and column 

931 transform = transform[:3, :3] 

932 

933 # move the geometry vertices into the requested frame 

934 current.apply_transform(transform) 

935 current.metadata["name"] = geometry_name 

936 current.metadata["node"] = node_name 

937 

938 # save to our list of meshes 

939 result.append(current) 

940 

941 if concatenate: 

942 warnings.warn( 

943 "`Scene.dump(concatenate=True)` DEPRECATED FOR REMOVAL APRIL 2025: replace with `Scene.to_geometry()`", 

944 category=DeprecationWarning, 

945 stacklevel=2, 

946 ) 

947 # if scene has mixed geometry this may drop some of it 

948 return util.concatenate(result) # type: ignore 

949 

950 return result 

951 

952 def to_mesh(self) -> "trimesh.Trimesh": # noqa: F821 

953 """ 

954 Concatenate every mesh instances in the scene into a single mesh, 

955 applying transforms and "baking" the result. Will drop any geometry 

956 in the scene that is not a `Trimesh` object. 

957 

958 Returns 

959 ---------- 

960 mesh 

961 All meshes in the scene concatenated into one. 

962 """ 

963 from ..base import Trimesh 

964 

965 # concatenate only meshes 

966 return util.concatenate([d for d in self.dump() if isinstance(d, Trimesh)]) 

967 

968 def to_geometry(self) -> Geometry: 

969 """ 

970 Concatenate geometry in the scene into a single like-typed geometry, 

971 applying the transforms and "baking" the result. May drop geometry 

972 if the scene has mixed geometry. 

973 

974 Returns 

975 --------- 

976 concat 

977 Either a Trimesh, Path2D, or Path3D depending on what is in the scene. 

978 """ 

979 # concatenate everything and return the most-occurring type. 

980 return util.concatenate(self.dump()) 

981 

982 def subscene(self, node: str) -> "Scene": 

983 """ 

984 Get part of a scene that succeeds a specified node. 

985 

986 Parameters 

987 ------------ 

988 node 

989 Hashable key in `scene.graph` 

990 

991 Returns 

992 ----------- 

993 subscene 

994 Partial scene generated from current. 

995 """ 

996 # get every node that is a successor to specified node 

997 # this includes `node` 

998 graph = self.graph 

999 nodes = graph.transforms.successors(node) 

1000 # get every edge that has an included node 

1001 edges = [e for e in graph.to_edgelist() if e[0] in nodes] 

1002 

1003 # create a scene graph when 

1004 graph = SceneGraph(base_frame=node) 

1005 graph.from_edgelist(edges) 

1006 

1007 geometry_names = {e[2]["geometry"] for e in edges if "geometry" in e[2]} 

1008 geometry = {k: self.geometry[k] for k in geometry_names} 

1009 result = Scene(geometry=geometry, graph=graph) 

1010 return result 

1011 

1012 @caching.cache_decorator 

1013 def convex_hull(self): 

1014 """ 

1015 The convex hull of the whole scene. 

1016 

1017 Returns 

1018 --------- 

1019 hull : trimesh.Trimesh 

1020 Trimesh object which is a convex hull of all meshes in scene 

1021 """ 

1022 points = util.vstack_empty([m.vertices for m in self.dump()]) # type: ignore 

1023 return convex.convex_hull(points) 

1024 

1025 def export(self, file_obj=None, file_type=None, **kwargs): 

1026 """ 

1027 Export a snapshot of the current scene. 

1028 

1029 Parameters 

1030 ---------- 

1031 file_obj : str, file-like, or None 

1032 File object to export to 

1033 file_type : str or None 

1034 What encoding to use for meshes 

1035 IE: dict, dict64, stl 

1036 

1037 Returns 

1038 ---------- 

1039 export : bytes 

1040 Only returned if file_obj is None 

1041 """ 

1042 return export.export_scene( 

1043 scene=self, file_obj=file_obj, file_type=file_type, **kwargs 

1044 ) 

1045 

1046 def save_image(self, resolution=None, **kwargs) -> bytes: 

1047 """ 

1048 Get a PNG image of a scene. 

1049 

1050 Parameters 

1051 ----------- 

1052 resolution : (2,) int 

1053 Resolution to render image 

1054 **kwargs 

1055 Passed to SceneViewer constructor 

1056 

1057 Returns 

1058 ----------- 

1059 png : bytes 

1060 Render of scene as a PNG 

1061 """ 

1062 from ..viewer.windowed import render_scene 

1063 

1064 return render_scene( 

1065 scene=self, resolution=resolution, fullscreen=False, resizable=False, **kwargs 

1066 ) 

1067 

1068 @property 

1069 def units(self) -> str | None: 

1070 """ 

1071 Get the units for every model in the scene. If the scene has 

1072 mixed units or no units this will return None. 

1073 

1074 Returns 

1075 ----------- 

1076 units 

1077 Units for every model in the scene or None 

1078 if there are no units or mixed units 

1079 """ 

1080 # get a set of the units of every geometry 

1081 existing = {i.units for i in self.geometry.values()} 

1082 if len(existing) == 1: 

1083 return existing.pop() 

1084 elif len(existing) > 1: 

1085 log.warning(f"Mixed units `{existing}` returning None") 

1086 return None 

1087 

1088 @units.setter 

1089 def units(self, value: str): 

1090 """ 

1091 Set the units for every model in the scene without 

1092 converting any units just setting the tag. 

1093 

1094 Parameters 

1095 ------------ 

1096 value : str 

1097 Value to set every geometry unit value to 

1098 """ 

1099 value = value.strip().lower() 

1100 for m in self.geometry.values(): 

1101 m.units = value 

1102 

1103 def convert_units(self, desired: str, guess: bool = False) -> "Scene": 

1104 """ 

1105 If geometry has units defined convert them to new units. 

1106 

1107 Returns a new scene with geometries and transforms scaled. 

1108 

1109 Parameters 

1110 ---------- 

1111 desired : str 

1112 Desired final unit system: 'inches', 'mm', etc. 

1113 guess : bool 

1114 Is the converter allowed to guess scale when models 

1115 don't have it specified in their metadata. 

1116 

1117 Returns 

1118 ---------- 

1119 scaled : trimesh.Scene 

1120 Copy of scene with scaling applied and units set 

1121 for every model 

1122 """ 

1123 # if there is no geometry do nothing 

1124 if len(self.geometry) == 0: 

1125 return self.copy() 

1126 

1127 current = self.units 

1128 if current is None: 

1129 # will raise ValueError if not in metadata 

1130 # and not allowed to guess 

1131 current = units.units_from_metadata(self, guess=guess) 

1132 

1133 # find the float conversion 

1134 scale = units.unit_conversion(current=current, desired=desired) 

1135 

1136 # apply scaling factor or exit early if scale ~= 1.0 

1137 result = self.scaled(scale=scale) 

1138 

1139 # apply the units to every geometry of the scaled result 

1140 result.units = desired 

1141 

1142 return result 

1143 

1144 def explode(self, vector=None, origin=None) -> None: 

1145 """ 

1146 Explode the current scene in-place around a point and vector. 

1147 

1148 Parameters 

1149 ----------- 

1150 vector : (3,) float or float 

1151 Explode radially around a direction vector or spherically 

1152 origin : (3,) float 

1153 Point to explode around 

1154 """ 

1155 if origin is None: 

1156 origin = self.centroid 

1157 if vector is None: 

1158 vector = self.scale / 25.0 

1159 

1160 vector = np.asanyarray(vector, dtype=np.float64) 

1161 origin = np.asanyarray(origin, dtype=np.float64) 

1162 

1163 for node_name in self.graph.nodes_geometry: 

1164 transform, geometry_name = self.graph[node_name] 

1165 centroid = self.geometry[geometry_name].centroid 

1166 # transform centroid into nodes location 

1167 centroid = np.dot(transform, np.append(centroid, 1))[:3] 

1168 

1169 if vector.shape == (): 

1170 # case where our vector is a single number 

1171 offset = (centroid - origin) * vector 

1172 elif np.shape(vector) == (3,): 

1173 projected = np.dot(vector, (centroid - origin)) 

1174 offset = vector * projected 

1175 else: 

1176 raise ValueError("explode vector wrong shape!") 

1177 

1178 # original transform is read-only 

1179 T_new = transform.copy() 

1180 T_new[:3, 3] += offset 

1181 self.graph[node_name] = T_new 

1182 

1183 def scaled(self, scale: Floating | ArrayLike) -> "Scene": 

1184 """ 

1185 Return a copy of the current scene, with meshes and scene 

1186 transforms scaled to the requested factor. 

1187 

1188 Parameters 

1189 ----------- 

1190 scale : float or (3,) float 

1191 Factor to scale meshes and transforms 

1192 

1193 Returns 

1194 ----------- 

1195 scaled : trimesh.Scene 

1196 A copy of the current scene but scaled 

1197 """ 

1198 result = self.copy() 

1199 

1200 # a scale of 1.0 is a no-op 

1201 if np.allclose(scale, 1.0): 

1202 return result 

1203 

1204 # convert 2D geometries to 3D for 3D scaling factors 

1205 scale_is_3D = isinstance(scale, (list, tuple, np.ndarray)) and len(scale) == 3 

1206 

1207 if scale_is_3D and np.all(np.asarray(scale) == scale[0]): 

1208 # scale is uniform 

1209 scale = float(scale[0]) 

1210 scale_is_3D = False 

1211 elif not scale_is_3D: 

1212 scale = float(scale) 

1213 

1214 # result is a copy 

1215 

1216 if scale_is_3D: 

1217 # Copy all geometries that appear multiple times in the scene, 

1218 # such that no two nodes share the same geometry. 

1219 # This is required since the non-uniform scaling will most likely 

1220 # affect the same geometry in different poses differently. 

1221 # Note, that this is not needed in the case of uniform scaling. 

1222 for geom_name in result.graph.geometry_nodes: 

1223 nodes_with_geom = result.graph.geometry_nodes[geom_name] 

1224 if len(nodes_with_geom) > 1: 

1225 geom = result.geometry[geom_name] 

1226 for n in nodes_with_geom: 

1227 p = result.graph.transforms.parents[n] 

1228 result.add_geometry( 

1229 geometry=geom.copy(), 

1230 geom_name=geom_name, 

1231 node_name=n, 

1232 parent_node_name=p, 

1233 transform=result.graph.transforms.edge_data[(p, n)].get( 

1234 "matrix", None 

1235 ), 

1236 metadata=result.graph.transforms.edge_data[(p, n)].get( 

1237 "metadata", None 

1238 ), 

1239 ) 

1240 result.delete_geometry(geom_name) 

1241 

1242 # Convert all 2D paths to 3D paths 

1243 for geom_name in result.geometry: 

1244 if result.geometry[geom_name].vertices.shape[1] == 2: 

1245 result.geometry[geom_name] = result.geometry[geom_name].to_3D() 

1246 

1247 for key in result.graph.nodes_geometry: 

1248 T, geom_name = result.graph.get(key) 

1249 # transform from graph should be read-only 

1250 T = T.copy() 

1251 T[:3, 3] = 0.0 

1252 

1253 # Get geometry transform w.r.t. base frame 

1254 result.geometry[geom_name].apply_transform(T).apply_scale( 

1255 scale 

1256 ).apply_transform(np.linalg.inv(T)) 

1257 

1258 # Scale all transformations in the scene graph 

1259 edge_data = result.graph.transforms.edge_data 

1260 for uv in edge_data: 

1261 if "matrix" in edge_data[uv]: 

1262 props = edge_data[uv] 

1263 T = edge_data[uv]["matrix"].copy() 

1264 T[:3, 3] *= scale 

1265 props["matrix"] = T 

1266 result.graph.update(frame_from=uv[0], frame_to=uv[1], **props) 

1267 # Clear cache 

1268 result.graph.transforms._cache = {} 

1269 result.graph.transforms._modified = str(uuid.uuid4()) 

1270 result.graph._cache.clear() 

1271 else: 

1272 # matrix for 2D scaling 

1273 scale_2D = np.eye(3) * scale 

1274 # matrix for 3D scaling 

1275 scale_3D = np.eye(4) * scale 

1276 

1277 # preallocate transforms and geometries 

1278 nodes = np.array(self.graph.nodes_geometry) 

1279 transforms = np.zeros((len(nodes), 4, 4)) 

1280 geometries = [None] * len(nodes) 

1281 

1282 # collect list of transforms 

1283 for i, node in enumerate(nodes): 

1284 transforms[i], geometries[i] = self.graph[node] 

1285 

1286 # remove all existing transforms 

1287 result.graph.clear() 

1288 

1289 for group in grouping.group(geometries): 

1290 # hashable reference to self.geometry 

1291 geometry = geometries[group[0]] 

1292 # original transform from world to geometry 

1293 original = transforms[group[0]] 

1294 # transform for geometry 

1295 new_geom = np.dot(scale_3D, original) 

1296 

1297 if result.geometry[geometry].vertices.shape[1] == 2: 

1298 # if our scene is 2D only scale in 2D 

1299 result.geometry[geometry].apply_transform(scale_2D) 

1300 else: 

1301 # otherwise apply the full transform 

1302 result.geometry[geometry].apply_transform(new_geom) 

1303 

1304 for node, T in zip(nodes[group], transforms[group]): 

1305 # generate the new transforms 

1306 transform = util.multi_dot([scale_3D, T, np.linalg.inv(new_geom)]) 

1307 # apply scale to translation 

1308 transform[:3, 3] *= scale 

1309 # update scene with new transforms 

1310 result.graph.update( 

1311 frame_to=node, matrix=transform, geometry=geometry 

1312 ) 

1313 

1314 # remove camera from copied 

1315 result._camera = None 

1316 

1317 return result 

1318 

1319 def copy(self) -> "Scene": 

1320 """ 

1321 Return a deep copy of the current scene 

1322 

1323 Returns 

1324 ---------- 

1325 copied : trimesh.Scene 

1326 Copy of the current scene 

1327 """ 

1328 # use the geometries copy method to 

1329 # allow them to handle references to unpickle-able objects 

1330 geometry = {n: g.copy() for n, g in self.geometry.items()} 

1331 

1332 if not hasattr(self, "_camera") or self._camera is None: 

1333 # if no camera set don't include it 

1334 camera = None 

1335 else: 

1336 # otherwise get a copy of the camera 

1337 camera = self.camera.copy() 

1338 # create a new scene with copied geometry and graph 

1339 copied = Scene( 

1340 geometry=geometry, 

1341 graph=self.graph.copy(), 

1342 metadata=self.metadata.copy(), 

1343 camera=camera, 

1344 ) 

1345 return copied 

1346 

1347 def show( 

1348 self, 

1349 viewer: ViewerType = None, 

1350 **kwargs, 

1351 ): 

1352 """ 

1353 Display the current scene. 

1354 

1355 Parameters 

1356 ----------- 

1357 viewer 

1358 What kind of viewer to use, such as 

1359 `gl` to open a pyglet window 

1360 `jupyter` for a jupyter notebook 

1361 `marimo'` for a marimo notebook 

1362 None for a "best guess" 

1363 kwargs 

1364 Passed to viewer, such as `smooth=False` which will turn 

1365 off automatic smooth shading 

1366 """ 

1367 

1368 if viewer is None: 

1369 # check to see if we are in a notebook or not 

1370 from ..viewer import in_notebook 

1371 

1372 # returns a literal for what kind of notebook, or False 

1373 viewer = in_notebook() 

1374 if not viewer: 

1375 viewer = "gl" 

1376 

1377 if viewer == "gl": 

1378 # this imports pyglet, and will raise an ImportError 

1379 # if pyglet is not available 

1380 from ..viewer import SceneViewer 

1381 

1382 return SceneViewer(self, **kwargs) 

1383 elif viewer == "jupyter": 

1384 from ..viewer import scene_to_notebook 

1385 

1386 return scene_to_notebook(self, **kwargs) 

1387 elif viewer == "marimo": 

1388 from ..viewer import scene_to_mo_notebook 

1389 

1390 return scene_to_mo_notebook(self, **kwargs) 

1391 elif callable(viewer): 

1392 # if a callable method like a custom class 

1393 # constructor was passed run using that 

1394 return viewer(self, **kwargs) 

1395 else: 

1396 raise ValueError( 

1397 "Invalid value for viewer: not 'gl', 'jupyter', 'marimo', callable, or None" 

1398 ) 

1399 

1400 def __add__(self, other): 

1401 """ 

1402 Concatenate the current scene with another scene or mesh. 

1403 

1404 Parameters 

1405 ------------ 

1406 other : trimesh.Scene, trimesh.Trimesh, trimesh.Path 

1407 Other object to append into the result scene 

1408 

1409 Returns 

1410 ------------ 

1411 appended : trimesh.Scene 

1412 Scene with geometry from both scenes 

1413 """ 

1414 result = append_scenes([self, other], common=[self.graph.base_frame]) 

1415 return result 

1416 

1417 

1418def split_scene(geometry, **kwargs): 

1419 """ 

1420 Given a geometry, list of geometries, or a Scene 

1421 return them as a single Scene object. 

1422 

1423 Parameters 

1424 ---------- 

1425 geometry : splittable 

1426 

1427 Returns 

1428 --------- 

1429 scene: trimesh.Scene 

1430 """ 

1431 # already a scene, so return it 

1432 if isinstance(geometry, Scene): 

1433 return geometry 

1434 

1435 # save metadata 

1436 metadata = {} 

1437 

1438 # a list of things 

1439 if util.is_sequence(geometry): 

1440 [metadata.update(getattr(g, "metadata", {})) for g in geometry] 

1441 

1442 scene = Scene(geometry, metadata=metadata) 

1443 scene._source = next((g.source for g in geometry if g.source is not None), None) 

1444 else: 

1445 # a single geometry so we are going to split 

1446 scene = Scene( 

1447 geometry.split(**kwargs), 

1448 metadata=deepcopy(geometry.metadata), 

1449 ) 

1450 scene._source = deepcopy(geometry.source) 

1451 

1452 return scene 

1453 

1454 

1455def append_scenes(iterable, common=None, base_frame="world"): 

1456 """ 

1457 Concatenate multiple scene objects into one scene. 

1458 

1459 Parameters 

1460 ------------- 

1461 iterable : (n,) Trimesh or Scene 

1462 Geometries that should be appended 

1463 common : (n,) str 

1464 Nodes that shouldn't be remapped 

1465 base_frame : str 

1466 Base frame of the resulting scene 

1467 

1468 Returns 

1469 ------------ 

1470 result : trimesh.Scene 

1471 Scene containing all geometry 

1472 """ 

1473 if isinstance(iterable, Scene): 

1474 return iterable 

1475 

1476 if common is None: 

1477 common = [base_frame] 

1478 

1479 # save geometry in dict 

1480 geometry = {} 

1481 # save transforms as edge tuples 

1482 edges = [] 

1483 

1484 # nodes which shouldn't be remapped 

1485 common = set(common) 

1486 # nodes which are consumed and need to be remapped 

1487 consumed = set() 

1488 

1489 def node_remap(node): 

1490 """ 

1491 Remap node to new name if necessary 

1492 

1493 Parameters 

1494 ------------- 

1495 node : hashable 

1496 Node name in original scene 

1497 

1498 Returns 

1499 ------------- 

1500 name : hashable 

1501 Node name in concatenated scene 

1502 """ 

1503 

1504 # if we've already remapped a node use it 

1505 if node in map_node: 

1506 return map_node[node] 

1507 

1508 # if a node is consumed and isn't one of the nodes 

1509 # we're going to hold common between scenes remap it 

1510 if node not in common and node in consumed: 

1511 # generate a name not in consumed 

1512 name = node + util.unique_id() 

1513 map_node[node] = name 

1514 node = name 

1515 

1516 # keep track of which nodes have been used 

1517 # in the current scene 

1518 current.add(node) 

1519 return node 

1520 

1521 # loop through every geometry 

1522 for s in iterable: 

1523 # allow Trimesh/Path2D geometry to be passed 

1524 if hasattr(s, "scene"): 

1525 s = s.scene() 

1526 # if we don't have a scene raise an exception 

1527 if not isinstance(s, Scene): 

1528 raise ValueError(f"{type(s).__name__} is not a scene!") 

1529 

1530 # remap geometries if they have been consumed 

1531 map_geom = {} 

1532 for k, v in s.geometry.items(): 

1533 # if a geometry already exists add a UUID to the name 

1534 name = unique_name(start=k, contains=geometry.keys()) 

1535 # store name mapping 

1536 map_geom[k] = name 

1537 # store geometry with new name 

1538 geometry[name] = v 

1539 

1540 # remap nodes and edges so duplicates won't 

1541 # stomp all over each other 

1542 map_node = {} 

1543 # the nodes used in this scene 

1544 current = set() 

1545 for a, b, attr in s.graph.to_edgelist(): 

1546 # remap node names from local names 

1547 a, b = node_remap(a), node_remap(b) 

1548 # remap geometry keys 

1549 # if key is not in map_geom it means one of the scenes 

1550 # referred to geometry that doesn't exist 

1551 # rather than crash here we ignore it as the user 

1552 # possibly intended to add in geometries back later 

1553 if "geometry" in attr and attr["geometry"] in map_geom: 

1554 attr["geometry"] = map_geom[attr["geometry"]] 

1555 # save the new edge 

1556 edges.append((a, b, attr)) 

1557 # mark nodes from current scene as consumed 

1558 consumed.update(current) 

1559 

1560 # add all data to a new scene 

1561 result = Scene(base_frame=base_frame) 

1562 result.graph.from_edgelist(edges) 

1563 result.geometry.update(geometry) 

1564 

1565 return result 

1566 

1567 

1568def reconstruct_instances(scene: Scene, cost_threshold: Floating = 1e-6) -> Scene: 

1569 """ 

1570 If a scene has been "baked" with meshes it means that 

1571 the duplicate nodes have *corresponding vertices* but are 

1572 rigidly transformed to different places. 

1573 

1574 This means the problem of finding ab instance transform can 

1575 use the `procrustes` analysis which is *very* fast relative 

1576 to more complicated registration problems that require ICP 

1577 and nearest-point-on-surface calculations. 

1578 

1579 TODO : construct a parent non-geometry node for containing every group. 

1580 

1581 Parameters 

1582 ---------- 

1583 scene 

1584 The scene to handle. 

1585 cost_threshold 

1586 The maximum value for `procrustes` cost which is "squared mean 

1587 vertex distance between pair". If the fit is above this value 

1588 the instance will be left even if it is a duplicate. 

1589 

1590 Returns 

1591 --------- 

1592 dedupe 

1593 A copy of the scene de-duplicated as much as possible. 

1594 """ 

1595 # start with the original scene graph and modify in-loop 

1596 graph = scene.graph.copy() 

1597 

1598 for group in scene.duplicate_nodes: 

1599 # not sure if this ever includes 

1600 if len(group) < 2: 

1601 continue 

1602 

1603 # we are going to use one of the geometries and try to register the others to it 

1604 node_base = group[0] 

1605 # get the geometry name for this base node 

1606 _, geom_base = scene.graph[node_base] 

1607 # get the vertices of the base model 

1608 base = scene.geometry[geom_base].vertices.view(np.ndarray) 

1609 

1610 for node in group[1:]: 

1611 # the original pose of this node in the scene 

1612 node_mat, node_geom = scene.graph[node] 

1613 # procrustes matches corresponding point arrays very quickly 

1614 # but we have to make sure that they actual correspond in shape 

1615 node_vertices = scene.geometry[node_geom].vertices.view(np.ndarray) 

1616 

1617 # procrustes only works on corresponding point clouds! 

1618 if node_vertices.shape != base.shape: 

1619 continue 

1620 

1621 # solve for a pose moving this instance into position 

1622 matrix, _p, cost = procrustes( 

1623 base, node_vertices, translation=True, scale=False, reflection=False 

1624 ) 

1625 if cost < cost_threshold: 

1626 # add the transform we found 

1627 graph.update(node, matrix=np.dot(node_mat, matrix), geometry=geom_base) 

1628 

1629 # get from the new graph which geometry ends up with a reference 

1630 referenced = set(graph.geometry_nodes.keys()) 

1631 

1632 # return a scene with the de-duplicated graph and a copy of any geometry 

1633 return Scene( 

1634 geometry={k: v.copy() for k, v in scene.geometry.items() if k in referenced}, 

1635 graph=graph, 

1636 )