Merge branch 'ui-panel-redesign': egui control panel redesign
Replaces the single floating Window of 11 CollapsingHeader sections with a docked right SidePanel, deep-space cyan/orange theme, layered grouping (4 always-open cards + 6 collapsing sections), a Cinematic/Performance/ Web/Custom preset bar, and aligned two-column Grid slider rows. 12-task plan executed via subagent-driven-development. egui 0.35 API adaptations (SidePanel->Panel, Shadow integer fields, set_theme/ global_style_mut, CollapsingState path) verified against source. All 28 tests pass (physics mirror + preset hash detection).
This commit is contained in:
commit
e779ad7bb7
11
src/lib.rs
11
src/lib.rs
@ -1 +1,12 @@
|
|||||||
|
// Lib crate root. The bin (main.rs) consumes these via `singularity_rs::...`
|
||||||
|
// rather than redeclaring the modules itself; otherwise both crate roots
|
||||||
|
// would try to own the same file-level modules (compile error: E0428/
|
||||||
|
// E0152). This is why every module the binary needs lives here.
|
||||||
|
pub mod camera;
|
||||||
|
pub mod params;
|
||||||
pub mod physics;
|
pub mod physics;
|
||||||
|
pub mod render;
|
||||||
|
pub mod scene;
|
||||||
|
pub mod ui;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub mod web;
|
||||||
|
|||||||
14
src/main.rs
14
src/main.rs
@ -1,14 +1,12 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::WindowPlugin;
|
use bevy::window::WindowPlugin;
|
||||||
|
|
||||||
|
// The bin consumes the lib crate's modules; it does NOT redeclare them.
|
||||||
|
// src/lib.rs owns every file-level module (camera/params/physics/render/
|
||||||
|
// scene/ui/web), so we import what main() needs from the lib root.
|
||||||
|
use singularity_rs::render::BlackHolePlugin;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
mod web;
|
use singularity_rs::web;
|
||||||
mod render;
|
|
||||||
mod camera;
|
|
||||||
mod params;
|
|
||||||
mod ui;
|
|
||||||
mod scene;
|
|
||||||
mod physics;
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// On web, abort startup if WebGPU isn't available and show a message.
|
// On web, abort startup if WebGPU isn't available and show a message.
|
||||||
@ -50,6 +48,6 @@ fn main() {
|
|||||||
// Dropping it removes that noise and shrinks the wasm binary.
|
// Dropping it removes that noise and shrinks the wasm binary.
|
||||||
.disable::<bevy::audio::AudioPlugin>(),
|
.disable::<bevy::audio::AudioPlugin>(),
|
||||||
)
|
)
|
||||||
.add_plugins(render::BlackHolePlugin)
|
.add_plugins(BlackHolePlugin)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -131,7 +131,12 @@ impl Plugin for BlackHolePlugin {
|
|||||||
.add_systems(Update, rebuild_bloom)
|
.add_systems(Update, rebuild_bloom)
|
||||||
// bevy_egui 0.41 requires UI systems to run inside the egui context
|
// bevy_egui 0.41 requires UI systems to run inside the egui context
|
||||||
// pass (fonts/ctx are initialized there); placing them in Update panics.
|
// pass (fonts/ctx are initialized there); placing them in Update panics.
|
||||||
.add_systems(bevy_egui::EguiPrimaryContextPass, crate::ui::ui_system);
|
// setup_egui_style is a one-shot (Local<bool> guard): retries until the
|
||||||
|
// context exists, applies the theme once, never runs again.
|
||||||
|
.add_systems(
|
||||||
|
bevy_egui::EguiPrimaryContextPass,
|
||||||
|
(crate::ui::setup_egui_style, crate::ui::ui_system).chain(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
185
src/ui.rs
185
src/ui.rs
@ -1,185 +0,0 @@
|
|||||||
use bevy::prelude::*;
|
|
||||||
use bevy_egui::egui;
|
|
||||||
|
|
||||||
pub fn ui_system(
|
|
||||||
mut contexts: bevy_egui::EguiContexts,
|
|
||||||
mut params: ResMut<crate::params::BlackHoleParams>,
|
|
||||||
mut camera: ResMut<crate::camera::OrbitCamera>,
|
|
||||||
mut wants: ResMut<crate::camera::WantsPointer>,
|
|
||||||
mut planet_dirty: ResMut<crate::scene::planets::PlanetSystemDirty>,
|
|
||||||
) {
|
|
||||||
if let Ok(ctx) = contexts.ctx_mut() {
|
|
||||||
egui::Window::new("Controls")
|
|
||||||
.collapsible(true)
|
|
||||||
.resizable(true)
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
259
src/ui/mod.rs
Normal file
259
src/ui/mod.rs
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
mod sections;
|
||||||
|
mod style;
|
||||||
|
pub mod preset;
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use bevy_egui::egui;
|
||||||
|
use egui::{LayerId, UiBuilder};
|
||||||
|
|
||||||
|
use crate::ui::preset::{Preset, apply, canonical_hash, params_hash};
|
||||||
|
use crate::ui::style::ACCENT_CYAN;
|
||||||
|
|
||||||
|
/// One-shot egui styling. Registered in `EguiPrimaryContextPass` with a
|
||||||
|
/// `Local<bool>` guard so it retries until `ctx_mut()` first succeeds, then
|
||||||
|
/// runs exactly once. Per-frame set_style/set_visuals would dirty layout
|
||||||
|
/// caches every frame; this avoids that.
|
||||||
|
pub fn setup_egui_style(
|
||||||
|
mut contexts: bevy_egui::EguiContexts,
|
||||||
|
mut done: Local<bool>,
|
||||||
|
) {
|
||||||
|
if *done {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Ok(ctx) = contexts.ctx_mut() {
|
||||||
|
crate::ui::style::setup(&ctx);
|
||||||
|
*done = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An always-open framed card. Title in cyan `RichText::strong().small()`.
|
||||||
|
/// Used for the 4 most-tuned groups (Camera / Black Hole / Disk / Quality).
|
||||||
|
fn group(ui: &mut egui::Ui, title: &str, body: impl FnOnce(&mut egui::Ui)) {
|
||||||
|
egui::Frame::group(ui.style())
|
||||||
|
.inner_margin(8.0)
|
||||||
|
.corner_radius(6.0)
|
||||||
|
.stroke(egui::Stroke::new(
|
||||||
|
1.0,
|
||||||
|
ui.visuals().window_stroke.color,
|
||||||
|
))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(ui.available_width());
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(title)
|
||||||
|
.strong()
|
||||||
|
.small()
|
||||||
|
.color(ACCENT_CYAN),
|
||||||
|
);
|
||||||
|
ui.add_space(2.0);
|
||||||
|
body(ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A collapsible section (`default_open` controls initial state).
|
||||||
|
/// Used for the 6 secondary groups. The body closure is only invoked when open.
|
||||||
|
fn collapsing(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
id: &str,
|
||||||
|
title: &str,
|
||||||
|
default_open: bool,
|
||||||
|
body: impl FnOnce(&mut egui::Ui),
|
||||||
|
) {
|
||||||
|
egui::CollapsingHeader::new(title)
|
||||||
|
.default_open(default_open)
|
||||||
|
.id_salt(id)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(ui.available_width());
|
||||||
|
body(ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collapsible section whose header hosts an enable toggle on the right.
|
||||||
|
/// Used by Doppler / Jets / Grid / Planets (design §4.1). The body closure
|
||||||
|
/// receives the current `enabled` state so it can `add_enabled` its rows.
|
||||||
|
///
|
||||||
|
/// State persistence is handled by `HeaderResponse::body`, which calls
|
||||||
|
/// `CollapsingState::store` internally via `show_body_indented` (see egui
|
||||||
|
/// 0.35 `containers/collapsing_header.rs`). `show_header` consumes the
|
||||||
|
/// state, so there is no separate `store` call here.
|
||||||
|
fn collapsing_with_toggle(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
id: &str,
|
||||||
|
title: &str,
|
||||||
|
default_open: bool,
|
||||||
|
enabled: &mut bool,
|
||||||
|
body: impl FnOnce(&mut egui::Ui, bool),
|
||||||
|
) {
|
||||||
|
let id = ui.make_persistent_id(id);
|
||||||
|
let state =
|
||||||
|
egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, default_open);
|
||||||
|
state
|
||||||
|
.show_header(ui, |ui| {
|
||||||
|
ui.checkbox(enabled, title);
|
||||||
|
})
|
||||||
|
.body(|ui| {
|
||||||
|
ui.set_width(ui.available_width());
|
||||||
|
body(ui, *enabled);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The top preset bar. `current` is the UI-layer state (which preset is
|
||||||
|
/// shown as selected); `just_applied` is set for one frame after a preset
|
||||||
|
/// is chosen, to skip the Custom-detection hash compare on that frame
|
||||||
|
/// (applying a preset changes params; that change must not flip to Custom).
|
||||||
|
fn preset_bar(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
params: &mut crate::params::BlackHoleParams,
|
||||||
|
current: &mut Preset,
|
||||||
|
just_applied: &mut bool,
|
||||||
|
) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Preset:");
|
||||||
|
let prev = *current;
|
||||||
|
egui::ComboBox::from_id_salt("preset_combo")
|
||||||
|
.selected_text(format!("{:?}", prev))
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
ui.selectable_value(current, Preset::Cinematic, "Cinematic");
|
||||||
|
ui.selectable_value(current, Preset::Performance, "Performance");
|
||||||
|
ui.selectable_value(current, Preset::Web, "Web");
|
||||||
|
ui.selectable_value(current, Preset::Custom, "Custom");
|
||||||
|
});
|
||||||
|
if *current != prev && *current != Preset::Custom {
|
||||||
|
// User picked a concrete preset → apply its bundle.
|
||||||
|
apply(*current, params);
|
||||||
|
*just_applied = true;
|
||||||
|
}
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
// Global reset-to-default for all params.
|
||||||
|
if ui.button("↺ all").clicked() {
|
||||||
|
*params = crate::params::BlackHoleParams::default();
|
||||||
|
*current = Preset::Custom;
|
||||||
|
*just_applied = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ui_system(
|
||||||
|
mut contexts: bevy_egui::EguiContexts,
|
||||||
|
mut params: ResMut<crate::params::BlackHoleParams>,
|
||||||
|
mut camera: ResMut<crate::camera::OrbitCamera>,
|
||||||
|
mut wants: ResMut<crate::camera::WantsPointer>,
|
||||||
|
mut planet_dirty: ResMut<crate::scene::planets::PlanetSystemDirty>,
|
||||||
|
mut current_preset: Local<Preset>,
|
||||||
|
mut just_applied: Local<bool>,
|
||||||
|
mut last_hash: Local<Option<u64>>,
|
||||||
|
) {
|
||||||
|
// Local<Preset> defaults to Custom (Preset::default()).
|
||||||
|
// Local<Option<u64>> defaults to None — first-frame sentinel.
|
||||||
|
|
||||||
|
let Ok(ctx) = contexts.ctx_mut() else {
|
||||||
|
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();
|
||||||
|
}
|
||||||
101
src/ui/preset.rs
Normal file
101
src/ui/preset.rs
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
//! Preset bundles + Custom-edit detection (design spec §3).
|
||||||
|
//!
|
||||||
|
//! `params_hash` hashes ONLY the fields that presets touch — otherwise a
|
||||||
|
//! non-preset edit (e.g. disk_tilt) would spuriously flip the bar to Custom.
|
||||||
|
//! The field set below is the single source of truth; if you add a field to
|
||||||
|
//! a preset bundle, add it to `hashed_fields` too or the test
|
||||||
|
//! `non_preset_field_change_flips_to_custom` will not catch the leak.
|
||||||
|
|
||||||
|
use crate::params::{AaQuality, BlackHoleParams, BloomQuality, DiskQuality};
|
||||||
|
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||||
|
pub enum Preset {
|
||||||
|
#[default]
|
||||||
|
/// Read-only marker set when any preset-touched field is hand-edited
|
||||||
|
/// away from a preset bundle. `apply(Custom, _)` is a no-op.
|
||||||
|
Custom,
|
||||||
|
Cinematic,
|
||||||
|
Performance,
|
||||||
|
Web,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The exact params each preset writes. `Custom` writes nothing.
|
||||||
|
fn bundle(p: Preset) -> Option<HashedParams> {
|
||||||
|
// Defaults chosen to mirror cfg!(wasm32) dual-defaults already in the codebase:
|
||||||
|
// Cinematic = desktop default, Web = wasm default, Performance = a low tier.
|
||||||
|
Some(match p {
|
||||||
|
Preset::Cinematic => HashedParams {
|
||||||
|
steps: 300, render_scale: 0.75,
|
||||||
|
bloom_quality: BloomQuality::High, disk_quality: DiskQuality::High, aa_quality: AaQuality::High,
|
||||||
|
},
|
||||||
|
Preset::Performance => HashedParams {
|
||||||
|
steps: 150, render_scale: 0.5,
|
||||||
|
bloom_quality: BloomQuality::Low, disk_quality: DiskQuality::Low, aa_quality: AaQuality::Off,
|
||||||
|
},
|
||||||
|
Preset::Web => HashedParams {
|
||||||
|
steps: 200, render_scale: 0.5,
|
||||||
|
bloom_quality: BloomQuality::Low, disk_quality: DiskQuality::Low, aa_quality: AaQuality::Off,
|
||||||
|
},
|
||||||
|
Preset::Custom => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a preset's bundle to params. `Custom` is a no-op.
|
||||||
|
pub fn apply(p: Preset, params: &mut BlackHoleParams) {
|
||||||
|
if let Some(b) = bundle(p) {
|
||||||
|
params.steps = b.steps;
|
||||||
|
params.render_scale = b.render_scale;
|
||||||
|
params.bloom_quality = b.bloom_quality;
|
||||||
|
params.disk_quality = b.disk_quality;
|
||||||
|
params.aa_quality = b.aa_quality;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable hash of a preset's canonical bundle. `Custom` returns 0.
|
||||||
|
/// (The Custom case is never fed to a `canonical_hash == params_hash`
|
||||||
|
/// comparison: `ui_system`'s preset-detection arm only checks
|
||||||
|
/// Cinematic | Performance | Web, so the 0 sentinel never risks matching.)
|
||||||
|
pub fn canonical_hash(p: Preset) -> u64 {
|
||||||
|
match bundle(p) {
|
||||||
|
Some(b) => b.hash(),
|
||||||
|
None => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash of the preset-touched fields of a live params. Used by `ui_system`
|
||||||
|
/// to detect hand-edits and flip the bar to Custom.
|
||||||
|
pub fn params_hash(params: &BlackHoleParams) -> u64 {
|
||||||
|
let h = HashedParams {
|
||||||
|
steps: params.steps,
|
||||||
|
render_scale: params.render_scale,
|
||||||
|
bloom_quality: params.bloom_quality,
|
||||||
|
disk_quality: params.disk_quality,
|
||||||
|
aa_quality: params.aa_quality,
|
||||||
|
};
|
||||||
|
h.hash()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- internals ---
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct HashedParams {
|
||||||
|
steps: u32,
|
||||||
|
render_scale: f32,
|
||||||
|
bloom_quality: BloomQuality,
|
||||||
|
disk_quality: DiskQuality,
|
||||||
|
aa_quality: AaQuality,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HashedParams {
|
||||||
|
fn hash(&self) -> u64 {
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
self.steps.hash(&mut hasher);
|
||||||
|
self.render_scale.to_bits().hash(&mut hasher); // f32: hash bit pattern, not value
|
||||||
|
self.bloom_quality.levels().hash(&mut hasher);
|
||||||
|
self.disk_quality.as_u32().hash(&mut hasher);
|
||||||
|
self.aa_quality.samples().hash(&mut hasher);
|
||||||
|
hasher.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
253
src/ui/sections.rs
Normal file
253
src/ui/sections.rs
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
//! Per-group section render functions. Each takes the `&mut egui::Ui` it
|
||||||
|
//! draws into plus the params/camera refs it needs. The `ui_system`
|
||||||
|
//! orchestrator (mod.rs) calls them inside `group()` / `collapsing()`.
|
||||||
|
|
||||||
|
use bevy_egui::egui;
|
||||||
|
|
||||||
|
use crate::camera::OrbitCamera;
|
||||||
|
use crate::params::{
|
||||||
|
AaQuality, BlackHoleParams, BloomQuality, DiskColorMode,
|
||||||
|
};
|
||||||
|
use crate::ui::style::{ACCENT_ORANGE, MUTED_TEXT};
|
||||||
|
|
||||||
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
|
/// Shared two-column row helper: label + sized widget.
|
||||||
|
fn row(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
label: &str,
|
||||||
|
add_widget: impl FnOnce(&mut egui::Ui) -> egui::Response,
|
||||||
|
) {
|
||||||
|
ui.label(label);
|
||||||
|
add_widget(ui);
|
||||||
|
ui.end_row();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================ Always-open cards ============================
|
||||||
|
|
||||||
|
pub fn section_camera(ui: &mut egui::Ui, cam: &mut OrbitCamera) {
|
||||||
|
egui::Grid::new("camera_grid").num_columns(2).spacing([8.0, 4.0])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
row(ui, "Distance", |ui| ui.add_sized([140.0, 16.0],
|
||||||
|
egui::Slider::new(&mut cam.distance, 3.0..=200.0)
|
||||||
|
.suffix(" r_g").fixed_decimals(1)));
|
||||||
|
row(ui, "Yaw", |ui| ui.add_sized([140.0, 16.0],
|
||||||
|
egui::Slider::new(&mut cam.yaw, -PI..=PI)
|
||||||
|
.suffix(" rad").fixed_decimals(2)));
|
||||||
|
row(ui, "Pitch", |ui| ui.add_sized([140.0, 16.0],
|
||||||
|
egui::Slider::new(&mut cam.pitch, (-PI + 0.05)..=(PI - 0.05))
|
||||||
|
.suffix(" rad").fixed_decimals(2)));
|
||||||
|
row(ui, "FOV", |ui| ui.add_sized([140.0, 16.0],
|
||||||
|
egui::Slider::new(&mut cam.fov, 0.3..=2.0).fixed_decimals(2)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn section_black_hole(ui: &mut egui::Ui, params: &mut BlackHoleParams) {
|
||||||
|
egui::Grid::new("bh_grid").num_columns(2).spacing([8.0, 4.0])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
row(ui, "Spin (χ)", |ui| ui.add_sized([140.0, 16.0],
|
||||||
|
egui::Slider::new(&mut params.spin, 0.0..=1.0).fixed_decimals(2)));
|
||||||
|
// Read-only derived values: right column, muted color.
|
||||||
|
ui.label(egui::RichText::new("ISCO (disk inner)").color(MUTED_TEXT));
|
||||||
|
ui.label(egui::RichText::new(format!("{:.3}", crate::physics::kerr_isco(params.spin)))
|
||||||
|
.color(MUTED_TEXT));
|
||||||
|
ui.end_row();
|
||||||
|
ui.label(egui::RichText::new("Horizon r+").color(MUTED_TEXT));
|
||||||
|
ui.label(egui::RichText::new(format!("{:.3}", crate::physics::kerr_horizon(params.spin)))
|
||||||
|
.color(MUTED_TEXT));
|
||||||
|
ui.end_row();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn section_quality(ui: &mut egui::Ui, params: &mut BlackHoleParams) {
|
||||||
|
// Bloom quality combobox + threshold/strength (hidden when Off — §4.3).
|
||||||
|
{
|
||||||
|
let mut q = params.bloom_quality;
|
||||||
|
egui::Grid::new("bloom_grid").num_columns(2).spacing([8.0, 4.0])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.label("Bloom");
|
||||||
|
egui::ComboBox::from_id_salt("bloom_combo")
|
||||||
|
.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");
|
||||||
|
});
|
||||||
|
ui.end_row();
|
||||||
|
});
|
||||||
|
params.bloom_quality = q;
|
||||||
|
}
|
||||||
|
if params.bloom_quality != BloomQuality::Off {
|
||||||
|
egui::Grid::new("bloom_detail_grid").num_columns(2).spacing([8.0, 4.0])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
row(ui, "Threshold", |ui| ui.add_sized([140.0, 16.0],
|
||||||
|
egui::Slider::new(&mut params.bloom_threshold, 0.0..=3.0).fixed_decimals(2)));
|
||||||
|
row(ui, "Strength", |ui| ui.add_sized([140.0, 16.0],
|
||||||
|
egui::Slider::new(&mut params.bloom_strength, 0.0..=2.0).fixed_decimals(2)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
egui::Grid::new("renderer_grid").num_columns(2).spacing([8.0, 4.0])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
// steps + render_scale migrated here from the deleted Renderer section.
|
||||||
|
row(ui, "Steps", |ui| ui.add_sized([140.0, 16.0],
|
||||||
|
egui::Slider::new(&mut params.steps, 50..=600)
|
||||||
|
.logarithmic(true).fixed_decimals(0)));
|
||||||
|
row(ui, "Resolution", |ui| ui.add_sized([140.0, 16.0],
|
||||||
|
egui::Slider::new(&mut params.render_scale, 0.25..=1.0)
|
||||||
|
.logarithmic(true).fixed_decimals(2)));
|
||||||
|
row(ui, "Exposure", |ui| ui.add_sized([140.0, 16.0],
|
||||||
|
egui::Slider::new(&mut params.exposure, 0.5..=3.0).fixed_decimals(2)));
|
||||||
|
row(ui, "Star AA", |ui| ui.checkbox(&mut params.star_aa, ""));
|
||||||
|
// Ring anti-alias (supersampling for lensed-image rings).
|
||||||
|
let mut a = params.aa_quality;
|
||||||
|
ui.label("Ring AA");
|
||||||
|
egui::ComboBox::from_id_salt("aa_combo")
|
||||||
|
.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×)");
|
||||||
|
});
|
||||||
|
ui.end_row();
|
||||||
|
params.aa_quality = a;
|
||||||
|
});
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new("MSAA is decorative on a fullscreen shader (no geometry edges).")
|
||||||
|
.small().color(MUTED_TEXT),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn section_disk(ui: &mut egui::Ui, params: &mut BlackHoleParams) {
|
||||||
|
egui::Grid::new("disk_grid").num_columns(2).spacing([8.0, 4.0])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
row(ui, "Outer radius", |ui| ui.add_sized([140.0, 16.0],
|
||||||
|
egui::Slider::new(&mut params.disk_outer, 6.0..=50.0)
|
||||||
|
.suffix(" r_g").fixed_decimals(1)));
|
||||||
|
row(ui, "Tilt", |ui| ui.add_sized([140.0, 16.0],
|
||||||
|
egui::Slider::new(&mut params.disk_tilt, 0.0..=PI)
|
||||||
|
.suffix(" rad").fixed_decimals(2)));
|
||||||
|
row(ui, "Brightness", |ui| ui.add_sized([140.0, 16.0],
|
||||||
|
egui::Slider::new(&mut params.disk_brightness, 0.0..=3.0).fixed_decimals(2)));
|
||||||
|
row(ui, "Rotation", |ui| ui.add_sized([140.0, 16.0],
|
||||||
|
egui::Slider::new(&mut params.disk_rotation_speed, 0.0..=3.0).fixed_decimals(2)));
|
||||||
|
// Color model combobox.
|
||||||
|
let mut cm = params.disk_color_mode;
|
||||||
|
ui.label("Color model");
|
||||||
|
egui::ComboBox::from_id_salt("disk_color_combo")
|
||||||
|
.selected_text(format!("{:?}", cm))
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
ui.selectable_value(&mut cm, DiskColorMode::Gradient, "Gradient");
|
||||||
|
ui.selectable_value(&mut cm, DiskColorMode::Blackbody, "Blackbody");
|
||||||
|
});
|
||||||
|
ui.end_row();
|
||||||
|
params.disk_color_mode = cm;
|
||||||
|
});
|
||||||
|
// Temperature only meaningful in Blackbody mode — disable (not hide) so
|
||||||
|
// the user sees their value while briefly in Gradient.
|
||||||
|
let blackbody = params.disk_color_mode == DiskColorMode::Blackbody;
|
||||||
|
ui.add_enabled(
|
||||||
|
blackbody,
|
||||||
|
egui::Slider::new(&mut params.disk_temp, 1000.0..=50000.0)
|
||||||
|
.suffix(" K")
|
||||||
|
.logarithmic(true)
|
||||||
|
.fixed_decimals(0)
|
||||||
|
.text("Temperature"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================ Collapsing sections ==========================
|
||||||
|
|
||||||
|
pub fn section_turbulence(ui: &mut egui::Ui, params: &mut BlackHoleParams) {
|
||||||
|
use crate::params::DiskQuality;
|
||||||
|
let mut q = params.disk_quality;
|
||||||
|
egui::Grid::new("diskq_grid").num_columns(2).spacing([8.0, 4.0])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.label("Disk quality");
|
||||||
|
egui::ComboBox::from_id_salt("diskq_combo")
|
||||||
|
.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");
|
||||||
|
});
|
||||||
|
ui.end_row();
|
||||||
|
});
|
||||||
|
params.disk_quality = q;
|
||||||
|
|
||||||
|
let on = q != DiskQuality::Off;
|
||||||
|
if !on {
|
||||||
|
ui.label(egui::RichText::new(
|
||||||
|
"Disk quality Off → flat zero-thickness disk rendered.",
|
||||||
|
).small().color(ACCENT_ORANGE));
|
||||||
|
}
|
||||||
|
egui::Grid::new("turb_grid").num_columns(2).spacing([8.0, 4.0])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.add_enabled(on, egui::Slider::new(&mut params.disk_half_thickness, 0.02..=0.3).text("Thickness (H/R)"));
|
||||||
|
ui.end_row();
|
||||||
|
ui.add_enabled(on, egui::Slider::new(&mut params.filament_freq, 0.2..=4.0).text("Filament frequency"));
|
||||||
|
ui.end_row();
|
||||||
|
ui.add_enabled(on, egui::Slider::new(&mut params.filament_sharpness, 1.0..=6.0).text("Filament sharpness"));
|
||||||
|
ui.end_row();
|
||||||
|
ui.add_enabled(on, egui::Slider::new(&mut params.density_freq, 0.2..=3.0).text("Density frequency"));
|
||||||
|
ui.end_row();
|
||||||
|
ui.add_enabled(on, egui::Slider::new(&mut params.density_strength, 0.0..=2.0).text("Density strength"));
|
||||||
|
ui.end_row();
|
||||||
|
ui.add_enabled(on, egui::Slider::new(&mut params.arm_count, 0.0..=6.0).text("Arm count"));
|
||||||
|
ui.end_row();
|
||||||
|
ui.add_enabled(on, egui::Slider::new(&mut params.arm_tightness, 0.0..=6.0).text("Arm tightness"));
|
||||||
|
ui.end_row();
|
||||||
|
ui.add_enabled(on, egui::Slider::new(&mut params.arm_strength, 0.0..=1.0).text("Arm strength"));
|
||||||
|
ui.end_row();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn section_doppler(ui: &mut egui::Ui, params: &mut BlackHoleParams, enabled: bool) {
|
||||||
|
ui.add_enabled(enabled, egui::Slider::new(&mut params.doppler_strength, 0.0..=3.0).text("Strength"));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn section_jets(ui: &mut egui::Ui, params: &mut BlackHoleParams, enabled: bool) {
|
||||||
|
// Mirror the shader's spin gate: jets render only for χ ≥ 0.05.
|
||||||
|
let jets_renderable = params.spin >= 0.05;
|
||||||
|
if enabled && !jets_renderable {
|
||||||
|
ui.label(egui::RichText::new(
|
||||||
|
"Jets need χ ≥ 0.05 (Blandford-Znajek is spin-powered).",
|
||||||
|
).small().color(ACCENT_ORANGE));
|
||||||
|
}
|
||||||
|
ui.add_enabled(
|
||||||
|
enabled && jets_renderable,
|
||||||
|
egui::Slider::new(&mut params.jets_strength, 0.0..=3.0).text("Strength"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn section_planets(ui: &mut egui::Ui, params: &mut BlackHoleParams, enabled: bool) {
|
||||||
|
ui.add_enabled(enabled, egui::Slider::new(&mut params.planet_count_target, 0..=8).text("Count"));
|
||||||
|
ui.add_enabled(enabled, egui::Slider::new(&mut params.planet_radius_factor, 1.1..=2.0).prefix("× ").text("Radius (× disk outer)"));
|
||||||
|
ui.add_enabled(
|
||||||
|
enabled,
|
||||||
|
egui::Label::new(
|
||||||
|
egui::RichText::new(format!(
|
||||||
|
"Orbit r = {:.2} (disk outer: {:.1})",
|
||||||
|
params.planet_radius_factor * params.disk_outer,
|
||||||
|
params.disk_outer
|
||||||
|
)).color(MUTED_TEXT),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ui.add_enabled(enabled, egui::Slider::new(&mut params.planet_seed, 0..=1000).text("Seed"));
|
||||||
|
ui.add_enabled(enabled, egui::Slider::new(&mut params.planet_time_scale, 1.0..=200.0).text("Time scale"));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn section_background(ui: &mut egui::Ui, params: &mut BlackHoleParams) {
|
||||||
|
egui::Grid::new("bg_grid").num_columns(2).spacing([8.0, 4.0])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
row(ui, "Star intensity", |ui| ui.add_sized([140.0, 16.0],
|
||||||
|
egui::Slider::new(&mut params.star_intensity, 0.0..=3.0).fixed_decimals(2)));
|
||||||
|
row(ui, "Skybox", |ui| ui.add_sized([140.0, 16.0],
|
||||||
|
egui::Slider::new(&mut params.skybox_intensity, 0.0..=3.0).fixed_decimals(2)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn section_grid(ui: &mut egui::Ui, params: &mut BlackHoleParams, enabled: bool) {
|
||||||
|
ui.add_enabled(enabled, egui::Slider::new(&mut params.grid_density, 0.1..=4.0).text("Density"));
|
||||||
|
}
|
||||||
83
src/ui/style.rs
Normal file
83
src/ui/style.rs
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
//! egui styling for the control panel. Deep-space cyan/orange theme,
|
||||||
|
//! hand-applied over `Visuals::dark()`. See design spec §1.
|
||||||
|
//!
|
||||||
|
//! Pinned to egui 0.35: the `Shadow` struct packs fields into 8-bit integers,
|
||||||
|
//! and the context style API is `set_theme` + `global_style_mut` (not the
|
||||||
|
//! `ctx.style()`/`set_style()` of 0.34). See inline comments at each site.
|
||||||
|
|
||||||
|
use bevy_egui::egui::{self, Color32, Stroke, TextStyle, Visuals};
|
||||||
|
|
||||||
|
// --- Palette (single source of truth; referenced by every section helper) ---
|
||||||
|
pub const ACCENT_CYAN: Color32 = Color32::from_rgb(90, 200, 255); // #5AC8FF
|
||||||
|
pub const ACCENT_ORANGE: Color32 = Color32::from_rgb(255, 140, 66); // #FF8C42
|
||||||
|
pub const PANEL_FILL: Color32 = Color32::from_rgb(14, 16, 20); // #0E1014
|
||||||
|
pub const EXTREME_BG: Color32 = Color32::from_rgb(8, 9, 12); // #08090C
|
||||||
|
pub const MUTED_TEXT: Color32 = Color32::from_rgb(140, 140, 140); // read-only / disabled labels
|
||||||
|
|
||||||
|
/// Text sizes applied over egui's default `Proportional` family. No custom
|
||||||
|
/// font binary is embedded (spec non-goal).
|
||||||
|
pub fn text_styles() -> Vec<(TextStyle, f32)> {
|
||||||
|
vec![
|
||||||
|
(TextStyle::Body, 14.0),
|
||||||
|
(TextStyle::Monospace, 12.0),
|
||||||
|
(TextStyle::Heading, 16.0),
|
||||||
|
(TextStyle::Small, 11.0),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The themed `Visuals`. Built fresh from `dark()` each call so it is
|
||||||
|
/// independent of whatever egui's defaults evolve into.
|
||||||
|
///
|
||||||
|
/// Note: `animation_time` lives on `Style`, not `Visuals`, since egui 0.35;
|
||||||
|
/// it is applied in [`setup`].
|
||||||
|
pub fn sci_fi_visuals() -> Visuals {
|
||||||
|
let mut v = Visuals::dark();
|
||||||
|
v.panel_fill = PANEL_FILL;
|
||||||
|
v.extreme_bg_color = EXTREME_BG;
|
||||||
|
v.hyperlink_color = ACCENT_CYAN; // also used as the section-heading color by convention
|
||||||
|
v.selection.bg_fill = ACCENT_CYAN;
|
||||||
|
v.selection.stroke = Stroke::new(1.0, ACCENT_ORANGE);
|
||||||
|
|
||||||
|
v.widgets.inactive.weak_bg_fill = Color32::from_rgb(30, 36, 48); // #1E2430
|
||||||
|
v.widgets.inactive.bg_stroke = Stroke::new(0.5, Color32::from_rgb(60, 70, 70)); // #3C4646
|
||||||
|
v.widgets.hovered.weak_bg_fill = Color32::from_rgb(40, 50, 70); // #283246
|
||||||
|
v.widgets.hovered.fg_stroke = Stroke::new(1.0, Color32::from_rgb(200, 220, 255)); // #C8DCFF
|
||||||
|
v.widgets.active.fg_stroke = Stroke::new(1.0, ACCENT_CYAN);
|
||||||
|
v.widgets.active.weak_bg_fill = Color32::from_rgb(50, 70, 100); // #324664
|
||||||
|
v.widgets.noninteractive.bg_stroke = Stroke::new(0.5, Color32::from_rgb(40, 46, 60)); // #282E3C
|
||||||
|
|
||||||
|
// egui 0.35's `epaint::Shadow` packs its fields into 8-bit integers
|
||||||
|
// (`offset: [i8; 2]`, `blur: u8`, `spread: u8`) to keep the struct at 8
|
||||||
|
// bytes. Use integer literals; the f32 form would not type-check.
|
||||||
|
v.window_shadow = egui::epaint::Shadow {
|
||||||
|
offset: [0, 4],
|
||||||
|
blur: 16,
|
||||||
|
spread: 0,
|
||||||
|
color: Color32::from_black_alpha(120),
|
||||||
|
};
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply fonts + spacing + visuals to a context. Called exactly once from
|
||||||
|
/// `setup_egui_style` (plugin.rs).
|
||||||
|
///
|
||||||
|
/// egui 0.35 replaced `ctx.style()` / `ctx.set_style()` with theme-scoped
|
||||||
|
/// accessors. We pin the active theme to Dark and mutate the active `Style`
|
||||||
|
/// in place; `Visuals` and `animation_time` are both fields of `Style`, so
|
||||||
|
/// they go through the same closure. `text_styles` is now a public
|
||||||
|
/// `BTreeMap` field (no `text_styles_mut()`).
|
||||||
|
pub fn setup(ctx: &egui::Context) {
|
||||||
|
ctx.set_theme(egui::Theme::Dark);
|
||||||
|
ctx.global_style_mut(|style| {
|
||||||
|
style.spacing.item_spacing = egui::vec2(8.0, 6.0);
|
||||||
|
style.spacing.slider_width = 150.0;
|
||||||
|
style.spacing.indent = 18.0;
|
||||||
|
style.spacing.button_padding = egui::vec2(10.0, 4.0);
|
||||||
|
style.spacing.indent_ends_with_horizontal_line = false;
|
||||||
|
style.animation_time = 0.12;
|
||||||
|
for (ts, size) in text_styles() {
|
||||||
|
style.text_styles.insert(ts, egui::FontId::proportional(size));
|
||||||
|
}
|
||||||
|
style.visuals = sci_fi_visuals();
|
||||||
|
});
|
||||||
|
}
|
||||||
41
tests/preset_test.rs
Normal file
41
tests/preset_test.rs
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
use singularity_rs::params::BlackHoleParams;
|
||||||
|
use singularity_rs::ui::preset::{Preset, apply, canonical_hash, params_hash};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonical_hash_matches_just_applied_params() {
|
||||||
|
// After applying a preset, params_hash of the result must equal that
|
||||||
|
// preset's canonical_hash — otherwise the Custom-detection logic would
|
||||||
|
// immediately flip a freshly-applied preset back to Custom.
|
||||||
|
let mut p = BlackHoleParams::default();
|
||||||
|
for preset in [Preset::Cinematic, Preset::Performance, Preset::Web] {
|
||||||
|
apply(preset, &mut p);
|
||||||
|
assert_eq!(
|
||||||
|
canonical_hash(preset),
|
||||||
|
params_hash(&p),
|
||||||
|
"preset {:?}: apply() did not reproduce canonical_hash",
|
||||||
|
preset
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_preset_field_change_flips_to_custom() {
|
||||||
|
// Editing a field that NO preset touches (disk_tilt is not in any preset bundle)
|
||||||
|
// must NOT change params_hash. This guards the "hash only preset fields"
|
||||||
|
// invariant: non-preset edits must not spuriously flip to Custom.
|
||||||
|
let mut p = BlackHoleParams::default();
|
||||||
|
let h0 = params_hash(&p);
|
||||||
|
p.disk_tilt = 1.0; // not in any preset bundle
|
||||||
|
assert_eq!(h0, params_hash(&p), "non-preset field leaked into hash");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preset_field_change_differs_from_canonical() {
|
||||||
|
// Editing a preset-touched field after applying must change the hash
|
||||||
|
// away from the preset's canonical_hash (i.e. flip to Custom).
|
||||||
|
let mut p = BlackHoleParams::default();
|
||||||
|
apply(Preset::Cinematic, &mut p);
|
||||||
|
let h_canonical = canonical_hash(Preset::Cinematic);
|
||||||
|
p.steps = 299; // off by one from the Cinematic bundle
|
||||||
|
assert_ne!(h_canonical, params_hash(&p));
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user