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:
parent
800d51f8c9
commit
3bf1f1ac83
11
src/lib.rs
11
src/lib.rs
@ -1 +1,12 @@
|
|||||||
|
// Lib crate root. The bin (main.rs) consumes these via `singularity_rs::...`
|
||||||
|
// rather than redeclaring the modules itself; otherwise both crate roots
|
||||||
|
// would try to own the same file-level modules (compile error: E0428/
|
||||||
|
// E0152). This is why every module the binary needs lives here.
|
||||||
|
pub mod camera;
|
||||||
|
pub mod params;
|
||||||
pub mod physics;
|
pub mod physics;
|
||||||
|
pub mod render;
|
||||||
|
pub mod scene;
|
||||||
|
pub mod ui;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub mod web;
|
||||||
|
|||||||
14
src/main.rs
14
src/main.rs
@ -1,14 +1,12 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::WindowPlugin;
|
use bevy::window::WindowPlugin;
|
||||||
|
|
||||||
|
// The bin consumes the lib crate's modules; it does NOT redeclare them.
|
||||||
|
// src/lib.rs owns every file-level module (camera/params/physics/render/
|
||||||
|
// scene/ui/web), so we import what main() needs from the lib root.
|
||||||
|
use singularity_rs::render::BlackHolePlugin;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
mod web;
|
use singularity_rs::web;
|
||||||
mod render;
|
|
||||||
mod camera;
|
|
||||||
mod params;
|
|
||||||
mod ui;
|
|
||||||
mod scene;
|
|
||||||
mod physics;
|
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// On web, abort startup if WebGPU isn't available and show a message.
|
// On web, abort startup if WebGPU isn't available and show a message.
|
||||||
@ -50,6 +48,6 @@ fn main() {
|
|||||||
// Dropping it removes that noise and shrinks the wasm binary.
|
// Dropping it removes that noise and shrinks the wasm binary.
|
||||||
.disable::<bevy::audio::AudioPlugin>(),
|
.disable::<bevy::audio::AudioPlugin>(),
|
||||||
)
|
)
|
||||||
.add_plugins(render::BlackHolePlugin)
|
.add_plugins(BlackHolePlugin)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
mod style;
|
mod style;
|
||||||
|
pub mod preset;
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy_egui::egui;
|
use bevy_egui::egui;
|
||||||
|
|||||||
103
src/ui/preset.rs
Normal file
103
src/ui/preset.rs
Normal 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
41
tests/preset_test.rs
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
use singularity_rs::params::BlackHoleParams;
|
||||||
|
use singularity_rs::ui::preset::{Preset, apply, canonical_hash, params_hash};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonical_hash_matches_just_applied_params() {
|
||||||
|
// After applying a preset, params_hash of the result must equal that
|
||||||
|
// preset's canonical_hash — otherwise the Custom-detection logic would
|
||||||
|
// immediately flip a freshly-applied preset back to Custom.
|
||||||
|
let mut p = BlackHoleParams::default();
|
||||||
|
for preset in [Preset::Cinematic, Preset::Performance, Preset::Web] {
|
||||||
|
apply(preset, &mut p);
|
||||||
|
assert_eq!(
|
||||||
|
canonical_hash(preset),
|
||||||
|
params_hash(&p),
|
||||||
|
"preset {:?}: apply() did not reproduce canonical_hash",
|
||||||
|
preset
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_preset_field_change_flips_to_custom() {
|
||||||
|
// Editing a field that NO preset touches (disk_tilt is not in any preset bundle)
|
||||||
|
// must NOT change params_hash. This guards the "hash only preset fields"
|
||||||
|
// invariant: non-preset edits must not spuriously flip to Custom.
|
||||||
|
let mut p = BlackHoleParams::default();
|
||||||
|
let h0 = params_hash(&p);
|
||||||
|
p.disk_tilt = 1.0; // not in any preset bundle
|
||||||
|
assert_eq!(h0, params_hash(&p), "non-preset field leaked into hash");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preset_field_change_differs_from_canonical() {
|
||||||
|
// Editing a preset-touched field after applying must change the hash
|
||||||
|
// away from the preset's canonical_hash (i.e. flip to Custom).
|
||||||
|
let mut p = BlackHoleParams::default();
|
||||||
|
apply(Preset::Cinematic, &mut p);
|
||||||
|
let h_canonical = canonical_hash(Preset::Cinematic);
|
||||||
|
p.steps = 299; // off by one from the Cinematic bundle
|
||||||
|
assert_ne!(h_canonical, params_hash(&p));
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user