diff --git a/src/lib.rs b/src/lib.rs index 55ada65..872a60d 100644 --- a/src/lib.rs +++ b/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 render; +pub mod scene; +pub mod ui; +#[cfg(target_arch = "wasm32")] +pub mod web; diff --git a/src/main.rs b/src/main.rs index 71c0841..499151d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,12 @@ use bevy::prelude::*; 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")] -mod web; -mod render; -mod camera; -mod params; -mod ui; -mod scene; -mod physics; +use singularity_rs::web; fn main() { // 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. .disable::(), ) - .add_plugins(render::BlackHolePlugin) + .add_plugins(BlackHolePlugin) .run(); } diff --git a/src/render/plugin.rs b/src/render/plugin.rs index 1938029..453c685 100644 --- a/src/render/plugin.rs +++ b/src/render/plugin.rs @@ -131,7 +131,12 @@ impl Plugin for BlackHolePlugin { .add_systems(Update, rebuild_bloom) // bevy_egui 0.41 requires UI systems to run inside the egui context // 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 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(), + ); } } diff --git a/src/ui.rs b/src/ui.rs deleted file mode 100644 index 83cf5f7..0000000 --- a/src/ui.rs +++ /dev/null @@ -1,185 +0,0 @@ -use bevy::prelude::*; -use bevy_egui::egui; - -pub fn ui_system( - mut contexts: bevy_egui::EguiContexts, - mut params: ResMut, - mut camera: ResMut, - mut wants: ResMut, - mut planet_dirty: ResMut, -) { - 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; - } -} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..04dda25 --- /dev/null +++ b/src/ui/mod.rs @@ -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` 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, +) { + 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, + mut camera: ResMut, + mut wants: ResMut, + mut planet_dirty: ResMut, + mut current_preset: Local, + mut just_applied: Local, + mut last_hash: Local>, +) { + // Local defaults to Custom (Preset::default()). + // Local> 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.` 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(); +} diff --git a/src/ui/preset.rs b/src/ui/preset.rs new file mode 100644 index 0000000..7f954a5 --- /dev/null +++ b/src/ui/preset.rs @@ -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 { + // 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() + } +} diff --git a/src/ui/sections.rs b/src/ui/sections.rs new file mode 100644 index 0000000..ed0e209 --- /dev/null +++ b/src/ui/sections.rs @@ -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")); +} diff --git a/src/ui/style.rs b/src/ui/style.rs new file mode 100644 index 0000000..99a36aa --- /dev/null +++ b/src/ui/style.rs @@ -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(); + }); +} diff --git a/tests/preset_test.rs b/tests/preset_test.rs new file mode 100644 index 0000000..b514ff0 --- /dev/null +++ b/tests/preset_test.rs @@ -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)); +}