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.
@@ -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]