feat(ui): rewrite ui_system as SidePanel chassis orchestrator
SidePanel::right(300, resizable 260-400) → preset_bar (top, fixed) → ScrollArea (section stack). 4 always-open group() cards + 6 collapsing sections (4 with header toggles). Planets dirty-detection relocated here (needs pre-render field snapshot). Preset Custom-detection runs each frame unless just_applied. WantsPointer assignment preserved verbatim. Preset now derives Default=Custom; last_hash is Option<u64> (None=first frame) to avoid the u64::MAX sentinel fragility. egui 0.35 rename: SidePanel→Panel, default_width→default_size, width_range→size_range. Panel::show takes &mut Ui (not Context), so a top-level Ui is constructed manually (matches bevy_egui 0.41 side_panel example). collapsing_with_toggle's header &mut bool + body &mut params is a double borrow, so the bool is copied out and written back.
This commit is contained in:
parent
e4115b3ffd
commit
e67a0eaec1
286
src/ui/mod.rs
286
src/ui/mod.rs
@ -4,6 +4,7 @@ pub mod preset;
|
|||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy_egui::egui;
|
use bevy_egui::egui;
|
||||||
|
use egui::{LayerId, UiBuilder};
|
||||||
|
|
||||||
use crate::ui::preset::{Preset, apply, canonical_hash, params_hash};
|
use crate::ui::preset::{Preset, apply, canonical_hash, params_hash};
|
||||||
use crate::ui::style::ACCENT_CYAN;
|
use crate::ui::style::ACCENT_CYAN;
|
||||||
@ -139,179 +140,120 @@ pub fn ui_system(
|
|||||||
mut camera: ResMut<crate::camera::OrbitCamera>,
|
mut camera: ResMut<crate::camera::OrbitCamera>,
|
||||||
mut wants: ResMut<crate::camera::WantsPointer>,
|
mut wants: ResMut<crate::camera::WantsPointer>,
|
||||||
mut planet_dirty: ResMut<crate::scene::planets::PlanetSystemDirty>,
|
mut planet_dirty: ResMut<crate::scene::planets::PlanetSystemDirty>,
|
||||||
|
mut current_preset: Local<Preset>,
|
||||||
|
mut just_applied: Local<bool>,
|
||||||
|
mut last_hash: Local<Option<u64>>,
|
||||||
) {
|
) {
|
||||||
if let Ok(ctx) = contexts.ctx_mut() {
|
// Local<Preset> defaults to Custom (Preset::default()).
|
||||||
egui::Window::new("Controls")
|
// Local<Option<u64>> defaults to None — first-frame sentinel.
|
||||||
.collapsible(true)
|
|
||||||
.resizable(true)
|
let Ok(ctx) = contexts.ctx_mut() else {
|
||||||
.default_pos([16.0, 16.0])
|
|
||||||
.default_width(300.0)
|
|
||||||
.default_height(560.0)
|
|
||||||
.show(ctx, |ui| {
|
|
||||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
|
||||||
egui::CollapsingHeader::new("Camera")
|
|
||||||
.default_open(true)
|
|
||||||
.show(ui, |ui| {
|
|
||||||
ui.add(egui::Slider::new(&mut camera.distance, 3.0..=200.0).text("Distance"));
|
|
||||||
ui.add(egui::Slider::new(&mut camera.yaw, -std::f32::consts::PI..=std::f32::consts::PI).text("Yaw"));
|
|
||||||
ui.add(egui::Slider::new(&mut camera.pitch, (-std::f32::consts::PI + 0.05)..=(std::f32::consts::PI - 0.05)).text("Pitch"));
|
|
||||||
ui.add(egui::Slider::new(&mut camera.fov, 0.3..=2.0).text("FOV"));
|
|
||||||
});
|
|
||||||
egui::CollapsingHeader::new("Black Hole")
|
|
||||||
.default_open(true)
|
|
||||||
.show(ui, |ui| {
|
|
||||||
ui.add(egui::Slider::new(&mut params.spin, 0.0..=1.0).text("Spin (χ)"));
|
|
||||||
ui.label(format!("ISCO (disk inner): {:.3}", crate::physics::kerr_isco(params.spin)));
|
|
||||||
ui.label(format!("Horizon r+: {:.3}", crate::physics::kerr_horizon(params.spin)));
|
|
||||||
});
|
|
||||||
egui::CollapsingHeader::new("Accretion Disk")
|
|
||||||
.default_open(true)
|
|
||||||
.show(ui, |ui| {
|
|
||||||
// disk_inner removed — now spin-derived (see Black Hole section).
|
|
||||||
ui.add(egui::Slider::new(&mut params.disk_outer, 6.0..=50.0).text("Outer radius"));
|
|
||||||
ui.add(egui::Slider::new(&mut params.disk_tilt, 0.0..=std::f32::consts::PI).text("Tilt"));
|
|
||||||
ui.add(egui::Slider::new(&mut params.disk_brightness, 0.0..=3.0).text("Brightness"));
|
|
||||||
ui.add(egui::Slider::new(&mut params.disk_rotation_speed, 0.0..=3.0).text("Rotation speed"));
|
|
||||||
use crate::params::DiskColorMode;
|
|
||||||
let mut cm = params.disk_color_mode;
|
|
||||||
egui::ComboBox::from_label("Color model")
|
|
||||||
.selected_text(format!("{:?}", cm))
|
|
||||||
.show_ui(ui, |ui| {
|
|
||||||
ui.selectable_value(&mut cm, DiskColorMode::Gradient, "Gradient");
|
|
||||||
ui.selectable_value(&mut cm, DiskColorMode::Blackbody, "Blackbody");
|
|
||||||
});
|
|
||||||
params.disk_color_mode = cm;
|
|
||||||
ui.add_enabled(
|
|
||||||
cm == DiskColorMode::Blackbody,
|
|
||||||
egui::Slider::new(&mut params.disk_temp, 1000.0..=50000.0).text("Temperature (K)"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
egui::CollapsingHeader::new("Planets")
|
|
||||||
.default_open(false)
|
|
||||||
.show(ui, |ui| {
|
|
||||||
// Record state before edits; if seed/count/k/enabled change,
|
|
||||||
// flag dirty so spawn_planet_system regenerates next frame.
|
|
||||||
// (time_scale excluded: it only scales orbit_system's time,
|
|
||||||
// no respawn needed.)
|
|
||||||
let prev = (
|
|
||||||
params.planets_enabled,
|
|
||||||
params.planet_count_target,
|
|
||||||
params.planet_radius_factor,
|
|
||||||
params.planet_seed,
|
|
||||||
);
|
|
||||||
ui.checkbox(&mut params.planets_enabled, "Enable");
|
|
||||||
ui.add(egui::Slider::new(&mut params.planet_count_target, 0..=8).text("Count"));
|
|
||||||
ui.add(egui::Slider::new(&mut params.planet_radius_factor, 1.1..=2.0).text("Radius (× disk outer)"));
|
|
||||||
ui.label(format!("Orbit r = {:.2} (disk outer: {:.1})", params.planet_radius_factor * params.disk_outer, params.disk_outer));
|
|
||||||
ui.add(egui::Slider::new(&mut params.planet_seed, 0..=1000).text("Seed"));
|
|
||||||
ui.add(egui::Slider::new(&mut params.planet_time_scale, 1.0..=200.0).text("Time scale"));
|
|
||||||
let curr = (
|
|
||||||
params.planets_enabled,
|
|
||||||
params.planet_count_target,
|
|
||||||
params.planet_radius_factor,
|
|
||||||
params.planet_seed,
|
|
||||||
);
|
|
||||||
if curr != prev {
|
|
||||||
planet_dirty.0 = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
egui::CollapsingHeader::new("Disk Turbulence")
|
|
||||||
.default_open(true)
|
|
||||||
.show(ui, |ui| {
|
|
||||||
use crate::params::DiskQuality;
|
|
||||||
let mut q = params.disk_quality;
|
|
||||||
egui::ComboBox::from_label("Disk quality")
|
|
||||||
.selected_text(format!("{:?}", q))
|
|
||||||
.show_ui(ui, |ui| {
|
|
||||||
ui.selectable_value(&mut q, DiskQuality::Off, "Off");
|
|
||||||
ui.selectable_value(&mut q, DiskQuality::Low, "Low");
|
|
||||||
ui.selectable_value(&mut q, DiskQuality::Medium, "Medium");
|
|
||||||
ui.selectable_value(&mut q, DiskQuality::High, "High");
|
|
||||||
});
|
|
||||||
params.disk_quality = q;
|
|
||||||
let on = q != DiskQuality::Off;
|
|
||||||
ui.add_enabled(on, egui::Slider::new(&mut params.disk_half_thickness, 0.02..=0.3).text("Thickness (H/R)"));
|
|
||||||
ui.add_enabled(on, egui::Slider::new(&mut params.filament_freq, 0.2..=4.0).text("Filament frequency"));
|
|
||||||
ui.add_enabled(on, egui::Slider::new(&mut params.filament_sharpness, 1.0..=6.0).text("Filament sharpness"));
|
|
||||||
ui.add_enabled(on, egui::Slider::new(&mut params.density_freq, 0.2..=3.0).text("Density frequency"));
|
|
||||||
ui.add_enabled(on, egui::Slider::new(&mut params.density_strength, 0.0..=2.0).text("Density strength"));
|
|
||||||
ui.add_enabled(on, egui::Slider::new(&mut params.arm_count, 0.0..=6.0).text("Arm count"));
|
|
||||||
ui.add_enabled(on, egui::Slider::new(&mut params.arm_tightness, 0.0..=6.0).text("Arm tightness"));
|
|
||||||
ui.add_enabled(on, egui::Slider::new(&mut params.arm_strength, 0.0..=1.0).text("Arm strength"));
|
|
||||||
});
|
|
||||||
egui::CollapsingHeader::new("Doppler").show(ui, |ui| {
|
|
||||||
ui.checkbox(&mut params.doppler_enabled, "Enabled");
|
|
||||||
ui.add_enabled(params.doppler_enabled, egui::Slider::new(&mut params.doppler_strength, 0.0..=3.0).text("Strength"));
|
|
||||||
});
|
|
||||||
egui::CollapsingHeader::new("Jets").show(ui, |ui| {
|
|
||||||
ui.checkbox(&mut params.jets_enabled, "Enabled");
|
|
||||||
// Mirror the shader's spin gate (sample_jets in black_hole.wgsl):
|
|
||||||
// jets are a spin-powered (Blandford-Znajek) outflow and render
|
|
||||||
// only for χ ≥ 0.05. When the user enables them at low spin,
|
|
||||||
// explain the no-op so the checkbox doesn't look broken.
|
|
||||||
let jets_renderable = params.spin >= 0.05;
|
|
||||||
if params.jets_enabled && !jets_renderable {
|
|
||||||
ui.label("Spin (χ) too low — jets need χ ≥ 0.05.");
|
|
||||||
}
|
|
||||||
ui.add_enabled(
|
|
||||||
params.jets_enabled && jets_renderable,
|
|
||||||
egui::Slider::new(&mut params.jets_strength, 0.0..=3.0).text("Strength"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
egui::CollapsingHeader::new("Renderer").show(ui, |ui| {
|
|
||||||
ui.add(egui::Slider::new(&mut params.steps, 50..=600).text("Steps"));
|
|
||||||
ui.add(egui::Slider::new(&mut params.render_scale, 0.25..=1.0).text("Render scale"));
|
|
||||||
});
|
|
||||||
egui::CollapsingHeader::new("Background").show(ui, |ui| {
|
|
||||||
ui.add(egui::Slider::new(&mut params.star_intensity, 0.0..=3.0).text("Star intensity"));
|
|
||||||
ui.add(egui::Slider::new(&mut params.skybox_intensity, 0.0..=3.0).text("Skybox intensity"));
|
|
||||||
});
|
|
||||||
egui::CollapsingHeader::new("Grid").show(ui, |ui| {
|
|
||||||
ui.checkbox(&mut params.grid_enabled, "Enabled");
|
|
||||||
ui.add_enabled(params.grid_enabled, egui::Slider::new(&mut params.grid_density, 0.1..=4.0).text("Density"));
|
|
||||||
});
|
|
||||||
egui::CollapsingHeader::new("Quality")
|
|
||||||
.default_open(true)
|
|
||||||
.show(ui, |ui| {
|
|
||||||
use crate::params::BloomQuality;
|
|
||||||
let mut q = params.bloom_quality;
|
|
||||||
egui::ComboBox::from_label("Bloom quality")
|
|
||||||
.selected_text(format!("{:?}", q))
|
|
||||||
.show_ui(ui, |ui| {
|
|
||||||
ui.selectable_value(&mut q, BloomQuality::Off, "Off");
|
|
||||||
ui.selectable_value(&mut q, BloomQuality::Low, "Low");
|
|
||||||
ui.selectable_value(&mut q, BloomQuality::Medium, "Medium");
|
|
||||||
ui.selectable_value(&mut q, BloomQuality::High, "High");
|
|
||||||
});
|
|
||||||
params.bloom_quality = q;
|
|
||||||
ui.add_enabled(q != BloomQuality::Off, egui::Slider::new(&mut params.bloom_threshold, 0.0..=3.0).text("Bloom threshold"));
|
|
||||||
ui.add_enabled(q != BloomQuality::Off, egui::Slider::new(&mut params.bloom_strength, 0.0..=2.0).text("Bloom strength"));
|
|
||||||
ui.add(egui::Slider::new(&mut params.exposure, 0.5..=3.0).text("Exposure"));
|
|
||||||
ui.add(egui::Slider::new(&mut params.render_scale, 0.25..=1.0).text("Resolution scale"));
|
|
||||||
ui.checkbox(&mut params.star_aa, "Anti-aliased stars");
|
|
||||||
{
|
|
||||||
// Per-pixel supersampling: antialiases the higher-order
|
|
||||||
// lensed-image rings on the disk into a smooth gradient.
|
|
||||||
// Cost scales linearly with sample count (each sub-ray
|
|
||||||
// runs the full RK45 march).
|
|
||||||
use crate::params::AaQuality;
|
|
||||||
let mut a = params.aa_quality;
|
|
||||||
egui::ComboBox::from_label("Ring anti-alias")
|
|
||||||
.selected_text(format!("{:?} ({}×)", a, a.samples()))
|
|
||||||
.show_ui(ui, |ui| {
|
|
||||||
ui.selectable_value(&mut a, AaQuality::Off, "Off (1×)");
|
|
||||||
ui.selectable_value(&mut a, AaQuality::Low, "Low (2×)");
|
|
||||||
ui.selectable_value(&mut a, AaQuality::High, "High (4×)");
|
|
||||||
});
|
|
||||||
params.aa_quality = a;
|
|
||||||
}
|
|
||||||
ui.label("MSAA is decorative on a fullscreen shader (no geometry edges to sample).");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// egui captures pointer when the cursor is over a window or being interacted with.
|
|
||||||
wants.0 = ctx.egui_wants_pointer_input();
|
|
||||||
} else {
|
|
||||||
wants.0 = false;
|
wants.0 = false;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Custom-detection (skip on the frame a preset was just applied) ---
|
||||||
|
let now_hash = params_hash(¶ms);
|
||||||
|
if !*just_applied && last_hash.is_some() && now_hash != last_hash.unwrap() {
|
||||||
|
// Some preset-touched field changed by hand. If it no longer matches
|
||||||
|
// the current concrete preset's canonical bundle, flip to Custom.
|
||||||
|
let matches_any = matches!(
|
||||||
|
*current_preset,
|
||||||
|
Preset::Cinematic | Preset::Performance | Preset::Web
|
||||||
|
) && canonical_hash(*current_preset) == now_hash;
|
||||||
|
if !matches_any && *current_preset != Preset::Custom {
|
||||||
|
*current_preset = Preset::Custom;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
*just_applied = false;
|
||||||
|
*last_hash = Some(now_hash);
|
||||||
|
|
||||||
|
// --- Chassis ---
|
||||||
|
// egui 0.35's Panel::show needs a parent `&mut Ui`, but bevy_egui's
|
||||||
|
// single-pass `EguiPrimaryContextPass` only hands us a `Context`. Create
|
||||||
|
// the top-level Ui manually (matches bevy_egui 0.41's side_panel example).
|
||||||
|
// `Context` is an `Arc`, so cloning it here is cheap.
|
||||||
|
let mut root_ui = egui::Ui::new(
|
||||||
|
ctx.clone(),
|
||||||
|
"controls_root".into(),
|
||||||
|
UiBuilder::new()
|
||||||
|
.layer_id(LayerId::background())
|
||||||
|
.max_rect(ctx.viewport_rect()),
|
||||||
|
);
|
||||||
|
egui::Panel::right("controls")
|
||||||
|
.default_size(300.0)
|
||||||
|
.size_range(260.0..=400.0)
|
||||||
|
.resizable(true)
|
||||||
|
.show(&mut root_ui, |ui| {
|
||||||
|
// Top bar (fixed).
|
||||||
|
preset_bar(ui, &mut params, &mut current_preset, &mut just_applied);
|
||||||
|
|
||||||
|
// Section stack (scrolls).
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
use crate::ui::sections::*;
|
||||||
|
|
||||||
|
// --- Always-open cards ---
|
||||||
|
group(ui, "Camera", |ui| section_camera(ui, &mut camera));
|
||||||
|
group(ui, "Black Hole", |ui| section_black_hole(ui, &mut params));
|
||||||
|
group(ui, "Accretion Disk", |ui| section_disk(ui, &mut params));
|
||||||
|
group(ui, "Quality", |ui| section_quality(ui, &mut params));
|
||||||
|
|
||||||
|
// --- Collapsing sections ---
|
||||||
|
collapsing(ui, "turbulence", "Disk Turbulence", false,
|
||||||
|
|ui| section_turbulence(ui, &mut params));
|
||||||
|
|
||||||
|
// collapsing_with_toggle takes `&mut bool` for the header
|
||||||
|
// checkbox AND a body closure that mutates `params`. Passing
|
||||||
|
// `&mut params.<field>` plus a closure borrowing `&mut params`
|
||||||
|
// is a double-mutable-borrow; copy the field out, pass a local
|
||||||
|
// ref, then write it back. (All four are `bool` → Copy.)
|
||||||
|
let mut en = params.doppler_enabled;
|
||||||
|
collapsing_with_toggle(ui, "doppler", "Doppler", false,
|
||||||
|
&mut en,
|
||||||
|
|ui, en| section_doppler(ui, &mut params, en));
|
||||||
|
params.doppler_enabled = en;
|
||||||
|
|
||||||
|
let mut en = params.jets_enabled;
|
||||||
|
collapsing_with_toggle(ui, "jets", "Jets", false,
|
||||||
|
&mut en,
|
||||||
|
|ui, en| section_jets(ui, &mut params, en));
|
||||||
|
params.jets_enabled = en;
|
||||||
|
|
||||||
|
// Planets: snapshot dirty-relevant fields before rendering so
|
||||||
|
// we can detect changes (relocated from the old Planets block).
|
||||||
|
let prev_planet = (
|
||||||
|
params.planets_enabled,
|
||||||
|
params.planet_count_target,
|
||||||
|
params.planet_radius_factor,
|
||||||
|
params.planet_seed,
|
||||||
|
);
|
||||||
|
let mut en = params.planets_enabled;
|
||||||
|
collapsing_with_toggle(ui, "planets", "Planets", false,
|
||||||
|
&mut en,
|
||||||
|
|ui, en| section_planets(ui, &mut params, en));
|
||||||
|
params.planets_enabled = en;
|
||||||
|
let curr_planet = (
|
||||||
|
params.planets_enabled,
|
||||||
|
params.planet_count_target,
|
||||||
|
params.planet_radius_factor,
|
||||||
|
params.planet_seed,
|
||||||
|
);
|
||||||
|
if curr_planet != prev_planet {
|
||||||
|
planet_dirty.0 = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
collapsing(ui, "background", "Background", false,
|
||||||
|
|ui| section_background(ui, &mut params));
|
||||||
|
|
||||||
|
let mut en = params.grid_enabled;
|
||||||
|
collapsing_with_toggle(ui, "grid", "Grid", false,
|
||||||
|
&mut en,
|
||||||
|
|ui, en| section_grid(ui, &mut params, en));
|
||||||
|
params.grid_enabled = en;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// egui captures pointer when the cursor is over a window or being
|
||||||
|
// interacted with. MUST stay last — load-bearing for orbit camera.
|
||||||
|
wants.0 = ctx.egui_wants_pointer_input();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,14 +10,15 @@ use crate::params::{AaQuality, BlackHoleParams, BloomQuality, DiskQuality};
|
|||||||
|
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||||
pub enum Preset {
|
pub enum Preset {
|
||||||
Cinematic,
|
#[default]
|
||||||
Performance,
|
|
||||||
Web,
|
|
||||||
/// Read-only marker set when any preset-touched field is hand-edited
|
/// Read-only marker set when any preset-touched field is hand-edited
|
||||||
/// away from a preset bundle. `apply(Custom, _)` is a no-op.
|
/// away from a preset bundle. `apply(Custom, _)` is a no-op.
|
||||||
Custom,
|
Custom,
|
||||||
|
Cinematic,
|
||||||
|
Performance,
|
||||||
|
Web,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The exact params each preset writes. `Custom` writes nothing.
|
/// The exact params each preset writes. `Custom` writes nothing.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user