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
gri_plot/shapes/cone.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Cone 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 AXIS_ALIGNMENT_THRESHOLD, cone_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 Cone(ImplicitShape):
|
|
19
|
+
"""A 3D cone defined by apex, axis direction, and half-angle.
|
|
20
|
+
|
|
21
|
+
The cone extends from the apex along the axis direction with the
|
|
22
|
+
specified half-angle.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
apex_xyz: Apex (tip) of the cone in XYZ coordinates.
|
|
26
|
+
axis: Normalized axis direction.
|
|
27
|
+
half_angle: Half-angle of the cone in radians.
|
|
28
|
+
height: Height (length along axis) of the cone.
|
|
29
|
+
label: Optional label for legends.
|
|
30
|
+
is_volume: Whether this shape represents a volume or surface.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__( # noqa: PLR0913 - 3D cone requires apex + axis + angle + height
|
|
34
|
+
self,
|
|
35
|
+
apex_xyz: NDArray[np.floating],
|
|
36
|
+
axis: NDArray[np.floating],
|
|
37
|
+
half_angle: float,
|
|
38
|
+
height: float,
|
|
39
|
+
label: str | None = None,
|
|
40
|
+
*,
|
|
41
|
+
as_volume: bool = True,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Initialize the cone.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
apex_xyz: Apex (tip) of the cone, shape (3,).
|
|
47
|
+
axis: Axis direction (will be normalized), shape (3,).
|
|
48
|
+
half_angle: Half-angle of the cone in radians (0 < angle < pi/2).
|
|
49
|
+
height: Height (length along axis) of the cone (positive).
|
|
50
|
+
label: Optional label for legends.
|
|
51
|
+
as_volume: Keyword-only. If True, interior is solution region.
|
|
52
|
+
If False, boundary is solution locus. Default True.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
ValueError: If half_angle is not in (0, pi/2) or height is not positive.
|
|
56
|
+
"""
|
|
57
|
+
self._apex = np.asarray(apex_xyz, dtype=np.float64)
|
|
58
|
+
axis = np.asarray(axis, dtype=np.float64)
|
|
59
|
+
self._axis = axis / np.linalg.norm(axis)
|
|
60
|
+
|
|
61
|
+
if not 0 < half_angle < np.pi / 2:
|
|
62
|
+
raise ValueError("half_angle must be in (0, pi/2)")
|
|
63
|
+
self._half_angle = float(half_angle)
|
|
64
|
+
|
|
65
|
+
if height <= 0:
|
|
66
|
+
raise ValueError("height must be positive")
|
|
67
|
+
self._height = float(height)
|
|
68
|
+
|
|
69
|
+
self._label = label
|
|
70
|
+
self._is_volume = as_volume
|
|
71
|
+
|
|
72
|
+
# Precompute orthonormal basis for residual calculations
|
|
73
|
+
self._compute_basis()
|
|
74
|
+
|
|
75
|
+
def _compute_basis(self) -> None:
|
|
76
|
+
"""Compute orthonormal basis vectors perpendicular to axis."""
|
|
77
|
+
if abs(self._axis[2]) < AXIS_ALIGNMENT_THRESHOLD:
|
|
78
|
+
perp1 = np.cross(self._axis, np.array([0.0, 0.0, 1.0]))
|
|
79
|
+
else:
|
|
80
|
+
perp1 = np.cross(self._axis, np.array([1.0, 0.0, 0.0]))
|
|
81
|
+
self._perp1 = perp1 / np.linalg.norm(perp1)
|
|
82
|
+
self._perp2 = np.cross(self._axis, self._perp1)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def apex_xyz(self) -> NDArray[np.floating]:
|
|
86
|
+
"""Get the apex in XYZ coordinates."""
|
|
87
|
+
return self._apex
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def axis(self) -> NDArray[np.floating]:
|
|
91
|
+
"""Get the normalized axis direction."""
|
|
92
|
+
return self._axis
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def half_angle(self) -> float:
|
|
96
|
+
"""Get the half-angle in radians."""
|
|
97
|
+
return self._half_angle
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def height(self) -> float:
|
|
101
|
+
"""Get the height."""
|
|
102
|
+
return self._height
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def label(self) -> str | None:
|
|
106
|
+
"""Get the label."""
|
|
107
|
+
return self._label
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def is_volume(self) -> bool:
|
|
111
|
+
"""Whether this shape represents a volume (True) or surface (False)."""
|
|
112
|
+
return self._is_volume
|
|
113
|
+
|
|
114
|
+
def residual_fn(self, xyz: NDArray[np.floating]) -> NDArray[np.floating]:
|
|
115
|
+
"""Compute the residual function for the cone.
|
|
116
|
+
|
|
117
|
+
The residual is the angular deviation from the cone surface, so:
|
|
118
|
+
- 0 on the surface
|
|
119
|
+
- Positive outside the cone
|
|
120
|
+
- Negative inside the cone
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
xyz: Points to evaluate, shape (..., 3).
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Residual values, shape (...).
|
|
127
|
+
"""
|
|
128
|
+
xyz = np.asarray(xyz)
|
|
129
|
+
diff = xyz - self._apex
|
|
130
|
+
|
|
131
|
+
# Distance along axis
|
|
132
|
+
h = np.einsum("...i,i->...", diff, self._axis)
|
|
133
|
+
|
|
134
|
+
# Perpendicular distance
|
|
135
|
+
h_expanded = np.einsum("...i,i->...", diff, self._axis)[..., np.newaxis]
|
|
136
|
+
parallel = h_expanded * self._axis
|
|
137
|
+
perp = diff - parallel
|
|
138
|
+
r = np.linalg.norm(perp, axis=-1)
|
|
139
|
+
|
|
140
|
+
# Expected radius at this height
|
|
141
|
+
expected_r = h * np.tan(self._half_angle)
|
|
142
|
+
|
|
143
|
+
# Residual: difference between actual and expected radius
|
|
144
|
+
# Normalized by distance from apex to make it scale-independent
|
|
145
|
+
distance = np.linalg.norm(diff, axis=-1)
|
|
146
|
+
distance = np.maximum(distance, 1e-10) # Avoid division by zero
|
|
147
|
+
|
|
148
|
+
return (r - expected_r) / distance
|
|
149
|
+
|
|
150
|
+
def to_mesh(
|
|
151
|
+
self,
|
|
152
|
+
resolution: int | None = None,
|
|
153
|
+
) -> tuple[NDArray[np.floating], NDArray[np.integer]]:
|
|
154
|
+
"""Generate mesh vertices and faces.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
resolution: Number of samples per dimension. If None, uses 50.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Tuple of (vertices, faces).
|
|
161
|
+
"""
|
|
162
|
+
if resolution is None:
|
|
163
|
+
resolution = 50
|
|
164
|
+
return cone_mesh(
|
|
165
|
+
self._apex,
|
|
166
|
+
self._axis,
|
|
167
|
+
self._half_angle,
|
|
168
|
+
self._height,
|
|
169
|
+
resolution,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def get_bounds_xyz(
|
|
173
|
+
self,
|
|
174
|
+
) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
|
|
175
|
+
"""Get axis-aligned bounding box in XYZ coordinates.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Tuple of (min_corner, max_corner).
|
|
179
|
+
"""
|
|
180
|
+
# Tip of cone
|
|
181
|
+
tip = self._apex
|
|
182
|
+
# Base of cone
|
|
183
|
+
base_center = self._apex + self._height * self._axis
|
|
184
|
+
base_radius = self._height * np.tan(self._half_angle)
|
|
185
|
+
|
|
186
|
+
# The base circle can extend in any direction perpendicular to axis
|
|
187
|
+
# Compute the maximum extent by sampling base circle at cardinal directions
|
|
188
|
+
perps = [self._perp1, -self._perp1, self._perp2, -self._perp2]
|
|
189
|
+
base_points = [base_center + base_radius * p for p in perps]
|
|
190
|
+
points = np.array([tip, *base_points])
|
|
191
|
+
return points.min(axis=0), points.max(axis=0)
|
|
192
|
+
|
|
193
|
+
def to_trace(
|
|
194
|
+
self,
|
|
195
|
+
resolution: int | None = None,
|
|
196
|
+
intensity_fn: Callable[[NDArray[np.floating]], NDArray[np.floating]]
|
|
197
|
+
| None = None,
|
|
198
|
+
**kwargs,
|
|
199
|
+
) -> go.Mesh3d:
|
|
200
|
+
"""Generate a Plotly Mesh3d trace.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
resolution: Mesh resolution.
|
|
204
|
+
intensity_fn: Optional intensity function for coloring.
|
|
205
|
+
**kwargs: Additional arguments for vertices_to_mesh3d.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
Plotly Mesh3d trace.
|
|
209
|
+
"""
|
|
210
|
+
vertices, faces = self.to_mesh(resolution)
|
|
211
|
+
return vertices_to_mesh3d(
|
|
212
|
+
vertices,
|
|
213
|
+
faces,
|
|
214
|
+
intensity_fn=intensity_fn,
|
|
215
|
+
name=self._label,
|
|
216
|
+
**kwargs,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def __repr__(self) -> str:
|
|
220
|
+
"""Return string representation."""
|
|
221
|
+
return (
|
|
222
|
+
f"Cone(apex={self._apex}, axis={self._axis}, "
|
|
223
|
+
f"half_angle={self._half_angle:.4f}, height={self._height})"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
__all__ = ["Cone"]
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Cylinder 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 AXIS_ALIGNMENT_THRESHOLD, cylinder_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 Cylinder(ImplicitShape):
|
|
19
|
+
"""A 3D cylinder defined by center, axis, radius, and height.
|
|
20
|
+
|
|
21
|
+
The cylinder is centered at the given point and extends height/2
|
|
22
|
+
in each direction along the axis.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
center_xyz: Center of the cylinder in XYZ coordinates.
|
|
26
|
+
axis: Normalized axis direction.
|
|
27
|
+
radius: Radius of the cylinder.
|
|
28
|
+
height: Total height of the cylinder.
|
|
29
|
+
label: Optional label for legends.
|
|
30
|
+
is_volume: Whether this shape represents a volume or surface.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__( # noqa: PLR0913 - 3D cylinder requires center + axis + radius + height
|
|
34
|
+
self,
|
|
35
|
+
center_xyz: NDArray[np.floating],
|
|
36
|
+
axis: NDArray[np.floating],
|
|
37
|
+
radius: float,
|
|
38
|
+
height: float,
|
|
39
|
+
label: str | None = None,
|
|
40
|
+
*,
|
|
41
|
+
as_volume: bool = True,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Initialize the cylinder.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
center_xyz: Center of the cylinder, shape (3,).
|
|
47
|
+
axis: Axis direction (will be normalized), shape (3,).
|
|
48
|
+
radius: Radius of the cylinder (positive).
|
|
49
|
+
height: Total height of the cylinder (positive).
|
|
50
|
+
label: Optional label for legends.
|
|
51
|
+
as_volume: Keyword-only. If True, interior is solution region.
|
|
52
|
+
If False, boundary is solution locus. Default True.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
ValueError: If radius or height is not positive.
|
|
56
|
+
"""
|
|
57
|
+
self._center = np.asarray(center_xyz, dtype=np.float64)
|
|
58
|
+
axis = np.asarray(axis, dtype=np.float64)
|
|
59
|
+
self._axis = axis / np.linalg.norm(axis)
|
|
60
|
+
|
|
61
|
+
if radius <= 0:
|
|
62
|
+
raise ValueError("radius must be positive")
|
|
63
|
+
self._radius = float(radius)
|
|
64
|
+
|
|
65
|
+
if height <= 0:
|
|
66
|
+
raise ValueError("height must be positive")
|
|
67
|
+
self._height = float(height)
|
|
68
|
+
|
|
69
|
+
self._label = label
|
|
70
|
+
self._is_volume = as_volume
|
|
71
|
+
|
|
72
|
+
# Precompute orthonormal basis
|
|
73
|
+
self._compute_basis()
|
|
74
|
+
|
|
75
|
+
def _compute_basis(self) -> None:
|
|
76
|
+
"""Compute orthonormal basis vectors perpendicular to axis."""
|
|
77
|
+
if abs(self._axis[2]) < AXIS_ALIGNMENT_THRESHOLD:
|
|
78
|
+
perp1 = np.cross(self._axis, np.array([0.0, 0.0, 1.0]))
|
|
79
|
+
else:
|
|
80
|
+
perp1 = np.cross(self._axis, np.array([1.0, 0.0, 0.0]))
|
|
81
|
+
self._perp1 = perp1 / np.linalg.norm(perp1)
|
|
82
|
+
self._perp2 = np.cross(self._axis, self._perp1)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def center_xyz(self) -> NDArray[np.floating]:
|
|
86
|
+
"""Get the center in XYZ coordinates."""
|
|
87
|
+
return self._center
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def axis(self) -> NDArray[np.floating]:
|
|
91
|
+
"""Get the normalized axis direction."""
|
|
92
|
+
return self._axis
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def radius(self) -> float:
|
|
96
|
+
"""Get the radius."""
|
|
97
|
+
return self._radius
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def height(self) -> float:
|
|
101
|
+
"""Get the height."""
|
|
102
|
+
return self._height
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def label(self) -> str | None:
|
|
106
|
+
"""Get the label."""
|
|
107
|
+
return self._label
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def is_volume(self) -> bool:
|
|
111
|
+
"""Whether this shape represents a volume (True) or surface (False)."""
|
|
112
|
+
return self._is_volume
|
|
113
|
+
|
|
114
|
+
def residual_fn(self, xyz: NDArray[np.floating]) -> NDArray[np.floating]:
|
|
115
|
+
"""Compute the residual function for the cylinder.
|
|
116
|
+
|
|
117
|
+
The residual is the perpendicular distance from the axis minus
|
|
118
|
+
the radius, so:
|
|
119
|
+
- 0 on the surface
|
|
120
|
+
- Positive outside
|
|
121
|
+
- Negative inside
|
|
122
|
+
|
|
123
|
+
Note: This only considers the cylindrical surface, not the end caps.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
xyz: Points to evaluate, shape (..., 3).
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Residual values, shape (...).
|
|
130
|
+
"""
|
|
131
|
+
xyz = np.asarray(xyz)
|
|
132
|
+
diff = xyz - self._center
|
|
133
|
+
|
|
134
|
+
# Component along axis
|
|
135
|
+
parallel = np.einsum("...i,i->...", diff, self._axis)
|
|
136
|
+
|
|
137
|
+
# Perpendicular component
|
|
138
|
+
parallel_vec = parallel[..., np.newaxis] * self._axis
|
|
139
|
+
perp = diff - parallel_vec
|
|
140
|
+
perp_dist = np.linalg.norm(perp, axis=-1)
|
|
141
|
+
|
|
142
|
+
return perp_dist - self._radius
|
|
143
|
+
|
|
144
|
+
def to_mesh(
|
|
145
|
+
self,
|
|
146
|
+
resolution: int | None = None,
|
|
147
|
+
) -> tuple[NDArray[np.floating], NDArray[np.integer]]:
|
|
148
|
+
"""Generate mesh vertices and faces.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
resolution: Number of samples per dimension. If None, uses 50.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Tuple of (vertices, faces).
|
|
155
|
+
"""
|
|
156
|
+
if resolution is None:
|
|
157
|
+
resolution = 50
|
|
158
|
+
return cylinder_mesh(
|
|
159
|
+
self._center,
|
|
160
|
+
self._axis,
|
|
161
|
+
self._radius,
|
|
162
|
+
self._height,
|
|
163
|
+
resolution,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def get_bounds_xyz(
|
|
167
|
+
self,
|
|
168
|
+
) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
|
|
169
|
+
"""Get axis-aligned bounding box in XYZ coordinates.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Tuple of (min_corner, max_corner).
|
|
173
|
+
"""
|
|
174
|
+
# Endpoints along axis
|
|
175
|
+
half_h = self._height / 2
|
|
176
|
+
end1 = self._center - half_h * self._axis
|
|
177
|
+
end2 = self._center + half_h * self._axis
|
|
178
|
+
|
|
179
|
+
# Sample the circles at each end
|
|
180
|
+
perps = [self._perp1, -self._perp1, self._perp2, -self._perp2]
|
|
181
|
+
points = [end + self._radius * perp for end in [end1, end2] for perp in perps]
|
|
182
|
+
points = np.array(points)
|
|
183
|
+
return points.min(axis=0), points.max(axis=0)
|
|
184
|
+
|
|
185
|
+
def to_trace(
|
|
186
|
+
self,
|
|
187
|
+
resolution: int | None = None,
|
|
188
|
+
intensity_fn: Callable[[NDArray[np.floating]], NDArray[np.floating]]
|
|
189
|
+
| None = None,
|
|
190
|
+
**kwargs,
|
|
191
|
+
) -> go.Mesh3d:
|
|
192
|
+
"""Generate a Plotly Mesh3d trace.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
resolution: Mesh resolution.
|
|
196
|
+
intensity_fn: Optional intensity function for coloring.
|
|
197
|
+
**kwargs: Additional arguments for vertices_to_mesh3d.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Plotly Mesh3d trace.
|
|
201
|
+
"""
|
|
202
|
+
vertices, faces = self.to_mesh(resolution)
|
|
203
|
+
return vertices_to_mesh3d(
|
|
204
|
+
vertices,
|
|
205
|
+
faces,
|
|
206
|
+
intensity_fn=intensity_fn,
|
|
207
|
+
name=self._label,
|
|
208
|
+
**kwargs,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def __repr__(self) -> str:
|
|
212
|
+
"""Return string representation."""
|
|
213
|
+
return (
|
|
214
|
+
f"Cylinder(center={self._center}, axis={self._axis}, "
|
|
215
|
+
f"radius={self._radius}, height={self._height})"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
__all__ = ["Cylinder"]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""2D ellipse shape for error ellipse visualization.
|
|
2
|
+
|
|
3
|
+
This module provides a standalone 2D ellipse class for rendering geolocation
|
|
4
|
+
error ellipses. Unlike the 3D shapes that inherit from ImplicitShape, this
|
|
5
|
+
is a simple 2D boundary representation parameterized by semi-major axis,
|
|
6
|
+
semi-minor axis, and orientation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from numpy.typing import NDArray
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Ellipse:
|
|
19
|
+
"""2D ellipse defined by semi-axes and orientation.
|
|
20
|
+
|
|
21
|
+
Represents a 2D error ellipse in an East/North coordinate frame,
|
|
22
|
+
parameterized the same way as gri-ell's Ell.ellipse output:
|
|
23
|
+
semi-major axis, semi-minor axis, and orientation in degrees
|
|
24
|
+
clockwise from North.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
sma: Semi-major axis length in meters. Must be positive and >= smi.
|
|
28
|
+
smi: Semi-minor axis length in meters. Must be positive.
|
|
29
|
+
ori_deg: Orientation in degrees clockwise from North.
|
|
30
|
+
Normalized to [0, 360).
|
|
31
|
+
center: Center point as (east_m, north_m). Defaults to origin.
|
|
32
|
+
label: Optional label for legend display.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
ValueError: If axes are non-positive or sma < smi.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__( # noqa: D107
|
|
39
|
+
self,
|
|
40
|
+
sma: float,
|
|
41
|
+
smi: float,
|
|
42
|
+
ori_deg: float,
|
|
43
|
+
center: tuple[float, float] = (0.0, 0.0),
|
|
44
|
+
label: str | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
if sma <= 0 or smi <= 0:
|
|
47
|
+
msg = f"Semi-axes must be positive, got sma={sma}, smi={smi}"
|
|
48
|
+
raise ValueError(msg)
|
|
49
|
+
if sma < smi:
|
|
50
|
+
msg = (
|
|
51
|
+
f"Semi-major axis must be >= semi-minor axis, got sma={sma}, smi={smi}"
|
|
52
|
+
)
|
|
53
|
+
raise ValueError(msg)
|
|
54
|
+
self._sma = float(sma)
|
|
55
|
+
self._smi = float(smi)
|
|
56
|
+
self._ori_deg = float(ori_deg) % 360.0
|
|
57
|
+
self._center = (float(center[0]), float(center[1]))
|
|
58
|
+
self._label = label
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def sma(self) -> float:
|
|
62
|
+
"""Semi-major axis length in meters."""
|
|
63
|
+
return self._sma
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def smi(self) -> float:
|
|
67
|
+
"""Semi-minor axis length in meters."""
|
|
68
|
+
return self._smi
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def ori_deg(self) -> float:
|
|
72
|
+
"""Orientation in degrees clockwise from North, in [0, 360)."""
|
|
73
|
+
return self._ori_deg
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def center(self) -> tuple[float, float]:
|
|
77
|
+
"""Center point as (east_m, north_m)."""
|
|
78
|
+
return self._center
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def label(self) -> str | None:
|
|
82
|
+
"""Optional label for legend display."""
|
|
83
|
+
return self._label
|
|
84
|
+
|
|
85
|
+
def boundary(self, n_pts: int = 200) -> tuple[NDArray, NDArray]:
|
|
86
|
+
"""Compute ellipse boundary points in East/North meters.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
n_pts: Number of boundary points to generate.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Tuple of (east, north) arrays, each of length n_pts.
|
|
93
|
+
"""
|
|
94
|
+
theta = np.linspace(0, 2 * np.pi, n_pts)
|
|
95
|
+
# Ellipse in local frame: SMA along x, SMI along y
|
|
96
|
+
x_local = self._sma * np.cos(theta)
|
|
97
|
+
y_local = self._smi * np.sin(theta)
|
|
98
|
+
# Rotate: ori_deg CW from North -> CCW from East = (90 - ori_deg)
|
|
99
|
+
angle_rad = math.radians(90.0 - self._ori_deg)
|
|
100
|
+
cos_a = math.cos(angle_rad)
|
|
101
|
+
sin_a = math.sin(angle_rad)
|
|
102
|
+
east = cos_a * x_local - sin_a * y_local + self._center[0]
|
|
103
|
+
north = sin_a * x_local + cos_a * y_local + self._center[1]
|
|
104
|
+
return east, north
|
|
105
|
+
|
|
106
|
+
def __repr__(self) -> str:
|
|
107
|
+
"""Return string representation."""
|
|
108
|
+
parts = [
|
|
109
|
+
f"Ellipse(sma={self._sma:.1f}",
|
|
110
|
+
f"smi={self._smi:.1f}",
|
|
111
|
+
f"ori={self._ori_deg:.1f} deg",
|
|
112
|
+
]
|
|
113
|
+
if self._center != (0.0, 0.0):
|
|
114
|
+
parts.append(f"center=({self._center[0]:.1f}, {self._center[1]:.1f})")
|
|
115
|
+
if self._label is not None:
|
|
116
|
+
parts.append(f"label={self._label!r}")
|
|
117
|
+
return ", ".join(parts) + ")"
|