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 ADDED
@@ -0,0 +1,94 @@
1
+ """Plotting utilities for GeoSol Research.
2
+
3
+ This package provides utilities for 2D and 3D visualization using Plotly,
4
+ including scatter plots, geographic maps, and 3D surfaces for geolocation
5
+ observables.
6
+
7
+ Main components:
8
+ - scatter: 2D scatter plots with Plotly
9
+ - scatter_map: Geographic scatter maps
10
+ - Figure3D: 3D figure for combining surfaces
11
+ - shapes: Geometric shapes (Ellipsoid, Sphere, Cone, Cylinder)
12
+ - observables: Geolocation surfaces (TDOA, FDOA, AOA, etc.)
13
+ - surfaces: Low-level surface utilities
14
+ - frames: Coordinate frame handling
15
+ """
16
+
17
+ # 2D plotting
18
+ # 3D plotting infrastructure
19
+ from .figure3d import DEFAULT_COLORS, Figure3D, plot_surfaces
20
+ from .frames import Bounds, Frame, FrameTransformer
21
+
22
+ # Observables
23
+ from .observables import (
24
+ AoaSurface,
25
+ FdoaSurface,
26
+ LosSurface,
27
+ RangeSphere,
28
+ TdoaSurface,
29
+ TerrainSurface,
30
+ )
31
+ from .plot_ellipse import plot_ellipse
32
+ from .scatter import scatter
33
+ from .scatter_map import scatter_map
34
+
35
+ # Shapes
36
+ from .shapes import Cone, Cylinder, Ellipse, Ellipsoid, Sphere
37
+
38
+ # Shape mesh generators
39
+ from .shapes.meshgen import cone_mesh, cylinder_mesh, ellipsoid_mesh, sphere_mesh
40
+
41
+ # Surface utilities
42
+ from .surfaces import (
43
+ ImplicitShape,
44
+ expand_bounds,
45
+ merge_bounds,
46
+ )
47
+ from .surfaces.gradients import (
48
+ axis_value,
49
+ distance_from_axis,
50
+ distance_from_line,
51
+ distance_from_line_segment,
52
+ distance_from_plane,
53
+ distance_from_point,
54
+ )
55
+ from .surfaces.mesh import field_to_mesh, grid_to_mesh, vertices_to_mesh3d
56
+
57
+ __all__ = [
58
+ "DEFAULT_COLORS",
59
+ "AoaSurface",
60
+ "Bounds",
61
+ "Cone",
62
+ "Cylinder",
63
+ "Ellipse",
64
+ "Ellipsoid",
65
+ "FdoaSurface",
66
+ "Figure3D",
67
+ "Frame",
68
+ "FrameTransformer",
69
+ "ImplicitShape",
70
+ "LosSurface",
71
+ "RangeSphere",
72
+ "Sphere",
73
+ "TdoaSurface",
74
+ "TerrainSurface",
75
+ "axis_value",
76
+ "cone_mesh",
77
+ "cylinder_mesh",
78
+ "distance_from_axis",
79
+ "distance_from_line",
80
+ "distance_from_line_segment",
81
+ "distance_from_plane",
82
+ "distance_from_point",
83
+ "ellipsoid_mesh",
84
+ "expand_bounds",
85
+ "field_to_mesh",
86
+ "grid_to_mesh",
87
+ "merge_bounds",
88
+ "plot_ellipse",
89
+ "plot_surfaces",
90
+ "scatter",
91
+ "scatter_map",
92
+ "sphere_mesh",
93
+ "vertices_to_mesh3d",
94
+ ]
gri_plot/figure3d.py ADDED
@@ -0,0 +1,476 @@
1
+ """3D Figure class for combining multiple surfaces.
2
+
3
+ This module provides the Figure3D class for building complex 3D visualizations
4
+ by combining multiple Surface objects with coordinate frame transformation.
5
+ """
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ import numpy as np
10
+ import plotly.graph_objects as go
11
+
12
+ from .frames import Frame, FrameTransformer
13
+ from .surfaces import ImplicitShape, expand_bounds, merge_bounds
14
+ from .surfaces.intersection import IntersectionField
15
+ from .surfaces.mesh import field_to_mesh, vertices_to_mesh3d
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Sequence
19
+
20
+ from numpy.typing import NDArray
21
+
22
+
23
+ # Default Plotly colors (Plotly's qualitative D3 palette)
24
+ DEFAULT_COLORS = [
25
+ "#1f77b4", # blue
26
+ "#ff7f0e", # orange
27
+ "#2ca02c", # green
28
+ "#d62728", # red
29
+ "#9467bd", # purple
30
+ "#8c564b", # brown
31
+ "#e377c2", # pink
32
+ "#7f7f7f", # gray
33
+ "#bcbd22", # olive
34
+ "#17becf", # cyan
35
+ ]
36
+
37
+
38
+ def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
39
+ """Convert hex color to RGB tuple."""
40
+ hex_color = hex_color.lstrip("#")
41
+ return (
42
+ int(hex_color[0:2], 16),
43
+ int(hex_color[2:4], 16),
44
+ int(hex_color[4:6], 16),
45
+ )
46
+
47
+
48
+ def _color_to_gradient(color: str) -> list[list]:
49
+ """Create a gradient colorscale from a single color.
50
+
51
+ Creates a colorscale that goes from a darker version of the color
52
+ to a lighter version, giving a gradient effect within one hue.
53
+
54
+ Args:
55
+ color: Hex color string (e.g., "#1f77b4").
56
+
57
+ Returns:
58
+ Plotly colorscale list [[0, dark], [0.5, base], [1, light]].
59
+ """
60
+ r, g, b = _hex_to_rgb(color)
61
+
62
+ # Create darker version (50% darker)
63
+ dark_r = int(r * 0.5)
64
+ dark_g = int(g * 0.5)
65
+ dark_b = int(b * 0.5)
66
+ dark = f"rgb({dark_r},{dark_g},{dark_b})"
67
+
68
+ # Base color
69
+ base = f"rgb({r},{g},{b})"
70
+
71
+ # Create lighter version (50% toward white)
72
+ light_r = int(r + (255 - r) * 0.5)
73
+ light_g = int(g + (255 - g) * 0.5)
74
+ light_b = int(b + (255 - b) * 0.5)
75
+ light = f"rgb({light_r},{light_g},{light_b})"
76
+
77
+ return [[0, dark], [0.5, base], [1, light]]
78
+
79
+
80
+ class Figure3D:
81
+ """3D figure for combining multiple surfaces with frame transformation.
82
+
83
+ This class manages a collection of 3D surfaces and provides methods for
84
+ adding surfaces, points, and rendering the final visualization.
85
+
86
+ Attributes:
87
+ display_frame: Coordinate frame for display.
88
+ origin_xyz: Origin for ENU frame transformations.
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ display_frame: Frame = Frame.XYZ,
94
+ origin_xyz: NDArray[np.floating] | None = None,
95
+ title: str | None = None,
96
+ ) -> None:
97
+ """Initialize the 3D figure.
98
+
99
+ Args:
100
+ display_frame: Coordinate frame for visualization.
101
+ origin_xyz: Origin in XYZ coordinates, required for ENU frame.
102
+ title: Optional title for the figure.
103
+
104
+ Raises:
105
+ ValueError: If display_frame is ENU but origin_xyz is not provided.
106
+ """
107
+ self._transformer = FrameTransformer(display_frame, origin_xyz)
108
+ self._title = title
109
+ self._traces: list[go.Scatter3d | go.Mesh3d] = []
110
+ self._surface_bounds: list[tuple[NDArray, NDArray]] = []
111
+ self._color_index = 0
112
+
113
+ @property
114
+ def display_frame(self) -> Frame:
115
+ """Get the display frame."""
116
+ return self._transformer.display_frame
117
+
118
+ @property
119
+ def origin_xyz(self) -> NDArray[np.floating] | None:
120
+ """Get the origin in XYZ coordinates."""
121
+ return self._transformer.origin_xyz
122
+
123
+ def _next_color(self) -> str:
124
+ """Get the next color from the default palette."""
125
+ color = DEFAULT_COLORS[self._color_index % len(DEFAULT_COLORS)]
126
+ self._color_index += 1
127
+ return color
128
+
129
+ def add_surface(
130
+ self,
131
+ surface: ImplicitShape,
132
+ resolution: int | None = None,
133
+ color: str | None = None,
134
+ opacity: float = 0.7,
135
+ **kwargs,
136
+ ) -> Figure3D:
137
+ """Add a surface to the figure.
138
+
139
+ Args:
140
+ surface: ImplicitShape object to visualize.
141
+ resolution: Mesh resolution. If None, uses field_to_mesh default.
142
+ color: Base color for single-color gradient. If None, uses full
143
+ Viridis colorscale. For multiple surfaces, pick colors from
144
+ DEFAULT_COLORS to distinguish them (e.g., color=DEFAULT_COLORS[0]).
145
+ opacity: Surface opacity (0-1).
146
+ **kwargs: Additional arguments passed to the surface's to_trace().
147
+
148
+ Returns:
149
+ Self for method chaining.
150
+
151
+ """
152
+ # If no color specified, use full Viridis gradient
153
+ # If color specified, create a single-color gradient from that color
154
+ colorscale = "Viridis" if color is None else _color_to_gradient(color)
155
+
156
+ # Get bounds in XYZ
157
+ bounds_xyz = surface.get_bounds_xyz()
158
+ self._surface_bounds.append(bounds_xyz)
159
+
160
+ # Generate Mesh3d trace with the surface's native method
161
+ trace = surface.to_trace(
162
+ resolution=resolution,
163
+ colorscale=colorscale,
164
+ opacity=opacity,
165
+ **kwargs,
166
+ )
167
+
168
+ if self.display_frame != Frame.XYZ:
169
+ trace = self._transform_trace(trace)
170
+
171
+ self._traces.append(trace)
172
+ return self
173
+
174
+ def add_points(
175
+ self,
176
+ points_xyz: NDArray[np.floating],
177
+ labels: Sequence[str] | None = None,
178
+ color: str | None = None,
179
+ size: int = 8,
180
+ symbol: str = "circle",
181
+ **kwargs,
182
+ ) -> Figure3D:
183
+ """Add scatter points to the figure.
184
+
185
+ Args:
186
+ points_xyz: Points in XYZ coordinates, shape (N, 3).
187
+ labels: Optional labels for each point.
188
+ color: Point color. If None, uses next color from palette.
189
+ size: Marker size.
190
+ symbol: Marker symbol.
191
+ **kwargs: Additional arguments for go.Scatter3d.
192
+
193
+ Returns:
194
+ Self for method chaining.
195
+ """
196
+ points_xyz = np.asarray(points_xyz)
197
+ if points_xyz.ndim == 1:
198
+ points_xyz = points_xyz.reshape(1, 3)
199
+
200
+ if color is None:
201
+ color = self._next_color()
202
+
203
+ # Transform to display frame
204
+ points_display = self._transformer.to_display(points_xyz)
205
+
206
+ # Update bounds
207
+ min_corner = points_xyz.min(axis=0)
208
+ max_corner = points_xyz.max(axis=0)
209
+ self._surface_bounds.append((min_corner, max_corner))
210
+
211
+ trace_kwargs = {
212
+ "x": points_display[:, 0],
213
+ "y": points_display[:, 1],
214
+ "z": points_display[:, 2],
215
+ "mode": "markers+text" if labels else "markers",
216
+ "marker": {"size": size, "color": color, "symbol": symbol},
217
+ }
218
+
219
+ if labels:
220
+ trace_kwargs["text"] = list(labels)
221
+ trace_kwargs["textposition"] = "top center"
222
+
223
+ trace_kwargs.update(kwargs)
224
+ self._traces.append(go.Scatter3d(**trace_kwargs))
225
+ return self
226
+
227
+ def add_line(
228
+ self,
229
+ points_xyz: NDArray[np.floating],
230
+ color: str | None = None,
231
+ width: int = 2,
232
+ name: str | None = None,
233
+ **kwargs,
234
+ ) -> Figure3D:
235
+ """Add a line connecting points.
236
+
237
+ Args:
238
+ points_xyz: Points in XYZ coordinates, shape (N, 3).
239
+ color: Line color. If None, uses next color from palette.
240
+ width: Line width.
241
+ name: Trace name for legend.
242
+ **kwargs: Additional arguments for go.Scatter3d.
243
+
244
+ Returns:
245
+ Self for method chaining.
246
+ """
247
+ points_xyz = np.asarray(points_xyz)
248
+
249
+ if color is None:
250
+ color = self._next_color()
251
+
252
+ # Transform to display frame
253
+ points_display = self._transformer.to_display(points_xyz)
254
+
255
+ # Update bounds
256
+ min_corner = points_xyz.min(axis=0)
257
+ max_corner = points_xyz.max(axis=0)
258
+ self._surface_bounds.append((min_corner, max_corner))
259
+
260
+ trace_kwargs = {
261
+ "x": points_display[:, 0],
262
+ "y": points_display[:, 1],
263
+ "z": points_display[:, 2],
264
+ "mode": "lines",
265
+ "line": {"width": width, "color": color},
266
+ }
267
+
268
+ if name is not None:
269
+ trace_kwargs["name"] = name
270
+ trace_kwargs["showlegend"] = True
271
+
272
+ trace_kwargs.update(kwargs)
273
+ self._traces.append(go.Scatter3d(**trace_kwargs))
274
+ return self
275
+
276
+ def _transform_trace(
277
+ self,
278
+ trace: go.Scatter3d | go.Mesh3d,
279
+ ) -> go.Scatter3d | go.Mesh3d:
280
+ """Transform trace coordinates from XYZ to display frame.
281
+
282
+ Args:
283
+ trace: Plotly trace with x, y, z data.
284
+
285
+ Returns:
286
+ New trace with transformed coordinates.
287
+ """
288
+ x = np.asarray(trace.x)
289
+ y = np.asarray(trace.y)
290
+ z = np.asarray(trace.z)
291
+
292
+ xyz = np.column_stack([x.ravel(), y.ravel(), z.ravel()])
293
+ transformed = self._transformer.to_display(xyz)
294
+
295
+ # Create a copy with updated coordinates
296
+ trace_dict = trace.to_plotly_json()
297
+ trace_dict["x"] = transformed[:, 0].reshape(x.shape)
298
+ trace_dict["y"] = transformed[:, 1].reshape(y.shape)
299
+ trace_dict["z"] = transformed[:, 2].reshape(z.shape)
300
+
301
+ # Reconstruct the trace
302
+ trace_type = type(trace)
303
+ return trace_type(**trace_dict)
304
+
305
+ def add_intersection( # noqa: PLR0913 - intersection has many visual options
306
+ self,
307
+ *surfaces: ImplicitShape,
308
+ threshold: float = 1.5,
309
+ steepness: float | None = None,
310
+ resolution: int | None = None,
311
+ color: str | None = None,
312
+ opacity: float = 0.8,
313
+ label: str | None = None,
314
+ **kwargs,
315
+ ) -> Figure3D:
316
+ """Add an intersection visualization for multiple implicit surfaces.
317
+
318
+ Highlights regions where multiple surfaces intersect by computing a
319
+ combined "closeness" field. Single-surface regions are hidden (below
320
+ threshold), while multi-surface intersections are progressively
321
+ highlighted.
322
+
323
+ Args:
324
+ *surfaces: ImplicitSurface objects to intersect. Must be at least 2.
325
+ threshold: Value subtracted from combined closeness. Default 1.5
326
+ hides single-surface regions while showing 2+ intersections.
327
+ steepness: Exponential falloff rate. If None, auto-computed based
328
+ on number of surfaces to ensure good visual gradient.
329
+ resolution: Mesh resolution. If None, uses field_to_mesh default.
330
+ color: Base color for gradient. If None, uses next palette color.
331
+ opacity: Surface opacity (0-1).
332
+ label: Optional label for legends.
333
+ **kwargs: Additional arguments passed to the Mesh3d trace.
334
+
335
+ Returns:
336
+ Self for method chaining.
337
+
338
+ Raises:
339
+ ValueError: If fewer than 2 surfaces provided.
340
+ """
341
+ intersection = IntersectionField(
342
+ surfaces,
343
+ threshold=threshold,
344
+ steepness=steepness,
345
+ )
346
+
347
+ # Get bounds and add to tracking
348
+ bounds_xyz = intersection.get_bounds_xyz()
349
+ self._surface_bounds.append(bounds_xyz)
350
+
351
+ # Determine color
352
+ if color is None:
353
+ color = self._next_color()
354
+ colorscale = _color_to_gradient(color)
355
+
356
+ # Generate mesh using marching cubes via field_to_mesh
357
+ bounds = expand_bounds(bounds_xyz, 1.05)
358
+ if resolution is None:
359
+ vertices, faces = field_to_mesh(intersection.residual_fn, bounds)
360
+ else:
361
+ vertices, faces = field_to_mesh(
362
+ intersection.residual_fn,
363
+ bounds,
364
+ resolution=resolution,
365
+ )
366
+
367
+ # Create Mesh3d trace
368
+ trace = vertices_to_mesh3d(
369
+ vertices,
370
+ faces,
371
+ colorscale=colorscale,
372
+ opacity=opacity,
373
+ name=label,
374
+ **kwargs,
375
+ )
376
+
377
+ if self.display_frame != Frame.XYZ:
378
+ trace = self._transform_trace(trace)
379
+
380
+ self._traces.append(trace)
381
+ return self
382
+
383
+ def get_bounds_xyz(
384
+ self,
385
+ ) -> tuple[NDArray[np.floating], NDArray[np.floating]] | None:
386
+ """Get combined bounds of all surfaces in XYZ.
387
+
388
+ Returns:
389
+ Tuple of (min_corner, max_corner) or None if no surfaces added.
390
+ """
391
+ if not self._surface_bounds:
392
+ return None
393
+ return merge_bounds(*self._surface_bounds)
394
+
395
+ def build(self) -> go.Figure:
396
+ """Build the Plotly Figure.
397
+
398
+ Returns:
399
+ Plotly Figure object.
400
+ """
401
+ fig = go.Figure(data=self._traces)
402
+
403
+ # Get axis labels
404
+ x_label, y_label, z_label = self._transformer.get_axis_labels()
405
+
406
+ # Configure layout
407
+ layout_kwargs = {
408
+ "template": "plotly_dark",
409
+ "scene": {
410
+ "xaxis_title": x_label,
411
+ "yaxis_title": y_label,
412
+ "zaxis_title": z_label,
413
+ "aspectmode": "data",
414
+ },
415
+ }
416
+
417
+ if self._title:
418
+ layout_kwargs["title"] = self._title
419
+
420
+ fig.update_layout(**layout_kwargs)
421
+ return fig
422
+
423
+ def show(self, **kwargs) -> None:
424
+ """Build and display the figure.
425
+
426
+ Args:
427
+ **kwargs: Additional arguments passed to fig.show().
428
+ """
429
+ fig = self.build()
430
+ fig.show(**kwargs)
431
+
432
+ def write_html(self, path: str, **kwargs) -> None:
433
+ """Build and save the figure as HTML.
434
+
435
+ Args:
436
+ path: Output file path.
437
+ **kwargs: Additional arguments passed to fig.write_html().
438
+ """
439
+ fig = self.build()
440
+ fig.write_html(path, **kwargs)
441
+
442
+
443
+ def plot_surfaces(
444
+ *surfaces: ImplicitShape,
445
+ display_frame: Frame = Frame.XYZ,
446
+ origin_xyz: NDArray[np.floating] | None = None,
447
+ title: str | None = None,
448
+ resolution: int | None = None,
449
+ **kwargs,
450
+ ) -> go.Figure:
451
+ """Quick function to plot multiple surfaces.
452
+
453
+ Args:
454
+ *surfaces: Surface objects to plot.
455
+ display_frame: Coordinate frame for visualization.
456
+ origin_xyz: Origin for ENU frame.
457
+ title: Figure title.
458
+ resolution: Mesh resolution. If None, uses field_to_mesh default.
459
+ **kwargs: Additional arguments passed to add_surface.
460
+
461
+ Returns:
462
+ Plotly Figure object.
463
+ """
464
+ fig = Figure3D(display_frame=display_frame, origin_xyz=origin_xyz, title=title)
465
+
466
+ for surface in surfaces:
467
+ fig.add_surface(surface, resolution=resolution, **kwargs)
468
+
469
+ return fig.build()
470
+
471
+
472
+ __all__ = [
473
+ "DEFAULT_COLORS",
474
+ "Figure3D",
475
+ "plot_surfaces",
476
+ ]