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,327 @@
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
+ Intersection Handler
36
+
37
+ GPU memory management for ray-surface intersection kernels.
38
+ This module provides high-level interfaces that handle cuda.to_device(),
39
+ copy_to_host(), and kernel launching for the intersection kernels.
40
+ """
41
+
42
+ import numpy as np
43
+ from numpy.typing import NDArray
44
+
45
+ from ..kernels.intersection import (
46
+ kernel_plane_intersect,
47
+ kernel_sphere_intersect,
48
+ )
49
+
50
+ # GPU support is optional
51
+ try:
52
+ from numba import cuda
53
+
54
+ _HAS_CUDA = cuda.is_available()
55
+ except ImportError:
56
+ _HAS_CUDA = False
57
+
58
+ class _FakeCuda:
59
+ @staticmethod
60
+ def is_available():
61
+ return False
62
+
63
+ cuda = _FakeCuda() # type: ignore[assignment]
64
+
65
+
66
+ def intersect_plane_gpu(
67
+ origins: NDArray[np.float32],
68
+ directions: NDArray[np.float32],
69
+ plane_normal: NDArray[np.float32],
70
+ plane_point: NDArray[np.float32],
71
+ threads_per_block: int = 256,
72
+ ) -> tuple[NDArray[np.float32], NDArray[np.bool_], NDArray[np.float32]]:
73
+ """
74
+ GPU-accelerated ray-plane intersection.
75
+
76
+ Parameters
77
+ ----------
78
+ origins : ndarray, shape (N, 3)
79
+ Ray origins
80
+ directions : ndarray, shape (N, 3)
81
+ Ray directions (should be normalized)
82
+ plane_normal : ndarray, shape (3,)
83
+ Plane normal vector (should be normalized)
84
+ plane_point : ndarray, shape (3,)
85
+ A point on the plane
86
+ threads_per_block : int, optional
87
+ CUDA threads per block (default 256)
88
+
89
+ Returns
90
+ -------
91
+ distances : ndarray, shape (N,)
92
+ Distance to intersection for each ray (inf if no hit)
93
+ hit_mask : ndarray, shape (N,)
94
+ Boolean mask indicating which rays hit the plane
95
+ normals : ndarray, shape (N, 3)
96
+ Surface normal at intersection (pointing toward ray origin)
97
+ """
98
+ num_rays = len(origins)
99
+
100
+ # Compute plane equation: n.p + d = 0
101
+ # d = -n.point
102
+ plane_d = -np.dot(plane_normal, plane_point)
103
+
104
+ if _HAS_CUDA:
105
+ # Allocate output arrays
106
+ distances = np.full(num_rays, np.inf, dtype=np.float32)
107
+ hit_mask = np.zeros(num_rays, dtype=np.bool_)
108
+ normals = np.zeros((num_rays, 3), dtype=np.float32)
109
+
110
+ # Transfer to GPU
111
+ d_origins = cuda.to_device(origins.astype(np.float32))
112
+ d_directions = cuda.to_device(directions.astype(np.float32))
113
+ d_distances = cuda.to_device(distances)
114
+ d_hit_mask = cuda.to_device(hit_mask)
115
+ d_normals = cuda.to_device(normals)
116
+
117
+ # Launch kernel
118
+ blocks = (num_rays + threads_per_block - 1) // threads_per_block
119
+ kernel_plane_intersect[blocks, threads_per_block](
120
+ d_origins,
121
+ d_directions,
122
+ d_distances,
123
+ d_hit_mask,
124
+ d_normals,
125
+ np.float32(plane_normal[0]),
126
+ np.float32(plane_normal[1]),
127
+ np.float32(plane_normal[2]),
128
+ np.float32(plane_d),
129
+ )
130
+
131
+ cuda.synchronize()
132
+ distances = d_distances.copy_to_host()
133
+ hit_mask = d_hit_mask.copy_to_host()
134
+ normals = d_normals.copy_to_host()
135
+ else:
136
+ distances, hit_mask, normals = _intersect_plane_cpu(
137
+ origins, directions, plane_normal, plane_point
138
+ )
139
+
140
+ return distances, hit_mask, normals
141
+
142
+
143
+ def intersect_sphere_gpu(
144
+ origins: NDArray[np.float32],
145
+ directions: NDArray[np.float32],
146
+ center: NDArray[np.float32],
147
+ radius: float,
148
+ threads_per_block: int = 256,
149
+ ) -> tuple[NDArray[np.float32], NDArray[np.bool_], NDArray[np.float32]]:
150
+ """
151
+ GPU-accelerated ray-sphere intersection.
152
+
153
+ Parameters
154
+ ----------
155
+ origins : ndarray, shape (N, 3)
156
+ Ray origins
157
+ directions : ndarray, shape (N, 3)
158
+ Ray directions (should be normalized)
159
+ center : ndarray, shape (3,)
160
+ Sphere center position
161
+ radius : float
162
+ Sphere radius
163
+ threads_per_block : int, optional
164
+ CUDA threads per block (default 256)
165
+
166
+ Returns
167
+ -------
168
+ distances : ndarray, shape (N,)
169
+ Distance to intersection for each ray (inf if no hit)
170
+ hit_mask : ndarray, shape (N,)
171
+ Boolean mask indicating which rays hit the sphere
172
+ normals : ndarray, shape (N, 3)
173
+ Surface normal at intersection (pointing outward)
174
+ """
175
+ num_rays = len(origins)
176
+
177
+ if _HAS_CUDA:
178
+ # Allocate output arrays
179
+ distances = np.full(num_rays, np.inf, dtype=np.float32)
180
+ hit_mask = np.zeros(num_rays, dtype=np.bool_)
181
+ normals = np.zeros((num_rays, 3), dtype=np.float32)
182
+
183
+ # Transfer to GPU
184
+ d_origins = cuda.to_device(origins.astype(np.float32))
185
+ d_directions = cuda.to_device(directions.astype(np.float32))
186
+ d_distances = cuda.to_device(distances)
187
+ d_hit_mask = cuda.to_device(hit_mask)
188
+ d_normals = cuda.to_device(normals)
189
+
190
+ # Launch kernel
191
+ blocks = (num_rays + threads_per_block - 1) // threads_per_block
192
+ kernel_sphere_intersect[blocks, threads_per_block](
193
+ d_origins,
194
+ d_directions,
195
+ d_distances,
196
+ d_hit_mask,
197
+ d_normals,
198
+ np.float32(center[0]),
199
+ np.float32(center[1]),
200
+ np.float32(center[2]),
201
+ np.float32(radius),
202
+ )
203
+
204
+ cuda.synchronize()
205
+ distances = d_distances.copy_to_host()
206
+ hit_mask = d_hit_mask.copy_to_host()
207
+ normals = d_normals.copy_to_host()
208
+ else:
209
+ distances, hit_mask, normals = _intersect_sphere_cpu(
210
+ origins, directions, center, radius
211
+ )
212
+
213
+ return distances, hit_mask, normals
214
+
215
+
216
+ # =============================================================================
217
+ # CPU Fallback Implementations
218
+ # =============================================================================
219
+
220
+
221
+ def _intersect_plane_cpu(
222
+ origins: NDArray[np.float32],
223
+ directions: NDArray[np.float32],
224
+ plane_normal: NDArray[np.float32],
225
+ plane_point: NDArray[np.float32],
226
+ ) -> tuple[NDArray[np.float32], NDArray[np.bool_], NDArray[np.float32]]:
227
+ """CPU implementation of ray-plane intersection."""
228
+ num_rays = len(origins)
229
+
230
+ # Normalize directions
231
+ d_lens = np.linalg.norm(directions, axis=1, keepdims=True)
232
+ d_lens = np.where(d_lens < 1e-10, 1.0, d_lens)
233
+ directions = directions / d_lens
234
+
235
+ # Compute plane equation: d = -n.point
236
+ plane_d = -np.dot(plane_normal, plane_point)
237
+
238
+ # Compute denominator: n . direction
239
+ denom = np.dot(directions, plane_normal)
240
+
241
+ # Compute numerator: -(n . origin + d)
242
+ numer = -(np.dot(origins, plane_normal) + plane_d)
243
+
244
+ # Initialize outputs
245
+ distances = np.full(num_rays, np.inf, dtype=np.float32)
246
+ hit_mask = np.zeros(num_rays, dtype=np.bool_)
247
+ normals = np.zeros((num_rays, 3), dtype=np.float32)
248
+
249
+ # Find valid intersections
250
+ valid = np.abs(denom) > 1e-10
251
+ t = np.where(valid, numer / np.where(valid, denom, 1.0), np.inf)
252
+ hit = valid & (t > 0)
253
+
254
+ distances[hit] = t[hit]
255
+ hit_mask = hit
256
+
257
+ # Set normals (flip if ray hits from behind)
258
+ for i in range(num_rays):
259
+ if hit[i]:
260
+ if denom[i] > 0:
261
+ normals[i] = -plane_normal
262
+ else:
263
+ normals[i] = plane_normal
264
+
265
+ return distances.astype(np.float32), hit_mask, normals.astype(np.float32)
266
+
267
+
268
+ def _intersect_sphere_cpu(
269
+ origins: NDArray[np.float32],
270
+ directions: NDArray[np.float32],
271
+ center: NDArray[np.float32],
272
+ radius: float,
273
+ ) -> tuple[NDArray[np.float32], NDArray[np.bool_], NDArray[np.float32]]:
274
+ """CPU implementation of ray-sphere intersection."""
275
+ num_rays = len(origins)
276
+
277
+ # Normalize directions
278
+ d_lens = np.linalg.norm(directions, axis=1, keepdims=True)
279
+ d_lens = np.where(d_lens < 1e-10, 1.0, d_lens)
280
+ directions = directions / d_lens
281
+
282
+ # Vector from origin to sphere center
283
+ oc = origins - center
284
+
285
+ # Quadratic coefficients
286
+ # a = 1 (normalized direction)
287
+ b = 2.0 * np.sum(directions * oc, axis=1)
288
+ c = np.sum(oc * oc, axis=1) - radius * radius
289
+
290
+ # Discriminant
291
+ discriminant = b * b - 4.0 * c
292
+
293
+ # Initialize outputs
294
+ distances = np.full(num_rays, np.inf, dtype=np.float32)
295
+ hit_mask = np.zeros(num_rays, dtype=np.bool_)
296
+ normals = np.zeros((num_rays, 3), dtype=np.float32)
297
+
298
+ # Find rays with valid intersections
299
+ valid = discriminant >= 0
300
+ sqrt_disc = np.sqrt(np.maximum(discriminant, 0))
301
+
302
+ t1 = np.where(valid, (-b - sqrt_disc) / 2.0, np.inf)
303
+ t2 = np.where(valid, (-b + sqrt_disc) / 2.0, np.inf)
304
+
305
+ # Use nearest positive t
306
+ t = np.where(t1 > 1e-6, t1, np.where(t2 > 1e-6, t2, np.inf))
307
+ hit = t < np.inf
308
+
309
+ distances[hit] = t[hit]
310
+ hit_mask = hit
311
+
312
+ # Compute normals at intersection points
313
+ for i in range(num_rays):
314
+ if hit[i]:
315
+ p = origins[i] + t[i] * directions[i]
316
+ n = p - center
317
+ n_len = np.linalg.norm(n)
318
+ if n_len > 1e-10:
319
+ normals[i] = n / n_len
320
+
321
+ return distances.astype(np.float32), hit_mask, normals.astype(np.float32)
322
+
323
+
324
+ __all__ = [
325
+ "intersect_plane_gpu",
326
+ "intersect_sphere_gpu",
327
+ ]