diff --git a/CLAUDE.md b/CLAUDE.md index e4161a7..9534cf4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,7 @@ cargo test # reference-orbit math, share-link round-trip, WGSL cargo test --test shader_valid # just the WGSL parse/validate tests (naga, no GPU needed) cargo clippy cargo fmt # rustfmt.toml just pins edition = "2024" +cargo build --release --no-default-features # headless-only binary: no eframe/egui (the default `gui` feature) ``` Web build (WebGPU): diff --git a/Cargo.toml b/Cargo.toml index c4a2f84..39bb4b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,11 +3,18 @@ name = "mandelbrot" version = "0.1.0" edition = "2024" +[features] +default = ["gui"] +# Windowed egui app. Without it only `--headless` rendering is built. +gui = ["dep:eframe", "dep:egui"] + [dependencies] bytemuck = { version = "1.25.2", features = ["derive"] } dashu-float = "0.6.0" -eframe = { version = "0.36.2", default-features = false, features = ["wgpu", "default_fonts", "x11", "wayland", "accesskit"] } -egui = "0.36.2" +eframe = { version = "0.36.2", default-features = false, features = ["wgpu", "default_fonts", "x11", "wayland", "accesskit"], optional = true } +egui = { version = "0.36.2", optional = true } +ecolor = { version = "0.36.2", features = ["bytemuck"] } +wgpu = "30.0.1" glam = "0.33.8" log = "0.4.34" png = "0.18.1" diff --git a/src/app.rs b/src/app.rs index 84c001b..bb0d321 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,9 +1,9 @@ use std::sync::{Arc, Mutex}; +#[cfg(feature = "gui")] use eframe::CreationContext; +#[cfg(feature = "gui")] use eframe::egui_wgpu; -#[cfg(target_arch = "wasm32")] -use eframe::egui_wgpu::wgpu; use glam::Vec4; use glam::Vec4Swizzles; @@ -316,6 +316,7 @@ impl ConstOrbit { /// Checkbox + speed/radius sliders; (re)centers the orbit on `current` /// when switched on. + #[cfg(feature = "gui")] fn ui(&mut self, ui: &mut egui::Ui, name: &str, current: (f64, f64)) { if ui.checkbox(&mut self.on, format!("Morph {name}")).changed() && self.on { self.enable(current); @@ -371,6 +372,7 @@ impl AxisOsc { self.base + self.amplitude * self.phase.sin() } + #[cfg(feature = "gui")] fn ui(&mut self, ui: &mut egui::Ui, name: &str, current: f64) { if ui .checkbox(&mut self.on, format!("Animate {name}")) @@ -594,6 +596,7 @@ pub struct FractalApp { /// PNG export resolution multiplier over the on-screen size. export_scale: f32, /// Last on-screen fractal size in physical pixels (for export sizing). + #[cfg(feature = "gui")] 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. @@ -646,6 +649,7 @@ fn parse_bits_for(s: &str, min_bits: usize) -> usize { } impl FractalApp { + #[cfg(feature = "gui")] pub fn new(cc: &CreationContext<'_>) -> Self { let render_state = cc .wgpu_render_state @@ -740,6 +744,7 @@ impl FractalApp { pending: false, export_scale: 2.0, render_scale_3d: 2.0, + #[cfg(feature = "gui")] last_size_px: egui::vec2(1280.0, 720.0), last_interact_time: -1.0e9, export_requested: false, @@ -1414,6 +1419,7 @@ impl FractalApp { /// 3D-mode zoom toward the screen point `off` (points from the widget /// center): unproject it through the camera onto the z = 0 fractal plane, /// then zoom the 2D view about the matching fractal-texture pixel. + #[cfg(feature = "gui")] fn zoom_3d_at(&mut self, off: egui::Vec2, rect: egui::Rect, height_px: f64, factor: f64) { let ndc = (off / rect.size()) * 2.; let camera_ndc_pos = self @@ -1515,6 +1521,7 @@ impl FractalApp { /// 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`. + #[cfg(feature = "gui")] fn do_export(&mut self, frame: &mut eframe::Frame) { if self.export.is_some() { return; // one export at a time @@ -1641,6 +1648,7 @@ impl FractalApp { /// Pick up a finished export (setting the status line) and keep repainting /// while one is in flight so its progress bar animates. + #[cfg(feature = "gui")] fn poll_export(&mut self, ctx: &egui::Context) { if let Some(shared) = &self.export { let done = shared.lock().unwrap().result.take(); @@ -1661,6 +1669,7 @@ impl FractalApp { /// 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). + #[cfg(feature = "gui")] 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)) @@ -1719,6 +1728,7 @@ impl FractalApp { /// "Fractal Info" window. Kept separate from `overlay_buttons` (top-left) /// so it stays out of the way of the panel toggle / fullscreen controls, /// but is still reachable even when the controls panel is collapsed. + #[cfg(feature = "gui")] fn info_button(&mut self, ui: &mut egui::Ui) { egui::Area::new(egui::Id::new("info_button")) .anchor(egui::Align2::LEFT_BOTTOM, egui::vec2(8.0, -8.0)) @@ -1740,6 +1750,7 @@ impl FractalApp { /// Window with details about what's currently on screen: formula, active /// per-kind constants, zoom depth, iteration count. Reads live state, so /// it stays correct as the user pans/zooms/switches kinds. + #[cfg(feature = "gui")] fn info_window(&mut self, ctx: &egui::Context) { let mut open = self.info_open; egui::Window::new("Fractal Info") @@ -1796,6 +1807,7 @@ impl FractalApp { /// Help window: what the app does, plus a reference for mouse/touch and /// keyboard controls. + #[cfg(feature = "gui")] fn help_window(&mut self, ctx: &egui::Context) { let mut open = self.help_open; egui::Window::new("Help") @@ -1886,7 +1898,7 @@ impl FractalApp { } /// Push the desired fullscreen state to the platform. - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(feature = "gui", not(target_arch = "wasm32")))] fn apply_fullscreen(&mut self, ctx: &egui::Context) { ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(self.fullscreen)); } @@ -1894,7 +1906,7 @@ impl FractalApp { /// 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")] + #[cfg(all(feature = "gui", target_arch = "wasm32"))] fn apply_fullscreen(&mut self, _ctx: &egui::Context) { let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return; @@ -1910,14 +1922,14 @@ impl FractalApp { /// 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"))] + #[cfg(all(feature = "gui", 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")] + #[cfg(all(feature = "gui", 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(); @@ -1929,6 +1941,7 @@ impl FractalApp { /// 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. + #[cfg(feature = "gui")] 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. @@ -1948,6 +1961,7 @@ impl FractalApp { /// 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). + #[cfg(feature = "gui")] 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.on && self.mode == FractalMode::Julia; @@ -2092,6 +2106,7 @@ impl FractalApp { } } + #[cfg(feature = "gui")] fn controls_ui(&mut self, ui: &mut egui::Ui) { ui.heading("Fractal Explorer"); ui.separator(); @@ -2574,6 +2589,7 @@ impl FractalApp { /// Controls for Buddhabrot mode: nested iteration caps (Nebulabrot R/G/B /// coloring), exposure, and the progressive-accumulation toggle. + #[cfg(feature = "gui")] fn buddhabrot_ui(&mut self, ui: &mut egui::Ui) { ui.separator(); ui.add( @@ -2614,6 +2630,7 @@ impl FractalApp { ui.small("PNG export isn't available in Buddhabrot mode yet."); } + #[cfg(feature = "gui")] 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()); @@ -2939,6 +2956,7 @@ impl FractalApp { } } +#[cfg(feature = "gui")] impl eframe::App for FractalApp { fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { self.poll_export(ui.ctx()); diff --git a/src/fractal/buddhabrot.rs b/src/fractal/buddhabrot.rs index 5d37921..339ae5b 100644 --- a/src/fractal/buddhabrot.rs +++ b/src/fractal/buddhabrot.rs @@ -5,7 +5,8 @@ use std::collections::HashMap; -use eframe::egui_wgpu::{self, wgpu}; +#[cfg(feature = "gui")] +use eframe::egui_wgpu; /// Random samples dispatched per accumulating frame. Chosen so a frame stays /// interactive on a modest GPU even when most samples run the full `b_cap` @@ -336,6 +337,7 @@ pub struct BuddhabrotCallback { pub size_px: [u32; 2], } +#[cfg(feature = "gui")] impl egui_wgpu::CallbackTrait for BuddhabrotCallback { fn prepare( &self, diff --git a/src/fractal/renderer.rs b/src/fractal/renderer.rs index 21759e2..b7160d2 100644 --- a/src/fractal/renderer.rs +++ b/src/fractal/renderer.rs @@ -12,7 +12,8 @@ use std::collections::HashMap; use std::sync::Arc; -use eframe::egui_wgpu::{self, wgpu}; +#[cfg(feature = "gui")] +use eframe::egui_wgpu; use wgpu::util::DeviceExt as _; use super::kind::FractalKind; @@ -1884,6 +1885,7 @@ pub struct FractalCallback { pub size_px: [u32; 2], } +#[cfg(feature = "gui")] impl egui_wgpu::CallbackTrait for FractalCallback { fn prepare( &self, diff --git a/src/headless.rs b/src/headless.rs index dd8a595..6572959 100644 --- a/src/headless.rs +++ b/src/headless.rs @@ -9,8 +9,6 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Mutex, mpsc}; -use eframe::egui_wgpu::wgpu; - use crate::app::{FractalApp, RefJob, parse_complex_pair, unix_timestamp}; use crate::cli::Cli; use crate::fractal::{ diff --git a/src/lights.rs b/src/lights.rs index 6d64061..edcfb4e 100644 --- a/src/lights.rs +++ b/src/lights.rs @@ -1,7 +1,9 @@ use std::f32::consts::PI; use bytemuck::{Pod, Zeroable}; -use egui::{Color32, Ui}; +use ecolor::Color32; +#[cfg(feature = "gui")] +use egui::Ui; /// Maximum number of simultaneous lights. pub const MAX_LIGHT_COUNT: usize = 16; @@ -27,6 +29,7 @@ impl Default for Light { } impl Light { + #[cfg(feature = "gui")] pub fn widget(&mut self, ui: &mut Ui) -> bool { let formater = |v, _| format!("{}°", ((v * 180. / std::f64::consts::PI) as u32)); let parser = |s: &str| { diff --git a/src/main.rs b/src/main.rs index d605d2a..e461e8c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ // Without these, rust fails to infer Send/Sync trait impls // Probably caused by the new trait solver #![recursion_limit = "256"] +// Without the `gui` feature, UI-only state and helpers go unused. +#![cfg_attr(not(feature = "gui"), allow(dead_code, unused_imports))] // Fractal Explorer — Rust + wgpu + egui + WGSL deep-zoom Mandelbrot. // @@ -21,8 +23,17 @@ mod headless; #[cfg(not(target_arch = "wasm32"))] mod worker; +#[cfg(all(target_arch = "wasm32", not(feature = "gui")))] +compile_error!("the web build needs the `gui` feature"); + +#[cfg(feature = "gui")] use app::FractalApp; +#[cfg(all(feature = "gui", not(target_arch = "wasm32")))] +type MainResult = eframe::Result; +#[cfg(all(not(feature = "gui"), not(target_arch = "wasm32")))] +type MainResult = Result<(), String>; + /// wgpu configuration for eframe. The fractal fragment shader reads the /// reference orbit from a **storage buffer**, so the device must allow storage /// buffers in the fragment stage. eframe's default requests WebGL2-downlevel @@ -30,6 +41,7 @@ use app::FractalApp; /// * request the adapter's real limits (which include storage buffers), and /// * force the WebGPU backend on the web (WebGL2 can't do storage buffers at /// all) — failing cleanly on browsers without WebGPU, per the design. +#[cfg(feature = "gui")] fn wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration { use eframe::egui_wgpu::{WgpuSetup, wgpu}; @@ -51,7 +63,7 @@ fn wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration { } #[cfg(not(target_arch = "wasm32"))] -fn main() -> eframe::Result { +fn main() -> MainResult { use clap::Parser as _; env_logger::builder() @@ -70,6 +82,13 @@ fn main() -> eframe::Result { }; } + #[cfg(not(feature = "gui"))] + { + eprintln!("error: built without the \"gui\" feature; only --headless is supported"); + std::process::exit(1); + } + + #[cfg(feature = "gui")] let native_options = eframe::NativeOptions { renderer: eframe::Renderer::Wgpu, wgpu_options: wgpu_options(), @@ -80,6 +99,7 @@ fn main() -> eframe::Result { ..Default::default() }; + #[cfg(feature = "gui")] eframe::run_native( "Fractal Explorer", native_options,