Coverage for trimesh/repair.py: 87%

132 statements  

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

1""" 

2repair.py 

3------------- 

4 

5Fill holes and fix winding and normals of meshes. 

6""" 

7 

8import numpy as np 

9 

10from . import graph, triangles 

11from .constants import log 

12from .geometry import faces_to_edges, triangulate_quads 

13from .graph import connected_components 

14from .grouping import group_rows, hashable_rows 

15 

16try: 

17 import networkx as nx 

18except BaseException as E: 

19 # create a dummy module which will raise the ImportError 

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

21 from .exceptions import ExceptionWrapper 

22 

23 nx = ExceptionWrapper(E) 

24 

25try: 

26 from .path.exchange.misc import faces_to_path 

27except BaseException as E: 

28 from .exceptions import ExceptionWrapper 

29 

30 faces_to_path = ExceptionWrapper(E) 

31 

32 

33def fix_winding(mesh): 

34 """ 

35 Traverse and change mesh faces in-place to make sure 

36 winding is correct with edges on adjacent faces in 

37 opposite directions. 

38 

39 Parameters 

40 ------------- 

41 mesh : Trimesh 

42 Source geometry to alter in-place. 

43 """ 

44 # anything we would fix is already done 

45 if mesh.is_winding_consistent: 

46 return 

47 

48 graph_all = nx.from_edgelist(mesh.face_adjacency) 

49 flipped = 0 

50 

51 faces = mesh.faces.view(np.ndarray).copy() 

52 

53 # we are going to traverse the graph using BFS 

54 # start a traversal for every connected component 

55 for components in nx.connected_components(graph_all): 

56 # get a subgraph for this component 

57 g = graph_all.subgraph(components) 

58 # get the first node in the graph in a way that works on nx's 

59 # new API and their old API 

60 start = next(iter(g.nodes())) 

61 

62 # we traverse every pair of faces in the graph 

63 # we modify mesh.faces and mesh.face_normals in place 

64 for face_pair in nx.bfs_edges(g, start): 

65 # for each pair of faces, we convert them into edges, 

66 # find the edge that both faces share and then see if edges 

67 # are reversed in order as you would expect 

68 # (2, ) int 

69 face_pair = np.ravel(face_pair) 

70 # (2, 3) int 

71 pair = faces[face_pair] 

72 # (6, 2) int 

73 edges = faces_to_edges(pair) 

74 overlap = group_rows(np.sort(edges, axis=1), require_count=2) 

75 if len(overlap) == 0: 

76 # only happens on non-watertight meshes 

77 continue 

78 edge_pair = edges[overlap[0]] 

79 if edge_pair[0][0] == edge_pair[1][0]: 

80 # if the edges aren't reversed, invert the order of one face 

81 flipped += 1 

82 faces[face_pair[1]] = faces[face_pair[1]][::-1] 

83 

84 if flipped > 0: 

85 mesh.faces = faces 

86 

87 log.debug("flipped %d/%d edges", flipped, len(mesh.faces) * 3) 

88 

89 

90def fix_inversion(mesh, multibody: bool = False): 

91 """ 

92 Check to see if a mesh has normals pointing "out." 

93 

94 Parameters 

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

96 mesh : trimesh.Trimesh 

97 Mesh to fix in-place. 

98 multibody : bool 

99 If True will try to fix normals on every body 

100 """ 

101 

102 # we are not considering multiple bodies so we only need to 

103 # do anything if the mesh is watertight and the volume is negative 

104 if not multibody: 

105 if mesh.is_watertight and mesh.volume < 0.0: 

106 # this will make things worse for non-watertight meshes 

107 mesh.invert() 

108 return 

109 

110 # get groups of connected faces 

111 groups = connected_components(mesh.face_adjacency) 

112 

113 # mask of faces to flip 

114 flip = np.zeros(len(mesh.faces), dtype=bool) 

115 

116 # save these to avoid thrashing cache in a loop 

117 tri = mesh.triangles 

118 cross = mesh.triangles_cross 

119 # get the edges indexable as faces 

120 face_edges = mesh.edges.reshape((-1, 6)) 

121 

122 # loop through indexes of `mesh.faces` 

123 for face_index in groups: 

124 # we need watertight bodies to do anything so immediately 

125 # skip anything smaller than 4 faces (tetrahedron) 

126 if len(face_index) < 4: 

127 continue 

128 # check the watertightness and winding of this group 

129 is_tight, is_wound = graph.is_watertight(face_edges[face_index].reshape((-1, 2))) 

130 

131 # if we're not completely correct skip it as flipping faces will make it worse 

132 if not (is_tight and is_wound): 

133 continue 

134 

135 # check the volume for just this body 

136 volume = triangles.mass_properties( 

137 tri[face_index], crosses=cross[face_index], skip_inertia=True 

138 ).volume 

139 # if that volume is negative it is either 

140 # inverted or just total garbage 

141 if volume < 0.0: 

142 flip[face_index] = True 

143 

144 # one or more faces needs flipping 

145 if flip.any(): 

146 if "face_normals" in mesh._cache: 

147 # flip normals of necessary faces 

148 normals = mesh.face_normals.copy() 

149 normals[flip] *= -1.0 

150 else: 

151 normals = None 

152 # flip faces 

153 mesh.faces[flip] = np.fliplr(mesh.faces[flip]) 

154 # assign corrected normals 

155 if normals is not None: 

156 mesh.face_normals = normals 

157 

158 

159def fix_normals(mesh, multibody=False): 

160 """ 

161 Fix the winding and direction of a mesh face and 

162 face normals in-place. 

163 

164 Really only meaningful on watertight meshes but will orient all 

165 faces and winding in a uniform way for non-watertight face 

166 patches as well. 

167 

168 Parameters 

169 ------------- 

170 mesh : trimesh.Trimesh 

171 Mesh to fix normals on 

172 multibody : bool 

173 if True try to correct normals direction 

174 on every body rather than just one 

175 

176 Notes 

177 -------------- 

178 mesh.faces : will flip columns on inverted faces 

179 """ 

180 # traverse face adjacency to correct winding 

181 fix_winding(mesh) 

182 # check to see if a mesh is inverted 

183 fix_inversion(mesh, multibody=multibody) 

184 

185 

186def broken_faces(mesh, color=None): 

187 """ 

188 Return the index of faces in the mesh which break the 

189 watertight status of the mesh. 

190 

191 Parameters 

192 -------------- 

193 mesh : trimesh.Trimesh 

194 Mesh to check broken faces on 

195 color: (4,) uint8 or None 

196 Will set broken faces to this color if not None 

197 

198 Returns 

199 --------------- 

200 broken : (n, ) int 

201 Indexes of mesh.faces 

202 """ 

203 adjacency = nx.from_edgelist(mesh.face_adjacency) 

204 broken = [k for k, v in dict(adjacency.degree()).items() if v != 3] 

205 broken = np.array(broken) 

206 if color is not None and broken.size != 0: 

207 # if someone passed a broken color 

208 color = np.array(color) 

209 if not (color.shape == (4,) or color.shape == (3,)): 

210 color = [255, 0, 0, 255] 

211 mesh.visual.face_colors[broken] = color 

212 return broken 

213 

214 

215def fill_holes(mesh, use_fan: bool = False): 

216 """ 

217 Fill boundary holes in-place using fans, which may result 

218 in bad answers if the holes are non convex! 

219 

220 Face colors and attributes will be padded with default values 

221 so shapes match. 

222 

223 Parameters 

224 ---------- 

225 mesh : trimesh.Trimesh 

226 Mesh will be repaired in-place. 

227 use_fan 

228 If passed, holes larger than quads will be triangulated 

229 using fans which are only valid for non-convex holes. 

230 """ 

231 if len(mesh.faces) < 3: 

232 return False 

233 if mesh.is_watertight: 

234 return True 

235 

236 # any edge that occurs once is on the boundary 

237 boundary_groups = group_rows(mesh.edges_sorted, require_count=1) 

238 # either watertight or broken in a weird way 

239 if len(boundary_groups) < 3: 

240 return False 

241 

242 # ordered edges on the boundary 

243 boundary = mesh.edges[boundary_groups] 

244 

245 # use networkx to find cycles of boundary edges 

246 holes = nx.cycle_basis(nx.from_edgelist(boundary)) 

247 

248 # this handles mixed tris, quads, and arbitrary polygons 

249 new_faces = triangulate_quads(holes, use_fan=use_fan) 

250 if len(new_faces) == 0: 

251 return False 

252 

253 # now we need to fix the winding for every new face 

254 new_edges = faces_to_edges(new_faces) 

255 

256 # the hashable rows for the mesh boundary edges 

257 # and the boundary of the original hole in the mesh 

258 # a well-constructed mesh has edges that are REVERSED 

259 # so we're going to try a cheap indexing operation 

260 hashable_new = hashable_rows(new_edges) 

261 hashable_old = hashable_rows(boundary) 

262 

263 # the magic trick: if any ordered edge of the NEW face is contained 

264 # in the OLD boundary edges, it means the new face has edges that 

265 # are in the same order as the boundary, and that new face 

266 # needs to be reversed and since new_edges has exactly three 

267 # edges per triangle we can compact indexes back to triangles 

268 needs_reverse = np.isin(hashable_new, hashable_old).reshape((-1, 3)).any(axis=1) 

269 

270 # now we have some shiny new faces that might even be wound correctly! 

271 new_faces[needs_reverse] = np.fliplr(new_faces[needs_reverse]) 

272 

273 # do the bookkeeping to preserve face normals and pad attributes 

274 mesh.extend_faces(new_faces) 

275 

276 return mesh.is_watertight 

277 

278 

279def stitch(mesh, faces=None, insert_vertices=False): 

280 """ 

281 Create a fan stitch over the boundary of the specified 

282 faces. If the boundary is non-convex a triangle fan 

283 is going to be extremely wonky. 

284 

285 Parameters 

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

287 mesh : trimesh.Trimesh 

288 Mesh to create fan stitch on. 

289 faces : (n,) int 

290 Face indexes to stitch with triangle fans. 

291 insert_vertices : bool 

292 Allow stitching to insert new vertices? 

293 

294 Returns 

295 ---------- 

296 fan : (m, 3) int 

297 New triangles referencing mesh.vertices. 

298 vertices : (p, 3) float 

299 Inserted vertices (only returned `if insert_vertices`) 

300 """ 

301 if faces is None: 

302 faces = np.arange(len(mesh.faces)) 

303 

304 # get a sequence of vertex indices representing the 

305 # boundary of the specified faces 

306 # will be referencing the same indexes of `mesh.vertices` 

307 points = [ 

308 e.points 

309 for e in faces_to_path(mesh, faces)["entities"] 

310 if len(e.points) > 3 and e.points[0] == e.points[-1] 

311 ] 

312 

313 # get properties to avoid querying in loop 

314 vertices = mesh.vertices 

315 normals = mesh.face_normals 

316 

317 # find which faces are associated with an edge 

318 edges_face = mesh.edges_face 

319 tree_edge = mesh.edges_sorted_tree 

320 

321 if insert_vertices: 

322 # create one new vertex per curve at the centroid 

323 centroids = np.array([vertices[p].mean(axis=0) for p in points]) 

324 # save the original length of the vertices 

325 count = len(vertices) 

326 # for the normal check stack our local vertices 

327 vertices = np.vstack((vertices, centroids)) 

328 # create a triangle between our new centroid vertex 

329 # and each one of the boundary curves 

330 fan = [ 

331 np.column_stack((np.ones(len(p) - 1, dtype=int) * (count + i), p[:-1], p[1:])) 

332 for i, p in enumerate(points) 

333 ] 

334 else: 

335 # since we're not allowed to insert new vertices 

336 # create a triangle fan for each boundary curve 

337 fan = [ 

338 np.column_stack((np.ones(len(p) - 3, dtype=int) * p[0], p[1:-2], p[2:-1])) 

339 for p in points 

340 ] 

341 

342 # now we do a normal check against an adjacent face 

343 # to see if each region needs to be flipped 

344 for i, t in zip(range(len(fan)), fan): 

345 # get the edges from the original mesh 

346 # for the first `n` new triangles 

347 e = t[:10, 1:].copy() 

348 e.sort(axis=1) 

349 

350 # find which indexes of `mesh.edges` these 

351 # new edges correspond with by finding edges 

352 # that exactly correspond with the tree 

353 query = tree_edge.query_ball_point(e, r=1e-10) 

354 if len(query) == 0: 

355 continue 

356 # stack all the indices that exist 

357 edge_index = np.concatenate(query) 

358 

359 # get the normals from the original mesh 

360 original = normals[edges_face[edge_index]] 

361 

362 # calculate the normals for a few new faces 

363 check, valid = triangles.normals(vertices[t[:3]]) 

364 if not valid.any(): 

365 continue 

366 # take the first valid normal from our new faces 

367 check = check[0] 

368 

369 # if our new faces are reversed from the original 

370 # Adjacent face flip them along their axis 

371 sign = np.dot(original, check) 

372 if sign.mean() < 0: 

373 fan[i] = np.fliplr(t) 

374 

375 fan = np.vstack(fan) 

376 

377 if insert_vertices: 

378 return fan, centroids 

379 return fan