feat: improve CLI arguments

This commit is contained in:
2026-09-22 11:39:11 +02:00
parent 7274f19b33
commit 694f63ab9d
4 changed files with 144 additions and 37 deletions
+54 -11
View File
@@ -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::<f64>()
&& let Ok(hh) = self.zoom_edit.trim().parse::<f64>()
&& 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.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()));
+26 -14
View File
@@ -13,6 +13,14 @@ pub struct Cli {
#[arg(long, value_enum)]
pub kind: Option<KindArg>,
/// Start in Julia mode with this seed constant.
#[arg(long, value_name = "RE,IM")]
pub julia: Option<String>,
/// 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<u32>,
@@ -21,14 +29,6 @@ pub struct Cli {
#[arg(long, value_name = "RE,IM")]
pub complex_power: Option<String>,
/// Start in Julia mode with this seed constant.
#[arg(long, value_name = "RE,IM")]
pub julia: Option<String>,
/// Coloring palette index.
#[arg(long, value_name = "INDEX")]
pub palette: Option<u32>,
/// Phoenix constant p for the Phoenix kind (z -> z^2 + c + p*z_prev).
#[arg(long, value_name = "RE,IM")]
pub phoenix_p: Option<String>,
@@ -45,17 +45,25 @@ pub struct Cli {
#[arg(long, value_name = "RE,IM,HALF_HEIGHT[,ITERATIONS]")]
pub view: Option<String>,
/// Jump to a specific position on startup.
#[arg(long, short('p'), value_name = "RE,IM")]
pub position: Option<String>,
/// Set a maximum iterations count on startup.
#[arg(long, short('i'))]
pub iterations: Option<u32>,
/// Set the zoom level on startup.
#[arg(long("zoom"), short('z'))]
pub half_height: Option<String>,
/// 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<u32>,
pub palette: Option<u32>,
/// Output path for --headless (default: fractal-<timestamp>.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<String>,
/// Set a maximum iterations count at animation end.
#[arg(long)]
pub to_iterations: Option<u32>,
/// Number of frames to render for an animation. Alternative to --fps +
/// --duration.
#[arg(long, value_name = "N")]
+26 -7
View File
@@ -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<String>,
to_share: Option<String>,
mut to_iterations: Option<u32>,
frames_arg: Option<u32>,
fps: f64,
duration: Option<f64>,
@@ -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();
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<ViewState, String> {
) -> Result<(ViewState, Option<u32>), 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.
+33
View File
@@ -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<u32>)> {
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<f64> {
let half_height = spec.trim().parse::<f64>().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