From 694f63ab9d0f1adf44b77d1a153e664a7225c8ff Mon Sep 17 00:00:00 2001 From: supersurviveur Date: Tue, 22 Sep 2026 11:39:11 +0200 Subject: [PATCH] feat: improve CLI arguments --- src/app.rs | 69 +++++++++++++++++++++++++++++++++++++++---------- src/cli.rs | 40 ++++++++++++++++++---------- src/headless.rs | 39 +++++++++++++++++++++------- src/view.rs | 33 +++++++++++++++++++++++ 4 files changed, 144 insertions(+), 37 deletions(-) diff --git a/src/app.rs b/src/app.rs index 9dfef62..3788a60 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,6 +14,8 @@ use crate::fractal::{ compute_set_reference, }; use crate::lights::Light; +use crate::view::parse_half_height_spec; +use crate::view::parse_re_im_spec; use crate::view::{ Big, DEFAULT_HALF_HEIGHT, ViewState, big_from_decimal_str, big_from_f64, big_to_decimal_str, parse_view_spec, precision_for, @@ -356,7 +358,7 @@ fn sig_digits_for(bits: usize) -> usize { } /// Format a magnification for the editable field (compact scientific). -fn format_magnification(m: f64) -> String { +fn format_zoom(m: f64) -> String { format!("{m:.4e}") } @@ -415,7 +417,7 @@ impl FractalApp { let sig = sig_digits_for(view.precision_bits()); let center_re_edit = big_to_decimal_str(&view.center_re, sig); let center_im_edit = big_to_decimal_str(&view.center_im, sig); - let zoom_edit = format_magnification(view.magnification()); + let zoom_edit = format_zoom(view.zoom()); Self { view, @@ -532,16 +534,24 @@ impl FractalApp { if let Some(spec) = cli.view { self.apply_view_spec(&spec); } + if let Some(iterations) = cli.iterations { + self.auto_iterations = false; + self.max_iterations = iterations; + } + if let Some(half_height) = cli.half_height { + self.apply_half_height_spec(&half_height); + } + if let Some(position) = cli.position { + self.apply_re_im_spec(&position); + } if cli.de { self.de_coloring = true; } if cli.buddhabrot { self.mode = FractalMode::Buddhabrot; } - if let Some(p) = cli.buddha_palette { - self.buddha_palette = p.min(BUDDHA_PALETTE_NAMES.len() as u32 - 1); - } if let Some(p) = cli.palette { + self.buddha_palette = p.min(BUDDHA_PALETTE_NAMES.len() as u32 - 1); self.palette = p.min(PALETTE_NAMES.len() as u32 - 1); } self.export_path = cli.export_path; @@ -562,6 +572,27 @@ impl FractalApp { true } + /// Apply a half_height spec. Used by the native debug env var. + #[allow(dead_code)] + pub fn apply_half_height_spec(&mut self, spec: &str) -> bool { + let Some(half_height) = parse_half_height_spec(spec) else { + return false; + }; + self.view.half_height = half_height; + true + } + /// Apply a view spec "re,im" (re/im are decimal, + /// parsed at full precision). Used by the native debug env var. + #[allow(dead_code)] + pub fn apply_re_im_spec(&mut self, spec: &str) -> bool { + let Some((re, im)) = parse_re_im_spec(spec, self.view.precision_bits()) else { + return false; + }; + self.view.center_re = re; + self.view.center_im = im; + true + } + /// The current view (center + half-height). Used by headless animation /// to snapshot the start of a camera path. #[cfg(not(target_arch = "wasm32"))] @@ -586,6 +617,19 @@ impl FractalApp { self.auto_iterations = v; } + /// Set `max_iterations`. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn set_max_iterations(&mut self, i: u32) { + self.auto_iterations = false; + self.max_iterations = i; + } + + /// Get `max_iterations`. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn max_iterations(&mut self) -> u32 { + self.max_iterations + } + /// Jump to a preset Mandelbrot location: decimal center (parsed at the /// precision the zoom needs), half-height, and a fitting iteration count. fn go_to_place(&mut self, re: &str, im: &str, half_height: f64, iterations: u32) { @@ -1837,7 +1881,7 @@ impl FractalApp { self.center_im_edit = big_to_decimal_str(&self.view.center_im, sig); } - ui.label("magnification (×):"); + ui.label("zoom:"); let zoom_resp = ui.add( egui::TextEdit::singleline(&mut self.zoom_edit) .desired_width(f32::INFINITY) @@ -1848,18 +1892,17 @@ impl FractalApp { } if zoom_resp.lost_focus() { if self.zoom_edited - && let Ok(m) = self.zoom_edit.trim().parse::() + && let Ok(hh) = self.zoom_edit.trim().parse::() + && hh > 0.0 + && hh.is_finite() { - let hh = DEFAULT_HALF_HEIGHT / m; - if m > 0.0 && hh > 0.0 && hh.is_finite() { - self.view.half_height = hh; - self.view.sync_precision(); - } + self.view.half_height = hh; + self.view.sync_precision(); } self.zoom_edited = false; } if !zoom_resp.has_focus() { - self.zoom_edit = format_magnification(self.view.magnification()); + self.zoom_edit = format_zoom(self.view.zoom()); } ui.label(format!("reference: {} pts", self.reference.len())); ui.label(format!("precision: {} bits", self.view.precision_bits())); diff --git a/src/cli.rs b/src/cli.rs index 16a4b76..308f0ee 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -13,6 +13,14 @@ pub struct Cli { #[arg(long, value_enum)] pub kind: Option, + /// Start in Julia mode with this seed constant. + #[arg(long, value_name = "RE,IM")] + pub julia: Option, + + /// Switch to the Buddhabrot renderer. + #[arg(long)] + pub buddhabrot: bool, + /// Exponent for the Multibrot kind (z -> z^power + c), clamped to [2, 8]. #[arg(long)] pub power: Option, @@ -21,14 +29,6 @@ pub struct Cli { #[arg(long, value_name = "RE,IM")] pub complex_power: Option, - /// Start in Julia mode with this seed constant. - #[arg(long, value_name = "RE,IM")] - pub julia: Option, - - /// Coloring palette index. - #[arg(long, value_name = "INDEX")] - pub palette: Option, - /// Phoenix constant p for the Phoenix kind (z -> z^2 + c + p*z_prev). #[arg(long, value_name = "RE,IM")] pub phoenix_p: Option, @@ -45,17 +45,25 @@ pub struct Cli { #[arg(long, value_name = "RE,IM,HALF_HEIGHT[,ITERATIONS]")] pub view: Option, + /// Jump to a specific position on startup. + #[arg(long, short('p'), value_name = "RE,IM")] + pub position: Option, + + /// Set a maximum iterations count on startup. + #[arg(long, short('i'))] + pub iterations: Option, + + /// Set the zoom level on startup. + #[arg(long("zoom"), short('z'))] + pub half_height: Option, + /// Enable distance-estimation shading. #[arg(long)] pub de: bool, - /// Switch to the Buddhabrot renderer. - #[arg(long)] - pub buddhabrot: bool, - - /// Buddhabrot tonemap palette index. + /// Coloring palette index. #[arg(long, value_name = "INDEX")] - pub buddha_palette: Option, + pub palette: Option, /// Output path for --headless (default: fractal-.png). When /// animating (--to-view/--to-share), this is a directory of @@ -77,6 +85,10 @@ pub struct Cli { #[arg(long, value_name = "FRAGMENT")] pub to_share: Option, + /// Set a maximum iterations count at animation end. + #[arg(long)] + pub to_iterations: Option, + /// Number of frames to render for an animation. Alternative to --fps + /// --duration. #[arg(long, value_name = "N")] diff --git a/src/headless.rs b/src/headless.rs index 7af260b..cb6eb94 100644 --- a/src/headless.rs +++ b/src/headless.rs @@ -11,7 +11,8 @@ use crate::app::{FractalApp, unix_timestamp}; use crate::cli::Cli; use crate::fractal::{ExportRender, FractalRenderer, ShareState, export_to_png_blocking}; use crate::view::{ - ViewState, big_from_decimal_str, interpolate_view, parse_view_spec, precision_for, + ViewState, big_from_decimal_str, interpolate_f64, interpolate_view, parse_view_spec, + precision_for, }; /// Cap on the output image dimension (px), to stay within GPU texture limits. @@ -29,6 +30,7 @@ pub fn run(cli: Cli) -> Result<(), String> { // consumes `cli` to build the start state. let to_view = cli.to_view.clone(); let to_share = cli.to_share.clone(); + let to_iterations = cli.to_iterations; let frames_arg = cli.frames; let fps = cli.fps; let duration = cli.duration; @@ -43,6 +45,7 @@ pub fn run(cli: Cli) -> Result<(), String> { app, to_view, to_share, + to_iterations, frames_arg, fps, duration, @@ -97,6 +100,7 @@ fn run_animation( mut app: FractalApp, to_view: Option, to_share: Option, + mut to_iterations: Option, frames_arg: Option, fps: f64, duration: Option, @@ -116,11 +120,20 @@ fn run_animation( return Err("animation needs at least 2 frames".into()); } - let to = parse_animation_target(to_view.as_deref(), to_share.as_deref())?; + let (to, to_iterations_share) = + parse_animation_target(to_view.as_deref(), to_share.as_deref())?; + if to_iterations.is_none() + && let Some(to_iterations_share) = to_iterations_share + { + to_iterations = Some(to_iterations_share); + } let from = app.view_state().clone(); - // Iteration count auto-scales with zoom depth per frame, the same way it - // does while zooming interactively — no need to interpolate it by hand. - app.set_auto_iterations(true); + let from_iterations = app.max_iterations(); + if to_iterations.is_none() { + // Iteration count auto-scales with zoom depth per frame, the same way it + // does while zooming interactively — no need to interpolate it by hand. + app.set_auto_iterations(true); + } let out_dir = export_path.unwrap_or_else(|| format!("frames-{}", unix_timestamp())); std::fs::create_dir_all(&out_dir).map_err(|e| format!("failed to create {out_dir}: {e}"))?; @@ -133,6 +146,11 @@ fn run_animation( for i in 0..frames { let raw_t = i as f64 / (frames - 1) as f64; let t = if linear { raw_t } else { smoothstep(raw_t) }; + if let Some(to) = to_iterations { + app.set_max_iterations( + interpolate_f64(from_iterations as f64, to as f64, t).round() as u32, + ); + } app.set_view(interpolate_view(&from, &to, t)); eprintln!("[{:>4}/{frames}] computing reference orbit…", i + 1); @@ -180,11 +198,9 @@ fn run_animation( fn parse_animation_target( to_view: Option<&str>, to_share: Option<&str>, -) -> Result { +) -> Result<(ViewState, Option), String> { if let Some(spec) = to_view { - return parse_view_spec(spec) - .map(|(view, _)| view) - .ok_or_else(|| format!("invalid --to-view spec: {spec}")); + return parse_view_spec(spec).ok_or_else(|| format!("invalid --to-view spec: {spec}")); } let frag = to_share.expect("run_animation only called with one of to_view/to_share set"); let state = @@ -194,7 +210,10 @@ fn parse_animation_target( big_from_decimal_str(&state.center_re, bits).ok_or("invalid --to-share center (re)")?; let im = big_from_decimal_str(&state.center_im, bits).ok_or("invalid --to-share center (im)")?; - Ok(ViewState::with_center(re, im, state.half_height)) + Ok(( + ViewState::with_center(re, im, state.half_height), + Some(state.iterations), + )) } /// Ease-in/ease-out pacing: slow at both ends, fast through the middle. diff --git a/src/view.rs b/src/view.rs index 1c2a563..810bd02 100644 --- a/src/view.rs +++ b/src/view.rs @@ -58,6 +58,11 @@ impl ViewState { DEFAULT_HALF_HEIGHT / self.half_height } + /// Current zoom level. + pub fn zoom(&self) -> f64 { + self.half_height + } + /// Bits of precision the center currently needs for this zoom level. pub fn precision_bits(&self) -> usize { precision_for(self.half_height) @@ -140,6 +145,30 @@ pub fn parse_view_spec(spec: &str) -> Option<(ViewState, Option)> { Some((ViewState::with_center(re, im, half_height), iterations)) } +/// Parse a half_height spec. Shared by +/// `FractalApp::apply_half_height_spec` (the `--zoom` CLI flag) and headless +/// animation's `--to-zoom`. +pub fn parse_half_height_spec(spec: &str) -> Option { + let half_height = spec.trim().parse::().ok()?; + if !(half_height > 0.0 && half_height.is_finite()) { + return None; + } + Some(half_height) +} +/// Parse a "re,im" spec (re/im decimal, parsed at +/// full precision) into a view. Shared by +/// `FractalApp::apply_re_im_spec` (the `--position` CLI flag) and headless +/// animation's `--to-position`. +pub fn parse_re_im_spec(spec: &str, bits: usize) -> Option<(Big, Big)> { + let parts: Vec<&str> = spec.split(',').collect(); + if parts.len() != 2 { + return None; + } + let re = big_from_decimal_str(parts[0], bits)?; + let im = big_from_decimal_str(parts[1], bits)?; + Some((re, im)) +} + /// Interpolate between two views for an animation frame, `t` in `[0, 1]`. /// The half-height interpolates geometrically (log-linear), since zoom depth /// spans many decades and a linear sweep would crawl at the start and blow @@ -172,6 +201,10 @@ pub fn interpolate_view(from: &ViewState, to: &ViewState, t: f64) -> ViewState { ViewState::with_center(center_re, center_im, half_height) } +pub fn interpolate_f64(from: f64, to: f64, t: f64) -> f64 { + from + (to - from) * t +} + /// Render a `Big` as a decimal string with `sig_digits` significant digits. pub fn big_to_decimal_str(x: &Big, sig_digits: usize) -> String { let dec = x