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.
- lsurf/__init__.py +471 -0
- lsurf/analysis/__init__.py +107 -0
- lsurf/analysis/healpix_utils.py +418 -0
- lsurf/analysis/sphere_viz.py +1280 -0
- lsurf/cli/__init__.py +48 -0
- lsurf/cli/build.py +398 -0
- lsurf/cli/config_schema.py +318 -0
- lsurf/cli/gui_cmd.py +76 -0
- lsurf/cli/interactive.py +850 -0
- lsurf/cli/main.py +81 -0
- lsurf/cli/run.py +806 -0
- lsurf/detectors/__init__.py +266 -0
- lsurf/detectors/analysis.py +289 -0
- lsurf/detectors/base.py +284 -0
- lsurf/detectors/constant_size_rings.py +485 -0
- lsurf/detectors/directional.py +45 -0
- lsurf/detectors/extended/__init__.py +73 -0
- lsurf/detectors/extended/local_sphere.py +353 -0
- lsurf/detectors/extended/recording_sphere.py +368 -0
- lsurf/detectors/planar.py +45 -0
- lsurf/detectors/protocol.py +187 -0
- lsurf/detectors/recording_spheres.py +63 -0
- lsurf/detectors/results.py +1140 -0
- lsurf/detectors/small/__init__.py +79 -0
- lsurf/detectors/small/directional.py +330 -0
- lsurf/detectors/small/planar.py +401 -0
- lsurf/detectors/small/spherical.py +450 -0
- lsurf/detectors/spherical.py +45 -0
- lsurf/geometry/__init__.py +199 -0
- lsurf/geometry/builder.py +478 -0
- lsurf/geometry/cell.py +228 -0
- lsurf/geometry/cell_geometry.py +247 -0
- lsurf/geometry/detector_arrays.py +1785 -0
- lsurf/geometry/geometry.py +222 -0
- lsurf/geometry/surface_analysis.py +375 -0
- lsurf/geometry/validation.py +91 -0
- lsurf/gui/__init__.py +51 -0
- lsurf/gui/app.py +903 -0
- lsurf/gui/core/__init__.py +39 -0
- lsurf/gui/core/scene.py +343 -0
- lsurf/gui/core/simulation.py +264 -0
- lsurf/gui/renderers/__init__.py +40 -0
- lsurf/gui/renderers/ray_renderer.py +353 -0
- lsurf/gui/renderers/source_renderer.py +505 -0
- lsurf/gui/renderers/surface_renderer.py +477 -0
- lsurf/gui/views/__init__.py +48 -0
- lsurf/gui/views/config_editor.py +3199 -0
- lsurf/gui/views/properties.py +257 -0
- lsurf/gui/views/results.py +291 -0
- lsurf/gui/views/scene_tree.py +180 -0
- lsurf/gui/views/viewport_3d.py +555 -0
- lsurf/gui/views/visualizations.py +712 -0
- lsurf/materials/__init__.py +169 -0
- lsurf/materials/base/__init__.py +64 -0
- lsurf/materials/base/full_inhomogeneous.py +208 -0
- lsurf/materials/base/grid_inhomogeneous.py +319 -0
- lsurf/materials/base/homogeneous.py +342 -0
- lsurf/materials/base/material_field.py +527 -0
- lsurf/materials/base/simple_inhomogeneous.py +418 -0
- lsurf/materials/base/spectral_inhomogeneous.py +497 -0
- lsurf/materials/implementations/__init__.py +120 -0
- lsurf/materials/implementations/data/alpha_values_typical_atmosphere_updated.txt +24 -0
- lsurf/materials/implementations/duct_atmosphere.py +390 -0
- lsurf/materials/implementations/exponential_atmosphere.py +435 -0
- lsurf/materials/implementations/gaussian_lens.py +120 -0
- lsurf/materials/implementations/interpolated_data.py +123 -0
- lsurf/materials/implementations/layered_atmosphere.py +134 -0
- lsurf/materials/implementations/linear_gradient.py +109 -0
- lsurf/materials/implementations/linsley_atmosphere.py +764 -0
- lsurf/materials/implementations/standard_materials.py +126 -0
- lsurf/materials/implementations/turbulent_atmosphere.py +135 -0
- lsurf/materials/implementations/us_standard_atmosphere.py +149 -0
- lsurf/materials/utils/__init__.py +77 -0
- lsurf/materials/utils/constants.py +45 -0
- lsurf/materials/utils/device_functions.py +117 -0
- lsurf/materials/utils/dispersion.py +160 -0
- lsurf/materials/utils/factories.py +142 -0
- lsurf/propagation/__init__.py +91 -0
- lsurf/propagation/detector_gpu.py +67 -0
- lsurf/propagation/gpu_device_rays.py +294 -0
- lsurf/propagation/kernels/__init__.py +175 -0
- lsurf/propagation/kernels/absorption/__init__.py +61 -0
- lsurf/propagation/kernels/absorption/grid.py +240 -0
- lsurf/propagation/kernels/absorption/simple.py +232 -0
- lsurf/propagation/kernels/absorption/spectral.py +410 -0
- lsurf/propagation/kernels/detection/__init__.py +64 -0
- lsurf/propagation/kernels/detection/protocol.py +102 -0
- lsurf/propagation/kernels/detection/spherical.py +255 -0
- lsurf/propagation/kernels/device_functions.py +790 -0
- lsurf/propagation/kernels/fresnel/__init__.py +64 -0
- lsurf/propagation/kernels/fresnel/protocol.py +97 -0
- lsurf/propagation/kernels/fresnel/standard.py +258 -0
- lsurf/propagation/kernels/intersection/__init__.py +79 -0
- lsurf/propagation/kernels/intersection/annular_plane.py +207 -0
- lsurf/propagation/kernels/intersection/bounded_plane.py +205 -0
- lsurf/propagation/kernels/intersection/plane.py +166 -0
- lsurf/propagation/kernels/intersection/protocol.py +95 -0
- lsurf/propagation/kernels/intersection/signed_distance.py +742 -0
- lsurf/propagation/kernels/intersection/sphere.py +190 -0
- lsurf/propagation/kernels/propagation/__init__.py +85 -0
- lsurf/propagation/kernels/propagation/grid.py +527 -0
- lsurf/propagation/kernels/propagation/protocol.py +105 -0
- lsurf/propagation/kernels/propagation/simple.py +460 -0
- lsurf/propagation/kernels/propagation/spectral.py +875 -0
- lsurf/propagation/kernels/registry.py +331 -0
- lsurf/propagation/kernels/surface/__init__.py +72 -0
- lsurf/propagation/kernels/surface/bisection.py +232 -0
- lsurf/propagation/kernels/surface/detection.py +402 -0
- lsurf/propagation/kernels/surface/reduction.py +166 -0
- lsurf/propagation/propagator_protocol.py +222 -0
- lsurf/propagation/propagators/__init__.py +101 -0
- lsurf/propagation/propagators/detector_handler.py +354 -0
- lsurf/propagation/propagators/factory.py +200 -0
- lsurf/propagation/propagators/fresnel_handler.py +305 -0
- lsurf/propagation/propagators/gpu_gradient.py +566 -0
- lsurf/propagation/propagators/gpu_surface_propagator.py +707 -0
- lsurf/propagation/propagators/gradient.py +429 -0
- lsurf/propagation/propagators/intersection_handler.py +327 -0
- lsurf/propagation/propagators/material_propagator.py +398 -0
- lsurf/propagation/propagators/signed_distance_handler.py +522 -0
- lsurf/propagation/propagators/spectral_gpu_gradient.py +553 -0
- lsurf/propagation/propagators/surface_interaction.py +616 -0
- lsurf/propagation/propagators/surface_propagator.py +719 -0
- lsurf/py.typed +1 -0
- lsurf/simulation/__init__.py +70 -0
- lsurf/simulation/config.py +164 -0
- lsurf/simulation/orchestrator.py +462 -0
- lsurf/simulation/result.py +299 -0
- lsurf/simulation/simulation.py +262 -0
- lsurf/sources/__init__.py +128 -0
- lsurf/sources/base.py +264 -0
- lsurf/sources/collimated.py +252 -0
- lsurf/sources/custom.py +409 -0
- lsurf/sources/diverging.py +228 -0
- lsurf/sources/gaussian.py +272 -0
- lsurf/sources/parallel_from_positions.py +197 -0
- lsurf/sources/point.py +172 -0
- lsurf/sources/uniform_diverging.py +258 -0
- lsurf/surfaces/__init__.py +184 -0
- lsurf/surfaces/cpu/__init__.py +50 -0
- lsurf/surfaces/cpu/curved_wave.py +463 -0
- lsurf/surfaces/cpu/gerstner_wave.py +381 -0
- lsurf/surfaces/cpu/wave_params.py +118 -0
- lsurf/surfaces/gpu/__init__.py +72 -0
- lsurf/surfaces/gpu/annular_plane.py +453 -0
- lsurf/surfaces/gpu/bounded_plane.py +390 -0
- lsurf/surfaces/gpu/curved_wave.py +483 -0
- lsurf/surfaces/gpu/gerstner_wave.py +377 -0
- lsurf/surfaces/gpu/multi_curved_wave.py +520 -0
- lsurf/surfaces/gpu/plane.py +299 -0
- lsurf/surfaces/gpu/recording_sphere.py +587 -0
- lsurf/surfaces/gpu/sphere.py +311 -0
- lsurf/surfaces/protocol.py +336 -0
- lsurf/surfaces/registry.py +373 -0
- lsurf/utilities/__init__.py +175 -0
- lsurf/utilities/detector_analysis.py +814 -0
- lsurf/utilities/fresnel.py +628 -0
- lsurf/utilities/interactions.py +1215 -0
- lsurf/utilities/propagation.py +602 -0
- lsurf/utilities/ray_data.py +532 -0
- lsurf/utilities/recording_sphere.py +745 -0
- lsurf/utilities/time_spread.py +463 -0
- lsurf/visualization/__init__.py +329 -0
- lsurf/visualization/absorption_plots.py +334 -0
- lsurf/visualization/atmospheric_plots.py +754 -0
- lsurf/visualization/common.py +348 -0
- lsurf/visualization/detector_plots.py +1350 -0
- lsurf/visualization/detector_sphere_plots.py +1173 -0
- lsurf/visualization/fresnel_plots.py +1061 -0
- lsurf/visualization/ocean_simulation_plots.py +999 -0
- lsurf/visualization/polarization_plots.py +916 -0
- lsurf/visualization/raytracing_plots.py +1521 -0
- lsurf/visualization/ring_detector_plots.py +1867 -0
- lsurf/visualization/time_spread_plots.py +531 -0
- lsurf-1.0.0.dist-info/METADATA +381 -0
- lsurf-1.0.0.dist-info/RECORD +180 -0
- lsurf-1.0.0.dist-info/WHEEL +5 -0
- lsurf-1.0.0.dist-info/entry_points.txt +2 -0
- lsurf-1.0.0.dist-info/licenses/LICENSE +32 -0
- 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}"
|