Skip to main content

eulumdat_bevy/viewer/
controls.rs

1//! Keyboard controls for the viewer.
2
3use super::scenes::SceneType;
4use super::ViewerSettings;
5use bevy::prelude::*;
6use eulumdat::Eulumdat;
7
8/// Dimension adjustment step size in meters.
9const DIMENSION_STEP: f32 = 0.5;
10/// Fine adjustment step size in meters (for pendulum, mounting height).
11const FINE_STEP: f32 = 0.1;
12/// Minimum room dimension in meters.
13const MIN_DIMENSION: f32 = 1.0;
14/// Maximum room dimension in meters.
15const MAX_DIMENSION: f32 = 50.0;
16/// Minimum room height in meters.
17const MIN_HEIGHT: f32 = 2.0;
18/// Maximum room height in meters.
19const MAX_HEIGHT: f32 = 20.0;
20/// Maximum pendulum length in meters (high-bay, industrial, street lamps).
21const MAX_PENDULUM: f32 = 20.0;
22
23/// Keyboard control system for the 3D viewer.
24///
25/// # Key bindings
26///
27/// ## Visualization toggles
28/// - `P`: Toggle photometric solid
29/// - `L`: Toggle luminaire model
30/// - `H`: Toggle shadows
31///
32/// ## Scene selection
33/// - `1-4`: Switch scene type (Room, Road, Parking, Outdoor)
34///
35/// ## Room dimensions
36/// - `[` / `]`: Decrease/increase room width (±0.5m)
37/// - `-` / `=`: Decrease/increase room length (±0.5m)
38/// - `9` / `0`: Decrease/increase room height (±0.5m)
39///
40/// ## Luminaire positioning
41/// - `;` / `'`: Decrease/increase pendulum/suspension length (±0.1m)
42/// - `,` / `.`: Decrease/increase mounting height (±0.1m, pole height for outdoor)
43/// - `T` / `Y`: Decrease/increase luminaire tilt angle (±5°, for road scenes)
44pub fn viewer_controls_system(
45    mut settings: ResMut<ViewerSettings>,
46    keyboard: Res<ButtonInput<KeyCode>>,
47) {
48    // Toggle photometric solid with P key
49    if keyboard.just_pressed(KeyCode::KeyP) {
50        settings.show_photometric_solid = !settings.show_photometric_solid;
51    }
52
53    // Toggle luminaire with L key
54    if keyboard.just_pressed(KeyCode::KeyL) {
55        settings.show_luminaire = !settings.show_luminaire;
56    }
57
58    // Toggle shadows with H key (H = Hide/show shadows)
59    if keyboard.just_pressed(KeyCode::KeyH) {
60        settings.show_shadows = !settings.show_shadows;
61    }
62
63    // Cycle scene types with 1-4 keys
64    if keyboard.just_pressed(KeyCode::Digit1) {
65        settings.scene_type = SceneType::Room;
66    }
67    if keyboard.just_pressed(KeyCode::Digit2) {
68        settings.scene_type = SceneType::Road;
69    }
70    if keyboard.just_pressed(KeyCode::Digit3) {
71        settings.scene_type = SceneType::Parking;
72    }
73    if keyboard.just_pressed(KeyCode::Digit4) {
74        settings.scene_type = SceneType::Outdoor;
75    }
76
77    // Room dimension controls (only for Room scene, but allow adjustment for all)
78    // Width: [ and ]
79    if keyboard.just_pressed(KeyCode::BracketLeft) {
80        settings.room_width = (settings.room_width - DIMENSION_STEP).max(MIN_DIMENSION);
81    }
82    if keyboard.just_pressed(KeyCode::BracketRight) {
83        settings.room_width = (settings.room_width + DIMENSION_STEP).min(MAX_DIMENSION);
84    }
85
86    // Length: - and =
87    if keyboard.just_pressed(KeyCode::Minus) {
88        settings.room_length = (settings.room_length - DIMENSION_STEP).max(MIN_DIMENSION);
89    }
90    if keyboard.just_pressed(KeyCode::Equal) {
91        settings.room_length = (settings.room_length + DIMENSION_STEP).min(MAX_DIMENSION);
92    }
93
94    // Height: 9 and 0
95    if keyboard.just_pressed(KeyCode::Digit9) {
96        settings.room_height = (settings.room_height - DIMENSION_STEP).max(MIN_HEIGHT);
97        // Also adjust mounting height if it exceeds room height
98        if settings.mounting_height > settings.room_height - 0.1 {
99            settings.mounting_height = settings.room_height - 0.1;
100        }
101    }
102    if keyboard.just_pressed(KeyCode::Digit0) {
103        settings.room_height = (settings.room_height + DIMENSION_STEP).min(MAX_HEIGHT);
104    }
105
106    // Pendulum/suspension length: ; and '
107    if keyboard.just_pressed(KeyCode::Semicolon) {
108        settings.pendulum_length = (settings.pendulum_length - FINE_STEP).max(0.0);
109    }
110    if keyboard.just_pressed(KeyCode::Quote) {
111        // Max pendulum = attachment_height - 1.0 (leave 1m clearance from floor)
112        let max_pendulum = (settings.attachment_height() - 1.0).clamp(0.0, MAX_PENDULUM);
113        settings.pendulum_length = (settings.pendulum_length + FINE_STEP).min(max_pendulum);
114    }
115
116    // Mounting height (for outdoor scenes): , and .
117    if keyboard.just_pressed(KeyCode::Comma) {
118        settings.mounting_height = (settings.mounting_height - FINE_STEP).max(2.0);
119    }
120    if keyboard.just_pressed(KeyCode::Period) {
121        settings.mounting_height = (settings.mounting_height + FINE_STEP).min(MAX_HEIGHT);
122    }
123
124    // Luminaire tilt angle (for road scenes): T and Y
125    const TILT_STEP: f32 = 5.0;
126    if keyboard.just_pressed(KeyCode::KeyT) {
127        settings.luminaire_tilt = (settings.luminaire_tilt - TILT_STEP).max(0.0);
128    }
129    if keyboard.just_pressed(KeyCode::KeyY) {
130        settings.luminaire_tilt = (settings.luminaire_tilt + TILT_STEP).min(90.0);
131    }
132}
133
134/// System to sync ViewerSettings with PhotometricLight components.
135///
136/// When settings change, this system:
137/// - Updates visualization flags (solid, model, shadows)
138/// - Respawns all luminaires if the count changes (scene type change, etc.)
139/// - Updates light positions and rotations
140pub fn sync_viewer_to_lights(
141    mut commands: Commands,
142    settings: Res<ViewerSettings>,
143    lights: Query<(
144        Entity,
145        &crate::photometric::PhotometricLight<Eulumdat>,
146        &Transform,
147    )>,
148) {
149    if !settings.is_changed() {
150        return;
151    }
152
153    // Get LDT data from first light
154    let ldt_data = lights.iter().next().map(|(_, l, _)| l.data.clone());
155    let Some(ldt) = ldt_data else {
156        return;
157    };
158
159    // Calculate required transforms for current scene
160    let transforms = calculate_all_luminaire_transforms(&settings, &ldt);
161    let current_count = lights.iter().count();
162    let required_count = transforms.len();
163
164    // If count changed, despawn all and respawn
165    if current_count != required_count {
166        // Despawn all existing lights
167        for (entity, _, _) in lights.iter() {
168            commands.entity(entity).despawn();
169        }
170
171        // Spawn new lights
172        for transform in transforms {
173            commands.spawn(
174                crate::eulumdat_impl::EulumdatLightBundle::new(ldt.clone())
175                    .with_transform(
176                        Transform::from_translation(transform.position)
177                            .with_rotation(transform.rotation),
178                    )
179                    .with_solid(settings.show_photometric_solid)
180                    .with_model(settings.show_luminaire)
181                    .with_shadows(settings.show_shadows),
182            );
183        }
184    } else {
185        // Just update existing lights in place
186        for (idx, (entity, light, _)) in lights.iter().enumerate() {
187            if let Some(lt) = transforms.get(idx) {
188                let mut updated_light =
189                    crate::photometric::PhotometricLight::new(light.data.clone());
190                updated_light.show_solid = settings.show_photometric_solid;
191                updated_light.show_model = settings.show_luminaire;
192                updated_light.shadows_enabled = settings.show_shadows;
193                updated_light.intensity_scale = light.intensity_scale;
194
195                commands.entity(entity).insert((
196                    Transform::from_translation(lt.position).with_rotation(lt.rotation),
197                    updated_light,
198                ));
199            }
200        }
201    }
202}
203
204/// Luminaire position with rotation for multi-luminaire scenes.
205#[derive(Clone, Copy)]
206pub struct LuminaireTransform {
207    pub position: Vec3,
208    pub rotation: Quat,
209}
210
211/// Calculate all luminaire positions for the current scene.
212/// Returns a list of positions and rotations for each luminaire.
213pub fn calculate_all_luminaire_transforms(
214    settings: &ViewerSettings,
215    ldt: &Eulumdat,
216) -> Vec<LuminaireTransform> {
217    let y = settings.luminaire_height(ldt);
218
219    match settings.scene_type {
220        SceneType::Room => {
221            // Single luminaire centered in room
222            vec![LuminaireTransform {
223                position: Vec3::new(settings.room_width / 2.0, y, settings.room_length / 2.0),
224                rotation: Quat::IDENTITY,
225            }]
226        }
227        SceneType::Road => calculate_road_luminaires(settings, y),
228        SceneType::Parking | SceneType::Outdoor => {
229            // Single luminaire for now
230            vec![LuminaireTransform {
231                position: Vec3::new(
232                    settings.room_width / 2.0 - 0.2,
233                    y,
234                    settings.room_length / 2.0,
235                ),
236                rotation: Quat::IDENTITY,
237            }]
238        }
239    }
240}
241
242/// Calculate luminaire positions for road scene based on EN 13201 guidelines.
243/// Luminaires are placed on outer sides (sidewalks) to illuminate both road and pedestrian areas.
244/// The wider part of the LDC faces the road, softer part faces the sidewalk.
245/// Middle poles are added every 50m for better center illumination on wide roads.
246fn calculate_road_luminaires(settings: &ViewerSettings, y: f32) -> Vec<LuminaireTransform> {
247    let lane_w = settings.lane_width;
248    let num_lanes = settings.num_lanes;
249    let sidewalk_w = settings.sidewalk_width;
250    let road_width = num_lanes as f32 * lane_w;
251    let total_width = road_width + 2.0 * sidewalk_w;
252    let road_length = settings.room_length;
253    let pole_spacing = settings.effective_pole_spacing();
254
255    // Calculate number of poles and actual spacing
256    let num_poles = ((road_length / pole_spacing).floor() as i32).max(1);
257    let actual_spacing = road_length / (num_poles as f32 + 1.0);
258
259    // Determine arrangement based on road/height ratio
260    let ratio = road_width / settings.mounting_height;
261    let tilt = settings.luminaire_tilt.to_radians();
262
263    // Arm extends from pole toward road center
264    let arm_length = 1.5;
265
266    let mut transforms = Vec::new();
267
268    // Middle pole spacing (every 50m for center illumination on wide roads)
269    let middle_pole_spacing = 50.0;
270    let center_x = sidewalk_w + road_width / 2.0;
271
272    if ratio < 1.0 {
273        // Single side arrangement - poles on right sidewalk
274        // Luminaire faces LEFT toward road (positive Z rotation tilts light toward -X)
275        let rotation = Quat::from_rotation_z(tilt);
276        for i in 1..=num_poles {
277            let z = i as f32 * actual_spacing;
278            transforms.push(LuminaireTransform {
279                position: Vec3::new(total_width - sidewalk_w / 2.0 - arm_length, y, z),
280                rotation,
281            });
282        }
283    } else if ratio < 1.5 {
284        // Staggered arrangement - alternating sides on sidewalks
285        for i in 1..=num_poles {
286            let z = i as f32 * actual_spacing;
287            if i % 2 == 0 {
288                // Left sidewalk - luminaire faces RIGHT toward road (negative Z rotation)
289                transforms.push(LuminaireTransform {
290                    position: Vec3::new(sidewalk_w / 2.0 + arm_length, y, z),
291                    rotation: Quat::from_rotation_z(-tilt),
292                });
293            } else {
294                // Right sidewalk - luminaire faces LEFT toward road (positive Z rotation)
295                transforms.push(LuminaireTransform {
296                    position: Vec3::new(total_width - sidewalk_w / 2.0 - arm_length, y, z),
297                    rotation: Quat::from_rotation_z(tilt),
298                });
299            }
300        }
301    } else {
302        // Opposite arrangement - poles on both sidewalks, aligned
303        // Each side illuminates its sidewalk + half the road
304        for i in 1..=num_poles {
305            let z = i as f32 * actual_spacing;
306            // Left sidewalk - luminaire faces RIGHT toward road (negative Z rotation)
307            transforms.push(LuminaireTransform {
308                position: Vec3::new(sidewalk_w / 2.0 + arm_length, y, z),
309                rotation: Quat::from_rotation_z(-tilt),
310            });
311            // Right sidewalk - luminaire faces LEFT toward road (positive Z rotation)
312            transforms.push(LuminaireTransform {
313                position: Vec3::new(total_width - sidewalk_w / 2.0 - arm_length, y, z),
314                rotation: Quat::from_rotation_z(tilt),
315            });
316        }
317
318        // Add middle poles every 50m for better center illumination
319        if road_width > 6.0 {
320            let num_middle_poles = ((road_length / middle_pole_spacing).floor() as i32).max(0);
321            for i in 1..=num_middle_poles {
322                let z = i as f32 * middle_pole_spacing;
323                // Middle pole with two luminaires pointing outward (no tilt, straight down)
324                // Left-facing luminaire
325                transforms.push(LuminaireTransform {
326                    position: Vec3::new(center_x - 1.0, y, z),
327                    rotation: Quat::from_rotation_z(-tilt * 0.5), // Less tilt for center
328                });
329                // Right-facing luminaire
330                transforms.push(LuminaireTransform {
331                    position: Vec3::new(center_x + 1.0, y, z),
332                    rotation: Quat::from_rotation_z(tilt * 0.5),
333                });
334            }
335        }
336    }
337
338    transforms
339}
340
341/// Calculate light position based on scene type and settings.
342/// Returns position for the first/primary luminaire only.
343/// For multi-luminaire scenes, use `calculate_all_luminaire_transforms`.
344pub fn calculate_light_position(settings: &ViewerSettings, ldt: &Eulumdat) -> Vec3 {
345    let transforms = calculate_all_luminaire_transforms(settings, ldt);
346    transforms.first().map(|t| t.position).unwrap_or(Vec3::ZERO)
347}
348
349/// Calculate light rotation based on scene type.
350///
351/// For road luminaires, the luminaire should be tilted to point across the road.
352/// The pole is on the right side of the road, so the luminaire tilts left (toward road center).
353/// The tilt angle is controlled by `settings.luminaire_tilt` (0° = down, 90° = horizontal).
354pub fn calculate_light_rotation(settings: &ViewerSettings) -> Quat {
355    match settings.scene_type {
356        SceneType::Room => Quat::IDENTITY, // No rotation for indoor
357        SceneType::Road => {
358            // Road luminaire needs to be tilted to point across the road
359            // Pole is on right side (high X), luminaire points toward road center (low X)
360            //
361            // Rotate around Z axis with NEGATIVE angle to tilt DOWN toward road (negative X)
362            // luminaire_tilt: 0 = pointing straight down, 90 = pointing horizontally toward road
363            let tilt_angle = -settings.luminaire_tilt.to_radians();
364            Quat::from_rotation_z(tilt_angle)
365        }
366        SceneType::Parking => Quat::IDENTITY, // Parking lots typically want omnidirectional
367        SceneType::Outdoor => Quat::IDENTITY, // Garden lights typically want omnidirectional
368    }
369}