825 lines
29 KiB
Rust
825 lines
29 KiB
Rust
use std::sync::Arc;
|
||
|
||
use eframe::CreationContext;
|
||
use eframe::egui_wgpu;
|
||
use eframe::egui_wgpu::wgpu;
|
||
|
||
use crate::fractal::{FractalCallback, FractalRenderer, MAX_REF_POINTS, ShareState, Uniforms};
|
||
#[cfg(target_arch = "wasm32")]
|
||
use crate::fractal::{compute_mandelbrot_reference, compute_reference};
|
||
use crate::view::{
|
||
Big, DEFAULT_HALF_HEIGHT, ViewState, big_from_decimal_str, big_from_f64, big_to_decimal_str,
|
||
precision_for,
|
||
};
|
||
|
||
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;
|
||
/// Palette names; index maps to `palette_id` in the shader.
|
||
const PALETTE_NAMES: &[&str] = &["Amber", "Rainbow", "Ember", "Lime", "Grayscale"];
|
||
|
||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||
pub enum FractalMode {
|
||
Mandelbrot,
|
||
Julia,
|
||
}
|
||
|
||
/// Nice-looking Julia constants offered as presets.
|
||
const JULIA_PRESETS: &[(&str, f64, f64)] = &[
|
||
("dendrite", -0.8, 0.156),
|
||
("rabbit", -0.123, 0.745),
|
||
("spiral", -0.4, 0.6),
|
||
("san marco", -0.75, 0.0),
|
||
("siegel", -0.391, -0.587),
|
||
];
|
||
|
||
/// 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),
|
||
iter: u32,
|
||
}
|
||
|
||
/// Top-level egui application.
|
||
pub struct FractalApp {
|
||
view: ViewState,
|
||
mode: FractalMode,
|
||
julia_c: (f64, f64),
|
||
max_iterations: u32,
|
||
color_scale: f32,
|
||
color_offset: f32,
|
||
palette: u32,
|
||
/// Supersample each pixel 2×2 for smoother edges (costs ~4× fragment work).
|
||
antialias: bool,
|
||
|
||
/// 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,
|
||
/// Set when the user requests a PNG export (handled after the panels draw).
|
||
export_requested: bool,
|
||
/// 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);
|
||
render_state
|
||
.renderer
|
||
.write()
|
||
.callback_resources
|
||
.insert(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,
|
||
julia_c: (-0.8, 0.156),
|
||
max_iterations: 512,
|
||
color_scale: 0.15,
|
||
color_offset: 0.0,
|
||
palette: 0,
|
||
antialias: false,
|
||
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),
|
||
export_requested: false,
|
||
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.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
if let Ok(jc) = std::env::var("MANDEL_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);
|
||
}
|
||
}
|
||
if let Ok(frag) = std::env::var("MANDEL_SHARE")
|
||
&& let Some(state) = ShareState::decode(&frag)
|
||
{
|
||
app.apply_share(&state);
|
||
}
|
||
if let Ok(spec) = std::env::var("MANDEL_VIEW") {
|
||
app.apply_view_spec(&spec);
|
||
}
|
||
if std::env::var("MANDEL_EXPORT").is_ok() {
|
||
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
|
||
}
|
||
|
||
/// 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),
|
||
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,
|
||
color_scale: self.color_scale,
|
||
color_offset: self.color_offset,
|
||
}
|
||
}
|
||
|
||
/// 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.julia_c = s.julia_c;
|
||
self.color_scale = s.color_scale;
|
||
self.color_offset = s.color_offset;
|
||
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 fractal mode.
|
||
fn default_view_for(mode: FractalMode) -> ViewState {
|
||
match mode {
|
||
FractalMode::Mandelbrot => ViewState::default(),
|
||
FractalMode::Julia => {
|
||
ViewState::with_center(big_from_f64(0.0, 53), big_from_f64(0.0, 53), 1.5)
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
iter: self.max_iterations,
|
||
}
|
||
}
|
||
|
||
/// 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.
|
||
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.iter != self.max_iterations
|
||
{
|
||
return true;
|
||
}
|
||
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 key = self.current_key();
|
||
let precision = self.view.precision_bits();
|
||
let max_iter = key.iter.min(MAX_REF_POINTS as u32 - 1);
|
||
|
||
#[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,
|
||
});
|
||
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,
|
||
)
|
||
} else {
|
||
compute_mandelbrot_reference(
|
||
&key.center_re,
|
||
&key.center_im,
|
||
max_iter,
|
||
precision,
|
||
)
|
||
};
|
||
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,
|
||
aa_level: if self.antialias { 2 } else { 1 },
|
||
dc_offset: self.dc_offset(),
|
||
}
|
||
}
|
||
|
||
/// Render the current view to a PNG at `export_scale` × the on-screen size,
|
||
/// then save it (native: file in cwd; web: browser download).
|
||
fn do_export(&mut self, frame: &mut eframe::Frame) {
|
||
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());
|
||
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 (buffer, padded_bpr, swap) = {
|
||
let guard = rs.renderer.read();
|
||
let Some(renderer) = guard.callback_resources.get::<FractalRenderer>() else {
|
||
self.status = Some("export unavailable".into());
|
||
return;
|
||
};
|
||
renderer.upload_reference(&queue, &self.reference);
|
||
let (buffer, bpr) = renderer.render_to_readback(&device, &queue, w, h, uniforms);
|
||
(buffer, bpr, renderer.needs_rb_swap())
|
||
};
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
let png = pollster::block_on(read_and_encode(&device, buffer, w, h, padded_bpr, swap));
|
||
let name = std::env::var("MANDEL_EXPORT_PATH")
|
||
.unwrap_or_else(|_| format!("fractal-{}.png", unix_timestamp()));
|
||
match std::fs::write(&name, &png) {
|
||
Ok(_) => self.status = Some(format!("saved {name} ({w}×{h})")),
|
||
Err(e) => self.status = Some(format!("save failed: {e}")),
|
||
}
|
||
}
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
self.status = Some(format!("exporting {w}×{h}…"));
|
||
wasm_bindgen_futures::spawn_local(async move {
|
||
let png = read_and_encode(&device, buffer, w, h, padded_bpr, swap).await;
|
||
web_download_png(&png, "fractal.png");
|
||
});
|
||
}
|
||
}
|
||
|
||
fn controls_ui(&mut self, ui: &mut egui::Ui) {
|
||
ui.heading("Fractal Explorer");
|
||
ui.separator();
|
||
|
||
ui.horizontal(|ui| {
|
||
ui.radio_value(&mut self.mode, FractalMode::Mandelbrot, "Mandelbrot");
|
||
ui.radio_value(&mut self.mode, FractalMode::Julia, "Julia");
|
||
});
|
||
|
||
if self.mode == FractalMode::Julia {
|
||
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");
|
||
});
|
||
ui.horizontal_wrapped(|ui| {
|
||
for &(name, re, im) in JULIA_PRESETS {
|
||
if ui.small_button(name).clicked() {
|
||
self.julia_c = (re, im);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
ui.separator();
|
||
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"));
|
||
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);
|
||
}
|
||
});
|
||
ui.checkbox(&mut self.antialias, "Antialiasing (2×2)")
|
||
.on_hover_text("Supersample each pixel for smoother edges (~4× slower).");
|
||
|
||
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();
|
||
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.button("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(status) = &self.status {
|
||
ui.small(status);
|
||
}
|
||
|
||
ui.separator();
|
||
if ui.button("Reset view").clicked() {
|
||
self.view = Self::default_view_for(self.mode);
|
||
}
|
||
ui.add_space(8.0);
|
||
ui.small("Drag to pan · scroll to zoom toward the cursor");
|
||
}
|
||
|
||
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();
|
||
|
||
// 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);
|
||
}
|
||
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);
|
||
}
|
||
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);
|
||
}
|
||
}
|
||
|
||
// 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);
|
||
ui.ctx().request_repaint();
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// Cache-texture resolution: the widget size in physical pixels.
|
||
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_uniforms(aspect);
|
||
ui.painter().add(egui_wgpu::Callback::new_paint_callback(
|
||
rect,
|
||
FractalCallback {
|
||
uniforms,
|
||
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) {
|
||
egui::Panel::right("controls")
|
||
.default_size(280.0)
|
||
.show(ui, |ui| self.controls_ui(ui));
|
||
|
||
egui::CentralPanel::default()
|
||
.frame(egui::Frame::NONE)
|
||
.show(ui, |ui| self.fractal_ui(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)
|
||
}
|
||
}
|
||
|
||
/// Map the readback buffer, unpad it, and encode a PNG. Awaited on both targets
|
||
/// (blocked on via pollster natively; spawned on the web).
|
||
async fn read_and_encode(
|
||
device: &wgpu::Device,
|
||
buffer: wgpu::Buffer,
|
||
width: u32,
|
||
height: u32,
|
||
padded_bpr: u32,
|
||
swap_rb: bool,
|
||
) -> Vec<u8> {
|
||
let (tx, rx) = futures_channel::oneshot::channel();
|
||
buffer.slice(..).map_async(wgpu::MapMode::Read, move |res| {
|
||
let _ = tx.send(res);
|
||
});
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
let _ = device.poll(wgpu::PollType::Wait {
|
||
submission_index: None,
|
||
timeout: None,
|
||
});
|
||
#[cfg(target_arch = "wasm32")]
|
||
let _ = device;
|
||
|
||
let _ = rx.await;
|
||
|
||
let png = {
|
||
let data = buffer
|
||
.slice(..)
|
||
.get_mapped_range()
|
||
.expect("map readback buffer");
|
||
crate::fractal::encode_png(&data, width, height, padded_bpr, swap_rb)
|
||
};
|
||
buffer.unmap();
|
||
png
|
||
}
|
||
|
||
#[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);
|
||
}
|