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,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()
|