Skip to content

Commit 78cb357

Browse files
authored
Merge pull request #68 from makepath/feature/phase1-composition
Decompose InteractiveViewer into composed subsystem objects
2 parents c3f0a77 + b8cf839 commit 78cb357

12 files changed

Lines changed: 2016 additions & 637 deletions

rtxpy/engine.py

Lines changed: 1457 additions & 637 deletions
Large diffs are not rendered by default.

rtxpy/viewer/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# rtxpy.viewer — Composed subsystem objects for InteractiveViewer.

rtxpy/viewer/camera.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Camera state and projection helpers for the interactive viewer."""
2+
3+
import numpy as np
4+
5+
6+
class CameraState:
7+
"""Camera position, orientation, and projection parameters.
8+
9+
Encapsulates all camera-related state: position, yaw/pitch, FOV,
10+
movement/look speeds, and time-of-day presets for sun positioning.
11+
"""
12+
13+
__slots__ = (
14+
'position', 'yaw', 'pitch', 'fov',
15+
'move_speed', 'look_speed',
16+
'_time_presets', '_time_preset_idx',
17+
)
18+
19+
def __init__(self):
20+
self.position = None
21+
self.yaw = 90.0 # Degrees, 0 = +X, 90 = +Y
22+
self.pitch = -15.0 # Degrees, negative = looking down
23+
self.fov = 60.0
24+
self.move_speed = None # Set in run() based on terrain extent
25+
self.look_speed = 5.0
26+
self._time_presets = [
27+
('Morning', 135.0, 25.0),
28+
('Midday', 180.0, 65.0),
29+
('Afternoon', 225.0, 35.0),
30+
('Golden Hour', 270.0, 12.0),
31+
('Sunset', 280.0, 3.0),
32+
]
33+
self._time_preset_idx = 2 # Afternoon (default)
34+
35+
def get_front(self):
36+
"""Get the forward direction vector."""
37+
yaw_rad = np.radians(self.yaw)
38+
pitch_rad = np.radians(self.pitch)
39+
return np.array([
40+
np.cos(yaw_rad) * np.cos(pitch_rad),
41+
np.sin(yaw_rad) * np.cos(pitch_rad),
42+
np.sin(pitch_rad)
43+
], dtype=np.float32)
44+
45+
def get_right(self):
46+
"""Get the right direction vector."""
47+
front = self.get_front()
48+
world_up = np.array([0, 0, 1], dtype=np.float32)
49+
right = np.cross(world_up, front)
50+
return right / (np.linalg.norm(right) + 1e-8)
51+
52+
def get_look_at(self):
53+
"""Get the current look-at point."""
54+
return self.position + self.get_front() * 1000.0
55+
56+
def screen_to_ray(self, screen_x, screen_y, render_width, render_height,
57+
display_width, display_height):
58+
"""Convert screen pixel coordinates to a world-space ray.
59+
60+
Returns (origin, direction) as numpy float32 arrays of shape (3,).
61+
"""
62+
front = self.get_front()
63+
world_up = np.array([0, 0, 1], dtype=np.float32)
64+
right = np.cross(world_up, front)
65+
rn = np.linalg.norm(right)
66+
if rn > 1e-8:
67+
right /= rn
68+
else:
69+
right = np.array([1, 0, 0], dtype=np.float32)
70+
cam_up = np.cross(front, right)
71+
72+
fov_scale = np.tan(np.radians(self.fov) / 2.0)
73+
aspect = render_width / max(1, render_height)
74+
75+
# Window coords -> NDC (-1..1)
76+
nx = 2.0 * screen_x / max(1, display_width) - 1.0
77+
ny = 1.0 - 2.0 * screen_y / max(1, display_height)
78+
79+
direction = front + nx * fov_scale * aspect * right + ny * fov_scale * cam_up
80+
direction = direction / (np.linalg.norm(direction) + 1e-30)
81+
return self.position.copy(), direction.astype(np.float32)

rtxpy/viewer/geometry_layers.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Geometry layer visibility management for the interactive viewer."""
2+
3+
4+
class GeometryLayerManager:
5+
"""Tracks GAS geometry groups and layer cycling state.
6+
7+
Manages which geometry groups are visible, the current cycling
8+
index, per-layer positions for jump-to-geometry, and point-cloud
9+
color modes.
10+
"""
11+
12+
__slots__ = (
13+
'all_geometries', 'geometry_layer_order', 'geometry_layer_idx',
14+
'layer_positions', 'current_geom_idx',
15+
'pc_color_modes', 'pc_color_mode_idx',
16+
'chunk_manager', 'baked_meshes', 'geometry_colors_builder',
17+
)
18+
19+
def __init__(self):
20+
self.all_geometries = []
21+
self.geometry_layer_order = ['none', 'all']
22+
self.geometry_layer_idx = 1 # Start at 'all'
23+
self.layer_positions = {} # layer_name -> [(x, y, z, geometry_id), ...]
24+
self.current_geom_idx = 0
25+
self.pc_color_modes = ['elevation', 'intensity', 'classification', 'rgb']
26+
self.pc_color_mode_idx = 0
27+
self.chunk_manager = None # set by explore() when scene_zarr provided
28+
self.baked_meshes = {}
29+
self.geometry_colors_builder = None

rtxpy/viewer/hud.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""HUD (heads-up display) state for the interactive viewer."""
2+
3+
4+
class HUDState:
5+
"""Holds title, subtitle, legend, help pages, and minimap state.
6+
7+
Groups all on-screen overlay / HUD variables into a single object.
8+
"""
9+
10+
__slots__ = (
11+
'title', 'subtitle', 'legend_config', 'info_text',
12+
'title_overlay_rgba', 'legend_rgba',
13+
'help_page_idx', 'help_pages',
14+
'show_minimap',
15+
'last_title', 'last_subtitle',
16+
'minimap_background', 'minimap_scale_x', 'minimap_scale_y',
17+
'minimap_has_tiles', 'minimap_rect',
18+
'minimap_style', 'minimap_layer', 'minimap_colors',
19+
)
20+
21+
def __init__(self, title='rtxpy', subtitle=None, legend=None):
22+
self.title = title
23+
self.subtitle = subtitle
24+
self.legend_config = legend
25+
self.info_text = None
26+
self.title_overlay_rgba = None
27+
self.legend_rgba = None
28+
self.help_page_idx = 0 # -1 = off, 0..N-1 = page index
29+
self.help_pages = []
30+
self.show_minimap = True
31+
self.last_title = None
32+
self.last_subtitle = None
33+
34+
# Minimap state (initialized in run() via _compute_minimap_background)
35+
self.minimap_background = None
36+
self.minimap_scale_x = 1.0
37+
self.minimap_scale_y = 1.0
38+
self.minimap_has_tiles = False
39+
self.minimap_rect = None
40+
self.minimap_style = None
41+
self.minimap_layer = None
42+
self.minimap_colors = None

rtxpy/viewer/input_state.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Input state tracking for the interactive viewer."""
2+
3+
4+
class InputState:
5+
"""Tracks keyboard and mouse input state.
6+
7+
Holds the set of currently-pressed movement keys and mouse drag
8+
state, decoupled from the viewer's rendering logic.
9+
"""
10+
11+
__slots__ = ('held_keys', 'mouse_dragging', 'mouse_last_x', 'mouse_last_y')
12+
13+
def __init__(self):
14+
self.held_keys = set()
15+
self.mouse_dragging = False
16+
self.mouse_last_x = None
17+
self.mouse_last_y = None

rtxpy/viewer/keybindings.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Declarative key-binding tables for the interactive viewer.
2+
3+
Three dispatch tables map key events to method names on InteractiveViewer:
4+
5+
- ``SHIFT_BINDINGS``: Checked first. Maps uppercase ``raw_key`` (Shift
6+
held) to a method name. E.g. ``'O'`` → ``'_action_shift_o'``.
7+
- ``KEY_BINDINGS``: Checked second. Maps lowercase ``key`` to a method
8+
name. E.g. ``'t'`` → ``'_action_toggle_shadows'``.
9+
- ``SPECIAL_BINDINGS``: Checked last. Maps ``(raw_key, key)`` tuples
10+
for keys that need both values (e.g. ``r`` vs ``R``).
11+
12+
Movement keys (WASD, arrows, IJKL, Q/E, PageUp/Down) are tracked in
13+
``MOVEMENT_KEYS`` and handled separately by adding to ``_held_keys``.
14+
15+
Observer slots 1-8 are handled separately in ``_handle_key_press``.
16+
"""
17+
18+
# Keys that get added to _held_keys for continuous movement/look
19+
MOVEMENT_KEYS = frozenset({
20+
'w', 's', 'a', 'd',
21+
'up', 'down', 'left', 'right',
22+
'q', 'e', 'pageup', 'pagedown',
23+
'i', 'j', 'k', 'l',
24+
})
25+
26+
# Shift+<key> bindings — checked first (raw_key is uppercase)
27+
SHIFT_BINDINGS = {
28+
'O': '_action_shift_o', # Cycle drone mode
29+
'V': '_action_shift_v', # Snap to observer
30+
'K': '_action_clear_observers', # Kill all observers
31+
'F': '_action_toggle_firms', # FIRMS fire layer
32+
'W': '_action_toggle_wind', # Wind particles
33+
'E': '_action_toggle_terrain_vis', # Toggle terrain visibility
34+
'B': '_action_toggle_gtfs_rt', # GTFS-RT vehicles
35+
'C': '_action_cycle_pc_colors', # Point cloud color mode
36+
'D': '_action_toggle_denoiser', # Denoiser
37+
'G': '_action_cycle_gi', # GI bounces
38+
'H': '_action_prev_help_page', # Previous help page
39+
'L': '_action_toggle_drone_glow', # Drone glow
40+
'T': '_action_cycle_time', # Time-of-day
41+
}
42+
43+
# Lowercase key bindings — checked after shift bindings
44+
KEY_BINDINGS = {
45+
't': '_action_toggle_shadows',
46+
'c': '_action_cycle_colormap',
47+
'g': '_cycle_terrain_layer',
48+
'n': '_cycle_geometry_layer',
49+
'p': '_action_jump_prev_geom',
50+
'h': '_action_next_help_page',
51+
'm': '_action_toggle_minimap',
52+
'o': '_action_place_observer',
53+
'v': '_toggle_viewshed',
54+
'[': '_action_observer_elev_down',
55+
']': '_action_observer_elev_up',
56+
'f': '_save_screenshot',
57+
'y': '_action_cycle_color_stretch',
58+
'b': '_action_cycle_mesh_type',
59+
'u': '_action_cycle_basemap_fwd',
60+
',': '_action_overlay_alpha_down',
61+
'.': '_action_overlay_alpha_up',
62+
'0': '_action_toggle_ao',
63+
'9': '_action_toggle_dof',
64+
';': '_action_dof_aperture_down',
65+
"'": '_action_dof_aperture_up',
66+
'escape': '_action_exit',
67+
'x': '_action_exit',
68+
}
69+
70+
# Keys that need both raw_key and key for dispatch
71+
# (raw_key, key) → method name
72+
SPECIAL_BINDINGS = {
73+
# Speed
74+
('+', '+'): '_action_speed_up',
75+
('=', '='): '_action_speed_up',
76+
('-', '-'): '_action_speed_down',
77+
# Resolution: r = coarser, R = finer
78+
('r', 'r'): '_action_resolution_coarser',
79+
('R', 'r'): '_action_resolution_finer',
80+
# Vertical exaggeration: z = decrease, Z = increase
81+
('z', 'z'): '_action_ve_down',
82+
('Z', 'z'): '_action_ve_up',
83+
# Basemap: U = reverse
84+
('U', 'u'): '_action_cycle_basemap_rev',
85+
# DOF focal distance: : = decrease, " = increase
86+
(':', ':'): '_action_dof_focal_down',
87+
('"', '"'): '_action_dof_focal_up',
88+
}

rtxpy/viewer/observers.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Observer management for the interactive viewer."""
2+
3+
import threading
4+
5+
6+
OBSERVER_COLORS = [
7+
(1.0, 0.2, 0.2), # 1: red
8+
(0.2, 0.6, 1.0), # 2: blue
9+
(0.2, 1.0, 0.3), # 3: green
10+
(1.0, 0.8, 0.1), # 4: yellow
11+
(1.0, 0.4, 0.0), # 5: orange
12+
(0.8, 0.2, 1.0), # 6: purple
13+
(0.0, 1.0, 0.9), # 7: cyan
14+
(1.0, 0.5, 0.7), # 8: pink
15+
]
16+
17+
18+
class Observer:
19+
"""State for a single observer slot (1-8)."""
20+
21+
__slots__ = (
22+
'slot', 'position', 'observer_elev', 'drone_mode', 'drone_placed',
23+
'yaw', 'pitch', 'saved_camera', 'tour_thread', 'tour_stop',
24+
'viewshed_enabled', 'viewshed_cache',
25+
)
26+
27+
def __init__(self, slot, position, observer_elev=0.05):
28+
self.slot = slot
29+
self.position = position
30+
self.observer_elev = observer_elev
31+
self.drone_mode = 'off'
32+
self.drone_placed = False
33+
self.yaw = 0.0
34+
self.pitch = 0.0
35+
self.saved_camera = None
36+
self.tour_thread = None
37+
self.tour_stop = threading.Event()
38+
self.viewshed_enabled = False
39+
self.viewshed_cache = None
40+
41+
@property
42+
def color(self):
43+
return OBSERVER_COLORS[(self.slot - 1) % len(OBSERVER_COLORS)]
44+
45+
def geometry_id(self, part_idx):
46+
"""Unique geometry ID for a drone sub-mesh."""
47+
return f'_observer{self.slot}_{part_idx}'
48+
49+
def is_touring(self):
50+
return self.tour_thread is not None and self.tour_thread.is_alive()
51+
52+
def stop_tour(self):
53+
self.tour_stop.set()
54+
if self.tour_thread is not None:
55+
self.tour_thread.join(timeout=2.0)
56+
self.tour_thread = None
57+
self.tour_stop.clear()
58+
59+
60+
class ObserverManager:
61+
"""Manages multi-observer system (up to 8 independent observers).
62+
63+
Holds observer instances, viewshed settings, and drone part state.
64+
"""
65+
66+
__slots__ = (
67+
'observers', 'active_observer',
68+
'viewshed_enabled', 'viewshed_observer_elev',
69+
'viewshed_target_elev', 'viewshed_opacity',
70+
'viewshed_cache', 'viewshed_coverage',
71+
'viewshed_recalc_interval', 'last_viewshed_time',
72+
'shared_drone_parts', 'drone_glow',
73+
)
74+
75+
def __init__(self):
76+
self.observers = {} # dict[int, Observer] — slot 1-8
77+
self.active_observer = None # int (slot 1-8) or None
78+
self.viewshed_enabled = False
79+
self.viewshed_observer_elev = 0.05
80+
self.viewshed_target_elev = 0.0
81+
self.viewshed_opacity = 0.35
82+
self.viewshed_cache = None
83+
self.viewshed_coverage = 0.0
84+
self.viewshed_recalc_interval = 0.4
85+
self.last_viewshed_time = 0.0
86+
self.shared_drone_parts = None
87+
self.drone_glow = False

rtxpy/viewer/overlays.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Overlay layer management for the interactive viewer."""
2+
3+
4+
class OverlayManager:
5+
"""Manages terrain overlay layers and basemap tile settings.
6+
7+
Tracks which overlay is active, the alpha blending value, basemap
8+
cycling state, and tile service reference.
9+
"""
10+
11+
__slots__ = (
12+
'overlay_layers', 'overlay_names',
13+
'active_color_data', 'active_overlay_data',
14+
'overlay_alpha', 'overlay_as_water',
15+
'terrain_layer_order', 'terrain_layer_idx',
16+
'base_overlay_layers',
17+
'tile_service', 'tiles_enabled',
18+
'basemap_options', 'basemap_idx',
19+
)
20+
21+
def __init__(self, overlay_layers=None, base_overlay_layers=None):
22+
self.overlay_layers = overlay_layers or {}
23+
self.overlay_names = list(self.overlay_layers.keys())
24+
self.active_color_data = None
25+
self.active_overlay_data = None
26+
self.overlay_alpha = 0.7
27+
self.overlay_as_water = False
28+
29+
self.terrain_layer_order = ['elevation'] + list(self.overlay_names)
30+
self.terrain_layer_idx = 0
31+
32+
self.base_overlay_layers = base_overlay_layers or {}
33+
34+
self.tile_service = None
35+
self.tiles_enabled = False
36+
self.basemap_options = ['none', 'satellite', 'osm']
37+
self.basemap_idx = 0

0 commit comments

Comments
 (0)