primlib 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- primlib/__init__.py +6 -0
- primlib/_version.py +24 -0
- primlib/line_set.py +435 -0
- primlib/point_cloud.py +711 -0
- primlib/triangle_mesh.py +1910 -0
- primlib/utils.py +53 -0
- primlib/vertices.py +604 -0
- primlib/visualizer.py +179 -0
- primlib-0.3.0.dist-info/METADATA +124 -0
- primlib-0.3.0.dist-info/RECORD +13 -0
- primlib-0.3.0.dist-info/WHEEL +5 -0
- primlib-0.3.0.dist-info/licenses/LICENSE +22 -0
- primlib-0.3.0.dist-info/top_level.txt +1 -0
primlib/triangle_mesh.py
ADDED
|
@@ -0,0 +1,1910 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Self, override
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pyembree2
|
|
9
|
+
from numpy.typing import ArrayLike
|
|
10
|
+
from plyfile import PlyData, PlyElement
|
|
11
|
+
from scipy.sparse import coo_array, csr_array, diags
|
|
12
|
+
from scipy.sparse.csgraph import connected_components
|
|
13
|
+
from scipy.sparse.linalg import eigsh, spsolve
|
|
14
|
+
from scipy.spatial import ConvexHull
|
|
15
|
+
from trafolib.trafo3d import Trafo3d
|
|
16
|
+
|
|
17
|
+
from primlib.point_cloud import PointCloud
|
|
18
|
+
from primlib.utils import scalar_to_bicolor, scalar_to_color
|
|
19
|
+
from primlib.vertices import Vertices
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TriangleMesh(Vertices):
|
|
23
|
+
"""
|
|
24
|
+
A class representing a surface by a mesh of triangles.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
__slots__ = (
|
|
28
|
+
"_face_colors",
|
|
29
|
+
"_face_normals",
|
|
30
|
+
"_faces",
|
|
31
|
+
"_vertex_adjacency",
|
|
32
|
+
"_vertex_face_incidence",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
vertices: ArrayLike | None = None,
|
|
38
|
+
faces: ArrayLike | None = None,
|
|
39
|
+
vertex_normals: ArrayLike | None = None,
|
|
40
|
+
face_normals: ArrayLike | None = None,
|
|
41
|
+
vertex_colors: ArrayLike | None = None,
|
|
42
|
+
face_colors: ArrayLike | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Constructor
|
|
45
|
+
|
|
46
|
+
:param vertices: Vertices of the triangle mesh, shape (n, 3)
|
|
47
|
+
:param faces: Faces of the triangle mesh as vertex indices,
|
|
48
|
+
shape (m, 3), integer type
|
|
49
|
+
:param vertex_normals: Normals for each vertex, shape (n, 3), normalized
|
|
50
|
+
:param face_normals: Normals for each face, shape (m, 3), normalized
|
|
51
|
+
:param vertex_colors: RGB colors for each vertex, shape (n, 3),
|
|
52
|
+
values [0.0..1.0]
|
|
53
|
+
:param face_colors: RGB colors for each face, shape (m, 3), values [0.0..1.0]
|
|
54
|
+
"""
|
|
55
|
+
super().__init__(
|
|
56
|
+
vertices=vertices, normals=vertex_normals, colors=vertex_colors
|
|
57
|
+
)
|
|
58
|
+
# Define types and initialize
|
|
59
|
+
self._faces: np.ndarray = np.zeros((0, 3), dtype=int)
|
|
60
|
+
self._face_normals: np.ndarray = np.zeros((0, 3))
|
|
61
|
+
self._face_colors: np.ndarray = np.zeros((0, 3))
|
|
62
|
+
self._vertex_adjacency: csr_array | None = None
|
|
63
|
+
self._vertex_face_incidence: csr_array | None = None
|
|
64
|
+
# Initialize using parameters and setters
|
|
65
|
+
self.faces = faces
|
|
66
|
+
self.face_normals = face_normals
|
|
67
|
+
self.face_colors = face_colors
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def num_vertices(self) -> int:
|
|
71
|
+
"""Number of vertices of the triangle mesh
|
|
72
|
+
:returns: Number of vertices
|
|
73
|
+
"""
|
|
74
|
+
return self._vertices.shape[0]
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def num_faces(self) -> int:
|
|
78
|
+
"""Number of faces of the triangle mesh
|
|
79
|
+
:returns: Number of faces
|
|
80
|
+
"""
|
|
81
|
+
return self._faces.shape[0]
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def empty(self) -> bool:
|
|
85
|
+
"""Check if triangle mesh is empty
|
|
86
|
+
:returns: True if triangle mesh has no vertices and no faces
|
|
87
|
+
"""
|
|
88
|
+
return self.num_vertices == 0 and self.num_faces == 0
|
|
89
|
+
|
|
90
|
+
@override
|
|
91
|
+
def __str__(self) -> str:
|
|
92
|
+
"""Get string representation of triangle mesh
|
|
93
|
+
:returns: String representation
|
|
94
|
+
"""
|
|
95
|
+
if self.empty:
|
|
96
|
+
return "TriangleMesh(empty)"
|
|
97
|
+
pmin = self.min()
|
|
98
|
+
pmax = self.max()
|
|
99
|
+
result = (
|
|
100
|
+
f"TriangleMesh({self.num_vertices:_} vertices: "
|
|
101
|
+
+ f"X[{pmin[0]:.3f}..{pmax[0]:.3f}], "
|
|
102
|
+
+ f"Y[{pmin[1]:.3f}..{pmax[1]:.3f}], "
|
|
103
|
+
+ f"Z[{pmin[2]:.3f}..{pmax[2]:.3f}]"
|
|
104
|
+
)
|
|
105
|
+
if self.has_normals:
|
|
106
|
+
result += ", vertex normals"
|
|
107
|
+
if self.has_colors:
|
|
108
|
+
result += ", vertex colors"
|
|
109
|
+
result += f", {self.num_faces:_} faces"
|
|
110
|
+
if self.has_face_normals:
|
|
111
|
+
result += ", face normals"
|
|
112
|
+
if self.has_face_colors:
|
|
113
|
+
result += ", face colors"
|
|
114
|
+
result += ")"
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
@override
|
|
118
|
+
def __eq__(self, other: object) -> bool:
|
|
119
|
+
"""Check two triangle meshes for equality.
|
|
120
|
+
|
|
121
|
+
:param other: Other object
|
|
122
|
+
:returns: True if triangle meshes are equal
|
|
123
|
+
"""
|
|
124
|
+
if not isinstance(other, TriangleMesh):
|
|
125
|
+
return NotImplemented
|
|
126
|
+
return (
|
|
127
|
+
super().__eq__(other)
|
|
128
|
+
# Faces
|
|
129
|
+
and self._faces.shape == other._faces.shape
|
|
130
|
+
and np.array_equal(self._faces, other._faces)
|
|
131
|
+
# Face normals
|
|
132
|
+
and self._face_normals.shape == other._face_normals.shape
|
|
133
|
+
and np.allclose(self._face_normals, other._face_normals)
|
|
134
|
+
# Face colors
|
|
135
|
+
and self._face_colors.shape == other._face_colors.shape
|
|
136
|
+
and np.allclose(
|
|
137
|
+
self._face_colors,
|
|
138
|
+
other._face_colors,
|
|
139
|
+
atol=1.0 / 255.0,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
@override
|
|
144
|
+
def __ne__(self, other: object) -> bool:
|
|
145
|
+
"""Check two triangle meshes for inequality
|
|
146
|
+
|
|
147
|
+
:param other: Other object
|
|
148
|
+
:returns: True, if triangle meshes are not equal
|
|
149
|
+
"""
|
|
150
|
+
return not self.__eq__(other)
|
|
151
|
+
|
|
152
|
+
@override
|
|
153
|
+
def __iadd__(self, other: Vertices) -> Self:
|
|
154
|
+
"""Concatenate another triangle mesh in-place
|
|
155
|
+
|
|
156
|
+
:param other: Other triangle mesh
|
|
157
|
+
:returns: This triangle mesh (self)
|
|
158
|
+
"""
|
|
159
|
+
if not isinstance(other, TriangleMesh):
|
|
160
|
+
raise TypeError("Can only append another TriangleMesh")
|
|
161
|
+
if (
|
|
162
|
+
self.has_face_normals != other.has_face_normals
|
|
163
|
+
or self.has_face_colors != other.has_face_colors
|
|
164
|
+
):
|
|
165
|
+
raise ValueError("Both meshes must have the same properties")
|
|
166
|
+
# Append vertices and vertex properties
|
|
167
|
+
vertex_offset = self.num_vertices
|
|
168
|
+
super().__iadd__(other)
|
|
169
|
+
# Append faces, adjusting vertex indices
|
|
170
|
+
self._faces = np.vstack((self._faces, other._faces + vertex_offset))
|
|
171
|
+
# Append face normals
|
|
172
|
+
if self.has_face_normals:
|
|
173
|
+
self._face_normals = np.vstack((self._face_normals, other._face_normals))
|
|
174
|
+
# Append face colors
|
|
175
|
+
if self.has_face_colors:
|
|
176
|
+
self._face_colors = np.vstack((self._face_colors, other._face_colors))
|
|
177
|
+
return self
|
|
178
|
+
|
|
179
|
+
@override
|
|
180
|
+
def __add__(self, other: Vertices) -> Self:
|
|
181
|
+
"""Concatenate two triangle meshes
|
|
182
|
+
|
|
183
|
+
:param other: Other triangle mesh
|
|
184
|
+
:returns: New triangle mesh
|
|
185
|
+
"""
|
|
186
|
+
result = self.copy()
|
|
187
|
+
result += other
|
|
188
|
+
return result
|
|
189
|
+
|
|
190
|
+
@override
|
|
191
|
+
def copy(self) -> Self:
|
|
192
|
+
"""Get copy of triangle mesh
|
|
193
|
+
|
|
194
|
+
:returns: Copy
|
|
195
|
+
"""
|
|
196
|
+
return copy.deepcopy(self)
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def faces(self) -> np.ndarray:
|
|
200
|
+
"""Get faces
|
|
201
|
+
|
|
202
|
+
Faces are 3-tuples of indices of type integer referencing vertices;
|
|
203
|
+
for example: the face (3, 7, 3) is a triangle consisting of the
|
|
204
|
+
vertices 3, 7 and 3.
|
|
205
|
+
|
|
206
|
+
:returns: Faces, shape (n, 3), type int
|
|
207
|
+
"""
|
|
208
|
+
return self._faces
|
|
209
|
+
|
|
210
|
+
@faces.setter
|
|
211
|
+
def faces(self, value: ArrayLike | None) -> None:
|
|
212
|
+
"""Set faces
|
|
213
|
+
|
|
214
|
+
Faces are 3-tuples of indices of type integer referencing vertices;
|
|
215
|
+
for example: the face (3, 7, 3) is a triangle consisting of the
|
|
216
|
+
vertices 3, 7 and 3.
|
|
217
|
+
|
|
218
|
+
If there are face normals or face colors defined in the triangle
|
|
219
|
+
mesh, then the number of faces has to match their number.
|
|
220
|
+
|
|
221
|
+
:param value: Faces, shape (n, 3), type int
|
|
222
|
+
"""
|
|
223
|
+
if value is None:
|
|
224
|
+
value = np.zeros((0, 3), dtype=int)
|
|
225
|
+
else:
|
|
226
|
+
value = np.asarray(value, dtype=int)
|
|
227
|
+
if value.ndim != 2 or value.shape[1] != 3:
|
|
228
|
+
raise Exception("Provide faces of shape (n, 3)")
|
|
229
|
+
if self.has_face_normals and value.shape[0] != self._face_normals.shape[0]:
|
|
230
|
+
raise Exception(
|
|
231
|
+
"Mesh already has face normals, provide proper amount of faces"
|
|
232
|
+
)
|
|
233
|
+
if self.has_face_colors and value.shape[0] != self._face_colors.shape[0]:
|
|
234
|
+
raise Exception(
|
|
235
|
+
"Mesh already has face colors, provide proper amount of faces"
|
|
236
|
+
)
|
|
237
|
+
unique_indices = np.unique(value)
|
|
238
|
+
if unique_indices.size > 0 and (
|
|
239
|
+
unique_indices[0] < 0 or unique_indices[-1] >= self.num_vertices
|
|
240
|
+
):
|
|
241
|
+
logging.warning("Some faces reference invalid vertices.")
|
|
242
|
+
if unique_indices.size != self.num_vertices:
|
|
243
|
+
logging.warning("Some vertices are not referenced by faces.")
|
|
244
|
+
self._faces = value
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def face_normals(self) -> np.ndarray:
|
|
248
|
+
"""Get face normals
|
|
249
|
+
|
|
250
|
+
Face normals are 3d vectors that are perpendicular to the respective
|
|
251
|
+
face/triangle. They are normalized, i.e. they have a length of 1.0.
|
|
252
|
+
|
|
253
|
+
:returns: Face normals, shape (n, 3)
|
|
254
|
+
"""
|
|
255
|
+
return self._face_normals
|
|
256
|
+
|
|
257
|
+
@face_normals.setter
|
|
258
|
+
def face_normals(self, value: ArrayLike | None) -> None:
|
|
259
|
+
"""Set face normals
|
|
260
|
+
|
|
261
|
+
If there are faces defined in the triangle mesh, then the number
|
|
262
|
+
of face normals has to match the number of faces.
|
|
263
|
+
|
|
264
|
+
Face normals should have a length > 0, otherwise an exception is
|
|
265
|
+
thrown. Face normals should have unit length. If not, normals will
|
|
266
|
+
be normalized and a warning is given.
|
|
267
|
+
|
|
268
|
+
:param value: Face normals, shape (n, 3)
|
|
269
|
+
"""
|
|
270
|
+
if value is None:
|
|
271
|
+
value = np.zeros((0, 3))
|
|
272
|
+
else:
|
|
273
|
+
value = np.asarray(value, dtype=float)
|
|
274
|
+
if value.ndim != 2 or value.shape[1] != 3:
|
|
275
|
+
raise Exception("Provide normals of shape (n, 3)")
|
|
276
|
+
if value.shape[0] > 0 and value.shape[0] != self.num_faces:
|
|
277
|
+
raise Exception("Provide as many face normals as faces")
|
|
278
|
+
lengths = np.sqrt(value[:, 0] ** 2 + value[:, 1] ** 2 + value[:, 2] ** 2)
|
|
279
|
+
if np.any(np.isclose(lengths, 0.0)):
|
|
280
|
+
raise Exception("Some normals have zero lengths")
|
|
281
|
+
if not np.allclose(lengths, 1.0):
|
|
282
|
+
logging.warning("Some normals are not normalized. Normalizing...")
|
|
283
|
+
value = value / lengths[:, None]
|
|
284
|
+
self._face_normals = value
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def has_face_normals(self) -> bool:
|
|
288
|
+
"""Check if triangle mesh has face normals
|
|
289
|
+
|
|
290
|
+
:returns: True, if triangle mesh has face normals
|
|
291
|
+
"""
|
|
292
|
+
return self._face_normals.shape[0] > 0
|
|
293
|
+
|
|
294
|
+
@property
|
|
295
|
+
def face_colors(self) -> np.ndarray:
|
|
296
|
+
"""Get face colors
|
|
297
|
+
|
|
298
|
+
Color are 3-tuples of RGB values in range [0..1] of type float.
|
|
299
|
+
|
|
300
|
+
:returns: Face colors, shape (n, 3)
|
|
301
|
+
"""
|
|
302
|
+
return self._face_colors
|
|
303
|
+
|
|
304
|
+
@face_colors.setter
|
|
305
|
+
def face_colors(self, value: ArrayLike | None) -> None:
|
|
306
|
+
"""Set face colors
|
|
307
|
+
|
|
308
|
+
If there are faces defined in the triangle mesh, then the number
|
|
309
|
+
of face colors has to match the number of faces.
|
|
310
|
+
|
|
311
|
+
Face colors are RGB values in range [0..1]. If values outside that
|
|
312
|
+
range are provided, values will be clipped and a warning is given.
|
|
313
|
+
|
|
314
|
+
:param value: Face colors, shape (n, 3)
|
|
315
|
+
"""
|
|
316
|
+
if value is None:
|
|
317
|
+
face_colors = np.zeros((0, 3))
|
|
318
|
+
else:
|
|
319
|
+
face_colors = np.asarray(value, dtype=float)
|
|
320
|
+
if face_colors.ndim != 2 or face_colors.shape[1] != 3:
|
|
321
|
+
raise ValueError("Provide face colors of shape (n, 3)")
|
|
322
|
+
if face_colors.shape[0] > 0 and face_colors.shape[0] != self.num_faces:
|
|
323
|
+
raise ValueError("Provide as many face colors as faces")
|
|
324
|
+
if not np.all(np.logical_and(face_colors >= 0.0, face_colors <= 1.0)):
|
|
325
|
+
logging.warning("Some colors are out of bounds. Clipping to [0,1] ...")
|
|
326
|
+
face_colors = np.clip(face_colors, 0.0, 1.0)
|
|
327
|
+
self._face_colors = face_colors
|
|
328
|
+
|
|
329
|
+
@property
|
|
330
|
+
def has_face_colors(self) -> bool:
|
|
331
|
+
"""Check if triangle mesh has face colors
|
|
332
|
+
|
|
333
|
+
:returns: True, if triangle mesh has face colors
|
|
334
|
+
"""
|
|
335
|
+
return self._face_colors.shape[0] > 0
|
|
336
|
+
|
|
337
|
+
@staticmethod
|
|
338
|
+
def _load_face_data(
|
|
339
|
+
plydata: PlyData,
|
|
340
|
+
) -> tuple[np.ndarray | None, np.ndarray | None, np.ndarray | None]:
|
|
341
|
+
"""Helper method to read face data
|
|
342
|
+
|
|
343
|
+
:param plydata: PlyData
|
|
344
|
+
:returns: Faces, face normals and face colors
|
|
345
|
+
"""
|
|
346
|
+
face_data = plydata["face"]
|
|
347
|
+
properties = {p.name: p.val_dtype for p in face_data.properties}
|
|
348
|
+
# Faces
|
|
349
|
+
if "vertex_indices" in properties:
|
|
350
|
+
face_index_key = "vertex_indices"
|
|
351
|
+
elif "vertex_index" in properties:
|
|
352
|
+
face_index_key = "vertex_index"
|
|
353
|
+
else:
|
|
354
|
+
raise Exception("Unable to find faces in triangle mesh")
|
|
355
|
+
faces = np.vstack([np.asarray(f) for f in face_data[face_index_key]]).astype(
|
|
356
|
+
int
|
|
357
|
+
)
|
|
358
|
+
# Face normals (if existing)
|
|
359
|
+
face_normals = None
|
|
360
|
+
face_normal_keys = ["nx", "ny", "nz"]
|
|
361
|
+
if all(k in properties for k in face_normal_keys):
|
|
362
|
+
face_normals = (
|
|
363
|
+
np.vstack([face_data["nx"], face_data["ny"], face_data["nz"]])
|
|
364
|
+
.astype(float)
|
|
365
|
+
.T
|
|
366
|
+
)
|
|
367
|
+
# Face colors (if existing)
|
|
368
|
+
face_colors = None
|
|
369
|
+
color_keys_rgb = ["red", "green", "blue"]
|
|
370
|
+
if all(k in properties for k in color_keys_rgb):
|
|
371
|
+
assert properties["red"] == "u1"
|
|
372
|
+
face_colors = np.vstack(
|
|
373
|
+
[face_data["red"], face_data["green"], face_data["blue"]]
|
|
374
|
+
).T
|
|
375
|
+
face_colors = face_colors.astype(float) / 255.0
|
|
376
|
+
return faces, face_normals, face_colors
|
|
377
|
+
|
|
378
|
+
def _save_face_data(self) -> PlyElement:
|
|
379
|
+
"""Helper method to save face data"""
|
|
380
|
+
# Collect dtypes
|
|
381
|
+
dtypes: list[tuple[str, str] | tuple[str, str, tuple[int, ...]]] = [
|
|
382
|
+
("vertex_indices", "i4", (3,))
|
|
383
|
+
]
|
|
384
|
+
if self.has_face_normals:
|
|
385
|
+
dtypes.extend([("nx", "f8"), ("ny", "f8"), ("nz", "f8")])
|
|
386
|
+
if self.has_face_colors:
|
|
387
|
+
dtypes.extend([("red", "u1"), ("green", "u1"), ("blue", "u1")])
|
|
388
|
+
# Collect data
|
|
389
|
+
element_data = np.empty(self._faces.shape[0], dtype=dtypes)
|
|
390
|
+
element_data["vertex_indices"] = self._faces
|
|
391
|
+
if self.has_face_normals:
|
|
392
|
+
element_data["nx"] = self._face_normals[:, 0]
|
|
393
|
+
element_data["ny"] = self._face_normals[:, 1]
|
|
394
|
+
element_data["nz"] = self._face_normals[:, 2]
|
|
395
|
+
if self.has_face_colors:
|
|
396
|
+
element_data["red"] = np.round(self._face_colors[:, 0] * 255.0).astype(
|
|
397
|
+
np.uint8
|
|
398
|
+
)
|
|
399
|
+
element_data["green"] = np.round(self._face_colors[:, 1] * 255.0).astype(
|
|
400
|
+
np.uint8
|
|
401
|
+
)
|
|
402
|
+
element_data["blue"] = np.round(self._face_colors[:, 2] * 255.0).astype(
|
|
403
|
+
np.uint8
|
|
404
|
+
)
|
|
405
|
+
return PlyElement.describe(element_data, "face")
|
|
406
|
+
|
|
407
|
+
@classmethod
|
|
408
|
+
@override
|
|
409
|
+
def from_ply(cls, path: str | os.PathLike) -> "TriangleMesh":
|
|
410
|
+
"""Create triangle mesh from PLY file
|
|
411
|
+
|
|
412
|
+
:param path: Path to PLY file to read
|
|
413
|
+
:returns: Resulting triangle mesh
|
|
414
|
+
"""
|
|
415
|
+
plydata = Vertices._read_ply_data(path)
|
|
416
|
+
vertices, normals, colors = Vertices._load_vertex_data(plydata)
|
|
417
|
+
faces, face_normals, face_colors = TriangleMesh._load_face_data(plydata)
|
|
418
|
+
return cls(
|
|
419
|
+
vertices=vertices,
|
|
420
|
+
faces=faces,
|
|
421
|
+
vertex_normals=normals,
|
|
422
|
+
face_normals=face_normals,
|
|
423
|
+
vertex_colors=colors,
|
|
424
|
+
face_colors=face_colors,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
@override
|
|
428
|
+
def save_ply(self, path: str | os.PathLike) -> None:
|
|
429
|
+
"""Save triangle mesh to PLY file
|
|
430
|
+
|
|
431
|
+
:param path: Path to PLY file to write
|
|
432
|
+
"""
|
|
433
|
+
vertex_element = self._save_vertex_data()
|
|
434
|
+
face_element = self._save_face_data()
|
|
435
|
+
Vertices._write_ply_data(path, [vertex_element, face_element])
|
|
436
|
+
|
|
437
|
+
@override
|
|
438
|
+
def transform(self, trafo: Trafo3d) -> None:
|
|
439
|
+
"""Transform triangle mesh by rigid transformation
|
|
440
|
+
|
|
441
|
+
:param trafo: Transformation
|
|
442
|
+
"""
|
|
443
|
+
super().transform(trafo)
|
|
444
|
+
if self.has_face_normals:
|
|
445
|
+
self._face_normals = self._face_normals @ trafo.rotation_matrix.T
|
|
446
|
+
|
|
447
|
+
@classmethod
|
|
448
|
+
def create_triangle(cls, colorize: bool = False) -> "TriangleMesh":
|
|
449
|
+
"""Create triangle mesh consisting of a single triangle
|
|
450
|
+
|
|
451
|
+
:param colorize: If true, the triangle has different
|
|
452
|
+
vertex colors for each vertex
|
|
453
|
+
:returns: Resulting triangle mesh
|
|
454
|
+
"""
|
|
455
|
+
vertices = np.array(
|
|
456
|
+
(
|
|
457
|
+
(1.0, 0.0, 0.0),
|
|
458
|
+
(0.0, 1.0, 0.0),
|
|
459
|
+
(0.0, 0.0, 0.0),
|
|
460
|
+
)
|
|
461
|
+
)
|
|
462
|
+
faces = np.array(((0, 1, 2),), dtype=int)
|
|
463
|
+
if colorize:
|
|
464
|
+
vertex_colors = np.array(
|
|
465
|
+
(
|
|
466
|
+
(1.0, 0.0, 0.0),
|
|
467
|
+
(0.0, 1.0, 0.0),
|
|
468
|
+
(0.0, 0.0, 1.0),
|
|
469
|
+
)
|
|
470
|
+
)
|
|
471
|
+
else:
|
|
472
|
+
vertex_colors = None
|
|
473
|
+
return TriangleMesh(vertices=vertices, faces=faces, vertex_colors=vertex_colors)
|
|
474
|
+
|
|
475
|
+
@classmethod
|
|
476
|
+
def create_rectangle(
|
|
477
|
+
cls,
|
|
478
|
+
scale: float | ArrayLike = 1.0,
|
|
479
|
+
resolution: int | ArrayLike = (2, 2),
|
|
480
|
+
) -> "TriangleMesh":
|
|
481
|
+
"""Create a rectangular triangle mesh
|
|
482
|
+
|
|
483
|
+
Rectangle is located in X/Y plane and starts at (0.0, 0.0, 0.0) and
|
|
484
|
+
extends to (scale, scale, 0.0).
|
|
485
|
+
|
|
486
|
+
Default resolution is (2, 2), which creates a rectangle just consisting
|
|
487
|
+
of 4 vertices and 2 faces/triangles.
|
|
488
|
+
|
|
489
|
+
:param scale: Scale of rectangle for X and Y as single float or 2d
|
|
490
|
+
:param resolution: Number of points in X and Y
|
|
491
|
+
:returns: Resulting triangle mesh
|
|
492
|
+
"""
|
|
493
|
+
if isinstance(scale, float):
|
|
494
|
+
scale = np.array((scale, scale))
|
|
495
|
+
else:
|
|
496
|
+
scale = np.asarray(scale)
|
|
497
|
+
if scale.size != 2:
|
|
498
|
+
raise Exception("Provide proper scale.")
|
|
499
|
+
if isinstance(resolution, int):
|
|
500
|
+
resolution = np.array((resolution, resolution), dtype=int)
|
|
501
|
+
else:
|
|
502
|
+
resolution = np.asarray(resolution, dtype=int)
|
|
503
|
+
if resolution.size != 2:
|
|
504
|
+
raise Exception("Provide proper resolution.")
|
|
505
|
+
# Generate vertices
|
|
506
|
+
xs = np.linspace(0.0, 1.0, resolution[0])
|
|
507
|
+
ys = np.linspace(0.0, 1.0, resolution[1])
|
|
508
|
+
xx, yy = np.meshgrid(xs, ys, indexing="xy")
|
|
509
|
+
vertices = np.empty((resolution[0] * resolution[1], 3))
|
|
510
|
+
vertices[:, 0] = scale[0] * xx.flatten()
|
|
511
|
+
vertices[:, 1] = scale[1] * yy.flatten()
|
|
512
|
+
vertices[:, 2] = 0.0
|
|
513
|
+
# Generate faces
|
|
514
|
+
i = np.arange(resolution[0] - 1)
|
|
515
|
+
j = np.arange(resolution[1] - 1)
|
|
516
|
+
ii, jj = np.meshgrid(i, j, indexing="xy")
|
|
517
|
+
base = jj.flatten() * resolution[0] + ii.flatten()
|
|
518
|
+
v0 = base
|
|
519
|
+
v1 = base + 1
|
|
520
|
+
v2 = base + resolution[0]
|
|
521
|
+
v3 = base + resolution[0] + 1
|
|
522
|
+
tri_bottom_left = np.stack([v0, v1, v2], axis=-1)
|
|
523
|
+
tri_top_right = np.stack([v1, v3, v2], axis=-1)
|
|
524
|
+
faces = np.vstack([tri_bottom_left, tri_top_right])
|
|
525
|
+
# Normals
|
|
526
|
+
vertex_normals = np.tile((0.0, 0.0, 1.0), (vertices.shape[0], 1))
|
|
527
|
+
face_normals = np.tile((0.0, 0.0, 1.0), (faces.shape[0], 1))
|
|
528
|
+
return TriangleMesh(
|
|
529
|
+
vertices=vertices,
|
|
530
|
+
faces=faces,
|
|
531
|
+
vertex_normals=vertex_normals,
|
|
532
|
+
face_normals=face_normals,
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
@classmethod
|
|
536
|
+
def create_function(
|
|
537
|
+
cls,
|
|
538
|
+
fun: Callable[[ArrayLike], ArrayLike],
|
|
539
|
+
scale: float | ArrayLike = 1.0,
|
|
540
|
+
resolution: int | ArrayLike = (2, 2),
|
|
541
|
+
) -> "TriangleMesh":
|
|
542
|
+
"""Create a rectangular triangle mesh in X/Y with Z being
|
|
543
|
+
determined by a function
|
|
544
|
+
|
|
545
|
+
Example to create nice sinus-shaped bump on a rectangle of shape (1.0, 1.0):
|
|
546
|
+
|
|
547
|
+
bump = TriangleMesh.create_function(
|
|
548
|
+
lambda xy: np.sin(np.pi * xy[:, 0]) * np.sin(np.pi * xy[:, 1]),
|
|
549
|
+
scale=1.0,
|
|
550
|
+
resolution=(20, 20),
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
:param fun: Function, input is xy values of shape (n, 2),
|
|
554
|
+
output z values shape (n,)
|
|
555
|
+
:param scale: Scale of rectangle for X and Y as single float or 2d
|
|
556
|
+
:param resolution: Number of points in X and Y
|
|
557
|
+
:returns: Resulting triangle mesh
|
|
558
|
+
"""
|
|
559
|
+
result = cls.create_rectangle(scale, resolution)
|
|
560
|
+
result._vertices[:, 2] = fun(result._vertices[:, :2])
|
|
561
|
+
result.compute_vertex_normals()
|
|
562
|
+
return result
|
|
563
|
+
|
|
564
|
+
@classmethod
|
|
565
|
+
def create_image(
|
|
566
|
+
cls, image: np.ndarray, pixel_sizes_mm: tuple[float, float] = (1.0, 1.0)
|
|
567
|
+
) -> "TriangleMesh":
|
|
568
|
+
"""Create a rectangular triangle mesh displaying an image
|
|
569
|
+
|
|
570
|
+
According to OpenCV convention, X is to the right, Y is down and Z
|
|
571
|
+
looks away from the viewer of we look at the image; so in contrast
|
|
572
|
+
to create_rectangle, we look at the "backside" of the image; so we
|
|
573
|
+
have to re-order the faces and fix all normals vectors...
|
|
574
|
+
|
|
575
|
+
:param image: RGB image, shape (height, width, 3)
|
|
576
|
+
:param pixel_sizes_mm: Size of pixels in X/Y in unit mm
|
|
577
|
+
:returns: Resulting triangle mesh
|
|
578
|
+
"""
|
|
579
|
+
if image.ndim != 3 or image.shape[2] != 3:
|
|
580
|
+
raise Exception("Provide image of shape (n, m, 3)")
|
|
581
|
+
shape = np.array(image.shape)[[1, 0]]
|
|
582
|
+
result = cls.create_rectangle(pixel_sizes_mm * shape, shape + 1)
|
|
583
|
+
result.face_colors = np.vstack((image.reshape((-1, 3)), image.reshape((-1, 3))))
|
|
584
|
+
result.faces[:, [0, 1]] = result.faces[:, [1, 0]]
|
|
585
|
+
result.normals = np.tile((0.0, 0.0, -1.0), (result.num_vertices, 1))
|
|
586
|
+
result.face_normals = np.tile((0.0, 0.0, -1.0), (result.num_faces, 1))
|
|
587
|
+
return result
|
|
588
|
+
|
|
589
|
+
@classmethod
|
|
590
|
+
def create_box(cls, scale: float | ArrayLike = 1.0) -> "TriangleMesh":
|
|
591
|
+
"""Create a box shaped triangle mesh
|
|
592
|
+
|
|
593
|
+
Box has 8 vertices, 6 faces with 2 triangles each, thats 12 mesh faces.
|
|
594
|
+
Box starts at (0, 0, 0) and extends to (scale, scale, scale).
|
|
595
|
+
|
|
596
|
+
:param scale: Scale of rectangle for X, Y and Z as single float or 3d
|
|
597
|
+
:returns: Resulting triangle mesh
|
|
598
|
+
"""
|
|
599
|
+
if isinstance(scale, float):
|
|
600
|
+
scale = np.array((scale, scale, scale))
|
|
601
|
+
else:
|
|
602
|
+
scale = np.asarray(scale)
|
|
603
|
+
if scale.size != 3:
|
|
604
|
+
raise Exception("Provide proper scale.")
|
|
605
|
+
vertices = scale * np.array(
|
|
606
|
+
[
|
|
607
|
+
[0.0, 0.0, 0.0], # 0: bottom-back-left
|
|
608
|
+
[1.0, 0.0, 0.0], # 1: bottom-back-right
|
|
609
|
+
[1.0, 1.0, 0.0], # 2: bottom-front-right
|
|
610
|
+
[0.0, 1.0, 0.0], # 3: bottom-front-left
|
|
611
|
+
[0.0, 0.0, 1.0], # 4: top-back-left
|
|
612
|
+
[1.0, 0.0, 1.0], # 5: top-back-right
|
|
613
|
+
[1.0, 1.0, 1.0], # 6: top-front-right
|
|
614
|
+
[0.0, 1.0, 1.0], # 7: top-front-left
|
|
615
|
+
],
|
|
616
|
+
dtype=float,
|
|
617
|
+
)
|
|
618
|
+
faces = np.array(
|
|
619
|
+
[
|
|
620
|
+
# Bottom face
|
|
621
|
+
[0, 2, 1],
|
|
622
|
+
[0, 3, 2],
|
|
623
|
+
# Top face
|
|
624
|
+
[4, 5, 6],
|
|
625
|
+
[4, 6, 7],
|
|
626
|
+
# Front face
|
|
627
|
+
[3, 7, 6],
|
|
628
|
+
[3, 6, 2],
|
|
629
|
+
# Back face
|
|
630
|
+
[0, 1, 5],
|
|
631
|
+
[0, 5, 4],
|
|
632
|
+
# Left face
|
|
633
|
+
[0, 4, 7],
|
|
634
|
+
[0, 7, 3],
|
|
635
|
+
# Right face
|
|
636
|
+
[1, 2, 6],
|
|
637
|
+
[1, 6, 5],
|
|
638
|
+
],
|
|
639
|
+
dtype=int,
|
|
640
|
+
)
|
|
641
|
+
result = cls(vertices=vertices, faces=faces)
|
|
642
|
+
result.compute_vertex_normals()
|
|
643
|
+
return result
|
|
644
|
+
|
|
645
|
+
@classmethod
|
|
646
|
+
def create_sphere(
|
|
647
|
+
cls, radius: float = 1.0, num_vertices: int = 500
|
|
648
|
+
) -> "TriangleMesh":
|
|
649
|
+
"""Create a sphere shaped triangle mesh
|
|
650
|
+
|
|
651
|
+
Usees Fibonacci sphere algorithm to create a number of points
|
|
652
|
+
evenly distributed on the surface of a sphere as vertices.
|
|
653
|
+
|
|
654
|
+
Center of sphere is at (0, 0, 0).
|
|
655
|
+
|
|
656
|
+
:param radius: Sphere radius
|
|
657
|
+
:param num_vertices: Number of vertices of sphere
|
|
658
|
+
:returns: Resulting triangle mesh
|
|
659
|
+
"""
|
|
660
|
+
indices = np.arange(0, num_vertices, dtype=float)
|
|
661
|
+
phi = (1.0 + np.sqrt(5.0)) / 2.0
|
|
662
|
+
z = 1.0 - (2.0 * indices + 1.0) / num_vertices
|
|
663
|
+
radii = np.sqrt(1.0 - z * z)
|
|
664
|
+
theta = 2.0 * np.pi * indices / phi
|
|
665
|
+
x = radii * np.cos(theta)
|
|
666
|
+
y = radii * np.sin(theta)
|
|
667
|
+
vertices = np.stack([x, y, z], axis=-1)
|
|
668
|
+
# Compute the convex hull of those points to triangulate the vertices
|
|
669
|
+
hull = ConvexHull(vertices)
|
|
670
|
+
faces = hull.simplices.copy()
|
|
671
|
+
# Compute face normals and check for orientation of normals;
|
|
672
|
+
# fix if necessary by re-ordering the vertex indices in the face
|
|
673
|
+
v0 = vertices[faces[:, 0]]
|
|
674
|
+
v1 = vertices[faces[:, 1]]
|
|
675
|
+
v2 = vertices[faces[:, 2]]
|
|
676
|
+
edge1 = v1 - v0
|
|
677
|
+
edge2 = v2 - v0
|
|
678
|
+
face_normals = np.cross(edge1, edge2)
|
|
679
|
+
face_centroids = (v0 + v1 + v2) / 3.0
|
|
680
|
+
orientations = np.sum(face_normals * face_centroids, axis=1)
|
|
681
|
+
flip_mask = orientations < 0
|
|
682
|
+
faces[flip_mask] = faces[flip_mask][:, [0, 2, 1]]
|
|
683
|
+
result = cls(vertices=radius * vertices, faces=faces)
|
|
684
|
+
result.compute_vertex_normals()
|
|
685
|
+
return result
|
|
686
|
+
|
|
687
|
+
@classmethod
|
|
688
|
+
def create_cone(
|
|
689
|
+
cls,
|
|
690
|
+
radius: float = 1.0,
|
|
691
|
+
height: float = 1.0,
|
|
692
|
+
resolution: int = 20,
|
|
693
|
+
split: int = 4,
|
|
694
|
+
) -> "TriangleMesh":
|
|
695
|
+
"""Create cone shaped triangle mesh
|
|
696
|
+
|
|
697
|
+
The bottom of the cone is a circle with center (0, 0, 0) in the X/Y plane,
|
|
698
|
+
the tip of the cone is at (0, 0, height).
|
|
699
|
+
|
|
700
|
+
The split is splits the cone into multiple sections in Z direction. That
|
|
701
|
+
helps avoiding long thin triangles which may cause visualization problems.
|
|
702
|
+
Increase split if your cone is very long: height >> radius.
|
|
703
|
+
|
|
704
|
+
:param radius: Radius of circle at the bottom
|
|
705
|
+
:param height: Height of cone in +Z direction
|
|
706
|
+
:param resolution: Number of intersections of circle and side
|
|
707
|
+
:param split: Number of intersections in Z direction
|
|
708
|
+
:returns: Resulting triangle mesh
|
|
709
|
+
"""
|
|
710
|
+
angles = np.linspace(0.0, 2 * np.pi, resolution + 1)[:-1]
|
|
711
|
+
# 1. Bottom Cap Center Vertex & Ring
|
|
712
|
+
bottom_center_idx = 0
|
|
713
|
+
bottom_ring_vertices = np.zeros((resolution, 3))
|
|
714
|
+
bottom_ring_vertices[:, 0] = radius * np.cos(angles)
|
|
715
|
+
bottom_ring_vertices[:, 1] = radius * np.sin(angles)
|
|
716
|
+
# 2. Lateral Surface Rings (from base to just below the tip)
|
|
717
|
+
lateral_vertices_list = [bottom_ring_vertices]
|
|
718
|
+
for s in range(1, split):
|
|
719
|
+
z = s * (height / split)
|
|
720
|
+
r = radius * (1.0 - s / split)
|
|
721
|
+
ring = np.zeros((resolution, 3))
|
|
722
|
+
ring[:, 0] = r * np.cos(angles)
|
|
723
|
+
ring[:, 1] = r * np.sin(angles)
|
|
724
|
+
ring[:, 2] = z
|
|
725
|
+
lateral_vertices_list.append(ring)
|
|
726
|
+
# Apex Vertex at the top
|
|
727
|
+
apex_vertex = np.array([[0.0, 0.0, height]])
|
|
728
|
+
# Combine all vertices
|
|
729
|
+
bottom_center_vertex = np.array([[0.0, 0.0, 0.0]])
|
|
730
|
+
vertices = np.vstack(
|
|
731
|
+
(bottom_center_vertex, *lateral_vertices_list, apex_vertex)
|
|
732
|
+
)
|
|
733
|
+
# Offset index helper for rings
|
|
734
|
+
# bottom_center_idx = 0
|
|
735
|
+
# ring s starts at: 1 + (s * resolution)
|
|
736
|
+
# apex vertex is at the very end
|
|
737
|
+
apex_idx = len(vertices) - 1
|
|
738
|
+
# 3. Faces
|
|
739
|
+
i = np.arange(resolution)
|
|
740
|
+
next_i = (i + 1) % resolution
|
|
741
|
+
# Bottom cap faces
|
|
742
|
+
bottom_faces = np.zeros((resolution, 3), dtype=int)
|
|
743
|
+
bottom_faces[:, 0] = bottom_center_idx
|
|
744
|
+
bottom_faces[:, 1] = 1 + next_i
|
|
745
|
+
bottom_faces[:, 2] = 1 + i
|
|
746
|
+
# Lateral faces between rings and up to the apex
|
|
747
|
+
lateral_faces_list = []
|
|
748
|
+
for s in range(split):
|
|
749
|
+
curr_ring_start = 1 + s * resolution
|
|
750
|
+
if s < split - 1:
|
|
751
|
+
next_ring_start = 1 + (s + 1) * resolution
|
|
752
|
+
# Quad split into 2 triangles for each resolution step
|
|
753
|
+
for j in range(resolution):
|
|
754
|
+
nxt = (j + 1) % resolution
|
|
755
|
+
v0 = curr_ring_start + j
|
|
756
|
+
v1 = curr_ring_start + nxt
|
|
757
|
+
v2 = next_ring_start + nxt
|
|
758
|
+
v3 = next_ring_start + j
|
|
759
|
+
lateral_faces_list.append([v0, v1, v2])
|
|
760
|
+
lateral_faces_list.append([v0, v2, v3])
|
|
761
|
+
else:
|
|
762
|
+
# Top section connecting the last ring to the apex
|
|
763
|
+
for j in range(resolution):
|
|
764
|
+
nxt = (j + 1) % resolution
|
|
765
|
+
v0 = curr_ring_start + j
|
|
766
|
+
v1 = curr_ring_start + nxt
|
|
767
|
+
lateral_faces_list.append([v0, v1, apex_idx])
|
|
768
|
+
lateral_faces = np.array(lateral_faces_list, dtype=int)
|
|
769
|
+
result = cls(
|
|
770
|
+
vertices=vertices,
|
|
771
|
+
faces=np.vstack((bottom_faces, lateral_faces)),
|
|
772
|
+
)
|
|
773
|
+
result.compute_vertex_normals()
|
|
774
|
+
return result
|
|
775
|
+
|
|
776
|
+
@classmethod
|
|
777
|
+
def create_cylinder(
|
|
778
|
+
cls,
|
|
779
|
+
radius: float = 1.0,
|
|
780
|
+
height: float = 1.0,
|
|
781
|
+
resolution: int = 20,
|
|
782
|
+
split: int = 4,
|
|
783
|
+
) -> "TriangleMesh":
|
|
784
|
+
"""Create cylinder shaped triangle mesh
|
|
785
|
+
|
|
786
|
+
The bottom of the cylinder is a circle with center (0, 0, 0) in the X/Y plane,
|
|
787
|
+
the top is a circle with center (0, 0, height) in the X/Y plane.
|
|
788
|
+
|
|
789
|
+
The split is splits the cylinder into multiple sections in Z direction. That
|
|
790
|
+
helps avoiding long thin triangles which may cause visualization problems.
|
|
791
|
+
Increase split if your cylinder is very long: height >> radius.
|
|
792
|
+
|
|
793
|
+
:param radius: Radius of circles at the bottom and top
|
|
794
|
+
:param height: Height of cylinder in +Z direction
|
|
795
|
+
:param resolution: Number of intersections of circle and sides
|
|
796
|
+
:param split: Number of intersections in Z direction
|
|
797
|
+
:returns: Resulting triangle mesh
|
|
798
|
+
"""
|
|
799
|
+
# Ensure split is at least 1
|
|
800
|
+
split = max(1, split)
|
|
801
|
+
# Vertices for all levels along the height
|
|
802
|
+
angles = np.linspace(0.0, 2 * np.pi, resolution + 1)[:-1]
|
|
803
|
+
z_coords = np.linspace(0, height, split + 1)
|
|
804
|
+
ring_vertices = np.zeros((resolution, 3))
|
|
805
|
+
ring_vertices[:, 0] = radius * np.cos(angles)
|
|
806
|
+
ring_vertices[:, 1] = radius * np.sin(angles)
|
|
807
|
+
all_vertices = []
|
|
808
|
+
for z in z_coords:
|
|
809
|
+
v = ring_vertices.copy()
|
|
810
|
+
v[:, 2] = z
|
|
811
|
+
all_vertices.append(v)
|
|
812
|
+
# Center vertices for bottom and top caps
|
|
813
|
+
center_vertices = np.array(
|
|
814
|
+
(
|
|
815
|
+
(0.0, 0.0, 0.0),
|
|
816
|
+
(0.0, 0.0, height),
|
|
817
|
+
)
|
|
818
|
+
)
|
|
819
|
+
all_vertices.append(center_vertices)
|
|
820
|
+
vertices = np.vstack(all_vertices)
|
|
821
|
+
# Faces
|
|
822
|
+
i = np.arange(resolution)
|
|
823
|
+
next_i = (i + 1) % resolution
|
|
824
|
+
side_faces = []
|
|
825
|
+
for s in range(split):
|
|
826
|
+
base_idx = s * resolution
|
|
827
|
+
next_base_idx = (s + 1) * resolution
|
|
828
|
+
side_faces1 = np.zeros((resolution, 3), dtype=int)
|
|
829
|
+
side_faces1[:, 0] = base_idx + i
|
|
830
|
+
side_faces1[:, 1] = base_idx + next_i
|
|
831
|
+
side_faces1[:, 2] = next_base_idx + next_i
|
|
832
|
+
side_faces2 = np.zeros((resolution, 3), dtype=int)
|
|
833
|
+
side_faces2[:, 0] = base_idx + i
|
|
834
|
+
side_faces2[:, 1] = next_base_idx + next_i
|
|
835
|
+
side_faces2[:, 2] = next_base_idx + i
|
|
836
|
+
side_faces.append(side_faces1)
|
|
837
|
+
side_faces.append(side_faces2)
|
|
838
|
+
side_faces_array = np.vstack(side_faces)
|
|
839
|
+
# Bottom and top cap faces
|
|
840
|
+
bottom_center_idx = (split + 1) * resolution
|
|
841
|
+
top_center_idx = (split + 1) * resolution + 1
|
|
842
|
+
bottom_faces = np.zeros((resolution, 3), dtype=int)
|
|
843
|
+
bottom_faces[:, 0] = bottom_center_idx
|
|
844
|
+
bottom_faces[:, 1] = next_i
|
|
845
|
+
bottom_faces[:, 2] = i
|
|
846
|
+
top_top_base = split * resolution
|
|
847
|
+
top_faces = np.zeros((resolution, 3), dtype=int)
|
|
848
|
+
top_faces[:, 0] = top_center_idx
|
|
849
|
+
top_faces[:, 1] = top_top_base + i
|
|
850
|
+
top_faces[:, 2] = top_top_base + next_i
|
|
851
|
+
result = cls(
|
|
852
|
+
vertices=vertices,
|
|
853
|
+
faces=np.vstack((side_faces_array, bottom_faces, top_faces)),
|
|
854
|
+
)
|
|
855
|
+
result.compute_vertex_normals()
|
|
856
|
+
return result
|
|
857
|
+
|
|
858
|
+
@classmethod
|
|
859
|
+
def create_arrow(
|
|
860
|
+
cls,
|
|
861
|
+
cylinder_radius: float = 1.0,
|
|
862
|
+
cone_radius: float = 1.5,
|
|
863
|
+
cylinder_height: float = 5.0,
|
|
864
|
+
cone_height: float = 4.0,
|
|
865
|
+
resolution: int = 20,
|
|
866
|
+
cylinder_split: int = 4,
|
|
867
|
+
cone_split: int = 1,
|
|
868
|
+
) -> "TriangleMesh":
|
|
869
|
+
"""Create coordinate axis shaped triangle mesh
|
|
870
|
+
|
|
871
|
+
The bottom of the coordinate axis is a cylinder, the top is a cone.
|
|
872
|
+
|
|
873
|
+
:param cylinder_radius: Radius of the cylinder
|
|
874
|
+
:param cone_radius: Radius of the cone
|
|
875
|
+
:param cylinder_height: Height of cylinder
|
|
876
|
+
:param cone_height: Height of cone
|
|
877
|
+
:param resolution: Number of intersection of cylinder and cone
|
|
878
|
+
:param cylinder_split: Number of intersections in Z direction for cylinder
|
|
879
|
+
:param cone_split: Number of intersections in Z direction for cone
|
|
880
|
+
:returns: Resulting triangle mesh
|
|
881
|
+
"""
|
|
882
|
+
cylinder = cls.create_cylinder(
|
|
883
|
+
cylinder_radius, cylinder_height, resolution, cylinder_split
|
|
884
|
+
)
|
|
885
|
+
cone = cls.create_cone(cone_radius, cone_height, resolution, cone_split)
|
|
886
|
+
cone.translate((0.0, 0.0, cylinder_height))
|
|
887
|
+
return cylinder + cone
|
|
888
|
+
|
|
889
|
+
@classmethod
|
|
890
|
+
def create_coordinate_frame(
|
|
891
|
+
cls, size: float = 1.0, pose: Trafo3d | None = None
|
|
892
|
+
) -> "TriangleMesh":
|
|
893
|
+
"""Create coordinate frame of a right-handed coordinate system
|
|
894
|
+
|
|
895
|
+
A single coordinate axis is an arrow that consists of a cylinder
|
|
896
|
+
with a cone as the tip. The three axes have colors red (X),
|
|
897
|
+
green (Y) and blue (Z).
|
|
898
|
+
|
|
899
|
+
:param size: Length of a single axis
|
|
900
|
+
:param pose: 6d pose of the coordinate system
|
|
901
|
+
:returns: Resulting triangle mesh
|
|
902
|
+
"""
|
|
903
|
+
cylinder_radius = size / 40.0
|
|
904
|
+
cone_radius = 1.5 * cylinder_radius
|
|
905
|
+
cylinder_height = 0.9 * size
|
|
906
|
+
cone_height = 0.1 * size
|
|
907
|
+
x_axis = cls.create_arrow(
|
|
908
|
+
cylinder_radius, cone_radius, cylinder_height, cone_height
|
|
909
|
+
)
|
|
910
|
+
x_axis.paint_uniform_color((1.0, 0.0, 0.0)) # red
|
|
911
|
+
x_axis.transform(Trafo3d(rpy=(0.0, np.pi / 2, 0.0)))
|
|
912
|
+
y_axis = cls.create_arrow(
|
|
913
|
+
cylinder_radius, cone_radius, cylinder_height, cone_height
|
|
914
|
+
)
|
|
915
|
+
y_axis.paint_uniform_color((0.0, 1.0, 0.0)) # green
|
|
916
|
+
y_axis.transform(Trafo3d(rpy=(-np.pi / 2, 0.0, 0.0)))
|
|
917
|
+
z_axis = cls.create_arrow(
|
|
918
|
+
cylinder_radius, cone_radius, cylinder_height, cone_height
|
|
919
|
+
)
|
|
920
|
+
z_axis.paint_uniform_color((0.0, 0.0, 1.0)) # blue
|
|
921
|
+
cs = x_axis + y_axis + z_axis
|
|
922
|
+
if pose is not None:
|
|
923
|
+
cs.transform(pose)
|
|
924
|
+
return cs
|
|
925
|
+
|
|
926
|
+
@classmethod
|
|
927
|
+
def create_from_point_cloud_poisson(
|
|
928
|
+
cls,
|
|
929
|
+
pcl: PointCloud,
|
|
930
|
+
depth: int = 8,
|
|
931
|
+
) -> "TriangleMesh":
|
|
932
|
+
raise NotImplementedError
|
|
933
|
+
|
|
934
|
+
def _select_by_vertices(self, inlier_mask_or_indices: np.ndarray) -> None:
|
|
935
|
+
"""Select part of a triangle mesh referenced by vertices, helper function
|
|
936
|
+
|
|
937
|
+
Selects vertices, (vertex) normals and (vertex) colors, the last
|
|
938
|
+
two if they are available. Fixes vertex indices in faces due to
|
|
939
|
+
removed vertex indices.
|
|
940
|
+
|
|
941
|
+
:param inlier_mask_or_indices: Either boolean mask or indices
|
|
942
|
+
"""
|
|
943
|
+
# Select vertices
|
|
944
|
+
self._vertices = self._vertices[inlier_mask_or_indices]
|
|
945
|
+
if self.has_normals:
|
|
946
|
+
self._normals = self._normals[inlier_mask_or_indices]
|
|
947
|
+
if self.has_colors:
|
|
948
|
+
self._colors = self._colors[inlier_mask_or_indices]
|
|
949
|
+
# Fix the vertex indexing in faces due to removed vertices
|
|
950
|
+
old_to_new_vertex_indices = np.cumsum(inlier_mask_or_indices) - 1
|
|
951
|
+
self._faces = old_to_new_vertex_indices[self._faces]
|
|
952
|
+
|
|
953
|
+
def select_by_vertices(self, inlier_mask_or_indices: np.ndarray) -> None:
|
|
954
|
+
"""Select part of a triangle mesh referenced by by vertices
|
|
955
|
+
|
|
956
|
+
Selects vertices, (vertex) normals and (vertex) colors, the last
|
|
957
|
+
two if they are available. Fixes vertex indices in faces due to
|
|
958
|
+
removed vertex indices. Removes faces that contained those vertices.
|
|
959
|
+
Removes unreferenced vertices because those faces were removed.
|
|
960
|
+
|
|
961
|
+
:param inlier_mask_or_indices: Either boolean mask or indices
|
|
962
|
+
"""
|
|
963
|
+
# If inlier_mask_or_indices is not a mask, convert it into one
|
|
964
|
+
if not isinstance(inlier_mask_or_indices[0], bool):
|
|
965
|
+
mask = np.zeros(self.num_vertices, dtype=bool)
|
|
966
|
+
mask[inlier_mask_or_indices] = True
|
|
967
|
+
inlier_mask_or_indices = mask
|
|
968
|
+
inlier_mask_faces = np.all(inlier_mask_or_indices[self._faces], axis=1)
|
|
969
|
+
# Remove vertices
|
|
970
|
+
self._select_by_vertices(inlier_mask_or_indices)
|
|
971
|
+
# Remove all faces that contained those vertices
|
|
972
|
+
self._select_by_faces(inlier_mask_faces)
|
|
973
|
+
# By removing those faces, there may be some unreferenced vertices
|
|
974
|
+
self.remove_unreferenced_vertices()
|
|
975
|
+
|
|
976
|
+
def get_selected_by_vertices(
|
|
977
|
+
self, inlier_mask_or_indices: np.ndarray
|
|
978
|
+
) -> "TriangleMesh":
|
|
979
|
+
"""Select part of a triangle mesh referenced by by vertices and
|
|
980
|
+
return as new triangle mesh
|
|
981
|
+
|
|
982
|
+
Selects vertices, (vertex) normals and (vertex) colors, the last
|
|
983
|
+
two if they are available. Fixes vertex indices in faces due to
|
|
984
|
+
removed vertex indices. Removes faces that contained those vertices.
|
|
985
|
+
Removes unreferenced vertices because those faces were removed.
|
|
986
|
+
|
|
987
|
+
:param inlier_mask_or_indices: Either boolean mask or indices
|
|
988
|
+
:returns: Resulting triangle mesh
|
|
989
|
+
"""
|
|
990
|
+
result = self.copy()
|
|
991
|
+
result.select_by_vertices(inlier_mask_or_indices)
|
|
992
|
+
return result
|
|
993
|
+
|
|
994
|
+
def _select_by_faces(self, inlier_mask_or_indices: np.ndarray) -> None:
|
|
995
|
+
"""Select part of a triangle mesh referenced by faces, helper function
|
|
996
|
+
|
|
997
|
+
Selects faces, face normals and faces colors, the last
|
|
998
|
+
two if they are available.
|
|
999
|
+
|
|
1000
|
+
:param inlier_mask_or_indices: Either boolean mask or indices
|
|
1001
|
+
"""
|
|
1002
|
+
self._faces = self._faces[inlier_mask_or_indices]
|
|
1003
|
+
if self.has_face_normals:
|
|
1004
|
+
self._face_normals = self._face_normals[inlier_mask_or_indices]
|
|
1005
|
+
if self.has_face_colors:
|
|
1006
|
+
self._face_colors = self._face_colors[inlier_mask_or_indices]
|
|
1007
|
+
|
|
1008
|
+
def select_by_faces(self, inlier_mask_or_indices: np.ndarray) -> None:
|
|
1009
|
+
"""Select part of a triangle mesh referenced by faces
|
|
1010
|
+
|
|
1011
|
+
Selects faces, face normals and faces colors, the last
|
|
1012
|
+
two if they are available. Removes unreferenced vertices
|
|
1013
|
+
because those faces were removed.
|
|
1014
|
+
|
|
1015
|
+
:param inlier_mask_or_indices: Either boolean mask or indices
|
|
1016
|
+
"""
|
|
1017
|
+
self._select_by_faces(inlier_mask_or_indices)
|
|
1018
|
+
self.remove_unreferenced_vertices()
|
|
1019
|
+
|
|
1020
|
+
def get_selected_by_faces(
|
|
1021
|
+
self, inlier_mask_or_indices: np.ndarray
|
|
1022
|
+
) -> "TriangleMesh":
|
|
1023
|
+
"""Select part of a triangle mesh referenced by faces and
|
|
1024
|
+
return as new triangle mesh
|
|
1025
|
+
|
|
1026
|
+
Selects faces, face normals and faces colors, the last
|
|
1027
|
+
two if they are available. Removes unreferenced vertices
|
|
1028
|
+
because those faces were removed.
|
|
1029
|
+
|
|
1030
|
+
:param inlier_mask_or_indices: Either boolean mask or indices
|
|
1031
|
+
:returns: Resulting triangle mesh
|
|
1032
|
+
"""
|
|
1033
|
+
result = self.copy()
|
|
1034
|
+
result.select_by_faces(inlier_mask_or_indices)
|
|
1035
|
+
return result
|
|
1036
|
+
|
|
1037
|
+
def remove_unreferenced_vertices(self) -> None:
|
|
1038
|
+
"""Removes unreferenced vertices
|
|
1039
|
+
|
|
1040
|
+
Removes vertices that are not referenced in faces
|
|
1041
|
+
"""
|
|
1042
|
+
unique_vertices = np.unique(self._faces)
|
|
1043
|
+
inlier_mask = np.zeros(self.num_vertices, dtype=bool)
|
|
1044
|
+
inlier_mask[unique_vertices] = True
|
|
1045
|
+
self._select_by_vertices(inlier_mask)
|
|
1046
|
+
|
|
1047
|
+
def remove_duplicate_vertices(self, tolerance: float = 1e-8) -> None:
|
|
1048
|
+
"""Merge duplicate or nearly identical vertices.
|
|
1049
|
+
|
|
1050
|
+
Vertices within the specified Euclidean distance are merged.
|
|
1051
|
+
Vertex attributes are averaged. Degenerate faces created by
|
|
1052
|
+
merging vertices are removed.
|
|
1053
|
+
|
|
1054
|
+
:param tolerance: Maximum distance between vertices to merge.
|
|
1055
|
+
"""
|
|
1056
|
+
if self.empty:
|
|
1057
|
+
return
|
|
1058
|
+
# Find vertex pairs within tolerance
|
|
1059
|
+
tree = self.kdtree()
|
|
1060
|
+
pairs = tree.query_pairs(tolerance, output_type="ndarray")
|
|
1061
|
+
if len(pairs) == 0:
|
|
1062
|
+
return
|
|
1063
|
+
# Group duplicate vertices using union-find
|
|
1064
|
+
parent = np.arange(self.num_vertices)
|
|
1065
|
+
|
|
1066
|
+
def find(x):
|
|
1067
|
+
while parent[x] != x:
|
|
1068
|
+
parent[x] = parent[parent[x]]
|
|
1069
|
+
x = parent[x]
|
|
1070
|
+
return x
|
|
1071
|
+
|
|
1072
|
+
for a, b in pairs:
|
|
1073
|
+
root_a = find(a)
|
|
1074
|
+
root_b = find(b)
|
|
1075
|
+
if root_a != root_b:
|
|
1076
|
+
parent[root_b] = root_a
|
|
1077
|
+
roots = np.array([find(i) for i in range(self.num_vertices)])
|
|
1078
|
+
# Determine unique vertices and their groups
|
|
1079
|
+
_, vertex_mapping = np.unique(roots, return_inverse=True)
|
|
1080
|
+
# Identify first occurrence of each vertex
|
|
1081
|
+
vertex_indices = np.unique(vertex_mapping, return_index=True)[1]
|
|
1082
|
+
# Average vertex attributes within each group
|
|
1083
|
+
n_unique = len(vertex_indices)
|
|
1084
|
+
counts = np.bincount(vertex_mapping)
|
|
1085
|
+
vertices_new = np.zeros((n_unique, 3))
|
|
1086
|
+
np.add.at(vertices_new, vertex_mapping, self._vertices)
|
|
1087
|
+
vertices_new /= counts[:, None]
|
|
1088
|
+
# Average vertex normals if available
|
|
1089
|
+
if self.has_normals:
|
|
1090
|
+
normals = np.zeros((n_unique, 3))
|
|
1091
|
+
np.add.at(normals, vertex_mapping, self._normals)
|
|
1092
|
+
normals /= counts[:, None]
|
|
1093
|
+
lengths = np.linalg.norm(normals, axis=1, keepdims=True)
|
|
1094
|
+
normals = np.divide(
|
|
1095
|
+
normals, lengths, out=np.zeros_like(normals), where=lengths > 0
|
|
1096
|
+
)
|
|
1097
|
+
self._normals = normals
|
|
1098
|
+
# Average vertex colors if available
|
|
1099
|
+
if self.has_colors:
|
|
1100
|
+
colors = np.zeros((n_unique, *self._colors.shape[1:]))
|
|
1101
|
+
np.add.at(colors, vertex_mapping, self._colors)
|
|
1102
|
+
colors /= counts.reshape((-1,) + (1,) * (colors.ndim - 1))
|
|
1103
|
+
self._colors = colors
|
|
1104
|
+
# Select unique vertices
|
|
1105
|
+
self._vertices = vertices_new
|
|
1106
|
+
# Update face indices
|
|
1107
|
+
self._faces = vertex_mapping[self._faces]
|
|
1108
|
+
# Remove degenerate faces
|
|
1109
|
+
valid_faces = (
|
|
1110
|
+
(self._faces[:, 0] != self._faces[:, 1])
|
|
1111
|
+
& (self._faces[:, 1] != self._faces[:, 2])
|
|
1112
|
+
& (self._faces[:, 2] != self._faces[:, 0])
|
|
1113
|
+
)
|
|
1114
|
+
self._select_by_faces(valid_faces)
|
|
1115
|
+
|
|
1116
|
+
def remove_duplicate_faces(self) -> None:
|
|
1117
|
+
"""Remove duplicate faces from the triangle mesh.
|
|
1118
|
+
|
|
1119
|
+
Faces are considered duplicates if they reference the same three
|
|
1120
|
+
vertices, regardless of vertex ordering.
|
|
1121
|
+
|
|
1122
|
+
Keeps the first occurrence of each unique face.
|
|
1123
|
+
"""
|
|
1124
|
+
if self.num_faces == 0:
|
|
1125
|
+
return
|
|
1126
|
+
# Sort vertex indices to obtain a canonical face representation
|
|
1127
|
+
sorted_faces = np.sort(self._faces, axis=1)
|
|
1128
|
+
# Find unique faces and retain their first occurrences
|
|
1129
|
+
_, indices = np.unique(sorted_faces, axis=0, return_index=True)
|
|
1130
|
+
# Create mask preserving the original face ordering
|
|
1131
|
+
face_mask = np.zeros(len(self._faces), dtype=bool)
|
|
1132
|
+
face_mask[indices] = True
|
|
1133
|
+
# Select unique faces and update associated face attributes
|
|
1134
|
+
self._select_by_faces(face_mask)
|
|
1135
|
+
|
|
1136
|
+
def remove_degenerated_faces(self, area_tolerance: float = 1e-8) -> None:
|
|
1137
|
+
"""Removes degenerate faces
|
|
1138
|
+
|
|
1139
|
+
Removes degenerated faces which are faces where two or more
|
|
1140
|
+
vertices are coincident, resulting in zero area.
|
|
1141
|
+
"""
|
|
1142
|
+
areas = self.face_areas()
|
|
1143
|
+
inlier_mask = areas >= area_tolerance
|
|
1144
|
+
self.select_by_faces(inlier_mask)
|
|
1145
|
+
|
|
1146
|
+
def compute_vertex_normals(self) -> None:
|
|
1147
|
+
"""Compute vertex normals
|
|
1148
|
+
|
|
1149
|
+
Computes face normals first. Then for each vertex computes mean
|
|
1150
|
+
normal from all face normals of adjacent faces.
|
|
1151
|
+
|
|
1152
|
+
Normalizes vertex normals, too. If any vertex normal has a zero size
|
|
1153
|
+
an exception is thrown. Clean up mesh first.
|
|
1154
|
+
"""
|
|
1155
|
+
self.compute_face_normals()
|
|
1156
|
+
self._normals = np.zeros_like(self._vertices)
|
|
1157
|
+
np.add.at(self._normals, self._faces[:, 0], self._face_normals)
|
|
1158
|
+
np.add.at(self._normals, self._faces[:, 1], self._face_normals)
|
|
1159
|
+
np.add.at(self._normals, self._faces[:, 2], self._face_normals)
|
|
1160
|
+
# Normalize vertex normals
|
|
1161
|
+
lengths = np.sqrt(
|
|
1162
|
+
self._normals[:, 0] ** 2
|
|
1163
|
+
+ self._normals[:, 1] ** 2
|
|
1164
|
+
+ self._normals[:, 2] ** 2
|
|
1165
|
+
)
|
|
1166
|
+
if np.any(np.isclose(lengths, 0.0)):
|
|
1167
|
+
raise Exception("Some normals have zero lengths")
|
|
1168
|
+
self._normals = self._normals / lengths[:, None]
|
|
1169
|
+
|
|
1170
|
+
def compute_face_normals(self) -> None:
|
|
1171
|
+
"""Compute face normals
|
|
1172
|
+
|
|
1173
|
+
Computes normal vector of each face.
|
|
1174
|
+
|
|
1175
|
+
Orientation of normals vector is determined by order of vertices,
|
|
1176
|
+
normal vector is cross product of vectors v0 to v1 and v0 to v2.
|
|
1177
|
+
|
|
1178
|
+
Normalizes face normals, too. If any face normal has a zero size
|
|
1179
|
+
(which may happen if there are degenerate faces), an exception
|
|
1180
|
+
is thrown. Clean up mesh first.
|
|
1181
|
+
"""
|
|
1182
|
+
v0 = self._vertices[self._faces[:, 0]]
|
|
1183
|
+
v1 = self._vertices[self._faces[:, 1]]
|
|
1184
|
+
v2 = self._vertices[self._faces[:, 2]]
|
|
1185
|
+
cross_products = np.cross(v1 - v0, v2 - v0)
|
|
1186
|
+
lengths = np.sqrt(
|
|
1187
|
+
cross_products[:, 0] ** 2
|
|
1188
|
+
+ cross_products[:, 1] ** 2
|
|
1189
|
+
+ cross_products[:, 2] ** 2
|
|
1190
|
+
)
|
|
1191
|
+
if np.any(np.isclose(lengths, 0.0)):
|
|
1192
|
+
raise Exception(
|
|
1193
|
+
"Some normals have zero lengths, there may be "
|
|
1194
|
+
"degenerated triangles in the mesh"
|
|
1195
|
+
)
|
|
1196
|
+
self._face_normals = cross_products / lengths[:, None]
|
|
1197
|
+
|
|
1198
|
+
def compute_vertex_adjacency(self) -> None:
|
|
1199
|
+
"""Computes vertex adjacency: neighbor vertices of a vertex."""
|
|
1200
|
+
i0, i1, i2 = self._faces[:, 0], self._faces[:, 1], self._faces[:, 2]
|
|
1201
|
+
rows = np.concatenate([i0, i1, i2])
|
|
1202
|
+
cols = np.concatenate([i1, i2, i0])
|
|
1203
|
+
rows_ = np.concatenate([rows, cols]) # Ensure symmetry of matrix
|
|
1204
|
+
cols_ = np.concatenate([cols, rows])
|
|
1205
|
+
self._vertex_adjacency = coo_array(
|
|
1206
|
+
(np.ones(rows_.shape[0], dtype=bool), (rows_, cols_)),
|
|
1207
|
+
shape=(self.num_vertices, self.num_vertices),
|
|
1208
|
+
).tocsr()
|
|
1209
|
+
assert self._vertex_adjacency is not None
|
|
1210
|
+
self._vertex_adjacency.sum_duplicates()
|
|
1211
|
+
self._vertex_adjacency.setdiag(False)
|
|
1212
|
+
self._vertex_adjacency.eliminate_zeros()
|
|
1213
|
+
|
|
1214
|
+
@property
|
|
1215
|
+
def vertex_adjacency(self) -> csr_array:
|
|
1216
|
+
"""Get the neighbor vertices of all vertices
|
|
1217
|
+
|
|
1218
|
+
:returns: Neighbor vertices of all vertices
|
|
1219
|
+
"""
|
|
1220
|
+
if self._vertex_adjacency is None:
|
|
1221
|
+
raise Exception("Compute vertex adjacency first")
|
|
1222
|
+
return self._vertex_adjacency
|
|
1223
|
+
|
|
1224
|
+
def get_adjacent_vertices(self, vertex_index: int) -> list[int]:
|
|
1225
|
+
"""Get the neighbor vertices of a single vertex
|
|
1226
|
+
|
|
1227
|
+
:param vertex_index: Vertex index
|
|
1228
|
+
:returns: List of neighbor vertices
|
|
1229
|
+
"""
|
|
1230
|
+
if self._vertex_adjacency is None:
|
|
1231
|
+
raise Exception("Compute vertex adjacency first")
|
|
1232
|
+
if not 0 <= vertex_index < self.num_vertices:
|
|
1233
|
+
raise IndexError(f"Vertex index {vertex_index} is out of range")
|
|
1234
|
+
# The indptr/indices slicing is the most efficient because it avoids
|
|
1235
|
+
# constructing any intermediate sparse object; it's the standard
|
|
1236
|
+
# CSR idiom. Compacter is self._adjacency[[vertex_index]].indices
|
|
1237
|
+
# but it is less fast.
|
|
1238
|
+
start = self._vertex_adjacency.indptr[vertex_index]
|
|
1239
|
+
end = self._vertex_adjacency.indptr[vertex_index + 1]
|
|
1240
|
+
return self._vertex_adjacency.indices[start:end].tolist()
|
|
1241
|
+
|
|
1242
|
+
def compute_vertex_face_incidence(self) -> None:
|
|
1243
|
+
"""Compute vertex-face incidences: incident faces of a vertex."""
|
|
1244
|
+
vertices = self._faces.reshape(-1)
|
|
1245
|
+
faces = np.repeat(np.arange(self.num_faces), 3)
|
|
1246
|
+
self._vertex_face_incidence = coo_array(
|
|
1247
|
+
(
|
|
1248
|
+
np.ones(vertices.size, dtype=bool),
|
|
1249
|
+
(vertices, faces),
|
|
1250
|
+
),
|
|
1251
|
+
shape=(self.num_vertices, self.num_faces),
|
|
1252
|
+
).tocsr()
|
|
1253
|
+
# Normally unnecessary for valid triangle meshes, but keeps the matrix
|
|
1254
|
+
# well-formed if a face contains a repeated vertex.
|
|
1255
|
+
assert self._vertex_face_incidence is not None
|
|
1256
|
+
self._vertex_face_incidence.sum_duplicates()
|
|
1257
|
+
self._vertex_face_incidence.data[:] = True
|
|
1258
|
+
|
|
1259
|
+
@property
|
|
1260
|
+
def vertex_face_incidence(self):
|
|
1261
|
+
"""Get the incident faces of all vertices
|
|
1262
|
+
|
|
1263
|
+
:returns: Incident faces of all vertices
|
|
1264
|
+
"""
|
|
1265
|
+
if self._vertex_face_incidence is None:
|
|
1266
|
+
raise RuntimeError("Compute vertex-face incidence first")
|
|
1267
|
+
return self._vertex_face_incidence
|
|
1268
|
+
|
|
1269
|
+
def get_incident_faces(self, vertex_index: int) -> list[int]:
|
|
1270
|
+
"""Get the incident faces of a single vertex
|
|
1271
|
+
|
|
1272
|
+
:param vertex_index: Vertex index
|
|
1273
|
+
:returns: List of incident faces
|
|
1274
|
+
"""
|
|
1275
|
+
if self._vertex_face_incidence is None:
|
|
1276
|
+
raise RuntimeError("Compute vertex-face incidence first")
|
|
1277
|
+
if not 0 <= vertex_index < self.num_vertices:
|
|
1278
|
+
raise IndexError(f"Vertex index {vertex_index} is out of range")
|
|
1279
|
+
start = self._vertex_face_incidence.indptr[vertex_index]
|
|
1280
|
+
end = self._vertex_face_incidence.indptr[vertex_index + 1]
|
|
1281
|
+
return self._vertex_face_incidence.indices[start:end].tolist()
|
|
1282
|
+
|
|
1283
|
+
def get_unique_edges(self) -> np.ndarray:
|
|
1284
|
+
"""Get unique edges of a mesh
|
|
1285
|
+
|
|
1286
|
+
Compute all edges, ensuring that the result contains each undirected edge
|
|
1287
|
+
only once; The output is sorted lexicographically by vertex index
|
|
1288
|
+
|
|
1289
|
+
:returns: Unique edges of mesh
|
|
1290
|
+
"""
|
|
1291
|
+
edges = np.concatenate(
|
|
1292
|
+
[self._faces[:, [0, 1]], self._faces[:, [1, 2]], self._faces[:, [2, 0]]],
|
|
1293
|
+
axis=0,
|
|
1294
|
+
)
|
|
1295
|
+
edges.sort(axis=1)
|
|
1296
|
+
return np.unique(edges, axis=0)
|
|
1297
|
+
|
|
1298
|
+
def compute_laplacian(self) -> csr_array:
|
|
1299
|
+
"""Computes the combinatorial graph Laplacian matrix
|
|
1300
|
+
|
|
1301
|
+
The combinatorial graph Laplacian matrix L is difference of the degree
|
|
1302
|
+
matrix D (every element of the diagonal contains the number of
|
|
1303
|
+
connections a vertex has to other vertices) and the adjacency matrix A;
|
|
1304
|
+
because in a mesh A is symmetric, L is symmetric too.
|
|
1305
|
+
|
|
1306
|
+
:returns: Combinatorial graph Laplacian matrix
|
|
1307
|
+
"""
|
|
1308
|
+
if self._vertex_adjacency is None:
|
|
1309
|
+
self.compute_vertex_adjacency()
|
|
1310
|
+
adjacency = self._vertex_adjacency
|
|
1311
|
+
assert adjacency is not None
|
|
1312
|
+
degrees = np.asarray(adjacency.sum(axis=1)).ravel()
|
|
1313
|
+
return csr_array(diags(degrees, dtype=float, format="csr") - adjacency)
|
|
1314
|
+
|
|
1315
|
+
def compute_cotangent_laplacian(self) -> csr_array:
|
|
1316
|
+
"""Computes the cotangent Laplacian matrix
|
|
1317
|
+
|
|
1318
|
+
:returns: cotangent Laplacian matrix
|
|
1319
|
+
"""
|
|
1320
|
+
# Compute double triangle areas
|
|
1321
|
+
i0, i1, i2 = self._faces.T
|
|
1322
|
+
v0 = self._vertices[i0]
|
|
1323
|
+
v1 = self._vertices[i1]
|
|
1324
|
+
v2 = self._vertices[i2]
|
|
1325
|
+
e0 = v2 - v1
|
|
1326
|
+
e1 = v0 - v2
|
|
1327
|
+
e2 = v1 - v0
|
|
1328
|
+
cross = np.cross(e0, -e2)
|
|
1329
|
+
double_area = np.linalg.norm(cross, axis=1)
|
|
1330
|
+
if np.any(np.isclose(double_area, 0.0)):
|
|
1331
|
+
raise Exception("Some triangles have zero area")
|
|
1332
|
+
# Compute cotangents of opposite angles
|
|
1333
|
+
cot0 = np.einsum("ij,ij->i", -e1, -e2) / double_area
|
|
1334
|
+
cot1 = np.einsum("ij,ij->i", -e2, -e0) / double_area
|
|
1335
|
+
cot2 = np.einsum("ij,ij->i", -e0, -e1) / double_area
|
|
1336
|
+
# Construct symmetric cotangent weights
|
|
1337
|
+
rows = np.concatenate([i1, i2, i2, i0, i0, i1])
|
|
1338
|
+
cols = np.concatenate([i2, i1, i0, i2, i1, i0])
|
|
1339
|
+
weights = 0.5 * np.concatenate(
|
|
1340
|
+
[
|
|
1341
|
+
cot0,
|
|
1342
|
+
cot0,
|
|
1343
|
+
cot1,
|
|
1344
|
+
cot1,
|
|
1345
|
+
cot2,
|
|
1346
|
+
cot2,
|
|
1347
|
+
]
|
|
1348
|
+
)
|
|
1349
|
+
# Construct off-diagonal entries
|
|
1350
|
+
L = coo_array(
|
|
1351
|
+
(weights, (rows, cols)),
|
|
1352
|
+
shape=(self.num_vertices, self.num_vertices),
|
|
1353
|
+
).tocsr()
|
|
1354
|
+
L.sum_duplicates()
|
|
1355
|
+
# Set diagonal entries to ensure zero row sums
|
|
1356
|
+
L.setdiag(-np.asarray(L.sum(axis=1)).ravel())
|
|
1357
|
+
return L.tocsr()
|
|
1358
|
+
|
|
1359
|
+
def compute_mass_matrix(self) -> csr_array:
|
|
1360
|
+
"""Computes the lumped barycentric mass matrix
|
|
1361
|
+
|
|
1362
|
+
:returns: Lumped barycentric mass matrix
|
|
1363
|
+
"""
|
|
1364
|
+
i0, i1, i2 = self._faces.T
|
|
1365
|
+
v0 = self._vertices[i0]
|
|
1366
|
+
v1 = self._vertices[i1]
|
|
1367
|
+
v2 = self._vertices[i2]
|
|
1368
|
+
areas = 0.5 * np.linalg.norm(
|
|
1369
|
+
np.cross(v1 - v0, v2 - v0),
|
|
1370
|
+
axis=1,
|
|
1371
|
+
)
|
|
1372
|
+
vertex_areas = np.zeros(self.num_vertices)
|
|
1373
|
+
np.add.at(vertex_areas, i0, areas / 3.0)
|
|
1374
|
+
np.add.at(vertex_areas, i1, areas / 3.0)
|
|
1375
|
+
np.add.at(vertex_areas, i2, areas / 3.0)
|
|
1376
|
+
return csr_array(diags(vertex_areas, format="csr"))
|
|
1377
|
+
|
|
1378
|
+
def face_areas(self) -> np.ndarray:
|
|
1379
|
+
"""Get face areas
|
|
1380
|
+
|
|
1381
|
+
:returns: Face areas
|
|
1382
|
+
"""
|
|
1383
|
+
v0 = self._vertices[self._faces[:, 0]]
|
|
1384
|
+
v1 = self._vertices[self._faces[:, 1]]
|
|
1385
|
+
v2 = self._vertices[self._faces[:, 2]]
|
|
1386
|
+
cross_products = np.cross(v1 - v0, v2 - v0)
|
|
1387
|
+
lengths = np.sqrt(
|
|
1388
|
+
cross_products[:, 0] ** 2
|
|
1389
|
+
+ cross_products[:, 1] ** 2
|
|
1390
|
+
+ cross_products[:, 2] ** 2
|
|
1391
|
+
)
|
|
1392
|
+
return 0.5 * lengths
|
|
1393
|
+
|
|
1394
|
+
def smoothness(self) -> tuple[float, np.ndarray]:
|
|
1395
|
+
"""Computes global and per-vertex geometric smoothness.
|
|
1396
|
+
|
|
1397
|
+
Smoothness is measured using the cotangent Laplacian and the
|
|
1398
|
+
lumped mass matrix, approximating the Laplace-Beltrami operator.
|
|
1399
|
+
|
|
1400
|
+
The first result is the global smoothness, normalized by the
|
|
1401
|
+
total surface area (area-weighted mean squared curvature).
|
|
1402
|
+
The second result contains the per-vertex curvature magnitudes.
|
|
1403
|
+
|
|
1404
|
+
Small per-vertex values indicate locally flatter geometry,
|
|
1405
|
+
while large values indicate stronger local curvature.
|
|
1406
|
+
|
|
1407
|
+
The measure is not scale-invariant. For example, scaling a
|
|
1408
|
+
mesh by a factor of 2 reduces its normalized squared-curvature
|
|
1409
|
+
measure by a factor of 4.
|
|
1410
|
+
|
|
1411
|
+
:returns: A tuple containing global smoothness and per-vertex
|
|
1412
|
+
curvature magnitudes.
|
|
1413
|
+
"""
|
|
1414
|
+
L = self.compute_cotangent_laplacian()
|
|
1415
|
+
M = self.compute_mass_matrix()
|
|
1416
|
+
laplacian_vectors = spsolve(M, L @ self._vertices)
|
|
1417
|
+
smoothness_per_vertex = np.linalg.norm(laplacian_vectors, axis=1)
|
|
1418
|
+
vertex_areas = M.diagonal()
|
|
1419
|
+
global_smoothness = np.sum(vertex_areas * smoothness_per_vertex**2) / np.sum(
|
|
1420
|
+
vertex_areas
|
|
1421
|
+
)
|
|
1422
|
+
return global_smoothness, smoothness_per_vertex
|
|
1423
|
+
|
|
1424
|
+
def sample_random_surface_points(
|
|
1425
|
+
self,
|
|
1426
|
+
num_points: int,
|
|
1427
|
+
rng: np.random.Generator | None = None,
|
|
1428
|
+
) -> PointCloud:
|
|
1429
|
+
"""Samples a number of random points on the surface of the mesh
|
|
1430
|
+
|
|
1431
|
+
The points are uniformly distributed over the surface.
|
|
1432
|
+
|
|
1433
|
+
:param num_points: Number of points to sample
|
|
1434
|
+
:param rng: NumPy random number generator
|
|
1435
|
+
:returns: Resulting point cloud
|
|
1436
|
+
"""
|
|
1437
|
+
if rng is None:
|
|
1438
|
+
rng = np.random.default_rng()
|
|
1439
|
+
# Pick faces proportional to area
|
|
1440
|
+
areas = self.face_areas()
|
|
1441
|
+
face_indices = rng.choice(
|
|
1442
|
+
self.num_faces, size=num_points, p=areas / np.sum(areas)
|
|
1443
|
+
)
|
|
1444
|
+
# Uniform barycentric sampling within each picked triangle
|
|
1445
|
+
r1 = np.sqrt(rng.random(num_points))
|
|
1446
|
+
r2 = rng.random(num_points)
|
|
1447
|
+
a = 1 - r1
|
|
1448
|
+
b = r1 * (1 - r2)
|
|
1449
|
+
c = r1 * r2
|
|
1450
|
+
# Interpolate vertices
|
|
1451
|
+
v0 = self._vertices[self._faces[face_indices, 0]]
|
|
1452
|
+
v1 = self._vertices[self._faces[face_indices, 1]]
|
|
1453
|
+
v2 = self._vertices[self._faces[face_indices, 2]]
|
|
1454
|
+
vertices = a[:, None] * v0 + b[:, None] * v1 + c[:, None] * v2
|
|
1455
|
+
# Interpolate normals
|
|
1456
|
+
if self.has_normals:
|
|
1457
|
+
n0 = self._normals[self._faces[face_indices, 0]]
|
|
1458
|
+
n1 = self._normals[self._faces[face_indices, 1]]
|
|
1459
|
+
n2 = self._normals[self._faces[face_indices, 2]]
|
|
1460
|
+
normals = a[:, None] * n0 + b[:, None] * n1 + c[:, None] * n2
|
|
1461
|
+
elif self.has_face_normals:
|
|
1462
|
+
normals = self._face_normals[face_indices]
|
|
1463
|
+
else:
|
|
1464
|
+
normals = None
|
|
1465
|
+
# Interpolate colors
|
|
1466
|
+
if self.has_colors:
|
|
1467
|
+
c0 = self._colors[self._faces[face_indices, 0]]
|
|
1468
|
+
c1 = self._colors[self._faces[face_indices, 1]]
|
|
1469
|
+
c2 = self._colors[self._faces[face_indices, 2]]
|
|
1470
|
+
colors = a[:, None] * c0 + b[:, None] * c1 + c[:, None] * c2
|
|
1471
|
+
elif self.has_face_colors:
|
|
1472
|
+
colors = self._face_colors[face_indices]
|
|
1473
|
+
else:
|
|
1474
|
+
colors = None
|
|
1475
|
+
return PointCloud(vertices, normals, colors)
|
|
1476
|
+
|
|
1477
|
+
def distances(
|
|
1478
|
+
self,
|
|
1479
|
+
other: "ArrayLike | PointCloud",
|
|
1480
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
1481
|
+
"""Computes unsigned distance between triangle mesh and points
|
|
1482
|
+
|
|
1483
|
+
:param other: Points or point cloud of n points
|
|
1484
|
+
:returns: Unsigned distances, shape (n,)
|
|
1485
|
+
"""
|
|
1486
|
+
mesh = pyembree2.triangle_mesh(
|
|
1487
|
+
vertices=np.ascontiguousarray(self.vertices),
|
|
1488
|
+
faces=np.ascontiguousarray(self.faces),
|
|
1489
|
+
)
|
|
1490
|
+
if isinstance(other, PointCloud):
|
|
1491
|
+
query_points = other.vertices
|
|
1492
|
+
else:
|
|
1493
|
+
query_points = np.asarray(other)
|
|
1494
|
+
_, face_indices, points, unsigned_distances = pyembree2.unsigned_distance(
|
|
1495
|
+
meshes=[mesh], points=np.ascontiguousarray(query_points)
|
|
1496
|
+
)
|
|
1497
|
+
return unsigned_distances, face_indices, points
|
|
1498
|
+
|
|
1499
|
+
def signed_distances(
|
|
1500
|
+
self,
|
|
1501
|
+
other: "ArrayLike | PointCloud",
|
|
1502
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
1503
|
+
"""Computes signed distance between triangle mesh and points
|
|
1504
|
+
|
|
1505
|
+
:param other: Points or point cloud of n points
|
|
1506
|
+
:returns: Signed distances, shape (n,)
|
|
1507
|
+
"""
|
|
1508
|
+
if not self.has_normals:
|
|
1509
|
+
raise Exception("Computation of signed distances needs vertex normals")
|
|
1510
|
+
mesh = pyembree2.triangle_mesh(
|
|
1511
|
+
vertices=np.ascontiguousarray(self.vertices),
|
|
1512
|
+
faces=np.ascontiguousarray(self.faces),
|
|
1513
|
+
vertex_normals=np.ascontiguousarray(self.normals),
|
|
1514
|
+
)
|
|
1515
|
+
if isinstance(other, PointCloud):
|
|
1516
|
+
query_points = other.vertices
|
|
1517
|
+
else:
|
|
1518
|
+
query_points = np.asarray(other)
|
|
1519
|
+
_, face_indices, points, signed_distances = pyembree2.signed_distance(
|
|
1520
|
+
meshes=[mesh], points=np.ascontiguousarray(query_points)
|
|
1521
|
+
)
|
|
1522
|
+
return signed_distances, face_indices, points
|
|
1523
|
+
|
|
1524
|
+
def colorize_faces_by_scalar(
|
|
1525
|
+
self,
|
|
1526
|
+
scalars: ArrayLike,
|
|
1527
|
+
cmap: str = "viridis",
|
|
1528
|
+
min_max: tuple[float, float] | None = None,
|
|
1529
|
+
nan_color: tuple[float, float, float] = (1, 0, 0),
|
|
1530
|
+
) -> None:
|
|
1531
|
+
"""Colorizes mesh faces by scalar
|
|
1532
|
+
|
|
1533
|
+
:param scalars: Scalar values, length has to match number of faces
|
|
1534
|
+
:param cmap: Matplotlib color map string
|
|
1535
|
+
:param min_max: Minimum and maximum
|
|
1536
|
+
:param nan_color: Color for NaN values, RGB values [0..1]
|
|
1537
|
+
"""
|
|
1538
|
+
scalars = np.asarray(scalars)
|
|
1539
|
+
if scalars.ndim != 1 or scalars.size != self.num_faces:
|
|
1540
|
+
raise Exception("Provide correct number of values to colorize faces")
|
|
1541
|
+
self._face_colors = scalar_to_color(scalars, cmap, min_max, nan_color)
|
|
1542
|
+
|
|
1543
|
+
def colorize_faces_by_scalar_bicolor(
|
|
1544
|
+
self,
|
|
1545
|
+
scalars: ArrayLike,
|
|
1546
|
+
cmaps: tuple[str, str] = ("Blues", "Reds"),
|
|
1547
|
+
vmax: float | None = None,
|
|
1548
|
+
nan_color: tuple[float, float, float] = (0, 1, 0),
|
|
1549
|
+
) -> None:
|
|
1550
|
+
"""Colorizes mesh faces by scalar, pos/neg in separate colors
|
|
1551
|
+
|
|
1552
|
+
:param scalars: Scalar values, length has to match number of faces
|
|
1553
|
+
:param cmaps: Matplotlib color map strings for pos/neg
|
|
1554
|
+
:param vmax: Maximum (pos/neg) value
|
|
1555
|
+
:param nan_color: Color for NaN values, RGB values [0..1]
|
|
1556
|
+
"""
|
|
1557
|
+
scalars = np.asarray(scalars)
|
|
1558
|
+
if scalars.ndim != 1 or scalars.size != self.num_faces:
|
|
1559
|
+
raise Exception("Provide correct number of values to colorize faces")
|
|
1560
|
+
self._face_colors = scalar_to_bicolor(scalars, cmaps, vmax, nan_color)
|
|
1561
|
+
|
|
1562
|
+
def icp(
|
|
1563
|
+
self,
|
|
1564
|
+
other: "ArrayLike | PointCloud",
|
|
1565
|
+
max_iterations: int = 1000,
|
|
1566
|
+
tolerance: float = 1e-6,
|
|
1567
|
+
) -> Trafo3d:
|
|
1568
|
+
"""Register a point cloud to the mesh using Iterative Closest Point (ICP)
|
|
1569
|
+
|
|
1570
|
+
Uses the iterative closest point algorithm (ICP) to register a
|
|
1571
|
+
point cloud to the triangle mesh.
|
|
1572
|
+
|
|
1573
|
+
:param other: Points or point cloud
|
|
1574
|
+
:param max_iterations: Number maximum number of iterations
|
|
1575
|
+
:param tolerance: If mean distance improvement from iteration to
|
|
1576
|
+
iteration is smaller than tolerance, stop iteration
|
|
1577
|
+
:returns: Transformation from self to other
|
|
1578
|
+
"""
|
|
1579
|
+
if isinstance(other, PointCloud):
|
|
1580
|
+
other = other.vertices
|
|
1581
|
+
else:
|
|
1582
|
+
other = np.asarray(other)
|
|
1583
|
+
dst = other.copy()
|
|
1584
|
+
prev_err = np.inf
|
|
1585
|
+
for _ in range(max_iterations):
|
|
1586
|
+
distances, _, closest_points = self.distances(dst)
|
|
1587
|
+
trafo = PointCloud(closest_points).estimate_transform(dst)
|
|
1588
|
+
dst = trafo * dst
|
|
1589
|
+
err = np.abs(np.mean(distances))
|
|
1590
|
+
if np.abs(prev_err - err) < tolerance:
|
|
1591
|
+
break
|
|
1592
|
+
prev_err = err
|
|
1593
|
+
return PointCloud(other).estimate_transform(dst).inverse
|
|
1594
|
+
|
|
1595
|
+
def cluster_by_connectivity(self, min_faces: int = 0) -> list["TriangleMesh"]:
|
|
1596
|
+
"""Split the mesh into connected components based on vertex connectivity
|
|
1597
|
+
|
|
1598
|
+
:param min_faces: Minimum number of faces required for a mesh to be included;
|
|
1599
|
+
Meshes with fewer faces are discarded
|
|
1600
|
+
:returns: Resulting meshes
|
|
1601
|
+
"""
|
|
1602
|
+
if min_faces < 0:
|
|
1603
|
+
raise ValueError("min_faces must be non-negative")
|
|
1604
|
+
if self._vertex_adjacency is None:
|
|
1605
|
+
self.compute_vertex_adjacency()
|
|
1606
|
+
assert self._vertex_adjacency is not None
|
|
1607
|
+
# Find connected components
|
|
1608
|
+
n_components, labels = connected_components(
|
|
1609
|
+
self._vertex_adjacency, directed=False, return_labels=True
|
|
1610
|
+
)
|
|
1611
|
+
# Assign each face to its corresponding component
|
|
1612
|
+
face_labels = labels[self._faces[:, 0]]
|
|
1613
|
+
# Count faces per component
|
|
1614
|
+
face_counts = np.bincount(face_labels, minlength=n_components)
|
|
1615
|
+
# Select components satisfying the minimum face count
|
|
1616
|
+
valid_components = np.flatnonzero(face_counts >= min_faces)
|
|
1617
|
+
# Collect meshes
|
|
1618
|
+
meshes = []
|
|
1619
|
+
for component in valid_components:
|
|
1620
|
+
# Get vertices and faces belonging to this component
|
|
1621
|
+
vertex_mask = labels == component
|
|
1622
|
+
face_mask = face_labels == component
|
|
1623
|
+
component_vertices = self._vertices[vertex_mask]
|
|
1624
|
+
component_faces = self._faces[face_mask]
|
|
1625
|
+
# Remap face indices to the new vertex array
|
|
1626
|
+
vertex_indices = np.full(len(self._vertices), -1, dtype=int)
|
|
1627
|
+
vertex_indices[vertex_mask] = np.arange(np.count_nonzero(vertex_mask))
|
|
1628
|
+
component_faces = vertex_indices[component_faces]
|
|
1629
|
+
# Create new TriangleMesh
|
|
1630
|
+
mesh = TriangleMesh(vertices=component_vertices, faces=component_faces)
|
|
1631
|
+
meshes.append(mesh)
|
|
1632
|
+
return meshes
|
|
1633
|
+
|
|
1634
|
+
def smooth_explicit(self, iterations: int = 10, lam: float = 0.1) -> None:
|
|
1635
|
+
"""Smooth the mesh using explicit Laplacian smoothing.
|
|
1636
|
+
|
|
1637
|
+
Vertex positions are iteratively updated by moving each vertex in the
|
|
1638
|
+
direction of its discrete Laplacian. The Laplacian is computed using the
|
|
1639
|
+
cotangent discretization of the Laplace-Beltrami operator and normalized
|
|
1640
|
+
by the vertex mass matrix.
|
|
1641
|
+
|
|
1642
|
+
At each iteration, the following linear system is solved to determine
|
|
1643
|
+
the vertex displacements:
|
|
1644
|
+
|
|
1645
|
+
M * delta = L * V
|
|
1646
|
+
|
|
1647
|
+
The vertex positions are then updated explicitly:
|
|
1648
|
+
|
|
1649
|
+
V_new = V_old - lam * delta
|
|
1650
|
+
|
|
1651
|
+
where:
|
|
1652
|
+
L : Cotangent stiffness matrix
|
|
1653
|
+
M : Diagonal vertex mass matrix
|
|
1654
|
+
V : Vertex position matrix (n_vertices x 3)
|
|
1655
|
+
delta : Discrete Laplacian displacement
|
|
1656
|
+
lam : Smoothing step size
|
|
1657
|
+
|
|
1658
|
+
Since the update is explicit, the smoothing step size must be
|
|
1659
|
+
sufficiently small to ensure numerical stability. Repeated smoothing
|
|
1660
|
+
generally reduces high-frequency surface variations but also causes
|
|
1661
|
+
the mesh to shrink and geometric details to be lost.
|
|
1662
|
+
|
|
1663
|
+
The mass matrix accounts for the local vertex areas and provides a
|
|
1664
|
+
geometry-aware normalization of the cotangent Laplacian. Unlike
|
|
1665
|
+
implicit Laplacian smoothing, this method computes the displacement
|
|
1666
|
+
from the current vertex positions before updating them, rather than
|
|
1667
|
+
solving directly for the new vertex positions.
|
|
1668
|
+
|
|
1669
|
+
:param iterations: Number of smoothing iterations
|
|
1670
|
+
:param lam: Smoothing step size Larger values produce stronger
|
|
1671
|
+
smoothing per iteration but may lead to numerical instability
|
|
1672
|
+
"""
|
|
1673
|
+
L = self.compute_cotangent_laplacian()
|
|
1674
|
+
M = self.compute_mass_matrix()
|
|
1675
|
+
V = self._vertices.copy()
|
|
1676
|
+
for _ in range(iterations):
|
|
1677
|
+
rhs = L @ V
|
|
1678
|
+
delta = spsolve(M, rhs)
|
|
1679
|
+
V -= lam * delta
|
|
1680
|
+
self._vertices = V
|
|
1681
|
+
|
|
1682
|
+
def smooth_implicit(self, iterations: int = 10, lam: float = 1.0) -> None:
|
|
1683
|
+
"""Smooth the mesh using implicit Laplacian smoothing.
|
|
1684
|
+
|
|
1685
|
+
Vertex positions are iteratively updated by solving a sparse linear
|
|
1686
|
+
system. Unlike explicit smoothing, the new vertex positions are
|
|
1687
|
+
determined implicitly from the smoothing equation.
|
|
1688
|
+
|
|
1689
|
+
The update is obtained by solving:
|
|
1690
|
+
|
|
1691
|
+
(M + lam * L) * V_new = M * V_old
|
|
1692
|
+
|
|
1693
|
+
where:
|
|
1694
|
+
L : Cotangent stiffness matrix
|
|
1695
|
+
M : Diagonal vertex mass matrix
|
|
1696
|
+
V_old : Vertex positions before the update
|
|
1697
|
+
V_new : Updated vertex positions
|
|
1698
|
+
lam : Smoothing parameter
|
|
1699
|
+
|
|
1700
|
+
The mass matrix provides area-weighted normalization, while the
|
|
1701
|
+
cotangent stiffness matrix describes the local mesh connectivity and
|
|
1702
|
+
geometry.
|
|
1703
|
+
|
|
1704
|
+
Implicit integration is generally more numerically stable than explicit
|
|
1705
|
+
integration and permits larger smoothing parameters without the same
|
|
1706
|
+
time-step restrictions. However, repeated smoothing still causes mesh
|
|
1707
|
+
shrinkage and loss of geometric detail.
|
|
1708
|
+
|
|
1709
|
+
The system matrix (M + lam * L) is constructed only once because the
|
|
1710
|
+
mesh connectivity and geometry-dependent matrices remain fixed during
|
|
1711
|
+
the iterations.
|
|
1712
|
+
|
|
1713
|
+
Unlike explicit smoothing, this method solves directly for the
|
|
1714
|
+
updated vertex positions rather than first calculating a
|
|
1715
|
+
displacement from the previous positions.
|
|
1716
|
+
|
|
1717
|
+
:param iterations: Number of smoothing iterations
|
|
1718
|
+
:param lam: Smoothing parameter. Larger values generally produce stronger
|
|
1719
|
+
smoothing per iteration
|
|
1720
|
+
"""
|
|
1721
|
+
L = self.compute_cotangent_laplacian()
|
|
1722
|
+
M = self.compute_mass_matrix()
|
|
1723
|
+
A = M + lam * L # Build system matrix
|
|
1724
|
+
V = self._vertices.copy()
|
|
1725
|
+
for _ in range(iterations):
|
|
1726
|
+
rhs = M @ V
|
|
1727
|
+
V = spsolve(A, rhs)
|
|
1728
|
+
self._vertices = V
|
|
1729
|
+
|
|
1730
|
+
def smooth_taubin(
|
|
1731
|
+
self,
|
|
1732
|
+
iterations: int = 10,
|
|
1733
|
+
lam: float = 0.5,
|
|
1734
|
+
mu: float = -0.53,
|
|
1735
|
+
normalize_parameters: bool = True,
|
|
1736
|
+
) -> None:
|
|
1737
|
+
"""Smooth the mesh using Taubin smoothing.
|
|
1738
|
+
|
|
1739
|
+
Taubin smoothing reduces surface noise while mitigating the shrinkage
|
|
1740
|
+
typically introduced by conventional Laplacian smoothing.
|
|
1741
|
+
|
|
1742
|
+
Each iteration consists of two consecutive Laplacian smoothing steps:
|
|
1743
|
+
|
|
1744
|
+
* Smoothing step (lambda): Applies Laplacian smoothing to attenuate
|
|
1745
|
+
high-frequency geometric variations.
|
|
1746
|
+
* Expansion step (mu): Applies a second Laplacian operation with a
|
|
1747
|
+
negative parameter to compensate for the shrinkage introduced
|
|
1748
|
+
by the first step.
|
|
1749
|
+
|
|
1750
|
+
The vertex updates are computed using the cotangent Laplacian
|
|
1751
|
+
normalized by the diagonal vertex mass matrix:
|
|
1752
|
+
|
|
1753
|
+
delta = M^(-1) * L * V
|
|
1754
|
+
|
|
1755
|
+
V = V - lam * delta
|
|
1756
|
+
V = V - mu * delta
|
|
1757
|
+
|
|
1758
|
+
If parameter normalization is enabled, the Laplacian is spectrally
|
|
1759
|
+
scaled using an estimate of its largest eigenvalue. The scaling is
|
|
1760
|
+
based on the symmetric mass-normalized Laplacian:
|
|
1761
|
+
|
|
1762
|
+
A = M^(-1/2) * L * M^(-1/2)
|
|
1763
|
+
|
|
1764
|
+
Both Taubin parameters are divided by the estimated largest
|
|
1765
|
+
eigenvalue to obtain scale-adjusted smoothing parameters.
|
|
1766
|
+
|
|
1767
|
+
Taubin smoothing reduces the volume shrinkage associated with
|
|
1768
|
+
ordinary Laplacian smoothing but does not guarantee volume
|
|
1769
|
+
preservation.
|
|
1770
|
+
|
|
1771
|
+
The method performs explicit Laplacian updates and does not solve
|
|
1772
|
+
a linear system for the vertex positions.
|
|
1773
|
+
|
|
1774
|
+
:param iterations: Number of Taubin smoothing iterations. Each
|
|
1775
|
+
iteration consists of one smoothing step followed by one expansion
|
|
1776
|
+
step
|
|
1777
|
+
:param lam: Positive smoothing parameter controlling the strength
|
|
1778
|
+
of the first Laplacian step
|
|
1779
|
+
:param mu: Negative expansion parameter controlling the strength
|
|
1780
|
+
of the second Laplacian step. It compensates for the shrinkage
|
|
1781
|
+
introduced by the smoothing step
|
|
1782
|
+
:param normalize_parameters: If True, scales both smoothing
|
|
1783
|
+
parameters using an estimate of the largest eigenvalue of the
|
|
1784
|
+
symmetric mass-normalized Laplacian. This makes the parameters
|
|
1785
|
+
less dependent on the magnitude of the Laplacian eigenvalues
|
|
1786
|
+
"""
|
|
1787
|
+
if iterations < 0:
|
|
1788
|
+
raise ValueError("Iterations must be non-negative.")
|
|
1789
|
+
if lam <= 0:
|
|
1790
|
+
raise ValueError("Lambda must be positive.")
|
|
1791
|
+
if mu >= 0:
|
|
1792
|
+
raise ValueError("Mu must be negative.")
|
|
1793
|
+
if iterations == 0:
|
|
1794
|
+
return
|
|
1795
|
+
L = self.compute_cotangent_laplacian()
|
|
1796
|
+
vertex_areas = self.compute_mass_matrix().diagonal()
|
|
1797
|
+
if np.any(vertex_areas <= 0):
|
|
1798
|
+
raise ValueError("Mesh contains vertices with zero area.")
|
|
1799
|
+
if normalize_parameters:
|
|
1800
|
+
# Construct symmetric mass-normalized Laplacian
|
|
1801
|
+
inv_sqrt_areas = 1.0 / np.sqrt(vertex_areas)
|
|
1802
|
+
D = diags(inv_sqrt_areas, format="csr")
|
|
1803
|
+
A = D @ L @ D
|
|
1804
|
+
# Estimate largest eigenvalue
|
|
1805
|
+
lambda_max = eigsh(
|
|
1806
|
+
A,
|
|
1807
|
+
k=1,
|
|
1808
|
+
which="LM",
|
|
1809
|
+
return_eigenvectors=False,
|
|
1810
|
+
)[0]
|
|
1811
|
+
if lambda_max <= 0:
|
|
1812
|
+
raise ValueError("Invalid Laplacian spectrum.")
|
|
1813
|
+
# Scale Taubin parameters
|
|
1814
|
+
lam /= lambda_max
|
|
1815
|
+
mu /= lambda_max
|
|
1816
|
+
V = self._vertices.copy()
|
|
1817
|
+
for _ in range(iterations):
|
|
1818
|
+
# Smoothing step
|
|
1819
|
+
delta = (L @ V) / vertex_areas[:, None]
|
|
1820
|
+
V -= lam * delta
|
|
1821
|
+
# Expansion step
|
|
1822
|
+
delta = (L @ V) / vertex_areas[:, None]
|
|
1823
|
+
V -= mu * delta
|
|
1824
|
+
self._vertices = V
|
|
1825
|
+
|
|
1826
|
+
def compute_vertex_quadrics(self):
|
|
1827
|
+
"""Compute vertex quadrics
|
|
1828
|
+
|
|
1829
|
+
See Garland, M. and Heckbert, P. S.: Surface Simplification
|
|
1830
|
+
Using Quadric Error Metrics, 1997
|
|
1831
|
+
https://www.cs.cmu.edu/~garland/Papers/quadrics.pdf
|
|
1832
|
+
"""
|
|
1833
|
+
# Plane equations: nx*x + ny*y + nz*z + d = 0
|
|
1834
|
+
# (nx, ny, nz) are the face normals and d = -(nx*x + ny*y + nz*z)
|
|
1835
|
+
planes = np.empty((self.num_faces, 4), dtype=np.float64)
|
|
1836
|
+
planes[:, :3] = self._face_normals
|
|
1837
|
+
planes[:, 3] = -np.einsum(
|
|
1838
|
+
"ij,ij->i", self._face_normals, self._vertices[self._faces[:, 0]]
|
|
1839
|
+
)
|
|
1840
|
+
# Outer product for every face: Fundamental error quadric K_p
|
|
1841
|
+
face_quadrics = planes[:, :, None] * planes[:, None, :]
|
|
1842
|
+
# Sum all 16 quadric entries over incident faces simultaneously
|
|
1843
|
+
assert self._vertex_face_incidence is not None
|
|
1844
|
+
vertex_quadrics = (
|
|
1845
|
+
self._vertex_face_incidence @ face_quadrics.reshape(self.num_faces, 16)
|
|
1846
|
+
).reshape(self.num_vertices, 4, 4)
|
|
1847
|
+
return vertex_quadrics
|
|
1848
|
+
|
|
1849
|
+
def best_collapse_positions_all_edges(self, vertex_quadrics, rcond=1e-12):
|
|
1850
|
+
edges = self.get_unique_edges()
|
|
1851
|
+
|
|
1852
|
+
u, v = edges[:, 0], edges[:, 1]
|
|
1853
|
+
Q = vertex_quadrics[u] + vertex_quadrics[v] # (E, 4, 4)
|
|
1854
|
+
|
|
1855
|
+
p0 = self._vertices[u]
|
|
1856
|
+
p1 = self._vertices[v]
|
|
1857
|
+
midpoint = 0.5 * (p0 + p1)
|
|
1858
|
+
|
|
1859
|
+
# Evaluate a batch of homogeneous positions against the corresponding Q.
|
|
1860
|
+
def costs_at(points):
|
|
1861
|
+
hp = np.concatenate(
|
|
1862
|
+
[points, np.ones((len(points), 1), dtype=points.dtype)],
|
|
1863
|
+
axis=1,
|
|
1864
|
+
)
|
|
1865
|
+
return np.einsum("ei,eij,ej->e", hp, Q, hp)
|
|
1866
|
+
|
|
1867
|
+
# Initial fallback candidates: endpoints and midpoint.
|
|
1868
|
+
candidates = np.stack([p0, p1, midpoint], axis=1) # (E, 3, 3)
|
|
1869
|
+
candidate_costs = np.stack(
|
|
1870
|
+
[costs_at(p0), costs_at(p1), costs_at(midpoint)],
|
|
1871
|
+
axis=1,
|
|
1872
|
+
) # (E, 3)
|
|
1873
|
+
|
|
1874
|
+
# The unconstrained optimum solves A p = -b, where
|
|
1875
|
+
# Q = [[A, b], [b.T, c]].
|
|
1876
|
+
A = Q[:, :3, :3]
|
|
1877
|
+
b = Q[:, :3, 3]
|
|
1878
|
+
|
|
1879
|
+
# Batched SVD identifies singular/ill-conditioned systems.
|
|
1880
|
+
singular_values = np.linalg.svd(A, compute_uv=False)
|
|
1881
|
+
well_conditioned = singular_values[:, -1] > rcond * singular_values[:, 0]
|
|
1882
|
+
|
|
1883
|
+
optimal = np.zeros((len(edges), 3), dtype=self._vertices.dtype)
|
|
1884
|
+
valid_indices = np.flatnonzero(well_conditioned)
|
|
1885
|
+
print(len(valid_indices))
|
|
1886
|
+
|
|
1887
|
+
try:
|
|
1888
|
+
optimal[valid_indices] = np.linalg.solve(
|
|
1889
|
+
A[valid_indices],
|
|
1890
|
+
-b[valid_indices, :, None],
|
|
1891
|
+
)[..., 0]
|
|
1892
|
+
except np.linalg.LinAlgError:
|
|
1893
|
+
# Fall back to solving each system separately.
|
|
1894
|
+
for i in valid_indices:
|
|
1895
|
+
try:
|
|
1896
|
+
optimal[i] = np.linalg.solve(A[i], -b[i])
|
|
1897
|
+
except np.linalg.LinAlgError:
|
|
1898
|
+
well_conditioned[i] = False
|
|
1899
|
+
|
|
1900
|
+
if np.any(well_conditioned):
|
|
1901
|
+
indices = np.flatnonzero(well_conditioned)
|
|
1902
|
+
optimal_costs = costs_at(optimal[indices])
|
|
1903
|
+
|
|
1904
|
+
candidates[indices, 0, :] = optimal[indices]
|
|
1905
|
+
candidate_costs[indices, 0] = optimal_costs
|
|
1906
|
+
|
|
1907
|
+
best_indices = np.argmin(candidate_costs, axis=1)
|
|
1908
|
+
rows = np.arange(len(edges))
|
|
1909
|
+
|
|
1910
|
+
return candidates[rows, best_indices], candidate_costs[rows, best_indices]
|