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,520 @@
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
+ Multi-Wave Curved Surface (GPU-Capable)
36
+
37
+ GPU-accelerated ocean wave surface on a curved (spherical) Earth with multiple
38
+ wave components. Supports up to 8 superimposed wave components for realistic
39
+ ocean surface modeling.
40
+
41
+ Uses geometry_id=5 for GPU kernel dispatch. Parameters are packed into a
42
+ 64-element tuple with the following layout:
43
+ - p0-2: earth_center_x/y/z
44
+ - p3: earth_radius
45
+ - p4: time
46
+ - p5: num_waves
47
+ - p6-7: reserved
48
+ - For each wave i (i=0..7), starting at offset 8 + i*8:
49
+ - amplitude, wave_number, dir_x, dir_y, phase, steepness, reserved, reserved
50
+ """
51
+
52
+ from dataclasses import dataclass, field
53
+ from typing import Any
54
+
55
+ import numpy as np
56
+ import numpy.typing as npt
57
+
58
+ from ..protocol import Surface, SurfaceRole
59
+ from ..registry import register_surface_type
60
+ from ..cpu.wave_params import GerstnerWaveParams
61
+
62
+ # Earth parameters
63
+ EARTH_RADIUS = 6.371e6 # Earth's mean radius in meters
64
+ GRAVITY = 9.81 # Gravity for deep water dispersion
65
+
66
+ # Maximum number of wave components
67
+ MAX_WAVES = 8
68
+
69
+
70
+ @dataclass
71
+ class GPUMultiCurvedWaveSurface(Surface):
72
+ """
73
+ GPU-accelerated curved-earth ocean wave surface with multiple wave components.
74
+
75
+ This surface supports up to 8 superimposed wave components on a spherical Earth.
76
+ Each wave is treated as a perturbation on top of Earth's spherical surface.
77
+
78
+ Uses GPU-accelerated signed distance computation with geometry_id=5.
79
+ Parameters are packed into a 64-element tuple for the GPU kernel.
80
+
81
+ Parameters
82
+ ----------
83
+ wave_params : list of GerstnerWaveParams
84
+ List of wave components (max 8). Each wave has amplitude, wavelength,
85
+ direction, phase, and steepness.
86
+ role : SurfaceRole
87
+ What happens when a ray hits (typically OPTICAL).
88
+ earth_center : tuple of float, optional
89
+ Center of Earth sphere. Default is (0, 0, -EARTH_RADIUS).
90
+ earth_radius : float, optional
91
+ Earth radius in meters. Default is EARTH_RADIUS.
92
+ time : float, optional
93
+ Animation time in seconds. Default is 0.0.
94
+ name : str
95
+ Human-readable name.
96
+ material_front : MaterialField, optional
97
+ Material above surface (atmosphere).
98
+ material_back : MaterialField, optional
99
+ Material below surface (ocean water).
100
+
101
+ Examples
102
+ --------
103
+ >>> from lsurf.surfaces import GPUMultiCurvedWaveSurface, SurfaceRole, GerstnerWaveParams
104
+ >>> from lsurf.materials import ExponentialAtmosphere, WATER
105
+ >>>
106
+ >>> waves = [
107
+ ... GerstnerWaveParams(amplitude=1.5, wavelength=50.0, direction=(1.0, 0.0)),
108
+ ... GerstnerWaveParams(amplitude=0.8, wavelength=30.0, direction=(0.7, 0.7)),
109
+ ... GerstnerWaveParams(amplitude=0.3, wavelength=15.0, direction=(-0.5, 0.8)),
110
+ ... ]
111
+ >>>
112
+ >>> ocean = GPUMultiCurvedWaveSurface(
113
+ ... wave_params=waves,
114
+ ... role=SurfaceRole.OPTICAL,
115
+ ... name="multi_wave_ocean",
116
+ ... material_front=ExponentialAtmosphere(),
117
+ ... material_back=WATER,
118
+ ... )
119
+ """
120
+
121
+ wave_params: list[GerstnerWaveParams]
122
+ role: SurfaceRole
123
+ earth_center: tuple[float, float, float] = (0, 0, -EARTH_RADIUS)
124
+ earth_radius: float = EARTH_RADIUS
125
+ time: float = 0.0
126
+ name: str = "multi_curved_wave"
127
+ material_front: Any = None
128
+ material_back: Any = None
129
+
130
+ # GPU-capable with geometry_id=5 (multi-wave curved surface)
131
+ _gpu_capable: bool = field(default=True, init=False, repr=False)
132
+ _geometry_id: int = field(default=5, init=False, repr=False)
133
+
134
+ # Precomputed values (set in __post_init__)
135
+ _earth_center_arr: npt.NDArray = field(default=None, init=False, repr=False)
136
+ _max_amplitude: float = field(default=0.0, init=False, repr=False)
137
+
138
+ def __post_init__(self) -> None:
139
+ if not self.wave_params:
140
+ raise ValueError("At least one wave component is required")
141
+ if len(self.wave_params) > MAX_WAVES:
142
+ raise ValueError(
143
+ f"Maximum {MAX_WAVES} wave components supported, got {len(self.wave_params)}"
144
+ )
145
+ if self.earth_radius <= 0:
146
+ raise ValueError("Earth radius must be positive")
147
+
148
+ for wave in self.wave_params:
149
+ if wave.amplitude <= 0:
150
+ raise ValueError("All wave amplitudes must be positive")
151
+ if wave.wavelength <= 0:
152
+ raise ValueError("All wavelengths must be positive")
153
+
154
+ # Store earth center as array
155
+ self._earth_center_arr = np.array(self.earth_center, dtype=np.float64)
156
+
157
+ # Compute max amplitude for bounding sphere calculations
158
+ self._max_amplitude = sum(w.amplitude for w in self.wave_params)
159
+
160
+ @property
161
+ def gpu_capable(self) -> bool:
162
+ """This surface supports GPU acceleration with geometry_id=5."""
163
+ return True
164
+
165
+ @property
166
+ def geometry_id(self) -> int:
167
+ """GPU geometry type ID (multi_curved_wave = 5)."""
168
+ return 5
169
+
170
+ @property
171
+ def num_waves(self) -> int:
172
+ """Number of wave components."""
173
+ return len(self.wave_params)
174
+
175
+ @property
176
+ def max_amplitude(self) -> float:
177
+ """Maximum combined wave amplitude (sum of all amplitudes)."""
178
+ return self._max_amplitude
179
+
180
+ def get_gpu_parameters(self) -> tuple:
181
+ """
182
+ Return 64-element parameter tuple for GPU kernel.
183
+
184
+ Parameter layout (geometry_id = 5):
185
+ - p0-2: earth_center_x/y/z
186
+ - p3: earth_radius
187
+ - p4: time
188
+ - p5: num_waves
189
+ - p6-7: reserved
190
+
191
+ For each wave i (i=0..7), starting at offset 8 + i*8:
192
+ - p[offset+0]: amplitude
193
+ - p[offset+1]: wave_number (k)
194
+ - p[offset+2]: dir_x (normalized)
195
+ - p[offset+3]: dir_y (normalized)
196
+ - p[offset+4]: phase
197
+ - p[offset+5]: steepness
198
+ - p[offset+6-7]: reserved
199
+
200
+ Returns
201
+ -------
202
+ tuple of 64 floats
203
+ """
204
+ params = [0.0] * 64
205
+
206
+ # Header parameters
207
+ params[0] = self.earth_center[0]
208
+ params[1] = self.earth_center[1]
209
+ params[2] = self.earth_center[2]
210
+ params[3] = self.earth_radius
211
+ params[4] = self.time
212
+ params[5] = float(len(self.wave_params))
213
+ # params[6-7] reserved
214
+
215
+ # Wave parameters (up to 8 waves)
216
+ for i, wave in enumerate(self.wave_params[:MAX_WAVES]):
217
+ offset = 8 + i * 8
218
+ dir_x, dir_y = wave.direction_normalized
219
+ params[offset + 0] = wave.amplitude
220
+ params[offset + 1] = wave.wave_number
221
+ params[offset + 2] = dir_x
222
+ params[offset + 3] = dir_y
223
+ params[offset + 4] = wave.phase
224
+ params[offset + 5] = wave.steepness
225
+ # params[offset+6-7] reserved
226
+
227
+ return tuple(params)
228
+
229
+ def get_materials(self) -> tuple | None:
230
+ """Return materials for Fresnel calculation."""
231
+ if self.role == SurfaceRole.OPTICAL:
232
+ return (self.material_front, self.material_back)
233
+ return None
234
+
235
+ def _wave_height(self, x: npt.NDArray, y: npt.NDArray) -> npt.NDArray:
236
+ """Compute combined wave height at positions (x, y) in local tangent space."""
237
+ total_height = np.zeros_like(x, dtype=np.float64)
238
+
239
+ for wave in self.wave_params:
240
+ dir_x, dir_y = wave.direction_normalized
241
+ omega = wave.angular_frequency
242
+ k = wave.wave_number
243
+
244
+ # Phase at each position
245
+ dot = dir_x * x + dir_y * y
246
+ theta = k * dot - omega * self.time + wave.phase
247
+
248
+ total_height += wave.amplitude * np.cos(theta)
249
+
250
+ return total_height
251
+
252
+ def signed_distance(
253
+ self,
254
+ positions: npt.NDArray[np.float32],
255
+ ) -> npt.NDArray[np.float32]:
256
+ """
257
+ Compute signed distance from positions to multi-wave curved surface.
258
+
259
+ Parameters
260
+ ----------
261
+ positions : ndarray, shape (N, 3)
262
+ Points to compute distance for
263
+
264
+ Returns
265
+ -------
266
+ ndarray, shape (N,)
267
+ Signed distance (positive outside, negative inside Earth+wave)
268
+ """
269
+ positions = positions.astype(np.float64)
270
+
271
+ # Vector from Earth center to each point
272
+ to_pos = positions - self._earth_center_arr
273
+ dist_from_center = np.linalg.norm(to_pos, axis=1)
274
+
275
+ # Combined wave height using x,y as local tangent coordinates
276
+ x = positions[:, 0]
277
+ y = positions[:, 1]
278
+ wave_height = self._wave_height(x, y)
279
+
280
+ # Surface is at earth_radius + wave_height from center
281
+ surface_radius = self.earth_radius + wave_height
282
+
283
+ # Signed distance: positive outside, negative inside
284
+ return (dist_from_center - surface_radius).astype(np.float32)
285
+
286
+ def intersect(
287
+ self,
288
+ origins: npt.NDArray[np.float32],
289
+ directions: npt.NDArray[np.float32],
290
+ min_distance: float = 1e-6,
291
+ max_iterations: int = 200,
292
+ tolerance: float = 1e-3,
293
+ max_distance: float | None = None,
294
+ ) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.bool_]]:
295
+ """
296
+ Find ray-surface intersections using ray marching.
297
+
298
+ Parameters
299
+ ----------
300
+ origins : ndarray, shape (N, 3)
301
+ Ray origin positions.
302
+ directions : ndarray, shape (N, 3)
303
+ Ray direction unit vectors.
304
+ min_distance : float
305
+ Minimum valid intersection distance.
306
+ max_iterations : int
307
+ Maximum ray marching iterations.
308
+ tolerance : float
309
+ Convergence tolerance in meters.
310
+ max_distance : float, optional
311
+ Maximum search distance.
312
+
313
+ Returns
314
+ -------
315
+ distances : ndarray, shape (N,)
316
+ Distance to intersection (inf if no hit).
317
+ hit_mask : ndarray, shape (N,), dtype=bool
318
+ True for rays that hit the surface.
319
+ """
320
+ origins = origins.astype(np.float64)
321
+ directions = directions.astype(np.float64)
322
+ n_rays = len(origins)
323
+
324
+ distances = np.full(n_rays, np.inf, dtype=np.float64)
325
+ hit_mask = np.zeros(n_rays, dtype=bool)
326
+
327
+ t = np.full(n_rays, min_distance, dtype=np.float64)
328
+ active = np.ones(n_rays, dtype=bool)
329
+
330
+ # Find intersection with outer sphere first
331
+ outer_radius = self.earth_radius + self._max_amplitude
332
+ oc = origins - self._earth_center_arr
333
+ a = np.sum(directions * directions, axis=1)
334
+ b = 2.0 * np.sum(directions * oc, axis=1)
335
+ c_outer = np.sum(oc * oc, axis=1) - outer_radius**2
336
+
337
+ discriminant_outer = b**2 - 4 * a * c_outer
338
+ has_potential_hit = discriminant_outer >= 0
339
+ active[~has_potential_hit] = False
340
+
341
+ sqrt_disc_outer = np.sqrt(np.maximum(discriminant_outer, 0))
342
+ t1_outer = (-b - sqrt_disc_outer) / (2 * a + 1e-20)
343
+ t_start = np.where(t1_outer > min_distance, t1_outer, min_distance)
344
+ t = t_start.copy()
345
+
346
+ prev_signed_dist = np.full(n_rays, np.inf)
347
+ prev_t = t.copy()
348
+ relaxation = 0.5
349
+
350
+ for _ in range(max_iterations):
351
+ if not np.any(active):
352
+ break
353
+
354
+ positions = origins + t[:, np.newaxis] * directions
355
+
356
+ to_pos = positions - self._earth_center_arr
357
+ dist_from_center = np.linalg.norm(to_pos, axis=1)
358
+ radial = to_pos / np.maximum(dist_from_center[:, np.newaxis], 1e-10)
359
+
360
+ cos_angle = np.abs(np.sum(directions * radial, axis=1))
361
+ cos_angle = np.maximum(cos_angle, 0.01)
362
+
363
+ x = positions[:, 0]
364
+ y = positions[:, 1]
365
+ wave_height = self._wave_height(x, y)
366
+
367
+ surface_radius = self.earth_radius + wave_height
368
+ signed_dist = dist_from_center - surface_radius
369
+
370
+ converged = np.abs(signed_dist) < tolerance
371
+ hit_mask[active & converged] = True
372
+ distances[active & converged] = t[active & converged]
373
+ active[converged] = False
374
+
375
+ # Bisection for sign changes
376
+ crossed = (
377
+ active
378
+ & (signed_dist * prev_signed_dist < 0)
379
+ & np.isfinite(prev_signed_dist)
380
+ )
381
+ if np.any(crossed):
382
+ t_low = np.where(prev_signed_dist > 0, prev_t, t)
383
+ t_high = np.where(prev_signed_dist > 0, t, prev_t)
384
+
385
+ for _ in range(15):
386
+ t_mid = (t_low + t_high) / 2
387
+ pos_mid = origins + t_mid[:, np.newaxis] * directions
388
+ to_pos_mid = pos_mid - self._earth_center_arr
389
+ dist_mid = np.linalg.norm(to_pos_mid, axis=1)
390
+ wh_mid = self._wave_height(pos_mid[:, 0], pos_mid[:, 1])
391
+ sd_mid = dist_mid - (self.earth_radius + wh_mid)
392
+
393
+ above = sd_mid > 0
394
+ t_low = np.where(crossed & above, t_mid, t_low)
395
+ t_high = np.where(crossed & ~above, t_mid, t_high)
396
+
397
+ t[crossed] = (t_low[crossed] + t_high[crossed]) / 2
398
+
399
+ positions = origins + t[:, np.newaxis] * directions
400
+ to_pos = positions - self._earth_center_arr
401
+ dist_from_center = np.linalg.norm(to_pos, axis=1)
402
+ wave_height = self._wave_height(positions[:, 0], positions[:, 1])
403
+ signed_dist = dist_from_center - (self.earth_radius + wave_height)
404
+
405
+ converged = np.abs(signed_dist) < tolerance
406
+ hit_mask[active & converged] = True
407
+ distances[active & converged] = t[active & converged]
408
+ active[converged] = False
409
+
410
+ too_far = signed_dist < -self._max_amplitude - 10
411
+ active[too_far] = False
412
+
413
+ if max_distance is not None:
414
+ exceeded_max = t > max_distance
415
+ active[exceeded_max] = False
416
+
417
+ prev_signed_dist = signed_dist.copy()
418
+ prev_t = t.copy()
419
+
420
+ step = signed_dist / cos_angle * relaxation
421
+ step = np.clip(step, -self._max_amplitude * 2, self._max_amplitude * 2)
422
+ t[active] += step[active]
423
+ t = np.maximum(t, 0)
424
+
425
+ return distances.astype(np.float32), hit_mask
426
+
427
+ def normal_at(
428
+ self,
429
+ positions: npt.NDArray[np.float32],
430
+ incoming_directions: npt.NDArray[np.float32] | None = None,
431
+ ) -> npt.NDArray[np.float32]:
432
+ """
433
+ Compute surface normal at given positions.
434
+
435
+ Parameters
436
+ ----------
437
+ positions : ndarray, shape (N, 3)
438
+ Points on the surface.
439
+ incoming_directions : ndarray, shape (N, 3), optional
440
+ Incoming ray directions.
441
+
442
+ Returns
443
+ -------
444
+ normals : ndarray, shape (N, 3)
445
+ Unit normal vectors.
446
+ """
447
+ positions = positions.astype(np.float64)
448
+
449
+ # Get local tangent basis
450
+ to_pos = positions - self._earth_center_arr
451
+ dist = np.linalg.norm(to_pos, axis=1, keepdims=True)
452
+ dist = np.maximum(dist, 1e-10)
453
+ radial = to_pos / dist
454
+
455
+ # Compute tangent vectors (simplified)
456
+ global_x = np.array([1.0, 0.0, 0.0], dtype=np.float64)
457
+ dot_x = np.sum(radial * global_x, axis=1, keepdims=True)
458
+ tangent_x = global_x - dot_x * radial
459
+ tangent_x_norm = np.linalg.norm(tangent_x, axis=1, keepdims=True)
460
+ tangent_x = tangent_x / np.maximum(tangent_x_norm, 1e-10)
461
+
462
+ global_y = np.array([0.0, 1.0, 0.0], dtype=np.float64)
463
+ dot_y = np.sum(radial * global_y, axis=1, keepdims=True)
464
+ tangent_y = global_y - dot_y * radial
465
+ tangent_y_norm = np.linalg.norm(tangent_y, axis=1, keepdims=True)
466
+ tangent_y = tangent_y / np.maximum(tangent_y_norm, 1e-10)
467
+
468
+ # Compute combined wave normal in local coordinates
469
+ x = positions[:, 0]
470
+ y = positions[:, 1]
471
+
472
+ # Sum wave contributions to gradient
473
+ nx_local = np.zeros(len(positions), dtype=np.float64)
474
+ ny_local = np.zeros(len(positions), dtype=np.float64)
475
+ nz_local = np.ones(len(positions), dtype=np.float64)
476
+
477
+ for wave in self.wave_params:
478
+ dir_x, dir_y = wave.direction_normalized
479
+ omega = wave.angular_frequency
480
+ k = wave.wave_number
481
+
482
+ dot_val = dir_x * x + dir_y * y
483
+ theta = k * dot_val - omega * self.time + wave.phase
484
+
485
+ sin_theta = np.sin(theta)
486
+ WA = k * wave.amplitude
487
+
488
+ # Accumulate gradient contributions
489
+ nx_local += dir_x * WA * sin_theta
490
+ ny_local += dir_y * WA * sin_theta
491
+ nz_local -= 0.5 * WA * sin_theta # Simplified steepness effect
492
+
493
+ # Normalize
494
+ norm = np.sqrt(nx_local**2 + ny_local**2 + nz_local**2)
495
+ norm = np.maximum(norm, 1e-10)
496
+ nx_local /= norm
497
+ ny_local /= norm
498
+ nz_local /= norm
499
+
500
+ # Transform to world coordinates
501
+ normals = (
502
+ nx_local[:, np.newaxis] * tangent_x
503
+ + ny_local[:, np.newaxis] * tangent_y
504
+ + nz_local[:, np.newaxis] * radial
505
+ )
506
+
507
+ if incoming_directions is not None:
508
+ dot_products = np.sum(normals * incoming_directions, axis=1)
509
+ flip_mask = dot_products > 0
510
+ normals[flip_mask] *= -1
511
+
512
+ return normals.astype(np.float32)
513
+
514
+ def set_time(self, time: float) -> None:
515
+ """Update the wave animation time."""
516
+ self.time = time
517
+
518
+
519
+ # Register class with registry
520
+ register_surface_type("gpu_multi_curved_wave", "gpu", 5, GPUMultiCurvedWaveSurface)