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
+ """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) + ")"