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,171 @@
|
|
|
1
|
+
"""Surface ABC and base utilities for 3D visualization.
|
|
2
|
+
|
|
3
|
+
This module defines the ImplicitShape ABC that all plottable shapes must implement.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
import plotly.graph_objects as go
|
|
13
|
+
from numpy.typing import NDArray
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ImplicitShape(ABC):
|
|
17
|
+
"""Base class for all plottable 3D shapes and observables.
|
|
18
|
+
|
|
19
|
+
This ABC provides a unified interface for shapes that can be:
|
|
20
|
+
- Evaluated via an implicit function (residual_fn)
|
|
21
|
+
- Rendered as a Plotly Mesh3d trace
|
|
22
|
+
- Used in intersection calculations
|
|
23
|
+
|
|
24
|
+
Residual convention:
|
|
25
|
+
- residual < 0: inside the shape
|
|
26
|
+
- residual = 0: on the boundary
|
|
27
|
+
- residual > 0: outside the shape
|
|
28
|
+
|
|
29
|
+
The is_volume property determines intersection behavior:
|
|
30
|
+
- True: interior (residual < 0) is the solution region
|
|
31
|
+
- False: boundary (residual = 0) is the solution locus
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def is_volume(self) -> bool:
|
|
37
|
+
"""Whether this shape represents a volume (True) or surface (False).
|
|
38
|
+
|
|
39
|
+
Volumes contribute to intersection everywhere inside (residual < 0).
|
|
40
|
+
Surfaces contribute based on distance from boundary (|residual|).
|
|
41
|
+
"""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def label(self) -> str | None:
|
|
47
|
+
"""Optional label for the shape in legends."""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def residual_fn(self, xyz: NDArray[np.floating]) -> NDArray[np.floating]:
|
|
52
|
+
"""Evaluate the implicit function at given points.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
xyz: Points to evaluate, shape (..., 3).
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Residual values, shape (...). Negative inside, zero on boundary,
|
|
59
|
+
positive outside.
|
|
60
|
+
"""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
def get_bounds_xyz(
|
|
65
|
+
self,
|
|
66
|
+
) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
|
|
67
|
+
"""Get axis-aligned bounding box in XYZ coordinates.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Tuple of (min_corner, max_corner), each shape (3,).
|
|
71
|
+
"""
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def to_mesh(
|
|
76
|
+
self,
|
|
77
|
+
resolution: int | None = None,
|
|
78
|
+
) -> tuple[NDArray[np.floating], NDArray[np.integer]]:
|
|
79
|
+
"""Generate mesh vertices and faces.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
resolution: Number of samples per dimension. If None, uses
|
|
83
|
+
shape-specific default.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Tuple of (vertices, faces) where:
|
|
87
|
+
- vertices: shape (N, 3) array of vertex positions
|
|
88
|
+
- faces: shape (M, 3) array of triangle indices
|
|
89
|
+
"""
|
|
90
|
+
...
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
def to_trace(
|
|
94
|
+
self,
|
|
95
|
+
resolution: int | None = None,
|
|
96
|
+
**kwargs,
|
|
97
|
+
) -> go.Mesh3d:
|
|
98
|
+
"""Generate a Plotly Mesh3d trace for visualization.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
resolution: Mesh resolution. If None, uses shape-specific default.
|
|
102
|
+
**kwargs: Additional arguments for the trace.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Plotly Mesh3d trace.
|
|
106
|
+
"""
|
|
107
|
+
...
|
|
108
|
+
|
|
109
|
+
def contains(self, xyz: NDArray[np.floating]) -> NDArray[np.bool_]:
|
|
110
|
+
"""Test if points are inside the shape.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
xyz: Points to test, shape (..., 3).
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Boolean array, shape (...). True if inside (residual < 0).
|
|
117
|
+
"""
|
|
118
|
+
return self.residual_fn(xyz) < 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def merge_bounds(
|
|
122
|
+
*bounds: tuple[NDArray[np.floating], NDArray[np.floating]],
|
|
123
|
+
) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
|
|
124
|
+
"""Merge multiple bounding boxes into one.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
*bounds: Tuples of (min_corner, max_corner), each shape (3,).
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Tuple of (min_corner, max_corner) encompassing all inputs.
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
ValueError: If no bounds are provided.
|
|
134
|
+
"""
|
|
135
|
+
if not bounds:
|
|
136
|
+
raise ValueError("At least one bounds tuple required")
|
|
137
|
+
|
|
138
|
+
all_mins = np.array([b[0] for b in bounds])
|
|
139
|
+
all_maxs = np.array([b[1] for b in bounds])
|
|
140
|
+
|
|
141
|
+
return all_mins.min(axis=0), all_maxs.max(axis=0)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def expand_bounds(
|
|
145
|
+
bounds: tuple[NDArray[np.floating], NDArray[np.floating]],
|
|
146
|
+
factor: float = 1.1,
|
|
147
|
+
) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
|
|
148
|
+
"""Expand bounds by a factor around their center.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
bounds: Tuple of (min_corner, max_corner), each shape (3,).
|
|
152
|
+
factor: Expansion factor (1.0 = no change, 1.1 = 10% expansion).
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Expanded bounds tuple.
|
|
156
|
+
"""
|
|
157
|
+
min_corner, max_corner = np.asarray(bounds[0]), np.asarray(bounds[1])
|
|
158
|
+
center = (min_corner + max_corner) / 2
|
|
159
|
+
half_extent = (max_corner - min_corner) / 2
|
|
160
|
+
|
|
161
|
+
return center - half_extent * factor, center + half_extent * factor
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
from .intersection import IntersectionField # noqa: E402
|
|
165
|
+
|
|
166
|
+
__all__ = [
|
|
167
|
+
"ImplicitShape",
|
|
168
|
+
"IntersectionField",
|
|
169
|
+
"expand_bounds",
|
|
170
|
+
"merge_bounds",
|
|
171
|
+
]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Intensity functions for mesh coloring.
|
|
2
|
+
|
|
3
|
+
This module provides factory functions that create intensity calculators
|
|
4
|
+
for coloring 3D mesh vertices. Each function returns a callable that takes
|
|
5
|
+
a vertex array and returns intensity values.
|
|
6
|
+
|
|
7
|
+
Example::
|
|
8
|
+
|
|
9
|
+
from gri_plot.surfaces.gradients import distance_from_axis, distance_from_line
|
|
10
|
+
from gri_plot.surfaces.mesh import vertices_to_mesh3d
|
|
11
|
+
|
|
12
|
+
# Color by distance from Z=0 (axis 2)
|
|
13
|
+
trace = vertices_to_mesh3d(
|
|
14
|
+
verts, faces,
|
|
15
|
+
intensity_fn=distance_from_axis(2, center=0.0),
|
|
16
|
+
colorbar_title="|Z| (m)",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Color by distance from baseline between two collectors
|
|
20
|
+
trace = vertices_to_mesh3d(
|
|
21
|
+
verts, faces,
|
|
22
|
+
intensity_fn=distance_from_line(c1, c2),
|
|
23
|
+
colorbar_title="Baseline distance (m)",
|
|
24
|
+
)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from .axis import axis_value, distance_from_axis
|
|
28
|
+
from .line import distance_from_line, distance_from_line_segment
|
|
29
|
+
from .plane import distance_from_plane
|
|
30
|
+
from .point import distance_from_point
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"axis_value",
|
|
34
|
+
"distance_from_axis",
|
|
35
|
+
"distance_from_line",
|
|
36
|
+
"distance_from_line_segment",
|
|
37
|
+
"distance_from_plane",
|
|
38
|
+
"distance_from_point",
|
|
39
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Axis-based intensity functions."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
from numpy.typing import NDArray
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def axis_value(
|
|
14
|
+
axis: int = 2,
|
|
15
|
+
) -> Callable[[NDArray[np.floating]], NDArray[np.floating]]:
|
|
16
|
+
"""Create function that returns raw axis coordinate value.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
axis: Which axis index (0, 1, or 2 for X, Y, Z respectively).
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Function that maps vertices (N, 3) to coordinate values (N,).
|
|
23
|
+
|
|
24
|
+
Example::
|
|
25
|
+
|
|
26
|
+
# Color vertices by their Z coordinate
|
|
27
|
+
intensity_fn = axis_value(2)
|
|
28
|
+
intensities = intensity_fn(vertices)
|
|
29
|
+
"""
|
|
30
|
+
if axis not in (0, 1, 2):
|
|
31
|
+
msg = f"axis must be 0, 1, or 2, got {axis}"
|
|
32
|
+
raise ValueError(msg)
|
|
33
|
+
|
|
34
|
+
def fn(vertices: NDArray[np.floating]) -> NDArray[np.floating]:
|
|
35
|
+
return vertices[:, axis]
|
|
36
|
+
|
|
37
|
+
return fn
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def distance_from_axis(
|
|
41
|
+
axis: int = 2,
|
|
42
|
+
center: float = 0.0,
|
|
43
|
+
) -> Callable[[NDArray[np.floating]], NDArray[np.floating]]:
|
|
44
|
+
"""Create function that computes distance from an axis value.
|
|
45
|
+
|
|
46
|
+
This produces symmetric coloring around a reference value. Useful for
|
|
47
|
+
showing distance from a ground plane (Z=0) or similar reference.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
axis: Which axis index (0, 1, or 2 for X, Y, Z respectively).
|
|
51
|
+
center: The reference value on that axis.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Function that maps vertices (N, 3) to |axis_value - center| (N,).
|
|
55
|
+
|
|
56
|
+
Example::
|
|
57
|
+
|
|
58
|
+
# Color by distance from ground (Z=0)
|
|
59
|
+
intensity_fn = distance_from_axis(2, center=0.0)
|
|
60
|
+
|
|
61
|
+
# Color by distance from X=1000
|
|
62
|
+
intensity_fn = distance_from_axis(0, center=1000.0)
|
|
63
|
+
"""
|
|
64
|
+
if axis not in (0, 1, 2):
|
|
65
|
+
msg = f"axis must be 0, 1, or 2, got {axis}"
|
|
66
|
+
raise ValueError(msg)
|
|
67
|
+
|
|
68
|
+
def fn(vertices: NDArray[np.floating]) -> NDArray[np.floating]:
|
|
69
|
+
return np.abs(vertices[:, axis] - center)
|
|
70
|
+
|
|
71
|
+
return fn
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
__all__ = ["axis_value", "distance_from_axis"]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Line-based intensity functions."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
from numpy.typing import NDArray
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def distance_from_line(
|
|
14
|
+
point1: NDArray[np.floating],
|
|
15
|
+
point2: NDArray[np.floating],
|
|
16
|
+
) -> Callable[[NDArray[np.floating]], NDArray[np.floating]]:
|
|
17
|
+
"""Create function that computes perpendicular distance from an infinite line.
|
|
18
|
+
|
|
19
|
+
The line extends infinitely in both directions through the two points.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
point1: First point on line, shape (3,).
|
|
23
|
+
point2: Second point on line, shape (3,).
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Function that maps vertices (N, 3) to perpendicular distances (N,).
|
|
27
|
+
|
|
28
|
+
Example::
|
|
29
|
+
|
|
30
|
+
# Color by distance from baseline between two collectors
|
|
31
|
+
intensity_fn = distance_from_line(collector1_xyz, collector2_xyz)
|
|
32
|
+
"""
|
|
33
|
+
p1 = np.asarray(point1, dtype=np.float64)
|
|
34
|
+
p2 = np.asarray(point2, dtype=np.float64)
|
|
35
|
+
line_vec = p2 - p1
|
|
36
|
+
line_len = np.linalg.norm(line_vec)
|
|
37
|
+
line_dir = line_vec / line_len
|
|
38
|
+
|
|
39
|
+
def fn(vertices: NDArray[np.floating]) -> NDArray[np.floating]:
|
|
40
|
+
to_vert = vertices - p1
|
|
41
|
+
# Project onto line direction
|
|
42
|
+
proj_len = np.dot(to_vert, line_dir)
|
|
43
|
+
proj_point = p1 + np.outer(proj_len, line_dir)
|
|
44
|
+
return np.linalg.norm(vertices - proj_point, axis=1)
|
|
45
|
+
|
|
46
|
+
return fn
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def distance_from_line_segment(
|
|
50
|
+
point1: NDArray[np.floating],
|
|
51
|
+
point2: NDArray[np.floating],
|
|
52
|
+
) -> Callable[[NDArray[np.floating]], NDArray[np.floating]]:
|
|
53
|
+
"""Create function that computes distance from a line segment.
|
|
54
|
+
|
|
55
|
+
Unlike distance_from_line, this clamps to the segment endpoints.
|
|
56
|
+
Points beyond the endpoints measure distance to the nearest endpoint.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
point1: Start of segment, shape (3,).
|
|
60
|
+
point2: End of segment, shape (3,).
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Function that maps vertices (N, 3) to distances (N,).
|
|
64
|
+
|
|
65
|
+
Example::
|
|
66
|
+
|
|
67
|
+
# Color by distance from a finite baseline segment
|
|
68
|
+
intensity_fn = distance_from_line_segment(start_xyz, end_xyz)
|
|
69
|
+
"""
|
|
70
|
+
p1 = np.asarray(point1, dtype=np.float64)
|
|
71
|
+
p2 = np.asarray(point2, dtype=np.float64)
|
|
72
|
+
line_vec = p2 - p1
|
|
73
|
+
line_len_sq = np.dot(line_vec, line_vec)
|
|
74
|
+
|
|
75
|
+
def fn(vertices: NDArray[np.floating]) -> NDArray[np.floating]:
|
|
76
|
+
to_vert = vertices - p1
|
|
77
|
+
# Parameter t along segment, clamped to [0, 1]
|
|
78
|
+
t = np.dot(to_vert, line_vec) / line_len_sq
|
|
79
|
+
t = np.clip(t, 0, 1)
|
|
80
|
+
proj_point = p1 + np.outer(t, line_vec)
|
|
81
|
+
return np.linalg.norm(vertices - proj_point, axis=1)
|
|
82
|
+
|
|
83
|
+
return fn
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
__all__ = ["distance_from_line", "distance_from_line_segment"]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Plane-based intensity functions."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
from numpy.typing import NDArray
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def distance_from_plane(
|
|
14
|
+
normal: NDArray[np.floating],
|
|
15
|
+
point_on_plane: NDArray[np.floating],
|
|
16
|
+
) -> Callable[[NDArray[np.floating]], NDArray[np.floating]]:
|
|
17
|
+
"""Create function that computes absolute distance from a plane.
|
|
18
|
+
|
|
19
|
+
The plane is defined by a normal vector and a point on the plane.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
normal: Plane normal vector (will be normalized), shape (3,).
|
|
23
|
+
point_on_plane: Any point on the plane, shape (3,).
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Function that maps vertices (N, 3) to absolute distances (N,).
|
|
27
|
+
|
|
28
|
+
Example::
|
|
29
|
+
|
|
30
|
+
# Color by height above ground (horizontal plane at Z=0)
|
|
31
|
+
intensity_fn = distance_from_plane(
|
|
32
|
+
normal=np.array([0, 0, 1]),
|
|
33
|
+
point_on_plane=np.array([0, 0, 0]),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Color by distance from a tilted reference plane
|
|
37
|
+
intensity_fn = distance_from_plane(
|
|
38
|
+
normal=np.array([1, 1, 1]), # Will be normalized
|
|
39
|
+
point_on_plane=center_xyz,
|
|
40
|
+
)
|
|
41
|
+
"""
|
|
42
|
+
normal = np.asarray(normal, dtype=np.float64)
|
|
43
|
+
normal = normal / np.linalg.norm(normal)
|
|
44
|
+
point_on_plane = np.asarray(point_on_plane, dtype=np.float64)
|
|
45
|
+
d = -np.dot(normal, point_on_plane)
|
|
46
|
+
|
|
47
|
+
def fn(vertices: NDArray[np.floating]) -> NDArray[np.floating]:
|
|
48
|
+
return np.abs(np.dot(vertices, normal) + d)
|
|
49
|
+
|
|
50
|
+
return fn
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
__all__ = ["distance_from_plane"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Point-based intensity functions."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
from numpy.typing import NDArray
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def distance_from_point(
|
|
14
|
+
point: NDArray[np.floating],
|
|
15
|
+
) -> Callable[[NDArray[np.floating]], NDArray[np.floating]]:
|
|
16
|
+
"""Create function that computes Euclidean distance from a point.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
point: Reference point, shape (3,).
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Function that maps vertices (N, 3) to distances (N,).
|
|
23
|
+
|
|
24
|
+
Example::
|
|
25
|
+
|
|
26
|
+
# Color by distance from origin
|
|
27
|
+
intensity_fn = distance_from_point(np.array([0, 0, 0]))
|
|
28
|
+
|
|
29
|
+
# Color by distance from sensor location
|
|
30
|
+
intensity_fn = distance_from_point(sensor_xyz)
|
|
31
|
+
"""
|
|
32
|
+
point = np.asarray(point, dtype=np.float64)
|
|
33
|
+
|
|
34
|
+
def fn(vertices: NDArray[np.floating]) -> NDArray[np.floating]:
|
|
35
|
+
return np.linalg.norm(vertices - point, axis=1)
|
|
36
|
+
|
|
37
|
+
return fn
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = ["distance_from_point"]
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Intersection field for visualizing where multiple implicit shapes meet.
|
|
2
|
+
|
|
3
|
+
This module provides the IntersectionField class for highlighting regions
|
|
4
|
+
where multiple implicit shapes intersect. The intersection is computed using
|
|
5
|
+
Euclidean distance in residual space, giving a smooth tube-like volume around
|
|
6
|
+
the intersection curve/region.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
# Import directly to avoid circular import
|
|
14
|
+
from gri_plot.surfaces import merge_bounds
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from gri_plot.surfaces import ImplicitShape
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
|
|
22
|
+
from numpy.typing import NDArray
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class IntersectionField:
|
|
26
|
+
"""Combined field showing where multiple implicit shapes intersect.
|
|
27
|
+
|
|
28
|
+
Computes the Euclidean distance to the intersection of multiple shapes.
|
|
29
|
+
For surfaces, this is sqrt(sum of squared residuals), giving a smooth
|
|
30
|
+
tube-like volume around the intersection curve. A radius parameter
|
|
31
|
+
controls the tube thickness.
|
|
32
|
+
|
|
33
|
+
Shapes are treated differently based on their is_volume property:
|
|
34
|
+
- Surfaces (is_volume=False): Use |residual| as distance to surface
|
|
35
|
+
- Volumes (is_volume=True): Use max(0, residual) as distance outside
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
shapes: The implicit shapes being intersected.
|
|
39
|
+
radius: Tube radius - distance from intersection where residual = 0.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
shapes: Sequence[ImplicitShape],
|
|
45
|
+
radius: float | None = None,
|
|
46
|
+
# Legacy parameters for backward compatibility
|
|
47
|
+
threshold: float | None = None,
|
|
48
|
+
steepness: float | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Initialize the intersection field.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
shapes: Sequence of ImplicitShape objects to intersect.
|
|
54
|
+
Must have at least 2 shapes.
|
|
55
|
+
radius: Tube radius around the intersection. Points within this
|
|
56
|
+
distance of the intersection curve have negative residual.
|
|
57
|
+
If None, auto-computed as 5% of bounds diagonal.
|
|
58
|
+
threshold: Deprecated, use radius instead.
|
|
59
|
+
steepness: Deprecated, ignored.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
ValueError: If fewer than 2 shapes provided.
|
|
63
|
+
"""
|
|
64
|
+
if len(shapes) < 2: # noqa: PLR2004 - intersection requires >= 2 shapes
|
|
65
|
+
raise ValueError("IntersectionField requires at least 2 shapes")
|
|
66
|
+
|
|
67
|
+
self._shapes = list(shapes)
|
|
68
|
+
|
|
69
|
+
# Compute characteristic scale from bounds
|
|
70
|
+
bounds = self._compute_bounds()
|
|
71
|
+
extent = bounds[1] - bounds[0]
|
|
72
|
+
self._scale = float(np.linalg.norm(extent))
|
|
73
|
+
if self._scale == 0:
|
|
74
|
+
self._scale = 1.0
|
|
75
|
+
|
|
76
|
+
# Handle radius - either explicit or auto-computed
|
|
77
|
+
# The radius is in "residual space" - the same units as residual_fn outputs.
|
|
78
|
+
# For normalized residuals (like TDOA), typical values are 0.01-0.5.
|
|
79
|
+
# For spatial residuals (like distance to sphere), values are in meters.
|
|
80
|
+
if radius is not None:
|
|
81
|
+
self._radius = float(radius)
|
|
82
|
+
else:
|
|
83
|
+
# Default: 0.1 in residual space
|
|
84
|
+
# This works well for normalized residuals (TDOA, etc.)
|
|
85
|
+
# For spatial residuals, user should specify radius explicitly
|
|
86
|
+
self._radius = 0.1
|
|
87
|
+
|
|
88
|
+
# Keep for backward compatibility (though not used in new approach)
|
|
89
|
+
self._threshold = threshold if threshold is not None else 1.5
|
|
90
|
+
self._steepness = steepness if steepness is not None else 9.0
|
|
91
|
+
|
|
92
|
+
self._label: str | None = None
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def shapes(self) -> list[ImplicitShape]:
|
|
96
|
+
"""Get the list of shapes."""
|
|
97
|
+
return self._shapes
|
|
98
|
+
|
|
99
|
+
# Backward compatibility alias
|
|
100
|
+
@property
|
|
101
|
+
def surfaces(self) -> list[ImplicitShape]:
|
|
102
|
+
"""Get the list of shapes (deprecated, use shapes instead)."""
|
|
103
|
+
return self._shapes
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def radius(self) -> float:
|
|
107
|
+
"""Get the tube radius."""
|
|
108
|
+
return self._radius
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def threshold(self) -> float:
|
|
112
|
+
"""Get the threshold value (deprecated)."""
|
|
113
|
+
return self._threshold
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def steepness(self) -> float:
|
|
117
|
+
"""Get the steepness parameter (deprecated)."""
|
|
118
|
+
return self._steepness
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def label(self) -> str | None:
|
|
122
|
+
"""Get the optional label."""
|
|
123
|
+
return self._label
|
|
124
|
+
|
|
125
|
+
@label.setter
|
|
126
|
+
def label(self, value: str | None) -> None:
|
|
127
|
+
"""Set the label."""
|
|
128
|
+
self._label = value
|
|
129
|
+
|
|
130
|
+
def distance_to_intersection(
|
|
131
|
+
self,
|
|
132
|
+
xyz: NDArray[np.floating],
|
|
133
|
+
) -> NDArray[np.floating]:
|
|
134
|
+
"""Compute Euclidean distance to the intersection.
|
|
135
|
+
|
|
136
|
+
For surfaces, computes sqrt(sum of squared residuals), which gives
|
|
137
|
+
the distance to the intersection curve/region in residual space.
|
|
138
|
+
|
|
139
|
+
For volumes, uses max(0, residual) so points inside contribute 0.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
xyz: Points to evaluate, shape (..., 3).
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Distance values, shape (...). Zero exactly on the intersection,
|
|
146
|
+
positive elsewhere.
|
|
147
|
+
"""
|
|
148
|
+
xyz = np.asarray(xyz)
|
|
149
|
+
sum_sq = np.zeros(xyz.shape[:-1], dtype=np.float64)
|
|
150
|
+
|
|
151
|
+
for shape in self._shapes:
|
|
152
|
+
residual = shape.residual_fn(xyz)
|
|
153
|
+
|
|
154
|
+
if shape.is_volume:
|
|
155
|
+
# Volume: only count distance if outside (residual > 0)
|
|
156
|
+
distance = np.maximum(0.0, residual)
|
|
157
|
+
else:
|
|
158
|
+
# Surface: distance is |residual|
|
|
159
|
+
distance = np.abs(residual)
|
|
160
|
+
|
|
161
|
+
sum_sq += distance * distance
|
|
162
|
+
|
|
163
|
+
return np.sqrt(sum_sq)
|
|
164
|
+
|
|
165
|
+
def residual_fn(self, xyz: NDArray[np.floating]) -> NDArray[np.floating]:
|
|
166
|
+
"""Compute the intersection field residual.
|
|
167
|
+
|
|
168
|
+
Returns distance_to_intersection - radius, so:
|
|
169
|
+
- Negative inside the tube (within radius of intersection)
|
|
170
|
+
- Zero on the tube surface
|
|
171
|
+
- Positive outside the tube
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
xyz: Points to evaluate, shape (..., 3).
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Residual values, shape (...).
|
|
178
|
+
"""
|
|
179
|
+
return self.distance_to_intersection(xyz) - self._radius
|
|
180
|
+
|
|
181
|
+
# Keep for backward compatibility
|
|
182
|
+
def combined_closeness(self, xyz: NDArray[np.floating]) -> NDArray[np.floating]:
|
|
183
|
+
"""Compute closeness to intersection (deprecated).
|
|
184
|
+
|
|
185
|
+
This method is kept for backward compatibility but now uses the
|
|
186
|
+
Euclidean distance approach internally.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
xyz: Points to evaluate, shape (..., 3).
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Closeness values based on distance to intersection.
|
|
193
|
+
"""
|
|
194
|
+
distance = self.distance_to_intersection(xyz)
|
|
195
|
+
# Convert distance to closeness using exponential falloff
|
|
196
|
+
# Normalize by radius so closeness ~ 1 at the intersection
|
|
197
|
+
return np.exp(-distance / self._radius)
|
|
198
|
+
|
|
199
|
+
def _compute_bounds(
|
|
200
|
+
self,
|
|
201
|
+
) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
|
|
202
|
+
"""Compute combined bounding box from all shapes."""
|
|
203
|
+
all_bounds = [s.get_bounds_xyz() for s in self._shapes]
|
|
204
|
+
return merge_bounds(*all_bounds)
|
|
205
|
+
|
|
206
|
+
def get_bounds_xyz(
|
|
207
|
+
self,
|
|
208
|
+
) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
|
|
209
|
+
"""Get combined bounding box from all shapes.
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
Tuple of (min_corner, max_corner) encompassing all shapes.
|
|
213
|
+
"""
|
|
214
|
+
return self._compute_bounds()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
__all__ = ["IntersectionField"]
|