lsurf 1.0.0__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.
Files changed (180) hide show
  1. lsurf/__init__.py +471 -0
  2. lsurf/analysis/__init__.py +107 -0
  3. lsurf/analysis/healpix_utils.py +418 -0
  4. lsurf/analysis/sphere_viz.py +1280 -0
  5. lsurf/cli/__init__.py +48 -0
  6. lsurf/cli/build.py +398 -0
  7. lsurf/cli/config_schema.py +318 -0
  8. lsurf/cli/gui_cmd.py +76 -0
  9. lsurf/cli/interactive.py +850 -0
  10. lsurf/cli/main.py +81 -0
  11. lsurf/cli/run.py +806 -0
  12. lsurf/detectors/__init__.py +266 -0
  13. lsurf/detectors/analysis.py +289 -0
  14. lsurf/detectors/base.py +284 -0
  15. lsurf/detectors/constant_size_rings.py +485 -0
  16. lsurf/detectors/directional.py +45 -0
  17. lsurf/detectors/extended/__init__.py +73 -0
  18. lsurf/detectors/extended/local_sphere.py +353 -0
  19. lsurf/detectors/extended/recording_sphere.py +368 -0
  20. lsurf/detectors/planar.py +45 -0
  21. lsurf/detectors/protocol.py +187 -0
  22. lsurf/detectors/recording_spheres.py +63 -0
  23. lsurf/detectors/results.py +1140 -0
  24. lsurf/detectors/small/__init__.py +79 -0
  25. lsurf/detectors/small/directional.py +330 -0
  26. lsurf/detectors/small/planar.py +401 -0
  27. lsurf/detectors/small/spherical.py +450 -0
  28. lsurf/detectors/spherical.py +45 -0
  29. lsurf/geometry/__init__.py +199 -0
  30. lsurf/geometry/builder.py +478 -0
  31. lsurf/geometry/cell.py +228 -0
  32. lsurf/geometry/cell_geometry.py +247 -0
  33. lsurf/geometry/detector_arrays.py +1785 -0
  34. lsurf/geometry/geometry.py +222 -0
  35. lsurf/geometry/surface_analysis.py +375 -0
  36. lsurf/geometry/validation.py +91 -0
  37. lsurf/gui/__init__.py +51 -0
  38. lsurf/gui/app.py +903 -0
  39. lsurf/gui/core/__init__.py +39 -0
  40. lsurf/gui/core/scene.py +343 -0
  41. lsurf/gui/core/simulation.py +264 -0
  42. lsurf/gui/renderers/__init__.py +40 -0
  43. lsurf/gui/renderers/ray_renderer.py +353 -0
  44. lsurf/gui/renderers/source_renderer.py +505 -0
  45. lsurf/gui/renderers/surface_renderer.py +477 -0
  46. lsurf/gui/views/__init__.py +48 -0
  47. lsurf/gui/views/config_editor.py +3199 -0
  48. lsurf/gui/views/properties.py +257 -0
  49. lsurf/gui/views/results.py +291 -0
  50. lsurf/gui/views/scene_tree.py +180 -0
  51. lsurf/gui/views/viewport_3d.py +555 -0
  52. lsurf/gui/views/visualizations.py +712 -0
  53. lsurf/materials/__init__.py +169 -0
  54. lsurf/materials/base/__init__.py +64 -0
  55. lsurf/materials/base/full_inhomogeneous.py +208 -0
  56. lsurf/materials/base/grid_inhomogeneous.py +319 -0
  57. lsurf/materials/base/homogeneous.py +342 -0
  58. lsurf/materials/base/material_field.py +527 -0
  59. lsurf/materials/base/simple_inhomogeneous.py +418 -0
  60. lsurf/materials/base/spectral_inhomogeneous.py +497 -0
  61. lsurf/materials/implementations/__init__.py +120 -0
  62. lsurf/materials/implementations/data/alpha_values_typical_atmosphere_updated.txt +24 -0
  63. lsurf/materials/implementations/duct_atmosphere.py +390 -0
  64. lsurf/materials/implementations/exponential_atmosphere.py +435 -0
  65. lsurf/materials/implementations/gaussian_lens.py +120 -0
  66. lsurf/materials/implementations/interpolated_data.py +123 -0
  67. lsurf/materials/implementations/layered_atmosphere.py +134 -0
  68. lsurf/materials/implementations/linear_gradient.py +109 -0
  69. lsurf/materials/implementations/linsley_atmosphere.py +764 -0
  70. lsurf/materials/implementations/standard_materials.py +126 -0
  71. lsurf/materials/implementations/turbulent_atmosphere.py +135 -0
  72. lsurf/materials/implementations/us_standard_atmosphere.py +149 -0
  73. lsurf/materials/utils/__init__.py +77 -0
  74. lsurf/materials/utils/constants.py +45 -0
  75. lsurf/materials/utils/device_functions.py +117 -0
  76. lsurf/materials/utils/dispersion.py +160 -0
  77. lsurf/materials/utils/factories.py +142 -0
  78. lsurf/propagation/__init__.py +91 -0
  79. lsurf/propagation/detector_gpu.py +67 -0
  80. lsurf/propagation/gpu_device_rays.py +294 -0
  81. lsurf/propagation/kernels/__init__.py +175 -0
  82. lsurf/propagation/kernels/absorption/__init__.py +61 -0
  83. lsurf/propagation/kernels/absorption/grid.py +240 -0
  84. lsurf/propagation/kernels/absorption/simple.py +232 -0
  85. lsurf/propagation/kernels/absorption/spectral.py +410 -0
  86. lsurf/propagation/kernels/detection/__init__.py +64 -0
  87. lsurf/propagation/kernels/detection/protocol.py +102 -0
  88. lsurf/propagation/kernels/detection/spherical.py +255 -0
  89. lsurf/propagation/kernels/device_functions.py +790 -0
  90. lsurf/propagation/kernels/fresnel/__init__.py +64 -0
  91. lsurf/propagation/kernels/fresnel/protocol.py +97 -0
  92. lsurf/propagation/kernels/fresnel/standard.py +258 -0
  93. lsurf/propagation/kernels/intersection/__init__.py +79 -0
  94. lsurf/propagation/kernels/intersection/annular_plane.py +207 -0
  95. lsurf/propagation/kernels/intersection/bounded_plane.py +205 -0
  96. lsurf/propagation/kernels/intersection/plane.py +166 -0
  97. lsurf/propagation/kernels/intersection/protocol.py +95 -0
  98. lsurf/propagation/kernels/intersection/signed_distance.py +742 -0
  99. lsurf/propagation/kernels/intersection/sphere.py +190 -0
  100. lsurf/propagation/kernels/propagation/__init__.py +85 -0
  101. lsurf/propagation/kernels/propagation/grid.py +527 -0
  102. lsurf/propagation/kernels/propagation/protocol.py +105 -0
  103. lsurf/propagation/kernels/propagation/simple.py +460 -0
  104. lsurf/propagation/kernels/propagation/spectral.py +875 -0
  105. lsurf/propagation/kernels/registry.py +331 -0
  106. lsurf/propagation/kernels/surface/__init__.py +72 -0
  107. lsurf/propagation/kernels/surface/bisection.py +232 -0
  108. lsurf/propagation/kernels/surface/detection.py +402 -0
  109. lsurf/propagation/kernels/surface/reduction.py +166 -0
  110. lsurf/propagation/propagator_protocol.py +222 -0
  111. lsurf/propagation/propagators/__init__.py +101 -0
  112. lsurf/propagation/propagators/detector_handler.py +354 -0
  113. lsurf/propagation/propagators/factory.py +200 -0
  114. lsurf/propagation/propagators/fresnel_handler.py +305 -0
  115. lsurf/propagation/propagators/gpu_gradient.py +566 -0
  116. lsurf/propagation/propagators/gpu_surface_propagator.py +707 -0
  117. lsurf/propagation/propagators/gradient.py +429 -0
  118. lsurf/propagation/propagators/intersection_handler.py +327 -0
  119. lsurf/propagation/propagators/material_propagator.py +398 -0
  120. lsurf/propagation/propagators/signed_distance_handler.py +522 -0
  121. lsurf/propagation/propagators/spectral_gpu_gradient.py +553 -0
  122. lsurf/propagation/propagators/surface_interaction.py +616 -0
  123. lsurf/propagation/propagators/surface_propagator.py +719 -0
  124. lsurf/py.typed +1 -0
  125. lsurf/simulation/__init__.py +70 -0
  126. lsurf/simulation/config.py +164 -0
  127. lsurf/simulation/orchestrator.py +462 -0
  128. lsurf/simulation/result.py +299 -0
  129. lsurf/simulation/simulation.py +262 -0
  130. lsurf/sources/__init__.py +128 -0
  131. lsurf/sources/base.py +264 -0
  132. lsurf/sources/collimated.py +252 -0
  133. lsurf/sources/custom.py +409 -0
  134. lsurf/sources/diverging.py +228 -0
  135. lsurf/sources/gaussian.py +272 -0
  136. lsurf/sources/parallel_from_positions.py +197 -0
  137. lsurf/sources/point.py +172 -0
  138. lsurf/sources/uniform_diverging.py +258 -0
  139. lsurf/surfaces/__init__.py +184 -0
  140. lsurf/surfaces/cpu/__init__.py +50 -0
  141. lsurf/surfaces/cpu/curved_wave.py +463 -0
  142. lsurf/surfaces/cpu/gerstner_wave.py +381 -0
  143. lsurf/surfaces/cpu/wave_params.py +118 -0
  144. lsurf/surfaces/gpu/__init__.py +72 -0
  145. lsurf/surfaces/gpu/annular_plane.py +453 -0
  146. lsurf/surfaces/gpu/bounded_plane.py +390 -0
  147. lsurf/surfaces/gpu/curved_wave.py +483 -0
  148. lsurf/surfaces/gpu/gerstner_wave.py +377 -0
  149. lsurf/surfaces/gpu/multi_curved_wave.py +520 -0
  150. lsurf/surfaces/gpu/plane.py +299 -0
  151. lsurf/surfaces/gpu/recording_sphere.py +587 -0
  152. lsurf/surfaces/gpu/sphere.py +311 -0
  153. lsurf/surfaces/protocol.py +336 -0
  154. lsurf/surfaces/registry.py +373 -0
  155. lsurf/utilities/__init__.py +175 -0
  156. lsurf/utilities/detector_analysis.py +814 -0
  157. lsurf/utilities/fresnel.py +628 -0
  158. lsurf/utilities/interactions.py +1215 -0
  159. lsurf/utilities/propagation.py +602 -0
  160. lsurf/utilities/ray_data.py +532 -0
  161. lsurf/utilities/recording_sphere.py +745 -0
  162. lsurf/utilities/time_spread.py +463 -0
  163. lsurf/visualization/__init__.py +329 -0
  164. lsurf/visualization/absorption_plots.py +334 -0
  165. lsurf/visualization/atmospheric_plots.py +754 -0
  166. lsurf/visualization/common.py +348 -0
  167. lsurf/visualization/detector_plots.py +1350 -0
  168. lsurf/visualization/detector_sphere_plots.py +1173 -0
  169. lsurf/visualization/fresnel_plots.py +1061 -0
  170. lsurf/visualization/ocean_simulation_plots.py +999 -0
  171. lsurf/visualization/polarization_plots.py +916 -0
  172. lsurf/visualization/raytracing_plots.py +1521 -0
  173. lsurf/visualization/ring_detector_plots.py +1867 -0
  174. lsurf/visualization/time_spread_plots.py +531 -0
  175. lsurf-1.0.0.dist-info/METADATA +381 -0
  176. lsurf-1.0.0.dist-info/RECORD +180 -0
  177. lsurf-1.0.0.dist-info/WHEEL +5 -0
  178. lsurf-1.0.0.dist-info/entry_points.txt +2 -0
  179. lsurf-1.0.0.dist-info/licenses/LICENSE +32 -0
  180. lsurf-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,463 @@
1
+ # The Clear BSD License
2
+ #
3
+ # Copyright (c) 2026 Tobias Heibges
4
+ # All rights reserved.
5
+ #
6
+ # Redistribution and use in source and binary forms, with or without
7
+ # modification, are permitted (subject to the limitations in the disclaimer
8
+ # below) provided that the following conditions are met:
9
+ #
10
+ # * Redistributions of source code must retain the above copyright notice,
11
+ # this list of conditions and the following disclaimer.
12
+ #
13
+ # * Redistributions in binary form must reproduce the above copyright
14
+ # notice, this list of conditions and the following disclaimer in the
15
+ # documentation and/or other materials provided with the distribution.
16
+ #
17
+ # * Neither the name of the copyright holder nor the names of its
18
+ # contributors may be used to endorse or promote products derived from this
19
+ # software without specific prior written permission.
20
+ #
21
+ # NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
22
+ # THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
23
+ # CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
24
+ # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
25
+ # PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
26
+ # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
27
+ # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
28
+ # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
29
+ # BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
30
+ # IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
31
+ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
32
+ # POSSIBILITY OF SUCH DAMAGE.
33
+
34
+ """
35
+ Curved Wave Surface (CPU-Only)
36
+
37
+ Ocean wave surface on a curved (spherical) Earth. Uses ray marching
38
+ for intersection due to complex geometry.
39
+
40
+ For flat-earth wave surfaces, see GerstnerWaveSurface.
41
+ """
42
+
43
+ from dataclasses import dataclass, field
44
+ from typing import Any
45
+
46
+ import numpy as np
47
+ from numpy.typing import NDArray
48
+
49
+ from ..protocol import Surface, SurfaceRole
50
+ from ..registry import register_surface_type
51
+ from .wave_params import GerstnerWaveParams
52
+
53
+ # Earth parameters
54
+ EARTH_RADIUS = 6.371e6 # Earth's mean radius in meters
55
+
56
+
57
+ @dataclass
58
+ class CurvedWaveSurface(Surface):
59
+ """
60
+ Ocean wave surface on a curved (spherical) Earth (CPU-only).
61
+
62
+ Combines Earth's spherical curvature with Gerstner wave perturbations
63
+ applied in local tangent space.
64
+
65
+ Parameters
66
+ ----------
67
+ wave_params : list of GerstnerWaveParams
68
+ List of wave components.
69
+ role : SurfaceRole
70
+ What happens when a ray hits (typically OPTICAL).
71
+ name : str
72
+ Human-readable name.
73
+ earth_center : tuple of float, optional
74
+ Center of Earth sphere. Default is (0, 0, -EARTH_RADIUS).
75
+ earth_radius : float, optional
76
+ Earth radius in meters. Default is EARTH_RADIUS.
77
+ time : float, optional
78
+ Time for wave animation in seconds. Default is 0.0.
79
+ material_front : MaterialField, optional
80
+ Material above surface (atmosphere).
81
+ material_back : MaterialField, optional
82
+ Material below surface (ocean water).
83
+
84
+ Examples
85
+ --------
86
+ >>> from lsurf.surfaces import CurvedWaveSurface, GerstnerWaveParams, SurfaceRole
87
+ >>> from lsurf.materials import ExponentialAtmosphere, WATER
88
+ >>>
89
+ >>> wave = GerstnerWaveParams(amplitude=1.0, wavelength=50.0)
90
+ >>> ocean = CurvedWaveSurface(
91
+ ... wave_params=[wave],
92
+ ... role=SurfaceRole.OPTICAL,
93
+ ... name="ocean",
94
+ ... material_front=ExponentialAtmosphere(),
95
+ ... material_back=WATER,
96
+ ... )
97
+ """
98
+
99
+ wave_params: list[GerstnerWaveParams]
100
+ role: SurfaceRole
101
+ name: str = "curved_wave"
102
+ earth_center: tuple[float, float, float] = (0, 0, -EARTH_RADIUS)
103
+ earth_radius: float = EARTH_RADIUS
104
+ time: float = 0.0
105
+ material_front: Any = None
106
+ material_back: Any = None
107
+
108
+ # CPU-only surface
109
+ _gpu_capable: bool = field(default=False, init=False, repr=False)
110
+ _geometry_id: int = field(
111
+ default=0, init=False, repr=False
112
+ ) # CPU-only, no GPU geometry
113
+
114
+ # Precomputed wave arrays
115
+ _wave_amplitudes: NDArray = field(default=None, init=False, repr=False)
116
+ _wave_numbers: NDArray = field(default=None, init=False, repr=False)
117
+ _wave_frequencies: NDArray = field(default=None, init=False, repr=False)
118
+ _wave_directions: NDArray = field(default=None, init=False, repr=False)
119
+ _wave_phases: NDArray = field(default=None, init=False, repr=False)
120
+ _wave_steepness: NDArray = field(default=None, init=False, repr=False)
121
+ _earth_center_arr: NDArray = field(default=None, init=False, repr=False)
122
+
123
+ def __post_init__(self) -> None:
124
+ self._earth_center_arr = np.array(self.earth_center, dtype=np.float64)
125
+ self._precompute_wave_params()
126
+
127
+ def _precompute_wave_params(self) -> None:
128
+ """Precompute wave parameters as arrays."""
129
+ n_waves = len(self.wave_params)
130
+ if n_waves == 0:
131
+ self._wave_amplitudes = np.array([], dtype=np.float64)
132
+ self._wave_numbers = np.array([], dtype=np.float64)
133
+ self._wave_frequencies = np.array([], dtype=np.float64)
134
+ self._wave_directions = np.zeros((0, 2), dtype=np.float64)
135
+ self._wave_phases = np.array([], dtype=np.float64)
136
+ self._wave_steepness = np.array([], dtype=np.float64)
137
+ return
138
+
139
+ self._wave_amplitudes = np.array(
140
+ [w.amplitude for w in self.wave_params], dtype=np.float64
141
+ )
142
+ self._wave_numbers = np.array(
143
+ [w.wave_number for w in self.wave_params], dtype=np.float64
144
+ )
145
+ self._wave_frequencies = np.array(
146
+ [w.angular_frequency for w in self.wave_params], dtype=np.float64
147
+ )
148
+ self._wave_directions = np.array(
149
+ [w.direction_normalized for w in self.wave_params], dtype=np.float64
150
+ )
151
+ self._wave_phases = np.array(
152
+ [w.phase for w in self.wave_params], dtype=np.float64
153
+ )
154
+ self._wave_steepness = np.array(
155
+ [w.steepness for w in self.wave_params], dtype=np.float64
156
+ )
157
+
158
+ @property
159
+ def gpu_capable(self) -> bool:
160
+ """This surface does NOT support GPU acceleration."""
161
+ return False
162
+
163
+ @property
164
+ def geometry_id(self) -> int:
165
+ """GPU geometry type ID (0 = CPU-only)."""
166
+ return 0
167
+
168
+ def get_materials(self) -> tuple | None:
169
+ """Return materials for Fresnel calculation."""
170
+ if self.role == SurfaceRole.OPTICAL:
171
+ return (self.material_front, self.material_back)
172
+ return None
173
+
174
+ def get_max_wave_height(self) -> float:
175
+ """Get maximum possible wave displacement."""
176
+ if len(self.wave_params) == 0:
177
+ return 0.0
178
+ return float(np.sum(self._wave_amplitudes))
179
+
180
+ def _get_local_tangent_basis(
181
+ self, positions: NDArray[np.float64]
182
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
183
+ """Compute local tangent basis at each position on the sphere."""
184
+ to_pos = positions - self._earth_center_arr
185
+ dist = np.linalg.norm(to_pos, axis=1, keepdims=True)
186
+ dist = np.maximum(dist, 1e-10)
187
+
188
+ radial = to_pos / dist
189
+
190
+ global_x = np.array([1.0, 0.0, 0.0], dtype=np.float64)
191
+ dot_x = np.sum(radial * global_x, axis=1, keepdims=True)
192
+ tangent_x = global_x - dot_x * radial
193
+ tangent_x_norm = np.linalg.norm(tangent_x, axis=1, keepdims=True)
194
+ tangent_x = tangent_x / np.maximum(tangent_x_norm, 1e-10)
195
+
196
+ global_y = np.array([0.0, 1.0, 0.0], dtype=np.float64)
197
+ dot_y = np.sum(radial * global_y, axis=1, keepdims=True)
198
+ tangent_y = global_y - dot_y * radial
199
+ tangent_y_norm = np.linalg.norm(tangent_y, axis=1, keepdims=True)
200
+ tangent_y = tangent_y / np.maximum(tangent_y_norm, 1e-10)
201
+
202
+ return radial, tangent_x, tangent_y
203
+
204
+ def _compute_wave_displacement(
205
+ self,
206
+ local_x: NDArray[np.float64],
207
+ local_y: NDArray[np.float64],
208
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
209
+ """Compute Gerstner wave displacement in local coordinates."""
210
+ n_points = len(local_x)
211
+ dx = np.zeros(n_points, dtype=np.float64)
212
+ dy = np.zeros(n_points, dtype=np.float64)
213
+ dz = np.zeros(n_points, dtype=np.float64)
214
+
215
+ for i in range(len(self.wave_params)):
216
+ A = self._wave_amplitudes[i]
217
+ k = self._wave_numbers[i]
218
+ omega = self._wave_frequencies[i]
219
+ dir_x, dir_y = self._wave_directions[i]
220
+ phi = self._wave_phases[i]
221
+ Q = self._wave_steepness[i]
222
+
223
+ phase = k * (dir_x * local_x + dir_y * local_y) - omega * self.time + phi
224
+
225
+ cos_phase = np.cos(phase)
226
+ sin_phase = np.sin(phase)
227
+
228
+ dx -= Q * A * dir_x * sin_phase
229
+ dy -= Q * A * dir_y * sin_phase
230
+ dz += A * cos_phase
231
+
232
+ return dx, dy, dz
233
+
234
+ def _compute_wave_normal(
235
+ self,
236
+ local_x: NDArray[np.float64],
237
+ local_y: NDArray[np.float64],
238
+ ) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
239
+ """Compute wave surface normal in local coordinates."""
240
+ n_points = len(local_x)
241
+
242
+ nx = np.zeros(n_points, dtype=np.float64)
243
+ ny = np.zeros(n_points, dtype=np.float64)
244
+ nz = np.ones(n_points, dtype=np.float64)
245
+
246
+ for i in range(len(self.wave_params)):
247
+ A = self._wave_amplitudes[i]
248
+ k = self._wave_numbers[i]
249
+ omega = self._wave_frequencies[i]
250
+ dir_x, dir_y = self._wave_directions[i]
251
+ phi = self._wave_phases[i]
252
+ Q = self._wave_steepness[i]
253
+
254
+ phase = k * (dir_x * local_x + dir_y * local_y) - omega * self.time + phi
255
+
256
+ sin_phase = np.sin(phase)
257
+
258
+ WA = k * A
259
+
260
+ nx += dir_x * WA * sin_phase
261
+ ny += dir_y * WA * sin_phase
262
+ nz -= Q * WA * sin_phase
263
+
264
+ norm = np.sqrt(nx**2 + ny**2 + nz**2)
265
+ norm = np.maximum(norm, 1e-10)
266
+
267
+ return nx / norm, ny / norm, nz / norm
268
+
269
+ def intersect(
270
+ self,
271
+ origins: NDArray[np.float32],
272
+ directions: NDArray[np.float32],
273
+ min_distance: float = 1e-6,
274
+ max_iterations: int = 200,
275
+ tolerance: float = 1e-3,
276
+ max_distance: float | None = None,
277
+ ) -> tuple[NDArray[np.float32], NDArray[np.bool_]]:
278
+ """
279
+ Find ray-surface intersections using ray marching.
280
+
281
+ Parameters
282
+ ----------
283
+ origins : ndarray, shape (N, 3)
284
+ Ray origin positions.
285
+ directions : ndarray, shape (N, 3)
286
+ Ray direction unit vectors.
287
+ min_distance : float
288
+ Minimum valid intersection distance.
289
+ max_iterations : int
290
+ Maximum ray marching iterations.
291
+ tolerance : float
292
+ Convergence tolerance in meters.
293
+ max_distance : float, optional
294
+ Maximum search distance.
295
+
296
+ Returns
297
+ -------
298
+ distances : ndarray, shape (N,)
299
+ Distance to intersection (inf if no hit).
300
+ hit_mask : ndarray, shape (N,), dtype=bool
301
+ True for rays that hit the surface.
302
+ """
303
+ origins = origins.astype(np.float64)
304
+ directions = directions.astype(np.float64)
305
+ n_rays = len(origins)
306
+
307
+ distances = np.full(n_rays, np.inf, dtype=np.float64)
308
+ hit_mask = np.zeros(n_rays, dtype=bool)
309
+
310
+ t = np.full(n_rays, min_distance, dtype=np.float64)
311
+ active = np.ones(n_rays, dtype=bool)
312
+
313
+ max_wave_height = self.get_max_wave_height()
314
+
315
+ # Find intersection with outer sphere
316
+ outer_radius = self.earth_radius + max_wave_height
317
+ oc = origins - self._earth_center_arr
318
+ a = np.sum(directions * directions, axis=1)
319
+ b = 2.0 * np.sum(directions * oc, axis=1)
320
+ c_outer = np.sum(oc * oc, axis=1) - outer_radius**2
321
+
322
+ discriminant_outer = b**2 - 4 * a * c_outer
323
+ has_potential_hit = discriminant_outer >= 0
324
+ active[~has_potential_hit] = False
325
+
326
+ sqrt_disc_outer = np.sqrt(np.maximum(discriminant_outer, 0))
327
+ t1_outer = (-b - sqrt_disc_outer) / (2 * a + 1e-20)
328
+ t_start = np.where(t1_outer > min_distance, t1_outer, min_distance)
329
+ t = t_start.copy()
330
+
331
+ prev_signed_dist = np.full(n_rays, np.inf)
332
+ prev_t = t.copy()
333
+ relaxation = 0.5
334
+
335
+ for _ in range(max_iterations):
336
+ if not np.any(active):
337
+ break
338
+
339
+ positions = origins + t[:, np.newaxis] * directions
340
+
341
+ to_pos = positions - self._earth_center_arr
342
+ dist_from_center = np.linalg.norm(to_pos, axis=1)
343
+ radial = to_pos / np.maximum(dist_from_center[:, np.newaxis], 1e-10)
344
+
345
+ cos_angle = np.abs(np.sum(directions * radial, axis=1))
346
+ cos_angle = np.maximum(cos_angle, 0.01)
347
+
348
+ local_x = positions[:, 0]
349
+ local_y = positions[:, 1]
350
+ _, _, wave_height = self._compute_wave_displacement(local_x, local_y)
351
+
352
+ surface_radius = self.earth_radius + wave_height
353
+ signed_dist = dist_from_center - surface_radius
354
+
355
+ converged = np.abs(signed_dist) < tolerance
356
+ hit_mask[active & converged] = True
357
+ distances[active & converged] = t[active & converged]
358
+ active[converged] = False
359
+
360
+ crossed = (
361
+ active
362
+ & (signed_dist * prev_signed_dist < 0)
363
+ & np.isfinite(prev_signed_dist)
364
+ )
365
+ if np.any(crossed):
366
+ t_low = np.where(prev_signed_dist > 0, prev_t, t)
367
+ t_high = np.where(prev_signed_dist > 0, t, prev_t)
368
+
369
+ for _ in range(15):
370
+ t_mid = (t_low + t_high) / 2
371
+ pos_mid = origins + t_mid[:, np.newaxis] * directions
372
+ to_pos_mid = pos_mid - self._earth_center_arr
373
+ dist_mid = np.linalg.norm(to_pos_mid, axis=1)
374
+ _, _, wh_mid = self._compute_wave_displacement(
375
+ pos_mid[:, 0], pos_mid[:, 1]
376
+ )
377
+ sd_mid = dist_mid - (self.earth_radius + wh_mid)
378
+
379
+ above = sd_mid > 0
380
+ t_low = np.where(crossed & above, t_mid, t_low)
381
+ t_high = np.where(crossed & ~above, t_mid, t_high)
382
+
383
+ t[crossed] = (t_low[crossed] + t_high[crossed]) / 2
384
+
385
+ positions = origins + t[:, np.newaxis] * directions
386
+ to_pos = positions - self._earth_center_arr
387
+ dist_from_center = np.linalg.norm(to_pos, axis=1)
388
+ _, _, wave_height = self._compute_wave_displacement(
389
+ positions[:, 0], positions[:, 1]
390
+ )
391
+ signed_dist = dist_from_center - (self.earth_radius + wave_height)
392
+
393
+ converged = np.abs(signed_dist) < tolerance
394
+ hit_mask[active & converged] = True
395
+ distances[active & converged] = t[active & converged]
396
+ active[converged] = False
397
+
398
+ too_far = signed_dist < -max_wave_height - 10
399
+ active[too_far] = False
400
+
401
+ if max_distance is not None:
402
+ exceeded_max = t > max_distance
403
+ active[exceeded_max] = False
404
+
405
+ prev_signed_dist = signed_dist.copy()
406
+ prev_t = t.copy()
407
+
408
+ step = signed_dist / cos_angle * relaxation
409
+ step = np.clip(step, -max_wave_height * 2, max_wave_height * 2)
410
+ t[active] += step[active]
411
+ t = np.maximum(t, 0)
412
+
413
+ return distances.astype(np.float32), hit_mask
414
+
415
+ def normal_at(
416
+ self,
417
+ positions: NDArray[np.float32],
418
+ incoming_directions: NDArray[np.float32] | None = None,
419
+ ) -> NDArray[np.float32]:
420
+ """
421
+ Compute surface normal at given positions.
422
+
423
+ Parameters
424
+ ----------
425
+ positions : ndarray, shape (N, 3)
426
+ Points on the surface.
427
+ incoming_directions : ndarray, shape (N, 3), optional
428
+ Incoming ray directions.
429
+
430
+ Returns
431
+ -------
432
+ normals : ndarray, shape (N, 3)
433
+ Unit normal vectors.
434
+ """
435
+ positions = positions.astype(np.float64)
436
+
437
+ radial, tangent_x, tangent_y = self._get_local_tangent_basis(positions)
438
+
439
+ local_x = positions[:, 0]
440
+ local_y = positions[:, 1]
441
+
442
+ nx_local, ny_local, nz_local = self._compute_wave_normal(local_x, local_y)
443
+
444
+ normals = (
445
+ nx_local[:, np.newaxis] * tangent_x
446
+ + ny_local[:, np.newaxis] * tangent_y
447
+ + nz_local[:, np.newaxis] * radial
448
+ )
449
+
450
+ if incoming_directions is not None:
451
+ dot_products = np.sum(normals * incoming_directions, axis=1)
452
+ flip_mask = dot_products > 0
453
+ normals[flip_mask] *= -1
454
+
455
+ return normals.astype(np.float32)
456
+
457
+ def set_time(self, time: float) -> None:
458
+ """Update the wave animation time."""
459
+ self.time = time
460
+
461
+
462
+ # Register class with registry
463
+ register_surface_type("curved_wave", "cpu", cls=CurvedWaveSurface)