First commit
This commit is contained in:
+693
@@ -0,0 +1,693 @@
|
||||
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, 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;
|
||||
/// Palette names; index maps to `palette_id` in the shader.
|
||||
const PALETTE_NAMES: &[&str] = &["Rainbow", "Amber", "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,
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
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 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,
|
||||
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,
|
||||
};
|
||||
|
||||
// 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 bits = self.view.precision_bits();
|
||||
let sig_digits = ((bits as f64) * std::f64::consts::LOG10_2).ceil() as usize + 3;
|
||||
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,
|
||||
_pad0: 0,
|
||||
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.separator();
|
||||
let (cre, cim) = self.view.center_f64();
|
||||
ui.label(format!("center re:\n {cre:+.17}"));
|
||||
ui.label(format!("center im:\n {cim:+.17}"));
|
||||
ui.label(format!("magnification: {:.3e}×", 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..=4.0)
|
||||
.speed(0.1),
|
||||
);
|
||||
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();
|
||||
|
||||
// Pan by dragging.
|
||||
if response.dragged() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Zoom toward the cursor on scroll.
|
||||
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();
|
||||
|
||||
// Nothing to draw until the first reference orbit is ready.
|
||||
if self.reference.is_empty() {
|
||||
ui.ctx().request_repaint();
|
||||
return;
|
||||
}
|
||||
// Keep polling the worker while a newer reference is computing.
|
||||
if self.pending {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user