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.
This commit is contained in:
xfy 2026-07-17 15:27:18 +08:00
parent 800d51f8c9
commit 3bf1f1ac83
5 changed files with 162 additions and 8 deletions

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

@ -1,4 +1,5 @@
mod style;
pub mod preset;
use bevy::prelude::*;
use bevy_egui::egui;

103
src/ui/preset.rs Normal file
View File

@ -0,0 +1,103 @@
//! 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)]
pub enum Preset {
Cinematic,
Performance,
Web,
/// Read-only marker set when any preset-touched field is hand-edited
/// away from a preset bundle. `apply(Custom, _)` is a no-op.
Custom,
}
/// 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 (it never
/// matches any real params state, by construction — see `hashed_fields`).
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.as_u32_().hash(&mut hasher);
self.disk_quality.as_u32().hash(&mut hasher);
self.aa_quality.samples().hash(&mut hasher);
hasher.finish()
}
}
// Local trait adapters: BloomQuality has levels(), DiskQuality has as_u32(),
// AaQuality has samples(). Unify under one name for hashing.
trait AsU32ForHash { fn as_u32_(self) -> u32; }
impl AsU32ForHash for BloomQuality { fn as_u32_(self) -> u32 { self.levels() } }

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