lsurf 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. lsurf/__init__.py +471 -0
  2. lsurf/analysis/__init__.py +107 -0
  3. lsurf/analysis/healpix_utils.py +418 -0
  4. lsurf/analysis/sphere_viz.py +1280 -0
  5. lsurf/cli/__init__.py +48 -0
  6. lsurf/cli/build.py +398 -0
  7. lsurf/cli/config_schema.py +318 -0
  8. lsurf/cli/gui_cmd.py +76 -0
  9. lsurf/cli/interactive.py +850 -0
  10. lsurf/cli/main.py +81 -0
  11. lsurf/cli/run.py +806 -0
  12. lsurf/detectors/__init__.py +266 -0
  13. lsurf/detectors/analysis.py +289 -0
  14. lsurf/detectors/base.py +284 -0
  15. lsurf/detectors/constant_size_rings.py +485 -0
  16. lsurf/detectors/directional.py +45 -0
  17. lsurf/detectors/extended/__init__.py +73 -0
  18. lsurf/detectors/extended/local_sphere.py +353 -0
  19. lsurf/detectors/extended/recording_sphere.py +368 -0
  20. lsurf/detectors/planar.py +45 -0
  21. lsurf/detectors/protocol.py +187 -0
  22. lsurf/detectors/recording_spheres.py +63 -0
  23. lsurf/detectors/results.py +1140 -0
  24. lsurf/detectors/small/__init__.py +79 -0
  25. lsurf/detectors/small/directional.py +330 -0
  26. lsurf/detectors/small/planar.py +401 -0
  27. lsurf/detectors/small/spherical.py +450 -0
  28. lsurf/detectors/spherical.py +45 -0
  29. lsurf/geometry/__init__.py +199 -0
  30. lsurf/geometry/builder.py +478 -0
  31. lsurf/geometry/cell.py +228 -0
  32. lsurf/geometry/cell_geometry.py +247 -0
  33. lsurf/geometry/detector_arrays.py +1785 -0
  34. lsurf/geometry/geometry.py +222 -0
  35. lsurf/geometry/surface_analysis.py +375 -0
  36. lsurf/geometry/validation.py +91 -0
  37. lsurf/gui/__init__.py +51 -0
  38. lsurf/gui/app.py +903 -0
  39. lsurf/gui/core/__init__.py +39 -0
  40. lsurf/gui/core/scene.py +343 -0
  41. lsurf/gui/core/simulation.py +264 -0
  42. lsurf/gui/renderers/__init__.py +40 -0
  43. lsurf/gui/renderers/ray_renderer.py +353 -0
  44. lsurf/gui/renderers/source_renderer.py +505 -0
  45. lsurf/gui/renderers/surface_renderer.py +477 -0
  46. lsurf/gui/views/__init__.py +48 -0
  47. lsurf/gui/views/config_editor.py +3199 -0
  48. lsurf/gui/views/properties.py +257 -0
  49. lsurf/gui/views/results.py +291 -0
  50. lsurf/gui/views/scene_tree.py +180 -0
  51. lsurf/gui/views/viewport_3d.py +555 -0
  52. lsurf/gui/views/visualizations.py +712 -0
  53. lsurf/materials/__init__.py +169 -0
  54. lsurf/materials/base/__init__.py +64 -0
  55. lsurf/materials/base/full_inhomogeneous.py +208 -0
  56. lsurf/materials/base/grid_inhomogeneous.py +319 -0
  57. lsurf/materials/base/homogeneous.py +342 -0
  58. lsurf/materials/base/material_field.py +527 -0
  59. lsurf/materials/base/simple_inhomogeneous.py +418 -0
  60. lsurf/materials/base/spectral_inhomogeneous.py +497 -0
  61. lsurf/materials/implementations/__init__.py +120 -0
  62. lsurf/materials/implementations/data/alpha_values_typical_atmosphere_updated.txt +24 -0
  63. lsurf/materials/implementations/duct_atmosphere.py +390 -0
  64. lsurf/materials/implementations/exponential_atmosphere.py +435 -0
  65. lsurf/materials/implementations/gaussian_lens.py +120 -0
  66. lsurf/materials/implementations/interpolated_data.py +123 -0
  67. lsurf/materials/implementations/layered_atmosphere.py +134 -0
  68. lsurf/materials/implementations/linear_gradient.py +109 -0
  69. lsurf/materials/implementations/linsley_atmosphere.py +764 -0
  70. lsurf/materials/implementations/standard_materials.py +126 -0
  71. lsurf/materials/implementations/turbulent_atmosphere.py +135 -0
  72. lsurf/materials/implementations/us_standard_atmosphere.py +149 -0
  73. lsurf/materials/utils/__init__.py +77 -0
  74. lsurf/materials/utils/constants.py +45 -0
  75. lsurf/materials/utils/device_functions.py +117 -0
  76. lsurf/materials/utils/dispersion.py +160 -0
  77. lsurf/materials/utils/factories.py +142 -0
  78. lsurf/propagation/__init__.py +91 -0
  79. lsurf/propagation/detector_gpu.py +67 -0
  80. lsurf/propagation/gpu_device_rays.py +294 -0
  81. lsurf/propagation/kernels/__init__.py +175 -0
  82. lsurf/propagation/kernels/absorption/__init__.py +61 -0
  83. lsurf/propagation/kernels/absorption/grid.py +240 -0
  84. lsurf/propagation/kernels/absorption/simple.py +232 -0
  85. lsurf/propagation/kernels/absorption/spectral.py +410 -0
  86. lsurf/propagation/kernels/detection/__init__.py +64 -0
  87. lsurf/propagation/kernels/detection/protocol.py +102 -0
  88. lsurf/propagation/kernels/detection/spherical.py +255 -0
  89. lsurf/propagation/kernels/device_functions.py +790 -0
  90. lsurf/propagation/kernels/fresnel/__init__.py +64 -0
  91. lsurf/propagation/kernels/fresnel/protocol.py +97 -0
  92. lsurf/propagation/kernels/fresnel/standard.py +258 -0
  93. lsurf/propagation/kernels/intersection/__init__.py +79 -0
  94. lsurf/propagation/kernels/intersection/annular_plane.py +207 -0
  95. lsurf/propagation/kernels/intersection/bounded_plane.py +205 -0
  96. lsurf/propagation/kernels/intersection/plane.py +166 -0
  97. lsurf/propagation/kernels/intersection/protocol.py +95 -0
  98. lsurf/propagation/kernels/intersection/signed_distance.py +742 -0
  99. lsurf/propagation/kernels/intersection/sphere.py +190 -0
  100. lsurf/propagation/kernels/propagation/__init__.py +85 -0
  101. lsurf/propagation/kernels/propagation/grid.py +527 -0
  102. lsurf/propagation/kernels/propagation/protocol.py +105 -0
  103. lsurf/propagation/kernels/propagation/simple.py +460 -0
  104. lsurf/propagation/kernels/propagation/spectral.py +875 -0
  105. lsurf/propagation/kernels/registry.py +331 -0
  106. lsurf/propagation/kernels/surface/__init__.py +72 -0
  107. lsurf/propagation/kernels/surface/bisection.py +232 -0
  108. lsurf/propagation/kernels/surface/detection.py +402 -0
  109. lsurf/propagation/kernels/surface/reduction.py +166 -0
  110. lsurf/propagation/propagator_protocol.py +222 -0
  111. lsurf/propagation/propagators/__init__.py +101 -0
  112. lsurf/propagation/propagators/detector_handler.py +354 -0
  113. lsurf/propagation/propagators/factory.py +200 -0
  114. lsurf/propagation/propagators/fresnel_handler.py +305 -0
  115. lsurf/propagation/propagators/gpu_gradient.py +566 -0
  116. lsurf/propagation/propagators/gpu_surface_propagator.py +707 -0
  117. lsurf/propagation/propagators/gradient.py +429 -0
  118. lsurf/propagation/propagators/intersection_handler.py +327 -0
  119. lsurf/propagation/propagators/material_propagator.py +398 -0
  120. lsurf/propagation/propagators/signed_distance_handler.py +522 -0
  121. lsurf/propagation/propagators/spectral_gpu_gradient.py +553 -0
  122. lsurf/propagation/propagators/surface_interaction.py +616 -0
  123. lsurf/propagation/propagators/surface_propagator.py +719 -0
  124. lsurf/py.typed +1 -0
  125. lsurf/simulation/__init__.py +70 -0
  126. lsurf/simulation/config.py +164 -0
  127. lsurf/simulation/orchestrator.py +462 -0
  128. lsurf/simulation/result.py +299 -0
  129. lsurf/simulation/simulation.py +262 -0
  130. lsurf/sources/__init__.py +128 -0
  131. lsurf/sources/base.py +264 -0
  132. lsurf/sources/collimated.py +252 -0
  133. lsurf/sources/custom.py +409 -0
  134. lsurf/sources/diverging.py +228 -0
  135. lsurf/sources/gaussian.py +272 -0
  136. lsurf/sources/parallel_from_positions.py +197 -0
  137. lsurf/sources/point.py +172 -0
  138. lsurf/sources/uniform_diverging.py +258 -0
  139. lsurf/surfaces/__init__.py +184 -0
  140. lsurf/surfaces/cpu/__init__.py +50 -0
  141. lsurf/surfaces/cpu/curved_wave.py +463 -0
  142. lsurf/surfaces/cpu/gerstner_wave.py +381 -0
  143. lsurf/surfaces/cpu/wave_params.py +118 -0
  144. lsurf/surfaces/gpu/__init__.py +72 -0
  145. lsurf/surfaces/gpu/annular_plane.py +453 -0
  146. lsurf/surfaces/gpu/bounded_plane.py +390 -0
  147. lsurf/surfaces/gpu/curved_wave.py +483 -0
  148. lsurf/surfaces/gpu/gerstner_wave.py +377 -0
  149. lsurf/surfaces/gpu/multi_curved_wave.py +520 -0
  150. lsurf/surfaces/gpu/plane.py +299 -0
  151. lsurf/surfaces/gpu/recording_sphere.py +587 -0
  152. lsurf/surfaces/gpu/sphere.py +311 -0
  153. lsurf/surfaces/protocol.py +336 -0
  154. lsurf/surfaces/registry.py +373 -0
  155. lsurf/utilities/__init__.py +175 -0
  156. lsurf/utilities/detector_analysis.py +814 -0
  157. lsurf/utilities/fresnel.py +628 -0
  158. lsurf/utilities/interactions.py +1215 -0
  159. lsurf/utilities/propagation.py +602 -0
  160. lsurf/utilities/ray_data.py +532 -0
  161. lsurf/utilities/recording_sphere.py +745 -0
  162. lsurf/utilities/time_spread.py +463 -0
  163. lsurf/visualization/__init__.py +329 -0
  164. lsurf/visualization/absorption_plots.py +334 -0
  165. lsurf/visualization/atmospheric_plots.py +754 -0
  166. lsurf/visualization/common.py +348 -0
  167. lsurf/visualization/detector_plots.py +1350 -0
  168. lsurf/visualization/detector_sphere_plots.py +1173 -0
  169. lsurf/visualization/fresnel_plots.py +1061 -0
  170. lsurf/visualization/ocean_simulation_plots.py +999 -0
  171. lsurf/visualization/polarization_plots.py +916 -0
  172. lsurf/visualization/raytracing_plots.py +1521 -0
  173. lsurf/visualization/ring_detector_plots.py +1867 -0
  174. lsurf/visualization/time_spread_plots.py +531 -0
  175. lsurf-1.0.0.dist-info/METADATA +381 -0
  176. lsurf-1.0.0.dist-info/RECORD +180 -0
  177. lsurf-1.0.0.dist-info/WHEEL +5 -0
  178. lsurf-1.0.0.dist-info/entry_points.txt +2 -0
  179. lsurf-1.0.0.dist-info/licenses/LICENSE +32 -0
  180. lsurf-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,555 @@
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
+ """3D Viewport - renders the scene using Dear PyGui's drawing API.
35
+
36
+ Uses manual 3D projection to render surfaces, sources, and rays
37
+ in a 2D drawlist with orbit/pan/zoom camera controls.
38
+ """
39
+
40
+ from typing import TYPE_CHECKING
41
+
42
+ import numpy as np
43
+
44
+ if TYPE_CHECKING:
45
+ from ..core.scene import Scene, SceneObject
46
+
47
+ import dearpygui.dearpygui as dpg
48
+
49
+
50
+ class Camera:
51
+ """Orbit camera for 3D viewport."""
52
+
53
+ def __init__(self) -> None:
54
+ self.position = np.array([30.0, -50.0, 30.0], dtype=np.float32)
55
+ self.target = np.array([0.0, 0.0, 0.0], dtype=np.float32)
56
+ self.up = np.array([0.0, 0.0, 1.0], dtype=np.float32)
57
+ self.fov = 45.0
58
+ self.near = 0.1
59
+ self.far = 10000.0
60
+
61
+ # Interaction state
62
+ self._last_mouse_pos = None
63
+ self._orbit_sensitivity = 0.01
64
+ self._pan_sensitivity = 0.05
65
+ self._zoom_sensitivity = 0.1
66
+
67
+ def get_view_matrix(self) -> np.ndarray:
68
+ """Compute view matrix (camera transformation)."""
69
+ forward = self.target - self.position
70
+ forward = forward / np.linalg.norm(forward)
71
+
72
+ right = np.cross(forward, self.up)
73
+ right = right / np.linalg.norm(right)
74
+
75
+ up = np.cross(right, forward)
76
+
77
+ view = np.eye(4, dtype=np.float32)
78
+ view[0, :3] = right
79
+ view[1, :3] = up
80
+ view[2, :3] = -forward
81
+ view[:3, 3] = -np.array(
82
+ [
83
+ np.dot(right, self.position),
84
+ np.dot(up, self.position),
85
+ np.dot(-forward, self.position),
86
+ ]
87
+ )
88
+
89
+ return view
90
+
91
+ def get_projection_matrix(self, aspect: float) -> np.ndarray:
92
+ """Compute perspective projection matrix."""
93
+ fov_rad = np.radians(self.fov)
94
+ f = 1.0 / np.tan(fov_rad / 2)
95
+
96
+ proj = np.zeros((4, 4), dtype=np.float32)
97
+ proj[0, 0] = f / aspect
98
+ proj[1, 1] = f
99
+ proj[2, 2] = (self.far + self.near) / (self.near - self.far)
100
+ proj[2, 3] = (2 * self.far * self.near) / (self.near - self.far)
101
+ proj[3, 2] = -1
102
+
103
+ return proj
104
+
105
+ def orbit(self, dx: float, dy: float) -> None:
106
+ """Orbit camera around target."""
107
+ # Convert to spherical coordinates relative to target
108
+ offset = self.position - self.target
109
+ r = np.linalg.norm(offset)
110
+
111
+ if r < 1e-6:
112
+ return
113
+
114
+ # Current angles
115
+ theta = np.arctan2(offset[1], offset[0]) # Azimuth
116
+ phi = np.arccos(np.clip(offset[2] / r, -1, 1)) # Elevation
117
+
118
+ # Update angles
119
+ theta -= dx * self._orbit_sensitivity
120
+ phi = np.clip(phi - dy * self._orbit_sensitivity, 0.01, np.pi - 0.01)
121
+
122
+ # Convert back to Cartesian
123
+ self.position = self.target + r * np.array(
124
+ [
125
+ np.sin(phi) * np.cos(theta),
126
+ np.sin(phi) * np.sin(theta),
127
+ np.cos(phi),
128
+ ],
129
+ dtype=np.float32,
130
+ )
131
+
132
+ def pan(self, dx: float, dy: float) -> None:
133
+ """Pan camera (move target and position)."""
134
+ forward = self.target - self.position
135
+ forward = forward / np.linalg.norm(forward)
136
+ right = np.cross(forward, self.up)
137
+ right = right / np.linalg.norm(right)
138
+ up = np.cross(right, forward)
139
+
140
+ offset = (-dx * right + dy * up) * self._pan_sensitivity
141
+ self.position += offset
142
+ self.target += offset
143
+
144
+ def zoom(self, delta: float) -> None:
145
+ """Zoom camera (move toward/away from target)."""
146
+ direction = self.target - self.position
147
+ distance = np.linalg.norm(direction)
148
+
149
+ # Prevent getting too close or too far
150
+ new_distance = distance * (1 - delta * self._zoom_sensitivity)
151
+ new_distance = np.clip(new_distance, 1.0, 1000.0)
152
+
153
+ if distance > 1e-6:
154
+ direction = direction / distance
155
+ self.position = self.target - direction * new_distance
156
+
157
+ def fit_to_bounds(self, min_bounds: np.ndarray, max_bounds: np.ndarray) -> None:
158
+ """Adjust camera to fit bounds in view."""
159
+ center = (min_bounds + max_bounds) / 2
160
+ size = np.linalg.norm(max_bounds - min_bounds)
161
+ distance = max(size * 1.5, 10.0)
162
+
163
+ self.target = center.astype(np.float32)
164
+ self.position = center + np.array(
165
+ [distance * 0.5, -distance * 0.7, distance * 0.5], dtype=np.float32
166
+ )
167
+
168
+ def set_preset(self, preset: str) -> None:
169
+ """Set camera to a preset view."""
170
+ distance = np.linalg.norm(self.position - self.target)
171
+
172
+ if preset == "top":
173
+ self.position = self.target + np.array([0, 0, distance], dtype=np.float32)
174
+ self.up = np.array([0, 1, 0], dtype=np.float32)
175
+ elif preset == "front":
176
+ self.position = self.target + np.array([0, -distance, 0], dtype=np.float32)
177
+ self.up = np.array([0, 0, 1], dtype=np.float32)
178
+ elif preset == "side":
179
+ self.position = self.target + np.array([distance, 0, 0], dtype=np.float32)
180
+ self.up = np.array([0, 0, 1], dtype=np.float32)
181
+ elif preset == "isometric":
182
+ d = distance / np.sqrt(3)
183
+ self.position = self.target + np.array([d, -d, d], dtype=np.float32)
184
+ self.up = np.array([0, 0, 1], dtype=np.float32)
185
+
186
+
187
+ class Viewport3D:
188
+ """3D viewport panel using Dear PyGui drawing API."""
189
+
190
+ def __init__(self, scene: "Scene") -> None:
191
+ self.scene = scene
192
+ self.camera = Camera()
193
+
194
+ # UI state
195
+ self._drawlist_tag: int | None = None
196
+ self._window_tag: int | None = None
197
+ self._width = 1200 # Larger initial size
198
+ self._height = 800
199
+
200
+ # Interaction state
201
+ self._dragging = False
202
+ self._drag_button = None
203
+ self._last_mouse_pos = (0, 0)
204
+
205
+ # Colors
206
+ self._grid_color = (80, 80, 80, 100)
207
+ self._axis_colors = {
208
+ "x": (200, 50, 50, 200),
209
+ "y": (50, 200, 50, 200),
210
+ "z": (50, 50, 200, 200),
211
+ }
212
+
213
+ def create(self, parent: int | str) -> int:
214
+ """Create the viewport UI elements.
215
+
216
+ Args:
217
+ parent: Parent container tag
218
+
219
+ Returns:
220
+ The window tag
221
+ """
222
+ self._parent_tag = parent
223
+
224
+ # Toolbar
225
+ with dpg.group(horizontal=True, parent=parent):
226
+ dpg.add_button(
227
+ label="Fit",
228
+ callback=self._on_fit_to_scene,
229
+ )
230
+ dpg.add_button(
231
+ label="Top",
232
+ callback=lambda: self._on_preset("top"),
233
+ )
234
+ dpg.add_button(
235
+ label="Front",
236
+ callback=lambda: self._on_preset("front"),
237
+ )
238
+ dpg.add_button(
239
+ label="Side",
240
+ callback=lambda: self._on_preset("side"),
241
+ )
242
+ dpg.add_button(
243
+ label="Iso",
244
+ callback=lambda: self._on_preset("isometric"),
245
+ )
246
+
247
+ # Child window to contain the drawlist (allows size tracking)
248
+ # resizable_y allows dragging the bottom edge to resize viewport/results split
249
+ with dpg.child_window(
250
+ parent=parent,
251
+ tag="viewport_drawlist_container",
252
+ height=-120, # Leave space for results panel below
253
+ border=False,
254
+ no_scrollbar=True,
255
+ resizable_y=True,
256
+ ) as self._window_tag:
257
+ # Drawing canvas
258
+ self._drawlist_tag = dpg.add_drawlist(
259
+ width=self._width,
260
+ height=self._height,
261
+ tag="viewport_drawlist",
262
+ )
263
+ # Register scene change callback
264
+ self.scene.on_change(self.refresh)
265
+
266
+ return self._window_tag
267
+
268
+ def register_handlers(self) -> None:
269
+ """Register mouse handlers for camera control. Must be called outside container context."""
270
+ with dpg.handler_registry():
271
+ dpg.add_mouse_drag_handler(
272
+ button=dpg.mvMouseButton_Left,
273
+ callback=self._on_mouse_drag,
274
+ )
275
+ dpg.add_mouse_drag_handler(
276
+ button=dpg.mvMouseButton_Middle,
277
+ callback=self._on_mouse_drag,
278
+ )
279
+ dpg.add_mouse_drag_handler(
280
+ button=dpg.mvMouseButton_Right,
281
+ callback=self._on_mouse_drag,
282
+ )
283
+ dpg.add_mouse_wheel_handler(callback=self._on_mouse_wheel)
284
+
285
+ def refresh(self) -> None:
286
+ """Redraw the viewport."""
287
+ if self._drawlist_tag is None:
288
+ return
289
+
290
+ # Update size based on container
291
+ if self._window_tag and dpg.does_item_exist(self._window_tag):
292
+ # Get actual rendered size of the container
293
+ try:
294
+ rect = dpg.get_item_rect_size(self._window_tag)
295
+ if rect and rect[0] > 50 and rect[1] > 50:
296
+ new_width = max(int(rect[0]) - 10, 100)
297
+ new_height = max(int(rect[1]) - 10, 100)
298
+
299
+ # Update if size changed
300
+ if new_width != self._width or new_height != self._height:
301
+ self._width = new_width
302
+ self._height = new_height
303
+ dpg.configure_item(
304
+ self._drawlist_tag, width=self._width, height=self._height
305
+ )
306
+ except Exception:
307
+ pass
308
+
309
+ # Clear previous drawing
310
+ dpg.delete_item(self._drawlist_tag, children_only=True)
311
+
312
+ # Draw background
313
+ dpg.draw_rectangle(
314
+ (0, 0),
315
+ (self._width, self._height),
316
+ fill=(30, 30, 35, 255),
317
+ parent=self._drawlist_tag,
318
+ )
319
+
320
+ # Compute view-projection matrix
321
+ aspect = self._width / max(self._height, 1)
322
+ view = self.camera.get_view_matrix()
323
+ proj = self.camera.get_projection_matrix(aspect)
324
+ vp = proj @ view
325
+
326
+ # Draw grid
327
+ self._draw_grid(vp)
328
+
329
+ # Draw axes
330
+ self._draw_axes(vp)
331
+
332
+ # Draw scene objects
333
+ visible_objects = self.scene.get_visible_objects()
334
+ for obj in visible_objects:
335
+ self._draw_object(obj, vp)
336
+
337
+ def _project(
338
+ self, points: np.ndarray, vp: np.ndarray
339
+ ) -> tuple[np.ndarray, np.ndarray]:
340
+ """Project 3D points to 2D screen coordinates.
341
+
342
+ Args:
343
+ points: (N, 3) array of 3D points
344
+ vp: View-projection matrix (4x4)
345
+
346
+ Returns:
347
+ (screen_coords, visible_mask) where screen_coords is (N, 2)
348
+ """
349
+ if len(points) == 0:
350
+ return np.zeros((0, 2), dtype=np.float32), np.zeros(0, dtype=bool)
351
+
352
+ # Homogeneous coordinates
353
+ n = len(points)
354
+ homogeneous = np.ones((n, 4), dtype=np.float32)
355
+ homogeneous[:, :3] = points
356
+
357
+ # Transform
358
+ clip = (vp @ homogeneous.T).T
359
+
360
+ # Perspective divide
361
+ w = clip[:, 3]
362
+ visible = w > 0.01 # Behind camera check
363
+
364
+ # Avoid division by zero
365
+ w[~visible] = 1.0
366
+ ndc = clip[:, :3] / w[:, np.newaxis]
367
+
368
+ # Convert to screen coordinates
369
+ screen = np.zeros((n, 2), dtype=np.float32)
370
+ screen[:, 0] = (ndc[:, 0] + 1) * 0.5 * self._width
371
+ screen[:, 1] = (1 - ndc[:, 1]) * 0.5 * self._height # Flip Y
372
+
373
+ # Clip check
374
+ visible &= (ndc[:, 0] >= -2) & (ndc[:, 0] <= 2)
375
+ visible &= (ndc[:, 1] >= -2) & (ndc[:, 1] <= 2)
376
+ visible &= (ndc[:, 2] >= -1) & (ndc[:, 2] <= 1)
377
+
378
+ return screen, visible
379
+
380
+ def _draw_grid(self, vp: np.ndarray) -> None:
381
+ """Draw a reference grid on the XY plane."""
382
+ grid_size = 50
383
+ grid_step = 5
384
+ lines = []
385
+
386
+ for i in range(-grid_size, grid_size + 1, grid_step):
387
+ lines.append(([i, -grid_size, 0], [i, grid_size, 0]))
388
+ lines.append(([-grid_size, i, 0], [grid_size, i, 0]))
389
+
390
+ for start, end in lines:
391
+ points = np.array([start, end], dtype=np.float32)
392
+ screen, visible = self._project(points, vp)
393
+ if visible.all():
394
+ dpg.draw_line(
395
+ tuple(screen[0]),
396
+ tuple(screen[1]),
397
+ color=self._grid_color,
398
+ parent=self._drawlist_tag,
399
+ )
400
+
401
+ def _draw_axes(self, vp: np.ndarray) -> None:
402
+ """Draw coordinate axes at origin."""
403
+ axis_length = 5.0
404
+ origin = np.array([[0, 0, 0]], dtype=np.float32)
405
+
406
+ axes = [
407
+ (
408
+ np.array([[axis_length, 0, 0]], dtype=np.float32),
409
+ self._axis_colors["x"],
410
+ "X",
411
+ ),
412
+ (
413
+ np.array([[0, axis_length, 0]], dtype=np.float32),
414
+ self._axis_colors["y"],
415
+ "Y",
416
+ ),
417
+ (
418
+ np.array([[0, 0, axis_length]], dtype=np.float32),
419
+ self._axis_colors["z"],
420
+ "Z",
421
+ ),
422
+ ]
423
+
424
+ origin_screen, origin_visible = self._project(origin, vp)
425
+
426
+ for end_point, color, label in axes:
427
+ end_screen, end_visible = self._project(end_point, vp)
428
+ if origin_visible[0] and end_visible[0]:
429
+ dpg.draw_line(
430
+ tuple(origin_screen[0]),
431
+ tuple(end_screen[0]),
432
+ color=color,
433
+ thickness=2,
434
+ parent=self._drawlist_tag,
435
+ )
436
+ dpg.draw_text(
437
+ tuple(end_screen[0]),
438
+ label,
439
+ color=color,
440
+ size=24,
441
+ parent=self._drawlist_tag,
442
+ )
443
+
444
+ def _draw_object(self, obj: "SceneObject", vp: np.ndarray) -> None:
445
+ """Draw a scene object."""
446
+
447
+ if obj.vertices is None or len(obj.vertices) == 0:
448
+ return
449
+
450
+ vertices = obj.vertices
451
+ indices = obj.indices
452
+
453
+ # Project vertices
454
+ screen, visible = self._project(vertices, vp)
455
+
456
+ # Convert color to 0-255 range
457
+ color = tuple(int(c * 255) for c in obj.color[:3]) + (int(obj.color[3] * 255),)
458
+
459
+ # Highlight if selected
460
+ if obj.selected:
461
+ color = (255, 255, 100, 255)
462
+
463
+ if indices is None or len(indices) == 0:
464
+ return
465
+
466
+ draw_count = 0
467
+ if obj.wireframe:
468
+ # Draw as lines (pairs of indices)
469
+ for i in range(0, len(indices) - 1, 2):
470
+ i0, i1 = indices[i], indices[i + 1]
471
+ if i0 < len(visible) and i1 < len(visible):
472
+ if visible[i0] and visible[i1]:
473
+ dpg.draw_line(
474
+ tuple(screen[i0]),
475
+ tuple(screen[i1]),
476
+ color=color,
477
+ thickness=2,
478
+ parent=self._drawlist_tag,
479
+ )
480
+ draw_count += 1
481
+ else:
482
+ # Draw as triangles (triplets of indices)
483
+ for i in range(0, len(indices) - 2, 3):
484
+ i0, i1, i2 = indices[i], indices[i + 1], indices[i + 2]
485
+ if i0 < len(visible) and i1 < len(visible) and i2 < len(visible):
486
+ if visible[i0] and visible[i1] and visible[i2]:
487
+ dpg.draw_triangle(
488
+ tuple(screen[i0]),
489
+ tuple(screen[i1]),
490
+ tuple(screen[i2]),
491
+ fill=color,
492
+ parent=self._drawlist_tag,
493
+ )
494
+ draw_count += 1
495
+
496
+ def _on_mouse_drag(self, sender, app_data) -> None:
497
+ """Handle mouse drag for camera control."""
498
+ # app_data = (button, dx, dy)
499
+ button, dx, dy = app_data
500
+
501
+ # Check if mouse is over viewport
502
+ if not self._is_mouse_over_viewport():
503
+ return
504
+
505
+ if button == dpg.mvMouseButton_Left:
506
+ # Orbit
507
+ self.camera.orbit(dx, dy)
508
+ elif button == dpg.mvMouseButton_Middle:
509
+ # Pan
510
+ self.camera.pan(dx, dy)
511
+ elif button == dpg.mvMouseButton_Right:
512
+ # Zoom (vertical drag)
513
+ self.camera.zoom(dy * 0.01)
514
+
515
+ self.refresh()
516
+
517
+ def _on_mouse_wheel(self, sender, app_data) -> None:
518
+ """Handle mouse wheel for zoom."""
519
+ if not self._is_mouse_over_viewport():
520
+ return
521
+
522
+ self.camera.zoom(app_data)
523
+ self.refresh()
524
+
525
+ def _is_mouse_over_viewport(self) -> bool:
526
+ """Check if mouse is over the viewport."""
527
+ if self._drawlist_tag is None:
528
+ return False
529
+
530
+ mouse_pos = dpg.get_mouse_pos(local=False)
531
+ if not dpg.does_item_exist(self._drawlist_tag):
532
+ return False
533
+
534
+ # Get drawlist screen position
535
+ try:
536
+ item_pos = dpg.get_item_pos(self._drawlist_tag)
537
+ item_rect = dpg.get_item_rect_size(self._drawlist_tag)
538
+
539
+ return (
540
+ item_pos[0] <= mouse_pos[0] <= item_pos[0] + item_rect[0]
541
+ and item_pos[1] <= mouse_pos[1] <= item_pos[1] + item_rect[1]
542
+ )
543
+ except Exception:
544
+ return False
545
+
546
+ def _on_fit_to_scene(self) -> None:
547
+ """Fit camera to scene bounds."""
548
+ min_bounds, max_bounds = self.scene.get_bounds()
549
+ self.camera.fit_to_bounds(min_bounds, max_bounds)
550
+ self.refresh()
551
+
552
+ def _on_preset(self, preset: str) -> None:
553
+ """Set camera to preset view."""
554
+ self.camera.set_preset(preset)
555
+ self.refresh()