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,3199 @@
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
+ """Config editor panel - create and edit simulation configurations in real-time.
35
+
36
+ Provides a GUI for editing surfaces, sources, and simulation parameters
37
+ with live preview in the 3D viewport.
38
+ """
39
+
40
+ from pathlib import Path
41
+ from typing import TYPE_CHECKING, Any, Callable
42
+
43
+ import numpy as np
44
+
45
+ import dearpygui.dearpygui as dpg
46
+
47
+ if TYPE_CHECKING:
48
+ from ..core.scene import Scene
49
+ from .results import ResultsPanel
50
+
51
+
52
+ class ConfigEditorPanel:
53
+ """Configuration editor with real-time preview."""
54
+
55
+ # Available surface roles
56
+ SURFACE_ROLES = ["optical", "absorber"]
57
+
58
+ # Class-level caches for discovered options (populated once)
59
+ _discovered_materials: list[str] | None = None
60
+ _discovered_sources: list[str] | None = None
61
+ _discovered_surfaces: list[str] | None = None
62
+ _discovered_atmospheres: list[str] | None = None
63
+
64
+ @classmethod
65
+ def get_available_materials(cls) -> list[str]:
66
+ """Dynamically discover available materials from lsurf.materials."""
67
+ if cls._discovered_materials is not None:
68
+ return cls._discovered_materials
69
+
70
+ materials = []
71
+ try:
72
+ import lsurf.materials as mat
73
+
74
+ # Add predefined material instances (like VACUUM, AIR_STP, etc.)
75
+ for name in dir(mat):
76
+ obj = getattr(mat, name)
77
+ if hasattr(obj, "__class__") and "Material" in type(obj).__name__:
78
+ if not name.startswith("_"):
79
+ # Convert to lowercase, handle underscores
80
+ clean_name = name.lower()
81
+ materials.append(clean_name)
82
+
83
+ # Add atmosphere models (like STANDARD_ATMOSPHERE, ExponentialAtmosphere)
84
+ for name in dir(mat):
85
+ if "Atmosphere" in name and not name.startswith("_"):
86
+ obj = getattr(mat, name)
87
+ if hasattr(obj, "__class__"):
88
+ clean_name = cls._camel_to_snake(name)
89
+ materials.append(clean_name)
90
+
91
+ except ImportError:
92
+ # Fallback if lsurf not available
93
+ materials = ["vacuum", "air", "water", "glass"]
94
+
95
+ # Add custom option for user-defined materials
96
+ if "custom" not in materials:
97
+ materials.append("custom")
98
+
99
+ cls._discovered_materials = sorted(set(materials))
100
+ return cls._discovered_materials
101
+
102
+ @classmethod
103
+ def get_available_sources(cls) -> list[str]:
104
+ """Dynamically discover available source types from lsurf.sources."""
105
+ if cls._discovered_sources is not None:
106
+ return cls._discovered_sources
107
+
108
+ sources = []
109
+ try:
110
+ import inspect
111
+ import lsurf.sources as src
112
+
113
+ for name in dir(src):
114
+ obj = getattr(src, name)
115
+ # Check if it's a class with generate method (source class)
116
+ if (
117
+ inspect.isclass(obj)
118
+ and hasattr(obj, "generate")
119
+ and name
120
+ not in ("RaySource", "CustomRaySource", "ParallelBeamFromPositions")
121
+ and not name.startswith("_")
122
+ ):
123
+ # Convert CamelCase to snake_case and simplify
124
+ snake_name = cls._camel_to_snake(name)
125
+ # Remove common suffixes for cleaner names
126
+ snake_name = snake_name.replace("_source", "").replace("_beam", "")
127
+ sources.append(snake_name)
128
+
129
+ except ImportError:
130
+ sources = ["point", "collimated", "diverging", "gaussian"]
131
+
132
+ cls._discovered_sources = sources
133
+ return cls._discovered_sources
134
+
135
+ @classmethod
136
+ def get_available_surfaces(cls) -> list[str]:
137
+ """Dynamically discover available surface types from lsurf.surfaces."""
138
+ if cls._discovered_surfaces is not None:
139
+ return cls._discovered_surfaces
140
+
141
+ surfaces = []
142
+ try:
143
+ import inspect
144
+ import lsurf.surfaces as surf
145
+
146
+ for name in dir(surf):
147
+ obj = getattr(surf, name)
148
+ # Check if it's a concrete surface class
149
+ if (
150
+ inspect.isclass(obj)
151
+ and hasattr(obj, "intersect")
152
+ and name not in ("Surface", "GPUSurface")
153
+ and not name.startswith("_")
154
+ ):
155
+ # Convert CamelCase to snake_case, remove 'Surface' suffix
156
+ clean_name = name.replace("Surface", "")
157
+ snake_name = cls._camel_to_snake(clean_name)
158
+ if snake_name:
159
+ surfaces.append(snake_name)
160
+
161
+ except ImportError:
162
+ surfaces = ["plane", "sphere", "bounded_plane"]
163
+
164
+ cls._discovered_surfaces = surfaces
165
+ return cls._discovered_surfaces
166
+
167
+ @classmethod
168
+ def get_available_atmospheres(cls) -> list[str]:
169
+ """Dynamically discover available atmosphere models from lsurf.materials."""
170
+ if cls._discovered_atmospheres is not None:
171
+ return cls._discovered_atmospheres
172
+
173
+ atmospheres = []
174
+ try:
175
+ import inspect
176
+ import lsurf.materials as mat
177
+
178
+ for name in dir(mat):
179
+ obj = getattr(mat, name)
180
+ if "Atmosphere" in name and not name.startswith("_"):
181
+ if inspect.isclass(obj):
182
+ snake_name = cls._camel_to_snake(name)
183
+ atmospheres.append(snake_name)
184
+ elif hasattr(obj, "__class__"):
185
+ # Instance like STANDARD_ATMOSPHERE
186
+ atmospheres.append(name.lower())
187
+
188
+ except ImportError:
189
+ atmospheres = ["exponential_atmosphere", "standard_atmosphere"]
190
+
191
+ cls._discovered_atmospheres = sorted(set(atmospheres))
192
+ return cls._discovered_atmospheres
193
+
194
+ # Class-level cache for material types
195
+ _discovered_material_types: list[str] | None = None
196
+
197
+ @classmethod
198
+ def get_available_material_types(cls) -> list[str]:
199
+ """Dynamically discover available material types from lsurf.materials."""
200
+ if cls._discovered_material_types is not None:
201
+ return cls._discovered_material_types
202
+
203
+ material_types = []
204
+ try:
205
+ import inspect
206
+ import lsurf.materials as mat
207
+
208
+ # Known material classes to look for
209
+ for name in dir(mat):
210
+ obj = getattr(mat, name)
211
+ if inspect.isclass(obj) and not name.startswith("_"):
212
+ # Check if it has refractive_index or is a material-like class
213
+ if (
214
+ hasattr(obj, "__init__")
215
+ and name not in ("MaterialField",)
216
+ and (
217
+ "Material" in name
218
+ or "Atmosphere" in name
219
+ or "Model" in name
220
+ )
221
+ ):
222
+ snake_name = cls._camel_to_snake(name)
223
+ material_types.append(snake_name)
224
+
225
+ except ImportError:
226
+ material_types = ["homogeneous_material", "exponential_atmosphere"]
227
+
228
+ cls._discovered_material_types = sorted(set(material_types))
229
+ return cls._discovered_material_types
230
+
231
+ @classmethod
232
+ def get_material_defaults(cls, material_type: str) -> dict[str, Any]:
233
+ """Get default parameters for a material type by introspecting its constructor."""
234
+ try:
235
+ import inspect
236
+ import lsurf.materials as mat
237
+
238
+ # Convert snake_case to CamelCase class name
239
+ class_name = cls._snake_to_camel(material_type)
240
+
241
+ if hasattr(mat, class_name):
242
+ material_class = getattr(mat, class_name)
243
+ sig = inspect.signature(material_class.__init__)
244
+ defaults = {}
245
+
246
+ for param_name, param in sig.parameters.items():
247
+ if param_name in ("self", "kernel", "propagator"):
248
+ continue
249
+ if param.default != inspect.Parameter.empty:
250
+ val = param.default
251
+ # Convert tuples to lists for UI
252
+ if isinstance(val, tuple):
253
+ val = list(val)
254
+ defaults[param_name] = val
255
+ else:
256
+ # Provide sensible defaults based on parameter name
257
+ if param_name == "name":
258
+ defaults[param_name] = material_type
259
+ elif param_name == "refractive_index":
260
+ defaults[param_name] = 1.0
261
+ elif "n_sea_level" in param_name:
262
+ defaults[param_name] = 1.000293
263
+ elif "scale_height" in param_name:
264
+ defaults[param_name] = 8500.0
265
+ elif "earth_radius" in param_name:
266
+ defaults[param_name] = 6371000.0
267
+ elif "earth_center" in param_name:
268
+ defaults[param_name] = [0.0, 0.0, 0.0]
269
+ elif "center" in param_name:
270
+ defaults[param_name] = [0.0, 0.0, 0.0]
271
+ elif "coef" in param_name:
272
+ defaults[param_name] = 0.0
273
+ elif "altitude_range" in param_name:
274
+ defaults[param_name] = [0.0, 200000.0]
275
+
276
+ return defaults
277
+
278
+ except Exception as e:
279
+ print(f"Error getting material defaults for {material_type}: {e}")
280
+
281
+ # Fallback defaults
282
+ fallbacks = {
283
+ "homogeneous_material": {
284
+ "name": "custom",
285
+ "refractive_index": 1.5,
286
+ "absorption_coef": 0.0,
287
+ },
288
+ "exponential_atmosphere": {
289
+ "name": "atmosphere",
290
+ "n_sea_level": 1.000293,
291
+ "scale_height": 8500.0,
292
+ "earth_radius": 6371000.0,
293
+ "earth_center": [0.0, 0.0, 0.0],
294
+ },
295
+ "duct_atmosphere": {
296
+ "name": "duct",
297
+ "n_sea_level": 1.000293,
298
+ "scale_height": 8500.0,
299
+ "earth_radius": 6371000.0,
300
+ "duct_center": 0.0,
301
+ "duct_width": 100.0,
302
+ "duct_intensity": 0.0,
303
+ },
304
+ }
305
+ return fallbacks.get(
306
+ material_type, {"name": material_type, "refractive_index": 1.0}
307
+ )
308
+
309
+ @classmethod
310
+ def get_surface_defaults(cls, surface_type: str) -> dict[str, Any]:
311
+ """Get default parameters for a surface type by introspecting its constructor."""
312
+ try:
313
+ import inspect
314
+ import lsurf.surfaces as surf
315
+
316
+ # Handle gpu_ prefix specially - strip it and prepend GPU to class name
317
+ if surface_type.startswith("gpu_"):
318
+ base_type = surface_type[4:] # Remove "gpu_" prefix
319
+ class_name = "GPU" + cls._snake_to_camel(base_type) + "Surface"
320
+ gpu_class_name = class_name
321
+ non_gpu_class_name = cls._snake_to_camel(base_type) + "Surface"
322
+ else:
323
+ class_name = cls._snake_to_camel(surface_type) + "Surface"
324
+ gpu_class_name = "GPU" + class_name
325
+ non_gpu_class_name = class_name
326
+
327
+ # Check if this is a wave surface (needs 2D direction, prefer GPU version)
328
+ is_wave_surface = "wave" in surface_type.lower()
329
+
330
+ # For wave surfaces, prefer GPU version (has individual params)
331
+ if is_wave_surface:
332
+ candidates = [gpu_class_name, non_gpu_class_name]
333
+ else:
334
+ candidates = [class_name, gpu_class_name]
335
+
336
+ surface_class = None
337
+ for candidate in candidates:
338
+ if hasattr(surf, candidate):
339
+ surface_class = getattr(surf, candidate)
340
+ break
341
+
342
+ if surface_class is not None:
343
+ sig = inspect.signature(surface_class.__init__)
344
+ defaults = {}
345
+
346
+ # Check if class uses wave_params (composite parameter)
347
+ uses_wave_params = "wave_params" in sig.parameters
348
+
349
+ # If using wave_params, add individual wave parameters as defaults
350
+ if uses_wave_params:
351
+ defaults["amplitude"] = 1.0
352
+ defaults["wavelength"] = 10.0
353
+ defaults["direction"] = [1.0, 0.0]
354
+ defaults["phase"] = 0.0
355
+ defaults["steepness"] = 0.0
356
+
357
+ for param_name, param in sig.parameters.items():
358
+ if param_name in ("self", "wave_params"):
359
+ continue
360
+ if param.default != inspect.Parameter.empty:
361
+ defaults[param_name] = param.default
362
+ else:
363
+ # Provide sensible defaults for common parameter types
364
+ if (
365
+ "point" in param_name
366
+ or "center" in param_name
367
+ or "position" in param_name
368
+ ):
369
+ if "earth" in param_name:
370
+ defaults[param_name] = [0.0, 0.0, -6.371e6]
371
+ else:
372
+ defaults[param_name] = [0.0, 0.0, 0.0]
373
+ elif "normal" in param_name:
374
+ defaults[param_name] = [0.0, 0.0, 1.0]
375
+ elif param_name == "direction":
376
+ # Wave surfaces use 2D direction, others use 3D
377
+ if is_wave_surface:
378
+ defaults[param_name] = [1.0, 0.0]
379
+ else:
380
+ defaults[param_name] = [0.0, 0.0, 1.0]
381
+ elif "radius" in param_name:
382
+ if "earth" in param_name:
383
+ defaults[param_name] = 6.371e6
384
+ else:
385
+ defaults[param_name] = 5.0
386
+ elif "width" in param_name or "height" in param_name:
387
+ defaults[param_name] = 10.0
388
+ elif "amplitude" in param_name:
389
+ defaults[param_name] = 1.0
390
+ elif "wavelength" in param_name:
391
+ defaults[param_name] = 10.0
392
+ elif param_name == "reference_z":
393
+ defaults[param_name] = 0.0
394
+
395
+ return defaults
396
+
397
+ except Exception as e:
398
+ print(f"Error getting defaults for {surface_type}: {e}")
399
+
400
+ # Fallback defaults for common types
401
+ fallbacks = {
402
+ "plane": {"point": [0, 0, 0], "normal": [0, 0, 1]},
403
+ "bounded_plane": {
404
+ "point": [0, 0, 0],
405
+ "normal": [0, 0, 1],
406
+ "width": 10,
407
+ "height": 10,
408
+ },
409
+ "sphere": {"center": [0, 0, 0], "radius": 5},
410
+ "annular_plane": {
411
+ "center": [0, 0, 0],
412
+ "normal": [0, 0, 1],
413
+ "inner_radius": 1,
414
+ "outer_radius": 5,
415
+ },
416
+ "gerstner_wave": {
417
+ "amplitude": 1.0,
418
+ "wavelength": 10.0,
419
+ "direction": [1.0, 0.0],
420
+ "reference_z": 0.0,
421
+ "phase": 0.0,
422
+ "steepness": 0.0,
423
+ },
424
+ "curved_wave": {
425
+ "amplitude": 1.0,
426
+ "wavelength": 10.0,
427
+ "direction": [1.0, 0.0],
428
+ "earth_center": [0, 0, -6.371e6],
429
+ "earth_radius": 6.371e6,
430
+ },
431
+ "gpu_gerstner_wave": {
432
+ "amplitude": 1.0,
433
+ "wavelength": 10.0,
434
+ "direction": [1.0, 0.0],
435
+ "reference_z": 0.0,
436
+ "phase": 0.0,
437
+ },
438
+ "gpu_curved_wave": {
439
+ "amplitude": 1.0,
440
+ "wavelength": 10.0,
441
+ "direction": [1.0, 0.0],
442
+ "earth_center": [0, 0, -6.371e6],
443
+ "earth_radius": 6.371e6,
444
+ },
445
+ }
446
+ return fallbacks.get(surface_type, {})
447
+
448
+ @classmethod
449
+ def get_source_defaults(cls, source_type: str) -> dict[str, Any]:
450
+ """Get default parameters for a source type by introspecting its constructor."""
451
+ try:
452
+ import inspect
453
+ import lsurf.sources as src
454
+
455
+ # Convert snake_case to CamelCase class name
456
+ # Handle special cases for naming
457
+ if source_type == "point":
458
+ class_name = "PointSource"
459
+ elif source_type == "collimated":
460
+ class_name = "CollimatedBeam"
461
+ elif source_type == "diverging":
462
+ class_name = "DivergingBeam"
463
+ elif source_type == "uniform_diverging":
464
+ class_name = "UniformDivergingBeam"
465
+ elif source_type == "gaussian":
466
+ class_name = "GaussianBeam"
467
+ else:
468
+ # Try to construct class name
469
+ class_name = cls._snake_to_camel(source_type)
470
+ if not class_name.endswith(("Source", "Beam")):
471
+ class_name += "Source"
472
+
473
+ if hasattr(src, class_name):
474
+ source_class = getattr(src, class_name)
475
+ sig = inspect.signature(source_class.__init__)
476
+ defaults = {}
477
+
478
+ for param_name, param in sig.parameters.items():
479
+ if param_name == "self":
480
+ continue
481
+ if param.default != inspect.Parameter.empty:
482
+ defaults[param_name] = param.default
483
+ else:
484
+ # Provide sensible defaults for common parameter types
485
+ if param_name in (
486
+ "position",
487
+ "origin",
488
+ "center",
489
+ "waist_position",
490
+ ):
491
+ defaults[param_name] = [0.0, 0.0, 10.0]
492
+ elif param_name in ("direction", "mean_direction"):
493
+ defaults[param_name] = [0.0, 0.0, -1.0]
494
+ elif param_name == "num_rays":
495
+ defaults[param_name] = 10000
496
+ elif param_name == "wavelength":
497
+ defaults[param_name] = 532e-9
498
+ elif param_name == "power":
499
+ defaults[param_name] = 1.0
500
+ elif "radius" in param_name:
501
+ defaults[param_name] = 1.0
502
+ elif "angle" in param_name:
503
+ defaults[param_name] = 10.0 # degrees
504
+
505
+ return defaults
506
+
507
+ except Exception:
508
+ pass
509
+
510
+ # Fallback defaults
511
+ fallbacks = {
512
+ "point": {"position": [0, 0, 10]},
513
+ "collimated": {"direction": [0, 0, -1], "radius": 1.0},
514
+ "diverging": {"direction": [0, 0, -1], "divergence_angle": 10.0},
515
+ "uniform_diverging": {"direction": [0, 0, -1], "divergence_angle": 10.0},
516
+ "gaussian": {"direction": [0, 0, -1], "waist_radius": 0.001},
517
+ }
518
+ return fallbacks.get(source_type, {})
519
+
520
+ @staticmethod
521
+ def _snake_to_camel(name: str) -> str:
522
+ """Convert snake_case to CamelCase."""
523
+ return "".join(word.capitalize() for word in name.split("_"))
524
+
525
+ @staticmethod
526
+ def _camel_to_snake(name: str) -> str:
527
+ """Convert CamelCase to snake_case."""
528
+ import re
529
+
530
+ # Insert underscore before uppercase letters and convert to lowercase
531
+ s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
532
+ return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
533
+
534
+ def __init__(
535
+ self,
536
+ scene: "Scene",
537
+ on_config_change: Callable[[], None] | None = None,
538
+ on_simulate: Callable[[], None] | None = None,
539
+ results_panel: "ResultsPanel | None" = None,
540
+ visualization_panel: Any | None = None,
541
+ ) -> None:
542
+ self.scene = scene
543
+ self.on_config_change = on_config_change
544
+ self.on_simulate = on_simulate
545
+ self.results_panel = results_panel
546
+ self.visualization_panel = visualization_panel
547
+ self._window_tag: int | None = None
548
+ self._last_export_debug: str = ""
549
+
550
+ # Current configuration state
551
+ # Surfaces now store medium config inline (no separate media dict needed)
552
+ self._surfaces: list[dict[str, Any]] = []
553
+ self._detectors: list[dict[str, Any]] = []
554
+ self._source_config: dict[str, Any] = {
555
+ "type": "point",
556
+ "position": [0.0, 0.0, 10.0],
557
+ "num_rays": 10000,
558
+ "wavelength": 532e-9,
559
+ "power": 1.0,
560
+ }
561
+ self._sim_config: dict[str, Any] = {
562
+ "max_bounces": 10,
563
+ "step_size": 100.0,
564
+ "use_gpu": True,
565
+ "bounding_center": [0.0, 0.0, 0.0],
566
+ "bounding_radius": 100.0,
567
+ "output_directory": "./results",
568
+ "output_prefix": "simulation",
569
+ "output_format": "hdf5",
570
+ "track_refracted_rays": True,
571
+ }
572
+
573
+ # Track UI element tags for updates
574
+ self._surface_container: int | None = None
575
+ self._detector_container: int | None = None
576
+
577
+ def create(self, parent: int | str) -> int:
578
+ """Create the config editor panel.
579
+
580
+ Args:
581
+ parent: Parent container tag
582
+
583
+ Returns:
584
+ The window tag
585
+ """
586
+ with dpg.child_window(
587
+ parent=parent,
588
+ tag="config_editor_window",
589
+ width=-1,
590
+ height=-1,
591
+ ) as self._window_tag:
592
+ # Create green theme for simulate button
593
+ with dpg.theme() as simulate_theme:
594
+ with dpg.theme_component(dpg.mvButton):
595
+ dpg.add_theme_color(dpg.mvThemeCol_Button, (40, 120, 40, 255))
596
+ dpg.add_theme_color(
597
+ dpg.mvThemeCol_ButtonHovered, (50, 150, 50, 255)
598
+ )
599
+ dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, (60, 180, 60, 255))
600
+
601
+ # Toolbar
602
+ with dpg.group(horizontal=True):
603
+ dpg.add_button(
604
+ label="New Config",
605
+ callback=self._on_new_config,
606
+ )
607
+ dpg.add_button(
608
+ label="Load Config",
609
+ callback=self._on_load_config,
610
+ )
611
+ dpg.add_button(
612
+ label="Apply",
613
+ callback=self._on_apply,
614
+ )
615
+ dpg.add_button(
616
+ label="Reload Scene",
617
+ callback=self._on_reload_scene,
618
+ )
619
+ dpg.add_button(
620
+ label="Export YAML",
621
+ callback=self._on_export_yaml,
622
+ )
623
+ dpg.add_spacer(width=10)
624
+ simulate_btn = dpg.add_button(
625
+ label="Simulate",
626
+ callback=self._on_simulate,
627
+ tag="simulate_btn",
628
+ )
629
+ dpg.bind_item_theme(simulate_btn, simulate_theme)
630
+
631
+ dpg.add_spacer(width=10)
632
+ dpg.add_text("", tag="simulate_status", color=(128, 128, 128))
633
+
634
+ dpg.add_separator()
635
+
636
+ # Second row of toolbar - Check Geometry button
637
+ with dpg.group(horizontal=True):
638
+ dpg.add_button(
639
+ label="Check Geometry",
640
+ callback=self._on_check_geometry,
641
+ )
642
+
643
+ dpg.add_separator()
644
+
645
+ # Tabbed sections - ordered logically:
646
+ # 1. Surfaces (with inline media configuration)
647
+ # 2. Detectors
648
+ # 3. Source
649
+ # 4. Simulation
650
+ with dpg.tab_bar():
651
+ # Surfaces tab - define surface geometry and assign media
652
+ with dpg.tab(label="Surfaces"):
653
+ with dpg.group():
654
+ dpg.add_text(
655
+ "Define surface geometry and optical properties",
656
+ color=(150, 150, 150),
657
+ )
658
+ dpg.add_text(
659
+ "Configure front/back media directly on each surface",
660
+ color=(120, 120, 120),
661
+ )
662
+ dpg.add_spacer(height=5)
663
+ dpg.add_button(
664
+ label="+ Add Surface",
665
+ callback=self._on_add_surface,
666
+ width=-1,
667
+ )
668
+ dpg.add_separator()
669
+ with dpg.child_window(
670
+ height=-1, # Fill available space, scrollable
671
+ tag="surfaces_container",
672
+ ) as self._surface_container:
673
+ self._rebuild_surfaces_ui()
674
+
675
+ # Detectors tab
676
+ with dpg.tab(label="Detectors"):
677
+ with dpg.group():
678
+ dpg.add_text(
679
+ "Define detector surfaces to capture rays",
680
+ color=(150, 150, 150),
681
+ )
682
+ dpg.add_spacer(height=5)
683
+ dpg.add_button(
684
+ label="+ Add Detector",
685
+ callback=self._on_add_detector,
686
+ width=-1,
687
+ )
688
+ dpg.add_separator()
689
+ with dpg.child_window(
690
+ height=-1, # Fill available space, scrollable
691
+ tag="detectors_container",
692
+ ) as self._detector_container:
693
+ self._rebuild_detectors_ui()
694
+
695
+ # Source tab
696
+ with dpg.tab(label="Source"):
697
+ with dpg.child_window(height=-1, tag="source_container"):
698
+ self._create_source_ui()
699
+
700
+ # Simulation tab
701
+ with dpg.tab(label="Simulation"):
702
+ with dpg.child_window(height=-1, tag="simulation_container"):
703
+ self._create_simulation_ui()
704
+
705
+ return self._window_tag
706
+
707
+ def _create_source_ui(self) -> None:
708
+ """Create the source configuration UI."""
709
+ dpg.add_text("Source Configuration", color=(180, 180, 180))
710
+ dpg.add_separator()
711
+
712
+ # Source type selector
713
+ dpg.add_text("Type:")
714
+ dpg.add_combo(
715
+ items=self.get_available_sources(),
716
+ default_value=self._source_config["type"],
717
+ callback=self._on_source_type_change,
718
+ tag="source_type_combo",
719
+ width=-1,
720
+ )
721
+
722
+ dpg.add_spacer(height=10)
723
+
724
+ # Common parameters - Position with label on left
725
+ with dpg.group(horizontal=True):
726
+ dpg.add_text("Position X:", color=(180, 180, 180))
727
+ dpg.add_input_float(
728
+ default_value=self._source_config["position"][0],
729
+ callback=lambda s, a: self._on_source_param_change("position", 0, a),
730
+ width=-1,
731
+ tag="source_pos_x",
732
+ )
733
+ with dpg.group(horizontal=True):
734
+ dpg.add_text("Position Y:", color=(180, 180, 180))
735
+ dpg.add_input_float(
736
+ default_value=self._source_config["position"][1],
737
+ callback=lambda s, a: self._on_source_param_change("position", 1, a),
738
+ width=-1,
739
+ tag="source_pos_y",
740
+ )
741
+ with dpg.group(horizontal=True):
742
+ dpg.add_text("Position Z:", color=(180, 180, 180))
743
+ dpg.add_input_float(
744
+ default_value=self._source_config["position"][2],
745
+ callback=lambda s, a: self._on_source_param_change("position", 2, a),
746
+ width=-1,
747
+ tag="source_pos_z",
748
+ )
749
+
750
+ dpg.add_spacer(height=5)
751
+ with dpg.group(horizontal=True):
752
+ dpg.add_text("Number of Rays:", color=(180, 180, 180))
753
+ dpg.add_input_int(
754
+ default_value=self._source_config["num_rays"],
755
+ callback=lambda s, a: self._on_source_param_change("num_rays", None, a),
756
+ width=-1,
757
+ min_value=100,
758
+ max_value=10000000,
759
+ tag="source_num_rays",
760
+ )
761
+
762
+ with dpg.group(horizontal=True):
763
+ dpg.add_text("Wavelength (nm):", color=(180, 180, 180))
764
+ dpg.add_input_float(
765
+ default_value=self._source_config["wavelength"] * 1e9,
766
+ callback=lambda s, a: self._on_source_param_change(
767
+ "wavelength", None, a * 1e-9
768
+ ),
769
+ width=-1,
770
+ min_value=100,
771
+ max_value=2000,
772
+ tag="source_wavelength",
773
+ )
774
+
775
+ with dpg.group(horizontal=True):
776
+ dpg.add_text("Power (W):", color=(180, 180, 180))
777
+ dpg.add_input_float(
778
+ default_value=self._source_config["power"],
779
+ callback=lambda s, a: self._on_source_param_change("power", None, a),
780
+ width=-1,
781
+ min_value=0.0,
782
+ tag="source_power",
783
+ )
784
+
785
+ # Type-specific parameters container
786
+ dpg.add_separator()
787
+ with dpg.group(tag="source_type_params"):
788
+ self._update_source_type_params()
789
+
790
+ def _update_source_type_params(self) -> None:
791
+ """Update source type-specific parameter UI dynamically from discovered defaults."""
792
+ if not dpg.does_item_exist("source_type_params"):
793
+ return
794
+
795
+ dpg.delete_item("source_type_params", children_only=True)
796
+
797
+ source_type = self._source_config["type"]
798
+
799
+ # Get defaults dynamically
800
+ defaults = self.get_source_defaults(source_type)
801
+
802
+ # Skip common params already shown in the main UI
803
+ skip_params = {
804
+ "num_rays",
805
+ "wavelength",
806
+ "power",
807
+ "position",
808
+ "origin",
809
+ "center",
810
+ "waist_position",
811
+ }
812
+
813
+ for param_name, default_val in defaults.items():
814
+ if param_name in skip_params:
815
+ continue
816
+
817
+ # Create human-readable label
818
+ label = param_name.replace("_", " ").title()
819
+
820
+ # Handle different parameter types
821
+ if isinstance(default_val, (list, tuple)) and len(default_val) == 3:
822
+ # 3D vector parameter (direction, etc.) - label on left
823
+ vec_labels = ["X", "Y", "Z"]
824
+ for i in range(3):
825
+ with dpg.group(horizontal=True, parent="source_type_params"):
826
+ dpg.add_text(f"{label} {vec_labels[i]}:", color=(180, 180, 180))
827
+ dpg.add_input_float(
828
+ default_value=self._source_config.get(
829
+ param_name, list(default_val)
830
+ )[i],
831
+ callback=lambda s, a, idx=i, pn=param_name: self._on_source_param_change(
832
+ pn, idx, a
833
+ ),
834
+ width=-1,
835
+ )
836
+
837
+ elif isinstance(default_val, (list, tuple)) and len(default_val) == 2:
838
+ # 2D vector parameter - label on left
839
+ vec_labels = ["X", "Y"]
840
+ for i in range(2):
841
+ with dpg.group(horizontal=True, parent="source_type_params"):
842
+ dpg.add_text(f"{label} {vec_labels[i]}:", color=(180, 180, 180))
843
+ dpg.add_input_float(
844
+ default_value=self._source_config.get(
845
+ param_name, list(default_val)
846
+ )[i],
847
+ callback=lambda s, a, idx=i, pn=param_name: self._on_source_param_change(
848
+ pn, idx, a
849
+ ),
850
+ width=-1,
851
+ )
852
+
853
+ elif isinstance(default_val, str):
854
+ # String parameter (like profile: "uniform" or "gaussian")
855
+ # Try to discover possible values
856
+ possible_values = self._get_param_choices(
857
+ source_type, param_name, default_val
858
+ )
859
+ dpg.add_text(f"{label}:", parent="source_type_params")
860
+ dpg.add_combo(
861
+ items=possible_values,
862
+ default_value=self._source_config.get(param_name, default_val),
863
+ callback=lambda s, a, pn=param_name: self._on_source_param_change(
864
+ pn, None, a
865
+ ),
866
+ width=-1,
867
+ parent="source_type_params",
868
+ )
869
+
870
+ elif isinstance(default_val, bool):
871
+ # Boolean parameter
872
+ dpg.add_checkbox(
873
+ label=label,
874
+ default_value=self._source_config.get(param_name, default_val),
875
+ callback=lambda s, a, pn=param_name: self._on_source_param_change(
876
+ pn, None, a
877
+ ),
878
+ parent="source_type_params",
879
+ )
880
+
881
+ elif isinstance(default_val, int):
882
+ # Integer parameter
883
+ dpg.add_text(f"{label}:", parent="source_type_params")
884
+ dpg.add_input_int(
885
+ default_value=self._source_config.get(param_name, default_val),
886
+ callback=lambda s, a, pn=param_name: self._on_source_param_change(
887
+ pn, None, a
888
+ ),
889
+ width=-1,
890
+ parent="source_type_params",
891
+ )
892
+
893
+ elif isinstance(default_val, float):
894
+ # Float parameter
895
+ dpg.add_text(f"{label}:", parent="source_type_params")
896
+ dpg.add_input_float(
897
+ default_value=self._source_config.get(param_name, default_val),
898
+ callback=lambda s, a, pn=param_name: self._on_source_param_change(
899
+ pn, None, a
900
+ ),
901
+ width=-1,
902
+ parent="source_type_params",
903
+ )
904
+
905
+ def _get_param_choices(
906
+ self, source_type: str, param_name: str, default_val: str
907
+ ) -> list[str]:
908
+ """Get possible choices for a string parameter by inspecting type hints or docstrings."""
909
+ # Known choices for common parameters
910
+ known_choices = {
911
+ "profile": ["uniform", "gaussian"],
912
+ }
913
+
914
+ if param_name in known_choices:
915
+ return known_choices[param_name]
916
+
917
+ # Default: return just the default value
918
+ return [default_val]
919
+
920
+ def _create_simulation_ui(self) -> None:
921
+ """Create the simulation configuration UI."""
922
+ dpg.add_text("Simulation Parameters", color=(180, 180, 180))
923
+ dpg.add_separator()
924
+
925
+ dpg.add_text("Max Bounces:")
926
+ dpg.add_input_int(
927
+ default_value=self._sim_config["max_bounces"],
928
+ callback=lambda s, a: self._on_sim_param_change("max_bounces", a),
929
+ width=-1,
930
+ min_value=1,
931
+ max_value=100,
932
+ )
933
+
934
+ dpg.add_text("Step Size (m):")
935
+ dpg.add_input_float(
936
+ default_value=self._sim_config["step_size"],
937
+ callback=lambda s, a: self._on_sim_param_change("step_size", a),
938
+ width=-1,
939
+ min_value=0.1,
940
+ )
941
+
942
+ dpg.add_text("Bounding Radius (m):")
943
+ dpg.add_input_float(
944
+ default_value=self._sim_config["bounding_radius"],
945
+ callback=lambda s, a: self._on_sim_param_change("bounding_radius", a),
946
+ width=-1,
947
+ min_value=1.0,
948
+ )
949
+
950
+ dpg.add_spacer(height=5)
951
+ dpg.add_checkbox(
952
+ label="Use GPU",
953
+ default_value=self._sim_config["use_gpu"],
954
+ callback=lambda s, a: self._on_sim_param_change("use_gpu", a),
955
+ )
956
+ dpg.add_checkbox(
957
+ label="Track Refracted Rays",
958
+ default_value=self._sim_config["track_refracted_rays"],
959
+ callback=lambda s, a: self._on_sim_param_change("track_refracted_rays", a),
960
+ )
961
+ with dpg.tooltip(dpg.last_item()):
962
+ dpg.add_text(
963
+ "When enabled, rays that refract through surfaces\ncontinue propagating. Disable for reflection-only\nsimulations (e.g., ocean surface reflection)."
964
+ )
965
+
966
+ dpg.add_spacer(height=10)
967
+ dpg.add_text("Output Settings", color=(180, 180, 180))
968
+ dpg.add_separator()
969
+
970
+ dpg.add_text("Output Directory:")
971
+ with dpg.group(horizontal=True):
972
+ dpg.add_input_text(
973
+ default_value=self._sim_config["output_directory"],
974
+ callback=lambda s, a: self._on_sim_param_change("output_directory", a),
975
+ width=-60,
976
+ tag="output_dir_input",
977
+ )
978
+ dpg.add_button(
979
+ label="...",
980
+ callback=self._on_browse_output_dir,
981
+ width=50,
982
+ )
983
+
984
+ dpg.add_text("Output Prefix:")
985
+ dpg.add_input_text(
986
+ default_value=self._sim_config["output_prefix"],
987
+ callback=lambda s, a: self._on_sim_param_change("output_prefix", a),
988
+ width=-1,
989
+ )
990
+
991
+ dpg.add_text("Output Format:")
992
+ dpg.add_combo(
993
+ items=["hdf5", "numpy", "csv"],
994
+ default_value=self._sim_config["output_format"],
995
+ callback=lambda s, a: self._on_sim_param_change("output_format", a),
996
+ width=-1,
997
+ )
998
+
999
+ def _on_browse_output_dir(self) -> None:
1000
+ """Handle browse button for output directory."""
1001
+
1002
+ def callback(sender, app_data):
1003
+ if app_data.get("file_path_name"):
1004
+ dir_path = app_data["file_path_name"]
1005
+ self._sim_config["output_directory"] = dir_path
1006
+ if dpg.does_item_exist("output_dir_input"):
1007
+ dpg.set_value("output_dir_input", dir_path)
1008
+
1009
+ with dpg.file_dialog(
1010
+ callback=callback,
1011
+ width=800,
1012
+ height=500,
1013
+ modal=True,
1014
+ show=True,
1015
+ directory_selector=True,
1016
+ ):
1017
+ pass
1018
+
1019
+ def _rebuild_surfaces_ui(self) -> None:
1020
+ """Rebuild the surfaces list UI."""
1021
+ if not dpg.does_item_exist("surfaces_container"):
1022
+ return
1023
+
1024
+ dpg.delete_item("surfaces_container", children_only=True)
1025
+
1026
+ if not self._surfaces:
1027
+ dpg.add_text(
1028
+ "No surfaces. Click '+ Add Surface' to create one.",
1029
+ color=(128, 128, 128),
1030
+ parent="surfaces_container",
1031
+ )
1032
+ return
1033
+
1034
+ for i, surface in enumerate(self._surfaces):
1035
+ self._create_surface_editor(i, surface, "surfaces_container")
1036
+
1037
+ def _rebuild_detectors_ui(self) -> None:
1038
+ """Rebuild the detectors list UI."""
1039
+ if not dpg.does_item_exist("detectors_container"):
1040
+ return
1041
+
1042
+ dpg.delete_item("detectors_container", children_only=True)
1043
+
1044
+ if not self._detectors:
1045
+ dpg.add_text(
1046
+ "No detectors. Click '+ Add Detector' to create one.",
1047
+ color=(128, 128, 128),
1048
+ parent="detectors_container",
1049
+ )
1050
+ return
1051
+
1052
+ for i, detector in enumerate(self._detectors):
1053
+ self._create_detector_editor(i, detector, "detectors_container")
1054
+
1055
+ def _create_surface_editor(
1056
+ self, index: int, surface: dict[str, Any], parent: int | str
1057
+ ) -> None:
1058
+ """Create editor UI for a single surface."""
1059
+ with dpg.collapsing_header(
1060
+ label=f"{surface.get('name', f'Surface {index}')} ({surface['type']})",
1061
+ default_open=True,
1062
+ parent=parent,
1063
+ ):
1064
+ # Name
1065
+ dpg.add_text("Name:")
1066
+ dpg.add_input_text(
1067
+ default_value=surface.get("name", f"surface_{index}"),
1068
+ callback=self._on_surface_name_change,
1069
+ user_data=index,
1070
+ width=-1,
1071
+ )
1072
+
1073
+ # Role selector
1074
+ dpg.add_text("Role:")
1075
+ dpg.add_combo(
1076
+ items=self.SURFACE_ROLES,
1077
+ default_value=surface.get("role", "optical"),
1078
+ callback=self._on_surface_role_change,
1079
+ user_data=index,
1080
+ width=-1,
1081
+ )
1082
+
1083
+ # Type selector
1084
+ dpg.add_text("Geometry:")
1085
+ dpg.add_combo(
1086
+ items=self.get_available_surfaces(),
1087
+ default_value=surface["type"],
1088
+ callback=self._on_surface_type_combo_change,
1089
+ user_data=index,
1090
+ width=-1,
1091
+ )
1092
+
1093
+ # Material selectors (only for optical surfaces)
1094
+ if surface.get("role", "optical") == "optical":
1095
+ dpg.add_separator()
1096
+
1097
+ # Help text for media matching
1098
+ dpg.add_text(
1099
+ "Tip: Back medium should match front medium of next surface",
1100
+ color=(120, 120, 120),
1101
+ )
1102
+
1103
+ # Get current medium configs (convert string to dict if needed)
1104
+ front_medium = surface.get("front_medium", {"type": "vacuum"})
1105
+ if isinstance(front_medium, str):
1106
+ front_medium = self._get_default_medium_config(front_medium)
1107
+ back_medium = surface.get("back_medium", {"type": "vacuum"})
1108
+ if isinstance(back_medium, str):
1109
+ back_medium = self._get_default_medium_config(back_medium)
1110
+
1111
+ # Front Medium: dropdown + Customize button
1112
+ dpg.add_text("Front Medium:")
1113
+ with dpg.group(horizontal=True):
1114
+ dpg.add_combo(
1115
+ items=self.get_available_materials(),
1116
+ default_value=front_medium.get("type", "vacuum"),
1117
+ callback=self._on_surface_front_medium_type_change,
1118
+ user_data=index,
1119
+ width=-100,
1120
+ )
1121
+ dpg.add_button(
1122
+ label="Customize",
1123
+ callback=self._on_customize_front_medium_btn,
1124
+ user_data=index,
1125
+ width=90,
1126
+ )
1127
+
1128
+ # Back Medium: dropdown + Customize button
1129
+ dpg.add_text("Back Medium:")
1130
+ with dpg.group(horizontal=True):
1131
+ dpg.add_combo(
1132
+ items=self.get_available_materials(),
1133
+ default_value=back_medium.get("type", "vacuum"),
1134
+ callback=self._on_surface_back_medium_type_change,
1135
+ user_data=index,
1136
+ width=-100,
1137
+ )
1138
+ dpg.add_button(
1139
+ label="Customize",
1140
+ callback=self._on_customize_back_medium_btn,
1141
+ user_data=index,
1142
+ width=90,
1143
+ )
1144
+
1145
+ dpg.add_separator()
1146
+
1147
+ # Type-specific parameters
1148
+ self._create_surface_type_params(index, surface)
1149
+
1150
+ # Remove button
1151
+ dpg.add_button(
1152
+ label="Remove",
1153
+ callback=self._on_remove_surface_btn,
1154
+ user_data=index,
1155
+ width=-1,
1156
+ )
1157
+
1158
+ dpg.add_separator()
1159
+
1160
+ def _on_surface_role_change(self, sender, app_data, user_data) -> None:
1161
+ """Handle surface role change."""
1162
+ if 0 <= user_data < len(self._surfaces):
1163
+ self._surfaces[user_data]["role"] = app_data
1164
+ self._rebuild_surfaces_ui()
1165
+ self._build_and_load_config()
1166
+
1167
+ def _on_surface_name_change(self, sender, app_data, user_data) -> None:
1168
+ """Handle surface name change."""
1169
+ self._on_surface_change(user_data, "name", app_data)
1170
+
1171
+ def _on_surface_type_combo_change(self, sender, app_data, user_data) -> None:
1172
+ """Handle surface type combo change."""
1173
+ self._on_surface_type_change(user_data, app_data)
1174
+
1175
+ def _on_remove_surface_btn(self, sender, app_data, user_data) -> None:
1176
+ """Handle remove surface button."""
1177
+ self._on_remove_surface(user_data)
1178
+
1179
+ def _create_surface_type_params(self, index: int, surface: dict[str, Any]) -> None:
1180
+ """Create type-specific parameter inputs for a surface dynamically."""
1181
+ surface_type = surface["type"]
1182
+
1183
+ # Get the surface class and introspect its parameters
1184
+ self._create_dynamic_params(index, surface, surface_type, is_surface=True)
1185
+
1186
+ def _create_dynamic_params(
1187
+ self,
1188
+ index: int,
1189
+ config: dict[str, Any],
1190
+ type_name: str,
1191
+ is_surface: bool = True,
1192
+ ) -> None:
1193
+ """Dynamically create UI for all parameters of a surface/detector type."""
1194
+ import inspect
1195
+ import lsurf.surfaces as surf
1196
+
1197
+ # Handle gpu_ prefix specially - strip it and prepend GPU to class name
1198
+ if type_name.startswith("gpu_"):
1199
+ base_type = type_name[4:] # Remove "gpu_" prefix
1200
+ class_name = "GPU" + self._snake_to_camel(base_type) + "Surface"
1201
+ gpu_class_name = class_name
1202
+ non_gpu_class_name = self._snake_to_camel(base_type) + "Surface"
1203
+ else:
1204
+ class_name = self._snake_to_camel(type_name) + "Surface"
1205
+ gpu_class_name = "GPU" + class_name
1206
+ non_gpu_class_name = class_name
1207
+
1208
+ # Check if this is a wave surface (uses 2D direction, prefer GPU version)
1209
+ is_wave = "wave" in type_name.lower()
1210
+
1211
+ # Try to find the class - prefer GPU version for wave surfaces
1212
+ # (GPU versions have individual amplitude/wavelength/direction params)
1213
+ surface_class = None
1214
+ if is_wave:
1215
+ candidates = [gpu_class_name, non_gpu_class_name]
1216
+ else:
1217
+ candidates = [class_name, gpu_class_name]
1218
+
1219
+ for candidate in candidates:
1220
+ if hasattr(surf, candidate):
1221
+ surface_class = getattr(surf, candidate)
1222
+ break
1223
+
1224
+ if surface_class is None:
1225
+ # Fallback to defaults-based generation
1226
+ self._create_params_from_defaults(index, config, type_name, is_surface)
1227
+ return
1228
+
1229
+ # Get constructor signature
1230
+ try:
1231
+ sig = inspect.signature(surface_class.__init__)
1232
+ except Exception:
1233
+ self._create_params_from_defaults(index, config, type_name, is_surface)
1234
+ return
1235
+
1236
+ # Parameters to skip (handled elsewhere or internal)
1237
+ skip_params = {"self", "name", "role", "material_front", "material_back"}
1238
+
1239
+ # Check if this class uses wave_params (composite parameter)
1240
+ # If so, expand it into individual amplitude/wavelength/direction/phase/steepness fields
1241
+ uses_wave_params = "wave_params" in sig.parameters
1242
+
1243
+ if uses_wave_params:
1244
+ # Add individual wave parameter UI elements
1245
+ self._create_wave_params_ui(index, config, is_surface)
1246
+ skip_params.add("wave_params")
1247
+
1248
+ # Process each parameter
1249
+ for param_name, param in sig.parameters.items():
1250
+ if param_name in skip_params:
1251
+ continue
1252
+
1253
+ # Get default value
1254
+ if param.default != inspect.Parameter.empty:
1255
+ default_val = param.default
1256
+ else:
1257
+ # Provide sensible defaults based on parameter name
1258
+ default_val = self._get_param_default(param_name, is_wave)
1259
+
1260
+ # Get current value from config or use default
1261
+ current_val = config.get(param_name, default_val)
1262
+
1263
+ # Create appropriate UI element based on type
1264
+ label = param_name.replace("_", " ").title()
1265
+
1266
+ if isinstance(current_val, (list, tuple)):
1267
+ if len(current_val) == 3:
1268
+ self._add_vector_input(
1269
+ index, config, param_name, label, list(current_val), is_surface
1270
+ )
1271
+ elif len(current_val) == 2:
1272
+ self._add_vector_input_2d(
1273
+ index, config, param_name, label, list(current_val), is_surface
1274
+ )
1275
+ elif isinstance(current_val, bool):
1276
+ if is_surface:
1277
+ callback = self._make_surface_scalar_callback(index, param_name)
1278
+ else:
1279
+ callback = self._make_detector_scalar_callback(index, param_name)
1280
+ with dpg.group(horizontal=True):
1281
+ dpg.add_text(f"{label}:", color=(180, 180, 180))
1282
+ dpg.add_checkbox(
1283
+ default_value=current_val,
1284
+ callback=callback,
1285
+ )
1286
+ elif isinstance(current_val, int) and not isinstance(current_val, bool):
1287
+ if is_surface:
1288
+ callback = self._make_surface_scalar_callback(index, param_name)
1289
+ else:
1290
+ callback = self._make_detector_scalar_callback(index, param_name)
1291
+ with dpg.group(horizontal=True):
1292
+ dpg.add_text(f"{label}:", color=(180, 180, 180))
1293
+ dpg.add_input_int(
1294
+ default_value=int(current_val),
1295
+ callback=callback,
1296
+ width=-1,
1297
+ )
1298
+ elif isinstance(current_val, (int, float)):
1299
+ if is_surface:
1300
+ callback = self._make_surface_scalar_callback(index, param_name)
1301
+ else:
1302
+ callback = self._make_detector_scalar_callback(index, param_name)
1303
+ # Choose format based on magnitude
1304
+ if abs(current_val) > 10000 or (
1305
+ current_val != 0 and abs(current_val) < 0.01
1306
+ ):
1307
+ fmt = "%.4g"
1308
+ else:
1309
+ fmt = "%.4f"
1310
+ with dpg.group(horizontal=True):
1311
+ dpg.add_text(f"{label}:", color=(180, 180, 180))
1312
+ dpg.add_input_float(
1313
+ default_value=float(current_val),
1314
+ callback=callback,
1315
+ width=-1,
1316
+ format=fmt,
1317
+ )
1318
+
1319
+ def _create_wave_params_ui(
1320
+ self, index: int, config: dict[str, Any], is_surface: bool = True
1321
+ ) -> None:
1322
+ """Create UI for wave surface parameters (amplitude, wavelength, direction, etc.)."""
1323
+ # Wave parameter defaults
1324
+ wave_fields = [
1325
+ ("amplitude", 1.0, "Amplitude (m)"),
1326
+ ("wavelength", 10.0, "Wavelength (m)"),
1327
+ ("phase", 0.0, "Phase (rad)"),
1328
+ ("steepness", 0.0, "Steepness"),
1329
+ ]
1330
+
1331
+ for param_name, default_val, label in wave_fields:
1332
+ current_val = config.get(param_name, default_val)
1333
+ if is_surface:
1334
+ callback = self._make_surface_scalar_callback(index, param_name)
1335
+ else:
1336
+ callback = self._make_detector_scalar_callback(index, param_name)
1337
+
1338
+ with dpg.group(horizontal=True):
1339
+ dpg.add_text(f"{label}:", color=(180, 180, 180))
1340
+ dpg.add_input_float(
1341
+ default_value=float(current_val),
1342
+ callback=callback,
1343
+ width=-1,
1344
+ format="%.4g",
1345
+ )
1346
+
1347
+ # Direction is a 2D vector for wave surfaces
1348
+ direction_default = [1.0, 0.0]
1349
+ current_direction = config.get("direction", direction_default)
1350
+ self._add_vector_input_2d(
1351
+ index, config, "direction", "Direction", list(current_direction), is_surface
1352
+ )
1353
+
1354
+ def _get_param_default(self, param_name: str, is_wave: bool = False) -> Any:
1355
+ """Get a sensible default value for a parameter based on its name."""
1356
+ name_lower = param_name.lower()
1357
+
1358
+ if "point" in name_lower or "center" in name_lower or "position" in name_lower:
1359
+ if "earth" in name_lower:
1360
+ return [0.0, 0.0, -6.371e6]
1361
+ return [0.0, 0.0, 0.0]
1362
+ elif "normal" in name_lower:
1363
+ return [0.0, 0.0, 1.0]
1364
+ elif param_name == "direction":
1365
+ return [1.0, 0.0] if is_wave else [0.0, 0.0, 1.0]
1366
+ elif "radius" in name_lower:
1367
+ if "earth" in name_lower:
1368
+ return 6.371e6
1369
+ elif "inner" in name_lower:
1370
+ return 1.0
1371
+ elif "outer" in name_lower:
1372
+ return 5.0
1373
+ return 5.0
1374
+ elif "width" in name_lower or "height" in name_lower:
1375
+ return 10.0
1376
+ elif "amplitude" in name_lower:
1377
+ return 1.0
1378
+ elif "wavelength" in name_lower:
1379
+ return 10.0
1380
+ elif "reference_z" in name_lower:
1381
+ return 0.0
1382
+ elif "altitude" in name_lower:
1383
+ return 100000.0
1384
+ elif "phase" in name_lower:
1385
+ return 0.0
1386
+ elif "time" in name_lower:
1387
+ return 0.0
1388
+ elif "num" in name_lower or "count" in name_lower:
1389
+ return 4
1390
+ else:
1391
+ return 0.0
1392
+
1393
+ def _create_params_from_defaults(
1394
+ self, index: int, config: dict[str, Any], type_name: str, is_surface: bool
1395
+ ) -> None:
1396
+ """Create UI from get_surface_defaults fallback."""
1397
+ defaults = self.get_surface_defaults(type_name)
1398
+ for param_name, default_val in defaults.items():
1399
+ if param_name in ("name", "role", "material_front", "material_back"):
1400
+ continue
1401
+
1402
+ current_val = config.get(param_name, default_val)
1403
+ label = param_name.replace("_", " ").title()
1404
+
1405
+ if isinstance(current_val, (list, tuple)) and len(current_val) == 3:
1406
+ self._add_vector_input(
1407
+ index, config, param_name, label, list(current_val), is_surface
1408
+ )
1409
+ elif isinstance(current_val, (list, tuple)) and len(current_val) == 2:
1410
+ self._add_vector_input_2d(
1411
+ index, config, param_name, label, list(current_val), is_surface
1412
+ )
1413
+ elif isinstance(current_val, (int, float)):
1414
+ if is_surface:
1415
+ callback = self._make_surface_scalar_callback(index, param_name)
1416
+ else:
1417
+ callback = self._make_detector_scalar_callback(index, param_name)
1418
+ with dpg.group(horizontal=True):
1419
+ dpg.add_text(f"{label}:", color=(180, 180, 180))
1420
+ dpg.add_input_float(
1421
+ default_value=float(current_val),
1422
+ callback=callback,
1423
+ width=-1,
1424
+ format="%.4g",
1425
+ )
1426
+
1427
+ def _add_vector_input(
1428
+ self,
1429
+ index: int,
1430
+ config: dict,
1431
+ param: str,
1432
+ label: str,
1433
+ default: list,
1434
+ is_surface: bool = True,
1435
+ ) -> None:
1436
+ """Add a labeled X/Y/Z vector input group (label on left, input on right)."""
1437
+ labels = ["X", "Y", "Z"]
1438
+ for j in range(3):
1439
+ if is_surface:
1440
+ callback = self._make_surface_vec_callback(index, param, j)
1441
+ else:
1442
+ callback = self._make_detector_vec_callback(index, param, j)
1443
+ with dpg.group(horizontal=True):
1444
+ dpg.add_text(f"{label} {labels[j]}:", color=(180, 180, 180))
1445
+ dpg.add_input_float(
1446
+ default_value=config.get(param, default)[j],
1447
+ callback=callback,
1448
+ width=-1,
1449
+ format="%.4g",
1450
+ )
1451
+
1452
+ def _add_vector_input_2d(
1453
+ self,
1454
+ index: int,
1455
+ config: dict,
1456
+ param: str,
1457
+ label: str,
1458
+ default: list,
1459
+ is_surface: bool = True,
1460
+ ) -> None:
1461
+ """Add a labeled X/Y 2D vector input group (label on left, input on right)."""
1462
+ labels = ["X", "Y"]
1463
+ for j in range(2):
1464
+ if is_surface:
1465
+ callback = self._make_surface_vec_callback(index, param, j)
1466
+ else:
1467
+ callback = self._make_detector_vec_callback(index, param, j)
1468
+ with dpg.group(horizontal=True):
1469
+ dpg.add_text(f"{label} {labels[j]}:", color=(180, 180, 180))
1470
+ dpg.add_input_float(
1471
+ default_value=config.get(param, default)[j],
1472
+ callback=callback,
1473
+ width=-1,
1474
+ format="%.4g",
1475
+ )
1476
+
1477
+ def _make_surface_vec_callback(self, index: int, param: str, component: int):
1478
+ """Create a callback for surface vector parameter changes."""
1479
+
1480
+ def callback(sender, app_data, user_data=None):
1481
+ self._on_surface_vec_change(index, param, component, app_data)
1482
+
1483
+ return callback
1484
+
1485
+ def _make_surface_scalar_callback(self, index: int, param: str):
1486
+ """Create a callback for surface scalar parameter changes."""
1487
+
1488
+ def callback(sender, app_data, user_data=None):
1489
+ self._on_surface_change(index, param, app_data)
1490
+
1491
+ return callback
1492
+
1493
+ def _create_detector_editor(
1494
+ self, index: int, detector: dict[str, Any], parent: int | str
1495
+ ) -> None:
1496
+ """Create editor UI for a single detector."""
1497
+ with dpg.collapsing_header(
1498
+ label=f"{detector.get('name', f'Detector {index}')} ({detector['type']})",
1499
+ default_open=True,
1500
+ parent=parent,
1501
+ ):
1502
+ dpg.add_text("Name:")
1503
+ dpg.add_input_text(
1504
+ default_value=detector.get("name", f"detector_{index}"),
1505
+ callback=self._on_detector_name_change,
1506
+ user_data=index,
1507
+ width=-1,
1508
+ )
1509
+
1510
+ dpg.add_text("Type:")
1511
+ dpg.add_combo(
1512
+ items=self.get_available_surfaces(),
1513
+ default_value=detector["type"],
1514
+ callback=self._on_detector_type_combo_change,
1515
+ user_data=index,
1516
+ width=-1,
1517
+ )
1518
+
1519
+ # Type-specific params - dynamically generated
1520
+ det_type = detector["type"]
1521
+ self._create_dynamic_params(index, detector, det_type, is_surface=False)
1522
+
1523
+ dpg.add_button(
1524
+ label="Remove",
1525
+ callback=self._on_remove_detector_btn,
1526
+ user_data=index,
1527
+ width=-1,
1528
+ )
1529
+
1530
+ dpg.add_separator()
1531
+
1532
+ def _on_detector_name_change(self, sender, app_data, user_data) -> None:
1533
+ """Handle detector name change."""
1534
+ self._on_detector_change(user_data, "name", app_data)
1535
+
1536
+ def _on_detector_type_combo_change(self, sender, app_data, user_data) -> None:
1537
+ """Handle detector type combo change."""
1538
+ self._on_detector_type_change(user_data, app_data)
1539
+
1540
+ def _on_remove_detector_btn(self, sender, app_data, user_data) -> None:
1541
+ """Handle remove detector button."""
1542
+ self._on_remove_detector(user_data)
1543
+
1544
+ def _make_detector_vec_callback(self, index: int, param: str, component: int):
1545
+ """Create a callback for detector vector parameter changes."""
1546
+
1547
+ def callback(sender, app_data, user_data=None):
1548
+ self._on_detector_vec_change(index, param, component, app_data)
1549
+
1550
+ return callback
1551
+
1552
+ def _make_detector_scalar_callback(self, index: int, param: str):
1553
+ """Create a callback for detector scalar parameter changes."""
1554
+
1555
+ def callback(sender, app_data, user_data=None):
1556
+ self._on_detector_change(index, param, app_data)
1557
+
1558
+ return callback
1559
+
1560
+ # === Event Handlers ===
1561
+
1562
+ def _on_new_config(self) -> None:
1563
+ """Create a new empty configuration."""
1564
+ self._surfaces = []
1565
+ self._detectors = []
1566
+ self._source_config = {
1567
+ "type": "point",
1568
+ "position": [0.0, 0.0, 10.0],
1569
+ "num_rays": 10000,
1570
+ "wavelength": 532e-9,
1571
+ "power": 1.0,
1572
+ }
1573
+ self._rebuild_surfaces_ui()
1574
+ self._rebuild_detectors_ui()
1575
+ self.scene.clear()
1576
+
1577
+ def _on_load_config(self) -> None:
1578
+ """Open file dialog to load a configuration file."""
1579
+
1580
+ def callback(sender, app_data):
1581
+ if app_data.get("file_path_name"):
1582
+ self.load_from_file(app_data["file_path_name"])
1583
+
1584
+ with dpg.file_dialog(
1585
+ callback=callback,
1586
+ width=800,
1587
+ height=500,
1588
+ modal=True,
1589
+ show=True,
1590
+ ):
1591
+ dpg.add_file_extension(".yaml", color=(0, 255, 0))
1592
+ dpg.add_file_extension(".yml", color=(0, 255, 0))
1593
+ dpg.add_file_extension(".toml", color=(0, 200, 255))
1594
+
1595
+ def load_from_file(self, file_path: str | Path) -> bool:
1596
+ """Load configuration from a YAML or TOML file into the editor.
1597
+
1598
+ Args:
1599
+ file_path: Path to the configuration file
1600
+
1601
+ Returns:
1602
+ True if loaded successfully
1603
+ """
1604
+ file_path = Path(file_path)
1605
+ if not file_path.exists():
1606
+ print(f"Config file not found: {file_path}")
1607
+ return False
1608
+
1609
+ try:
1610
+ # Load the raw config data
1611
+ if file_path.suffix in (".yaml", ".yml"):
1612
+ import yaml
1613
+
1614
+ with open(file_path) as f:
1615
+ data = yaml.safe_load(f)
1616
+ elif file_path.suffix == ".toml":
1617
+ import tomllib
1618
+
1619
+ with open(file_path, "rb") as f:
1620
+ data = tomllib.load(f)
1621
+ else:
1622
+ print(f"Unsupported file format: {file_path.suffix}")
1623
+ return False
1624
+
1625
+ print(f"[GUI] Loaded config from: {file_path}")
1626
+ print(
1627
+ f"[GUI] Found {len(data.get('surfaces', []))} surfaces, {len(data.get('detectors', []))} detectors"
1628
+ )
1629
+
1630
+ # Parse and populate the editor state
1631
+ self._load_config_data(data)
1632
+ return True
1633
+
1634
+ except Exception as e:
1635
+ import traceback
1636
+
1637
+ print(f"Error loading config: {e}")
1638
+ traceback.print_exc()
1639
+ return False
1640
+
1641
+ def _load_config_data(self, data: dict) -> None:
1642
+ """Populate editor state from parsed config data."""
1643
+ # Clear existing state
1644
+ self._surfaces = []
1645
+ self._detectors = []
1646
+
1647
+ # Load media (for reference)
1648
+ media_data = data.get("media", {})
1649
+
1650
+ # Load surfaces
1651
+ for i, surf_data in enumerate(data.get("surfaces", [])):
1652
+ surface_config = self._parse_surface_config(surf_data, media_data, i)
1653
+ if surface_config:
1654
+ self._surfaces.append(surface_config)
1655
+
1656
+ # Load detectors
1657
+ for i, det_data in enumerate(data.get("detectors", [])):
1658
+ detector_config = self._parse_detector_config(det_data, i)
1659
+ if detector_config:
1660
+ self._detectors.append(detector_config)
1661
+
1662
+ # Load source
1663
+ source_data = data.get("source", {})
1664
+ if source_data:
1665
+ self._source_config = self._parse_source_config(source_data)
1666
+
1667
+ # Load simulation config
1668
+ sim_data = data.get("simulation", {})
1669
+ output_data = data.get("output", {})
1670
+ self._sim_config = {
1671
+ "max_bounces": sim_data.get("max_bounces", 10),
1672
+ "step_size": sim_data.get("step_size", 100.0),
1673
+ "use_gpu": sim_data.get("use_gpu", True),
1674
+ "bounding_center": sim_data.get("bounding_center", [0.0, 0.0, 0.0]),
1675
+ "bounding_radius": sim_data.get("bounding_radius", 100.0),
1676
+ "track_refracted_rays": sim_data.get("track_refracted_rays", True),
1677
+ "output_directory": output_data.get("directory", "./results"),
1678
+ "output_prefix": output_data.get("prefix", "simulation"),
1679
+ "output_format": output_data.get("format", "hdf5"),
1680
+ }
1681
+
1682
+ print(
1683
+ f"[GUI] Parsed {len(self._surfaces)} surfaces, {len(self._detectors)} detectors"
1684
+ )
1685
+ for i, s in enumerate(self._surfaces):
1686
+ print(
1687
+ f"[GUI] Surface {i}: {s.get('name', 'unnamed')} ({s.get('type', '?')})"
1688
+ )
1689
+ for i, d in enumerate(self._detectors):
1690
+ print(
1691
+ f"[GUI] Detector {i}: {d.get('name', 'unnamed')} ({d.get('type', '?')})"
1692
+ )
1693
+
1694
+ # Rebuild UI
1695
+ self._rebuild_surfaces_ui()
1696
+ self._rebuild_detectors_ui()
1697
+
1698
+ # Apply to scene for preview
1699
+ self._build_and_load_config()
1700
+
1701
+ def _parse_surface_config(
1702
+ self, surf_data: dict, media_data: dict, index: int = 0
1703
+ ) -> dict | None:
1704
+ """Parse a surface configuration from loaded data."""
1705
+ surface_type = surf_data.get("type", "plane")
1706
+
1707
+ config = {
1708
+ "type": surface_type,
1709
+ "name": surf_data.get("name", f"surface_{index}"),
1710
+ "role": surf_data.get("role", "optical"),
1711
+ }
1712
+
1713
+ # Handle front_medium - can be string or dict
1714
+ front_medium = surf_data.get("front_medium", surf_data.get("front", "vacuum"))
1715
+ if isinstance(front_medium, str):
1716
+ config["front_medium"] = self._get_default_medium_config(front_medium)
1717
+ elif isinstance(front_medium, dict):
1718
+ config["front_medium"] = front_medium
1719
+ else:
1720
+ config["front_medium"] = {"type": "vacuum"}
1721
+
1722
+ # Handle back_medium - can be string or dict
1723
+ back_medium = surf_data.get("back_medium", surf_data.get("back", "vacuum"))
1724
+ if isinstance(back_medium, str):
1725
+ config["back_medium"] = self._get_default_medium_config(back_medium)
1726
+ elif isinstance(back_medium, dict):
1727
+ config["back_medium"] = back_medium
1728
+ else:
1729
+ config["back_medium"] = {"type": "vacuum"}
1730
+
1731
+ # Handle CLI config format which uses nested "params" dict
1732
+ params = surf_data.get("params", {})
1733
+
1734
+ # Copy parameters from params dict first (CLI format)
1735
+ for key, value in params.items():
1736
+ if isinstance(value, tuple):
1737
+ value = list(value)
1738
+ config[key] = value
1739
+
1740
+ # Copy all other parameters from the source data (flat format)
1741
+ # This ensures we don't miss any surface-specific parameters
1742
+ skip_keys = {
1743
+ "type",
1744
+ "name",
1745
+ "role",
1746
+ "front",
1747
+ "back",
1748
+ "front_medium",
1749
+ "back_medium",
1750
+ "params",
1751
+ }
1752
+ for key, value in surf_data.items():
1753
+ if key not in skip_keys:
1754
+ # Convert to list if it's a tuple for UI compatibility
1755
+ if isinstance(value, tuple):
1756
+ value = list(value)
1757
+ config[key] = value
1758
+
1759
+ # Normalize center -> point for surface types that use "point"
1760
+ # (bounded_plane uses "point" but some YAMLs use "center")
1761
+ if (
1762
+ surface_type in ("bounded_plane", "plane")
1763
+ and "center" in config
1764
+ and "point" not in config
1765
+ ):
1766
+ config["point"] = config.pop("center")
1767
+
1768
+ return config
1769
+
1770
+ def _parse_detector_config(self, det_data: dict, index: int = 0) -> dict | None:
1771
+ """Parse a detector configuration from loaded data."""
1772
+ detector_type = det_data.get("type", "bounded_plane")
1773
+
1774
+ # Map CLI detector types to surface types
1775
+ type_mapping = {
1776
+ "planar": "bounded_plane",
1777
+ "spherical": "sphere",
1778
+ }
1779
+ surface_type = type_mapping.get(detector_type, detector_type)
1780
+
1781
+ config = {
1782
+ "type": surface_type,
1783
+ "name": det_data.get("name", f"detector_{index}"),
1784
+ }
1785
+
1786
+ # Handle CLI config format which uses nested "params" dict
1787
+ params = det_data.get("params", {})
1788
+
1789
+ # Copy parameters from params dict first (CLI format)
1790
+ for key, value in params.items():
1791
+ if isinstance(value, tuple):
1792
+ value = list(value)
1793
+ config[key] = value
1794
+
1795
+ # Copy all other parameters from the source data (flat format)
1796
+ skip_keys = {"type", "name", "params"}
1797
+ for key, value in det_data.items():
1798
+ if key not in skip_keys:
1799
+ # Convert to list if it's a tuple for UI compatibility
1800
+ if isinstance(value, tuple):
1801
+ value = list(value)
1802
+ config[key] = value
1803
+
1804
+ # Normalize center -> point for detector types that use "point"
1805
+ # (bounded_plane uses "point" but some YAMLs use "center")
1806
+ if (
1807
+ surface_type in ("bounded_plane", "plane")
1808
+ and "center" in config
1809
+ and "point" not in config
1810
+ ):
1811
+ config["point"] = config.pop("center")
1812
+
1813
+ return config
1814
+
1815
+ def _parse_source_config(self, source_data: dict) -> dict:
1816
+ """Parse a source configuration from loaded data."""
1817
+ source_type = source_data.get("type", "point")
1818
+
1819
+ # Map CLI source types to our internal types
1820
+ type_mapping = {
1821
+ "collimated_beam": "collimated",
1822
+ "diverging_beam": "diverging",
1823
+ "gaussian_beam": "gaussian",
1824
+ }
1825
+ internal_type = type_mapping.get(source_type, source_type)
1826
+
1827
+ # Handle CLI config format which uses nested "params" dict
1828
+ params = source_data.get("params", {})
1829
+
1830
+ config = {
1831
+ "type": internal_type,
1832
+ "num_rays": params.get("num_rays", source_data.get("num_rays", 10000)),
1833
+ "wavelength": params.get(
1834
+ "wavelength", source_data.get("wavelength", 532e-9)
1835
+ ),
1836
+ "power": params.get("power", source_data.get("power", 1.0)),
1837
+ }
1838
+
1839
+ # Copy parameters from params dict first (CLI format)
1840
+ skip_keys = {"type", "num_rays", "wavelength", "power", "params"}
1841
+ for key, value in params.items():
1842
+ if key not in skip_keys:
1843
+ if isinstance(value, tuple):
1844
+ value = list(value)
1845
+ config[key] = value
1846
+
1847
+ # Copy all other parameters from the source data (flat format)
1848
+ for key, value in source_data.items():
1849
+ if key not in skip_keys:
1850
+ # Convert to list if it's a tuple for UI compatibility
1851
+ if isinstance(value, tuple):
1852
+ value = list(value)
1853
+ config[key] = value
1854
+
1855
+ # Normalize position field - the UI uses "position" as the common field
1856
+ # but different sources use different names (origin, center, etc.)
1857
+ if "position" not in config:
1858
+ for pos_key in ("origin", "center", "waist_position"):
1859
+ if pos_key in config:
1860
+ config["position"] = config[pos_key]
1861
+ break
1862
+ else:
1863
+ config["position"] = [0.0, 0.0, 10.0]
1864
+
1865
+ # Handle beam_radius -> radius mapping
1866
+ if "beam_radius" in config and "radius" not in config:
1867
+ config["radius"] = config["beam_radius"]
1868
+
1869
+ return config
1870
+
1871
+ def _on_apply(self) -> None:
1872
+ """Apply the current configuration to the scene."""
1873
+ self._build_and_load_config()
1874
+
1875
+ def _on_reload_scene(self) -> None:
1876
+ """Reload the scene: clear results and rebuild all geometry from config."""
1877
+ from ..core.scene import ObjectType
1878
+
1879
+ # Clear simulation results from scene
1880
+ to_remove = [
1881
+ name
1882
+ for name, obj in self.scene.objects.items()
1883
+ if obj.obj_type in (ObjectType.RAY_PATHS, ObjectType.DETECTIONS)
1884
+ ]
1885
+ for name in to_remove:
1886
+ self.scene.remove_object(name)
1887
+
1888
+ self.scene.result = None
1889
+
1890
+ # Rebuild and reload geometry and source
1891
+ self._build_and_load_config()
1892
+
1893
+ print("[GUI] Scene reloaded")
1894
+
1895
+ def _update_simulate_status(
1896
+ self, message: str, color: tuple = (128, 128, 128)
1897
+ ) -> None:
1898
+ """Update the simulation status text."""
1899
+ if dpg.does_item_exist("simulate_status"):
1900
+ dpg.set_value("simulate_status", message)
1901
+ dpg.configure_item("simulate_status", color=color)
1902
+ # Force a frame render to show the update immediately
1903
+ dpg.render_dearpygui_frame()
1904
+
1905
+ def _on_simulate(self) -> None:
1906
+ """Run the simulation by exporting to YAML and calling the CLI."""
1907
+ import subprocess
1908
+ import tempfile
1909
+ import os
1910
+ from pathlib import Path
1911
+
1912
+ # Show running status
1913
+ self._update_simulate_status("Running simulation...", (100, 200, 255))
1914
+
1915
+ # Disable simulate button during run
1916
+ if dpg.does_item_exist("simulate_btn"):
1917
+ dpg.configure_item("simulate_btn", enabled=False)
1918
+
1919
+ # First apply the current config to update the 3D view
1920
+ self._build_and_load_config()
1921
+
1922
+ # Get output settings
1923
+ output_dir = (
1924
+ Path(self._sim_config.get("output_directory", "./results"))
1925
+ .expanduser()
1926
+ .resolve()
1927
+ )
1928
+ output_prefix = self._sim_config.get("output_prefix", "simulation")
1929
+ output_format = self._sim_config.get("output_format", "hdf5")
1930
+
1931
+ # Ensure output directory exists
1932
+ output_dir.mkdir(parents=True, exist_ok=True)
1933
+
1934
+ # Export to temp YAML file
1935
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
1936
+ temp_path = f.name
1937
+
1938
+ stdout = ""
1939
+ stderr = ""
1940
+ return_code = 0
1941
+
1942
+ try:
1943
+ self._export_to_yaml(temp_path)
1944
+ print(f"[GUI] Exported config to {temp_path}")
1945
+
1946
+ # Run the CLI with output directory (no --no-save)
1947
+ cmd = ["lsurf", "run", temp_path, "--output-dir", str(output_dir)]
1948
+ print(f"[GUI] Running: {' '.join(cmd)}")
1949
+
1950
+ result = subprocess.run(
1951
+ cmd,
1952
+ capture_output=True,
1953
+ text=True,
1954
+ timeout=300, # 5 minute timeout
1955
+ )
1956
+
1957
+ stdout = result.stdout or ""
1958
+ stderr = result.stderr or ""
1959
+ return_code = result.returncode
1960
+
1961
+ # Print output to console as well
1962
+ if stdout:
1963
+ print(stdout)
1964
+ if stderr:
1965
+ print(f"[STDERR] {stderr}")
1966
+
1967
+ if return_code != 0:
1968
+ print(f"[GUI] CLI exited with code {return_code}")
1969
+ else:
1970
+ # Try to load results for visualization
1971
+ self._load_results_for_visualization(
1972
+ output_dir, output_prefix, output_format
1973
+ )
1974
+
1975
+ except subprocess.TimeoutExpired:
1976
+ stderr = "Simulation timed out after 5 minutes"
1977
+ return_code = -1
1978
+ print(f"[GUI] {stderr}")
1979
+ except Exception as e:
1980
+ stderr = f"Error running simulation: {e}"
1981
+ return_code = -1
1982
+ print(f"[GUI] {stderr}")
1983
+ finally:
1984
+ # Clean up temp file
1985
+ try:
1986
+ os.unlink(temp_path)
1987
+ except Exception:
1988
+ pass
1989
+
1990
+ # Display results in the results panel
1991
+ if self.results_panel:
1992
+ # Append debug info to stdout
1993
+ debug_info = getattr(self, "_last_export_debug", "")
1994
+ if debug_info:
1995
+ stdout = stdout + "\n\n=== Exported Config ===\n" + debug_info
1996
+ # Add output location info
1997
+ stdout = stdout + f"\n\nResults saved to: {output_dir}"
1998
+ self.results_panel.display_cli_output(stdout, stderr, return_code)
1999
+
2000
+ # Update status and re-enable button
2001
+ if dpg.does_item_exist("simulate_btn"):
2002
+ dpg.configure_item("simulate_btn", enabled=True)
2003
+
2004
+ if return_code == 0:
2005
+ self._update_simulate_status("Simulation complete", (100, 255, 100))
2006
+ else:
2007
+ self._update_simulate_status("Simulation failed", (255, 100, 100))
2008
+
2009
+ # Note: We intentionally do NOT call on_simulate() here because
2010
+ # we're using the CLI for simulation instead of the internal runner.
2011
+ # Calling it would run the old simulation and overwrite our results.
2012
+
2013
+ def _load_results_for_visualization(
2014
+ self, output_dir, output_prefix: str, output_format: str
2015
+ ) -> None:
2016
+ """Load simulation results from output files for visualization."""
2017
+ import numpy as np
2018
+
2019
+ try:
2020
+ from lsurf.detectors.results import DetectorResult
2021
+
2022
+ # The CLI saves in a custom format, not the DetectorResult format
2023
+ # So we need to load manually and construct a DetectorResult
2024
+
2025
+ detected_file = None
2026
+ positions = None
2027
+ directions = None
2028
+ intensities = None
2029
+ times = None
2030
+ wavelengths = None
2031
+
2032
+ # Try HDF5 format first
2033
+ h5_file = output_dir / f"{output_prefix}_detected.h5"
2034
+ if h5_file.exists():
2035
+ try:
2036
+ import h5py
2037
+
2038
+ with h5py.File(h5_file, "r") as f:
2039
+ positions = f["positions"][...]
2040
+ if "directions" in f:
2041
+ directions = f["directions"][...]
2042
+ if "intensities" in f:
2043
+ intensities = f["intensities"][...]
2044
+ if "times" in f:
2045
+ times = f["times"][...]
2046
+ if "wavelengths" in f:
2047
+ wavelengths = f["wavelengths"][...]
2048
+ detected_file = h5_file
2049
+ except Exception as e:
2050
+ print(f"[GUI] Failed to load HDF5: {e}")
2051
+
2052
+ # Try numpy format
2053
+ if positions is None:
2054
+ npz_file = output_dir / f"{output_prefix}_detected.npz"
2055
+ if npz_file.exists():
2056
+ data = np.load(npz_file)
2057
+ positions = data.get("positions")
2058
+ directions = data.get("directions")
2059
+ intensities = data.get("intensities")
2060
+ times = data.get("times")
2061
+ wavelengths = data.get("wavelengths")
2062
+ detected_file = npz_file
2063
+
2064
+ if positions is None or len(positions) == 0:
2065
+ print(f"[GUI] No detected rays found in {output_dir}")
2066
+ return
2067
+
2068
+ # Fill in defaults for missing data
2069
+ num_rays = len(positions)
2070
+ if directions is None or len(directions) == 0:
2071
+ directions = np.zeros((num_rays, 3), dtype=np.float32)
2072
+ if intensities is None or len(intensities) == 0:
2073
+ intensities = np.ones(num_rays, dtype=np.float32)
2074
+ if times is None or len(times) == 0:
2075
+ times = np.zeros(num_rays, dtype=np.float32)
2076
+ if wavelengths is None or len(wavelengths) == 0:
2077
+ wavelengths = np.full(
2078
+ num_rays, 532e-9, dtype=np.float32
2079
+ ) # Default green
2080
+
2081
+ # Create DetectorResult
2082
+ detected = DetectorResult(
2083
+ positions=positions.astype(np.float32),
2084
+ directions=directions.astype(np.float32),
2085
+ times=times.astype(np.float32),
2086
+ intensities=intensities.astype(np.float32),
2087
+ wavelengths=wavelengths.astype(np.float32),
2088
+ detector_name="cli_results",
2089
+ )
2090
+
2091
+ print(
2092
+ f"[GUI] Loaded {detected.num_rays} detected rays from {detected_file}"
2093
+ )
2094
+ if self.visualization_panel:
2095
+ self.visualization_panel.set_detected(detected)
2096
+
2097
+ except Exception as e:
2098
+ print(f"[GUI] Failed to load results for visualization: {e}")
2099
+ import traceback
2100
+
2101
+ traceback.print_exc()
2102
+
2103
+ def _on_export_yaml(self) -> None:
2104
+ """Export configuration to YAML file."""
2105
+
2106
+ def callback(sender, app_data):
2107
+ if app_data.get("file_path_name"):
2108
+ self._export_to_yaml(app_data["file_path_name"])
2109
+
2110
+ with dpg.file_dialog(
2111
+ callback=callback,
2112
+ width=800,
2113
+ height=500,
2114
+ modal=True,
2115
+ show=True,
2116
+ default_filename="simulation.yaml",
2117
+ ):
2118
+ dpg.add_file_extension(".yaml")
2119
+ dpg.add_file_extension(".yml")
2120
+
2121
+ def _on_add_surface(self) -> None:
2122
+ """Add a new surface."""
2123
+ # Offset each new surface so they don't overlap
2124
+ index = len(self._surfaces)
2125
+ z_offset = index * 5.0 # Stack surfaces 5 units apart in Z
2126
+
2127
+ self._surfaces.append(
2128
+ {
2129
+ "type": "bounded_plane",
2130
+ "name": f"surface_{index}",
2131
+ "role": "optical",
2132
+ "front_medium": {"type": "vacuum", "refractive_index": 1.0},
2133
+ "back_medium": {"type": "vacuum", "refractive_index": 1.0},
2134
+ "point": [0.0, 0.0, z_offset],
2135
+ "normal": [0.0, 0.0, 1.0],
2136
+ "width": 10.0,
2137
+ "height": 10.0,
2138
+ }
2139
+ )
2140
+ self._rebuild_surfaces_ui()
2141
+ self._build_and_load_config()
2142
+
2143
+ def _on_remove_surface(self, index: int) -> None:
2144
+ """Remove a surface."""
2145
+ if 0 <= index < len(self._surfaces):
2146
+ del self._surfaces[index]
2147
+ self._rebuild_surfaces_ui()
2148
+ self._build_and_load_config()
2149
+
2150
+ def _on_surface_change(self, index: int, param: str, value: Any) -> None:
2151
+ """Handle surface parameter change."""
2152
+ if 0 <= index < len(self._surfaces):
2153
+ self._surfaces[index][param] = value
2154
+ self._build_and_load_config()
2155
+
2156
+ def _on_surface_vec_change(
2157
+ self, index: int, param: str, component: int, value: float
2158
+ ) -> None:
2159
+ """Handle surface vector parameter change."""
2160
+ if 0 <= index < len(self._surfaces):
2161
+ if param not in self._surfaces[index]:
2162
+ self._surfaces[index][param] = [0.0, 0.0, 0.0]
2163
+ self._surfaces[index][param][component] = value
2164
+ self._build_and_load_config()
2165
+
2166
+ def _on_surface_type_change(self, index: int, new_type: str) -> None:
2167
+ """Handle surface type change."""
2168
+ if 0 <= index < len(self._surfaces):
2169
+ old = self._surfaces[index]
2170
+ # Preserve common settings including medium configs
2171
+ front_medium = old.get("front_medium", {"type": "vacuum"})
2172
+ back_medium = old.get("back_medium", {"type": "vacuum"})
2173
+ # Ensure they're dicts
2174
+ if isinstance(front_medium, str):
2175
+ front_medium = self._get_default_medium_config(front_medium)
2176
+ if isinstance(back_medium, str):
2177
+ back_medium = self._get_default_medium_config(back_medium)
2178
+
2179
+ self._surfaces[index] = {
2180
+ "type": new_type,
2181
+ "name": old.get("name", f"surface_{index}"),
2182
+ "role": old.get("role", "optical"),
2183
+ "front_medium": front_medium,
2184
+ "back_medium": back_medium,
2185
+ }
2186
+ # Get defaults for new type dynamically
2187
+ defaults = self.get_surface_defaults(new_type)
2188
+ self._surfaces[index].update(defaults)
2189
+
2190
+ self._rebuild_surfaces_ui()
2191
+ self._build_and_load_config()
2192
+
2193
+ def _on_add_detector(self) -> None:
2194
+ """Add a new detector."""
2195
+ # Offset each new detector so they don't overlap
2196
+ index = len(self._detectors)
2197
+ z_offset = 20.0 + index * 5.0 # Stack detectors 5 units apart starting at z=20
2198
+
2199
+ self._detectors.append(
2200
+ {
2201
+ "type": "plane",
2202
+ "name": f"detector_{index}",
2203
+ "point": [0.0, 0.0, z_offset],
2204
+ "normal": [0.0, 0.0, -1.0],
2205
+ }
2206
+ )
2207
+ self._rebuild_detectors_ui()
2208
+ self._build_and_load_config()
2209
+
2210
+ def _on_remove_detector(self, index: int) -> None:
2211
+ """Remove a detector."""
2212
+ if 0 <= index < len(self._detectors):
2213
+ del self._detectors[index]
2214
+ self._rebuild_detectors_ui()
2215
+ self._build_and_load_config()
2216
+
2217
+ def _on_detector_change(self, index: int, param: str, value: Any) -> None:
2218
+ """Handle detector parameter change."""
2219
+ if 0 <= index < len(self._detectors):
2220
+ self._detectors[index][param] = value
2221
+ self._build_and_load_config()
2222
+
2223
+ def _on_detector_vec_change(
2224
+ self, index: int, param: str, component: int, value: float
2225
+ ) -> None:
2226
+ """Handle detector vector parameter change."""
2227
+ if 0 <= index < len(self._detectors):
2228
+ if param not in self._detectors[index]:
2229
+ self._detectors[index][param] = [0.0, 0.0, 0.0]
2230
+ self._detectors[index][param][component] = value
2231
+ self._build_and_load_config()
2232
+
2233
+ def _on_detector_type_change(self, index: int, new_type: str) -> None:
2234
+ """Handle detector type change."""
2235
+ if 0 <= index < len(self._detectors):
2236
+ old = self._detectors[index]
2237
+ self._detectors[index] = {
2238
+ "type": new_type,
2239
+ "name": old.get("name", f"detector_{index}"),
2240
+ }
2241
+ # Get defaults for new type dynamically
2242
+ defaults = self.get_surface_defaults(new_type)
2243
+ self._detectors[index].update(defaults)
2244
+
2245
+ self._rebuild_detectors_ui()
2246
+ self._build_and_load_config()
2247
+
2248
+ def _on_source_type_change(self, sender, value) -> None:
2249
+ """Handle source type change."""
2250
+ self._source_config["type"] = value
2251
+ # Set defaults for new type dynamically
2252
+ defaults = self.get_source_defaults(value)
2253
+ for key, default_val in defaults.items():
2254
+ self._source_config.setdefault(key, default_val)
2255
+
2256
+ self._update_source_type_params()
2257
+ self._build_and_load_config()
2258
+
2259
+ def _on_source_param_change(
2260
+ self, param: str, component: int | None, value: Any
2261
+ ) -> None:
2262
+ """Handle source parameter change."""
2263
+ if component is not None:
2264
+ if param not in self._source_config:
2265
+ self._source_config[param] = [0.0, 0.0, 0.0]
2266
+ self._source_config[param][component] = value
2267
+ else:
2268
+ self._source_config[param] = value
2269
+ self._build_and_load_config()
2270
+
2271
+ def _on_sim_param_change(self, param: str, value: Any) -> None:
2272
+ """Handle simulation parameter change."""
2273
+ self._sim_config[param] = value
2274
+
2275
+ # === Medium Customization Methods ===
2276
+
2277
+ def _get_medium_params(self, medium_type: str) -> dict[str, Any]:
2278
+ """Get editable parameters for a medium type by introspection."""
2279
+ import inspect
2280
+ import lsurf.materials as mat
2281
+
2282
+ params = {}
2283
+
2284
+ # Map simple type names to class names
2285
+ type_to_class = {
2286
+ "vacuum": "HomogeneousMaterial",
2287
+ "air": "HomogeneousMaterial",
2288
+ "water": "HomogeneousMaterial",
2289
+ "glass": "HomogeneousMaterial",
2290
+ "homogeneous_material": "HomogeneousMaterial",
2291
+ "exponential_atmosphere": "ExponentialAtmosphere",
2292
+ "duct_atmosphere": "DuctAtmosphere",
2293
+ }
2294
+
2295
+ class_name = type_to_class.get(medium_type, self._snake_to_camel(medium_type))
2296
+
2297
+ if not hasattr(mat, class_name):
2298
+ # Fallback for simple materials
2299
+ return {"refractive_index": 1.0}
2300
+
2301
+ material_class = getattr(mat, class_name)
2302
+
2303
+ try:
2304
+ sig = inspect.signature(material_class.__init__)
2305
+ for param_name, param in sig.parameters.items():
2306
+ if param_name in ("self", "kernel", "propagator", "name"):
2307
+ continue
2308
+
2309
+ # Get default value
2310
+ if param.default != inspect.Parameter.empty:
2311
+ default_val = param.default
2312
+ elif param_name == "refractive_index":
2313
+ default_val = 1.0
2314
+ elif "n_sea_level" in param_name:
2315
+ default_val = 1.000293
2316
+ elif "scale_height" in param_name:
2317
+ default_val = 8500.0
2318
+ elif "earth_radius" in param_name:
2319
+ default_val = 6371000.0
2320
+ elif "earth_center" in param_name or "center" in param_name:
2321
+ default_val = [0.0, 0.0, 0.0]
2322
+ elif "coef" in param_name:
2323
+ default_val = 0.0
2324
+ elif "altitude_range" in param_name:
2325
+ default_val = [0.0, 200000.0]
2326
+ else:
2327
+ default_val = 0.0
2328
+
2329
+ # Convert tuples to lists for UI
2330
+ if isinstance(default_val, tuple):
2331
+ default_val = list(default_val)
2332
+
2333
+ params[param_name] = default_val
2334
+
2335
+ except Exception:
2336
+ params = {"refractive_index": 1.0}
2337
+
2338
+ return params
2339
+
2340
+ def _get_default_medium_config(self, medium_type: str) -> dict[str, Any]:
2341
+ """Get a default medium config dict for a given type."""
2342
+ # Predefined defaults
2343
+ predefined = {
2344
+ "vacuum": {"type": "vacuum", "refractive_index": 1.0},
2345
+ "air": {"type": "air", "refractive_index": 1.000293},
2346
+ "water": {"type": "water", "refractive_index": 1.333},
2347
+ "glass": {"type": "glass", "refractive_index": 1.5},
2348
+ }
2349
+
2350
+ if medium_type in predefined:
2351
+ return predefined[medium_type].copy()
2352
+
2353
+ # For other types, get params via introspection
2354
+ config = {"type": medium_type}
2355
+ config.update(self._get_medium_params(medium_type))
2356
+ return config
2357
+
2358
+ def _on_customize_medium(
2359
+ self, surface_index: int, side: str, medium_config: dict[str, Any]
2360
+ ) -> None:
2361
+ """Open a popup to customize medium parameters.
2362
+
2363
+ Args:
2364
+ surface_index: Index of the surface
2365
+ side: "front" or "back"
2366
+ medium_config: Current medium configuration dict
2367
+ """
2368
+ medium_type = medium_config.get("type", "vacuum")
2369
+ title = f"Customize {side.title()} Medium ({medium_type})"
2370
+
2371
+ # Get parameters for this medium type
2372
+ params = self._get_medium_params(medium_type)
2373
+
2374
+ # Create unique popup tag
2375
+ popup_tag = f"medium_popup_{surface_index}_{side}"
2376
+
2377
+ # Delete existing popup if any
2378
+ if dpg.does_item_exist(popup_tag):
2379
+ dpg.delete_item(popup_tag)
2380
+
2381
+ # Store temp values for editing
2382
+ temp_values = {}
2383
+ for param_name, default_val in params.items():
2384
+ temp_values[param_name] = medium_config.get(param_name, default_val)
2385
+
2386
+ def on_ok():
2387
+ # Apply temp values to the surface config
2388
+ if 0 <= surface_index < len(self._surfaces):
2389
+ medium_key = f"{side}_medium"
2390
+ new_config = {"type": medium_type}
2391
+ new_config.update(temp_values)
2392
+ self._surfaces[surface_index][medium_key] = new_config
2393
+ self._build_and_load_config()
2394
+ dpg.delete_item(popup_tag)
2395
+
2396
+ def on_cancel():
2397
+ dpg.delete_item(popup_tag)
2398
+
2399
+ def make_scalar_callback(param_name: str):
2400
+ def callback(sender, value):
2401
+ temp_values[param_name] = value
2402
+
2403
+ return callback
2404
+
2405
+ def make_vec_callback(param_name: str, component: int):
2406
+ def callback(sender, value):
2407
+ if param_name not in temp_values:
2408
+ temp_values[param_name] = [0.0, 0.0, 0.0]
2409
+ temp_values[param_name][component] = value
2410
+
2411
+ return callback
2412
+
2413
+ with dpg.window(
2414
+ label=title,
2415
+ modal=True,
2416
+ tag=popup_tag,
2417
+ width=400,
2418
+ height=300,
2419
+ pos=[200, 150],
2420
+ no_resize=True,
2421
+ ):
2422
+ # Create parameter inputs
2423
+ for param_name, default_val in params.items():
2424
+ current_val = temp_values.get(param_name, default_val)
2425
+ label = param_name.replace("_", " ").title()
2426
+
2427
+ if isinstance(current_val, list) and len(current_val) == 3:
2428
+ # 3D vector
2429
+ vec_labels = ["X", "Y", "Z"]
2430
+ for j in range(3):
2431
+ with dpg.group(horizontal=True):
2432
+ dpg.add_text(
2433
+ f"{label} {vec_labels[j]}:", color=(180, 180, 180)
2434
+ )
2435
+ dpg.add_input_float(
2436
+ default_value=current_val[j],
2437
+ callback=make_vec_callback(param_name, j),
2438
+ width=-1,
2439
+ format="%.6g",
2440
+ )
2441
+ elif isinstance(current_val, list) and len(current_val) == 2:
2442
+ # 2D vector (range)
2443
+ vec_labels = ["Min", "Max"]
2444
+ for j in range(2):
2445
+ with dpg.group(horizontal=True):
2446
+ dpg.add_text(
2447
+ f"{label} {vec_labels[j]}:", color=(180, 180, 180)
2448
+ )
2449
+ dpg.add_input_float(
2450
+ default_value=current_val[j],
2451
+ callback=make_vec_callback(param_name, j),
2452
+ width=-1,
2453
+ format="%.6g",
2454
+ )
2455
+ elif isinstance(current_val, bool):
2456
+ with dpg.group(horizontal=True):
2457
+ dpg.add_text(f"{label}:", color=(180, 180, 180))
2458
+ dpg.add_checkbox(
2459
+ default_value=current_val,
2460
+ callback=make_scalar_callback(param_name),
2461
+ )
2462
+ elif isinstance(current_val, int) and not isinstance(current_val, bool):
2463
+ with dpg.group(horizontal=True):
2464
+ dpg.add_text(f"{label}:", color=(180, 180, 180))
2465
+ dpg.add_input_int(
2466
+ default_value=current_val,
2467
+ callback=make_scalar_callback(param_name),
2468
+ width=-1,
2469
+ )
2470
+ elif isinstance(current_val, (int, float)):
2471
+ fmt = (
2472
+ "%.6g"
2473
+ if abs(current_val) > 10000
2474
+ or (current_val != 0 and abs(current_val) < 0.001)
2475
+ else "%.6f"
2476
+ )
2477
+ with dpg.group(horizontal=True):
2478
+ dpg.add_text(f"{label}:", color=(180, 180, 180))
2479
+ dpg.add_input_float(
2480
+ default_value=float(current_val),
2481
+ callback=make_scalar_callback(param_name),
2482
+ width=-1,
2483
+ format=fmt,
2484
+ )
2485
+
2486
+ dpg.add_separator()
2487
+
2488
+ # OK/Cancel buttons
2489
+ with dpg.group(horizontal=True):
2490
+ dpg.add_button(label="OK", callback=on_ok, width=100)
2491
+ dpg.add_spacer(width=10)
2492
+ dpg.add_button(label="Cancel", callback=on_cancel, width=100)
2493
+
2494
+ def _on_surface_front_medium_type_change(self, sender, app_data, user_data) -> None:
2495
+ """Handle surface front medium type dropdown change."""
2496
+ surface_index = user_data
2497
+ if 0 <= surface_index < len(self._surfaces):
2498
+ new_type = app_data
2499
+ # Create new medium config with defaults for this type
2500
+ new_config = self._get_default_medium_config(new_type)
2501
+ self._surfaces[surface_index]["front_medium"] = new_config
2502
+ self._build_and_load_config()
2503
+
2504
+ def _on_surface_back_medium_type_change(self, sender, app_data, user_data) -> None:
2505
+ """Handle surface back medium type dropdown change."""
2506
+ surface_index = user_data
2507
+ if 0 <= surface_index < len(self._surfaces):
2508
+ new_type = app_data
2509
+ # Create new medium config with defaults for this type
2510
+ new_config = self._get_default_medium_config(new_type)
2511
+ self._surfaces[surface_index]["back_medium"] = new_config
2512
+ self._build_and_load_config()
2513
+
2514
+ def _on_customize_front_medium_btn(self, sender, app_data, user_data) -> None:
2515
+ """Handle customize front medium button click."""
2516
+ surface_index = user_data
2517
+ if 0 <= surface_index < len(self._surfaces):
2518
+ medium_config = self._surfaces[surface_index].get(
2519
+ "front_medium", {"type": "vacuum"}
2520
+ )
2521
+ # Ensure it's a dict
2522
+ if isinstance(medium_config, str):
2523
+ medium_config = self._get_default_medium_config(medium_config)
2524
+ self._on_customize_medium(surface_index, "front", medium_config)
2525
+
2526
+ def _on_customize_back_medium_btn(self, sender, app_data, user_data) -> None:
2527
+ """Handle customize back medium button click."""
2528
+ surface_index = user_data
2529
+ if 0 <= surface_index < len(self._surfaces):
2530
+ medium_config = self._surfaces[surface_index].get(
2531
+ "back_medium", {"type": "vacuum"}
2532
+ )
2533
+ # Ensure it's a dict
2534
+ if isinstance(medium_config, str):
2535
+ medium_config = self._get_default_medium_config(medium_config)
2536
+ self._on_customize_medium(surface_index, "back", medium_config)
2537
+
2538
+ # === Geometry Validation ===
2539
+
2540
+ def _on_check_geometry(self) -> None:
2541
+ """Validate geometry and show warnings."""
2542
+ warnings = []
2543
+
2544
+ # Check all optical surfaces have media
2545
+ for i, surf in enumerate(self._surfaces):
2546
+ if surf.get("role") == "optical":
2547
+ front = surf.get("front_medium")
2548
+ back = surf.get("back_medium")
2549
+
2550
+ if not front:
2551
+ warnings.append(
2552
+ f"Surface '{surf.get('name', f'surface_{i}')}' missing front medium"
2553
+ )
2554
+ if not back:
2555
+ warnings.append(
2556
+ f"Surface '{surf.get('name', f'surface_{i}')}' missing back medium"
2557
+ )
2558
+
2559
+ # Create popup to show results
2560
+ popup_tag = "check_geometry_popup"
2561
+ if dpg.does_item_exist(popup_tag):
2562
+ dpg.delete_item(popup_tag)
2563
+
2564
+ def on_close():
2565
+ dpg.delete_item(popup_tag)
2566
+
2567
+ with dpg.window(
2568
+ label="Geometry Check",
2569
+ modal=True,
2570
+ tag=popup_tag,
2571
+ width=400,
2572
+ height=200,
2573
+ pos=[200, 150],
2574
+ no_resize=True,
2575
+ ):
2576
+ if warnings:
2577
+ dpg.add_text("Warnings:", color=(255, 200, 100))
2578
+ dpg.add_separator()
2579
+ for warning in warnings:
2580
+ dpg.add_text(f" \u26a0 {warning}", color=(255, 200, 100))
2581
+ else:
2582
+ dpg.add_text("\u2713 Geometry OK", color=(100, 255, 100))
2583
+ dpg.add_separator()
2584
+ dpg.add_text(
2585
+ f" {len(self._surfaces)} surface(s) configured",
2586
+ color=(150, 150, 150),
2587
+ )
2588
+ dpg.add_text(
2589
+ f" {len(self._detectors)} detector(s) configured",
2590
+ color=(150, 150, 150),
2591
+ )
2592
+
2593
+ dpg.add_separator()
2594
+ dpg.add_button(label="Close", callback=on_close, width=-1)
2595
+
2596
+ # === Build and Export ===
2597
+
2598
+ def _build_and_load_config(self) -> None:
2599
+ """Build geometry and source from current config and load into scene."""
2600
+ try:
2601
+ geometry = self._build_geometry()
2602
+ if geometry:
2603
+ print(
2604
+ f"[GUI] Built geometry with {len(geometry.surfaces)} surfaces, {len(geometry.detectors)} detectors"
2605
+ )
2606
+ self.scene.load_geometry(geometry)
2607
+ else:
2608
+ print("[GUI] No geometry built (no surfaces/detectors)")
2609
+
2610
+ source = self._build_source()
2611
+ if source:
2612
+ print(f"[GUI] Built source: {type(source).__name__}")
2613
+ self.scene.load_source(source)
2614
+ else:
2615
+ print("[GUI] No source built")
2616
+
2617
+ if self.on_config_change:
2618
+ self.on_config_change()
2619
+
2620
+ except Exception as e:
2621
+ import traceback
2622
+
2623
+ print(f"Error building config: {e}")
2624
+ traceback.print_exc()
2625
+
2626
+ def _build_geometry(self):
2627
+ """Build a Geometry object from current config."""
2628
+ from lsurf.geometry import GeometryBuilder
2629
+ from lsurf.materials import AIR_STP, BK7_GLASS, VACUUM, WATER
2630
+ from lsurf.surfaces import SurfaceRole
2631
+
2632
+ builder = GeometryBuilder()
2633
+
2634
+ # Register predefined materials for simple type names
2635
+ predefined_materials = {
2636
+ "vacuum": VACUUM,
2637
+ "air": AIR_STP,
2638
+ "air_stp": AIR_STP,
2639
+ "water": WATER,
2640
+ "glass": BK7_GLASS,
2641
+ "bk7_glass": BK7_GLASS,
2642
+ "bk7": BK7_GLASS,
2643
+ }
2644
+
2645
+ for name, material in predefined_materials.items():
2646
+ builder.register_medium(name, material)
2647
+
2648
+ # Always use vacuum as background
2649
+ builder.set_background("vacuum")
2650
+
2651
+ # Add surfaces - build materials inline from surface configs
2652
+ medium_counter = 0
2653
+ for surf_config in self._surfaces:
2654
+ role_str = surf_config.get("role", "optical")
2655
+ if role_str == "optical":
2656
+ role = SurfaceRole.OPTICAL
2657
+ else:
2658
+ role = SurfaceRole.ABSORBER
2659
+
2660
+ surface = self._create_surface(surf_config, role)
2661
+ if surface:
2662
+ # Get front/back medium configs (could be string or dict)
2663
+ front_config = surf_config.get("front_medium", {"type": "vacuum"})
2664
+ back_config = surf_config.get("back_medium", {"type": "vacuum"})
2665
+
2666
+ # Convert string to dict if needed
2667
+ if isinstance(front_config, str):
2668
+ front_config = {"type": front_config}
2669
+ if isinstance(back_config, str):
2670
+ back_config = {"type": back_config}
2671
+
2672
+ # Build and register materials with unique names
2673
+ front_name = self._register_inline_medium(
2674
+ builder, front_config, predefined_materials, medium_counter
2675
+ )
2676
+ medium_counter += 1
2677
+ back_name = self._register_inline_medium(
2678
+ builder, back_config, predefined_materials, medium_counter
2679
+ )
2680
+ medium_counter += 1
2681
+
2682
+ builder.add_surface(surface, front_name, back_name)
2683
+
2684
+ # Add detectors
2685
+ for det_config in self._detectors:
2686
+ detector = self._create_surface(det_config, SurfaceRole.DETECTOR)
2687
+ if detector:
2688
+ builder.add_detector(detector)
2689
+
2690
+ return builder.build()
2691
+
2692
+ def _register_inline_medium(
2693
+ self, builder, medium_config: dict, predefined: dict, counter: int
2694
+ ) -> str:
2695
+ """Register a medium from inline config and return its name.
2696
+
2697
+ For simple predefined types with no customizations, returns the predefined name.
2698
+ For customized materials, builds and registers them with a unique name.
2699
+ """
2700
+ medium_type = medium_config.get("type", "vacuum")
2701
+
2702
+ # Check if this is a simple predefined type with no customizations
2703
+ if medium_type in predefined:
2704
+ # Check if there are any non-default custom parameters
2705
+ default_config = self._get_default_medium_config(medium_type)
2706
+ has_customization = False
2707
+ for key, value in medium_config.items():
2708
+ if key == "type":
2709
+ continue
2710
+ if key in default_config and default_config[key] != value:
2711
+ has_customization = True
2712
+ break
2713
+
2714
+ if not has_customization:
2715
+ return medium_type
2716
+
2717
+ # Build custom material
2718
+ material = self._build_material(medium_config)
2719
+ if material:
2720
+ # Register with unique name
2721
+ unique_name = f"_inline_medium_{counter}"
2722
+ builder.register_medium(unique_name, material)
2723
+ return unique_name
2724
+
2725
+ # Fallback to vacuum
2726
+ return "vacuum"
2727
+
2728
+ def _create_surface(self, config: dict, role):
2729
+ """Create a surface from config dict dynamically."""
2730
+ import inspect
2731
+ import lsurf.surfaces as surf
2732
+
2733
+ surface_type = config["type"]
2734
+ name = config.get("name", "unnamed")
2735
+
2736
+ # Handle gpu_ prefix specially - strip it and prepend GPU to class name
2737
+ if surface_type.startswith("gpu_"):
2738
+ base_type = surface_type[4:] # Remove "gpu_" prefix
2739
+ class_name = "GPU" + self._snake_to_camel(base_type) + "Surface"
2740
+ gpu_class_name = class_name # Already GPU
2741
+ non_gpu_class_name = self._snake_to_camel(base_type) + "Surface"
2742
+ else:
2743
+ class_name = self._snake_to_camel(surface_type) + "Surface"
2744
+ gpu_class_name = "GPU" + class_name
2745
+ non_gpu_class_name = class_name
2746
+
2747
+ # Check if this is a wave surface
2748
+ is_wave = "wave" in surface_type.lower()
2749
+
2750
+ # For wave surfaces, prefer GPU version (has individual params instead of wave_params)
2751
+ if is_wave:
2752
+ candidates = [gpu_class_name, non_gpu_class_name]
2753
+ else:
2754
+ candidates = [class_name, gpu_class_name]
2755
+
2756
+ # Try to find the class
2757
+ surface_class = None
2758
+ for candidate in candidates:
2759
+ if hasattr(surf, candidate):
2760
+ surface_class = getattr(surf, candidate)
2761
+ break
2762
+
2763
+ if surface_class is None:
2764
+ print(f"[GUI] Unknown surface type: {surface_type} (tried {candidates})")
2765
+ return None
2766
+
2767
+ try:
2768
+ print(f"[GUI] Creating {surface_class.__name__} with config: {config}")
2769
+ # Get constructor signature
2770
+ sig = inspect.signature(surface_class.__init__)
2771
+
2772
+ # Build kwargs from config, matching parameter names
2773
+ kwargs = {"role": role, "name": name}
2774
+
2775
+ # Check if this class expects wave_params instead of individual parameters
2776
+ uses_wave_params = "wave_params" in sig.parameters
2777
+
2778
+ if uses_wave_params:
2779
+ # Build wave_params from individual config values
2780
+ wave_params = self._build_wave_params(config)
2781
+ if wave_params:
2782
+ kwargs["wave_params"] = wave_params
2783
+
2784
+ for param_name, param in sig.parameters.items():
2785
+ if param_name in ("self", "role", "name", "wave_params"):
2786
+ continue
2787
+
2788
+ # Check if we have this parameter in config
2789
+ if param_name in config:
2790
+ value = config[param_name]
2791
+ # Convert lists to tuples for vector params
2792
+ if isinstance(value, list):
2793
+ value = tuple(value)
2794
+ kwargs[param_name] = value
2795
+ elif param.default == inspect.Parameter.empty:
2796
+ # Required parameter not in config - try common mappings
2797
+ if param_name == "point" and "center" in config:
2798
+ kwargs[param_name] = tuple(config["center"])
2799
+ elif param_name == "center" and "point" in config:
2800
+ kwargs[param_name] = tuple(config["point"])
2801
+
2802
+ return surface_class(**kwargs)
2803
+
2804
+ except Exception as e:
2805
+ import traceback
2806
+
2807
+ print(f"Error creating surface {surface_type}: {e}")
2808
+ traceback.print_exc()
2809
+ return None
2810
+
2811
+ def _build_wave_params(self, config: dict):
2812
+ """Build a list of GerstnerWaveParams objects from config values.
2813
+
2814
+ Non-GPU wave surfaces expect a list of wave params for multiple wave components.
2815
+ The GUI edits a single wave, so we return a list with one element.
2816
+ """
2817
+ try:
2818
+ from lsurf.surfaces import GerstnerWaveParams
2819
+
2820
+ amplitude = config.get("amplitude", 1.0)
2821
+ wavelength = config.get("wavelength", 10.0)
2822
+ direction = config.get("direction", [1.0, 0.0])
2823
+ phase = config.get("phase", 0.0)
2824
+ steepness = config.get("steepness", 0.0)
2825
+
2826
+ if isinstance(direction, list):
2827
+ direction = tuple(direction)
2828
+
2829
+ wave_param = GerstnerWaveParams(
2830
+ amplitude=amplitude,
2831
+ wavelength=wavelength,
2832
+ direction=direction,
2833
+ phase=phase,
2834
+ steepness=steepness,
2835
+ )
2836
+ # Return as list since non-GPU wave surfaces expect list[GerstnerWaveParams]
2837
+ return [wave_param]
2838
+ except Exception as e:
2839
+ print(f"Error building wave params: {e}")
2840
+ return None
2841
+
2842
+ def _build_material(self, config: dict):
2843
+ """Build a material object from config dict dynamically."""
2844
+ import inspect
2845
+ import lsurf.materials as mat
2846
+
2847
+ material_type = config.get("type", "homogeneous_material")
2848
+
2849
+ # Convert snake_case to CamelCase class name
2850
+ class_name = self._snake_to_camel(material_type)
2851
+
2852
+ if not hasattr(mat, class_name):
2853
+ print(f"[GUI] Unknown material type: {material_type} (tried {class_name})")
2854
+ # Fallback to HomogeneousMaterial
2855
+ from lsurf.materials import HomogeneousMaterial
2856
+
2857
+ return HomogeneousMaterial(
2858
+ name=config.get("name", "custom"),
2859
+ refractive_index=config.get("refractive_index", 1.0),
2860
+ )
2861
+
2862
+ material_class = getattr(mat, class_name)
2863
+
2864
+ try:
2865
+ print(f"[GUI] Creating {class_name} with config: {config}")
2866
+ sig = inspect.signature(material_class.__init__)
2867
+
2868
+ # Build kwargs from config
2869
+ kwargs = {}
2870
+
2871
+ for param_name, param in sig.parameters.items():
2872
+ if param_name in ("self", "kernel", "propagator"):
2873
+ continue
2874
+
2875
+ if param_name in config:
2876
+ value = config[param_name]
2877
+ # Convert lists to tuples for vector params
2878
+ if isinstance(value, list):
2879
+ value = tuple(value)
2880
+ kwargs[param_name] = value
2881
+ elif param.default == inspect.Parameter.empty:
2882
+ # Required parameter - try to provide sensible default
2883
+ if param_name == "name":
2884
+ kwargs[param_name] = config.get("name", material_type)
2885
+ elif param_name == "refractive_index":
2886
+ kwargs[param_name] = 1.0
2887
+
2888
+ return material_class(**kwargs)
2889
+
2890
+ except Exception as e:
2891
+ import traceback
2892
+
2893
+ print(f"Error creating material {material_type}: {e}")
2894
+ traceback.print_exc()
2895
+ return None
2896
+
2897
+ def _build_source(self):
2898
+ """Build a RaySource from current config dynamically."""
2899
+ import inspect
2900
+ import lsurf.sources as src
2901
+
2902
+ source_type = self._source_config["type"]
2903
+
2904
+ # Map source type names to class names
2905
+ class_name_map = {
2906
+ "point": "PointSource",
2907
+ "collimated": "CollimatedBeam",
2908
+ "diverging": "DivergingBeam",
2909
+ "uniform_diverging": "UniformDivergingBeam",
2910
+ "gaussian": "GaussianBeam",
2911
+ }
2912
+
2913
+ # Get class name
2914
+ if source_type in class_name_map:
2915
+ class_name = class_name_map[source_type]
2916
+ else:
2917
+ # Try to construct class name
2918
+ class_name = self._snake_to_camel(source_type)
2919
+ if not class_name.endswith(("Source", "Beam")):
2920
+ class_name += "Source"
2921
+
2922
+ if not hasattr(src, class_name):
2923
+ print(f"Unknown source type: {source_type}")
2924
+ return None
2925
+
2926
+ source_class = getattr(src, class_name)
2927
+
2928
+ try:
2929
+ # Get constructor signature
2930
+ sig = inspect.signature(source_class.__init__)
2931
+
2932
+ # Build kwargs from config, matching parameter names
2933
+ kwargs = {}
2934
+
2935
+ # Common parameters
2936
+ kwargs["num_rays"] = self._source_config.get("num_rays", 10000)
2937
+ kwargs["wavelength"] = self._source_config.get("wavelength", 532e-9)
2938
+ kwargs["power"] = self._source_config.get("power", 1.0)
2939
+
2940
+ for param_name, param in sig.parameters.items():
2941
+ if param_name in ("self", "num_rays", "wavelength", "power"):
2942
+ continue
2943
+
2944
+ # Map common UI field "position" to various parameter names
2945
+ if param_name in ("position", "origin", "center", "waist_position"):
2946
+ value = self._source_config.get("position", [0, 0, 10])
2947
+ kwargs[param_name] = tuple(value)
2948
+ elif param_name in ("direction", "mean_direction"):
2949
+ value = self._source_config.get("direction", [0, 0, -1])
2950
+ kwargs[param_name] = tuple(value)
2951
+ elif param_name == "divergence_angle":
2952
+ # Convert degrees to radians
2953
+ value = self._source_config.get("divergence_angle", 10.0)
2954
+ kwargs[param_name] = np.radians(value)
2955
+ elif param_name in self._source_config:
2956
+ value = self._source_config[param_name]
2957
+ if isinstance(value, list):
2958
+ value = tuple(value)
2959
+ kwargs[param_name] = value
2960
+ elif param.default != inspect.Parameter.empty:
2961
+ # Use default from signature
2962
+ pass # Will use class default
2963
+ else:
2964
+ # Required parameter not found - try to provide sensible default
2965
+ if "radius" in param_name:
2966
+ kwargs[param_name] = 1.0
2967
+
2968
+ return source_class(**kwargs)
2969
+
2970
+ except Exception as e:
2971
+ print(f"Error creating source {source_type}: {e}")
2972
+ return None
2973
+
2974
+ def _export_to_yaml(self, filepath: str) -> None:
2975
+ """Export current configuration to YAML file (CLI-compatible format)."""
2976
+ import yaml
2977
+
2978
+ # Map GUI source types to CLI source types
2979
+ source_type_map = {
2980
+ "point": "point",
2981
+ "collimated": "collimated_beam",
2982
+ "diverging": "diverging_beam",
2983
+ "uniform_diverging": "diverging_beam",
2984
+ "gaussian": "gaussian_beam",
2985
+ }
2986
+
2987
+ # Map GUI detector types to CLI detector types
2988
+ detector_type_map = {
2989
+ "plane": "planar",
2990
+ "bounded_plane": "planar",
2991
+ "sphere": "spherical",
2992
+ }
2993
+
2994
+ def normalize_medium_type(medium_type: str) -> str:
2995
+ """Normalize GUI medium type to CLI-compatible type."""
2996
+ # Strip numeric suffixes like _0, _1, etc.
2997
+ import re
2998
+
2999
+ base_type = re.sub(r"_\d+$", "", medium_type)
3000
+
3001
+ # Map to CLI types
3002
+ type_map = {
3003
+ "air_stp": "air",
3004
+ "air": "air",
3005
+ "vacuum": "vacuum",
3006
+ "water": "water",
3007
+ "glass": "glass",
3008
+ "bk7_glass": "glass",
3009
+ "bk7": "glass",
3010
+ "homogeneous": "homogeneous",
3011
+ "exponential_atmosphere": "exponential_atmosphere",
3012
+ "duct_atmosphere": "duct_atmosphere",
3013
+ }
3014
+
3015
+ # Try exact match first
3016
+ if base_type in type_map:
3017
+ return type_map[base_type]
3018
+
3019
+ # Try prefix matching for variants
3020
+ for key, value in type_map.items():
3021
+ if base_type.startswith(key):
3022
+ return value
3023
+
3024
+ # Default: return as-is (may fail CLI validation)
3025
+ return base_type
3026
+
3027
+ # Separate simulation config from output config
3028
+ sim_config_for_export = {
3029
+ k: v
3030
+ for k, v in self._sim_config.items()
3031
+ if k not in ("output_directory", "output_prefix", "output_format")
3032
+ }
3033
+
3034
+ config = {
3035
+ "media": {
3036
+ "vacuum": {
3037
+ "type": "vacuum"
3038
+ }, # Always include vacuum as it's the background
3039
+ },
3040
+ "background": "vacuum",
3041
+ "surfaces": [],
3042
+ "detectors": [],
3043
+ "source": {},
3044
+ "simulation": sim_config_for_export,
3045
+ "output": {
3046
+ "directory": self._sim_config.get("output_directory", "./results"),
3047
+ "prefix": self._sim_config.get("output_prefix", "simulation"),
3048
+ "format": self._sim_config.get("output_format", "hdf5"),
3049
+ "save_statistics": True,
3050
+ },
3051
+ }
3052
+
3053
+ # Collect unique media and add to media section
3054
+ media_counter = 0
3055
+ media_name_map = {} # Map from (type, params_hash) to media name
3056
+
3057
+ def get_or_create_medium_name(medium_config: dict | str) -> str:
3058
+ """Get or create a unique medium name for this config."""
3059
+ nonlocal media_counter
3060
+
3061
+ if isinstance(medium_config, str):
3062
+ medium_type = medium_config
3063
+ params = {}
3064
+ else:
3065
+ medium_type = medium_config.get("type", "vacuum")
3066
+ # Filter out params that should use CLI defaults
3067
+ skip_params = {
3068
+ "type",
3069
+ "refractive_index",
3070
+ } # Let CLI use correct defaults
3071
+ params = {
3072
+ k: v for k, v in medium_config.items() if k not in skip_params
3073
+ }
3074
+
3075
+ # Map to CLI-compatible type
3076
+ cli_type = normalize_medium_type(medium_type)
3077
+
3078
+ # For simple predefined types, always use the type name (let CLI use defaults)
3079
+ if cli_type in ("vacuum", "air", "water", "glass"):
3080
+ if cli_type not in config["media"]:
3081
+ config["media"][cli_type] = {"type": cli_type}
3082
+ return cli_type
3083
+
3084
+ # Create a key for this medium config
3085
+ params_key = str(sorted(params.items()))
3086
+ cache_key = (cli_type, params_key)
3087
+
3088
+ if cache_key in media_name_map:
3089
+ return media_name_map[cache_key]
3090
+
3091
+ # Create new medium entry
3092
+ media_name = f"{cli_type}_{media_counter}"
3093
+ media_counter += 1
3094
+ config["media"][media_name] = {"type": cli_type, "params": params}
3095
+ media_name_map[cache_key] = media_name
3096
+ return media_name
3097
+
3098
+ # Add surfaces (CLI expects params in nested dict)
3099
+ for surf in self._surfaces:
3100
+ # Get medium names (strings for CLI)
3101
+ front_medium = surf.get("front_medium", {"type": "vacuum"})
3102
+ back_medium = surf.get("back_medium", {"type": "vacuum"})
3103
+ front_name = get_or_create_medium_name(front_medium)
3104
+ back_name = get_or_create_medium_name(back_medium)
3105
+
3106
+ # Collect surface params (everything except type/name/role/media)
3107
+ skip_keys = {"type", "name", "role", "front_medium", "back_medium"}
3108
+ params = {k: v for k, v in surf.items() if k not in skip_keys}
3109
+
3110
+ surf_config = {
3111
+ "name": surf.get("name", "surface"),
3112
+ "type": surf["type"],
3113
+ "role": surf.get("role", "optical"),
3114
+ "front_medium": front_name,
3115
+ "back_medium": back_name,
3116
+ "params": params,
3117
+ }
3118
+ config["surfaces"].append(surf_config)
3119
+
3120
+ # Add detectors (CLI uses different type names and param names)
3121
+ for det in self._detectors:
3122
+ det_type = det["type"]
3123
+ cli_type = detector_type_map.get(det_type, "planar")
3124
+
3125
+ # Build detector params with correct CLI names
3126
+ if cli_type == "planar":
3127
+ params = {
3128
+ "center": det.get("point", det.get("center", [0, 0, 0])),
3129
+ "normal": det.get("normal", [0, 0, 1]),
3130
+ "width": det.get("width", 10.0),
3131
+ "height": det.get("height", 10.0),
3132
+ }
3133
+ elif cli_type == "spherical":
3134
+ params = {
3135
+ "center": det.get("center", [0, 0, 0]),
3136
+ "radius": det.get("radius", 1.0),
3137
+ }
3138
+ else:
3139
+ skip_keys = {"type", "name"}
3140
+ params = {k: v for k, v in det.items() if k not in skip_keys}
3141
+
3142
+ det_config = {
3143
+ "name": det.get("name", "detector"),
3144
+ "type": cli_type,
3145
+ "params": params,
3146
+ }
3147
+ config["detectors"].append(det_config)
3148
+
3149
+ # Add source (CLI uses different type names and nested params)
3150
+ source_type = self._source_config["type"]
3151
+ cli_source_type = source_type_map.get(source_type, source_type)
3152
+
3153
+ source_params = {
3154
+ "num_rays": self._source_config.get("num_rays", 10000),
3155
+ "wavelength": self._source_config.get("wavelength", 532e-9),
3156
+ "power": self._source_config.get("power", 1.0),
3157
+ }
3158
+
3159
+ if source_type == "point":
3160
+ source_params["position"] = self._source_config.get("position", [0, 0, 10])
3161
+ elif source_type == "collimated":
3162
+ source_params["center"] = self._source_config.get("position", [0, 0, 10])
3163
+ source_params["direction"] = self._source_config.get(
3164
+ "direction", [0, 0, -1]
3165
+ )
3166
+ source_params["beam_radius"] = self._source_config.get("radius", 1.0)
3167
+ elif source_type == "gaussian":
3168
+ source_params["waist_position"] = self._source_config.get(
3169
+ "position", [0, 0, 10]
3170
+ )
3171
+ source_params["direction"] = self._source_config.get(
3172
+ "direction", [0, 0, -1]
3173
+ )
3174
+ source_params["waist_radius"] = self._source_config.get(
3175
+ "waist_radius", 0.001
3176
+ )
3177
+ elif source_type in ("diverging", "uniform_diverging"):
3178
+ source_params["origin"] = self._source_config.get("position", [0, 0, 10])
3179
+ source_params["mean_direction"] = self._source_config.get(
3180
+ "direction", [0, 0, -1]
3181
+ )
3182
+ # Convert degrees to radians for CLI (sources expect radians)
3183
+ divergence_deg = self._source_config.get("divergence_angle", 10.0)
3184
+ source_params["divergence_angle"] = float(np.radians(divergence_deg))
3185
+
3186
+ config["source"] = {
3187
+ "type": cli_source_type,
3188
+ "params": source_params,
3189
+ }
3190
+
3191
+ with open(filepath, "w") as f:
3192
+ yaml.dump(config, f, default_flow_style=False, sort_keys=False)
3193
+
3194
+ # Also save a copy to a known location for debugging
3195
+ debug_path = "/tmp/lsurf_gui_last_export.yaml"
3196
+ with open(debug_path, "w") as f:
3197
+ yaml.dump(config, f, default_flow_style=False, sort_keys=False)
3198
+
3199
+ self._last_export_debug = f"Config saved to: {debug_path}"