Skip to main content

ringkernel_wavesim3d/gui/
controls.rs

1//! GUI controls for the 3D wave simulation.
2//!
3//! Uses egui for immediate-mode UI rendering.
4
5use crate::audio::AudioSource;
6use crate::simulation::{Medium, Position3D, SimulationEngine};
7use crate::visualization::{ColorMap, SliceAxis, SliceConfig};
8
9/// GUI state for the simulation.
10#[derive(Debug, Clone)]
11pub struct GuiState {
12    // Simulation controls
13    pub is_playing: bool,
14    pub simulation_speed: f32,
15    pub steps_per_frame: u32,
16
17    // Environment
18    pub temperature_c: f32,
19    pub humidity_percent: f32,
20    pub medium: Medium,
21
22    // Slice controls
23    pub xy_slice_position: f32,
24    pub xz_slice_position: f32,
25    pub yz_slice_position: f32,
26    pub show_xy_slice: bool,
27    pub show_xz_slice: bool,
28    pub show_yz_slice: bool,
29
30    // Source controls
31    pub source_x: f32,
32    pub source_y: f32,
33    pub source_z: f32,
34    pub source_amplitude: f32,
35    pub source_frequency: f32,
36    pub source_type_idx: usize,
37
38    // Listener controls
39    pub listener_x: f32,
40    pub listener_y: f32,
41    pub listener_z: f32,
42    pub listener_yaw: f32,
43    pub show_listener: bool,
44
45    // Visualization
46    pub color_map_idx: usize,
47    pub auto_scale: bool,
48    pub max_pressure: f32,
49    pub slice_opacity: f32,
50    pub show_bounding_box: bool,
51    pub show_floor_grid: bool,
52    pub show_sources: bool,
53
54    // Info
55    pub show_info_panel: bool,
56}
57
58impl Default for GuiState {
59    fn default() -> Self {
60        Self {
61            is_playing: false,
62            simulation_speed: 1.0,
63            steps_per_frame: 1,
64
65            temperature_c: 20.0,
66            humidity_percent: 50.0,
67            medium: Medium::Air,
68
69            xy_slice_position: 0.5,
70            xz_slice_position: 0.5,
71            yz_slice_position: 0.5,
72            show_xy_slice: true,
73            show_xz_slice: true,
74            show_yz_slice: true,
75
76            source_x: 0.5,
77            source_y: 0.5,
78            source_z: 0.5,
79            source_amplitude: 1.0,
80            source_frequency: 440.0,
81            source_type_idx: 0,
82
83            listener_x: 0.5,
84            listener_y: 0.5,
85            listener_z: 0.7,
86            listener_yaw: 0.0,
87            show_listener: true,
88
89            color_map_idx: 0,
90            auto_scale: true,
91            max_pressure: 1.0,
92            slice_opacity: 0.85,
93            show_bounding_box: true,
94            show_floor_grid: true,
95            show_sources: true,
96
97            show_info_panel: true,
98        }
99    }
100}
101
102impl GuiState {
103    /// Get the selected color map.
104    pub fn color_map(&self) -> ColorMap {
105        match self.color_map_idx {
106            0 => ColorMap::BlueWhiteRed,
107            1 => ColorMap::CoolWarm,
108            2 => ColorMap::Hot,
109            3 => ColorMap::Diverging,
110            _ => ColorMap::Grayscale,
111        }
112    }
113
114    /// Get the selected source type name.
115    pub fn source_type_name(&self) -> &'static str {
116        match self.source_type_idx {
117            0 => "Impulse",
118            1 => "Tone",
119            2 => "Chirp",
120            3 => "White Noise",
121            4 => "Pink Noise",
122            _ => "Gaussian Pulse",
123        }
124    }
125
126    /// Create an audio source from current settings.
127    pub fn create_source(&self, grid_size: (f32, f32, f32)) -> AudioSource {
128        let position = Position3D::new(
129            self.source_x * grid_size.0,
130            self.source_y * grid_size.1,
131            self.source_z * grid_size.2,
132        );
133
134        match self.source_type_idx {
135            0 => AudioSource::impulse(0, position, self.source_amplitude),
136            1 => AudioSource::tone(0, position, self.source_frequency, self.source_amplitude),
137            2 => AudioSource::chirp(0, position, 100.0, 2000.0, 0.5, self.source_amplitude),
138            3 => AudioSource::noise(0, position, self.source_amplitude, false),
139            4 => AudioSource::noise(0, position, self.source_amplitude, true),
140            _ => AudioSource::gaussian_pulse(0, position, 0.01, 0.002, self.source_amplitude),
141        }
142    }
143
144    /// Get source position in world coordinates.
145    pub fn source_position(&self, grid_size: (f32, f32, f32)) -> Position3D {
146        Position3D::new(
147            self.source_x * grid_size.0,
148            self.source_y * grid_size.1,
149            self.source_z * grid_size.2,
150        )
151    }
152
153    /// Get listener position in world coordinates.
154    pub fn listener_position(&self, grid_size: (f32, f32, f32)) -> Position3D {
155        Position3D::new(
156            self.listener_x * grid_size.0,
157            self.listener_y * grid_size.1,
158            self.listener_z * grid_size.2,
159        )
160    }
161
162    /// Create slice configurations from state.
163    pub fn slice_configs(&self) -> Vec<SliceConfig> {
164        let mut configs = Vec::new();
165
166        if self.show_xy_slice {
167            configs.push(SliceConfig {
168                axis: SliceAxis::XY,
169                position: self.xy_slice_position,
170                opacity: self.slice_opacity,
171                color_map: self.color_map(),
172                show_grid: false,
173            });
174        }
175
176        if self.show_xz_slice {
177            configs.push(SliceConfig {
178                axis: SliceAxis::XZ,
179                position: self.xz_slice_position,
180                opacity: self.slice_opacity,
181                color_map: self.color_map(),
182                show_grid: false,
183            });
184        }
185
186        if self.show_yz_slice {
187            configs.push(SliceConfig {
188                axis: SliceAxis::YZ,
189                position: self.yz_slice_position,
190                opacity: self.slice_opacity,
191                color_map: self.color_map(),
192                show_grid: false,
193            });
194        }
195
196        configs
197    }
198}
199
200/// Simulation controls panel.
201pub struct SimulationControls {
202    state: GuiState,
203}
204
205impl SimulationControls {
206    pub fn new() -> Self {
207        Self {
208            state: GuiState::default(),
209        }
210    }
211
212    pub fn state(&self) -> &GuiState {
213        &self.state
214    }
215
216    pub fn state_mut(&mut self) -> &mut GuiState {
217        &mut self.state
218    }
219
220    /// Render the main control panel.
221    pub fn ui(&mut self, ctx: &egui::Context, engine: &SimulationEngine) {
222        egui::SidePanel::left("controls")
223            .default_width(280.0)
224            .show(ctx, |ui| {
225                egui::ScrollArea::vertical().show(ui, |ui| {
226                    self.simulation_section(ui, engine);
227                    ui.separator();
228                    self.environment_section(ui);
229                    ui.separator();
230                    self.source_section(ui);
231                    ui.separator();
232                    self.listener_section(ui);
233                    ui.separator();
234                    self.visualization_section(ui);
235                    ui.separator();
236                    self.slice_section(ui);
237                });
238            });
239
240        if self.state.show_info_panel {
241            egui::Window::new("Simulation Info")
242                .anchor(egui::Align2::RIGHT_TOP, [-10.0, 10.0])
243                .show(ctx, |ui| {
244                    self.info_panel(ui, engine);
245                });
246        }
247    }
248
249    fn simulation_section(&mut self, ui: &mut egui::Ui, engine: &SimulationEngine) {
250        ui.heading("Simulation");
251
252        ui.horizontal(|ui| {
253            if ui
254                .button(if self.state.is_playing {
255                    "⏸ Pause"
256                } else {
257                    "▶ Play"
258                })
259                .clicked()
260            {
261                self.state.is_playing = !self.state.is_playing;
262            }
263            if ui.button("⟲ Reset").clicked() {
264                self.state.is_playing = false;
265            }
266        });
267
268        ui.horizontal(|ui| {
269            ui.label("Speed:");
270            ui.add(egui::Slider::new(
271                &mut self.state.simulation_speed,
272                0.1..=5.0,
273            ));
274        });
275
276        ui.horizontal(|ui| {
277            ui.label("Steps/frame:");
278            ui.add(egui::Slider::new(&mut self.state.steps_per_frame, 1..=50));
279        });
280
281        let using_gpu = engine.is_using_gpu();
282        ui.label(format!(
283            "Backend: {}",
284            if using_gpu { "GPU (CUDA)" } else { "CPU" }
285        ));
286    }
287
288    fn environment_section(&mut self, ui: &mut egui::Ui) {
289        ui.heading("Environment");
290
291        ui.horizontal(|ui| {
292            ui.label("Temperature (°C):");
293            ui.add(egui::Slider::new(
294                &mut self.state.temperature_c,
295                -20.0..=40.0,
296            ));
297        });
298
299        ui.horizontal(|ui| {
300            ui.label("Humidity (%):");
301            ui.add(egui::Slider::new(
302                &mut self.state.humidity_percent,
303                0.0..=100.0,
304            ));
305        });
306
307        ui.horizontal(|ui| {
308            ui.label("Medium:");
309            egui::ComboBox::from_id_salt("medium_select")
310                .selected_text(match self.state.medium {
311                    Medium::Air => "Air",
312                    Medium::Water => "Water",
313                    Medium::Steel => "Steel",
314                    Medium::Aluminum => "Aluminum",
315                    Medium::Custom => "Custom",
316                })
317                .show_ui(ui, |ui| {
318                    ui.selectable_value(&mut self.state.medium, Medium::Air, "Air");
319                    ui.selectable_value(&mut self.state.medium, Medium::Water, "Water");
320                    ui.selectable_value(&mut self.state.medium, Medium::Steel, "Steel");
321                    ui.selectable_value(&mut self.state.medium, Medium::Aluminum, "Aluminum");
322                });
323        });
324    }
325
326    fn source_section(&mut self, ui: &mut egui::Ui) {
327        ui.heading("Audio Source");
328
329        ui.horizontal(|ui| {
330            ui.label("Type:");
331            egui::ComboBox::from_id_salt("source_type")
332                .selected_text(self.state.source_type_name())
333                .show_ui(ui, |ui| {
334                    ui.selectable_value(&mut self.state.source_type_idx, 0, "Impulse");
335                    ui.selectable_value(&mut self.state.source_type_idx, 1, "Tone");
336                    ui.selectable_value(&mut self.state.source_type_idx, 2, "Chirp");
337                    ui.selectable_value(&mut self.state.source_type_idx, 3, "White Noise");
338                    ui.selectable_value(&mut self.state.source_type_idx, 4, "Pink Noise");
339                    ui.selectable_value(&mut self.state.source_type_idx, 5, "Gaussian Pulse");
340                });
341        });
342
343        ui.label("Position:");
344        ui.horizontal(|ui| {
345            ui.label("X:");
346            ui.add(egui::Slider::new(&mut self.state.source_x, 0.0..=1.0).text(""));
347        });
348        ui.horizontal(|ui| {
349            ui.label("Y:");
350            ui.add(egui::Slider::new(&mut self.state.source_y, 0.0..=1.0).text(""));
351        });
352        ui.horizontal(|ui| {
353            ui.label("Z:");
354            ui.add(egui::Slider::new(&mut self.state.source_z, 0.0..=1.0).text(""));
355        });
356
357        ui.horizontal(|ui| {
358            ui.label("Amplitude:");
359            ui.add(egui::Slider::new(
360                &mut self.state.source_amplitude,
361                0.1..=5.0,
362            ));
363        });
364
365        if self.state.source_type_idx == 1 {
366            // Tone
367            ui.horizontal(|ui| {
368                ui.label("Frequency (Hz):");
369                ui.add(
370                    egui::Slider::new(&mut self.state.source_frequency, 20.0..=2000.0)
371                        .logarithmic(true),
372                );
373            });
374        }
375
376        if ui.button("🔊 Fire Source").clicked() {
377            // Signal to main loop to inject source
378        }
379    }
380
381    fn listener_section(&mut self, ui: &mut egui::Ui) {
382        ui.heading("Virtual Listener");
383
384        ui.checkbox(&mut self.state.show_listener, "Show listener");
385
386        ui.label("Position:");
387        ui.horizontal(|ui| {
388            ui.label("X:");
389            ui.add(egui::Slider::new(&mut self.state.listener_x, 0.0..=1.0).text(""));
390        });
391        ui.horizontal(|ui| {
392            ui.label("Y:");
393            ui.add(egui::Slider::new(&mut self.state.listener_y, 0.0..=1.0).text(""));
394        });
395        ui.horizontal(|ui| {
396            ui.label("Z:");
397            ui.add(egui::Slider::new(&mut self.state.listener_z, 0.0..=1.0).text(""));
398        });
399
400        ui.horizontal(|ui| {
401            ui.label("Yaw (°):");
402            ui.add(egui::Slider::new(
403                &mut self.state.listener_yaw,
404                -180.0..=180.0,
405            ));
406        });
407    }
408
409    fn visualization_section(&mut self, ui: &mut egui::Ui) {
410        ui.heading("Visualization");
411
412        ui.horizontal(|ui| {
413            ui.label("Color map:");
414            egui::ComboBox::from_id_salt("color_map")
415                .selected_text(match self.state.color_map_idx {
416                    0 => "Blue-White-Red",
417                    1 => "Cool-Warm",
418                    2 => "Hot",
419                    3 => "Diverging",
420                    _ => "Grayscale",
421                })
422                .show_ui(ui, |ui| {
423                    ui.selectable_value(&mut self.state.color_map_idx, 0, "Blue-White-Red");
424                    ui.selectable_value(&mut self.state.color_map_idx, 1, "Cool-Warm");
425                    ui.selectable_value(&mut self.state.color_map_idx, 2, "Hot");
426                    ui.selectable_value(&mut self.state.color_map_idx, 3, "Diverging");
427                    ui.selectable_value(&mut self.state.color_map_idx, 4, "Grayscale");
428                });
429        });
430
431        ui.checkbox(&mut self.state.auto_scale, "Auto-scale colors");
432
433        if !self.state.auto_scale {
434            ui.horizontal(|ui| {
435                ui.label("Max pressure:");
436                ui.add(
437                    egui::Slider::new(&mut self.state.max_pressure, 0.1..=10.0).logarithmic(true),
438                );
439            });
440        }
441
442        ui.horizontal(|ui| {
443            ui.label("Slice opacity:");
444            ui.add(egui::Slider::new(&mut self.state.slice_opacity, 0.1..=1.0));
445        });
446
447        ui.checkbox(&mut self.state.show_bounding_box, "Show bounding box");
448        ui.checkbox(&mut self.state.show_floor_grid, "Show floor grid");
449        ui.checkbox(&mut self.state.show_sources, "Show source markers");
450        ui.checkbox(&mut self.state.show_info_panel, "Show info panel");
451    }
452
453    fn slice_section(&mut self, ui: &mut egui::Ui) {
454        ui.heading("Slices");
455
456        ui.horizontal(|ui| {
457            ui.checkbox(&mut self.state.show_xy_slice, "XY");
458            if self.state.show_xy_slice {
459                ui.add(egui::Slider::new(&mut self.state.xy_slice_position, 0.0..=1.0).text("Z"));
460            }
461        });
462
463        ui.horizontal(|ui| {
464            ui.checkbox(&mut self.state.show_xz_slice, "XZ");
465            if self.state.show_xz_slice {
466                ui.add(egui::Slider::new(&mut self.state.xz_slice_position, 0.0..=1.0).text("Y"));
467            }
468        });
469
470        ui.horizontal(|ui| {
471            ui.checkbox(&mut self.state.show_yz_slice, "YZ");
472            if self.state.show_yz_slice {
473                ui.add(egui::Slider::new(&mut self.state.yz_slice_position, 0.0..=1.0).text("X"));
474            }
475        });
476    }
477
478    fn info_panel(&self, ui: &mut egui::Ui, engine: &SimulationEngine) {
479        let (w, h, d) = engine.dimensions();
480        let (pw, ph, pd) = engine.grid.physical_size();
481
482        ui.label(format!("Grid: {}×{}×{} cells", w, h, d));
483        ui.label(format!("Size: {:.1}×{:.1}×{:.1} m", pw, ph, pd));
484        ui.label(format!("Step: {}", engine.step_count()));
485        ui.label(format!("Time: {:.4} s", engine.time()));
486        ui.label(format!("Max pressure: {:.4}", engine.grid.max_pressure()));
487        ui.label(format!("Total energy: {:.4}", engine.grid.total_energy()));
488
489        let speed = engine.grid.params.medium.speed_of_sound;
490        ui.label(format!("Speed of sound: {:.1} m/s", speed));
491    }
492}
493
494impl Default for SimulationControls {
495    fn default() -> Self {
496        Self::new()
497    }
498}