gri-plot 0.2.0.post1__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,227 @@
1
+ """Ellipsoid shape for 3D visualization."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ import numpy as np
6
+
7
+ from gri_plot.shapes.meshgen import ellipsoid_mesh
8
+ from gri_plot.surfaces import ImplicitShape
9
+ from gri_plot.surfaces.mesh import vertices_to_mesh3d
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Callable
13
+
14
+ import plotly.graph_objects as go
15
+ from numpy.typing import NDArray
16
+
17
+
18
+ class Ellipsoid(ImplicitShape):
19
+ """A 3D ellipsoid defined by center and covariance matrix.
20
+
21
+ The ellipsoid represents a 1-sigma contour of a 3D Gaussian distribution,
22
+ or can be specified by semi-axes and rotation.
23
+
24
+ The residual function returns Mahalanobis distance squared - 1, so:
25
+ - 0 on the 1-sigma surface
26
+ - Positive outside
27
+ - Negative inside
28
+
29
+ Attributes:
30
+ center_xyz: Center of the ellipsoid in XYZ coordinates.
31
+ covariance: Covariance matrix (3x3).
32
+ semi_axes: Semi-axis lengths (a, b, c).
33
+ rotation: Rotation matrix (3x3).
34
+ label: Optional label for legends.
35
+ is_volume: Whether this shape represents a volume or surface.
36
+ """
37
+
38
+ def __init__( # noqa: PLR0913 - supports covariance or semi-axes + rotation
39
+ self,
40
+ center_xyz: NDArray[np.floating],
41
+ covariance: NDArray[np.floating] | None = None,
42
+ semi_axes: NDArray[np.floating] | None = None,
43
+ rotation: NDArray[np.floating] | None = None,
44
+ label: str | None = None,
45
+ *,
46
+ as_volume: bool = True,
47
+ ) -> None:
48
+ """Initialize the ellipsoid.
49
+
50
+ The ellipsoid can be defined either by a covariance matrix or by
51
+ semi-axes and rotation. If covariance is provided, semi_axes and
52
+ rotation are derived from its eigendecomposition.
53
+
54
+ Args:
55
+ center_xyz: Center of the ellipsoid, shape (3,).
56
+ covariance: Covariance matrix, shape (3, 3). Mutually exclusive
57
+ with semi_axes/rotation.
58
+ semi_axes: Semi-axis lengths (a, b, c) in descending order.
59
+ Required if covariance is None.
60
+ rotation: Rotation matrix, shape (3, 3). Columns are the principal
61
+ axes. Defaults to identity if not provided.
62
+ label: Optional label for legends.
63
+ as_volume: Keyword-only. If True, interior is solution region.
64
+ If False, boundary is solution locus. Default True.
65
+
66
+ Raises:
67
+ ValueError: If neither covariance nor semi_axes is provided, or
68
+ if both are provided.
69
+ """
70
+ self._center = np.asarray(center_xyz, dtype=np.float64)
71
+ self._label = label
72
+ self._is_volume = as_volume
73
+
74
+ if covariance is not None and semi_axes is not None:
75
+ raise ValueError("Provide either covariance or semi_axes, not both")
76
+
77
+ if covariance is not None:
78
+ self._covariance = np.asarray(covariance, dtype=np.float64)
79
+ # Eigendecomposition
80
+ eigenvalues, eigenvectors = np.linalg.eigh(self._covariance)
81
+ # Sort in descending order
82
+ idx = np.argsort(eigenvalues)[::-1]
83
+ eigenvalues = eigenvalues[idx]
84
+ eigenvectors = eigenvectors[:, idx]
85
+
86
+ self._semi_axes = np.sqrt(np.abs(eigenvalues))
87
+ self._rotation = eigenvectors
88
+ # Compute inverse covariance for residual
89
+ self._cov_inv = np.linalg.inv(self._covariance)
90
+
91
+ elif semi_axes is not None:
92
+ self._semi_axes = np.asarray(semi_axes, dtype=np.float64)
93
+ if rotation is None:
94
+ self._rotation = np.eye(3)
95
+ else:
96
+ self._rotation = np.asarray(rotation, dtype=np.float64)
97
+
98
+ # Reconstruct covariance: C = R @ diag(s^2) @ R.T
99
+ s_squared = np.diag(self._semi_axes**2)
100
+ self._covariance = self._rotation @ s_squared @ self._rotation.T
101
+ self._cov_inv = np.linalg.inv(self._covariance)
102
+
103
+ else:
104
+ raise ValueError("Either covariance or semi_axes must be provided")
105
+
106
+ @property
107
+ def center_xyz(self) -> NDArray[np.floating]:
108
+ """Get the center in XYZ coordinates."""
109
+ return self._center
110
+
111
+ @property
112
+ def covariance(self) -> NDArray[np.floating]:
113
+ """Get the covariance matrix."""
114
+ return self._covariance
115
+
116
+ @property
117
+ def semi_axes(self) -> NDArray[np.floating]:
118
+ """Get the semi-axis lengths."""
119
+ return self._semi_axes
120
+
121
+ @property
122
+ def rotation(self) -> NDArray[np.floating]:
123
+ """Get the rotation matrix."""
124
+ return self._rotation
125
+
126
+ @property
127
+ def label(self) -> str | None:
128
+ """Get the label."""
129
+ return self._label
130
+
131
+ @property
132
+ def is_volume(self) -> bool:
133
+ """Whether this shape represents a volume (True) or surface (False)."""
134
+ return self._is_volume
135
+
136
+ def residual_fn(self, xyz: NDArray[np.floating]) -> NDArray[np.floating]:
137
+ """Compute the residual function (Mahalanobis distance squared - 1).
138
+
139
+ Args:
140
+ xyz: Points to evaluate, shape (..., 3).
141
+
142
+ Returns:
143
+ Residual values, shape (...). Zero on surface, positive outside,
144
+ negative inside.
145
+ """
146
+ xyz = np.asarray(xyz)
147
+ diff = xyz - self._center
148
+
149
+ # Mahalanobis distance squared: d^2 = (x-mu)^T @ C^-1 @ (x-mu)
150
+ # Using einsum for batch computation
151
+ temp = np.einsum("...i,ij->...j", diff, self._cov_inv)
152
+ mahal_sq = np.einsum("...i,...i->...", diff, temp)
153
+
154
+ return mahal_sq - 1.0
155
+
156
+ def to_mesh(
157
+ self,
158
+ resolution: int | None = None,
159
+ ) -> tuple[NDArray[np.floating], NDArray[np.integer]]:
160
+ """Generate mesh vertices and faces.
161
+
162
+ Args:
163
+ resolution: Number of samples per dimension. If None, uses 50.
164
+
165
+ Returns:
166
+ Tuple of (vertices, faces).
167
+ """
168
+ if resolution is None:
169
+ resolution = 50
170
+ return ellipsoid_mesh(
171
+ self._center,
172
+ semi_axes=self._semi_axes,
173
+ rotation=self._rotation,
174
+ resolution=resolution,
175
+ )
176
+
177
+ def get_bounds_xyz(
178
+ self,
179
+ ) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
180
+ """Get axis-aligned bounding box in XYZ coordinates.
181
+
182
+ The bounding box accounts for the ellipsoid's rotation.
183
+
184
+ Returns:
185
+ Tuple of (min_corner, max_corner).
186
+ """
187
+ # The extent in each axis direction
188
+ # For an ellipsoid with semi-axes s and rotation R, the extent along
189
+ # axis e_i is sqrt(sum_j (R[i,j] * s[j])^2)
190
+ extents = np.zeros(3)
191
+ for i in range(3):
192
+ extents[i] = np.sqrt(np.sum((self._rotation[i, :] * self._semi_axes) ** 2))
193
+
194
+ return self._center - extents, self._center + extents
195
+
196
+ def to_trace(
197
+ self,
198
+ resolution: int | None = None,
199
+ intensity_fn: Callable[[NDArray[np.floating]], NDArray[np.floating]]
200
+ | None = None,
201
+ **kwargs,
202
+ ) -> go.Mesh3d:
203
+ """Generate a Plotly Mesh3d trace.
204
+
205
+ Args:
206
+ resolution: Mesh resolution.
207
+ intensity_fn: Optional intensity function for coloring.
208
+ **kwargs: Additional arguments for vertices_to_mesh3d.
209
+
210
+ Returns:
211
+ Plotly Mesh3d trace.
212
+ """
213
+ vertices, faces = self.to_mesh(resolution)
214
+ return vertices_to_mesh3d(
215
+ vertices,
216
+ faces,
217
+ intensity_fn=intensity_fn,
218
+ name=self._label,
219
+ **kwargs,
220
+ )
221
+
222
+ def __repr__(self) -> str:
223
+ """Return string representation."""
224
+ return f"Ellipsoid(center={self._center}, semi_axes={self._semi_axes})"
225
+
226
+
227
+ __all__ = ["Ellipsoid"]
@@ -0,0 +1,245 @@
1
+ """Mesh generation for geometric shapes.
2
+
3
+ This module provides functions to generate vertices and faces for common
4
+ 3D shapes. The output can be passed to vertices_to_mesh3d() for rendering.
5
+ """
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ import numpy as np
10
+
11
+ from gri_plot.surfaces.mesh import grid_to_mesh
12
+
13
+ # Threshold for choosing perpendicular vector when building orthonormal basis.
14
+ # When |axis[2]| < this value, cross with Z-axis; otherwise cross with X-axis.
15
+ AXIS_ALIGNMENT_THRESHOLD = 0.9
16
+
17
+ if TYPE_CHECKING:
18
+ from numpy.typing import NDArray
19
+
20
+
21
+ def sphere_mesh(
22
+ center: NDArray[np.floating],
23
+ radius: float,
24
+ resolution: int = 50,
25
+ ) -> tuple[NDArray[np.floating], NDArray[np.integer]]:
26
+ """Generate vertices and faces for a sphere.
27
+
28
+ Args:
29
+ center: Center of the sphere, shape (3,).
30
+ radius: Radius of the sphere.
31
+ resolution: Number of samples per dimension.
32
+
33
+ Returns:
34
+ Tuple of (vertices, faces).
35
+ """
36
+ center = np.asarray(center)
37
+
38
+ u = np.linspace(0, 2 * np.pi, resolution)
39
+ v = np.linspace(0, np.pi, resolution)
40
+ u_grid, v_grid = np.meshgrid(u, v)
41
+
42
+ x = center[0] + radius * np.cos(u_grid) * np.sin(v_grid)
43
+ y = center[1] + radius * np.sin(u_grid) * np.sin(v_grid)
44
+ z = center[2] + radius * np.cos(v_grid)
45
+
46
+ return grid_to_mesh(x, y, z)
47
+
48
+
49
+ def ellipsoid_mesh(
50
+ center: NDArray[np.floating],
51
+ covariance: NDArray[np.floating] | None = None,
52
+ semi_axes: NDArray[np.floating] | None = None,
53
+ rotation: NDArray[np.floating] | None = None,
54
+ resolution: int = 50,
55
+ ) -> tuple[NDArray[np.floating], NDArray[np.integer]]:
56
+ """Generate vertices and faces for an ellipsoid.
57
+
58
+ The ellipsoid can be specified either by a covariance matrix or by
59
+ semi-axes and rotation matrix.
60
+
61
+ Args:
62
+ center: Center of the ellipsoid, shape (3,).
63
+ covariance: Covariance matrix, shape (3, 3). If provided, semi_axes
64
+ and rotation are computed from it.
65
+ semi_axes: Semi-axis lengths (a, b, c). Required if covariance is None.
66
+ rotation: Rotation matrix, shape (3, 3). Defaults to identity.
67
+ resolution: Number of samples per dimension.
68
+
69
+ Returns:
70
+ Tuple of (vertices, faces).
71
+
72
+ Raises:
73
+ ValueError: If neither covariance nor semi_axes is provided.
74
+ """
75
+ center = np.asarray(center)
76
+
77
+ if covariance is not None:
78
+ covariance = np.asarray(covariance)
79
+ eigenvalues, eigenvectors = np.linalg.eigh(covariance)
80
+ # Sort in descending order
81
+ idx = np.argsort(eigenvalues)[::-1]
82
+ eigenvalues = eigenvalues[idx]
83
+ eigenvectors = eigenvectors[:, idx]
84
+
85
+ semi_axes = np.sqrt(np.abs(eigenvalues))
86
+ rotation = eigenvectors
87
+
88
+ elif semi_axes is not None:
89
+ semi_axes = np.asarray(semi_axes)
90
+ rotation = np.eye(3) if rotation is None else np.asarray(rotation)
91
+ else:
92
+ raise ValueError("Either covariance or semi_axes must be provided")
93
+
94
+ # Generate unit sphere
95
+ u = np.linspace(0, 2 * np.pi, resolution)
96
+ v = np.linspace(0, np.pi, resolution)
97
+ u_grid, v_grid = np.meshgrid(u, v)
98
+
99
+ x_sphere = np.cos(u_grid) * np.sin(v_grid)
100
+ y_sphere = np.sin(u_grid) * np.sin(v_grid)
101
+ z_sphere = np.cos(v_grid)
102
+
103
+ # Scale by semi-axes
104
+ x_ellipsoid = semi_axes[0] * x_sphere
105
+ y_ellipsoid = semi_axes[1] * y_sphere
106
+ z_ellipsoid = semi_axes[2] * z_sphere
107
+
108
+ # Stack and rotate
109
+ points = np.stack([x_ellipsoid, y_ellipsoid, z_ellipsoid], axis=-1)
110
+ rotated = np.einsum("ij,...j->...i", rotation, points)
111
+
112
+ # Translate to center
113
+ x = rotated[..., 0] + center[0]
114
+ y = rotated[..., 1] + center[1]
115
+ z = rotated[..., 2] + center[2]
116
+
117
+ return grid_to_mesh(x, y, z)
118
+
119
+
120
+ def cone_mesh(
121
+ apex: NDArray[np.floating],
122
+ axis: NDArray[np.floating],
123
+ half_angle: float,
124
+ height: float,
125
+ resolution: int = 50,
126
+ ) -> tuple[NDArray[np.floating], NDArray[np.integer]]:
127
+ """Generate vertices and faces for a cone.
128
+
129
+ The cone extends from the apex along the axis direction.
130
+
131
+ Args:
132
+ apex: Apex (tip) of the cone, shape (3,).
133
+ axis: Axis direction (normalized or not), shape (3,).
134
+ half_angle: Half-angle of the cone in radians.
135
+ height: Height (length along axis) of the cone.
136
+ resolution: Number of samples per dimension.
137
+
138
+ Returns:
139
+ Tuple of (vertices, faces).
140
+ """
141
+ apex = np.asarray(apex)
142
+ axis = np.asarray(axis)
143
+ axis = axis / np.linalg.norm(axis)
144
+
145
+ # Create orthonormal basis
146
+ if abs(axis[2]) < AXIS_ALIGNMENT_THRESHOLD:
147
+ perp1 = np.cross(axis, np.array([0.0, 0.0, 1.0]))
148
+ else:
149
+ perp1 = np.cross(axis, np.array([1.0, 0.0, 0.0]))
150
+ perp1 = perp1 / np.linalg.norm(perp1)
151
+ perp2 = np.cross(axis, perp1)
152
+
153
+ # Parametric surface: h from 0 to height, theta from 0 to 2*pi
154
+ h = np.linspace(0, height, resolution)
155
+ theta = np.linspace(0, 2 * np.pi, resolution)
156
+ h_grid, theta_grid = np.meshgrid(h, theta)
157
+
158
+ # Radius at height h
159
+ r = h_grid * np.tan(half_angle)
160
+
161
+ # Position along cone surface
162
+ x = (
163
+ apex[0]
164
+ + h_grid * axis[0]
165
+ + r * (np.cos(theta_grid) * perp1[0] + np.sin(theta_grid) * perp2[0])
166
+ )
167
+ y = (
168
+ apex[1]
169
+ + h_grid * axis[1]
170
+ + r * (np.cos(theta_grid) * perp1[1] + np.sin(theta_grid) * perp2[1])
171
+ )
172
+ z = (
173
+ apex[2]
174
+ + h_grid * axis[2]
175
+ + r * (np.cos(theta_grid) * perp1[2] + np.sin(theta_grid) * perp2[2])
176
+ )
177
+
178
+ return grid_to_mesh(x, y, z)
179
+
180
+
181
+ def cylinder_mesh(
182
+ center: NDArray[np.floating],
183
+ axis: NDArray[np.floating],
184
+ radius: float,
185
+ height: float,
186
+ resolution: int = 50,
187
+ ) -> tuple[NDArray[np.floating], NDArray[np.integer]]:
188
+ """Generate vertices and faces for a cylinder.
189
+
190
+ The cylinder is centered at center and extends height/2 in each
191
+ direction along the axis.
192
+
193
+ Args:
194
+ center: Center of the cylinder, shape (3,).
195
+ axis: Axis direction (normalized or not), shape (3,).
196
+ radius: Radius of the cylinder.
197
+ height: Total height of the cylinder.
198
+ resolution: Number of samples per dimension.
199
+
200
+ Returns:
201
+ Tuple of (vertices, faces).
202
+ """
203
+ center = np.asarray(center)
204
+ axis = np.asarray(axis)
205
+ axis = axis / np.linalg.norm(axis)
206
+
207
+ # Create orthonormal basis
208
+ if abs(axis[2]) < AXIS_ALIGNMENT_THRESHOLD:
209
+ perp1 = np.cross(axis, np.array([0.0, 0.0, 1.0]))
210
+ else:
211
+ perp1 = np.cross(axis, np.array([1.0, 0.0, 0.0]))
212
+ perp1 = perp1 / np.linalg.norm(perp1)
213
+ perp2 = np.cross(axis, perp1)
214
+
215
+ # Parametric surface: h from -height/2 to height/2, theta from 0 to 2*pi
216
+ h = np.linspace(-height / 2, height / 2, resolution)
217
+ theta = np.linspace(0, 2 * np.pi, resolution)
218
+ h_grid, theta_grid = np.meshgrid(h, theta)
219
+
220
+ # Position along cylinder surface
221
+ x = (
222
+ center[0]
223
+ + h_grid * axis[0]
224
+ + radius * (np.cos(theta_grid) * perp1[0] + np.sin(theta_grid) * perp2[0])
225
+ )
226
+ y = (
227
+ center[1]
228
+ + h_grid * axis[1]
229
+ + radius * (np.cos(theta_grid) * perp1[1] + np.sin(theta_grid) * perp2[1])
230
+ )
231
+ z = (
232
+ center[2]
233
+ + h_grid * axis[2]
234
+ + radius * (np.cos(theta_grid) * perp1[2] + np.sin(theta_grid) * perp2[2])
235
+ )
236
+
237
+ return grid_to_mesh(x, y, z)
238
+
239
+
240
+ __all__ = [
241
+ "cone_mesh",
242
+ "cylinder_mesh",
243
+ "ellipsoid_mesh",
244
+ "sphere_mesh",
245
+ ]
@@ -0,0 +1,152 @@
1
+ """Sphere shape for 3D visualization."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ import numpy as np
6
+
7
+ from gri_plot.shapes.meshgen import sphere_mesh
8
+ from gri_plot.surfaces import ImplicitShape
9
+ from gri_plot.surfaces.mesh import vertices_to_mesh3d
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Callable
13
+
14
+ import plotly.graph_objects as go
15
+ from numpy.typing import NDArray
16
+
17
+
18
+ class Sphere(ImplicitShape):
19
+ """A 3D sphere defined by center and radius.
20
+
21
+ Attributes:
22
+ center_xyz: Center of the sphere in XYZ coordinates.
23
+ radius: Radius of the sphere.
24
+ label: Optional label for legends.
25
+ is_volume: Whether this shape represents a volume or surface.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ center_xyz: NDArray[np.floating],
31
+ radius: float,
32
+ label: str | None = None,
33
+ *,
34
+ as_volume: bool = True,
35
+ ) -> None:
36
+ """Initialize the sphere.
37
+
38
+ Args:
39
+ center_xyz: Center of the sphere, shape (3,).
40
+ radius: Radius of the sphere (positive).
41
+ label: Optional label for legends.
42
+ as_volume: Keyword-only. If True, interior is solution region.
43
+ If False, boundary is solution locus. Default True.
44
+
45
+ Raises:
46
+ ValueError: If radius is not positive.
47
+ """
48
+ self._center = np.asarray(center_xyz, dtype=np.float64)
49
+ if radius <= 0:
50
+ raise ValueError("Radius must be positive")
51
+ self._radius = float(radius)
52
+ self._label = label
53
+ self._is_volume = as_volume
54
+
55
+ @property
56
+ def center_xyz(self) -> NDArray[np.floating]:
57
+ """Get the center in XYZ coordinates."""
58
+ return self._center
59
+
60
+ @property
61
+ def radius(self) -> float:
62
+ """Get the radius."""
63
+ return self._radius
64
+
65
+ @property
66
+ def label(self) -> str | None:
67
+ """Get the label."""
68
+ return self._label
69
+
70
+ @property
71
+ def is_volume(self) -> bool:
72
+ """Whether this shape represents a volume (True) or surface (False)."""
73
+ return self._is_volume
74
+
75
+ def residual_fn(self, xyz: NDArray[np.floating]) -> NDArray[np.floating]:
76
+ """Compute the residual function for the sphere.
77
+
78
+ The residual is distance_from_center - radius, so:
79
+ - 0 on the surface
80
+ - Positive outside
81
+ - Negative inside
82
+
83
+ Args:
84
+ xyz: Points to evaluate, shape (..., 3).
85
+
86
+ Returns:
87
+ Residual values, shape (...).
88
+ """
89
+ xyz = np.asarray(xyz)
90
+ diff = xyz - self._center
91
+ distance = np.linalg.norm(diff, axis=-1)
92
+ return distance - self._radius
93
+
94
+ def to_mesh(
95
+ self,
96
+ resolution: int | None = None,
97
+ ) -> tuple[NDArray[np.floating], NDArray[np.integer]]:
98
+ """Generate mesh vertices and faces.
99
+
100
+ Args:
101
+ resolution: Number of samples per dimension. If None, uses 50.
102
+
103
+ Returns:
104
+ Tuple of (vertices, faces).
105
+ """
106
+ if resolution is None:
107
+ resolution = 50
108
+ return sphere_mesh(self._center, self._radius, resolution)
109
+
110
+ def get_bounds_xyz(
111
+ self,
112
+ ) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
113
+ """Get axis-aligned bounding box in XYZ coordinates.
114
+
115
+ Returns:
116
+ Tuple of (min_corner, max_corner).
117
+ """
118
+ offset = np.array([self._radius, self._radius, self._radius])
119
+ return self._center - offset, self._center + offset
120
+
121
+ def to_trace(
122
+ self,
123
+ resolution: int | None = None,
124
+ intensity_fn: Callable[[NDArray[np.floating]], NDArray[np.floating]]
125
+ | None = None,
126
+ **kwargs,
127
+ ) -> go.Mesh3d:
128
+ """Generate a Plotly Mesh3d trace.
129
+
130
+ Args:
131
+ resolution: Mesh resolution. If None, uses default (50).
132
+ intensity_fn: Optional intensity function for coloring.
133
+ **kwargs: Additional arguments for vertices_to_mesh3d.
134
+
135
+ Returns:
136
+ Plotly Mesh3d trace.
137
+ """
138
+ vertices, faces = self.to_mesh(resolution)
139
+ return vertices_to_mesh3d(
140
+ vertices,
141
+ faces,
142
+ intensity_fn=intensity_fn,
143
+ name=self._label,
144
+ **kwargs,
145
+ )
146
+
147
+ def __repr__(self) -> str:
148
+ """Return string representation."""
149
+ return f"Sphere(center={self._center}, radius={self._radius})"
150
+
151
+
152
+ __all__ = ["Sphere"]