Coverage for trimesh/scene/transforms.py: 93%

313 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-31 23:55 +0000

1import collections 

2import itertools 

3from copy import deepcopy 

4 

5import numpy as np 

6 

7from .. import caching, util 

8from ..caching import hash_fast 

9from ..transformations import fix_rigid, quaternion_matrix, rotation_matrix 

10from ..typed import ArrayLike, Floating, Hashable, NDArray, Sequence 

11 

12# we compare to identity a lot 

13_identity = np.eye(4) 

14_identity.flags["WRITEABLE"] = False 

15 

16# default name for the root frame of a scene graph 

17DEFAULT_BASE_FRAME = "world" 

18 

19 

20class SceneGraph: 

21 """ 

22 Hold data about positions and instances of geometry 

23 in a scene. This includes a forest (i.e. multi-root tree) 

24 of transforms and information on which node is the base 

25 frame, and which geometries are affiliated with which 

26 nodes. 

27 """ 

28 

29 def __init__( 

30 self, base_frame: Hashable | None = None, repair_rigid: Floating | None = 1e-5 

31 ): 

32 """ 

33 Create a scene graph, holding homogeneous transformation 

34 matrices and instance information about geometry. 

35 

36 Parameters 

37 ----------- 

38 base_frame 

39 The root node transforms will be positioned from. 

40 repair_rigid 

41 If a float will attempt to repair rotation matrices 

42 where `M @ M.T` differs from an identity matrix by 

43 more than floating point zero but less than this value. 

44 This can happen in a deep tree with a lot of matrix 

45 multiplies. 

46 """ 

47 

48 # a graph structure, subclass of networkx DiGraph 

49 self.transforms = EnforcedForest() 

50 # hashable, the base or root frame — only replace None 

51 # as falsy frames like `0` or `""` are valid node names 

52 self.base_frame = base_frame if base_frame is not None else DEFAULT_BASE_FRAME 

53 # if passed as a float try to repair rigid transforms 

54 # that have accumulated floating point error 

55 self.repair_rigid = repair_rigid 

56 # cache transformation matrices keyed with tuples 

57 self._cache = caching.Cache(self.__hash__) 

58 

59 def update(self, frame_to, frame_from=None, **kwargs): 

60 """ 

61 Update a transform in the tree. 

62 

63 Parameters 

64 ------------ 

65 frame_from : hashable object 

66 Usually a string (eg 'world'). 

67 If left as None it will be set to self.base_frame 

68 frame_to : hashable object 

69 Usually a string (eg 'mesh_0') 

70 matrix : (4,4) float 

71 Homogeneous transformation matrix 

72 quaternion : (4,) float 

73 Quaternion ordered [w, x, y, z] 

74 axis : (3,) float 

75 Axis of rotation 

76 angle : float 

77 Angle of rotation, in radians 

78 translation : (3,) float 

79 Distance to translate 

80 geometry : hashable 

81 Geometry object name, e.g. 'mesh_0' 

82 metadata: dictionary 

83 Optional metadata attached to the new frame 

84 (exports to glTF node 'extras'). 

85 """ 

86 # if no frame specified, use base frame 

87 if frame_from is None: 

88 frame_from = self.base_frame 

89 

90 # pass through 

91 attr = {k: v for k, v in kwargs.items() if k in {"geometry", "metadata"}} 

92 # convert various kwargs to a single matrix 

93 attr["matrix"] = kwargs_to_matrix(**kwargs) 

94 

95 # add the edges for the transforms 

96 # wi ll return if it changed anything 

97 self.transforms.add_edge(frame_from, frame_to, **attr) 

98 

99 # set the node attribute with the geometry information 

100 if "geometry" in kwargs: 

101 self.transforms.node_data[frame_to]["geometry"] = kwargs["geometry"] 

102 

103 def get( 

104 self, frame_to: Hashable, frame_from: Hashable | None = None 

105 ) -> tuple[NDArray[np.float64], Hashable | None]: 

106 """ 

107 Get the transform from one frame to another. 

108 

109 Parameters 

110 ------------ 

111 frame_to : hashable 

112 Node name, usually a string (eg 'mesh_0') 

113 frame_from : hashable 

114 Node name, usually a string (eg 'world'). 

115 If None it will be set to self.base_frame 

116 

117 Returns 

118 ---------- 

119 transform : (4, 4) float 

120 Homogeneous transformation matrix 

121 geometry 

122 The name of the geometry if it exists 

123 

124 Raises 

125 ----------- 

126 ValueError 

127 If the frames aren't connected. 

128 """ 

129 

130 # use base frame if not specified 

131 if frame_from is None: 

132 frame_from = self.base_frame 

133 

134 # look up transform to see if we have it already 

135 key = (frame_from, frame_to) 

136 if key in self._cache: 

137 return self._cache[key] 

138 

139 # get the geometry at the final node if any 

140 geometry = self.transforms.node_data[frame_to].get("geometry") 

141 

142 # get a local reference to edge data 

143 data = self.transforms.edge_data 

144 

145 if frame_from == frame_to: 

146 # if we're going from ourself return identity 

147 matrix = _identity 

148 elif key in data: 

149 # if the path is just an edge return early 

150 matrix = data[key]["matrix"] 

151 else: 

152 # we have a 3+ node path 

153 # get the path from the forest always going from 

154 # parent -> child -> child 

155 path = self.transforms.shortest_path(frame_from, frame_to) 

156 # the path should always start with `frame_from` 

157 assert path[0] == frame_from 

158 # and end with the `frame_to` node 

159 assert path[-1] == frame_to 

160 

161 # loop through pairs of the path 

162 matrices = [] 

163 for u, v in itertools.pairwise(path): 

164 forward = data.get((u, v)) 

165 if forward is not None: 

166 if "matrix" in forward: 

167 # append the matrix from u to v 

168 matrices.append(forward["matrix"]) 

169 continue 

170 # since forwards didn't exist backward must 

171 # exist otherwise this is a disconnected path 

172 # and we should raise an error anyway 

173 backward = data[(v, u)] 

174 if "matrix" in backward: 

175 # append the inverted backwards matrix 

176 matrices.append(np.linalg.inv(backward["matrix"])) 

177 # filter out any identity matrices 

178 matrices = [m for m in matrices if np.abs(m - _identity).max() > 1e-8] 

179 if len(matrices) == 0: 

180 matrix = _identity 

181 elif len(matrices) == 1: 

182 matrix = matrices[0] 

183 else: 

184 # multiply matrices into single transform 

185 matrix = util.multi_dot(matrices) 

186 

187 # if instructed to repair rigid transforms do it here 

188 if self.repair_rigid is not None: 

189 matrix = fix_rigid(matrix, max_deviance=self.repair_rigid) 

190 

191 # matrix being edited in-place leads to subtle bugs 

192 matrix.flags["WRITEABLE"] = False 

193 

194 # store the result 

195 self._cache[key] = (matrix, geometry) 

196 

197 return matrix, geometry 

198 

199 def __hash__(self): 

200 return self.transforms.__hash__() 

201 

202 def copy(self): 

203 """ 

204 Return a copy of the current TransformForest. 

205 

206 Returns 

207 ------------ 

208 copied : TransformForest 

209 Copy of current object. 

210 """ 

211 # create a copy without transferring cache 

212 copied = SceneGraph() 

213 copied.base_frame = deepcopy(self.base_frame) 

214 copied.transforms = deepcopy(self.transforms) 

215 return copied 

216 

217 def to_flattened(self): 

218 """ 

219 Export the current transform graph with all 

220 transforms baked into world->instance. 

221 

222 Returns 

223 --------- 

224 flat : dict 

225 Keyed {node : {transform, geometry} 

226 """ 

227 flat = {} 

228 base_frame = self.base_frame 

229 for node in self.nodes: 

230 if node == base_frame: 

231 continue 

232 # get the matrix and geometry name 

233 matrix, geometry = self.get(frame_to=node, frame_from=base_frame) 

234 # store matrix as list rather than numpy array 

235 flat[node] = {"transform": matrix.tolist(), "geometry": geometry} 

236 

237 return flat 

238 

239 def to_gltf(self, scene, mesh_index=None): 

240 """ 

241 Export a transforms as the 'nodes' section of the 

242 GLTF header dict. 

243 

244 Parameters 

245 ------------ 

246 scene : trimesh.Scene 

247 Scene with geometry. 

248 mesh_index : dict or None 

249 Mapping { key in scene.geometry : int } 

250 

251 Returns 

252 -------- 

253 gltf : dict 

254 With 'nodes' referencing a list of dicts and 'scene_roots' 

255 referencing the node indices the scene should start from. 

256 """ 

257 

258 if mesh_index is None: 

259 # geometry is an OrderedDict 

260 # map mesh name to index: {geometry key : index} 

261 mesh_index = {name: i for i, name in enumerate(scene.geometry.keys())} 

262 

263 # get graph information into local scope before loop 

264 graph = self.transforms 

265 # get the stored node data 

266 node_data = graph.node_data 

267 edge_data = graph.edge_data 

268 base_frame = self.base_frame 

269 # does the scene have a defined camera to export 

270 has_camera = scene.has_camera 

271 children = graph.children 

272 

273 # the base frame is a synthetic wrapper: when it carries nothing 

274 # export its children as the scene roots instead of writing it — 

275 # a file node named like the base frame is renamed on import so 

276 # writing one would grow a wrapper node on every round-trip 

277 skip_base = ( 

278 "geometry" not in node_data.get(base_frame, {}) 

279 and not (has_camera and base_frame == scene.camera.name) 

280 and len(children.get(base_frame, [])) > 0 

281 ) 

282 

283 # list of dict, in gltf format 

284 # {node name : node index in gltf} 

285 if skip_base: 

286 result = [] 

287 lookup = {} 

288 else: 

289 result = [{"name": base_frame}] 

290 lookup = {base_frame: 0} 

291 

292 # collect the nodes in order 

293 for node in node_data.keys(): 

294 if node == base_frame: 

295 continue 

296 # assign the index to the node-name lookup 

297 lookup[node] = len(result) 

298 # populate a result at the correct index 

299 result.append({"name": node}) 

300 

301 extensions_used = set() 

302 

303 # then iterate through to collect data 

304 for info in result: 

305 # name of the scene node 

306 node = info["name"] 

307 

308 # get the original node names for children 

309 childs = children.get(node, []) 

310 if len(childs) > 0: 

311 info["children"] = [lookup[k] for k in childs] 

312 

313 # if we have a mesh store by index 

314 if "geometry" in node_data[node]: 

315 mesh_key = node_data[node]["geometry"] 

316 if mesh_key in mesh_index: 

317 info["mesh"] = mesh_index[mesh_key] 

318 # check to see if we have camera node 

319 if has_camera and node == scene.camera.name: 

320 info["camera"] = 0 

321 

322 if node != base_frame: 

323 parent = graph.parents[node] 

324 node_edge = edge_data[(parent, node)] 

325 

326 # get the matrix from this edge 

327 matrix = node_edge["matrix"] 

328 # only include if it's not an identify matrix 

329 if not util.allclose(matrix, _identity): 

330 info["matrix"] = matrix.T.reshape(-1).tolist() 

331 

332 # if an extra was stored on this edge 

333 extras = node_edge.get("metadata") 

334 if extras: 

335 extras = extras.copy() 

336 

337 # if extensionss were stored on this edge 

338 extensions = extras.pop("gltf_extensions", None) 

339 if isinstance(extensions, dict): 

340 info["extensions"] = extensions 

341 extensions_used = extensions_used.union(set(extensions.keys())) 

342 

343 # convert any numpy arrays to lists 

344 extras.update( 

345 {k: v.tolist() for k, v in extras.items() if hasattr(v, "tolist")} 

346 ) 

347 info["extras"] = extras 

348 

349 if skip_base: 

350 roots = [lookup[c] for c in children[base_frame]] 

351 else: 

352 roots = [lookup[base_frame]] 

353 

354 gltf = {"nodes": result, "scene_roots": roots} 

355 if len(extensions_used) > 0: 

356 gltf["extensionsUsed"] = list(extensions_used) 

357 return gltf 

358 

359 def to_edgelist(self): 

360 """ 

361 Export the current transforms as a list of 

362 edge tuples, with each tuple having the format: 

363 (node_a, node_b, {metadata}) 

364 

365 Returns 

366 --------- 

367 edgelist : (n,) list 

368 Of edge tuples 

369 """ 

370 # save local reference to node_data 

371 nodes = self.transforms.node_data 

372 # save cleaned edges 

373 export = [] 

374 # loop through (node, node, edge attributes) 

375 for edge, attr in self.transforms.edge_data.items(): 

376 # node indexes from edge 

377 a, b = edge 

378 # geometry is a node property but save it to the 

379 # edge so we don't need two dictionaries 

380 b_attr = nodes[b] 

381 # make sure we're not stomping on original 

382 attr_new = attr.copy() 

383 # apply node geometry to edge attributes 

384 if "geometry" in b_attr: 

385 attr_new["geometry"] = b_attr["geometry"] 

386 # convert any numpy arrays to regular lists 

387 attr_new.update( 

388 {k: v.tolist() for k, v in attr_new.items() if hasattr(v, "tolist")} 

389 ) 

390 export.append([a, b, attr_new]) 

391 return export 

392 

393 def from_edgelist(self, edges, strict=True): 

394 """ 

395 Load transform data from an edge list into the current 

396 scene graph. 

397 

398 Parameters 

399 ------------- 

400 edgelist : (n,) tuples 

401 Keyed (node_a, node_b, {key: value}) 

402 strict : bool 

403 If True raise a ValueError when a 

404 malformed edge is passed in a tuple. 

405 """ 

406 

407 # loop through each edge 

408 for edge in edges: 

409 # edge contains attributes 

410 if len(edge) == 3: 

411 self.update(edge[1], edge[0], **edge[2]) 

412 # edge just contains nodes 

413 elif len(edge) == 2: 

414 self.update(edge[1], edge[0]) 

415 # edge is broken 

416 elif strict: 

417 raise ValueError("edge incorrect shape: %s", str(edge)) 

418 

419 def to_networkx(self): 

420 """ 

421 Return a `networkx` copy of this graph. 

422 

423 Returns 

424 ---------- 

425 graph : networkx.DiGraph 

426 Directed graph. 

427 """ 

428 import networkx 

429 

430 return networkx.from_edgelist(self.to_edgelist(), create_using=networkx.DiGraph) 

431 

432 def show(self, **kwargs): 

433 """ 

434 Plot the scene graph using `networkx.draw_networkx` 

435 which uses matplotlib to display the graph. 

436 

437 Parameters 

438 ----------- 

439 kwargs : dict 

440 Passed to `networkx.draw_networkx` 

441 """ 

442 import matplotlib.pyplot as plt # noqa 

443 import networkx 

444 

445 # default kwargs will only be set if not 

446 # passed explicitly to the show command 

447 defaults = {"with_labels": True} 

448 kwargs.update(**{k: v for k, v in defaults.items() if k not in kwargs}) 

449 networkx.draw_networkx(G=self.to_networkx(), **kwargs) 

450 

451 plt.show() 

452 

453 def load(self, edgelist): 

454 """ 

455 Load transform data from an edge list into the current 

456 scene graph. 

457 

458 Parameters 

459 ------------- 

460 edgelist : (n,) tuples 

461 Structured (node_a, node_b, {key: value}) 

462 """ 

463 self.from_edgelist(edgelist, strict=True) 

464 

465 @caching.cache_decorator 

466 def nodes(self): 

467 """ 

468 A list of every node in the graph. 

469 

470 Returns 

471 ------------- 

472 nodes : (n,) array 

473 All node names. 

474 """ 

475 return self.transforms.nodes 

476 

477 @caching.cache_decorator 

478 def nodes_geometry(self): 

479 """ 

480 The nodes in the scene graph with geometry attached. 

481 

482 Returns 

483 ------------ 

484 nodes_geometry : (m,) array 

485 Node names which have geometry associated 

486 """ 

487 return [n for n, attr in self.transforms.node_data.items() if "geometry" in attr] 

488 

489 @caching.cache_decorator 

490 def geometry_nodes(self): 

491 """ 

492 Which nodes have this geometry? Inverse 

493 of `nodes_geometry`. 

494 

495 Returns 

496 ------------ 

497 geometry_nodes : dict 

498 Keyed {geometry_name : node name} 

499 """ 

500 res = collections.defaultdict(list) 

501 for node, attr in self.transforms.node_data.items(): 

502 if "geometry" in attr: 

503 res[attr["geometry"]].append(node) 

504 return res 

505 

506 def remove_geometries(self, geometries: str | set | Sequence): 

507 """ 

508 Remove the reference for specified geometries 

509 from nodes without deleting the node. 

510 

511 Parameters 

512 ------------ 

513 geometries : list or str 

514 Name of scene.geometry to dereference. 

515 """ 

516 # make sure we have a set of geometries to remove 

517 if isinstance(geometries, str): 

518 geometries = [geometries] 

519 geometries = set(geometries) 

520 

521 # remove the geometry reference from the node without deleting nodes 

522 # this lets us keep our cached paths, and will not screw up children 

523 for attrib in self.transforms.node_data.values(): 

524 if "geometry" in attrib and attrib["geometry"] in geometries: 

525 attrib.pop("geometry") 

526 

527 # it would be safer to just run _cache.clear 

528 # but the only property using the geometry should be 

529 # nodes_geometry: if this becomes not true change this to clear! 

530 self._cache.cache.pop("nodes_geometry", None) 

531 self.transforms._hash = None 

532 

533 def __contains__(self, key: Hashable) -> bool: 

534 return key in self.transforms.node_data 

535 

536 def __getitem__(self, key: Hashable) -> tuple[NDArray[np.float64], Hashable | None]: 

537 return self.get(key) 

538 

539 def __setitem__(self, key: Hashable, value: ArrayLike): 

540 value = np.asanyarray(value, dtype=np.float64) 

541 if value.shape != (4, 4): 

542 raise ValueError("Matrix must be specified!") 

543 return self.update(key, matrix=value) 

544 

545 def clear(self): 

546 self.transforms = EnforcedForest() 

547 self._cache.clear() 

548 

549 

550class EnforcedForest: 

551 """ 

552 A simple forest graph data structure: every node 

553 is allowed to have exactly one parent. This makes 

554 traversal and implementation much simpler than a 

555 full graph data type; by storing only one parent 

556 reference, it enforces the structure for "free." 

557 """ 

558 

559 def __init__(self): 

560 # since every node can have only one parent 

561 # this data structure transparently enforces 

562 # the forest data structure without checks 

563 # a dict {child : parent} 

564 self.parents = {} 

565 

566 # store data for a particular edge keyed by tuple 

567 # {(u, v) : data } 

568 self.edge_data = collections.defaultdict(dict) 

569 # {u: data} 

570 self.node_data = collections.defaultdict(dict) 

571 

572 # if multiple calls are made for the same path 

573 # but the connectivity hasn't changed return cached 

574 self._cache = {} 

575 

576 def add_edge(self, u, v, **kwargs): 

577 """ 

578 Add an edge to the forest cleanly. 

579 

580 Parameters 

581 ----------- 

582 u : any 

583 Hashable node key. 

584 v : any 

585 Hashable node key. 

586 kwargs : dict 

587 Stored as (u, v) edge data. 

588 

589 Returns 

590 -------- 

591 changed : bool 

592 Return if this operation changed anything. 

593 """ 

594 self._hash = None 

595 

596 # topology has changed so clear cache 

597 if (u, v) not in self.edge_data: 

598 self._cache = {} 

599 else: 

600 # check to see if matrix and geometry are identical 

601 edge = self.edge_data[(u, v)] 

602 if util.allclose( 

603 kwargs.get("matrix", _identity), edge.get("matrix", _identity), 1e-8 

604 ) and (edge.get("geometry") == kwargs.get("geometry")): 

605 return False 

606 

607 # store a parent reference for traversal 

608 self.parents[v] = u 

609 # store kwargs for edge data keyed with tuple 

610 self.edge_data[(u, v)] = kwargs 

611 # set empty node data 

612 self.node_data[u].update({}) 

613 if "geometry" in kwargs: 

614 self.node_data[v].update({"geometry": kwargs["geometry"]}) 

615 else: 

616 self.node_data[v].update({}) 

617 

618 return True 

619 

620 def remove_node(self, u): 

621 """ 

622 Remove a node from the forest. 

623 

624 Parameters 

625 ----------- 

626 u : any 

627 Hashable node key. 

628 

629 Returns 

630 -------- 

631 changed : bool 

632 Return if this operation changed anything. 

633 """ 

634 # check if node is part of forest 

635 if u not in self.node_data: 

636 return False 

637 

638 # topology will change so clear cache 

639 self._cache = {} 

640 self._hash = None 

641 

642 # delete all children's references and parent reference 

643 children = [child for (child, parent) in self.parents.items() if parent == u] 

644 for c in children: 

645 del self.parents[c] 

646 if u in self.parents: 

647 del self.parents[u] 

648 

649 # delete edge data 

650 edges = [(a, b) for (a, b) in self.edge_data if a == u or b == u] 

651 for e in edges: 

652 del self.edge_data[e] 

653 

654 # delete node data 

655 del self.node_data[u] 

656 

657 return True 

658 

659 def shortest_path(self, u, v): 

660 """ 

661 Find the shortest path between `u` and `v`, returning 

662 a path where the first element is always `u` and the 

663 last element is always `v`, disregarding edge direction. 

664 

665 Parameters 

666 ----------- 

667 u : any 

668 Hashable node key. 

669 v : any 

670 Hashable node key. 

671 

672 Returns 

673 ----------- 

674 path : (n,) 

675 Path between `u` and `v` 

676 """ 

677 # see if we've already computed this path 

678 if u == v: 

679 # the path between itself is an edge case 

680 return [] 

681 elif (u, v) in self._cache: 

682 # return the same path for either direction 

683 return self._cache[(u, v)] 

684 elif (v, u) in self._cache: 

685 return self._cache[(v, u)][::-1] 

686 

687 # local reference to parent dict for performance 

688 parents = self.parents 

689 # store both forward and backwards traversal 

690 forward = [u] 

691 backward = [v] 

692 

693 # cap iteration to number of total nodes 

694 for _ in range(len(parents) + 1): 

695 # store the parent both forwards and backwards 

696 f = parents.get(forward[-1]) 

697 b = parents.get(backward[-1]) 

698 forward.append(f) 

699 backward.append(b) 

700 

701 if f == v: 

702 self._cache[(u, v)] = forward 

703 return forward 

704 elif b == u: 

705 # return reversed path 

706 backward = backward[::-1] 

707 self._cache[(u, v)] = backward 

708 return backward 

709 elif (b in forward) or (f is None and b is None): 

710 # we have a either a common node between both 

711 # traversal directions or we have consumed the whole 

712 # tree in both directions so try to find the common node 

713 common = set(backward).intersection(forward).difference({None}) 

714 if len(common) == 0: 

715 raise ValueError(f"No path from {u}->{v}!") 

716 elif len(common) > 1: 

717 # get the first occurring common element in "forward" 

718 link = next(f for f in forward if f in common) 

719 assert link in common 

720 else: 

721 # take the only common element 

722 link = next(iter(common)) 

723 

724 # combine the forward and backwards traversals 

725 a = forward[: forward.index(link) + 1] 

726 b = backward[: backward.index(link)] 

727 path = a + b[::-1] 

728 

729 # verify we didn't screw up the order 

730 assert path[0] == u 

731 assert path[-1] == v 

732 

733 self._cache[(u, v)] = path 

734 

735 return path 

736 

737 raise ValueError("Iteration limit exceeded!") 

738 

739 @property 

740 def nodes(self): 

741 """ 

742 Get a set of every node. 

743 

744 Returns 

745 ----------- 

746 nodes : set 

747 Every node currently stored. 

748 """ 

749 return self.node_data.keys() 

750 

751 @property 

752 def children(self): 

753 """ 

754 Get the children of each node. 

755 

756 Returns 

757 ---------- 

758 children : dict 

759 Keyed {node : [child, child, ...]} 

760 """ 

761 if "children" in self._cache: 

762 return self._cache["children"] 

763 child = collections.defaultdict(list) 

764 # append children to parent references 

765 # skip self-references to avoid a node loop 

766 [child[v].append(u) for u, v in self.parents.items() if u != v] 

767 

768 # cache and return as a vanilla dict 

769 self._cache["children"] = dict(child) 

770 return self._cache["children"] 

771 

772 def successors(self, node): 

773 """ 

774 Get all nodes that are successors to specified node, 

775 including the specified node. 

776 

777 Parameters 

778 ------------- 

779 node : any 

780 Hashable key for a node. 

781 

782 Returns 

783 ------------ 

784 successors : set 

785 Nodes that succeed specified node. 

786 """ 

787 # get mapping of {parent : child} 

788 children = self.children 

789 # if node doesn't exist return early 

790 if node not in children: 

791 return {node} 

792 

793 # children we need to collect 

794 queue = [node] 

795 # start collecting values with children of source 

796 collected = set(queue) 

797 

798 # cap maximum iterations 

799 for _ in range(len(self.node_data) + 1): 

800 if len(queue) == 0: 

801 # no more nodes to visit so we're done 

802 return collected 

803 # add the children of this node to be processed 

804 childs = children.get(queue.pop()) 

805 if childs is not None: 

806 queue.extend(childs) 

807 collected.update(childs) 

808 return collected 

809 

810 def __hash__(self): 

811 """ 

812 Actually hash all of the data, but use a "dirty" mechanism 

813 in functions that modify the data, which MUST 

814 # all invalidate the hash by setting `self._hash = None` 

815 

816 This was optimized a bit, and is evaluating on an 

817 older laptop on a scene with 77 nodes and 76 edges 

818 10,000 times in 0.7s which seems fast enough. 

819 """ 

820 # see if there is an available hash value 

821 # if you are seeing cache bugs this is the thing 

822 # to try eliminating because it is very likely that 

823 # someone somewhere is modifying the data without 

824 # setting `self._hash = None` 

825 hashed = getattr(self, "_hash", None) 

826 if hashed is not None: 

827 return hashed 

828 

829 hashed = hash_fast( 

830 ( 

831 "".join( 

832 str(hash(k)) + v.get("geometry", "") 

833 for k, v in self.edge_data.items() 

834 ) 

835 + "".join( 

836 str(k) + v.get("geometry", "") for k, v in self.node_data.items() 

837 ) 

838 ).encode("utf-8") 

839 + b"".join( 

840 v["matrix"].tobytes() for v in self.edge_data.values() if "matrix" in v 

841 ) 

842 ) 

843 self._hash = hashed 

844 return hashed 

845 

846 

847def kwargs_to_matrix( 

848 matrix=None, quaternion=None, translation=None, axis=None, angle=None, **kwargs 

849): 

850 """ 

851 Take multiple keyword arguments and parse them 

852 into a homogeneous transformation matrix. 

853 

854 Returns 

855 --------- 

856 matrix : (4, 4) float 

857 Homogeneous transformation matrix. 

858 """ 

859 if matrix is not None: 

860 # a matrix takes immediate precedence over other options 

861 return np.array(matrix, dtype=np.float64) 

862 elif quaternion is not None: 

863 matrix = quaternion_matrix(quaternion) 

864 elif axis is not None and angle is not None: 

865 matrix = rotation_matrix(angle, axis) 

866 else: 

867 matrix = np.eye(4) 

868 

869 if translation is not None: 

870 # translation can be used in conjunction with any 

871 # of the methods specifying transforms 

872 matrix[:3, 3] += translation 

873 

874 return matrix