Coverage for trimesh/path/traversal.py: 84%

164 statements  

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

1import copy 

2import itertools 

3 

4import numpy as np 

5 

6from .. import constants, grouping, util 

7from ..typed import ArrayLike, Integer, NDArray, Number 

8from .util import is_ccw 

9 

10try: 

11 import networkx as nx 

12except BaseException as E: 

13 # create a dummy module which will raise the ImportError 

14 # or other exception only when someone tries to use networkx 

15 from ..exceptions import ExceptionWrapper 

16 

17 nx = ExceptionWrapper(E) 

18 

19 

20def vertex_graph(entities): 

21 """ 

22 Given a set of entity objects generate a networkx.Graph 

23 that represents their vertex nodes. 

24 

25 Parameters 

26 -------------- 

27 entities : list 

28 Objects with 'closed' and 'nodes' attributes 

29 

30 Returns 

31 ------------- 

32 graph : networkx.Graph 

33 Graph where node indexes represent vertices 

34 closed : (n,) int 

35 Indexes of entities which are 'closed' 

36 """ 

37 graph = nx.Graph() 

38 closed = [] 

39 for index, entity in enumerate(entities): 

40 if entity.closed: 

41 closed.append(index) 

42 else: 

43 # or `entity.end_points` 

44 graph.add_edges_from(entity.nodes, entity_index=index) 

45 return graph, np.array(closed) 

46 

47 

48def vertex_to_entity_path(vertex_path, graph, entities, vertices=None): 

49 """ 

50 Convert a path of vertex indices to a path of entity indices. 

51 

52 Parameters 

53 ---------- 

54 vertex_path : (n,) int 

55 Ordered list of vertex indices representing a path 

56 graph : nx.Graph 

57 Vertex connectivity 

58 entities : (m,) list 

59 Entity objects 

60 vertices : (p, dimension) float 

61 Vertex points in space 

62 

63 Returns 

64 ---------- 

65 entity_path : (q,) int 

66 Entity indices which make up vertex_path 

67 """ 

68 

69 def edge_direction(a, b): 

70 """ 

71 Given two edges, figure out if the first needs to be 

72 reversed to keep the progression forward. 

73 

74 [1,0] [1,2] -1 1 

75 [1,0] [2,1] -1 -1 

76 [0,1] [1,2] 1 1 

77 [0,1] [2,1] 1 -1 

78 

79 Parameters 

80 ------------ 

81 a : (2,) int 

82 b : (2,) int 

83 

84 Returns 

85 ------------ 

86 a_direction : int 

87 b_direction : int 

88 """ 

89 if a[0] == b[0]: 

90 return -1, 1 

91 elif a[0] == b[1]: 

92 return -1, -1 

93 elif a[1] == b[0]: 

94 return 1, 1 

95 elif a[1] == b[1]: 

96 return 1, -1 

97 else: 

98 constants.log.debug( 

99 "\n".join( 

100 [ 

101 "edges not connected!", 

102 "vertex path %s", 

103 "entity path: %s", 

104 "entity[a]: %s,", 

105 "entity[b]: %s", 

106 ] 

107 ), 

108 vertex_path, 

109 entity_path, 

110 entities[ea].points, 

111 entities[eb].points, 

112 ) 

113 

114 return None, None 

115 

116 if vertices is None or vertices.shape[1] != 2: 

117 ccw_direction = 1 

118 else: 

119 ccw_check = is_ccw(vertices[np.append(vertex_path, vertex_path[0])]) 

120 ccw_direction = (ccw_check * 2) - 1 

121 

122 # make sure vertex path is correct type 

123 vertex_path = np.asanyarray(vertex_path, dtype=np.int64) 

124 # we will be saving entity indexes 

125 entity_path = [] 

126 # loop through pairs of vertices 

127 for i in np.arange(len(vertex_path) + 1): 

128 # get two wrapped vertex positions 

129 vertex_path_pos = np.mod(np.arange(2) + i, len(vertex_path)) 

130 vertex_index = vertex_path[vertex_path_pos] 

131 entity_index = graph.get_edge_data(*vertex_index)["entity_index"] 

132 entity_path.append(entity_index) 

133 # remove duplicate entities and order CCW 

134 entity_path = grouping.unique_ordered(entity_path)[::ccw_direction] 

135 # check to make sure there is more than one entity 

136 if len(entity_path) == 1: 

137 # apply CCW reverse in place if necessary 

138 if ccw_direction < 0: 

139 index = entity_path[0] 

140 entities[index].reverse() 

141 

142 return entity_path 

143 # traverse the entity path and reverse entities in place to 

144 # align with this path ordering 

145 round_trip = np.append(entity_path, entity_path[0]) 

146 round_trip = itertools.pairwise(round_trip) 

147 for ea, eb in round_trip: 

148 da, db = edge_direction(entities[ea].end_points, entities[eb].end_points) 

149 if da is not None: 

150 entities[ea].reverse(direction=da) 

151 entities[eb].reverse(direction=db) 

152 

153 entity_path = np.array(entity_path) 

154 

155 return entity_path 

156 

157 

158def closed_paths(entities, vertices): 

159 """ 

160 Paths are lists of entity indices. 

161 We first generate vertex paths using graph cycle algorithms, 

162 and then convert them to entity paths. 

163 

164 This will also change the ordering of entity.points in place 

165 so a path may be traversed without having to reverse the entity. 

166 

167 Parameters 

168 ------------- 

169 entities : (n,) entity objects 

170 Entity objects 

171 vertices : (m, dimension) float 

172 Vertex points in space 

173 

174 Returns 

175 ------------- 

176 entity_paths : sequence of (n,) int 

177 Ordered traversals of entities 

178 """ 

179 # get a networkx graph of entities 

180 graph, closed = vertex_graph(entities) 

181 # add entities that are closed as single- entity paths 

182 entity_paths = np.reshape(closed, (-1, 1)).tolist() 

183 # look for cycles in the graph, or closed loops 

184 vertex_paths = nx.cycles.cycle_basis(graph) 

185 

186 # loop through every vertex cycle 

187 for vertex_path in vertex_paths: 

188 # a path has no length if it has fewer than 2 vertices 

189 if len(vertex_path) < 2: 

190 continue 

191 # convert vertex indices to entity indices 

192 entity_paths.append(vertex_to_entity_path(vertex_path, graph, entities, vertices)) 

193 

194 return entity_paths 

195 

196 

197def discretize_path(entities, vertices, path, scale=1.0): 

198 """ 

199 Turn a list of entity indices into a path of connected points. 

200 

201 Parameters 

202 ----------- 

203 entities : (j,) entity objects 

204 Objects like 'Line', 'Arc', etc. 

205 vertices: (n, dimension) float 

206 Vertex points in space. 

207 path : (m,) int 

208 Indexes of entities 

209 scale : float 

210 Overall scale of drawing used for 

211 Number tolerances in certain cases 

212 

213 Returns 

214 ----------- 

215 discrete : (p, dimension) float 

216 Connected points in space that lie on the 

217 path and can be connected with line segments. 

218 """ 

219 # make sure vertices are numpy array 

220 vertices = np.asanyarray(vertices) 

221 path_len = len(path) 

222 if path_len == 0: 

223 raise ValueError("Cannot discretize empty path!") 

224 if path_len == 1: 

225 # case where we only have one entity 

226 discrete = np.asanyarray(entities[path[0]].discrete(vertices, scale=scale)) 

227 else: 

228 # run through path appending each entity 

229 discrete = [] 

230 for i, entity_id in enumerate(path): 

231 # the current (n, dimension) discrete curve of an entity 

232 current = entities[entity_id].discrete(vertices, scale=scale) 

233 # check if we are on the final entity 

234 if i >= (path_len - 1): 

235 # if we are on the last entity include the last point 

236 discrete.append(current) 

237 else: 

238 # slice off the last point so we don't get duplicate 

239 # points from the end of one entity and the start of another 

240 discrete.append(current[:-1]) 

241 # stack all curves to one nice (n, dimension) curve 

242 discrete = np.vstack(discrete) 

243 # make sure 2D curves are are counterclockwise 

244 if vertices.shape[1] == 2 and not is_ccw(discrete): 

245 # reversing will make array non c- contiguous 

246 discrete = np.ascontiguousarray(discrete[::-1]) 

247 

248 return discrete 

249 

250 

251class PathSample: 

252 def __init__(self, points: ArrayLike): 

253 # make sure input array is numpy 

254 self._points = np.array(points) 

255 # find the direction of each segment 

256 self._vectors = np.diff(self._points, axis=0) 

257 # find the length of each segment 

258 self._norms = util.row_norm(self._vectors) 

259 # unit vectors for each segment 

260 nonzero = self._norms > constants.tol_path.zero 

261 self._unit_vec = self._vectors.copy() 

262 self._unit_vec[nonzero] /= self._norms[nonzero].reshape((-1, 1)) 

263 # total distance in the path 

264 self.length = self._norms.sum() 

265 # cumulative sum of section length 

266 # note that this is sorted 

267 self._cum_norm = np.cumsum(self._norms) 

268 

269 def sample( 

270 self, distances: ArrayLike, include_original: bool = False 

271 ) -> NDArray[np.float64]: 

272 """ 

273 Return points at the distances along the path requested. 

274 

275 Parameters 

276 ---------- 

277 distances 

278 Distances along the path to sample at. 

279 include_original 

280 Include the original vertices even if they are not 

281 specified in `distance`. Useful as this will return 

282 a result with identical area and length, however 

283 indexes of `distance` will not correspond with result. 

284 

285 Returns 

286 -------- 

287 samples : (n, dimension) 

288 Samples requested. 

289 `n==len(distances)` if not `include_original` 

290 """ 

291 # return the indices in cum_norm that each sample would 

292 # need to be inserted at to maintain the sorted property 

293 positions = np.searchsorted(self._cum_norm, distances) 

294 positions = np.clip(positions, 0, len(self._unit_vec) - 1) 

295 offsets = np.append(0, self._cum_norm)[positions] 

296 # the distance past the reference vertex we need to travel 

297 projection = distances - offsets 

298 # find out which direction we need to project 

299 direction = self._unit_vec[positions] 

300 # find out which vertex we're offset from 

301 origin = self._points[positions] 

302 

303 # just the parametric equation for a line 

304 resampled = origin + (direction * projection.reshape((-1, 1))) 

305 

306 if include_original: 

307 # find the original positions that were not inserted 

308 # note that this checks *exact float equal* 

309 uninserted = ~np.isin(np.append(self._cum_norm, 0.0), projection) 

310 

311 if uninserted.any(): 

312 # find the index of the uninserted original points in the new sampling 

313 index = np.searchsorted(positions, np.nonzero(uninserted)[0]) 

314 # insert the original points at the index 

315 resampled = np.insert(resampled, index, self._points[uninserted], axis=0) 

316 

317 return resampled 

318 

319 def truncate(self, distance: Number) -> NDArray[np.float64]: 

320 """ 

321 Return a truncated version of the path. 

322 Only one vertex (at the endpoint) will be added. 

323 

324 Parameters 

325 ---------- 

326 distance 

327 Distance along the path to truncate at. 

328 

329 Returns 

330 ---------- 

331 path 

332 Path clipped to `distance` requested. 

333 """ 

334 position = np.searchsorted(self._cum_norm, distance) 

335 offset = distance - self._cum_norm[position - 1] 

336 

337 if offset < constants.tol_path.merge: 

338 truncated = self._points[: position + 1] 

339 else: 

340 vector = util.unitize( 

341 np.diff(self._points[np.arange(2) + position], axis=0).reshape(-1) 

342 ) 

343 vector *= offset 

344 endpoint = self._points[position] + vector 

345 truncated = np.vstack((self._points[: position + 1], endpoint)) 

346 assert ( 

347 util.row_norm(np.diff(truncated, axis=0)).sum() - distance 

348 ) < constants.tol_path.merge 

349 

350 return truncated 

351 

352 

353def resample_path( 

354 points: ArrayLike, 

355 count: Integer | None = None, 

356 step: Number | None = None, 

357 step_round: bool = True, 

358 include_original: bool = False, 

359) -> NDArray[np.float64]: 

360 """ 

361 Given a path along (n,d) points, resample them such that the 

362 distance traversed along the path is constant in between each 

363 of the resampled points. Note that this can produce clipping at 

364 corners, as the original vertices are NOT guaranteed to be in the 

365 new, resampled path. 

366 

367 ONLY ONE of count or step can be specified 

368 Result can be uniformly distributed (np.linspace) by specifying count 

369 Result can have a specific distance (np.arange) by specifying step 

370 

371 

372 Parameters 

373 ---------- 

374 points: (n, d) float 

375 Points in space 

376 count : int, 

377 Number of points to sample evenly (aka np.linspace) 

378 step : float 

379 Distance each step should take along the path (aka np.arange) 

380 step_round 

381 Alter `step` to the nearest integer division of overall length. 

382 include_original 

383 Include the exact original points in the output. 

384 

385 Returns 

386 ---------- 

387 resampled : (j,d) float 

388 Points on the path 

389 """ 

390 points = np.array(points, dtype=np.float64) 

391 # generate samples along the perimeter from kwarg count or step 

392 if (count is not None) and (step is not None): 

393 raise ValueError("Only step OR count can be specified") 

394 if (count is None) and (step is None): 

395 raise ValueError("Either step or count must be specified") 

396 

397 sampler = PathSample(points) 

398 if step is not None and step_round: 

399 if step >= sampler.length: 

400 return points[[0, -1]] 

401 

402 count = int(np.ceil(sampler.length / step)) 

403 

404 if count is not None: 

405 samples = np.linspace(0, sampler.length, count) 

406 elif step is not None: 

407 samples = np.arange(0, sampler.length, step) 

408 

409 resampled = sampler.sample(samples, include_original=include_original) 

410 

411 if constants.tol.strict: 

412 check = util.row_norm(points[[0, -1]] - resampled[[0, -1]]) 

413 assert check[0] < constants.tol_path.merge 

414 if count is not None: 

415 assert check[1] < constants.tol_path.merge 

416 

417 return resampled 

418 

419 

420def split(path): 

421 """ 

422 Split a Path2D into multiple Path2D objects where each 

423 one has exactly one root curve. 

424 

425 Parameters 

426 -------------- 

427 path : trimesh.path.Path2D 

428 Input geometry 

429 

430 Returns 

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

432 split : list of trimesh.path.Path2D 

433 Original geometry as separate paths 

434 """ 

435 # avoid a circular import by referencing class of path 

436 Path2D = type(path) 

437 

438 # save the results of the split to an array 

439 split = [] 

440 

441 # get objects from cache to avoid a bajillion 

442 # cache checks inside the tight loop 

443 paths = path.paths 

444 discrete = path.discrete 

445 polygons_closed = path.polygons_closed 

446 enclosure_directed = path.enclosure_directed 

447 

448 for root_index, root in enumerate(path.root): 

449 # get a list of the root curve's children 

450 connected = list(enclosure_directed[root].keys()) 

451 # add the root node to the list 

452 connected.append(root) 

453 

454 # store new paths and entities 

455 new_paths = [] 

456 new_entities = [] 

457 

458 for index in connected: 

459 nodes = paths[index] 

460 # add a path which is just sequential indexes 

461 new_paths.append(np.arange(len(nodes)) + len(new_entities)) 

462 # save the entity indexes 

463 new_entities.extend(nodes) 

464 

465 # store the root index from the original drawing 

466 metadata = copy.deepcopy(path.metadata) 

467 metadata["split_2D"] = root_index 

468 # we made the root path the last index of connected 

469 new_root = np.array([len(new_paths) - 1]) 

470 

471 # prevents the copying from nuking our cache 

472 with path._cache: 

473 # create the Path2D 

474 split.append( 

475 Path2D( 

476 entities=copy.deepcopy(path.entities[new_entities]), 

477 vertices=copy.deepcopy(path.vertices), 

478 metadata=metadata, 

479 ) 

480 ) 

481 

482 # add back expensive things to the cache 

483 split[-1]._cache.update( 

484 { 

485 "paths": new_paths, 

486 "polygons_closed": polygons_closed[connected], 

487 "discrete": [discrete[c] for c in connected], 

488 "root": new_root, 

489 } 

490 ) 

491 # set the cache ID 

492 split[-1]._cache.id_set() 

493 

494 return np.array(split)