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.
- gri_plot/__init__.py +94 -0
- gri_plot/figure3d.py +476 -0
- gri_plot/frames.py +181 -0
- gri_plot/observables/__init__.py +21 -0
- gri_plot/observables/aoa.py +123 -0
- gri_plot/observables/fdoa.py +254 -0
- gri_plot/observables/los.py +154 -0
- gri_plot/observables/range_sphere.py +74 -0
- gri_plot/observables/tdoa.py +201 -0
- gri_plot/observables/terrain.py +293 -0
- gri_plot/plot_ellipse.py +183 -0
- gri_plot/py.typed +0 -0
- gri_plot/scatter.py +76 -0
- gri_plot/scatter_map.py +93 -0
- gri_plot/shapes/__init__.py +19 -0
- gri_plot/shapes/cone.py +227 -0
- gri_plot/shapes/cylinder.py +219 -0
- gri_plot/shapes/ellipse.py +117 -0
- gri_plot/shapes/ellipsoid.py +227 -0
- gri_plot/shapes/meshgen.py +245 -0
- gri_plot/shapes/sphere.py +152 -0
- gri_plot/surfaces/__init__.py +171 -0
- gri_plot/surfaces/gradients/__init__.py +39 -0
- gri_plot/surfaces/gradients/axis.py +74 -0
- gri_plot/surfaces/gradients/line.py +86 -0
- gri_plot/surfaces/gradients/plane.py +53 -0
- gri_plot/surfaces/gradients/point.py +40 -0
- gri_plot/surfaces/intersection.py +217 -0
- gri_plot/surfaces/mesh.py +603 -0
- gri_plot-0.2.0.post1.dist-info/METADATA +149 -0
- gri_plot-0.2.0.post1.dist-info/RECORD +33 -0
- gri_plot-0.2.0.post1.dist-info/WHEEL +4 -0
- gri_plot-0.2.0.post1.dist-info/licenses/LICENSE +21 -0
|
@@ -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"]
|