Coverage for trimesh/geometry.py: 86%

151 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-10-02 20:54 +0000

1import numpy as np 

2 

3from . import util 

4from .constants import log 

5from .typed import NDArray 

6 

7try: 

8 import scipy.sparse 

9except BaseException as E: 

10 from . import exceptions 

11 

12 # raise E again if anyone tries to use sparse 

13 scipy = exceptions.ExceptionWrapper(E) 

14 

15 

16def plane_transform(origin, normal): 

17 """ 

18 Given the origin and normal of a plane find the transform 

19 that will move that plane to be coplanar with the XY plane. 

20 

21 Parameters 

22 ---------- 

23 origin : (3,) float 

24 Point that lies on the plane 

25 normal : (3,) float 

26 Vector that points along normal of plane 

27 

28 Returns 

29 --------- 

30 transform: (4,4) float 

31 Transformation matrix to move points onto XY plane 

32 """ 

33 transform = align_vectors(normal, [0, 0, 1]) 

34 if origin is not None: 

35 transform[:3, 3] = -np.dot(transform, np.append(origin, 1))[:3] 

36 return transform 

37 

38 

39def align_vectors(a, b, return_angle=False): 

40 """ 

41 Find the rotation matrix that transforms one 3D vector 

42 to another. 

43 

44 Parameters 

45 ------------ 

46 a : (3,) float 

47 Unit vector 

48 b : (3,) float 

49 Unit vector 

50 return_angle : bool 

51 Return the angle between vectors or not 

52 

53 Returns 

54 ------------- 

55 matrix : (4, 4) float 

56 Homogeneous transform to rotate from `a` to `b` 

57 angle : float 

58 If `return_angle` angle in radians between `a` and `b` 

59 

60 """ 

61 a = np.array(a, dtype=np.float64) 

62 b = np.array(b, dtype=np.float64) 

63 if a.shape != (3,) or b.shape != (3,): 

64 raise ValueError("vectors must be (3,)!") 

65 

66 # find the SVD of the two vectors 

67 au = np.linalg.svd(a.reshape((-1, 1)))[0] 

68 bu = np.linalg.svd(b.reshape((-1, 1)))[0] 

69 

70 if np.linalg.det(au) < 0: 

71 au[:, -1] *= -1.0 

72 if np.linalg.det(bu) < 0: 

73 bu[:, -1] *= -1.0 

74 

75 # put rotation into homogeneous transformation 

76 matrix = np.eye(4) 

77 matrix[:3, :3] = bu.dot(au.T) 

78 

79 if return_angle: 

80 # projection of a onto b 

81 # first column of SVD result is normalized source vector 

82 dot = np.dot(au[:, 0], bu[:, 0]) 

83 # clip to avoid floating point error 

84 angle = np.arccos(np.clip(dot, -1.0, 1.0)) 

85 return matrix, angle 

86 

87 return matrix 

88 

89 

90def faces_to_edges(faces, return_index=False): 

91 """ 

92 Given a list of faces (n,3), return a list of edges (n*3,2) 

93 

94 Parameters 

95 ----------- 

96 faces : (n, 3) int 

97 Vertex indices representing faces 

98 

99 Returns 

100 ----------- 

101 edges : (n*3, 2) int 

102 Vertex indices representing edges 

103 """ 

104 faces = np.asanyarray(faces, np.int64) 

105 

106 # each face has three edges 

107 edges = faces[:, [0, 1, 1, 2, 2, 0]].reshape((-1, 2)) 

108 

109 if return_index: 

110 # edges are in order of faces due to reshape 

111 face_index = np.tile(np.arange(len(faces)), (3, 1)).T.reshape(-1) 

112 return edges, face_index 

113 return edges 

114 

115 

116def vector_angle(pairs): 

117 """ 

118 Find the angles between pairs of unit vectors. 

119 

120 Parameters 

121 ---------- 

122 pairs : (n, 2, 3) float 

123 Unit vector pairs 

124 

125 Returns 

126 ---------- 

127 angles : (n,) float 

128 Angles between vectors in radians 

129 """ 

130 pairs = np.asanyarray(pairs, dtype=np.float64) 

131 if len(pairs) == 0: 

132 return np.array([]) 

133 elif util.is_shape(pairs, (2, 3)): 

134 pairs = pairs.reshape((-1, 2, 3)) 

135 elif not util.is_shape(pairs, (-1, 2, (2, 3))): 

136 raise ValueError("pairs must be (n,2,(2|3))!") 

137 

138 # do the dot product between vectors 

139 dots = util.diagonal_dot(pairs[:, 0], pairs[:, 1]) 

140 # clip for floating point error 

141 dots = np.clip(dots, -1.0, 1.0) 

142 # do cos and remove arbitrary sign 

143 angles = np.abs(np.arccos(dots)) 

144 

145 return angles 

146 

147 

148def triangulate_quads(quads, dtype=np.int64, use_fan: bool = True) -> NDArray: 

149 """ 

150 Given an array of quad faces return them as triangle faces, 

151 also handles pure triangles and mixed triangles and quads. 

152 

153 Parameters 

154 ----------- 

155 quads: (n, 4) int 

156 Vertex indices of quad faces. 

157 dtype 

158 Data type requested for the return 

159 use_fan 

160 Triangulate holes larger than quads with fans, 

161 which may be wrong if the holes are non-convex 

162 

163 Returns 

164 ----------- 

165 faces : (m, 3) int 

166 Vertex indices of triangular faces. 

167 """ 

168 

169 if len(quads) == 0: 

170 return np.zeros(0, dtype=dtype) 

171 

172 try: 

173 # this will fail in newer versions of numpy 

174 # if there are mixed quads and tris 

175 quads = np.array(quads, dtype=dtype) 

176 

177 if len(quads.shape) == 2 and quads.shape[1] == 3: 

178 # if they are just triangles return immediately 

179 return quads.astype(dtype) 

180 

181 if len(quads.shape) == 2 and quads.shape[1] == 4: 

182 # if they are just quads stack and return 

183 return np.vstack((quads[:, [0, 1, 2]], quads[:, [2, 3, 0]])).astype(dtype) 

184 except ValueError: 

185 # new numpy raises an error for sequences 

186 pass 

187 

188 # we made it here so we have mixed tris/quads/polygons 

189 # do one pass to get the lengths 

190 lengths = np.array([len(i) for i in quads], dtype=np.int64) 

191 

192 # get triangles and quads as clean constant-row numpy arrays 

193 tri = np.array([quads[i] for i in np.nonzero(lengths == 3)[0]], dtype=np.int64) 

194 quad = np.array([quads[i] for i in np.nonzero(lengths == 4)[0]], dtype=np.int64) 

195 

196 if use_fan: 

197 # get arbitrary polygons as a ragged sequence 

198 poly = [quads[i] for i in np.nonzero(lengths > 4)[0]] 

199 else: 

200 # skip any hole larger than a quad 

201 poly = [] 

202 

203 if len(quad) == 0 and len(poly) == 0: 

204 # only triangles, return triangles 

205 return tri.astype(dtype) 

206 if len(poly) > 0: 

207 # use numpy slicing to triangulate ragged-sequence polygons 

208 poly = util.triangle_fans_to_faces(poly) 

209 if len(quad) > 0: 

210 # trivially tessellate quads 

211 quad = np.vstack((quad[:, [0, 1, 2]], quad[:, [2, 3, 0]])) 

212 # stack triangles from all three cases 

213 return util.vstack_empty([tri, quad, poly]).astype(dtype) 

214 

215 

216def vertex_face_indices(vertex_count, faces, faces_sparse): 

217 """ 

218 Find vertex face indices from the faces array of vertices 

219 

220 Parameters 

221 ----------- 

222 vertex_count : int 

223 The number of vertices faces refer to 

224 faces : (n, 3) int 

225 List of vertex indices 

226 faces_sparse : scipy.sparse.COO 

227 Sparse matrix 

228 

229 Returns 

230 ----------- 

231 vertex_faces : (vertex_count, ) int 

232 Face indices for every vertex 

233 Array padded with -1 in each row for all vertices with fewer 

234 face indices than the max number of face indices. 

235 """ 

236 # Create 2D array with row for each vertex and 

237 # length of max number of faces for a vertex 

238 try: 

239 counts = np.bincount(faces.flatten(), minlength=vertex_count) 

240 except TypeError: 

241 # casting failed on 32 bit Windows 

242 log.warning("casting failed, falling back!") 

243 # fall back to np.unique (usually ~35x slower than bincount) 

244 counts = np.unique(faces.flatten(), return_counts=True)[1] 

245 assert len(counts) == vertex_count 

246 assert faces.max() < vertex_count 

247 

248 # start cumulative sum at zero and clip off the last value 

249 starts = np.append(0, np.cumsum(counts)[:-1]) 

250 # pack incrementing array into final shape 

251 pack = np.arange(counts.max()) + starts[:, None] 

252 # pad each row with -1 to pad to the max length 

253 padded = -(pack >= (starts + counts)[:, None]).astype(np.int64) 

254 

255 try: 

256 # do most of the work with a sparse dot product 

257 identity = scipy.sparse.identity(len(faces), dtype=int) 

258 sorted_faces = faces_sparse.dot(identity).nonzero()[1] 

259 # this will fail if any face was degenerate 

260 # TODO 

261 # figure out how to filter out degenerate faces from sparse 

262 # result if sorted_faces.size != faces.size 

263 padded[padded == 0] = sorted_faces 

264 except BaseException: 

265 # fall back to a slow loop 

266 log.warning( 

267 "vertex_faces falling back to slow loop! " 

268 + "mesh probably has degenerate faces", 

269 exc_info=True, 

270 ) 

271 sort = np.zeros(faces.size, dtype=np.int64) 

272 flat = faces.flatten() 

273 for v in range(vertex_count): 

274 # assign the data in order 

275 sort[starts[v] : starts[v] + counts[v]] = (np.where(flat == v)[0] // 3)[::-1] 

276 padded[padded == 0] = sort 

277 return padded 

278 

279 

280def mean_vertex_normals(vertex_count, faces, face_normals, sparse=None, **kwargs): 

281 """ 

282 Find vertex normals from the mean of the faces that contain 

283 that vertex. 

284 

285 Parameters 

286 ----------- 

287 vertex_count : int 

288 The number of vertices faces refer to 

289 faces : (n, 3) int 

290 List of vertex indices 

291 face_normals : (n, 3) float 

292 Normal vector for each face 

293 

294 Returns 

295 ----------- 

296 vertex_normals : (vertex_count, 3) float 

297 Normals for every vertex 

298 Vertices unreferenced by faces will be zero. 

299 """ 

300 

301 def summed_sparse(): 

302 # use a sparse matrix of which face contains each vertex to 

303 # figure out the summed normal at each vertex 

304 # allow cached sparse matrix to be passed 

305 if sparse is None: 

306 matrix = index_sparse(vertex_count, faces) 

307 else: 

308 matrix = sparse 

309 summed = matrix.dot(face_normals) 

310 return summed 

311 

312 def summed_loop(): 

313 # loop through every face, in tests was ~50x slower than 

314 # doing this with a sparse matrix 

315 summed = np.zeros((vertex_count, 3)) 

316 for face, normal in zip(faces, face_normals): 

317 summed[face] += normal 

318 return summed 

319 

320 try: 

321 summed = summed_sparse() 

322 except BaseException: 

323 log.warning("unable to use sparse matrix, falling back!", exc_info=True) 

324 summed = summed_loop() 

325 

326 # invalid normals will be returned as zero 

327 vertex_normals = util.unitize(summed) 

328 

329 return vertex_normals 

330 

331 

332def weighted_vertex_normals( 

333 vertex_count, faces, face_normals, face_angles, use_loop=False 

334): 

335 """ 

336 Compute vertex normals from the faces that contain that vertex. 

337 The contribution of a face's normal to a vertex normal is the 

338 ratio of the corner-angle in which the vertex is, with respect 

339 to the sum of all corner-angles surrounding the vertex. 

340 

341 Grit Thuerrner & Charles A. Wuethrich (1998) 

342 Computing Vertex Normals from Polygonal Facets, 

343 Journal of Graphics Tools, 3:1, 43-46 

344 

345 Parameters 

346 ----------- 

347 vertex_count : int 

348 The number of vertices faces refer to 

349 faces : (n, 3) int 

350 List of vertex indices 

351 face_normals : (n, 3) float 

352 Normal vector for each face 

353 face_angles : (n, 3) float 

354 Angles at each vertex in the face 

355 

356 Returns 

357 ----------- 

358 vertex_normals : (vertex_count, 3) float 

359 Normals for every vertex 

360 Vertices unreferenced by faces will be zero. 

361 """ 

362 

363 def summed_sparse(): 

364 # use a sparse matrix of which face contains each vertex to 

365 # figure out the summed normal at each vertex 

366 # allow cached sparse matrix to be passed 

367 # fill the matrix with vertex-corner angles as weights 

368 matrix = index_sparse(vertex_count, faces, data=face_angles.ravel()) 

369 return matrix.dot(face_normals) 

370 

371 def summed_loop(): 

372 summed = np.zeros((vertex_count, 3), np.float64) 

373 for vertex_idx in np.arange(vertex_count): 

374 # loop over all vertices 

375 # compute normal contributions from surrounding faces 

376 # obviously slower than with the sparse matrix 

377 face_idxs, inface_idxs = np.where(faces == vertex_idx) 

378 surrounding_angles = face_angles[face_idxs, inface_idxs] 

379 summed[vertex_idx] = np.dot( 

380 surrounding_angles / surrounding_angles.sum(), face_normals[face_idxs] 

381 ) 

382 

383 return summed 

384 

385 # normals should be unit vectors 

386 face_ok = (face_normals**2).sum(axis=1) > 0.5 

387 # don't consider faces with invalid normals 

388 faces = faces[face_ok] 

389 face_normals = face_normals[face_ok] 

390 face_angles = face_angles[face_ok] 

391 

392 if not use_loop: 

393 try: 

394 return util.unitize(summed_sparse()) 

395 except BaseException: 

396 log.warning("unable to use sparse matrix, falling back!", exc_info=True) 

397 # we either crashed or were asked to loop 

398 return util.unitize(summed_loop()) 

399 

400 

401def index_sparse(columns, indices, data=None, dtype=None): 

402 """ 

403 Return a sparse matrix for which vertices are contained in which faces. 

404 A data vector can be passed which is then used instead of booleans 

405 

406 Parameters 

407 ------------ 

408 columns : int 

409 Number of columns, usually number of vertices 

410 indices : (m, d) int 

411 Usually mesh.faces 

412 

413 Returns 

414 --------- 

415 sparse: scipy.sparse.coo_matrix of shape (columns, len(faces)) 

416 dtype is boolean 

417 

418 Examples 

419 ---------- 

420 In [1]: sparse = faces_sparse(len(mesh.vertices), mesh.faces) 

421 

422 In [2]: sparse.shape 

423 Out[2]: (12, 20) 

424 

425 In [3]: mesh.faces.shape 

426 Out[3]: (20, 3) 

427 

428 In [4]: mesh.vertices.shape 

429 Out[4]: (12, 3) 

430 

431 In [5]: dense = sparse.toarray().astype(int) 

432 

433 In [6]: dense 

434 Out[6]: 

435 array([[1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 

436 [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 

437 [1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0], 

438 [0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0], 

439 [0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0], 

440 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, 1, 0, 0], 

441 [0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0], 

442 [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0, 0, 1], 

443 [1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0], 

444 [0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 0], 

445 [0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1], 

446 [0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1]]) 

447 

448 In [7]: dense.sum(axis=0) 

449 Out[7]: array([3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]) 

450 """ 

451 indices = np.asanyarray(indices) 

452 columns = int(columns) 

453 

454 # flattened list 

455 row = indices.reshape(-1) 

456 col = np.tile( 

457 np.arange(len(indices)).reshape((-1, 1)), (1, indices.shape[1]) 

458 ).reshape(-1) 

459 

460 shape = (columns, len(indices)) 

461 if data is None: 

462 data = np.ones(len(col), dtype=bool) 

463 elif len(data) != len(col): 

464 raise ValueError("data incorrect length") 

465 

466 if dtype is not None: 

467 data = data.astype(dtype) 

468 

469 # assemble into sparse matrix 

470 return scipy.sparse.coo_matrix((data, (row, col)), shape=shape, dtype=data.dtype)