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 ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("primlib")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0"
primlib/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.3.0'
22
+ __version_tuple__ = version_tuple = (0, 3, 0)
23
+
24
+ __commit_id__ = commit_id = None
primlib/line_set.py ADDED
@@ -0,0 +1,435 @@
1
+ import copy
2
+ import logging
3
+ from typing import Self, override
4
+
5
+ import numpy as np
6
+ from numpy.typing import ArrayLike
7
+
8
+ from primlib.triangle_mesh import TriangleMesh
9
+ from primlib.utils import scalar_to_bicolor, scalar_to_color
10
+ from primlib.vertices import Vertices
11
+
12
+
13
+ class LineSet(Vertices):
14
+ __slots__ = ("_line_colors", "_lines")
15
+
16
+ def __init__(
17
+ self,
18
+ vertices: ArrayLike | None = None,
19
+ lines: ArrayLike | None = None,
20
+ vertex_colors: ArrayLike | None = None,
21
+ line_colors: ArrayLike | None = None,
22
+ ):
23
+ """Constructor
24
+
25
+ :param vertices: Vertices of the line set, shape (n, 3)
26
+ :param lines: Lines of the line set as vertex indices,
27
+ shape (m, 2), integer type
28
+ :param vertex_colors: RGB colors for each vertex, shape (n, 3),
29
+ values [0.0..1.0]
30
+ :param line_colors: RGB colors for each line, shape (m, 3),
31
+ values [0.0..1.0]
32
+ """
33
+ super().__init__(vertices=vertices, colors=vertex_colors)
34
+ # Define types and initialize
35
+ self._lines: np.ndarray = np.zeros((0, 2), dtype=int)
36
+ self._line_colors: np.ndarray = np.zeros((0, 3))
37
+ # Initialize using parameters and setters
38
+ self.lines = lines
39
+ self.line_colors = line_colors
40
+
41
+ @property
42
+ def num_vertices(self) -> int:
43
+ """Number of vertices of the line set
44
+ :returns: Number of vertices
45
+ """
46
+ return self._vertices.shape[0]
47
+
48
+ @property
49
+ def num_lines(self) -> int:
50
+ """Number of faces of the line set
51
+ :returns: Number of lines
52
+ """
53
+ return self._lines.shape[0]
54
+
55
+ @property
56
+ def empty(self) -> bool:
57
+ """Check if line set is empty
58
+ :returns: True if line set has no vertices and no lines
59
+ """
60
+ return self.num_vertices == 0 and self.num_lines == 0
61
+
62
+ def __str__(self) -> str:
63
+ """Get string representation of line set
64
+ :returns: String representation
65
+ """
66
+ if self.empty:
67
+ return "LineSet(empty)"
68
+ pmin = self.min()
69
+ pmax = self.max()
70
+ result = (
71
+ f"LineSet({self.num_vertices:_} vertices: "
72
+ + f"X[{pmin[0]:.3f}..{pmax[0]:.3f}], "
73
+ + f"Y[{pmin[1]:.3f}..{pmax[1]:.3f}], "
74
+ + f"Z[{pmin[2]:.3f}..{pmax[2]:.3f}]"
75
+ )
76
+ if self.has_colors:
77
+ result += ", vertex_colors"
78
+ result += f", {self.num_lines:_} lines"
79
+ if self.has_line_colors:
80
+ result += ", line_colors"
81
+ result += ")"
82
+ return result
83
+
84
+ @override
85
+ def __eq__(self, other: object) -> bool:
86
+ """Check two line sets for equality.
87
+
88
+ :param other: Other object
89
+ :returns: True if line sets are equal
90
+ """
91
+ if not isinstance(other, LineSet):
92
+ return NotImplemented
93
+ return (
94
+ super().__eq__(other)
95
+ # Lines
96
+ and self._lines.shape == other._lines.shape
97
+ and np.array_equal(self._lines, other._lines)
98
+ # Line colors
99
+ and self._line_colors.shape == other._line_colors.shape
100
+ and np.allclose(
101
+ self._line_colors,
102
+ other._line_colors,
103
+ atol=1.0 / 255.0,
104
+ )
105
+ )
106
+
107
+ @override
108
+ def __ne__(self, other: object) -> bool:
109
+ """Check two line sets for inequality
110
+
111
+ :param other: Other object
112
+ :returns: True, if line sets are not equal
113
+ """
114
+ return not self.__eq__(other)
115
+
116
+ @override
117
+ def __iadd__(self, other: Vertices) -> Self:
118
+ """Concatenate another line set in-place
119
+
120
+ :param other: Other line set
121
+ :returns: This line set (self)
122
+ """
123
+ if not isinstance(other, LineSet):
124
+ raise TypeError("Can only append another LineSet")
125
+ if self.has_line_colors != other.has_line_colors:
126
+ raise ValueError("Both line sets must have the same properties")
127
+ # Append vertices and vertex properties
128
+ vertex_offset = self.num_vertices
129
+ super().__iadd__(other)
130
+ # Append lines, adjusting vertex indices
131
+ self._lines = np.vstack((self._lines, other._lines + vertex_offset))
132
+ # Append line colors
133
+ if self.has_line_colors:
134
+ self._line_colors = np.vstack((self._line_colors, other._line_colors))
135
+ return self
136
+
137
+ @override
138
+ def __add__(self, other: Vertices) -> "LineSet":
139
+ """Concatenate two line sets
140
+
141
+ :param other: Other line set
142
+ :returns: New line set
143
+ """
144
+ result = self.copy()
145
+ result += other
146
+ return result
147
+
148
+ def copy(self) -> Self:
149
+ """Get copy of line set
150
+
151
+ :returns: Copy
152
+ """
153
+ return copy.deepcopy(self)
154
+
155
+ @property
156
+ def lines(self) -> np.ndarray:
157
+ """Get lines
158
+
159
+ Lines are 2-tuples of indices of type integer referencing vertices;
160
+ for example: the line (3, 7) is a line connecting vertices 3 and 7.
161
+
162
+ :returns: Lines, shape (n, 2), type int
163
+ """
164
+ return self._lines
165
+
166
+ @lines.setter
167
+ def lines(self, value: ArrayLike | None) -> None:
168
+ """Set lines
169
+
170
+ Lines are 2-tuples of indices of type integer referencing vertices;
171
+ for example: the line (3, 7) is a line connecting vertices 3 and 7.
172
+
173
+ If there are line colors defined in the line set,
174
+ then the number of lines has to match their number.
175
+
176
+ :param value: Lines, shape (n, 2), type int
177
+ """
178
+ if value is None:
179
+ value = np.zeros((0, 2), dtype=int)
180
+ else:
181
+ value = np.asarray(value, dtype=int)
182
+ if value.ndim != 2 or value.shape[1] != 2:
183
+ raise Exception("Provide lines of shape (n, 2)")
184
+ if self.has_line_colors and value.shape[0] != self._line_colors.shape[0]:
185
+ raise Exception(
186
+ "Line set already has line colors, provide proper amount of lines"
187
+ )
188
+ unique_indices = np.unique(value)
189
+ if unique_indices.size > 0 and (
190
+ unique_indices[0] < 0 or unique_indices[-1] >= self.num_vertices
191
+ ):
192
+ logging.warning("Some lines reference invalid vertices.")
193
+ if unique_indices.size != self.num_vertices:
194
+ logging.warning("Some vertices are not referenced by lines.")
195
+ self._lines = value
196
+
197
+ @property
198
+ def line_colors(self) -> np.ndarray:
199
+ """Get line colors
200
+
201
+ RGB values in range [0..1].
202
+
203
+ :returns: Line colors, shape (n, 3)
204
+ """
205
+ return self._line_colors
206
+
207
+ @line_colors.setter
208
+ def line_colors(self, value: ArrayLike | None) -> None:
209
+ """Set line colors
210
+
211
+ If there are lines defined in the line set, then the number
212
+ of line colors has to match the number of lines.
213
+
214
+ Line colors are RGB values in range [0..1]. If values outside that
215
+ range are provided, values will be clipped and a warnig is given.
216
+
217
+ :param value: Line colors, shape (n, 3)
218
+ """
219
+ if value is None:
220
+ line_colors = np.zeros((0, 3))
221
+ else:
222
+ line_colors = np.asarray(value, dtype=float)
223
+ if line_colors.ndim != 2 or line_colors.shape[1] != 3:
224
+ raise ValueError("Provide line colors of shape (n, 3)")
225
+ if line_colors.shape[0] > 0 and line_colors.shape[0] != self.num_lines:
226
+ raise ValueError("Provide as many line colors as lines")
227
+ if not np.all(np.logical_and(line_colors >= 0.0, line_colors <= 1.0)):
228
+ logging.warning("Some colors are out of bounds. Clipping to [0,1] ...")
229
+ line_colors = np.clip(line_colors, 0.0, 1.0)
230
+ self._line_colors = line_colors
231
+
232
+ @property
233
+ def has_line_colors(self) -> bool:
234
+ """Check if line set has line colors
235
+
236
+ :returns: True, if line set has line colors
237
+ """
238
+ return self._line_colors.shape[0] > 0
239
+
240
+ def colorize_lines_by_scalar(
241
+ self,
242
+ scalars: ArrayLike,
243
+ cmap: str = "viridis",
244
+ min_max: tuple[float, float] | None = None,
245
+ nan_color: tuple[float, float, float] = (1, 0, 0),
246
+ ) -> None:
247
+ """Colorizes line set lines by scalar
248
+
249
+ :param scalars: Scalar values, length has to match number of lines
250
+ :param cmap: Matplotlib color map string
251
+ :param min_max: Minimum and maximum
252
+ :param nan_color: Color for NaN values, RGB values [0..1]
253
+ """
254
+ scalars = np.asarray(scalars)
255
+ if scalars.ndim != 1 or scalars.size != self.num_lines:
256
+ raise Exception("Provide correct number of values to colorize lines")
257
+ self._line_colors = scalar_to_color(scalars, cmap, min_max, nan_color)
258
+
259
+ def colorize_lines_by_scalar_bicolor(
260
+ self,
261
+ scalars: ArrayLike,
262
+ cmaps: tuple[str, str] = ("Blues", "Reds"),
263
+ vmax: float | None = None,
264
+ nan_color: tuple[float, float, float] = (0, 1, 0),
265
+ ) -> None:
266
+ """Colorizes line set lines by scalar, pos/neg in separate colors
267
+
268
+ :param scalars: Scalar values, length has to match number of lines
269
+ :param cmaps: Matplotlib color map strings for pos/neg
270
+ :param vmax: Maximum (pos/neg) value
271
+ :param nan_color: Color for NaN values, RGB values [0..1]
272
+ """
273
+ scalars = np.asarray(scalars)
274
+ if scalars.ndim != 1 or scalars.size != self.num_lines:
275
+ raise Exception("Provide correct number of values to colorize lines")
276
+ self._line_colors = scalar_to_bicolor(scalars, cmaps, vmax, nan_color)
277
+
278
+ @classmethod
279
+ def create_from_point_sequence(cls, points: np.ndarray) -> "LineSet":
280
+ return cls(
281
+ vertices=points,
282
+ lines=np.vstack(
283
+ (
284
+ np.arange(0, points.shape[0] - 1),
285
+ np.arange(1, points.shape[0]),
286
+ )
287
+ ).T,
288
+ )
289
+
290
+ @classmethod
291
+ def create_from_normals(
292
+ cls,
293
+ vertices: Vertices,
294
+ scale: float = 1.0,
295
+ keep_ratio: float = 1.0,
296
+ rng: np.random.Generator | None = None,
297
+ ) -> "LineSet":
298
+ """Create line set from the normals of a point cloud or triangle mesh
299
+
300
+ :param vertices: Vertices object, i.e. point cloud or triangle mesh
301
+ :param scale: Length of the normal vectors
302
+ :param keep_ratio: Ratio of normal vectors to create,
303
+ 0.1 randomly samples 10% in normal vectors in vertices
304
+ :param rng: NumPy random number generator
305
+ :returns: Resulting line set
306
+ """
307
+ if not vertices.has_normals:
308
+ raise Exception("No normals to create lineset from")
309
+ if np.isclose(keep_ratio, 1.0):
310
+ num_normals = vertices.vertices.shape[0]
311
+ verts = np.vstack(
312
+ (
313
+ vertices.vertices,
314
+ vertices.vertices + scale * vertices.normals,
315
+ )
316
+ )
317
+ else:
318
+ if rng is None:
319
+ rng = np.random.default_rng()
320
+ keep_ratio = np.clip(keep_ratio, 0.0, 1.0)
321
+ num_normals = np.round(keep_ratio * vertices.vertices.shape[0]).astype(int)
322
+ normal_indices = rng.choice(
323
+ vertices.vertices.shape[0], num_normals, replace=False
324
+ )
325
+ verts = np.vstack(
326
+ (
327
+ vertices.vertices[normal_indices],
328
+ vertices.vertices[normal_indices]
329
+ + scale * vertices.normals[normal_indices],
330
+ )
331
+ )
332
+ return cls(
333
+ vertices=verts,
334
+ lines=np.vstack(
335
+ (
336
+ np.arange(num_normals),
337
+ np.arange(num_normals) + num_normals,
338
+ )
339
+ ).T,
340
+ )
341
+
342
+ @classmethod
343
+ def create_from_mesh(cls, mesh: TriangleMesh) -> "LineSet":
344
+ """Creates line set from triangle edges of a mesh
345
+
346
+ Creates a line set that represents the wire frame of the triangle
347
+ mesh: we extract each unique edge of the triangle mesh and add
348
+ that as a line to our line set.
349
+
350
+ :param mesh: Triangle mesh
351
+ :returns: Resulting line set
352
+ """
353
+ vertices = mesh.vertices
354
+ lines = mesh.get_unique_edges()
355
+ if mesh.has_colors:
356
+ vertex_colors = mesh.colors
357
+ else:
358
+ # Edges separate faces; does not make sense to use
359
+ # face colors to colorize edges!?
360
+ vertex_colors = None
361
+ return cls(
362
+ vertices=vertices,
363
+ lines=lines,
364
+ vertex_colors=vertex_colors,
365
+ )
366
+
367
+ @classmethod
368
+ def create_box(cls, scale: float | ArrayLike = 1.0) -> "LineSet":
369
+ """Create a box shaped line set
370
+
371
+ Box has 8 vertices and 12 lines.
372
+ Box starts at (0, 0, 0) and extends to (scale, scale, scale).
373
+
374
+ :param scale: Scale of rectangle for X, Y and Z as single float or 3d
375
+ :returns: Resulting line set
376
+ """
377
+ if isinstance(scale, float):
378
+ scale = np.array((scale, scale, scale))
379
+ else:
380
+ scale = np.asarray(scale)
381
+ if scale.size != 3:
382
+ raise Exception("Provide proper scale.")
383
+ vertices = scale * np.array(
384
+ [
385
+ [0.0, 0.0, 0.0], # 0: bottom-back-left
386
+ [1.0, 0.0, 0.0], # 1: bottom-back-right
387
+ [1.0, 1.0, 0.0], # 2: bottom-front-right
388
+ [0.0, 1.0, 0.0], # 3: bottom-front-left
389
+ [0.0, 0.0, 1.0], # 4: top-back-left
390
+ [1.0, 0.0, 1.0], # 5: top-back-right
391
+ [1.0, 1.0, 1.0], # 6: top-front-right
392
+ [0.0, 1.0, 1.0], # 7: top-front-left
393
+ ],
394
+ dtype=float,
395
+ )
396
+ lines = np.array(
397
+ [
398
+ # Bottom lines
399
+ [0, 1],
400
+ [1, 2],
401
+ [2, 3],
402
+ [3, 0],
403
+ # Top lines
404
+ [4, 5],
405
+ [5, 6],
406
+ [6, 7],
407
+ [7, 4],
408
+ # Side lines
409
+ [0, 4],
410
+ [1, 5],
411
+ [2, 6],
412
+ [3, 7],
413
+ ]
414
+ )
415
+ return cls(vertices=vertices, lines=lines)
416
+
417
+ @classmethod
418
+ def create_bounding_box(cls, bounds: np.ndarray):
419
+ """Create a line set representing a line set
420
+
421
+ Box has 8 vertices and 12 lines.
422
+ Box starts at bounds[0] and extends to bounds[1].
423
+
424
+ Can directly used with Vertices::bounds() to generate a
425
+ bounding box for e.g. a point cloud or triangle mesh.
426
+
427
+ :param bounds: Min and max values for all coordinates, shape (2, 3)
428
+ :returns: Resulting line set
429
+ """
430
+ if bounds.ndim != 2 and bounds.shape != (2, 3):
431
+ raise Exception(f"Invalid bounds {bounds}, expected shape (2, 3)")
432
+ bounds = np.array(bounds)
433
+ box = cls.create_box(scale=bounds[1] - bounds[0])
434
+ box.vertices += bounds[0]
435
+ return box