1921 lines
73 KiB
Rust
1921 lines
73 KiB
Rust
use std::sync::{Arc, Mutex};
|
||
|
||
use eframe::CreationContext;
|
||
use eframe::egui_wgpu;
|
||
use eframe::egui_wgpu::wgpu;
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
use crate::cli::Cli;
|
||
use crate::fractal::{
|
||
BuddhabrotCallback, BuddhabrotRenderer, BuddhabrotUniforms, ExportRender, FractalCallback,
|
||
FractalKind, FractalRenderer, MAX_REF_POINTS, ShareState, Uniforms,
|
||
};
|
||
#[cfg(target_arch = "wasm32")]
|
||
use crate::fractal::{compute_reference, compute_set_reference};
|
||
use crate::lights::Light;
|
||
use crate::view::{
|
||
Big, DEFAULT_HALF_HEIGHT, ViewState, big_from_decimal_str, big_from_f64, big_to_decimal_str,
|
||
precision_for,
|
||
};
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
use clap::Parser;
|
||
|
||
const BAILOUT_SQ: f32 = 1.0e6;
|
||
/// Cap on exported image dimension (px), to stay within GPU texture limits.
|
||
const MAX_EXPORT_DIM: u32 = 8192 * 16;
|
||
/// While the user is actively panning/zooming, the fractal is rendered into a
|
||
/// cache texture downscaled by this factor per axis (and with AA forced off), so
|
||
/// each interacting frame is cheap; the linear blit upsamples it to the widget.
|
||
/// A full-resolution render replaces it once input settles. 2 → quarter the
|
||
/// pixels (~4× faster); raise for more speed at the cost of more blur in motion.
|
||
const INTERACT_DOWNSCALE: u32 = 2;
|
||
/// Seconds without pan/zoom input after which the view counts as settled and is
|
||
/// re-rendered at full resolution.
|
||
const INTERACT_SETTLE: f64 = 0.12;
|
||
/// Palette names; index maps to `palette_id` in the shader.
|
||
const PALETTE_NAMES: &[&str] = &["Amber", "Rainbow", "Ember", "Lime", "Grayscale"];
|
||
/// Shadow palette names; index maps to `palette_id` in the shader.
|
||
const SHADOW_PALETTE_NAMES: &[&str] = &["Grayscale", "Red & Blue", "Custom lights"];
|
||
/// Buddhabrot tonemap style names; index maps to `BuddhabrotUniforms::palette`.
|
||
const BUDDHA_PALETTE_NAMES: &[&str] = &["Nebula", "Yellow", "Grayscale"];
|
||
|
||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||
pub enum FractalMode {
|
||
Mandelbrot,
|
||
Julia,
|
||
}
|
||
|
||
/// Selectable fractal formulas, with UI labels.
|
||
const KINDS: &[(FractalKind, &str)] = &[
|
||
(FractalKind::Mandelbrot, "Mandelbrot"),
|
||
(FractalKind::BurningShip, "Burning Ship"),
|
||
(FractalKind::Tricorn, "Tricorn"),
|
||
(FractalKind::Multibrot, "Multibrot"),
|
||
(FractalKind::Celtic, "Celtic"),
|
||
(FractalKind::Perpendicular, "Perpendicular"),
|
||
(FractalKind::Buffalo, "Buffalo"),
|
||
(FractalKind::Phoenix, "Phoenix"),
|
||
(FractalKind::Lambda, "Lambda"),
|
||
];
|
||
|
||
/// UI label for a fractal kind.
|
||
fn kind_label(kind: FractalKind) -> &'static str {
|
||
KINDS
|
||
.iter()
|
||
.find(|(k, _)| *k == kind)
|
||
.map(|(_, name)| *name)
|
||
.unwrap_or("Mandelbrot")
|
||
}
|
||
|
||
type JuliaPreset = (&'static str, f64, f64, u32, Option<(f64, f64)>);
|
||
|
||
/// Nice-looking Julia constants offered as presets.
|
||
const JULIA_PRESETS: [&[JuliaPreset]; FractalKind::Lambda as usize + 1] = [
|
||
&[
|
||
("dendrite", -0.8, 0.156, 400, None),
|
||
("rabbit", -0.123, 0.745, 400, None),
|
||
("spiral", -0.4, 0.6, 400, None),
|
||
("san marco", -0.75, 0.0, 400, None),
|
||
("siegel", -0.391, -0.587, 400, None),
|
||
],
|
||
&[("eyes", -0.241, 0.157, 1000, None)],
|
||
&[("pools", -0.50381, 0.07750, 400, None)],
|
||
&[],
|
||
&[],
|
||
&[],
|
||
&[],
|
||
&[
|
||
("archipelago 1", -0.415, -0.267, 500, Some((-0.556, 0.253))),
|
||
("archipelago 2", -0.556, 0.253, 500, Some((-0.415, -0.267))),
|
||
],
|
||
&[],
|
||
];
|
||
|
||
type SetPreset = (
|
||
&'static str,
|
||
&'static str,
|
||
&'static str,
|
||
f64,
|
||
u32,
|
||
Option<(f64, f64)>,
|
||
);
|
||
|
||
/// Curated beautiful locations offered as one-click presets.
|
||
/// Each is `(name, center_re, center_im, half_height, iterations)`; the centers
|
||
/// are decimals parsed at full precision so deep places stay sharp.
|
||
const SET_PRESETS: [&[SetPreset]; FractalKind::Lambda as usize + 1] = [
|
||
&[
|
||
(
|
||
"Seahorse Valley",
|
||
"-0.743643887037158704752191506114774",
|
||
"0.131825904205311970493132056385139",
|
||
4.0e-6,
|
||
1500,
|
||
None,
|
||
),
|
||
(
|
||
"Elephant Valley",
|
||
"0.2549870375144766",
|
||
"0.0005679790528465",
|
||
6.0e-5,
|
||
2000,
|
||
None,
|
||
),
|
||
("Scepter Valley", "-1.36012", "0.0406", 2.5e-4, 2000, None),
|
||
("Starburst", "-1.62917", "0.0203968", 1.5e-3, 1500, None),
|
||
(
|
||
"Deep Spiral",
|
||
"-0.7436438870371587",
|
||
"0.1318259042053",
|
||
8.0e-8,
|
||
2000,
|
||
None,
|
||
),
|
||
],
|
||
&[(
|
||
"Ship",
|
||
"-1.76485017213465",
|
||
"-0.0317013204392752",
|
||
5.3e-2,
|
||
1500,
|
||
None,
|
||
)],
|
||
&[],
|
||
&[],
|
||
&[],
|
||
&[],
|
||
&[],
|
||
&[(
|
||
"Galaxy",
|
||
"-0.2165696026100408",
|
||
"-0.0676553191878954",
|
||
5e-1,
|
||
1000,
|
||
Some((-0.9, -0.49)),
|
||
)],
|
||
&[],
|
||
];
|
||
|
||
/// Parameters a reference orbit was (or will be) computed for. Used to decide
|
||
/// when the current reference is stale enough to recompute.
|
||
struct RequestKey {
|
||
center_re: Big,
|
||
center_im: Big,
|
||
half_height: f64,
|
||
julia: bool,
|
||
julia_c: (f64, f64),
|
||
phoenix_p: (f64, f64),
|
||
lambda_l: (f64, f64),
|
||
iter: u32,
|
||
kind: FractalKind,
|
||
power: u32,
|
||
}
|
||
|
||
/// Shared state for an in-progress PNG export. The worker (a background thread
|
||
/// on native, an async task on web) writes `fraction`/`phase` as it goes and
|
||
/// sets `result` once when finished; the UI reads it each frame to draw a
|
||
/// progress bar and, on completion, to report the outcome.
|
||
struct ExportShared {
|
||
fraction: f32,
|
||
phase: &'static str,
|
||
result: Option<Result<String, String>>,
|
||
}
|
||
|
||
/// Time-based animation of a few view/coloring parameters. Each toggle drives
|
||
/// continuous repaints while on; orbit-affecting ones (Julia c, Phoenix p, zoom)
|
||
/// recompute the reference each frame and render the cheap low-res pass so they
|
||
/// stay smooth.
|
||
#[derive(Clone)]
|
||
struct AnimState {
|
||
/// Cycle the palette offset (colours flow through the fractal).
|
||
color: bool,
|
||
/// Palette cycles per second.
|
||
color_speed: f32,
|
||
|
||
/// Drift the Julia constant `c` around a circle to morph the Julia set.
|
||
julia: bool,
|
||
/// Revolutions per second.
|
||
julia_speed: f32,
|
||
/// Circle radius in the c-plane.
|
||
julia_radius: f64,
|
||
/// Circle center, captured when the animation is enabled.
|
||
julia_base: (f64, f64),
|
||
julia_angle: f64,
|
||
|
||
/// Drift the Phoenix distortion `p` around a circle.
|
||
phoenix: bool,
|
||
phoenix_speed: f32,
|
||
phoenix_radius: f64,
|
||
phoenix_base: (f64, f64),
|
||
phoenix_angle: f64,
|
||
|
||
/// Drift the Lambda distortion `λ` around a circle.
|
||
lambda: bool,
|
||
lambda_speed: f32,
|
||
lambda_radius: f64,
|
||
lambda_base: (f64, f64),
|
||
lambda_angle: f64,
|
||
|
||
/// Continuously zoom toward the current center.
|
||
zoom: bool,
|
||
/// e-folds per second; positive zooms in, negative zooms out.
|
||
zoom_speed: f32,
|
||
}
|
||
|
||
impl Default for AnimState {
|
||
fn default() -> Self {
|
||
Self {
|
||
color: false,
|
||
color_speed: 0.15,
|
||
julia: false,
|
||
julia_speed: 0.05,
|
||
julia_radius: 0.08,
|
||
julia_base: (0.0, 0.0),
|
||
julia_angle: 0.0,
|
||
phoenix: false,
|
||
phoenix_speed: 0.05,
|
||
phoenix_radius: 0.08,
|
||
phoenix_base: (0.0, 0.0),
|
||
phoenix_angle: 0.0,
|
||
lambda: false,
|
||
lambda_speed: 0.05,
|
||
lambda_radius: 0.08,
|
||
lambda_base: (0.0, 0.0),
|
||
lambda_angle: 0.0,
|
||
zoom: false,
|
||
zoom_speed: 0.5,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Top-level egui application.
|
||
pub struct FractalApp {
|
||
view: ViewState,
|
||
mode: FractalMode,
|
||
/// Iteration formula.
|
||
kind: FractalKind,
|
||
/// Exponent for the Multibrot kind.
|
||
power: u32,
|
||
julia_c: (f64, f64),
|
||
/// Distortion constant `p` for the Phoenix kind (`z^2 + c + p·z_{n-1}`).
|
||
phoenix_p: (f64, f64),
|
||
/// Distortion constant `l` for the Lambda kind (`l·z(1 - z_{n-1})`).
|
||
lambda_l: (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,
|
||
shadow_palette: u32,
|
||
/// Supersample each pixel 2×2 for smoother edges (costs ~4× fragment work).
|
||
antialias: bool,
|
||
/// Distance-estimation shading: darkens toward the set boundary using the
|
||
/// orbit derivative, giving crisp filaments at deep zoom instead of speckle.
|
||
de_coloring: bool,
|
||
// Use shadow coloring
|
||
shadow: bool,
|
||
|
||
/// List of enabled lights in the world
|
||
lights: Vec<Light>,
|
||
|
||
/// Render as a Buddhabrot (Monte-Carlo orbit-density histogram) instead of
|
||
/// the ordinary escape-time set. Plain f32 view — no deep zoom, no
|
||
/// perturbation/reference-orbit machinery (see `fractal::buddhabrot`).
|
||
buddhabrot: bool,
|
||
/// Nested escape-iteration caps for the R/G/B histogram channels
|
||
/// (Nebulabrot coloring); kept ordered r <= g <= b by the UI.
|
||
buddha_r_cap: u32,
|
||
buddha_g_cap: u32,
|
||
buddha_b_cap: u32,
|
||
/// Tonemap brightness multiplier.
|
||
buddha_exposure: f32,
|
||
/// Tonemap colour style (index into `BUDDHA_PALETTE_NAMES`).
|
||
buddha_palette: u32,
|
||
/// Keep dispatching new sample batches every frame (progressive
|
||
/// accumulation). Turning it off freezes the current histogram.
|
||
buddha_accumulate: bool,
|
||
/// Whether the controls side panel is expanded. Collapsible so the fractal
|
||
/// can take (nearly) the whole screen — important on a phone.
|
||
controls_open: bool,
|
||
/// Whether the app is in fullscreen (browser Fullscreen API on web, viewport
|
||
/// fullscreen on native). Kept in sync with the real state each frame.
|
||
fullscreen: bool,
|
||
/// Time-based animation of colours / Julia c / Phoenix p / zoom.
|
||
anim: AnimState,
|
||
|
||
/// Smoothed frames-per-second, recomputed each ~0.5 s window. Only advances
|
||
/// while the app is actually repainting (interaction / animation / export);
|
||
/// idle frames aren't forced, so a frozen value means "nothing to render".
|
||
fps: f32,
|
||
/// Frames counted in the current FPS window, and its start time (`i.time`).
|
||
fps_frames: u32,
|
||
fps_window_start: f64,
|
||
|
||
/// Reference orbit (`Z_n` as f32 pairs) for the current view.
|
||
reference: Arc<Vec<[f32; 2]>>,
|
||
/// Bumped whenever `reference` is replaced, so the GPU re-uploads it.
|
||
generation: u64,
|
||
/// Center + zoom the current `reference` was computed at (may differ
|
||
/// slightly from the live view; the shader compensates via `dc_offset`).
|
||
ref_center_re: Big,
|
||
ref_center_im: Big,
|
||
ref_half_height: f64,
|
||
/// Parameters of the most recent reference request (drift baseline / dedupe).
|
||
last_request: Option<RequestKey>,
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
worker: crate::worker::RefWorker,
|
||
/// A reference computation is in flight (native async worker).
|
||
pending: bool,
|
||
|
||
/// PNG export resolution multiplier over the on-screen size.
|
||
export_scale: f32,
|
||
/// Last on-screen fractal size in physical pixels (for export sizing).
|
||
last_size_px: egui::Vec2,
|
||
/// egui time (seconds) of the most recent pan/zoom. While recent (within
|
||
/// `INTERACT_SETTLE`) the fractal renders downscaled for smooth interaction.
|
||
last_interact_time: f64,
|
||
/// Set when the user requests a PNG export (handled after the panels draw).
|
||
export_requested: bool,
|
||
/// Progress/handle for an in-flight PNG export, if any.
|
||
export: Option<Arc<Mutex<ExportShared>>>,
|
||
/// Output path for `--export` (native CLI only); falls back to a
|
||
/// timestamped name when unset.
|
||
export_path: Option<String>,
|
||
/// Short status line (saved path, "link copied", errors).
|
||
status: Option<String>,
|
||
|
||
/// Editable text buffers for the center coordinates (decimal, full
|
||
/// precision). Kept in sync with the live view except while the field is
|
||
/// focused, so the user's in-progress typing is not clobbered by pan/zoom.
|
||
center_re_edit: String,
|
||
center_im_edit: String,
|
||
/// Editable magnification (×). Its display is lossy, so `zoom_edited` guards
|
||
/// applying it: without that, clicking in and out would round-trip the value
|
||
/// through the display format and drift the zoom.
|
||
zoom_edit: String,
|
||
zoom_edited: bool,
|
||
}
|
||
|
||
/// Significant decimal digits to show for a center at the given precision (bits).
|
||
fn sig_digits_for(bits: usize) -> usize {
|
||
((bits as f64) * std::f64::consts::LOG10_2).ceil() as usize + 3
|
||
}
|
||
|
||
/// Format a magnification for the editable field (compact scientific).
|
||
fn format_magnification(m: f64) -> String {
|
||
format!("{m:.4e}")
|
||
}
|
||
|
||
/// Precision (bits) to parse a typed center at: at least what the current zoom
|
||
/// needs, but enough to preserve every digit the user pasted, so a deep
|
||
/// coordinate entered while zoomed out is not truncated. Capped like `view`.
|
||
fn parse_bits_for(s: &str, min_bits: usize) -> usize {
|
||
let digits = s.chars().filter(char::is_ascii_digit).count();
|
||
let from_input = (digits as f64 * std::f64::consts::LOG2_10).ceil() as usize + 16;
|
||
min_bits.max(from_input).min(2048)
|
||
}
|
||
|
||
impl FractalApp {
|
||
pub fn new(cc: &CreationContext<'_>) -> Self {
|
||
let render_state = cc
|
||
.wgpu_render_state
|
||
.as_ref()
|
||
.expect("eframe must run with the wgpu backend");
|
||
|
||
let renderer = FractalRenderer::new(&render_state.device, render_state.target_format);
|
||
let buddhabrot_renderer =
|
||
BuddhabrotRenderer::new(&render_state.device, render_state.target_format);
|
||
{
|
||
let mut guard = render_state.renderer.write();
|
||
guard.callback_resources.insert(renderer);
|
||
guard.callback_resources.insert(buddhabrot_renderer);
|
||
}
|
||
|
||
let view = ViewState::default();
|
||
let ref_center_re = view.center_re.clone();
|
||
let ref_center_im = view.center_im.clone();
|
||
let ref_half_height = view.half_height;
|
||
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 mut app = Self {
|
||
view,
|
||
mode: FractalMode::Mandelbrot,
|
||
kind: FractalKind::Mandelbrot,
|
||
power: 3,
|
||
julia_c: (-0.8, 0.156),
|
||
phoenix_p: (-0.5, 0.0),
|
||
lambda_l: (-0.5, 0.0),
|
||
max_iterations: 512,
|
||
auto_iterations: true,
|
||
color_scale: 0.15,
|
||
color_offset: 0.0,
|
||
palette: 0,
|
||
shadow_palette: 0,
|
||
antialias: false,
|
||
de_coloring: false,
|
||
shadow: false,
|
||
lights: vec![Light::default()],
|
||
buddhabrot: false,
|
||
buddha_r_cap: 50,
|
||
buddha_g_cap: 500,
|
||
buddha_b_cap: 2000,
|
||
buddha_exposure: 1.0,
|
||
buddha_palette: 0,
|
||
buddha_accumulate: true,
|
||
controls_open: true,
|
||
fullscreen: false,
|
||
anim: AnimState::default(),
|
||
fps: 0.0,
|
||
fps_frames: 0,
|
||
fps_window_start: 0.0,
|
||
reference: Arc::new(Vec::new()),
|
||
generation: 0,
|
||
ref_center_re,
|
||
ref_center_im,
|
||
ref_half_height,
|
||
last_request: None,
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
worker: crate::worker::RefWorker::spawn(),
|
||
pending: false,
|
||
export_scale: 2.0,
|
||
last_size_px: egui::vec2(1280.0, 720.0),
|
||
last_interact_time: -1.0e9,
|
||
export_requested: false,
|
||
export: None,
|
||
export_path: None,
|
||
status: None,
|
||
center_re_edit,
|
||
center_im_edit,
|
||
zoom_edit,
|
||
zoom_edited: false,
|
||
};
|
||
|
||
// On the web, restore a shared view from the URL fragment (#...).
|
||
#[cfg(target_arch = "wasm32")]
|
||
if let Some(frag) = web_location_hash() {
|
||
if let Some(state) = ShareState::decode(&frag) {
|
||
app.apply_share(&state);
|
||
}
|
||
}
|
||
|
||
// Debug/testing hooks, driven by CLI flags.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
let cli = Cli::parse();
|
||
|
||
if let Some(k) = cli.kind {
|
||
app.kind = k.into();
|
||
if let Some(p) = cli.power {
|
||
app.power = p.clamp(2, 8);
|
||
}
|
||
app.view = Self::default_view_for(app.mode, app.kind);
|
||
}
|
||
if let Some(jc) = cli.julia {
|
||
let p: Vec<&str> = jc.split(',').collect();
|
||
if let (Some(Ok(re)), Some(Ok(im))) = (
|
||
p.first().map(|s| s.trim().parse::<f64>()),
|
||
p.get(1).map(|s| s.trim().parse::<f64>()),
|
||
) {
|
||
app.mode = FractalMode::Julia;
|
||
app.julia_c = (re, im);
|
||
app.view = Self::default_view_for(FractalMode::Julia, app.kind);
|
||
}
|
||
}
|
||
if let Some(frag) = cli.share
|
||
&& let Some(state) = ShareState::decode(&frag)
|
||
{
|
||
app.apply_share(&state);
|
||
}
|
||
if let Some(spec) = cli.view {
|
||
app.apply_view_spec(&spec);
|
||
}
|
||
if cli.de {
|
||
app.de_coloring = true;
|
||
}
|
||
if cli.buddhabrot {
|
||
app.buddhabrot = true;
|
||
}
|
||
if let Some(p) = cli.buddha_palette {
|
||
app.buddha_palette = p.min(BUDDHA_PALETTE_NAMES.len() as u32 - 1);
|
||
}
|
||
app.export_path = cli.export_path;
|
||
if cli.export {
|
||
app.export_requested = true;
|
||
}
|
||
}
|
||
|
||
app
|
||
}
|
||
|
||
/// Apply a view spec "re,im,half_height[,iterations]" (re/im are decimal,
|
||
/// parsed at full precision). Used by the native debug env var.
|
||
#[allow(dead_code)]
|
||
pub fn apply_view_spec(&mut self, spec: &str) -> bool {
|
||
let parts: Vec<&str> = spec.split(',').collect();
|
||
if parts.len() < 3 {
|
||
return false;
|
||
}
|
||
let Ok(half_height) = parts[2].trim().parse::<f64>() else {
|
||
return false;
|
||
};
|
||
if !(half_height > 0.0 && half_height.is_finite()) {
|
||
return false;
|
||
}
|
||
let bits = precision_for(half_height);
|
||
let (Some(re), Some(im)) = (
|
||
big_from_decimal_str(parts[0], bits),
|
||
big_from_decimal_str(parts[1], bits),
|
||
) else {
|
||
return false;
|
||
};
|
||
self.view = ViewState::with_center(re, im, half_height);
|
||
if let Some(it) = parts.get(3)
|
||
&& let Ok(v) = it.trim().parse::<u32>()
|
||
{
|
||
self.max_iterations = v.clamp(32, MAX_REF_POINTS as u32 - 1);
|
||
}
|
||
true
|
||
}
|
||
|
||
/// 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) {
|
||
let bits = precision_for(half_height);
|
||
if let (Some(cre), Some(cim)) = (
|
||
big_from_decimal_str(re, bits),
|
||
big_from_decimal_str(im, bits),
|
||
) {
|
||
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,
|
||
iterations: self.max_iterations,
|
||
julia_c: self.julia_c,
|
||
phoenix_p: self.phoenix_p,
|
||
lambda_l: self.lambda_l,
|
||
color_scale: self.color_scale,
|
||
color_offset: self.color_offset,
|
||
palette: self.palette,
|
||
shadow_palette: self.shadow_palette,
|
||
}
|
||
}
|
||
|
||
/// Restore a shared state into this app.
|
||
fn apply_share(&mut self, s: &ShareState) {
|
||
self.mode = if s.julia {
|
||
FractalMode::Julia
|
||
} else {
|
||
FractalMode::Mandelbrot
|
||
};
|
||
self.kind = s.kind;
|
||
self.power = s.power.clamp(2, 8);
|
||
self.julia_c = s.julia_c;
|
||
self.phoenix_p = s.phoenix_p;
|
||
self.lambda_l = s.lambda_l;
|
||
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;
|
||
self.shadow_palette =
|
||
(s.shadow_palette as usize).min(SHADOW_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)) = (
|
||
big_from_decimal_str(&s.center_re, bits),
|
||
big_from_decimal_str(&s.center_im, bits),
|
||
) {
|
||
self.view = ViewState::with_center(re, im, s.half_height);
|
||
}
|
||
}
|
||
|
||
/// A full shareable URL. On web this is the page URL with a `#fragment`; on
|
||
/// native (no page) it is just the fragment for pasting onto a deployment.
|
||
fn share_url(&self) -> String {
|
||
let fragment = self.share_state().encode();
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
if let Some(w) = web_sys::window() {
|
||
let loc = w.location();
|
||
let origin = loc.origin().unwrap_or_default();
|
||
let path = loc.pathname().unwrap_or_default();
|
||
return format!("{origin}{path}#{fragment}");
|
||
}
|
||
}
|
||
format!("#{fragment}")
|
||
}
|
||
|
||
/// Default view for a given set type and fractal kind. The Julia (dynamical)
|
||
/// plane is centered on the origin for every kind; the parameter plane frames
|
||
/// each kind's interesting region.
|
||
fn default_view_for(mode: FractalMode, kind: FractalKind) -> ViewState {
|
||
if mode == FractalMode::Julia {
|
||
return ViewState::with_center(big_from_f64(0.0, 53), big_from_f64(0.0, 53), 1.5);
|
||
}
|
||
let (cr, ci, hh) = match kind {
|
||
FractalKind::Mandelbrot => (-0.5, 0.0, 1.25),
|
||
FractalKind::BurningShip => (-0.5, -0.5, 1.3),
|
||
FractalKind::Tricorn => (-0.25, 0.0, 1.6),
|
||
FractalKind::Multibrot => (0.0, 0.0, 1.5),
|
||
FractalKind::Celtic => (-0.5, 0.0, 1.6),
|
||
FractalKind::Perpendicular => (-0.5, 0.0, 1.5),
|
||
FractalKind::Buffalo => (-0.5, -0.5, 1.5),
|
||
FractalKind::Phoenix => (0.0, 0.0, 1.6),
|
||
FractalKind::Lambda => (0.0, 0.0, 1.6),
|
||
};
|
||
ViewState::with_center(big_from_f64(cr, 53), big_from_f64(ci, 53), hh)
|
||
}
|
||
|
||
fn current_key(&self) -> RequestKey {
|
||
RequestKey {
|
||
center_re: self.view.center_re.clone(),
|
||
center_im: self.view.center_im.clone(),
|
||
half_height: self.view.half_height,
|
||
julia: matches!(self.mode, FractalMode::Julia),
|
||
julia_c: self.julia_c,
|
||
phoenix_p: self.phoenix_p,
|
||
lambda_l: self.lambda_l,
|
||
iter: self.max_iterations,
|
||
kind: self.kind,
|
||
power: self.power,
|
||
}
|
||
}
|
||
|
||
/// Distance (complex units) the live view center has drifted from `key`.
|
||
fn drift_from(&self, key: &RequestKey) -> f64 {
|
||
let dre = (&self.view.center_re - &key.center_re).to_f64().value();
|
||
let dim = (&self.view.center_im - &key.center_im).to_f64().value();
|
||
(dre * dre + dim * dim).sqrt()
|
||
}
|
||
|
||
/// Whether the reference should be (re)computed: parameters changed, or the
|
||
/// view drifted / zoomed far enough that the current reference no longer
|
||
/// serves it well. Lambda in Set mode has a static fractal (doesn't depend
|
||
/// on center), so we skip center drift checks but allow zoom precision updates.
|
||
fn should_request(&self) -> bool {
|
||
let Some(key) = &self.last_request else {
|
||
return true;
|
||
};
|
||
if key.julia != matches!(self.mode, FractalMode::Julia)
|
||
|| key.julia_c != self.julia_c
|
||
|| key.phoenix_p != self.phoenix_p
|
||
|| key.lambda_l != self.lambda_l
|
||
|| key.iter != self.max_iterations
|
||
|| key.kind != self.kind
|
||
|| key.power != self.power
|
||
{
|
||
return true;
|
||
}
|
||
// Lambda in Set mode is a static fractal; don't trigger recompute on center drift.
|
||
if self.kind == FractalKind::Lambda && matches!(self.mode, FractalMode::Mandelbrot) {
|
||
// But still recompute on significant zoom changes for precision
|
||
let ratio = self.view.half_height / key.half_height;
|
||
return !(0.5..=2.0).contains(&ratio);
|
||
}
|
||
let ratio = self.view.half_height / key.half_height;
|
||
self.drift_from(key) > 0.5 * self.view.half_height || !(0.5..=2.0).contains(&ratio)
|
||
}
|
||
|
||
/// Complex offset of the live view center from the reference center, in f32.
|
||
fn dc_offset(&self) -> [f32; 2] {
|
||
let dre = (&self.view.center_re - &self.ref_center_re)
|
||
.to_f64()
|
||
.value() as f32;
|
||
let dim = (&self.view.center_im - &self.ref_center_im)
|
||
.to_f64()
|
||
.value() as f32;
|
||
[dre, dim]
|
||
}
|
||
|
||
fn apply_reference(&mut self, points: Vec<[f32; 2]>, cre: Big, cim: Big, hh: f64) {
|
||
self.reference = Arc::new(points);
|
||
self.ref_center_re = cre;
|
||
self.ref_center_im = cim;
|
||
self.ref_half_height = hh;
|
||
self.generation = self.generation.wrapping_add(1);
|
||
}
|
||
|
||
/// Recompute the reference orbit when needed. Native: dispatch to a worker
|
||
/// thread and pick up completed results. Web: compute inline.
|
||
fn ensure_reference(&mut self) {
|
||
if self.should_request() {
|
||
let mut key = self.current_key();
|
||
let precision = self.view.precision_bits();
|
||
let max_iter = key.iter.min(MAX_REF_POINTS as u32 - 1);
|
||
|
||
// Lambda in Set mode has a static fractal centered at origin.
|
||
if key.kind == FractalKind::Lambda && !key.julia {
|
||
key.center_re = big_from_f64(0.0, precision);
|
||
key.center_im = big_from_f64(0.0, precision);
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
self.worker.request(crate::worker::RefRequest {
|
||
center_re: key.center_re.clone(),
|
||
center_im: key.center_im.clone(),
|
||
half_height: key.half_height,
|
||
julia: key.julia,
|
||
julia_c: key.julia_c,
|
||
max_iter,
|
||
precision,
|
||
kind: key.kind,
|
||
power: key.power,
|
||
phoenix_p: key.phoenix_p,
|
||
lambda_l: key.lambda_l,
|
||
});
|
||
self.pending = true;
|
||
}
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
let points = if key.julia {
|
||
let jr = big_from_f64(key.julia_c.0, precision);
|
||
let ji = big_from_f64(key.julia_c.1, precision);
|
||
compute_reference(
|
||
&key.center_re,
|
||
&key.center_im,
|
||
&jr,
|
||
&ji,
|
||
max_iter,
|
||
precision,
|
||
key.kind,
|
||
key.power,
|
||
key.phoenix_p,
|
||
key.lambda_l,
|
||
)
|
||
} else {
|
||
compute_set_reference(
|
||
&key.center_re,
|
||
&key.center_im,
|
||
max_iter,
|
||
precision,
|
||
key.kind,
|
||
key.power,
|
||
key.phoenix_p,
|
||
key.lambda_l,
|
||
)
|
||
};
|
||
self.apply_reference(
|
||
points,
|
||
key.center_re.clone(),
|
||
key.center_im.clone(),
|
||
key.half_height,
|
||
);
|
||
}
|
||
|
||
self.last_request = Some(key);
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
if let Some(res) = self.worker.try_take_latest() {
|
||
self.apply_reference(res.points, res.center_re, res.center_im, res.half_height);
|
||
self.pending = false;
|
||
}
|
||
}
|
||
|
||
fn make_uniforms(&self, aspect: f64) -> Uniforms {
|
||
let (span_x, span_y) = self.view.span(aspect);
|
||
Uniforms {
|
||
span: [span_x as f32, span_y as f32],
|
||
max_iter: self.max_iterations.min(MAX_REF_POINTS as u32 - 1),
|
||
ref_len: self.reference.len() as u32,
|
||
color_offset: self.color_offset,
|
||
color_scale: self.color_scale,
|
||
bailout_sq: BAILOUT_SQ,
|
||
is_julia: matches!(self.mode, FractalMode::Julia) as u32,
|
||
palette_id: self.palette,
|
||
shadow_palette_id: self.shadow_palette,
|
||
aa_level: if self.antialias { 2 } else { 1 },
|
||
kind: self.kind as u32,
|
||
power: self.power,
|
||
dc_offset: self.dc_offset(),
|
||
phoenix_p: [self.phoenix_p.0 as f32, self.phoenix_p.1 as f32],
|
||
lambda_l: [self.lambda_l.0 as f32, self.lambda_l.1 as f32],
|
||
de_coloring: (self.de_coloring | self.shadow) as u32,
|
||
shadow: self.shadow as u32,
|
||
_pad: [0; _],
|
||
}
|
||
}
|
||
|
||
/// Buddhabrot pass uniforms. Unlike `make_uniforms`, the view center is
|
||
/// collapsed straight to f32 (no arbitrary-precision reference orbit) —
|
||
/// Buddhabrot mode doesn't support deep zoom (see `fractal::buddhabrot`).
|
||
fn make_buddhabrot_uniforms(&self, aspect: f64) -> BuddhabrotUniforms {
|
||
let center = [
|
||
self.view.center_re.to_f64().value() as f32,
|
||
self.view.center_im.to_f64().value() as f32,
|
||
];
|
||
BuddhabrotUniforms {
|
||
center,
|
||
half_height: self.view.half_height as f32,
|
||
aspect: aspect as f32,
|
||
phoenix_p: [self.phoenix_p.0 as f32, self.phoenix_p.1 as f32],
|
||
lambda_l: [self.lambda_l.0 as f32, self.lambda_l.1 as f32],
|
||
bailout_sq: BAILOUT_SQ,
|
||
kind: self.kind as u32,
|
||
power: self.power,
|
||
r_cap: self.buddha_r_cap,
|
||
g_cap: self.buddha_g_cap,
|
||
b_cap: self.buddha_b_cap,
|
||
seed: 0, // set by the callback's own dispatch counter
|
||
samples_this_dispatch: 0, // set by the callback
|
||
exposure: self.buddha_exposure,
|
||
width: 0, // set by the callback from size_px
|
||
height: 0, // set by the callback from size_px
|
||
total_samples: 0.0, // tracked by the renderer across frames
|
||
palette: self.buddha_palette,
|
||
_pad: [0; 3],
|
||
}
|
||
}
|
||
|
||
/// Render the current view to a PNG at `export_scale` × the on-screen size,
|
||
/// then save it (native: file in cwd; web: browser download). Runs off the
|
||
/// UI thread so a progress bar can animate; progress lands in `self.export`.
|
||
fn do_export(&mut self, frame: &mut eframe::Frame) {
|
||
if self.export.is_some() {
|
||
return; // one export at a time
|
||
}
|
||
if self.buddhabrot {
|
||
self.status = Some("PNG export isn't available in Buddhabrot mode yet".into());
|
||
return;
|
||
}
|
||
let Some(rs) = frame.wgpu_render_state() else {
|
||
self.status = Some("export unavailable (no wgpu backend)".into());
|
||
return;
|
||
};
|
||
if self.reference.is_empty() {
|
||
self.status = Some("still computing reference…".into());
|
||
self.export_requested = true; // retry once the reference is ready
|
||
return;
|
||
}
|
||
|
||
let scale = self.export_scale.max(1.0);
|
||
let w = ((self.last_size_px.x * scale).round() as u32).clamp(16, MAX_EXPORT_DIM);
|
||
let h = ((self.last_size_px.y * scale).round() as u32).clamp(16, MAX_EXPORT_DIM);
|
||
let uniforms = self.make_uniforms(w as f64 / h as f64);
|
||
|
||
let device = rs.device.clone();
|
||
let queue = rs.queue.clone();
|
||
let (pipeline, bind_group_layout, format) = {
|
||
let guard = rs.renderer.read();
|
||
let Some(renderer) = guard.callback_resources.get::<FractalRenderer>() else {
|
||
self.status = Some("export unavailable".into());
|
||
return;
|
||
};
|
||
renderer.export_handles()
|
||
};
|
||
let reference = Arc::clone(&self.reference);
|
||
|
||
let shared = Arc::new(Mutex::new(ExportShared {
|
||
fraction: 0.0,
|
||
phase: "Rendering",
|
||
result: None,
|
||
}));
|
||
self.status = None;
|
||
self.export = Some(Arc::clone(&shared));
|
||
|
||
// Progress budget: rendering fills [0, RENDER_END], encoding the rest.
|
||
const RENDER_END: f32 = 0.6;
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
let name = self
|
||
.export_path
|
||
.clone()
|
||
.unwrap_or_else(|| format!("fractal-{}.png", unix_timestamp()));
|
||
std::thread::spawn(move || {
|
||
let er = ExportRender::new(
|
||
&device,
|
||
&queue,
|
||
pipeline,
|
||
&bind_group_layout,
|
||
format,
|
||
w,
|
||
h,
|
||
uniforms,
|
||
reference.as_slice(),
|
||
);
|
||
|
||
// Render the image tile by tile, waiting for each so progress
|
||
// reflects real GPU work.
|
||
for t in 0..er.tiles {
|
||
er.render_tile(&device, &queue, t);
|
||
let _ = device.poll(wgpu::PollType::Wait {
|
||
submission_index: None,
|
||
timeout: None,
|
||
});
|
||
let done = (t + 1) as f32 / er.tiles as f32;
|
||
set_progress(&shared, "Rendering", RENDER_END * done);
|
||
}
|
||
er.copy_to_readback(&device, &queue);
|
||
|
||
// Wait for the copy, then read the mapped bytes.
|
||
let (tx, rx) = std::sync::mpsc::channel();
|
||
er.readback()
|
||
.slice(..)
|
||
.map_async(wgpu::MapMode::Read, move |res| {
|
||
let _ = tx.send(res);
|
||
});
|
||
let _ = device.poll(wgpu::PollType::Wait {
|
||
submission_index: None,
|
||
timeout: None,
|
||
});
|
||
let _ = rx.recv();
|
||
|
||
set_progress(&shared, "Encoding", RENDER_END);
|
||
let png = {
|
||
let data = er
|
||
.readback()
|
||
.slice(..)
|
||
.get_mapped_range()
|
||
.expect("map readback buffer");
|
||
let sh = Arc::clone(&shared);
|
||
crate::fractal::encode_png_with_progress(
|
||
&data,
|
||
er.width,
|
||
er.height,
|
||
er.padded_bpr,
|
||
er.swap_rb,
|
||
|f| set_progress(&sh, "Encoding", RENDER_END + (0.97 - RENDER_END) * f),
|
||
)
|
||
};
|
||
er.readback().unmap();
|
||
|
||
set_progress(&shared, "Saving", 0.98);
|
||
let result = std::fs::write(&name, &png)
|
||
.map(|_| format!("saved {name} ({w}×{h})"))
|
||
.map_err(|e| format!("save failed: {e}"));
|
||
finish_export(&shared, result);
|
||
});
|
||
}
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
wasm_bindgen_futures::spawn_local(async move {
|
||
let er = ExportRender::new(
|
||
&device,
|
||
&queue,
|
||
pipeline,
|
||
&bind_group_layout,
|
||
format,
|
||
w,
|
||
h,
|
||
uniforms,
|
||
reference.as_slice(),
|
||
);
|
||
|
||
// Render tile by tile, awaiting each submission so the browser
|
||
// executes it and the UI can repaint between tiles.
|
||
for t in 0..er.tiles {
|
||
er.render_tile(&device, &queue, t);
|
||
let (tx, rx) = futures_channel::oneshot::channel();
|
||
queue.on_submitted_work_done(move || {
|
||
let _ = tx.send(());
|
||
});
|
||
let _ = rx.await;
|
||
let done = (t + 1) as f32 / er.tiles as f32;
|
||
set_progress(&shared, "Rendering", RENDER_END * done);
|
||
}
|
||
er.copy_to_readback(&device, &queue);
|
||
|
||
let (tx, rx) = futures_channel::oneshot::channel();
|
||
er.readback()
|
||
.slice(..)
|
||
.map_async(wgpu::MapMode::Read, move |res| {
|
||
let _ = tx.send(res);
|
||
});
|
||
let _ = rx.await;
|
||
|
||
set_progress(&shared, "Encoding", RENDER_END);
|
||
let png = {
|
||
let data = er
|
||
.readback()
|
||
.slice(..)
|
||
.get_mapped_range()
|
||
.expect("map readback buffer");
|
||
let sh = Arc::clone(&shared);
|
||
crate::fractal::encode_png_with_progress(
|
||
&data,
|
||
er.width,
|
||
er.height,
|
||
er.padded_bpr,
|
||
er.swap_rb,
|
||
|f| set_progress(&sh, "Encoding", RENDER_END + (0.97 - RENDER_END) * f),
|
||
)
|
||
};
|
||
er.readback().unmap();
|
||
|
||
set_progress(&shared, "Saving", 0.98);
|
||
web_download_png(&png, "fractal.png");
|
||
finish_export(&shared, Ok(format!("downloaded {w}×{h}")));
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Pick up a finished export (setting the status line) and keep repainting
|
||
/// while one is in flight so its progress bar animates.
|
||
fn poll_export(&mut self, ctx: &egui::Context) {
|
||
if let Some(shared) = &self.export {
|
||
let done = shared.lock().unwrap().result.take();
|
||
match done {
|
||
Some(Ok(msg)) => {
|
||
self.status = Some(msg);
|
||
self.export = None;
|
||
}
|
||
Some(Err(e)) => {
|
||
self.status = Some(e);
|
||
self.export = None;
|
||
}
|
||
None => ctx.request_repaint(),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Floating top-left overlay with the panel toggle and fullscreen toggle.
|
||
/// Always on top of the fractal, so both stay reachable when the controls
|
||
/// panel is collapsed (the common case on a phone).
|
||
fn overlay_buttons(&mut self, ui: &mut egui::Ui) {
|
||
egui::Area::new(egui::Id::new("overlay_buttons"))
|
||
.anchor(egui::Align2::LEFT_TOP, egui::vec2(8.0, 8.0))
|
||
.show(ui.ctx(), |ui| {
|
||
egui::Frame::popup(ui.style())
|
||
.shadow(egui::Shadow::NONE)
|
||
.show(ui, |ui| {
|
||
ui.horizontal(|ui| {
|
||
let panel_label = if self.controls_open { "Hide" } else { "Menu" };
|
||
if ui
|
||
.button(panel_label)
|
||
.on_hover_text("Show/hide the controls panel")
|
||
.clicked()
|
||
{
|
||
self.controls_open = !self.controls_open;
|
||
}
|
||
let fs_label = if self.fullscreen {
|
||
"Windowed"
|
||
} else {
|
||
"Fullscreen"
|
||
};
|
||
if ui
|
||
.button(fs_label)
|
||
.on_hover_text("Toggle fullscreen")
|
||
.clicked()
|
||
{
|
||
self.fullscreen = !self.fullscreen;
|
||
self.apply_fullscreen(ui.ctx());
|
||
}
|
||
// FPS readout. Monospace + fixed width so the number
|
||
// changing doesn't jitter the button row.
|
||
ui.add(
|
||
egui::Label::new(
|
||
egui::RichText::new(format!("{:>3.0} FPS", self.fps))
|
||
.monospace(),
|
||
)
|
||
.selectable(false),
|
||
)
|
||
.on_hover_text(
|
||
"Frames per second while rendering (interaction, \
|
||
animation, export). Frozen when idle.",
|
||
);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
/// Push the desired fullscreen state to the platform.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn apply_fullscreen(&mut self, ctx: &egui::Context) {
|
||
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(self.fullscreen));
|
||
}
|
||
|
||
/// Push the desired fullscreen state to the browser. `request_fullscreen`
|
||
/// must run inside a user gesture; the button click provides the transient
|
||
/// activation that carries into this frame.
|
||
#[cfg(target_arch = "wasm32")]
|
||
fn apply_fullscreen(&mut self, _ctx: &egui::Context) {
|
||
let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
|
||
return;
|
||
};
|
||
if self.fullscreen {
|
||
if let Some(el) = doc.document_element() {
|
||
let _ = el.request_fullscreen();
|
||
}
|
||
} else {
|
||
doc.exit_fullscreen();
|
||
}
|
||
}
|
||
|
||
/// Refresh `self.fullscreen` from the real platform state, so the label is
|
||
/// correct even when fullscreen is left by Esc/F11 or the browser UI.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn sync_fullscreen(&mut self, ctx: &egui::Context) {
|
||
if let Some(fs) = ctx.input(|i| i.viewport().fullscreen) {
|
||
self.fullscreen = fs;
|
||
}
|
||
}
|
||
|
||
#[cfg(target_arch = "wasm32")]
|
||
fn sync_fullscreen(&mut self, _ctx: &egui::Context) {
|
||
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
|
||
self.fullscreen = doc.fullscreen_element().is_some();
|
||
}
|
||
}
|
||
|
||
/// Recompute the smoothed FPS. Counts frames over a ~0.5 s wall-clock window
|
||
/// (using egui's monotonic `i.time`, which works on native and web) and
|
||
/// divides once the window closes, so the readout is steady rather than
|
||
/// jittering every frame. Only advances when egui repaints — i.e. while the
|
||
/// app is doing work — so an idle app shows its last measured rate.
|
||
fn update_fps(&mut self, ui: &egui::Ui) {
|
||
let now = ui.input(|i| i.time);
|
||
// Reset the window if time went backwards or hasn't started yet.
|
||
if self.fps_window_start <= 0.0 || now < self.fps_window_start {
|
||
self.fps_window_start = now;
|
||
self.fps_frames = 0;
|
||
}
|
||
self.fps_frames += 1;
|
||
let elapsed = now - self.fps_window_start;
|
||
if elapsed >= 0.5 {
|
||
self.fps = (self.fps_frames as f64 / elapsed) as f32;
|
||
self.fps_frames = 0;
|
||
self.fps_window_start = now;
|
||
}
|
||
}
|
||
|
||
/// Advance any enabled animations by the frame's elapsed time, and request a
|
||
/// repaint while active. Animations render at full resolution/AA (they do not
|
||
/// trigger the interaction low-res pass).
|
||
fn tick_animations(&mut self, ui: &egui::Ui) {
|
||
// Julia c only matters in Julia mode; Phoenix p only for the Phoenix kind; Lambda λ only for Lambda kind.
|
||
let julia_on = self.anim.julia && self.mode == FractalMode::Julia;
|
||
let phoenix_on = self.anim.phoenix && self.kind == FractalKind::Phoenix;
|
||
let lambda_on = self.anim.lambda && self.kind == FractalKind::Lambda;
|
||
if !(self.anim.color || self.anim.zoom || julia_on || phoenix_on || lambda_on) {
|
||
return;
|
||
}
|
||
|
||
// Clamp dt so a stall (tab hidden, first frame) can't jump the animation.
|
||
let dt = ui.input(|i| i.stable_dt as f64).clamp(0.0, 0.1);
|
||
|
||
if self.anim.color {
|
||
self.color_offset =
|
||
(self.color_offset + self.anim.color_speed * dt as f32).rem_euclid(1.0);
|
||
}
|
||
if julia_on {
|
||
self.anim.julia_angle += std::f64::consts::TAU * self.anim.julia_speed as f64 * dt;
|
||
let (s, c) = self.anim.julia_angle.sin_cos();
|
||
self.julia_c = (
|
||
self.anim.julia_base.0 + self.anim.julia_radius * c,
|
||
self.anim.julia_base.1 + self.anim.julia_radius * s,
|
||
);
|
||
}
|
||
if phoenix_on {
|
||
self.anim.phoenix_angle += std::f64::consts::TAU * self.anim.phoenix_speed as f64 * dt;
|
||
let (s, c) = self.anim.phoenix_angle.sin_cos();
|
||
self.phoenix_p = (
|
||
self.anim.phoenix_base.0 + self.anim.phoenix_radius * c,
|
||
self.anim.phoenix_base.1 + self.anim.phoenix_radius * s,
|
||
);
|
||
}
|
||
let lambda_on = self.anim.lambda && self.kind == FractalKind::Lambda;
|
||
if lambda_on {
|
||
self.anim.lambda_angle += std::f64::consts::TAU * self.anim.lambda_speed as f64 * dt;
|
||
let (s, c) = self.anim.lambda_angle.sin_cos();
|
||
self.lambda_l = (
|
||
self.anim.lambda_base.0 + self.anim.lambda_radius * c,
|
||
self.anim.lambda_base.1 + self.anim.lambda_radius * s,
|
||
);
|
||
}
|
||
if self.anim.zoom && self.anim.zoom_speed != 0.0 {
|
||
let min_hh = DEFAULT_HALF_HEIGHT * 1.0e-26; // practical f32-perturbation depth
|
||
let max_hh = DEFAULT_HALF_HEIGHT * 4.0;
|
||
let factor = (-(self.anim.zoom_speed as f64) * dt).exp();
|
||
let target = (self.view.half_height * factor).clamp(min_hh, max_hh);
|
||
let f = target / self.view.half_height;
|
||
if (f - 1.0).abs() > 1.0e-9 {
|
||
self.view
|
||
.zoom_at_pixel(0.0, 0.0, self.last_size_px.y.max(1.0) as f64, f);
|
||
}
|
||
}
|
||
|
||
ui.ctx().request_repaint();
|
||
}
|
||
|
||
fn controls_ui(&mut self, ui: &mut egui::Ui) {
|
||
ui.heading("Fractal Explorer");
|
||
ui.separator();
|
||
|
||
// Fractal formula. Switching kinds jumps to a sensible default view,
|
||
// since interesting regions differ between fractals.
|
||
let prev_kind = self.kind;
|
||
egui::ComboBox::from_label("fractal")
|
||
.selected_text(kind_label(self.kind))
|
||
.show_ui(ui, |ui| {
|
||
for &(kind, name) in KINDS {
|
||
ui.selectable_value(&mut self.kind, kind, name);
|
||
}
|
||
});
|
||
if self.kind == FractalKind::Multibrot {
|
||
ui.add(egui::Slider::new(&mut self.power, 2..=8).text("power"));
|
||
}
|
||
if self.kind == FractalKind::Phoenix {
|
||
ui.horizontal(|ui| {
|
||
ui.label("p =");
|
||
ui.add(
|
||
egui::DragValue::new(&mut self.phoenix_p.0)
|
||
.speed(0.001)
|
||
.range(-2.0..=2.0),
|
||
);
|
||
ui.add(
|
||
egui::DragValue::new(&mut self.phoenix_p.1)
|
||
.speed(0.001)
|
||
.range(-2.0..=2.0),
|
||
);
|
||
ui.label("i");
|
||
});
|
||
}
|
||
if self.kind == FractalKind::Lambda {
|
||
ui.horizontal(|ui| {
|
||
ui.label("λ =");
|
||
ui.add(
|
||
egui::DragValue::new(&mut self.lambda_l.0)
|
||
.speed(0.001)
|
||
.range(-2.0..=2.0),
|
||
);
|
||
ui.add(
|
||
egui::DragValue::new(&mut self.lambda_l.1)
|
||
.speed(0.001)
|
||
.range(-2.0..=2.0),
|
||
);
|
||
ui.label("i");
|
||
});
|
||
}
|
||
if self.kind != prev_kind {
|
||
self.view = Self::default_view_for(self.mode, self.kind);
|
||
}
|
||
|
||
ui.checkbox(&mut self.buddhabrot, "Buddhabrot")
|
||
.on_hover_text(
|
||
"Monte-Carlo density of escaping orbits instead of the ordinary \
|
||
escape-time set. Plain f32 view (no deep zoom); the image \
|
||
progressively sharpens while the view stays still.",
|
||
);
|
||
|
||
if self.buddhabrot {
|
||
self.buddhabrot_ui(ui);
|
||
ui.separator();
|
||
if ui.button("Reset view").clicked() {
|
||
self.view = Self::default_view_for(self.mode, self.kind);
|
||
}
|
||
ui.add_space(8.0);
|
||
ui.small("Drag to pan · scroll to zoom toward the cursor");
|
||
return;
|
||
}
|
||
|
||
ui.horizontal(|ui| {
|
||
ui.radio_value(&mut self.mode, FractalMode::Mandelbrot, "Set");
|
||
ui.radio_value(&mut self.mode, FractalMode::Julia, "Julia");
|
||
});
|
||
|
||
if self.mode == FractalMode::Julia && self.kind != FractalKind::Lambda {
|
||
ui.horizontal(|ui| {
|
||
ui.label("c =");
|
||
ui.add(
|
||
egui::DragValue::new(&mut self.julia_c.0)
|
||
.speed(0.001)
|
||
.range(-2.0..=2.0),
|
||
);
|
||
ui.add(
|
||
egui::DragValue::new(&mut self.julia_c.1)
|
||
.speed(0.001)
|
||
.range(-2.0..=2.0),
|
||
);
|
||
ui.label("i");
|
||
});
|
||
|
||
if !JULIA_PRESETS[self.kind as usize].is_empty() {
|
||
ui.horizontal_wrapped(|ui| {
|
||
for &(name, re, im, iterations, phoenix) in JULIA_PRESETS[self.kind as usize] {
|
||
if ui.small_button(name).clicked() {
|
||
self.julia_c = (re, im);
|
||
self.max_iterations = iterations.clamp(32, MAX_REF_POINTS as u32 - 1);
|
||
|
||
if let Some(phoenix) = phoenix {
|
||
self.phoenix_p = phoenix;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
if self.mode == FractalMode::Mandelbrot && !SET_PRESETS[self.kind as usize].is_empty() {
|
||
ui.label("places:");
|
||
ui.horizontal_wrapped(|ui| {
|
||
for &(name, re, im, half_height, iter, phoenix) in SET_PRESETS[self.kind as usize] {
|
||
if ui.small_button(name).clicked() {
|
||
self.go_to_place(re, im, half_height, iter);
|
||
|
||
if let Some(phoenix) = phoenix {
|
||
self.phoenix_p = phoenix;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
ui.separator();
|
||
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")
|
||
.logarithmic(true),
|
||
);
|
||
ui.add(egui::Slider::new(&mut self.color_offset, 0.0..=1.0).text("color offset"));
|
||
ui.checkbox(&mut self.shadow, "Shadow");
|
||
if !self.shadow {
|
||
egui::ComboBox::from_label("palette")
|
||
.selected_text(PALETTE_NAMES[self.palette as usize])
|
||
.show_ui(ui, |ui| {
|
||
for (i, name) in PALETTE_NAMES.iter().enumerate() {
|
||
ui.selectable_value(&mut self.palette, i as u32, *name);
|
||
}
|
||
});
|
||
} else {
|
||
egui::ComboBox::from_label("palette")
|
||
.selected_text(SHADOW_PALETTE_NAMES[self.shadow_palette as usize])
|
||
.show_ui(ui, |ui| {
|
||
for (i, name) in SHADOW_PALETTE_NAMES.iter().enumerate() {
|
||
ui.selectable_value(&mut self.shadow_palette, i as u32, *name);
|
||
}
|
||
});
|
||
}
|
||
if self.shadow && self.shadow_palette as usize == SHADOW_PALETTE_NAMES.len() - 1 {
|
||
ui.horizontal(|ui| {
|
||
ui.label("lights:");
|
||
if ui.button("+").clicked() {
|
||
self.lights.push(Light::default());
|
||
}
|
||
});
|
||
egui::Grid::new("lights")
|
||
.striped(true)
|
||
.num_columns(1)
|
||
.show(ui, |ui| {
|
||
self.lights.retain_mut(|light| {
|
||
let delete = !light.widget(ui);
|
||
ui.end_row();
|
||
delete
|
||
});
|
||
});
|
||
}
|
||
ui.separator();
|
||
ui.checkbox(&mut self.antialias, "Antialiasing (2×2)")
|
||
.on_hover_text("Supersample each pixel for smoother edges (~4× slower).");
|
||
if !self.shadow {
|
||
ui.checkbox(&mut self.de_coloring, "Distance shading")
|
||
.on_hover_text(
|
||
"Shade by distance to the set boundary (from the orbit derivative) \
|
||
for crisp filaments at deep zoom. Exact for the holomorphic kinds \
|
||
(Mandelbrot/Multibrot/Phoenix), approximate for the abs-based kinds \
|
||
(Burning Ship/Tricorn/Celtic/Perpendicular/Buffalo).",
|
||
);
|
||
}
|
||
|
||
ui.collapsing("Animation", |ui| {
|
||
if !self.shadow {
|
||
ui.checkbox(&mut self.anim.color, "Cycle colours")
|
||
.on_hover_text("Scroll the palette offset over time.");
|
||
if self.anim.color {
|
||
ui.add(
|
||
egui::Slider::new(&mut self.anim.color_speed, 0.01..=2.0)
|
||
.text("cycles/s")
|
||
.logarithmic(true),
|
||
);
|
||
}
|
||
}
|
||
|
||
ui.checkbox(&mut self.anim.zoom, "Auto-zoom")
|
||
.on_hover_text("Continuously zoom toward the current center.");
|
||
if self.anim.zoom {
|
||
ui.add(
|
||
egui::Slider::new(&mut self.anim.zoom_speed, -2.0..=2.0).text("rate (+ = in)"),
|
||
);
|
||
}
|
||
|
||
// Julia c only affects Julia mode; Phoenix p only the Phoenix kind.
|
||
if self.mode == FractalMode::Julia {
|
||
if ui.checkbox(&mut self.anim.julia, "Morph c").changed() && self.anim.julia {
|
||
self.anim.julia_base = self.julia_c; // orbit around the current c
|
||
self.anim.julia_angle = 0.0;
|
||
}
|
||
if self.anim.julia {
|
||
ui.add(
|
||
egui::Slider::new(&mut self.anim.julia_speed, 0.005..=0.5)
|
||
.text("c rev/s")
|
||
.logarithmic(true),
|
||
);
|
||
ui.add(
|
||
egui::Slider::new(&mut self.anim.julia_radius, 0.005..=0.5)
|
||
.text("c radius")
|
||
.logarithmic(true),
|
||
);
|
||
}
|
||
}
|
||
if self.kind == FractalKind::Phoenix {
|
||
if ui.checkbox(&mut self.anim.phoenix, "Morph p").changed() && self.anim.phoenix {
|
||
self.anim.phoenix_base = self.phoenix_p;
|
||
self.anim.phoenix_angle = 0.0;
|
||
}
|
||
if self.anim.phoenix {
|
||
ui.add(
|
||
egui::Slider::new(&mut self.anim.phoenix_speed, 0.005..=0.5)
|
||
.text("p rev/s")
|
||
.logarithmic(true),
|
||
);
|
||
ui.add(
|
||
egui::Slider::new(&mut self.anim.phoenix_radius, 0.005..=0.5)
|
||
.text("p radius")
|
||
.logarithmic(true),
|
||
);
|
||
}
|
||
}
|
||
if self.kind == FractalKind::Lambda {
|
||
if ui.checkbox(&mut self.anim.lambda, "Morph λ").changed() && self.anim.lambda {
|
||
self.anim.lambda_base = self.lambda_l;
|
||
self.anim.lambda_angle = 0.0;
|
||
}
|
||
if self.anim.lambda {
|
||
ui.add(
|
||
egui::Slider::new(&mut self.anim.lambda_speed, 0.005..=0.5)
|
||
.text("λ rev/s")
|
||
.logarithmic(true),
|
||
);
|
||
ui.add(
|
||
egui::Slider::new(&mut self.anim.lambda_radius, 0.005..=0.5)
|
||
.text("λ radius")
|
||
.logarithmic(true),
|
||
);
|
||
}
|
||
}
|
||
});
|
||
|
||
ui.separator();
|
||
// Editable center coordinates. Shown at full precision; parsed
|
||
// losslessly on commit (Enter or focus loss). While a field is focused
|
||
// we leave the user's text alone; otherwise we refresh it from the live
|
||
// view, which panning and zooming keep changing.
|
||
let bits = self.view.precision_bits();
|
||
let sig = sig_digits_for(bits);
|
||
|
||
ui.label("center re:");
|
||
let re_resp = ui.add(
|
||
egui::TextEdit::singleline(&mut self.center_re_edit)
|
||
.desired_width(f32::INFINITY)
|
||
.font(egui::TextStyle::Monospace),
|
||
);
|
||
if re_resp.lost_focus()
|
||
&& let Some(v) = big_from_decimal_str(
|
||
&self.center_re_edit,
|
||
parse_bits_for(&self.center_re_edit, bits),
|
||
)
|
||
{
|
||
self.view.center_re = v;
|
||
self.view.sync_precision();
|
||
}
|
||
if !re_resp.has_focus() {
|
||
self.center_re_edit = big_to_decimal_str(&self.view.center_re, sig);
|
||
}
|
||
|
||
ui.label("center im:");
|
||
let im_resp = ui.add(
|
||
egui::TextEdit::singleline(&mut self.center_im_edit)
|
||
.desired_width(f32::INFINITY)
|
||
.font(egui::TextStyle::Monospace),
|
||
);
|
||
if im_resp.lost_focus()
|
||
&& let Some(v) = big_from_decimal_str(
|
||
&self.center_im_edit,
|
||
parse_bits_for(&self.center_im_edit, bits),
|
||
)
|
||
{
|
||
self.view.center_im = v;
|
||
self.view.sync_precision();
|
||
}
|
||
if !im_resp.has_focus() {
|
||
self.center_im_edit = big_to_decimal_str(&self.view.center_im, sig);
|
||
}
|
||
|
||
ui.label("magnification (×):");
|
||
let zoom_resp = ui.add(
|
||
egui::TextEdit::singleline(&mut self.zoom_edit)
|
||
.desired_width(f32::INFINITY)
|
||
.font(egui::TextStyle::Monospace),
|
||
);
|
||
if zoom_resp.changed() {
|
||
self.zoom_edited = true;
|
||
}
|
||
if zoom_resp.lost_focus() {
|
||
if self.zoom_edited
|
||
&& let Ok(m) = self.zoom_edit.trim().parse::<f64>()
|
||
{
|
||
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());
|
||
}
|
||
ui.label(format!("reference: {} pts", self.reference.len()));
|
||
ui.label(format!("precision: {} bits", self.view.precision_bits()));
|
||
if self.pending {
|
||
ui.colored_label(egui::Color32::LIGHT_YELLOW, "computing reference…");
|
||
}
|
||
|
||
ui.separator();
|
||
let exporting = self.export.is_some();
|
||
ui.horizontal(|ui| {
|
||
if ui.button("Copy link").clicked() {
|
||
let url = self.share_url();
|
||
ui.ctx().copy_text(url);
|
||
self.status = Some("link copied".into());
|
||
}
|
||
if ui
|
||
.add_enabled(!exporting, egui::Button::new("Export PNG"))
|
||
.clicked()
|
||
{
|
||
self.export_requested = true;
|
||
}
|
||
});
|
||
ui.horizontal(|ui| {
|
||
ui.label("export scale");
|
||
ui.add(
|
||
egui::DragValue::new(&mut self.export_scale)
|
||
.range(1.0..=16.0)
|
||
.speed(0.5),
|
||
);
|
||
ui.label(format!(
|
||
"→ {}×{}",
|
||
(self.last_size_px.x * self.export_scale) as u32,
|
||
(self.last_size_px.y * self.export_scale) as u32,
|
||
));
|
||
});
|
||
if let Some(shared) = &self.export {
|
||
let (fraction, phase) = {
|
||
let s = shared.lock().unwrap();
|
||
(s.fraction, s.phase)
|
||
};
|
||
ui.add(
|
||
egui::ProgressBar::new(fraction)
|
||
.animate(true)
|
||
.text(format!("{phase} {:.0}%", fraction * 100.0)),
|
||
);
|
||
} else if let Some(status) = &self.status {
|
||
ui.small(status);
|
||
}
|
||
|
||
ui.separator();
|
||
if ui.button("Reset view").clicked() {
|
||
self.view = Self::default_view_for(self.mode, self.kind);
|
||
}
|
||
ui.add_space(8.0);
|
||
ui.small("Drag to pan · scroll to zoom toward the cursor");
|
||
}
|
||
|
||
/// Controls for Buddhabrot mode: nested iteration caps (Nebulabrot R/G/B
|
||
/// coloring), exposure, and the progressive-accumulation toggle.
|
||
fn buddhabrot_ui(&mut self, ui: &mut egui::Ui) {
|
||
ui.separator();
|
||
ui.add(
|
||
egui::Slider::new(&mut self.buddha_r_cap, 5..=5_000)
|
||
.text("red cap")
|
||
.logarithmic(true),
|
||
);
|
||
ui.add(
|
||
egui::Slider::new(&mut self.buddha_g_cap, 5..=20_000)
|
||
.text("green cap")
|
||
.logarithmic(true),
|
||
);
|
||
ui.add(
|
||
egui::Slider::new(&mut self.buddha_b_cap, 5..=50_000)
|
||
.text("blue cap")
|
||
.logarithmic(true),
|
||
);
|
||
ui.add(
|
||
egui::Slider::new(&mut self.buddha_exposure, 0.02..=50.0)
|
||
.text("exposure")
|
||
.logarithmic(true),
|
||
);
|
||
egui::ComboBox::from_label("colors")
|
||
.selected_text(BUDDHA_PALETTE_NAMES[self.buddha_palette as usize])
|
||
.show_ui(ui, |ui| {
|
||
for (i, name) in BUDDHA_PALETTE_NAMES.iter().enumerate() {
|
||
ui.selectable_value(&mut self.buddha_palette, i as u32, *name);
|
||
}
|
||
});
|
||
ui.checkbox(&mut self.buddha_accumulate, "Keep sampling")
|
||
.on_hover_text("Dispatch a fresh batch of random samples every frame.");
|
||
if self.view.magnification() > 1.0e5 {
|
||
ui.colored_label(
|
||
egui::Color32::LIGHT_YELLOW,
|
||
"deep zoom isn't supported here (f32 precision only)",
|
||
);
|
||
}
|
||
ui.small("PNG export isn't available in Buddhabrot mode yet.");
|
||
}
|
||
|
||
fn fractal_ui(&mut self, ui: &mut egui::Ui) {
|
||
let size = ui.available_size();
|
||
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click_and_drag());
|
||
if rect.width() < 1.0 || rect.height() < 1.0 {
|
||
return;
|
||
}
|
||
let height_px = rect.height() as f64;
|
||
let aspect = (rect.width() / rect.height()) as f64;
|
||
self.last_size_px = rect.size();
|
||
|
||
// Tracks whether the view actually moved this frame, so progressive
|
||
// rendering can drop to a cheap low-res pass only while interacting.
|
||
let mut interacted = false;
|
||
|
||
// Advance time-based animations (colours / Julia c / Phoenix p / zoom).
|
||
// These render at full resolution/AA — only real pan/zoom drops to the
|
||
// cheap low-res pass, so `interacted` is left untouched here.
|
||
self.tick_animations(ui);
|
||
|
||
// Touch: pinch to zoom (toward the gesture center) and two-finger pan.
|
||
// Takes precedence over single-finger drag while two fingers are down.
|
||
let multi_touch = ui.input(|i| i.multi_touch());
|
||
if let Some(mt) = multi_touch {
|
||
let t = mt.translation_delta;
|
||
if t.x != 0.0 || t.y != 0.0 {
|
||
self.view.pan_pixels(t.x as f64, t.y as f64, height_px);
|
||
interacted = true;
|
||
}
|
||
if mt.zoom_delta != 1.0 {
|
||
let off = mt.center_pos - rect.center();
|
||
// zoom_delta > 1 = fingers spreading = zoom in (smaller span).
|
||
let factor = 1.0 / mt.zoom_delta as f64;
|
||
self.view
|
||
.zoom_at_pixel(off.x as f64, off.y as f64, height_px, factor);
|
||
interacted = true;
|
||
}
|
||
ui.ctx().request_repaint();
|
||
} else if response.dragged() {
|
||
// Single-finger / mouse drag pans.
|
||
let d = response.drag_delta();
|
||
if d.x != 0.0 || d.y != 0.0 {
|
||
self.view.pan_pixels(d.x as f64, d.y as f64, height_px);
|
||
interacted = true;
|
||
}
|
||
}
|
||
|
||
// Mouse wheel / trackpad: zoom toward the cursor.
|
||
let (scroll_y, hover) = ui.input(|i| (i.smooth_scroll_delta.y, i.pointer.hover_pos()));
|
||
if scroll_y != 0.0
|
||
&& let Some(pos) = hover
|
||
&& rect.contains(pos)
|
||
{
|
||
let off = pos - rect.center();
|
||
let factor = (-scroll_y as f64 * 0.0015).exp();
|
||
self.view
|
||
.zoom_at_pixel(off.x as f64, off.y as f64, height_px, factor);
|
||
interacted = true;
|
||
ui.ctx().request_repaint();
|
||
}
|
||
|
||
if self.buddhabrot {
|
||
// No reference orbit / perturbation machinery: iterate directly in
|
||
// f32 from the live view. Progressive accumulation means this
|
||
// needs its own continuous repaint, separate from the escape-time
|
||
// interaction-driven one above.
|
||
let ppp = ui.ctx().pixels_per_point();
|
||
let size_px = [
|
||
((rect.width() * ppp).round() as u32).max(1),
|
||
((rect.height() * ppp).round() as u32).max(1),
|
||
];
|
||
let uniforms = self.make_buddhabrot_uniforms(aspect);
|
||
ui.painter().add(egui_wgpu::Callback::new_paint_callback(
|
||
rect,
|
||
BuddhabrotCallback {
|
||
uniforms,
|
||
accumulate: self.buddha_accumulate,
|
||
size_px,
|
||
},
|
||
));
|
||
if self.buddha_accumulate {
|
||
ui.ctx().request_repaint();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 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,
|
||
// instead of spinning a full-speed repaint. Once ready, changed inputs
|
||
// (or the initial draw) drive repaints on their own.
|
||
let poll = std::time::Duration::from_millis(30);
|
||
if self.reference.is_empty() {
|
||
// Nothing to draw until the first reference orbit is ready.
|
||
ui.ctx().request_repaint_after(poll);
|
||
return;
|
||
}
|
||
if self.pending {
|
||
ui.ctx().request_repaint_after(poll);
|
||
}
|
||
|
||
// Progressive rendering: while the user is actively panning/zooming (an
|
||
// interaction within the last `INTERACT_SETTLE` seconds), render at a
|
||
// fraction of the resolution with AA off so each frame is cheap, then let
|
||
// it snap to full resolution once input settles. `i.time` is monotonic on
|
||
// both native and web (avoids `Instant`, which isn't available on wasm).
|
||
let now = ui.input(|i| i.time);
|
||
if interacted {
|
||
self.last_interact_time = now;
|
||
}
|
||
let interacting = now - self.last_interact_time < INTERACT_SETTLE;
|
||
if interacting {
|
||
// Ensure a frame fires once the settle window elapses, so the view
|
||
// is re-rendered at full resolution even if no further input arrives.
|
||
ui.ctx()
|
||
.request_repaint_after(std::time::Duration::from_secs_f64(INTERACT_SETTLE));
|
||
}
|
||
|
||
// Cache-texture resolution: the widget size in physical pixels, divided
|
||
// down while interacting (the linear blit upsamples it to the widget).
|
||
let ppp = ui.ctx().pixels_per_point();
|
||
let downscale = if interacting { INTERACT_DOWNSCALE } else { 1 };
|
||
let size_px = [
|
||
(((rect.width() * ppp).round() as u32) / downscale).max(1),
|
||
(((rect.height() * ppp).round() as u32) / downscale).max(1),
|
||
];
|
||
|
||
let mut uniforms = self.make_uniforms(aspect);
|
||
if interacting {
|
||
uniforms.aa_level = 1; // supersampling is wasted on the low-res pass
|
||
}
|
||
ui.painter().add(egui_wgpu::Callback::new_paint_callback(
|
||
rect,
|
||
FractalCallback {
|
||
uniforms,
|
||
lights: self.lights.clone(),
|
||
reference: Arc::clone(&self.reference),
|
||
generation: self.generation,
|
||
size_px,
|
||
},
|
||
));
|
||
}
|
||
}
|
||
|
||
impl eframe::App for FractalApp {
|
||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||
self.poll_export(ui.ctx());
|
||
self.update_fps(ui);
|
||
// Track the real fullscreen state (e.g. the user pressing Esc/F11 or the
|
||
// browser leaving fullscreen) so the toggle button label stays correct.
|
||
self.sync_fullscreen(ui.ctx());
|
||
|
||
// Cap the panel width so it never swallows a narrow (phone) screen, and
|
||
// make it collapsible + scrollable so every parameter stays reachable.
|
||
let panel_max = (ui.available_width() * 0.6).clamp(160.0, 340.0);
|
||
let mut open = self.controls_open;
|
||
egui::Panel::right("controls")
|
||
.resizable(true)
|
||
.default_size(panel_max.min(280.0))
|
||
.max_size(panel_max)
|
||
.show_collapsible(ui, &mut open, |ui| {
|
||
egui::ScrollArea::vertical()
|
||
.auto_shrink([false, false])
|
||
.show(ui, |ui| self.controls_ui(ui));
|
||
});
|
||
self.controls_open = open;
|
||
|
||
egui::CentralPanel::default()
|
||
.frame(egui::Frame::NONE)
|
||
.show(ui, |ui| self.fractal_ui(ui));
|
||
|
||
// Floating overlay, always reachable (even when the panel is collapsed):
|
||
// toggle the panel and toggle fullscreen. Essential on a phone.
|
||
self.overlay_buttons(ui);
|
||
|
||
if std::mem::take(&mut self.export_requested) {
|
||
self.do_export(frame);
|
||
}
|
||
}
|
||
|
||
#[cfg(target_arch = "wasm32")]
|
||
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
|
||
Some(&mut *self)
|
||
}
|
||
}
|
||
|
||
/// Update an export's progress (phase label + fraction).
|
||
fn set_progress(shared: &Arc<Mutex<ExportShared>>, phase: &'static str, fraction: f32) {
|
||
let mut s = shared.lock().unwrap();
|
||
s.phase = phase;
|
||
s.fraction = fraction;
|
||
}
|
||
|
||
/// Mark an export finished with its outcome.
|
||
fn finish_export(shared: &Arc<Mutex<ExportShared>>, result: Result<String, String>) {
|
||
let mut s = shared.lock().unwrap();
|
||
s.phase = "Done";
|
||
s.fraction = 1.0;
|
||
s.result = Some(result);
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn unix_timestamp() -> u64 {
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_secs())
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
#[cfg(target_arch = "wasm32")]
|
||
fn web_location_hash() -> Option<String> {
|
||
let hash = web_sys::window()?.location().hash().ok()?;
|
||
if hash.trim_start_matches('#').is_empty() {
|
||
None
|
||
} else {
|
||
Some(hash)
|
||
}
|
||
}
|
||
|
||
#[cfg(target_arch = "wasm32")]
|
||
fn web_download_png(bytes: &[u8], filename: &str) {
|
||
use wasm_bindgen::JsCast as _;
|
||
|
||
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
|
||
return;
|
||
};
|
||
let array = js_sys::Uint8Array::from(bytes);
|
||
let parts = js_sys::Array::new();
|
||
parts.push(&array);
|
||
let options = web_sys::BlobPropertyBag::new();
|
||
options.set_type("image/png");
|
||
let Ok(blob) = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options) else {
|
||
return;
|
||
};
|
||
let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob) else {
|
||
return;
|
||
};
|
||
if let Some(anchor) = document
|
||
.create_element("a")
|
||
.ok()
|
||
.and_then(|el| el.dyn_into::<web_sys::HtmlAnchorElement>().ok())
|
||
{
|
||
anchor.set_href(&url);
|
||
anchor.set_download(filename);
|
||
anchor.click();
|
||
}
|
||
let _ = web_sys::Url::revoke_object_url(&url);
|
||
}
|