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/__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
|
+
]
|