diff --git a/src/app.rs b/src/app.rs index 6dbfd1c..94ae055 100644 --- a/src/app.rs +++ b/src/app.rs @@ -124,6 +124,10 @@ pub struct FractalApp { power: u32, julia_c: (f64, f64), max_iterations: u32, + /// When set, `max_iterations` tracks the zoom depth automatically (so deep + /// zooms stay sharp without hand-tuning); the manual slider takes over when + /// unset. Turned off when a preset or share link supplies an explicit count. + auto_iterations: bool, color_scale: f32, color_offset: f32, palette: u32, @@ -222,6 +226,7 @@ impl FractalApp { power: 3, julia_c: (-0.8, 0.156), max_iterations: 512, + auto_iterations: true, color_scale: 0.15, color_offset: 0.0, palette: 0, @@ -338,16 +343,28 @@ impl FractalApp { { self.mode = FractalMode::Mandelbrot; self.view = ViewState::with_center(cre, cim, half_height); + // Presets carry a hand-tuned count; don't let the auto-scaler clobber it. + self.auto_iterations = false; self.max_iterations = iterations.clamp(32, MAX_REF_POINTS as u32 - 1); } } + /// Iteration count scaled to the current zoom depth, used while + /// `auto_iterations` is on. Grows roughly linearly with zoom decades so deep + /// zooms keep enough iterations to stay sharp instead of banding. + fn auto_iteration_count(&self) -> u32 { + let decades = self.view.magnification().log10().max(0.0); + let iters = 400.0 + 900.0 * decades; + (iters.round() as u32).clamp(200, MAX_REF_POINTS as u32 - 1) + } + /// Snapshot the current view as a shareable state. fn share_state(&self) -> ShareState { let sig_digits = sig_digits_for(self.view.precision_bits()); ShareState { julia: matches!(self.mode, FractalMode::Julia), kind: self.kind, + power: self.power, center_re: big_to_decimal_str(&self.view.center_re, sig_digits), center_im: big_to_decimal_str(&self.view.center_im, sig_digits), half_height: self.view.half_height, @@ -355,6 +372,7 @@ impl FractalApp { julia_c: self.julia_c, color_scale: self.color_scale, color_offset: self.color_offset, + palette: self.palette, } } @@ -366,9 +384,14 @@ impl FractalApp { FractalMode::Mandelbrot }; self.kind = s.kind; + self.power = s.power.clamp(2, 8); self.julia_c = s.julia_c; self.color_scale = s.color_scale; self.color_offset = s.color_offset; + self.palette = (s.palette as usize).min(PALETTE_NAMES.len() - 1) as u32; + // The link carries an explicit iteration count; honor it rather than + // letting the auto-scaler immediately overwrite it. + self.auto_iterations = false; self.max_iterations = s.iterations.clamp(32, MAX_REF_POINTS as u32 - 1); let bits = precision_for(s.half_height); if let (Some(re), Some(im)) = ( @@ -808,11 +831,17 @@ impl FractalApp { } ui.separator(); - ui.add( - egui::Slider::new(&mut self.max_iterations, 32..=100_000) - .text("iterations") - .logarithmic(true), - ); + ui.checkbox(&mut self.auto_iterations, "Auto iterations") + .on_hover_text("Scale the iteration count with zoom depth so deep zooms stay sharp."); + if self.auto_iterations { + ui.label(format!("iterations: {} (auto)", self.max_iterations)); + } else { + ui.add( + egui::Slider::new(&mut self.max_iterations, 32..=100_000) + .text("iterations") + .logarithmic(true), + ); + } ui.add( egui::Slider::new(&mut self.color_scale, 0.01..=1.0) .text("color scale") @@ -1006,6 +1035,11 @@ impl FractalApp { ui.ctx().request_repaint(); } + // Keep the iteration count matched to the zoom depth while auto is on. + if self.auto_iterations { + self.max_iterations = self.auto_iteration_count(); + } + self.ensure_reference(); // Poll the worker roughly every 30 ms while a reference is computing, diff --git a/src/fractal/share.rs b/src/fractal/share.rs index 2a6452b..9fe9da9 100644 --- a/src/fractal/share.rs +++ b/src/fractal/share.rs @@ -14,6 +14,8 @@ use crate::fractal::FractalKind; pub struct ShareState { pub julia: bool, pub kind: FractalKind, + /// Exponent for the Multibrot kind (ignored by others). + pub power: u32, pub center_re: String, pub center_im: String, pub half_height: f64, @@ -21,6 +23,8 @@ pub struct ShareState { pub julia_c: (f64, f64), pub color_scale: f32, pub color_offset: f32, + /// Palette index (`palette_id` in the shader). + pub palette: u32, } impl ShareState { @@ -33,14 +37,15 @@ impl ShareState { FractalKind::Multibrot => "multi", FractalKind::Tricorn => "tricorn", })); + s.push_str(&format!("&pw={}", self.power)); s.push_str(&format!( "&re={}&im={}&hh={}&it={}", self.center_re, self.center_im, self.half_height, self.iterations )); s.push_str(&format!("&jr={}&ji={}", self.julia_c.0, self.julia_c.1)); s.push_str(&format!( - "&cs={}&co={}", - self.color_scale, self.color_offset + "&cs={}&co={}&pal={}", + self.color_scale, self.color_offset, self.palette )); s } @@ -66,6 +71,7 @@ impl ShareState { _ => FractalKind::Mandelbrot }) .unwrap_or(FractalKind::Mandelbrot), + power: map.get("pw").and_then(|s| s.parse().ok()).unwrap_or(2), center_re: (*map.get("re")?).to_string(), center_im: (*map.get("im")?).to_string(), half_height: map.get("hh")?.parse().ok()?, @@ -76,6 +82,7 @@ impl ShareState { ), color_scale: map.get("cs").and_then(|s| s.parse().ok()).unwrap_or(0.02), color_offset: map.get("co").and_then(|s| s.parse().ok()).unwrap_or(0.0), + palette: map.get("pal").and_then(|s| s.parse().ok()).unwrap_or(0), }) } } @@ -88,7 +95,8 @@ mod tests { fn round_trip() { let s = ShareState { julia: true, - kind: FractalKind::Tricorn, + kind: FractalKind::Multibrot, + power: 5, center_re: "-0.743643887037158704752191506114774".into(), center_im: "0.131825904205311970493132056385139".into(), half_height: 1.5e-20, @@ -96,15 +104,18 @@ mod tests { julia_c: (-0.123, 0.745), color_scale: 0.02, color_offset: 0.25, + palette: 3, }; let d = ShareState::decode(&s.encode()).unwrap(); assert_eq!(d.julia, s.julia); assert_eq!(d.kind, s.kind); + assert_eq!(d.power, s.power); assert_eq!(d.center_re, s.center_re); assert_eq!(d.center_im, s.center_im); assert_eq!(d.half_height, s.half_height); assert_eq!(d.iterations, s.iterations); assert_eq!(d.julia_c, s.julia_c); + assert_eq!(d.palette, s.palette); } #[test]