Compare commits

...

19 Commits

Author SHA1 Message Date
xfy
cdc12f16da chore(git): ignore logs/ directory 2026-07-17 17:44:01 +08:00
xfy
0a03c3a718 fix(ui): block scroll-wheel passthrough to orbit camera over the panel
Wheel-scrolling over the right-side egui panel was zooming the orbit
camera instead of scrolling the panel.

Root cause: ui_system set WantsPointer from ctx.egui_wants_pointer_input(),
which delegates to is_pointer_over_egui() -> layer_id_at(). That lookup only
walks egui's `areas` table. But this SidePanel is shown on the background
layer of a hand-rolled root `Ui` (the bevy_egui 0.41 single-context SidePanel
pattern) and never registers an Area, so layer_id_at() returns None over the
panel and egui_wants_pointer_input() returns false even with the cursor
squarely over it. WantsPointer stayed false, so orbit_controller let every
MouseWheel event through. Confirmed via diagnostics: `over=false` on every
frame while hovering the panel.

First attempt — claiming the panel interior with ui.interact(Sense::hover())
— failed: interact registers a *widget*, not an *area*, so layer_id_at() still
found nothing (verified: over stayed false).

Fix: bypass egui's area heuristic entirely. Capture the SidePanel's
InnerResponse and test the pointer position against its response.rect
directly (a deterministic geometric check). Keep egui_is_using_pointer() so
an in-progress slider/resize drag still claims the pointer even if the cursor
momentarily exits the rect.

  wants.0 = pointer_in(panel_rect) || egui_is_using_pointer()
2026-07-17 17:41:39 +08:00
xfy
e779ad7bb7 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).
2026-07-17 16:42:03 +08:00
xfy
99b23cd056 chore(ui/style): drop unused DIM_TEXT palette constant
Defined in Task 2 as a palette slot for section-disabled header styling,
but the final code path never used it — disabled sections rely on egui's
add_enabled auto-dimming instead of a manual header color. Removes the
last dead-code warning from the UI rewrite.
2026-07-17 16:35:28 +08:00
xfy
e67a0eaec1 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.
2026-07-17 16:16:04 +08:00
xfy
e4115b3ffd feat(ui): add preset_bar() — combobox + global reset
Selecting a concrete preset applies its bundle and sets just_applied
for one frame to suppress Custom-detection on that frame. Global '↺ all'
reset (deviation from spec's per-section reset, flagged in plan for review).
2026-07-17 16:03:20 +08:00
xfy
1f90c790d3 feat(ui): add 6 collapsing sections
Turbulence (default closed, longest; orange warning when disk quality Off),
Doppler/Jets/Grid/Planets (header enable toggle passed in as 'enabled'),
Background. Planets dirty-detection moves to ui_system (Task 11) since it
needs pre-render values. Jets retains its spin<0.05 warning.
2026-07-17 15:58:06 +08:00
xfy
2e2a2a1baf feat(ui): add Accretion Disk card with color-mode/temp interaction
Temperature disabled (not hidden) when color mode = Gradient, so the
user sees their value. Log-scale slider for the 1000-50000K range.
2026-07-17 15:53:24 +08:00
xfy
34fa609a7d feat(ui): add sections.rs — Camera/BlackHole/Quality cards
Two-column Grid rows with unit suffixes + log scales on wide ranges.
Bloom threshold/strength hidden when Bloom=Off (spec §4.3). steps and
render_scale migrated here from the deleted Renderer section, fixing
the duplicate-render_scale bug.
2026-07-17 15:49:42 +08:00
xfy
dfa8c77ab1 feat(ui): add collapsing_with_toggle() for header-row enables
Doppler/Jets/Grid/Planets get their enable checkbox in the collapsing
header (not a body row). Uses CollapsingState::show_header. Body closure
receives the enabled flag so it can add_enabled() its rows.
2026-07-17 15:44:59 +08:00
xfy
d542d527f2 feat(ui): add group()/collapsing() section helpers
group() = always-open framed card with cyan heading.
collapsing() = CollapsingHeader wrapper with stable id_salt.
These eliminate the 11x boilerplate in the upcoming section rewrite.
2026-07-17 15:41:22 +08:00
xfy
77b1817f58 feat(ui): wire setup_egui_style one-shot into EguiPrimaryContextPass
Local<bool> guard retries until ctx_mut() first succeeds, applies the
sci-fi theme once, then never runs again. .chain()'d before ui_system so
the very first successful frame is already themed. Per-frame set_style
would dirty layout caches; this avoids that.
2026-07-17 15:37:34 +08:00
xfy
3a8a1ee1c3 refactor(ui/preset): drop AsU32ForHash trait, tighten Custom-hash doc
The trait had one impl (BloomQuality) while DiskQuality/AaQuality were
hashed inline — it abstracted nothing. Call BloomQuality::levels()
directly so the three hash lines read as parallel conversions. Hash
output is unchanged (as_u32_ just delegated to levels()).

Also fix the canonical_hash(Custom) doc: it claimed 'by construction'
but the 0-sentinel guarantee actually comes from ui_system's
matches!(Cinematic|Performance|Web) arm excluding Custom from the
comparison, not from SipHash avoiding 0.
2026-07-17 15:36:07 +08:00
xfy
3bf1f1ac83 feat(ui): add preset module with hash-based Custom detection
Preset::Cinematic/Performance/Web write 5 params (steps, render_scale,
bloom/disk/aa quality); Custom is a no-op marker. params_hash hashes
ONLY preset-touched fields so non-preset edits (disk_tilt, etc.) don't
spuriously flip the bar to Custom. Test-pinned via tests/preset_test.rs.

Exports ui + params from lib.rs so the test can construct params.

Lib-compile unblock: the library crate could not build with only
`pub mod {params,physics,ui}` because src/ui/mod.rs's ui_system
references crate::camera and crate::scene (transitively crate::render).
Added pub mod {camera,scene,render,web} to lib.rs and reworked main.rs
to consume them via `use singularity_rs::*` instead of redeclaring
the same file-level modules. preset is declared `pub mod` (not private
`mod`) because tests/preset_test.rs imports it cross-crate.
2026-07-17 15:27:18 +08:00
xfy
800d51f8c9 fix(ui): correct inactive bg_stroke color to spec #3C4646
Plan transcribed #3C4646 as rgb(60,70,90)=#3C465A (20 off on blue);
spec §1 specifies rgb(60,70,70). Also add module-level egui-0.35
version-drift note and hex comments on inline widget-state colors
for grep-able spec↔code comparison.
2026-07-17 15:04:57 +08:00
xfy
df5830ef32 feat(ui): add style module — deep-space cyan/orange theme
Palette constants + sci_fi_visuals() + setup(). Hand-applied over
Visuals::dark(); no custom font, no new deps. Not yet wired into
ui_system (Task 4 does that via setup_egui_style).
2026-07-17 14:58:04 +08:00
xfy
fbab7cd3e7 refactor(ui): move ui.rs into ui/ module directory
No content change; sets up for sibling style/preset/sections modules.
2026-07-17 14:50:56 +08:00
xfy
4de7800beb docs(plan): UI panel redesign implementation plan
12 bite-sized tasks: module-dir split → style.rs → preset.rs (TDD) →
wire setup_egui_style → group/collapsing helpers → collapsing_with_toggle
→ sections.rs (4 cards + 6 collapsing) → preset_bar → ui_system rewrite
→ acceptance (build/test/desktop/web).

Testable surface is preset.rs's hash logic (tests/preset_test.rs via new
lib.rs exports); everything else verified by the visual/web acceptance
checklist, matching the codebase convention that rendering isn't unit-tested.
2026-07-17 14:46:19 +08:00
xfy
c260b6fbfb docs(spec): UI panel redesign — SidePanel + sci-fi theme + presets
Design spec for the egui control panel overhaul. Addresses the
user-reported 'settings all look bad' by fixing the structural root
causes (flat 11-CollapsingHeader scroll, no styling, ragged sliders,
no presets) rather than a cosmetic reskin.

Key decisions captured:
- Chassis: right SidePanel (replaces floating Window)
- Theme: deep-space cyan/orange, hand-applied over Visuals::dark()
- Structure: 4 always-open framed cards + 6 collapsing sections
  (11→10 groups; Renderer merges into Quality, fixing a latent
  duplicate render_scale slider bug)
- Preset bar: Cinematic/Performance/Web/Custom with per-frame hash
  detection of hand-edits
- Two-column Grid rows with unit suffixes + log scales on wide ranges
- Group-enable toggles in collapsing headers; standardized warning text

Non-goals explicit: no params/shader/physics changes, no custom font
binary, no painted toggle, no new deps.
2026-07-17 14:39:43 +08:00
12 changed files with 2436 additions and 194 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@
/dist
.zcode/
others/
logs/

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,284 @@
# Control Panel Redesign — Design Spec
**Date:** 2026-07-17
**Phase:** UI (presentation layer only; no physics/shader changes)
**Status:** approved design, pending implementation plan
## Goal
Redesign the egui control panel from a single floating `Window` with 11 `CollapsingHeader` sections of 40+ raw `Slider::new(...).text(...)` calls (no styling) into a docked right-side `SidePanel` with a cohesive dark sci-fi theme, layered grouping, presets, and aligned label/value rows.
This addresses the user-observed problem: "the settings all look bad." The root causes are structural, not cosmetic:
1. **No global styling** — egui defaults render against a busy HDR black-hole render; no accent color, no spacing tuning, default font sizes.
2. **Flat structure** — 11 `CollapsingHeader`s in a single scroll produces 11 collapse arrows and a long scroll, with no priority distinction between always-tuned (Camera, Quality) and rarely-tuned (Grid, Doppler) sections.
3. **Ragged widgets**`Slider::new(x, r).text("label")` produces misaligned labels and value readouts; no units on any readout.
4. **No presets** — the project already ships two quality tiers (`cfg!(wasm32)` defaults) but exposes them only at startup; the user cannot switch between "cinematic" and "performance" intents without nudging 6 sliders.
## Non-goals
- **No `BlackHoleParams` structural changes.** No new fields. Preset is a UI-layer concept, not stored in the params resource.
- **No shader / physics-mirror changes.** `physics.rs`, `black_hole.wgsl`, `material.rs` are untouched. The CPU↔shader mirror invariant is not at risk.
- **No camera-control logic changes.** `WantsPointer` and the `wants.0 = ctx.egui_wants_pointer_input()` assignment are retained verbatim — this is load-bearing (lets the orbit camera ignore drags over the panel).
- **No custom font binary.** Use egui's default `Proportional` family; only adjust `text_styles` sizes. Embedding Inter would add an asset and a `Trunk.toml`/`include_bytes!` step not justified by the visual gain.
- **No custom-painted toggle switch.** egui has no native iOS-style toggle, but painting one (~30 lines of `Painter`) is maintenance cost the project doesn't need. Use `ui::checkbox` with accent coloring instead.
- **No new dependencies.** `egui_colors` / `egui_widget_ext` / `egui-thematic` are not added. The accent palette is hand-applied via `Visuals` field overrides (§1).
## Approach A — layered grouping (chosen)
Among three approaches considered:
- **A. Layered grouping** (chosen): `SidePanel` + top preset bar + 4 always-open framed cards + 6 collapsing sections, `Grid` two-column rows, hand-tuned sci-fi theme.
- B. Pure cosmetic refresh: keep the 11-`CollapsingHeader` structure, only restyle. Rejected — the structural problem (flat long scroll, no priority) survives.
- C. Tabbed (`TopBottomPanel` of tabs): zero scroll but cross-tab tuning is hostile to explorable debugging.
A balances noise reduction against parameter access. Its preset entry (§3) directly maps onto the existing `cfg!(wasm32)` dual defaults — those two default bundles are essentially two presets already.
## §1 — Chassis and startup styling
### Chassis
`egui::SidePanel::right("controls")`, `default_width(300.0)`, `width_range(260.0..=400.0)`, `resizable(true)`. Inside: a `TopBottomPanel::top("preset_bar")` (fixed) followed by a `ScrollArea::vertical` (the section stack).
### One-shot styling
A new `setup_egui_style` system runs **once** (not per-frame — per-frame `set_style`/`set_visuals` dirties font/layout caches every frame). It is registered in `EguiPrimaryContextPass` (not `Startup` — see gotcha below), guarded by a `Local<bool>` flag so it runs exactly once on the first tick where `ctx_mut()` succeeds. It applies three things to the `egui::Context`:
**Fonts** — keep default `Proportional` family; override `text_styles` sizes:
- `TextStyle::Body` → 14
- `TextStyle::Monospace` → 12
- `TextStyle::Heading` → 16
- `TextStyle::Small` → 11
**Spacing** (`Style::spacing`):
- `item_spacing = (8.0, 6.0)` — tighter gutters than default
- `slider_width = 150.0` — uniform rail length (the single biggest alignment fix)
- `indent = 18.0`
- `button_padding = (10.0, 4.0)`
- `indent_ends_with_horizontal_line = false` — removes the collapsing-header divider noise
**Visuals** (deep-space cyan/orange, hand-applied over `Visuals::dark()`):
- `panel_fill = #0E1014` (blue-black, not pure black)
- `extreme_bg_color = #08090C`
- `hyperlink_color = #5AC8FF` (cyan; by convention also used for section headings)
- `selection.bg_fill = #5AC8FF`
- `selection.stroke = Stroke::new(1.0, #FF8C42)` (orange selection outline)
- `widgets.inactive.weak_bg_fill = #1E2430`, `bg_stroke = Stroke::new(0.5, #3C4646)`
- `widgets.hovered.weak_bg_fill = #283246`, `fg_stroke = #C8DCFF`
- `widgets.active.fg_stroke = #5AC8FF`, `weak_bg_fill = #324664`
- `widgets.noninteractive.bg_stroke = Stroke::new(0.5, #282E3C)`
- `animation_time = 0.12` (snappier than default 0.25)
Color constants are defined once in `src/ui/style.rs` (`ACCENT_CYAN`, `ACCENT_ORANGE`, `PANEL_FILL`, `MUTED_TEXT`, …) and reused by every section helper.
### bevy_egui 0.41 startup gotcha
The egui context is exposed via `EguiContexts`, which is reliable in `EguiPrimaryContextPass` (where `ui_system` already runs) but not guaranteed available in `Startup`. So `setup_egui_style` is registered in `EguiPrimaryContextPass`, guarded by a `Local<bool>`: it retries each tick until `ctx_mut()` first succeeds, applies the style once, sets the flag, and never runs again.
## §2 — Section skeleton and `section()` abstraction
Two helper functions in `src/ui/mod.rs` eliminate the current 11× `CollapsingHeader::new(...).default_open(...).show(...)` boilerplate:
```rust
/// Always-open framed card. Title in cyan RichText::strong().small().
fn group(ui: &mut egui::Ui, title: &str, body: impl FnOnce(&mut egui::Ui));
/// Collapsible section. Title row can host an enable toggle (§4).
fn collapsing(
ui: &mut egui::Ui,
id: &str,
title: &str,
default_open: bool,
body: impl FnOnce(&mut egui::Ui),
);
```
### Section roster (11 → 10 groups)
The current Renderer section is removed; its contents merge into Quality (see bug fix below).
| Group | Form | Contents |
|---|---|---|
| Camera | always-open card | distance / yaw / pitch / fov |
| Black Hole | always-open card | spin slider + ISCO / horizon r+ read-only |
| Accretion Disk | always-open card | outer / tilt / brightness / rotation / color model / temp |
| Quality | always-open card | bloom + exposure + **steps** + **render_scale** + AA + star_aa |
| Disk Turbulence | collapsing (closed) | disk_quality + 7 turbulence params (longest section) |
| Doppler | collapsing | enabled + strength |
| Jets | collapsing | enabled + strength (spin<0.05 warning retained) |
| Planets | collapsing | 6 params (dirty-detection retained) |
| Background | collapsing | star_intensity / skybox_intensity |
| Grid | collapsing | enabled + density |
Always-open: the 4 most-frequently-tuned. Collapsing: 6 secondary sections, most defaulting closed. This halves the visible scroll length and drops collapse-arrow count from 11 to 6.
### Bug fix: duplicate `render_scale` slider
Current `ui.rs` binds `params.render_scale` twice — once in Renderer (`ui.rs:131`, "Render scale") and once in Quality (`ui.rs:158`, "Resolution scale"). Both write the same field; whichever runs second wins the frame, and the two labels disagree. The redesign consolidates this into a single Quality-row entry labeled "Resolution scale" with `.logarithmic(true)`. This is a latent bug fix included opportunistically in the refactor (the AGENTS.md guidance to "improve code you're working in").
## §3 — Preset bar and Grid two-column rows
### Preset bar
`TopBottomPanel::top("preset_bar")` fixed above the `ScrollArea`:
```
┌─────────────────────────────┐
│ Preset: [Cinematic ▾] [↺] │
├─────────────────────────────┤
│ (ScrollArea sections…) │
```
**Four presets**, each writes a bundle of params:
| Preset | Intent | Key overrides |
|---|---|---|
| Cinematic | desktop showpiece | steps=300, render_scale=0.75, bloom=High, disk=High, aa=High |
| Performance | high fps | steps=150, render_scale=0.5, bloom=Low, disk=Low, aa=Off |
| Web | wasm default bundle | the existing `cfg!(wasm32)` default set |
| Custom | read-only marker | written when any slider is hand-edited; writes nothing |
**Custom detection.** egui's `Slider` has no `on_change` callback. The reliable cross-widget "user modified something" signal is a per-frame hash: at the top of `ui_system`, compute a hash of the params fields that presets touch; if it differs from last frame's hash and does not equal the current preset's canonical hash, set `current_preset = Custom`. Applying a preset sets a `just_applied_preset` flag for one frame to skip the hash comparison (the apply itself changes params; that change must not re-trigger Custom).
**[↺] reset button** resets only the **currently displayed section** to its `Default::default()` values, not all params. Resetting the whole panel is destructive (erases hand-tuned values across sections); per-section reset is the safer granularity and matches the section's visual scope.
**Preset state location.** `current_preset: Local<Preset>` and `just_applied_preset: Local<bool>` live as `Local` system params in `ui_system`. They are UI-layer state, not added to `BlackHoleParams` (non-goal).
### Grid two-column rows
Every numeric row converts from:
```rust
ui.add(egui::Slider::new(&mut cam.distance, 3.0..=200.0).text("Distance"));
```
to a two-column `Grid` with label + sized slider:
```rust
egui::Grid::new("camera").num_columns(2).spacing([8.0, 4.0])
.show(ui, |ui| {
ui.label("Distance");
ui.add_sized([140.0, 16.0],
egui::Slider::new(&mut cam.distance, 3.0..=200.0)
.suffix(" r_g").fixed_decimals(1));
ui.end_row();
// …
});
```
This produces aligned label/value gutters and uniform slider rails — the biggest single visual upgrade.
### Units and scales
Every slider gains a unit suffix to disambiguate readouts:
- Angles (yaw / pitch / tilt) → `.suffix(" rad")` + `.fixed_decimals(2)`
- `disk_temp``.suffix(" K")` + `.logarithmic(true)` (range 100050000; linear wastes the low end)
- `steps` (50600) and `render_scale` (0.251.0) → `.logarithmic(true)`
- `planet_radius_factor``.prefix("× ")`
Read-only derived values (ISCO / horizon r+ / planet orbit radius) stay as `ui.label(format!(...))` but are placed in the Grid's right column for alignment, colored `MUTED_TEXT` (`Color32::from_gray(140)`) to signal "not editable."
## §4 — Toggles, disabled state, and warning text
The current panel has three inconsistent enable/disable patterns:
- `ui.checkbox(&mut x, "Enable")` on its own row (Doppler/Jets/Grid/Planets)
- `ui.add_enabled(cond, slider)` scattered (turbulence / bloom / temp / jets strength)
- Inline warning text (Jets only)
Unified into one rule set:
### 1. Group-enable toggle in the header row
Doppler / Jets / Grid / Planets — the four "whole section disableable" groups — get their enable checkbox moved into the collapsing header row via `CollapsingState::show_header`, not as a separate row inside the body:
```
▼ Doppler [✓]
└ Strength
▼ Jets [✓] (spin<0.05 checkbox disabled, title dimmed)
└ Strength
```
The body's widgets are wrapped in `ui.add_enabled(group_enabled && extra_cond, ...)`.
### 2. Disabled-state visualization
Currently disabled rows are merely non-interactive — indistinguishable from enabled. Two changes:
- Disabled widgets pick up `widgets.noninteractive.weak_bg_fill` (darker gray) automatically via egui's `add_enabled`.
- Section title and row labels drop to `Color32::from_gray(110)` when their section is disabled.
### 3. Inline warning text, templated
The existing Jets warning ("Spin too low — jets need χ ≥ 0.05") is the template. Standardized to orange `RichText::small()` at the top of any section whose controls are gated:
- Jets + spin<0.05 "Jets need χ 0.05 (Blandford-Znajek is spin-powered)"
- Disk quality=Off → "Disk quality Off → flat zero-thickness disk rendered" (turbulence sliders below stay visible-but-disabled)
- Bloom=Off → threshold/strength rows are **hidden** (not disabled). Rationale: those two params are meaningless when bloom is off; hiding is cleaner than greying. This is an intentional asymmetry vs. the turbulence case (§4 note below).
**Asymmetry rationale.** Bloom's child params (threshold/strength) have no interpretation when bloom is off → hide. Turbulence's child params (the 7 noise sliders) retain their values as user-tuned state worth seeing while quality=Off briefly → disable-but-show. The distinction is "param meaningless" vs. "param currently not applied but still yours."
### 4. Toggle visual
No custom-painted iOS toggle (non-goal). `ui.checkbox` in the header row, with accent coloring: cyan ✓ when on, gray when off. This reads as a switch in context without the maintenance cost of a `Painter` widget.
## §5 — File structure, migration, and acceptance
### File changes
**New** `src/ui/style.rs` — all egui styling:
- `pub fn setup(ctx: &egui::Context)` — fonts + spacing + visuals; called once from the startup system
- `pub const ACCENT_CYAN / ACCENT_ORANGE / PANEL_FILL / MUTED_TEXT / …` — palette constants
- `pub fn sci_fi_visuals() -> egui::Visuals` — returns the themed Visuals (unit-testable in isolation)
- `pub fn text_styles() -> Vec<(egui::TextStyle, f32)>` — the size table
**New** `src/ui/preset.rs` — preset logic:
- `pub enum Preset { Cinematic, Performance, Web, Custom }`
- `pub fn apply(p: Preset, params: &mut BlackHoleParams)`
- `pub fn canonical_hash(p: Preset) -> u64` — the hash each preset's param bundle produces
- `pub fn params_hash(params: &BlackHoleParams) -> u64` — current-state hash for Custom detection
**New** `src/ui/sections.rs` — the 10 `section_*` render functions, one per group. Each takes `(ui, params, …)` and mutates params in place. Keeps `mod.rs` a thin orchestrator.
**Refactor** `src/ui.rs``src/ui/mod.rs`:
- `mod style; mod preset; mod sections;`
- `pub fn ui_system(...)` — thinned to the chassis: `SidePanel::right``TopBottomPanel`(preset bar) + `ScrollArea`(section calls)
- `fn group(...)` / `fn collapsing(...)` helpers (§2)
- `fn preset_bar(...)` — top bar UI
- `Local<Preset>` / `Local<bool>` (current_preset / just_applied_preset) state
**Edit** `src/render/plugin.rs`:
- Register `setup_egui_style` as a one-shot system in `EguiPrimaryContextPass` (with `Local<bool>` guard, §1). Must be `EguiPrimaryContextPass`, not `Startup` — the egui context is reliable there but not guaranteed in `Startup`.
**Unchanged**: `params.rs`, `lib.rs`, all shaders, `physics.rs`, `camera.rs`, `material.rs`, `scene/planets.rs`, `web.rs`, `main.rs`.
### Out of scope (explicit)
- No `BlackHoleParams` field changes or default value changes
- No shader / physics mirror edits
- No camera control / `WantsPointer` changes
- No embedded font binary
- No custom-painted toggle
- No new crate dependencies
### Acceptance / regression checklist
Run after implementation:
1. `cargo build --release` compiles
2. `cargo test` passes (`physics.rs` mirror unaffected; confirms `lib.rs` export intact)
3. `cargo run --release` visual checks:
- SidePanel docked right, draggable width 260400
- 4 always-open cards + 6 collapsing headers visible
- Preset bar at top: select Cinematic → sliders jump to its bundle, bar shows "Cinematic"
- Drag any slider → bar auto-switches to "Custom"
- [↺] in a section header → that section's params reset to default
- Jets + spin=0 → header checkbox disabled + orange warning text
- Bloom=Off → threshold/strength rows disappear
- Disk quality=Off → turbulence sliders disabled-but-visible + warning
- **Regression-critical**: dragging a slider does NOT rotate the orbit camera (`WantsPointer` still correct)
- **Regression-critical**: no "grey screen" / frozen-frame — the `nudge_camera` workaround is untouched
4. `trunk serve` web build boots without panic; SidePanel renders; egui 0.34 breaking changes (`Rounding→CornerRadius`, `Frame` stroke-in-padding) surface here if missed
## Open questions for implementation plan
- The hash function for preset detection (`params_hash`) needs to hash only the fields presets touch, not all of `BlackHoleParams` — otherwise non-preset edits (e.g. camera distance) would spuriously flip to Custom. The plan should enumerate the exact hashed field set.

View File

@ -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;

View File

@ -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::<bevy::audio::AudioPlugin>(),
)
.add_plugins(render::BlackHolePlugin)
.add_plugins(BlackHolePlugin)
.run();
}

View File

@ -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<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
View File

@ -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;
}
}

271
src/ui/mod.rs Normal file
View File

@ -0,0 +1,271 @@
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(&params);
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()),
);
let panel_response = 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;
});
});
// Decide whether egui should own the pointer this frame, so the orbit
// camera can stand down. We can't rely on `ctx.egui_wants_pointer_input()`:
// this SidePanel is rendered on the background layer of a hand-rolled root
// `Ui` (the bevy_egui 0.41 single-context pattern) and never registers an
// `Area`, so egui's `is_pointer_over_egui()` / `layer_id_at()` return `false`
// even while the cursor is squarely over the panel — wheel scroll then leaks
// straight through to the camera. Instead we test the pointer against the
// panel's own response rect (a deterministic geometric check), and keep
// `egui_is_using_pointer()` so dragging a slider/resize-handle still claims
// the pointer even if the cursor momentarily leaves the rect.
let panel_rect = panel_response.response.rect;
let pointer_pos = ctx.input(|i| i.pointer.interact_pos());
let over_panel = pointer_pos.is_some_and(|p| panel_rect.contains(p));
let using = ctx.egui_is_using_pointer();
wants.0 = over_panel || using;
}

101
src/ui/preset.rs Normal file
View 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
View 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
View 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
View 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));
}