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

508 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-31 23:55 +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 Hashable, 

20 Integer, 

21 Iterable, 

22 NDArray, 

23 Sequence, 

24 ViewerType, 

25 float64, 

26 int64, 

27) 

28from ..util import unique_name 

29from . import cameras, lighting 

30from .transforms import SceneGraph 

31 

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

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

34 

35 

36class Scene(Geometry3D): 

37 """ 

38 A simple scene graph which can be rendered directly via 

39 pyglet/openGL or through other endpoints such as a 

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

41 moved by updating transform in the transform tree. 

42 """ 

43 

44 def __init__( 

45 self, 

46 geometry: GeometryInput | None = None, 

47 base_frame: Hashable = "world", 

48 metadata: dict | None = None, 

49 graph: SceneGraph | None = None, 

50 camera: cameras.Camera | None = None, 

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

52 camera_transform: NDArray | None = None, 

53 ): 

54 """ 

55 Create a new Scene object. 

56 

57 Parameters 

58 ------------- 

59 geometry : Trimesh, Path2D, Path3D PointCloud or list 

60 Geometry to initially add to the scene 

61 base_frame 

62 Name of base frame 

63 metadata 

64 Any metadata about the scene 

65 graph 

66 A passed transform graph to use 

67 camera : Camera or None 

68 A passed camera to use 

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

70 A passed lights to use 

71 camera_transform 

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

73 """ 

74 # mesh name : Trimesh object 

75 self.geometry = collections.OrderedDict() 

76 

77 # create a new graph 

78 self.graph = SceneGraph(base_frame=base_frame) 

79 

80 # create our cache 

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

82 

83 if geometry is not None: 

84 # add passed geometry to scene 

85 self.add_geometry(geometry) 

86 

87 # hold metadata about the scene 

88 self.metadata = {} 

89 if isinstance(metadata, dict): 

90 self.metadata.update(metadata) 

91 

92 if graph is not None: 

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

94 self.graph = graph 

95 

96 if lights is not None: 

97 self.lights = lights 

98 if camera is not None: 

99 self.camera = camera 

100 if camera_transform is not None: 

101 self.camera_transform = camera_transform 

102 

103 def apply_transform(self, transform): 

104 """ 

105 Apply a transform to all children of the base frame 

106 without modifying any geometry. 

107 

108 Parameters 

109 -------------- 

110 transform : (4, 4) 

111 Homogeneous transformation matrix. 

112 """ 

113 base = self.graph.base_frame 

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

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

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

117 return self 

118 

119 def add_geometry( 

120 self, 

121 geometry: GeometryInput, 

122 node_name: Hashable | None = None, 

123 geom_name: str | None = None, 

124 parent_node_name: Hashable | None = None, 

125 transform: NDArray | None = None, 

126 metadata: dict | None = None, 

127 ): 

128 """ 

129 Add a geometry to the scene. 

130 

131 If the mesh has multiple transforms defined in its 

132 metadata, they will all be copied into the 

133 TransformForest of the current scene automatically. 

134 

135 Parameters 

136 ---------- 

137 geometry : Trimesh, Path2D, Path3D PointCloud or list 

138 Geometry to initially add to the scene 

139 node_name : None or str 

140 Name of the added node. 

141 geom_name : None or str 

142 Name of the added geometry. 

143 parent_node_name : None or str 

144 Name of the parent node in the graph. 

145 transform : None or (4, 4) float 

146 Transform that applies to the added node. 

147 metadata : None or dict 

148 Optional metadata for the node. 

149 

150 Returns 

151 ---------- 

152 node_name : str 

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

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

155 """ 

156 

157 if geometry is None: 

158 return 

159 # PointCloud objects will look like a sequence 

160 elif util.is_sequence(geometry): 

161 # if passed a sequence add all elements 

162 return [ 

163 self.add_geometry( 

164 geometry=value, 

165 node_name=node_name, 

166 geom_name=geom_name, 

167 parent_node_name=parent_node_name, 

168 transform=transform, 

169 metadata=metadata, 

170 ) 

171 for value in geometry # type: ignore 

172 ] 

173 elif isinstance(geometry, dict): 

174 # if someone passed us a dict of geometry 

175 return { 

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

177 for k, v in geometry.items() 

178 } 

179 

180 elif isinstance(geometry, Scene): 

181 # concatenate current scene with passed scene 

182 concat = self + geometry 

183 # replace geometry in-place 

184 self.geometry.clear() 

185 self.geometry.update(concat.geometry) 

186 # replace graph data with concatenated graph 

187 self.graph.transforms = concat.graph.transforms 

188 return 

189 

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

191 if geom_name is not None: 

192 # if name is passed use it 

193 name = geom_name 

194 elif "name" in geometry.metadata: 

195 # if name is in metadata use it 

196 name = geometry.metadata["name"] 

197 elif geometry.source.file_name is not None: 

198 name = geometry.source.file_name 

199 else: 

200 # try to create a simple name 

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

202 

203 # if its already taken use our unique name logic 

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

205 # save the geometry reference 

206 self.geometry[name] = geometry 

207 

208 # create a unique node name if not passed 

209 if node_name is None: 

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

211 # which graph nodes already exist 

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

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

214 # at the name we have 

215 node_name = unique_name(name, existing) 

216 assert node_name not in existing 

217 

218 if transform is None: 

219 # create an identity transform from parent_node 

220 transform = np.eye(4) 

221 

222 self.graph.update( 

223 frame_to=node_name, 

224 frame_from=parent_node_name, 

225 matrix=transform, 

226 geometry=name, 

227 geometry_flags={"visible": True}, 

228 metadata=metadata, 

229 ) 

230 

231 return node_name 

232 

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

234 """ 

235 Delete one more multiple geometries from the scene and also 

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

237 

238 Parameters 

239 -------------- 

240 name : hashable 

241 Name that references self.geometry 

242 """ 

243 # make sure we have a set we can check 

244 if isinstance(names, str): 

245 names = [names] 

246 names = set(names) 

247 

248 # remove the geometry reference from relevant nodes 

249 self.graph.remove_geometries(names) 

250 # remove the geometries from our geometry store 

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

252 

253 def strip_visuals(self) -> None: 

254 """ 

255 Strip visuals from every Trimesh geometry 

256 and set them to an empty `ColorVisuals`. 

257 """ 

258 from ..visual.color import ColorVisuals 

259 

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

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

262 geometry.visual = ColorVisuals(mesh=geometry) 

263 

264 def simplify_quadric_decimation( 

265 self, 

266 percent: Floating | None = None, 

267 face_count: Integer | None = None, 

268 aggression: Integer | None = None, 

269 ) -> None: 

270 """ 

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

272 in the scene. 

273 

274 Parameters 

275 ----------- 

276 percent 

277 A number between 0.0 and 1.0 for how much 

278 face_count 

279 Target number of faces desired in the resulting mesh. 

280 aggression 

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

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

283 

284 """ 

285 # save the updates for after the loop 

286 updates = {} 

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

288 if hasattr(v, "simplify_quadric_decimation"): 

289 updates[k] = v.simplify_quadric_decimation( 

290 percent=percent, face_count=face_count, aggression=aggression 

291 ) 

292 self.geometry.update(updates) 

293 

294 def __hash__(self) -> int: 

295 """ 

296 Return information about scene which is hashable. 

297 

298 Returns 

299 --------- 

300 hashed 

301 String hashing scene. 

302 """ 

303 # avoid accessing attribute in tight loop 

304 geometry = self.geometry 

305 # hash of geometry and transforms 

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

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

308 # take the re-hex string of the hash 

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

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

311 

312 @property 

313 def is_empty(self) -> bool: 

314 """ 

315 Does the scene have anything in it. 

316 

317 Returns 

318 ---------- 

319 is_empty 

320 True if nothing is in the scene 

321 """ 

322 

323 return len(self.geometry) == 0 

324 

325 @property 

326 def is_valid(self) -> bool: 

327 """ 

328 Is every geometry connected to the root node. 

329 

330 Returns 

331 ----------- 

332 is_valid : bool 

333 Does every geometry have a transform 

334 """ 

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

336 return True 

337 

338 try: 

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

340 except BaseException: 

341 # if connectivity to world frame is broken return false 

342 return False 

343 

344 # every geometry is referenced 

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

346 

347 @caching.cache_decorator 

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

349 """ 

350 Get the post-transform AABB for each node 

351 which has geometry defined. 

352 

353 Returns 

354 ----------- 

355 corners 

356 Bounds for each node with vertices: 

357 {node_name : (2, 3) float} 

358 """ 

359 # collect AABB for each geometry 

360 corners = {} 

361 # collect vertices for every mesh 

362 vertices = { 

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

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

365 } 

366 # handle 2D geometries 

367 vertices.update( 

368 { 

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

370 for k, v in vertices.items() 

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

372 } 

373 ) 

374 

375 # loop through every node with geometry 

376 for node_name in self.graph.nodes_geometry: 

377 # access the transform and geometry name from node 

378 transform, geometry_name = self.graph[node_name] 

379 # will be None if no vertices for this node 

380 points = vertices.get(geometry_name) 

381 # skip empty geometries 

382 if points is None: 

383 continue 

384 # apply just the rotation to skip N multiplies 

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

386 # append the AABB with translation applied after 

387 corners[node_name] = np.array( 

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

389 ) 

390 return corners 

391 

392 @caching.cache_decorator 

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

394 """ 

395 Return the overall bounding box of the scene. 

396 

397 Returns 

398 -------- 

399 bounds : (2, 3) float or None 

400 Position of [min, max] bounding box 

401 Returns None if no valid bounds exist 

402 """ 

403 bounds_corners = self.bounds_corners 

404 if len(bounds_corners) == 0: 

405 return None 

406 # combine each geometry node AABB into a larger list 

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

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

409 

410 @caching.cache_decorator 

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

412 """ 

413 Return the axis aligned box size of the current scene 

414 or None if the scene is empty. 

415 

416 Returns 

417 ---------- 

418 extents 

419 Bounding box sides length or None for empty scene. 

420 """ 

421 bounds = self.bounds 

422 if bounds is None: 

423 return None 

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

425 

426 @caching.cache_decorator 

427 def scale(self) -> float: 

428 """ 

429 The approximate scale of the mesh 

430 

431 Returns 

432 ----------- 

433 scale : float 

434 The mean of the bounding box edge lengths 

435 """ 

436 extents = self.extents 

437 if extents is None: 

438 return 1.0 

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

440 

441 @caching.cache_decorator 

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

443 """ 

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

445 

446 Returns 

447 -------- 

448 centroid : (3) float 

449 Point for center of bounding box 

450 """ 

451 bounds = self.bounds 

452 if bounds is None: 

453 return None 

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

455 return centroid 

456 

457 @caching.cache_decorator 

458 def center_mass(self) -> NDArray: 

459 """ 

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

461 

462 Returns 

463 ------------ 

464 center_mass : (3,) float 

465 The center of mass of the scene 

466 """ 

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

468 center_mass = { 

469 k: m.center_mass 

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

471 if hasattr(m, "center_mass") 

472 } 

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

474 

475 # get the geometry name and transform for each instance 

476 graph = self.graph 

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

478 

479 # get the transformed center of mass for each instance 

480 transformed = np.array( 

481 [ 

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

483 for mat, g in instance 

484 if g in center_mass 

485 ], 

486 dtype=np.float64, 

487 ) 

488 # weight the center of mass locations by volume 

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

490 weights /= weights.sum() 

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

492 

493 @caching.cache_decorator 

494 def moment_inertia(self): 

495 """ 

496 Return the moment of inertia of the current scene with 

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

498 

499 Returns 

500 ------------ 

501 inertia : (3, 3) float 

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

503 """ 

504 return inertia.scene_inertia( 

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

506 ) 

507 

508 def moment_inertia_frame(self, transform): 

509 """ 

510 Return the moment of inertia of the current scene relative 

511 to a transform from the base frame. 

512 

513 Parameters 

514 transform : (4, 4) float 

515 Homogeneous transformation matrix. 

516 

517 Returns 

518 ------------- 

519 inertia : (3, 3) float 

520 Inertia tensor at requested frame. 

521 """ 

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

523 

524 @caching.cache_decorator 

525 def area(self) -> float: 

526 """ 

527 What is the summed area of every geometry which 

528 has area. 

529 

530 Returns 

531 ------------ 

532 area : float 

533 Summed area of every instanced geometry 

534 """ 

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

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

537 # sum the area including instancing 

538 return sum( 

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

540 ) 

541 

542 @caching.cache_decorator 

543 def volume(self) -> float64: 

544 """ 

545 What is the summed volume of every geometry which 

546 has volume 

547 

548 Returns 

549 ------------ 

550 volume : float 

551 Summed area of every instanced geometry 

552 """ 

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

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

555 # sum the area including instancing 

556 return sum( 

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

558 ) 

559 

560 @caching.cache_decorator 

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

562 """ 

563 Return a correctly transformed polygon soup of the 

564 current scene. 

565 

566 Returns 

567 ---------- 

568 triangles : (n, 3, 3) float 

569 Triangles in space 

570 """ 

571 triangles = [] 

572 triangles_node = [] 

573 for node_name in self.graph.nodes_geometry: 

574 # which geometry does this node refer to 

575 transform, geometry_name = self.graph[node_name] 

576 

577 # get the actual potential mesh instance 

578 geometry = self.geometry[geometry_name] 

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

580 continue 

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

582 triangles.append( 

583 transformations.transform_points( 

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

585 ) 

586 ) 

587 # save the node names for each triangle 

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

589 # save the resulting nodes to the cache 

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

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

592 

593 @caching.cache_decorator 

594 def triangles_node(self): 

595 """ 

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

597 

598 Returns 

599 --------- 

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

601 Node name for each triangle 

602 """ 

603 populate = self.triangles # NOQA 

604 return self._cache["triangles_node"] 

605 

606 @caching.cache_decorator 

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

608 """ 

609 Look up geometries by identifier hash. 

610 

611 Returns 

612 --------- 

613 identifiers 

614 {Identifier hash: key in self.geometry} 

615 """ 

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

617 

618 @caching.cache_decorator 

619 def identifier_hash(self) -> str: 

620 """ 

621 Get a unique identifier for the scene. 

622 """ 

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

624 hash(self.graph) 

625 ) 

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

627 

628 @caching.cache_decorator 

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

630 """ 

631 Return a sequence of node keys of identical meshes. 

632 

633 Will include meshes with different geometry but identical 

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

635 

636 Returns 

637 ----------- 

638 duplicates 

639 Keys of self.graph that represent identical geometry 

640 """ 

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

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

643 return [] 

644 

645 # geometry name : hash of mesh 

646 hashes = { 

647 k: int(m.identifier_hash, 16) 

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

649 if hasattr(m, "identifier_hash") 

650 } 

651 

652 # bring into local scope for loop 

653 graph = self.graph 

654 # get a hash for each node name 

655 # scene.graph node name : hashed geometry 

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

657 

658 # collect node names for each hash key 

659 duplicates = collections.defaultdict(list) 

660 # use a slightly off-label list comprehension 

661 # for debatable function call overhead avoidance 

662 [ 

663 duplicates[hashed].append(node) 

664 for node, hashed in node_hash.items() 

665 if hashed is not None 

666 ] 

667 

668 # we only care about the values keys are garbage 

669 return list(duplicates.values()) 

670 

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

672 """ 

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

674 the duplicate nodes have *corresponding vertices* but are 

675 rigidly transformed to different places. 

676 

677 This means the problem of finding ab instance transform can 

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

679 to more complicated registration problems that require ICP 

680 and nearest-point-on-surface calculations. 

681 

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

683 

684 Parameters 

685 ---------- 

686 scene 

687 The scene to handle. 

688 cost_threshold 

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

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

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

692 

693 Returns 

694 --------- 

695 dedupe 

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

697 """ 

698 return reconstruct_instances(self, cost_threshold=cost_threshold) 

699 

700 def set_camera( 

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

702 ) -> cameras.Camera: 

703 """ 

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

705 a transform to self.graph for it. 

706 

707 If arguments are not passed sane defaults will be figured 

708 out which show the mesh roughly centered. 

709 

710 Parameters 

711 ----------- 

712 angles : (3,) float 

713 Initial euler angles in radians 

714 distance : float 

715 Distance from centroid 

716 center : (3,) float 

717 Point camera should be center on 

718 camera : Camera object 

719 Object that stores camera parameters 

720 """ 

721 

722 if fov is None: 

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

724 

725 # if no geometry nothing to set camera to 

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

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

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

729 return self._camera 

730 # set with no rotation by default 

731 if angles is None: 

732 angles = np.zeros(3) 

733 

734 rotation = transformations.euler_matrix(*angles) 

735 transform = cameras.look_at( 

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

737 ) 

738 

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

740 self._camera.fov = fov 

741 if resolution is not None: 

742 self._camera.resolution = resolution 

743 else: 

744 # create a new camera object 

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

746 

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

748 

749 return self._camera 

750 

751 @property 

752 def camera_transform(self): 

753 """ 

754 Get camera transform in the base frame. 

755 

756 Returns 

757 ------- 

758 camera_transform : (4, 4) float 

759 Camera transform in the base frame 

760 """ 

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

762 

763 @camera_transform.setter 

764 def camera_transform(self, matrix: ArrayLike): 

765 """ 

766 Set the camera transform in the base frame 

767 

768 Parameters 

769 ---------- 

770 camera_transform : (4, 4) float 

771 Camera transform in the base frame 

772 """ 

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

774 

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

776 """ 

777 Calculate the trimesh.scene.Camera origin and ray 

778 direction vectors. Returns one ray per pixel as set 

779 in camera.resolution 

780 

781 Returns 

782 -------------- 

783 origin: (n, 3) float 

784 Ray origins in space 

785 vectors: (n, 3) float 

786 Ray direction unit vectors in world coordinates 

787 pixels : (n, 2) int 

788 Which pixel does each ray correspond to in an image 

789 """ 

790 # get the unit vectors of the camera 

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

792 # find our scene's transform for the camera 

793 transform = self.camera_transform 

794 # apply the rotation to the unit ray direction vectors 

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

796 # camera origin is single point so extract from 

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

798 transform 

799 ) 

800 return origins, vectors, pixels 

801 

802 @property 

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

804 """ 

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

806 set one will abe automatically generated. 

807 

808 Returns 

809 ---------- 

810 camera : trimesh.scene.Camera 

811 Camera object defined for the scene 

812 """ 

813 # no camera set for the scene yet 

814 if not self.has_camera: 

815 # will create a camera with everything in view 

816 return self.set_camera() 

817 assert self._camera is not None 

818 

819 return self._camera 

820 

821 @camera.setter 

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

823 """ 

824 Set a camera object for the Scene. 

825 

826 Parameters 

827 ----------- 

828 camera : trimesh.scene.Camera 

829 Camera object for the scene 

830 """ 

831 if camera is None: 

832 return 

833 self._camera = camera 

834 

835 @property 

836 def has_camera(self) -> bool: 

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

838 

839 @property 

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

841 """ 

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

843 set it will generate some automatically. 

844 

845 Returns 

846 ------------- 

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

848 Lights in the scene. 

849 """ 

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

851 # do some automatic lighting 

852 lights, transforms = lighting.autolight(self) 

853 # assign the transforms to the scene graph 

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

855 self.graph[L.name] = T 

856 # set the lights 

857 self._lights = lights 

858 return self._lights 

859 

860 @lights.setter 

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

862 """ 

863 Assign a list of light objects to the scene 

864 

865 Parameters 

866 -------------- 

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

868 Lights in the scene. 

869 """ 

870 self._lights = lights 

871 

872 def rezero(self) -> None: 

873 """ 

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

875 scene is centered at the origin. 

876 

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

878 base frame. 

879 """ 

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

881 # early exit since what we want already exists 

882 return 

883 

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

885 matrix = np.eye(4) 

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

887 

888 # we are going to change the base frame 

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

890 self.graph.update( 

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

892 ) 

893 self.graph.base_frame = new_base 

894 

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

896 """ 

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

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

899 

900 Parameters 

901 ------------ 

902 concatenate 

903 KWARG IS DEPRECATED FOR REMOVAL APRIL 2025 

904 Concatenate results into single geometry. 

905 This keyword argument will make the type hint incorrect and 

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

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

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

909 

910 Returns 

911 ---------- 

912 dumped 

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

914 """ 

915 

916 result = [] 

917 for node_name in self.graph.nodes_geometry: 

918 transform, geometry_name = self.graph[node_name] 

919 # get a copy of the geometry 

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

921 

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

923 if hasattr(current, "to_3D"): 

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

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

926 check[:2, :3] = True 

927 if not check.all(): 

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

929 current = current.to_3D() 

930 else: 

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

932 transform = transform[:3, :3] 

933 

934 # move the geometry vertices into the requested frame 

935 current.apply_transform(transform) 

936 current.metadata["name"] = geometry_name 

937 current.metadata["node"] = node_name 

938 

939 # save to our list of meshes 

940 result.append(current) 

941 

942 if concatenate: 

943 warnings.warn( 

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

945 category=DeprecationWarning, 

946 stacklevel=2, 

947 ) 

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

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

950 

951 return result 

952 

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

954 """ 

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

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

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

958 

959 Returns 

960 ---------- 

961 mesh 

962 All meshes in the scene concatenated into one. 

963 """ 

964 from ..base import Trimesh 

965 

966 # concatenate only meshes 

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

968 

969 def to_geometry(self) -> Geometry: 

970 """ 

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

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

973 if the scene has mixed geometry. 

974 

975 Returns 

976 --------- 

977 concat 

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

979 """ 

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

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

982 

983 def subscene(self, node: Hashable) -> "Scene": 

984 """ 

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

986 

987 Parameters 

988 ------------ 

989 node 

990 Hashable key in `scene.graph` 

991 

992 Returns 

993 ----------- 

994 subscene 

995 Partial scene generated from current. 

996 """ 

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

998 # this includes `node` 

999 graph = self.graph 

1000 nodes = graph.transforms.successors(node) 

1001 # get every edge that has an included node 

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

1003 

1004 # create a scene graph when 

1005 graph = SceneGraph(base_frame=node) 

1006 graph.from_edgelist(edges) 

1007 

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

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

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

1011 return result 

1012 

1013 @caching.cache_decorator 

1014 def convex_hull(self): 

1015 """ 

1016 The convex hull of the whole scene. 

1017 

1018 Returns 

1019 --------- 

1020 hull : trimesh.Trimesh 

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

1022 """ 

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

1024 return convex.convex_hull(points) 

1025 

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

1027 """ 

1028 Export a snapshot of the current scene. 

1029 

1030 Parameters 

1031 ---------- 

1032 file_obj : str, file-like, or None 

1033 File object to export to 

1034 file_type : str or None 

1035 What encoding to use for meshes 

1036 IE: dict, dict64, stl 

1037 

1038 Returns 

1039 ---------- 

1040 export : bytes 

1041 Only returned if file_obj is None 

1042 """ 

1043 return export.export_scene( 

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

1045 ) 

1046 

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

1048 """ 

1049 Get a PNG image of a scene. 

1050 

1051 Parameters 

1052 ----------- 

1053 resolution : (2,) int 

1054 Resolution to render image 

1055 **kwargs 

1056 Passed to SceneViewer constructor 

1057 

1058 Returns 

1059 ----------- 

1060 png : bytes 

1061 Render of scene as a PNG 

1062 """ 

1063 from ..viewer.windowed import render_scene 

1064 

1065 return render_scene( 

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

1067 ) 

1068 

1069 @property 

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

1071 """ 

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

1073 mixed units or no units this will return None. 

1074 

1075 Returns 

1076 ----------- 

1077 units 

1078 Units for every model in the scene or None 

1079 if there are no units or mixed units 

1080 """ 

1081 # get a set of the units of every geometry 

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

1083 if len(existing) == 1: 

1084 return existing.pop() 

1085 elif len(existing) > 1: 

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

1087 return None 

1088 

1089 @units.setter 

1090 def units(self, value: str): 

1091 """ 

1092 Set the units for every model in the scene without 

1093 converting any units just setting the tag. 

1094 

1095 Parameters 

1096 ------------ 

1097 value : str 

1098 Value to set every geometry unit value to 

1099 """ 

1100 value = value.strip().lower() 

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

1102 m.units = value 

1103 

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

1105 """ 

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

1107 

1108 Returns a new scene with geometries and transforms scaled. 

1109 

1110 Parameters 

1111 ---------- 

1112 desired : str 

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

1114 guess : bool 

1115 Is the converter allowed to guess scale when models 

1116 don't have it specified in their metadata. 

1117 

1118 Returns 

1119 ---------- 

1120 scaled : trimesh.Scene 

1121 Copy of scene with scaling applied and units set 

1122 for every model 

1123 """ 

1124 # if there is no geometry do nothing 

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

1126 return self.copy() 

1127 

1128 current = self.units 

1129 if current is None: 

1130 # will raise ValueError if not in metadata 

1131 # and not allowed to guess 

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

1133 

1134 # find the float conversion 

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

1136 

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

1138 result = self.scaled(scale=scale) 

1139 

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

1141 result.units = desired 

1142 

1143 return result 

1144 

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

1146 """ 

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

1148 

1149 Parameters 

1150 ----------- 

1151 vector : (3,) float or float 

1152 Explode radially around a direction vector or spherically 

1153 origin : (3,) float 

1154 Point to explode around 

1155 """ 

1156 if origin is None: 

1157 origin = self.centroid 

1158 if vector is None: 

1159 vector = self.scale / 25.0 

1160 

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

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

1163 

1164 for node_name in self.graph.nodes_geometry: 

1165 transform, geometry_name = self.graph[node_name] 

1166 centroid = self.geometry[geometry_name].centroid 

1167 # transform centroid into nodes location 

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

1169 

1170 if vector.shape == (): 

1171 # case where our vector is a single number 

1172 offset = (centroid - origin) * vector 

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

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

1175 offset = vector * projected 

1176 else: 

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

1178 

1179 # original transform is read-only 

1180 T_new = transform.copy() 

1181 T_new[:3, 3] += offset 

1182 self.graph[node_name] = T_new 

1183 

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

1185 """ 

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

1187 transforms scaled to the requested factor. 

1188 

1189 Parameters 

1190 ----------- 

1191 scale : float or (3,) float 

1192 Factor to scale meshes and transforms 

1193 

1194 Returns 

1195 ----------- 

1196 scaled : trimesh.Scene 

1197 A copy of the current scene but scaled 

1198 """ 

1199 result = self.copy() 

1200 

1201 # a scale of 1.0 is a no-op 

1202 if np.allclose(scale, 1.0): 

1203 return result 

1204 

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

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

1207 

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

1209 # scale is uniform 

1210 scale = float(scale[0]) 

1211 scale_is_3D = False 

1212 elif not scale_is_3D: 

1213 scale = float(scale) 

1214 

1215 # result is a copy 

1216 

1217 if scale_is_3D: 

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

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

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

1221 # affect the same geometry in different poses differently. 

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

1223 for geom_name in result.graph.geometry_nodes: 

1224 nodes_with_geom = result.graph.geometry_nodes[geom_name] 

1225 if len(nodes_with_geom) > 1: 

1226 geom = result.geometry[geom_name] 

1227 for n in nodes_with_geom: 

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

1229 result.add_geometry( 

1230 geometry=geom.copy(), 

1231 geom_name=geom_name, 

1232 node_name=n, 

1233 parent_node_name=p, 

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

1235 "matrix", None 

1236 ), 

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

1238 "metadata", None 

1239 ), 

1240 ) 

1241 result.delete_geometry(geom_name) 

1242 

1243 # Convert all 2D paths to 3D paths 

1244 for geom_name in result.geometry: 

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

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

1247 

1248 for key in result.graph.nodes_geometry: 

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

1250 # transform from graph should be read-only 

1251 T = T.copy() 

1252 T[:3, 3] = 0.0 

1253 

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

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

1256 scale 

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

1258 

1259 # Scale all transformations in the scene graph 

1260 edge_data = result.graph.transforms.edge_data 

1261 for uv in edge_data: 

1262 if "matrix" in edge_data[uv]: 

1263 props = edge_data[uv] 

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

1265 T[:3, 3] *= scale 

1266 props["matrix"] = T 

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

1268 # Clear cache 

1269 result.graph.transforms._cache = {} 

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

1271 result.graph._cache.clear() 

1272 else: 

1273 # matrix for 2D scaling 

1274 scale_2D = np.eye(3) * scale 

1275 # matrix for 3D scaling 

1276 scale_3D = np.eye(4) * scale 

1277 

1278 # preallocate transforms and geometries 

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

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

1281 geometries = [None] * len(nodes) 

1282 

1283 # collect list of transforms 

1284 for i, node in enumerate(nodes): 

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

1286 

1287 # remove all existing transforms 

1288 result.graph.clear() 

1289 

1290 for group in grouping.group(geometries): 

1291 # hashable reference to self.geometry 

1292 geometry = geometries[group[0]] 

1293 # original transform from world to geometry 

1294 original = transforms[group[0]] 

1295 # transform for geometry 

1296 new_geom = np.dot(scale_3D, original) 

1297 

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

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

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

1301 else: 

1302 # otherwise apply the full transform 

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

1304 

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

1306 # generate the new transforms 

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

1308 # apply scale to translation 

1309 transform[:3, 3] *= scale 

1310 # update scene with new transforms 

1311 result.graph.update( 

1312 frame_to=node, matrix=transform, geometry=geometry 

1313 ) 

1314 

1315 # remove camera from copied 

1316 result._camera = None 

1317 

1318 return result 

1319 

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

1321 """ 

1322 Return a deep copy of the current scene 

1323 

1324 Returns 

1325 ---------- 

1326 copied : trimesh.Scene 

1327 Copy of the current scene 

1328 """ 

1329 # use the geometries copy method to 

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

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

1332 

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

1334 # if no camera set don't include it 

1335 camera = None 

1336 else: 

1337 # otherwise get a copy of the camera 

1338 camera = self.camera.copy() 

1339 # create a new scene with copied geometry and graph 

1340 copied = Scene( 

1341 geometry=geometry, 

1342 graph=self.graph.copy(), 

1343 metadata=self.metadata.copy(), 

1344 camera=camera, 

1345 ) 

1346 return copied 

1347 

1348 def show( 

1349 self, 

1350 viewer: ViewerType = None, 

1351 **kwargs, 

1352 ): 

1353 """ 

1354 Display the current scene. 

1355 

1356 Parameters 

1357 ----------- 

1358 viewer 

1359 What kind of viewer to use, such as 

1360 `gl` to open a pyglet window 

1361 `jupyter` for a jupyter notebook 

1362 `marimo'` for a marimo notebook 

1363 None for a "best guess" 

1364 kwargs 

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

1366 off automatic smooth shading 

1367 """ 

1368 

1369 if viewer is None: 

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

1371 from ..viewer import in_notebook 

1372 

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

1374 viewer = in_notebook() 

1375 if not viewer: 

1376 viewer = "gl" 

1377 

1378 if viewer == "gl": 

1379 # this imports pyglet, and will raise an ImportError 

1380 # if pyglet is not available 

1381 from ..viewer import SceneViewer 

1382 

1383 return SceneViewer(self, **kwargs) 

1384 elif viewer == "jupyter": 

1385 from ..viewer import scene_to_notebook 

1386 

1387 return scene_to_notebook(self, **kwargs) 

1388 elif viewer == "marimo": 

1389 from ..viewer import scene_to_mo_notebook 

1390 

1391 return scene_to_mo_notebook(self, **kwargs) 

1392 elif callable(viewer): 

1393 # if a callable method like a custom class 

1394 # constructor was passed run using that 

1395 return viewer(self, **kwargs) 

1396 else: 

1397 raise ValueError( 

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

1399 ) 

1400 

1401 def __add__(self, other): 

1402 """ 

1403 Concatenate the current scene with another scene or mesh. 

1404 

1405 Parameters 

1406 ------------ 

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

1408 Other object to append into the result scene 

1409 

1410 Returns 

1411 ------------ 

1412 appended : trimesh.Scene 

1413 Scene with geometry from both scenes 

1414 """ 

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

1416 return result 

1417 

1418 

1419def split_scene(geometry, **kwargs): 

1420 """ 

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

1422 return them as a single Scene object. 

1423 

1424 Parameters 

1425 ---------- 

1426 geometry : splittable 

1427 

1428 Returns 

1429 --------- 

1430 scene: trimesh.Scene 

1431 """ 

1432 # already a scene, so return it 

1433 if isinstance(geometry, Scene): 

1434 return geometry 

1435 

1436 # save metadata 

1437 metadata = {} 

1438 

1439 # a list of things 

1440 if util.is_sequence(geometry): 

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

1442 

1443 scene = Scene(geometry, metadata=metadata) 

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

1445 else: 

1446 # a single geometry so we are going to split 

1447 scene = Scene( 

1448 geometry.split(**kwargs), 

1449 metadata=deepcopy(geometry.metadata), 

1450 ) 

1451 scene._source = deepcopy(geometry.source) 

1452 

1453 return scene 

1454 

1455 

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

1457 """ 

1458 Concatenate multiple scene objects into one scene. 

1459 

1460 Parameters 

1461 ------------- 

1462 iterable : (n,) Trimesh or Scene 

1463 Geometries that should be appended 

1464 common : (n,) str 

1465 Nodes that shouldn't be remapped 

1466 base_frame : str 

1467 Base frame of the resulting scene 

1468 

1469 Returns 

1470 ------------ 

1471 result : trimesh.Scene 

1472 Scene containing all geometry 

1473 """ 

1474 if isinstance(iterable, Scene): 

1475 return iterable 

1476 

1477 if common is None: 

1478 common = [base_frame] 

1479 

1480 # save geometry in dict 

1481 geometry = {} 

1482 # save transforms as edge tuples 

1483 edges = [] 

1484 

1485 # nodes which shouldn't be remapped 

1486 common = set(common) 

1487 # nodes which are consumed and need to be remapped 

1488 consumed = set() 

1489 

1490 def node_remap(node): 

1491 """ 

1492 Remap node to new name if necessary 

1493 

1494 Parameters 

1495 ------------- 

1496 node : hashable 

1497 Node name in original scene 

1498 

1499 Returns 

1500 ------------- 

1501 name : hashable 

1502 Node name in concatenated scene 

1503 """ 

1504 

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

1506 if node in map_node: 

1507 return map_node[node] 

1508 

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

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

1511 if node not in common and node in consumed: 

1512 # generate a name not in consumed 

1513 name = node + util.unique_id() 

1514 map_node[node] = name 

1515 node = name 

1516 

1517 # keep track of which nodes have been used 

1518 # in the current scene 

1519 current.add(node) 

1520 return node 

1521 

1522 # loop through every geometry 

1523 for s in iterable: 

1524 # allow Trimesh/Path2D geometry to be passed 

1525 if hasattr(s, "scene"): 

1526 s = s.scene() 

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

1528 if not isinstance(s, Scene): 

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

1530 

1531 # remap geometries if they have been consumed 

1532 map_geom = {} 

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

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

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

1536 # store name mapping 

1537 map_geom[k] = name 

1538 # store geometry with new name 

1539 geometry[name] = v 

1540 

1541 # remap nodes and edges so duplicates won't 

1542 # stomp all over each other 

1543 map_node = {} 

1544 # the nodes used in this scene 

1545 current = set() 

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

1547 # remap node names from local names 

1548 a, b = node_remap(a), node_remap(b) 

1549 # remap geometry keys 

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

1551 # referred to geometry that doesn't exist 

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

1553 # possibly intended to add in geometries back later 

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

1555 attr["geometry"] = map_geom[attr["geometry"]] 

1556 # save the new edge 

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

1558 # mark nodes from current scene as consumed 

1559 consumed.update(current) 

1560 

1561 # add all data to a new scene 

1562 result = Scene(base_frame=base_frame) 

1563 result.graph.from_edgelist(edges) 

1564 result.geometry.update(geometry) 

1565 

1566 return result 

1567 

1568 

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

1570 """ 

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

1572 the duplicate nodes have *corresponding vertices* but are 

1573 rigidly transformed to different places. 

1574 

1575 This means the problem of finding ab instance transform can 

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

1577 to more complicated registration problems that require ICP 

1578 and nearest-point-on-surface calculations. 

1579 

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

1581 

1582 Parameters 

1583 ---------- 

1584 scene 

1585 The scene to handle. 

1586 cost_threshold 

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

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

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

1590 

1591 Returns 

1592 --------- 

1593 dedupe 

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

1595 """ 

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

1597 graph = scene.graph.copy() 

1598 

1599 for group in scene.duplicate_nodes: 

1600 # not sure if this ever includes 

1601 if len(group) < 2: 

1602 continue 

1603 

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

1605 node_base = group[0] 

1606 # get the geometry name for this base node 

1607 _, geom_base = scene.graph[node_base] 

1608 # get the vertices of the base model 

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

1610 

1611 for node in group[1:]: 

1612 # the original pose of this node in the scene 

1613 node_mat, node_geom = scene.graph[node] 

1614 # procrustes matches corresponding point arrays very quickly 

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

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

1617 

1618 # procrustes only works on corresponding point clouds! 

1619 if node_vertices.shape != base.shape: 

1620 continue 

1621 

1622 # solve for a pose moving this instance into position 

1623 matrix, _p, cost = procrustes( 

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

1625 ) 

1626 if cost < cost_threshold: 

1627 # add the transform we found 

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

1629 

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

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

1632 

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

1634 return Scene( 

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

1636 graph=graph, 

1637 )