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/ui/mod.rs b/src/ui/mod.rs index b071430..546f3e6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,5 @@ mod style; +pub mod preset; use bevy::prelude::*; use bevy_egui::egui; diff --git a/src/ui/preset.rs b/src/ui/preset.rs new file mode 100644 index 0000000..93a1290 --- /dev/null +++ b/src/ui/preset.rs @@ -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 { + // 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() } } 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)); +}