Skip to main content

wallswitch/effects/
common.rs

1//! Procedural wallpaper overlay common utilities, math structures, and shared factory helpers.
2//!
3//! This module provides:
4//! - Shared mathematical helpers (escape-time loops, viewport transforms, coloring functions).
5//! - The [`ImageEffect`] trait, the blanket [`FractalDescriptor`] implementation, and the
6//!   [`ProceduralEffect`] enum used across the entire effects sub-package.
7//! - Parallel rendering infrastructure with built-in 2x2 Supersampling (SSAA)
8//!   and 3D directional normal-vector embossing.
9
10use crate::effects::{
11    aurora::AuroraGenerator, julia::JuliaGenerator, mandelbrot::MandelbrotGenerator,
12    newton::NewtonGenerator, nova::NovaGenerator, star::StarfieldGenerator,
13};
14use crate::{
15    ColorRGB, Complex, Config, Monitor, NeonColor, WallSwitchError, WallSwitchResult,
16    get_random_integer,
17};
18use clap::ValueEnum;
19use image::RgbImage;
20use rayon::prelude::*;
21use serde::{Deserialize, Serialize};
22use std::f64::consts::FRAC_1_SQRT_2;
23use std::{
24    f64::consts::{LOG2_E, PI},
25    io::Error,
26    path::Path,
27};
28
29/// The number of angular steps used to evaluate structural rotations during optimization.
30pub const ROTATION_STEPS: usize = 16;
31
32// ============================================================================
33// CORE TRAITS
34// ============================================================================
35
36/// Trait defining the behaviour for any image-processing overlay effect.
37///
38/// Implementations receive a mutable [`RgbImage`] buffer and blend their output
39/// in-place, following the "Functional Core, Imperative Shell" pattern.
40pub trait ImageEffect: Sync + Send {
41    /// Applies the procedural effect in-place to a mutable image buffer.
42    fn apply(&self, rgb_img: &mut RgbImage);
43
44    /// Returns a formatted string containing diagnostic details of the active effect.
45    fn info(&self) -> String;
46
47    /// Convenience helper: opens `input_path`, runs the effect, writes `output_path`.
48    fn apply_effect(&self, input_path: &Path, output_path: &Path) -> WallSwitchResult<()> {
49        let img = image::open(input_path)
50            .map_err(|e| WallSwitchError::UnableToFind(format!("Failed to open image: {e}")))?;
51
52        let mut rgb_img = img.to_rgb8();
53        self.apply(&mut rgb_img);
54
55        rgb_img
56            .save(output_path)
57            .map_err(|e| WallSwitchError::Io(Error::other(e)))?;
58
59        Ok(())
60    }
61}
62
63/// Unified viewport layout and rendering configuration shared by all fractal generators.
64///
65/// Centralises the four parameters that every escape-time fractal needs so that
66/// generators hold a single [`FractalConfig`] field instead of repeating them.
67#[derive(Debug, Clone)]
68pub struct FractalConfig {
69    /// Maximum iteration limit for escape-time / convergence calculations.
70    pub scan_iterations: u32,
71    /// Base colour palette for neon glow blending.
72    pub color_palette: NeonColor,
73    /// Viewport zoom scale level (complex-plane units across the shorter screen axis).
74    pub zoom: f64,
75    /// Unit-phasor representing the viewport rotation angle (|rotation| == 1).
76    pub rotation: Complex,
77}
78
79/// A polymorphic trait that defines the core algebraic structure for any procedural fractal.
80///
81/// Implementing this trait automatically provides an optimised [`ImageEffect`] implementation
82/// via the blanket `impl` below, keeping the rendering engine fully decoupled from the
83/// specific mathematics of each generator.
84pub trait FractalDescriptor {
85    /// Retrieves the shared viewport layout configuration.
86    fn config(&self) -> &FractalConfig;
87
88    /// Focus centre point on the complex plane.
89    ///
90    /// For Julia-family fractals the centre is the constant `c`; for Mandelbrot-family
91    /// fractals it is the viewport centre used by [`Viewport`].
92    fn center(&self) -> Complex;
93
94    /// When `true` the viewport maps the initial `z`; when `false` it maps `c`.
95    ///
96    /// Defaults to `false` (Mandelbrot-style mapping).
97    #[inline(always)]
98    fn is_julia(&self) -> bool {
99        false
100    }
101
102    /// Maps a pre-projected complex coordinate to its blended colour contribution.
103    ///
104    /// Returns `(rgb, alpha, shadow_alpha)`.
105    fn render_pixel(&self, z_init: Complex, scale: f64, max_radius: f64) -> (ColorRGB, f64, f64);
106
107    /// Returns a comprehensive diagnostic string formatted for the generator's equation.
108    fn info_text(&self) -> String;
109}
110
111/// Blanket implementation: any type that implements [`FractalDescriptor`] automatically
112/// gets a full, parallelised [`ImageEffect`] implementation — DRY by construction.
113impl<T: FractalDescriptor + Sync + Send> ImageEffect for T {
114    fn apply(&self, rgb_img: &mut RgbImage) {
115        let cfg = self.config();
116        render_fractal_parallel(
117            rgb_img,
118            cfg.zoom,
119            cfg.rotation,
120            self.center(),
121            self.is_julia(),
122            |z, scale, max_radius| self.render_pixel(z, scale, max_radius),
123        );
124    }
125
126    fn info(&self) -> String {
127        self.info_text()
128    }
129}
130
131// ============================================================================
132// PROCEDURAL EFFECT ENUM
133// ============================================================================
134
135/// Represents all supported procedural background overlay effects.
136#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
137#[serde(rename_all = "lowercase")]
138pub enum ProceduralEffect {
139    /// No overlay effect is applied; displays the raw, unaltered wallpaper.
140    #[value(name = "none")]
141    #[default]
142    None,
143
144    /// Julia Set fractal overlay.
145    ///
146    /// * Characteristics: Rendered as thin, sharp, self-similar contour lines forming highly
147    ///   symmetrical branching patterns. Depending on the selected complex constant, the lines trace
148    ///   intricate shapes resembling swirling clouds, dendritic lace, spiral galaxy arms, leafy
149    ///   filaments, or crystalline snowflakes.
150    /// * Creator: Developed mathematically by the French mathematician Gaston Julia in 1918.
151    /// * Generator function: Calculated by mapping the convergence boundary under the recursive function:
152    ///   f(z) = z^2 + c
153    ///   where c is a fixed complex constant perturbation and the initial coordinate z_0 varies across the viewport.
154    #[value(name = "julia")]
155    JuliaSet,
156
157    /// Mandelbrot Set fractal overlay.
158    ///
159    /// * Characteristics: Rendered as thin, sharp, self-similar contour lines tracing the boundary of
160    ///   the set. The lines expose highly detailed structural contours, including a main cardioid,
161    ///   circular period bulbs, swirling spiral valleys, and repeating miniature copies of
162    ///   the entire set connected by thin filaments.
163    /// * Creator: First visualized and defined by the Polish-born French-American mathematician
164    ///   Benoit Mandelbrot in 1980.
165    /// * Generator function: Modeled using the quadratic recurrence equation starting from the origin:
166    ///   z(n+1) = z(n)^2 + c
167    ///   where z_0 = 0 and the complex parameter c varies across the viewport grid coordinates.
168    #[value(name = "mandelbrot")]
169    Mandelbrot,
170
171    /// Newton-Raphson Basin of Attraction fractal overlay.
172    ///
173    /// * Characteristics: Symmetrical, kaleidoscope-like mandala structures representing root-finding
174    ///   convergence fields across complex space boundaries. It maps the limits of convergence zones
175    ///   where points migrate to specific roots of a polynomial equation.
176    /// * Creator: Formulated based on Sir Isaac Newton's root-approximation methods (1690s) and Arthur
177    ///   Cayley's subsequent complex-plane studies (1879).
178    /// * Generator function: Computed using a relaxed Newton-Raphson recurrence formula:
179    ///   z(n+1) = z(n) - lambda * f(z(n)) / f'(z(n))
180    ///   on the polynomial f(z) = z^p - 1, where p is the integer polynomial power and lambda is a complex relaxation factor.
181    #[value(name = "newton")]
182    NewtonBasins,
183
184    /// Nova Julia liquid fractal overlay.
185    ///
186    /// * Characteristics: Organic, flowing, fluid-like plumes resembling liquid mercury,
187    ///   cosmic nebulae, or dynamic plasma current paths.
188    /// * Creator: Developed by Paul Derbyshire in the late 1990s as a structural variation and
189    ///   relaxation of the classic Newton-Raphson fractal.
190    /// * Generator function: Evaluated using the relaxed Newton recurrence relation perturbed by a
191    ///   dynamic additive complex value:
192    ///   z(n+1) = z(n) - R * (z(n)^p - 1) / (p * z(n)^(p-1)) + c
193    ///   where p is the polynomial exponent, R is a complex relaxation modifier, and c is a fixed perturbation coordinate.
194    #[value(name = "nova")]
195    NovaJulia,
196
197    /// Procedural Cosmic Aurora wave generator.
198    ///
199    /// Generator function: multi-frequency sinusoidal wave composition.
200    #[value(name = "aurora")]
201    CosmicAurora,
202
203    /// Procedural Starfield / Bokeh generator.
204    ///
205    /// Generator function: I(d) = I_0 * exp(-d^2 / (2 * sigma^2)) (Gaussian).
206    #[value(name = "star")]
207    Starfield,
208
209    /// Fractal mode selector: randomly chooses between Julia or Mandelbrot.
210    #[value(name = "fractal")]
211    Fractal,
212
213    /// Fractal mode selector: randomly chooses between Newton or Nova.
214    #[value(name = "polynomial")]
215    Polynomial,
216
217    /// Fully randomised mode selector: picks any effect independently per display.
218    #[value(name = "random")]
219    Random,
220}
221
222impl ProceduralEffect {
223    /// Human-readable display name for diagnostics and terminal output.
224    pub fn get_name(self) -> &'static str {
225        match self {
226            Self::None => "None",
227            Self::JuliaSet => "Julia Sets",
228            Self::Mandelbrot => "Mandelbrot",
229            Self::NewtonBasins => "Newton Basins",
230            Self::NovaJulia => "Nova Julia",
231            Self::CosmicAurora => "Cosmic Aurora",
232            Self::Starfield => "Starfield",
233            Self::Fractal => "Fractal",
234            Self::Polynomial => "Polynomial",
235            Self::Random => "Random",
236        }
237    }
238
239    /// Resolves meta-variants (`Fractal`, `Random`) to a single concrete effect.
240    ///
241    /// Concrete variants pass through unchanged so callers can always call
242    /// `resolve()` unconditionally.
243    pub fn resolve(self) -> Self {
244        match self {
245            Self::Random => match get_random_integer(0, 5) {
246                // Fractal
247                0 => Self::JuliaSet,
248                1 => Self::Mandelbrot,
249                // Polynomial
250                2 => Self::NewtonBasins,
251                3 => Self::NovaJulia,
252                // Others
253                4 => Self::CosmicAurora,
254                _ => Self::Starfield,
255            },
256            Self::Fractal => match get_random_integer(0, 1) {
257                0 => Self::JuliaSet,
258                _ => Self::Mandelbrot,
259            },
260            Self::Polynomial => match get_random_integer(0, 1) {
261                0 => Self::NewtonBasins,
262                _ => Self::NovaJulia,
263            },
264            concrete => concrete,
265        }
266    }
267
268    /// Constructs a heap-allocated, monitor-fitted [`ImageEffect`] for this variant.
269    ///
270    /// Returns `None` for [`ProceduralEffect::None`] and the meta-variants
271    /// `Fractal` / `Random` (callers should call [`resolve`](Self::resolve) first).
272    ///
273    /// # Errors
274    ///
275    /// Returns a [`WallSwitchError`] if the generator initialization fails.
276    pub fn get_renderer(
277        self,
278        monitor: &Monitor,
279        config: &Config,
280    ) -> WallSwitchResult<Option<Box<dyn ImageEffect>>> {
281        let renderer: Option<Box<dyn ImageEffect>> = match self {
282            Self::JuliaSet => Some(Box::new(JuliaGenerator::random(monitor, config)?)),
283            Self::Mandelbrot => Some(Box::new(MandelbrotGenerator::random(monitor, config)?)),
284            Self::NewtonBasins => Some(Box::new(NewtonGenerator::random(monitor, config)?)),
285            Self::NovaJulia => Some(Box::new(NovaGenerator::random(monitor, config)?)),
286            Self::Starfield => Some(Box::new(StarfieldGenerator::random(monitor)?)),
287            Self::CosmicAurora => Some(Box::new(AuroraGenerator::random(monitor)?)),
288            _ => None,
289        };
290
291        Ok(renderer)
292    }
293}
294
295// ============================================================================
296// COORDINATE PRESET
297// ============================================================================
298
299/// A named complex-coordinate preset for escape-time fractal viewports.
300#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
301pub struct FractalPreset {
302    /// Focal centre in the complex plane.
303    pub center: Complex,
304    /// Human-readable name of the structural pattern.
305    pub fractal_name: std::borrow::Cow<'static, str>,
306    /// Which effect category this preset belongs to.
307    pub effect_name: ProceduralEffect,
308}
309
310impl std::fmt::Display for FractalPreset {
311    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
312        write!(
313            f,
314            "{} ({:+.5} {:+.5}i) under {:?}",
315            self.fractal_name, self.center.re, self.center.im, self.effect_name
316        )
317    }
318}
319
320// ============================================================================
321// RELAXED-CONVERGENCE SHARED TYPES  (Newton / Nova)
322// ============================================================================
323
324/// Configuration options for fitting relaxed-convergence fractal viewports (Newton / Nova).
325#[derive(Debug, Clone, Copy)]
326pub struct RelaxedViewportConfig {
327    /// Horizontal physical display resolution.
328    pub width: u32,
329    /// Vertical physical display resolution.
330    pub height: u32,
331    /// Maximum search boundary in complex coordinates.
332    pub search_limit: f64,
333    /// Number of steps in the coordinate search grid.
334    pub steps: usize,
335    /// Clamping zoom boundaries `[min, max]`.
336    pub zoom_range: [f64; 2],
337    /// Randomised fitting margin multipliers `[min, max]`.
338    pub rand_range: [f64; 2],
339    /// Fallback boundaries when the search sweep finds no boundary points `[min, max]`.
340    pub fallback_range: [f64; 2],
341}
342
343/// Intermediate result of a relaxed Newton-Raphson or Nova Julia iteration sweep.
344///
345/// Carries just enough information for [`RelaxedEscape::color_newton`] and
346/// [`RelaxedEscape::color_nova`] to produce their dual-tone convergence renders.
347#[derive(Debug, Clone, Copy, PartialEq)]
348pub struct RelaxedEscape {
349    /// Iteration count at convergence or bail-out.
350    pub iterations: u32,
351    /// Configured maximum iteration count.
352    pub max_iterations: u32,
353    /// Squared norm of the final Newton step (proximity-to-root metric).
354    pub diff_norm: f64,
355    /// Final complex coordinate at convergence.
356    pub z_final: Complex,
357}
358
359impl RelaxedEscape {
360    /// Computes a visually consistent dual-tone render for Newton-Raphson boundaries.
361    ///
362    /// Uses phase-based lighting and per-iteration colour cycling to create
363    /// kaleidoscope-like mandala structures over the desktop background.
364    #[inline(always)]
365    pub fn color_newton(
366        &self,
367        color_palette: NeonColor,
368        edge_fade: f64,
369        ln_epsilon: f64,
370    ) -> (ColorRGB, f64, f64) {
371        self.color_impl(color_palette, edge_fade, ln_epsilon, false)
372    }
373
374    /// Computes a visually consistent dual-tone render for Nova Julia plumes.
375    ///
376    /// Uses higher-contrast wave profiles to enhance the fluid, organic character
377    /// of Nova Julia structures compared to the Newton render.
378    #[inline(always)]
379    pub fn color_nova(
380        &self,
381        color_palette: NeonColor,
382        edge_fade: f64,
383        ln_epsilon: f64,
384    ) -> (ColorRGB, f64, f64) {
385        self.color_impl(color_palette, edge_fade, ln_epsilon, true)
386    }
387
388    /// Internal implementation shared by both Newton and Nova coloring paths.
389    ///
390    /// The `is_nova` flag selects slightly different wave exponents, glow weights,
391    /// and shadow profiles tuned for each fractal's visual character.
392    #[inline(always)]
393    fn color_impl(
394        &self,
395        color_palette: NeonColor,
396        edge_fade: f64,
397        ln_epsilon: f64,
398        is_nova: bool,
399    ) -> (ColorRGB, f64, f64) {
400        if self.iterations >= self.max_iterations {
401            return (ColorRGB::default(), 0.0, 0.0);
402        }
403
404        let smooth_i = self.iterations as f64 + (self.diff_norm.ln() / ln_epsilon).clamp(0.0, 1.0);
405
406        let ripple_frequency = 0.50_f64;
407        let raw_wave = (smooth_i * ripple_frequency * PI).sin().abs();
408
409        let norm_dist = if is_nova {
410            raw_wave.powf(2.5)
411        } else {
412            raw_wave
413        };
414
415        // Core-line brightness and shadow profiles differ between Newton and Nova.
416        let (core_thresh, core_range, glow_exp, glow_weight, shadow_exp) = if is_nova {
417            (0.92_f64, 0.08_f64, 6_i32, 0.52_f64, 3_i32)
418        } else {
419            (0.95_f64, 0.05_f64, 5_i32, 0.40_f64, 2_i32)
420        };
421
422        let core = if norm_dist > core_thresh {
423            (norm_dist - core_thresh) / core_range
424        } else {
425            0.0
426        };
427        let glow = norm_dist.powi(glow_exp) * glow_weight;
428        let (profile_w_core, profile_w_glow) = if is_nova { (0.78, 0.22) } else { (0.70, 0.30) };
429        let profile = core * profile_w_core + glow * profile_w_glow;
430        let shadow_profile = (1.0 - norm_dist).powi(shadow_exp) * if is_nova { 0.48 } else { 0.35 };
431
432        // Phase-based lighting: `arg(z_final)` maps the root sector to a shading angle.
433        let angle = self.z_final.arg();
434        let cos_arg = if is_nova {
435            (angle * 4.0).cos().abs()
436        } else {
437            (angle * 3.0).cos().abs()
438        };
439        let light = if is_nova {
440            0.75 + 0.25 * cos_arg
441        } else {
442            0.70 + 0.30 * cos_arg
443        };
444
445        let t_cycled = (smooth_i * 0.08) % 1.0;
446        let secondary = color_palette.rotated_color();
447        let core_color = if is_nova {
448            let t_cos = (t_cycled * PI).cos() * 0.5 + 0.5;
449            secondary.lerp(color_palette.color_rgb, t_cos)
450        } else {
451            secondary.lerp(color_palette.color_rgb, t_cycled)
452        };
453
454        let border_color = core_color.complementary().saturate_components();
455        let blended = if is_nova {
456            core_color.lerp(border_color, norm_dist.powf(3.0))
457        } else {
458            core_color.lerp(border_color, norm_dist)
459        };
460
461        let brightness_boost = if is_nova { 1.45 } else { 1.25 };
462        let rgb = (blended * (light * brightness_boost)).clamp_bounds();
463
464        let limit_fade_iter = if is_nova { 6 } else { 8 };
465        let iteration_fade = if self.iterations < limit_fade_iter {
466            self.iterations as f64 / limit_fade_iter as f64
467        } else {
468            1.0
469        };
470
471        (
472            rgb,
473            profile * 0.95 * iteration_fade * edge_fade,
474            shadow_profile * iteration_fade * edge_fade,
475        )
476    }
477}
478
479// ============================================================================
480// PARALLEL RENDERING INFRASTRUCTURE
481// ============================================================================
482
483/// Partitions an RGB image buffer into mutable row segments for thread-safe parallel processing.
484pub fn partition_rows(rgb_img: &mut RgbImage) -> (Vec<(usize, &mut [u8])>, usize) {
485    let (width, _) = rgb_img.dimensions();
486    let row_stride = width as usize * 3;
487    let rows: Vec<(usize, &mut [u8])> = rgb_img
488        .as_mut()
489        .chunks_exact_mut(row_stride)
490        .enumerate()
491        .collect();
492    (rows, width as usize)
493}
494
495/// Executes row-by-row processing in parallel using Rayon's work-stealing thread pool.
496///
497/// The closure receives `(y: u32, row_data: &mut [u8])` where `row_data` is the
498/// raw pixel bytes for the row (`width * 3` bytes, RGB packed).
499pub fn process_rows_parallel_scoped<F>(rgb_img: &mut RgbImage, row_processor: F)
500where
501    F: Fn(u32, &mut [u8]) + Send + Sync,
502{
503    let (rows, _) = partition_rows(rgb_img);
504    rows.into_par_iter()
505        .for_each(|(y, row_data)| row_processor(y as u32, row_data));
506}
507
508/// Applies power-law (gamma) stretching to enhance the visual contrast of fractal filaments.
509#[inline(always)]
510pub fn stretch_potential(raw_t: f64) -> f64 {
511    raw_t.clamp(0.0, 1.0).powf(0.35)
512}
513
514/// Calculates the continuous potential (smooth colouring) value for quadratic escape-time fractals.
515///
516/// Filters out low escape iterations to guarantee complete transparency in the far exterior.
517#[inline]
518pub fn calculate_smooth_potential(i: u32, max_iterations: u32, z: Complex) -> f64 {
519    if i >= max_iterations {
520        return 1.0;
521    }
522
523    let mag2 = z.abs_sq();
524    let smooth_i = if mag2 > 4.0 {
525        let log_zn = mag2.ln() * 0.5;
526        let nu = log_zn.ln() * LOG2_E;
527        (i as f64 + 1.0 - nu).max(0.0)
528    } else {
529        i as f64
530    };
531
532    let min_render_iter = 32.0_f64;
533    if smooth_i < min_render_iter {
534        return 0.0;
535    }
536
537    let normalized = (smooth_i - min_render_iter) / (max_iterations as f64 - min_render_iter);
538    stretch_potential(normalized)
539}
540
541/// Calculates the analytical distance estimator (DEM) to the boundary of the fractal set.
542#[inline]
543pub fn calculate_distance_estimator(i: u32, max_iterations: u32, z: Complex, dz: Complex) -> f64 {
544    if i < max_iterations {
545        let z_mag = z.abs();
546        let dz_mag = dz.abs();
547        if z_mag > 0.0 && dz_mag > 0.0 {
548            return 2.0 * z_mag * z_mag.ln() / dz_mag;
549        }
550    }
551    0.0
552}
553
554/// Standard smoothstep interpolation function.
555#[inline(always)]
556pub fn smoothstep(edge0: f64, edge1: f64, x: f64) -> f64 {
557    let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
558    t * t * (3.0 - 2.0 * t)
559}
560
561/// Blends the computed fractal colour and vignette shadow onto a mutable [`ColorRGB`] pixel.
562///
563/// Uses gamma-corrected blending via [`ColorRGB::blend`] for both the fractal
564/// glow and the shadow darkening pass.
565#[inline(always)]
566pub fn blend_and_vignette(
567    pixel: &mut ColorRGB,
568    fractal_rgb: ColorRGB,
569    alpha: f64,
570    shadow_alpha: f64,
571) {
572    if shadow_alpha > 0.005 {
573        *pixel = pixel.scale(1.0 - shadow_alpha);
574    }
575    if alpha > 0.005 {
576        *pixel = pixel.blend(fractal_rgb, alpha);
577    }
578}
579
580// ============================================================================
581// HOT-LOOP ESCAPE-TIME EVALUATORS
582// ============================================================================
583
584/// Pure evaluation loop optimised for Julia Sets.
585///
586/// Iterates `z(n+1) = z(n)^2 + c` with simultaneous derivative tracking `dz`.
587/// Returns `(iteration_count, final_z, final_dz)`.
588#[inline(always)]
589pub fn julia_escape(z_init: Complex, c: Complex, max_iter: u32) -> (u32, Complex, Complex) {
590    let mut z = z_init;
591    let mut dz = Complex::one();
592    let mut i = 0;
593    while i < max_iter && z.abs_sq() <= 4.0 {
594        dz = dz * z * 2.0;
595        z = z.square() + c;
596        i += 1;
597    }
598    (i, z, dz)
599}
600
601/// Pure evaluation loop optimised for the Mandelbrot Set.
602///
603/// Incorporates early-exit shortcuts for the main cardioid and the period-2 bulb.
604/// Returns `(iteration_count, final_z, final_dz)`.
605#[inline(always)]
606pub fn mandelbrot_escape(c: Complex, max_iter: u32) -> (u32, Complex, Complex) {
607    // Cardioid / period-2 bulb shortcut — points inside escape immediately.
608    let q = (c - Complex::new(0.25, 0.0)).abs_sq();
609    if q * (q + (c.re - 0.25)) < 0.25 * c.im * c.im {
610        return (max_iter, Complex::zero(), Complex::zero());
611    }
612    if (c + Complex::one()).abs_sq() < 0.0625 {
613        return (max_iter, Complex::zero(), Complex::zero());
614    }
615
616    let mut z = Complex::zero();
617    let mut dz = Complex::zero();
618    let mut i = 0;
619    while i < max_iter && z.abs_sq() <= 4.0 {
620        dz = dz * z * 2.0 + Complex::one();
621        z = z.square() + c;
622        i += 1;
623    }
624    (i, z, dz)
625}
626
627// ============================================================================
628// VIEWPORT MAPPING
629// ============================================================================
630
631/// Parameters used to construct a [`Viewport`] from physical screen dimensions.
632pub struct ViewportSpecs {
633    /// Focal complex centre point.
634    pub center: Complex,
635    /// Zoom translation scaling index.
636    pub zoom: f64,
637    /// Rotational coordinate transformation phasor.
638    pub rotation: Complex,
639    /// When `true`, the viewport maps around the origin (Julia-style).
640    pub is_julia: bool,
641}
642
643/// Maps physical pixel coordinates `(x, y)` to complex plane coordinates via an
644/// affine transform defined by `start`, `dx` (step per pixel in x) and `dy` (step
645/// per pixel in y).
646pub struct Viewport {
647    /// Complex coordinate at pixel `(0, 0)`.
648    pub start: Complex,
649    /// Complex step per pixel along the screen X-axis.
650    pub dx: Complex,
651    /// Complex step per pixel along the screen Y-axis.
652    pub dy: Complex,
653}
654
655impl Viewport {
656    /// Constructs a viewport from physical screen size and rendering specs.
657    pub fn new(width: f64, height: f64, specs: &ViewportSpecs) -> Self {
658        let min_dim = width.min(height);
659        let scale = specs.zoom / min_dim;
660
661        let dx = specs.rotation * scale;
662        let dy = dx * Complex::i();
663
664        // Julia variants centre around the origin; Mandelbrot centres around specs.center.
665        let v_center = if specs.is_julia {
666            Complex::zero()
667        } else {
668            specs.center
669        };
670        let start = v_center - dx * (width / 2.0) - dy * (height / 2.0);
671
672        Self { start, dx, dy }
673    }
674
675    /// Maps physical pixel coordinate `(x, y)` to the corresponding complex value.
676    #[inline(always)]
677    pub fn map(&self, x: f64, y: f64) -> Complex {
678        self.start + self.dx * x + self.dy * y
679    }
680}
681
682// ============================================================================
683// COLOUR HELPERS FOR ESCAPE-TIME FRACTALS
684// ============================================================================
685
686/// Produces the distance-estimator colouring used by both Julia and Mandelbrot generators.
687///
688/// Combines a glow profile, smooth potential cycling, and 3-D directional normal embossing
689/// into a single `(rgb, alpha, shadow_alpha)` triple ready for [`blend_and_vignette`].
690#[inline(always)]
691pub fn color_distance_estimator(
692    i: u32,
693    scan_iterations: u32,
694    z: Complex,
695    dz: Complex,
696    scale: f64,
697    color_palette: NeonColor,
698) -> (ColorRGB, f64, f64) {
699    let t = calculate_smooth_potential(i, scan_iterations, z);
700    if t <= 0.005 || i >= scan_iterations {
701        return (ColorRGB::default(), 0.0, 0.0);
702    }
703
704    let dist_complex = calculate_distance_estimator(i, scan_iterations, z, dz);
705    let dist_pixels = dist_complex / scale;
706
707    let max_radius = 4.0; // Slightly tighter glow radius to reduce blurriness (was 5.0)
708    let shadow_radius = max_radius * 1.25; // Tighter shadow radius for sharper boundaries (was 1.5)
709
710    if dist_pixels >= shadow_radius {
711        return (ColorRGB::default(), 0.0, 0.0);
712    }
713
714    // Surface normal estimation for 3D embossing.
715    // Approximated by the direction of the complex value z/dz.
716    let u = z / dz;
717    let u_abs = u.abs();
718    let normal = if u_abs > 0.0 {
719        u / u_abs
720    } else {
721        Complex::one()
722    };
723
724    // Standard directional light source (normalized vector pointing from top-left, i.e., -45 degrees)
725    // Directional light source from top-left (135 degrees or 3 * pi/4 radians).
726    // Pre-calculated using std constants to avoid hot-loop trig (sin/cos) overhead.
727    let light_dir = Complex::new(-FRAC_1_SQRT_2, FRAC_1_SQRT_2);
728
729    // Emboss dot product calculation between normal vector and light direction
730    let dot = normal.re * light_dir.re + normal.im * light_dir.im;
731
732    let norm_dist = (dist_pixels / max_radius).clamp(0.0, 1.0);
733
734    // Highly defined, sharper core line
735    let core = if dist_pixels < 1.0 {
736        (1.0 - dist_pixels / 1.0).powi(2)
737    } else {
738        0.0
739    };
740
741    let ripple_freq = 12.0_f64;
742    let ripple_wave = (t * PI * ripple_freq).sin().abs();
743    let nested_detail =
744        (1.0 - smoothstep(0.0, 0.4, 1.0 - ripple_wave)) * (1.0 - norm_dist).max(0.0);
745
746    // Faster, tighter neon glow decay (using powi(8) instead of powi(6) to eliminate blurry halos)
747    let glow = if dist_pixels < max_radius {
748        (1.0 - norm_dist * norm_dist).powi(8) * 0.40
749    } else {
750        0.0
751    };
752
753    // Combine profile components with strong emphasis on the sharp core
754    let profile = core * 0.70 + nested_detail * 0.15 + glow * 0.15;
755
756    // Directional 3D Drop Shadow:
757    // Casts a higher-contrast, asymmetric shadow opposite to the light direction
758    let norm_shadow = (dist_pixels / shadow_radius).clamp(0.0, 1.0);
759    let shadow_intensity = 0.65; // Highly pronounced shadow (was 0.35)
760    let shadow_shading = (1.0 - dot * 0.45).clamp(0.1, 1.5); // Thicker shadow on opposite side
761    let shadow_profile =
762        (1.0 - norm_shadow * norm_shadow).powi(3) * shadow_intensity * shadow_shading;
763
764    // Apply 3D emboss lighting to the fractal itself
765    let light_emboss = 0.85 + 0.35 * dot; // Shading range of [0.5, 1.2]
766    let light = light_emboss * (0.80 + 0.20 * (z.arg() * 4.0).cos().abs());
767    let t_cycled = (t * 2.0) % 1.0;
768
769    // Cycle between base palette and its channel-rotated secondary
770    let secondary = color_palette.rotated_color();
771    let core_color = if t_cycled < 0.5 {
772        secondary.lerp(color_palette.color_rgb, t_cycled * 2.0)
773    } else {
774        color_palette
775            .color_rgb
776            .lerp(secondary, (t_cycled - 0.5) * 2.0)
777    };
778
779    let border_color = core_color.complementary().saturate_components();
780    let blended = core_color.lerp(border_color, norm_dist.powi(2));
781    let rgb = (blended * (light * 1.30)).clamp_bounds();
782
783    let iteration_fade = if i < 16 { (i as f64 - 3.0) / 13.0 } else { 1.0 };
784
785    (
786        rgb,
787        profile * 0.98 * iteration_fade,
788        shadow_profile * iteration_fade,
789    )
790}
791
792// ============================================================================
793// VIEWPORT OPTIMISATION UTILITIES
794// ============================================================================
795
796/// Scans a complex coordinate grid and returns the optimal zoom and rotation phasor
797/// that tightly frames all points for which `escape_check` returns `true`.
798pub fn optimize_fractal_viewport<F>(
799    width: u32,
800    height: u32,
801    search_limit: f64,
802    steps: usize,
803    rotation: Complex,
804    mut escape_check: F,
805) -> (f64, Complex)
806where
807    F: FnMut(Complex) -> bool,
808{
809    let inv_steps_minus_1 = 1.0 / (steps - 1) as f64;
810    let range = 2.0 * search_limit;
811    let mut active_points = Vec::with_capacity(steps * steps);
812
813    for step_y in 0..steps {
814        let ry = -search_limit + (step_y as f64 * inv_steps_minus_1) * range;
815        for step_x in 0..steps {
816            let rx = -search_limit + (step_x as f64 * inv_steps_minus_1) * range;
817            let z = Complex::new(rx, ry);
818            if escape_check(z) {
819                active_points.push(z);
820            }
821        }
822    }
823
824    if !active_points.is_empty() {
825        find_optimal_framing(&active_points, width, height, rotation)
826    } else {
827        (f64::MAX, rotation)
828    }
829}
830
831/// Unified fitting helper for relaxed-convergence fractals (Newton / Nova).
832///
833/// Combines [`optimize_fractal_viewport`] with a random fitting margin and
834/// automatic fallback to prevent degenerate zoom values.
835pub fn optimize_relaxed_viewport<F>(
836    config: RelaxedViewportConfig,
837    rotation: Complex,
838    mut escape_check: F,
839) -> (f64, Complex)
840where
841    F: FnMut(Complex) -> bool,
842{
843    let (best_zoom, best_rotation) = optimize_fractal_viewport(
844        config.width,
845        config.height,
846        config.search_limit,
847        config.steps,
848        rotation,
849        &mut escape_check,
850    );
851
852    if best_zoom < f64::MAX {
853        let rand_factor = get_random_integer::<_, f64>(
854            (config.rand_range[0] * 100.0) as u64,
855            (config.rand_range[1] * 100.0) as u64,
856        ) / 100.0;
857        let zoom = (best_zoom * rand_factor).clamp(config.zoom_range[0], config.zoom_range[1]);
858        (zoom, best_rotation)
859    } else {
860        let flat_rand = get_random_integer::<_, f64>(
861            (config.fallback_range[0] * 100.0) as u64,
862            (config.fallback_range[1] * 100.0) as u64,
863        ) / 100.0;
864        (
865            flat_rand.clamp(config.zoom_range[0], config.zoom_range[1]),
866            rotation,
867        )
868    }
869}
870
871/// Core parallel fractal renderer with built-in 2x2 Supersampling Anti-Aliasing (SSAA).
872///
873/// Evaluates 4 sub-pixel samples per pixel and averages them in linear color space
874/// to eliminate jagged edges and preserve microscopic fractal filaments.
875pub fn render_fractal_parallel<F>(
876    rgb_img: &mut RgbImage,
877    zoom: f64,
878    rotation: Complex,
879    center: Complex,
880    is_julia: bool,
881    pixel_fn: F,
882) where
883    F: Fn(Complex, f64, f64) -> (ColorRGB, f64, f64) + Send + Sync,
884{
885    let (width, height) = rgb_img.dimensions();
886    let (w_f, h_f) = (width as f64, height as f64);
887    let aspect_ratio = w_f.max(h_f) / w_f.min(h_f);
888    let max_radius = 0.98 * 0.5 * zoom * aspect_ratio;
889
890    let specs = ViewportSpecs {
891        center,
892        zoom,
893        rotation,
894        is_julia,
895    };
896    let viewport = Viewport::new(w_f, h_f, &specs);
897    let scale = zoom / w_f.min(h_f);
898
899    // 2x2 sub-pixel offsets for grid supersampling
900    let offsets = [(-0.25, -0.25), (0.25, -0.25), (-0.25, 0.25), (0.25, 0.25)];
901
902    process_rows_parallel_scoped(rgb_img, |y, row_data| {
903        let y_f = y as f64;
904        for (x, pixel_slice) in row_data.chunks_exact_mut(3).enumerate() {
905            let x_f = x as f64;
906
907            let mut bg_color = ColorRGB::from_slice(pixel_slice);
908
909            // Accumulators for linear-space supersampling
910            let mut accumulated_fractal = ColorRGB::default();
911            let mut accumulated_alpha = 0.0;
912            let mut accumulated_shadow = 0.0;
913
914            for &(ox, oy) in &offsets {
915                let z_init = viewport.map(x_f + ox, y_f + oy);
916                let (f_rgb, alpha, s_alpha) = pixel_fn(z_init, scale, max_radius);
917
918                // Convert color to linear space before accumulation
919                accumulated_fractal = accumulated_fractal + f_rgb.gamma2() * alpha;
920                accumulated_alpha += alpha;
921                accumulated_shadow += s_alpha;
922            }
923
924            // Average the 4 sub-pixel samples
925            let alpha = accumulated_alpha * 0.25;
926            let shadow_alpha = accumulated_shadow * 0.25;
927
928            if alpha > 0.005 || shadow_alpha > 0.005 {
929                let avg_fractal = if accumulated_alpha > 0.001 {
930                    (accumulated_fractal * (1.0 / accumulated_alpha)).ungamma2()
931                } else {
932                    ColorRGB::default()
933                };
934
935                blend_and_vignette(&mut bg_color, avg_fractal, alpha, shadow_alpha);
936                bg_color.write_to_slice(pixel_slice);
937            }
938        }
939    });
940}
941
942/// Finds the optimal viewport rotation and zoom for a set of active complex points.
943pub fn find_optimal_framing(
944    active_points: &[Complex],
945    width: u32,
946    height: u32,
947    default_rotation: Complex,
948) -> (f64, Complex) {
949    if active_points.is_empty() {
950        return (f64::MAX, default_rotation);
951    }
952
953    let (w_f, h_f) = (width as f64, height as f64);
954    let min_dim = w_f.min(h_f);
955
956    let mut best_zoom = f64::MAX;
957    let mut best_rotation = default_rotation;
958
959    for phasor in Complex::rotation_phasors(ROTATION_STEPS) {
960        let inverse_phasor = phasor.conj();
961        let mut max_cx_abs = 0.0_f64;
962        let mut max_cy_abs = 0.0_f64;
963
964        for &point in active_points {
965            let rotated = point * inverse_phasor;
966            max_cx_abs = max_cx_abs.max(rotated.re.abs());
967            max_cy_abs = max_cy_abs.max(rotated.im.abs());
968        }
969
970        let required_zoom =
971            (2.0 * max_cx_abs * min_dim / w_f).max(2.0 * max_cy_abs * min_dim / h_f);
972
973        if required_zoom < best_zoom {
974            best_zoom = required_zoom;
975            best_rotation = phasor;
976        }
977    }
978    (best_zoom, best_rotation)
979}
980
981// ============================================================================
982// TESTS
983// ============================================================================
984
985#[cfg(test)]
986mod tests_common {
987    use crate::{NEON_PALETTES, RandomExt};
988
989    use super::*;
990
991    #[test]
992    fn test_procedural_effect_resolution() {
993        let resolved_rand = ProceduralEffect::Random.resolve();
994        assert_ne!(resolved_rand, ProceduralEffect::Random);
995        assert_ne!(resolved_rand, ProceduralEffect::Fractal);
996
997        let resolved_fractal = ProceduralEffect::Fractal.resolve();
998        assert_ne!(resolved_fractal, ProceduralEffect::Fractal);
999        assert_ne!(resolved_fractal, ProceduralEffect::Random);
1000
1001        // Concrete variants are identity under resolve().
1002        assert_eq!(
1003            ProceduralEffect::JuliaSet.resolve(),
1004            ProceduralEffect::JuliaSet
1005        );
1006        assert_eq!(ProceduralEffect::None.resolve(), ProceduralEffect::None);
1007    }
1008
1009    #[test]
1010    fn test_smooth_potential_clamped() {
1011        let z = Complex::new(5.0, 5.0);
1012        let t = calculate_smooth_potential(50, 100, z);
1013        assert!((0.0..=1.0).contains(&t), "potential out of range: {t}");
1014    }
1015
1016    #[test]
1017    fn test_smooth_potential_interior_returns_one() {
1018        // Points that never escape should return 1.0.
1019        let t = calculate_smooth_potential(100, 100, Complex::zero());
1020        assert_eq!(t, 1.0);
1021    }
1022
1023    #[test]
1024    fn test_viewport_maps_center() {
1025        let specs = ViewportSpecs {
1026            center: Complex::new(0.5, -0.5),
1027            zoom: 2.0,
1028            rotation: Complex::one(),
1029            is_julia: false,
1030        };
1031        let viewport = Viewport::new(100.0, 100.0, &specs);
1032        let mapped = viewport.map(50.0, 50.0);
1033        assert!((mapped.re - 0.5).abs() < 1e-9, "re mismatch: {}", mapped.re);
1034    }
1035
1036    #[test]
1037    fn test_rotation_phasors_are_unit() {
1038        let phasors: Vec<Complex> = Complex::rotation_phasors(ROTATION_STEPS).collect();
1039        assert_eq!(phasors.len(), ROTATION_STEPS);
1040        for p in phasors {
1041            assert!((p.abs() - 1.0).abs() < 1e-9, "phasor not unit: {p:?}");
1042        }
1043    }
1044
1045    #[test]
1046    fn test_sample_helpers_in_range() -> WallSwitchResult<()> {
1047        for _ in 0..20 {
1048            let color_palette = NEON_PALETTES.get_random_sample()?;
1049            assert!(color_palette.color_rgb.red >= 0.0 && color_palette.color_rgb.red <= 1.0);
1050
1051            let rot = Complex::sample_rotation();
1052            assert!((rot.abs() - 1.0).abs() < 1e-9, "rotation not unit: {rot:?}");
1053        }
1054        Ok(())
1055    }
1056
1057    #[test]
1058    fn test_optimize_relaxed_viewport_in_bounds() {
1059        let cfg = RelaxedViewportConfig {
1060            width: 100,
1061            height: 100,
1062            search_limit: 1.5,
1063            steps: 10,
1064            zoom_range: [1.0, 3.0],
1065            rand_range: [0.9, 1.1],
1066            fallback_range: [1.2, 2.0],
1067        };
1068        let (zoom, rot) = optimize_relaxed_viewport(cfg, Complex::one(), |z| z.abs() < 1.0);
1069        assert!((1.0..=3.0).contains(&zoom), "zoom out of bounds: {zoom}");
1070        assert!((rot.abs() - 1.0).abs() < 1e-9, "rotation not unit: {rot:?}");
1071    }
1072
1073    #[test]
1074    fn test_julia_escape_interior() {
1075        // c = 0 → z(n+1) = z(n)^2, starting at z=0 never escapes.
1076        let (i, _, _) = julia_escape(Complex::zero(), Complex::zero(), 100);
1077        assert_eq!(i, 100, "interior point should reach max_iter");
1078    }
1079
1080    #[test]
1081    fn test_julia_escape_exterior() {
1082        // Starting outside the 2-radius bailout should escape on the first step.
1083        let (i, _, _) = julia_escape(Complex::new(3.0, 0.0), Complex::zero(), 100);
1084        assert_eq!(i, 0, "exterior point should escape immediately");
1085    }
1086
1087    #[test]
1088    fn test_mandelbrot_escape_main_cardioid() {
1089        // The origin is the deepest interior point; it should reach max_iter.
1090        let (i, _, _) = mandelbrot_escape(Complex::zero(), 100);
1091        assert_eq!(i, 100);
1092    }
1093
1094    #[test]
1095    fn test_relaxed_escape_color_consistency() {
1096        let palette = crate::NEON_PALETTES[0];
1097
1098        let escape = RelaxedEscape {
1099            iterations: 10,
1100            max_iterations: 100,
1101            diff_norm: 1e-7,
1102            z_final: Complex::new(1.0, 0.0),
1103        };
1104
1105        let (rgb_n, alpha_n, shadow_n) = escape.color_newton(palette, 1.0, (1e-6_f64).ln());
1106        let (rgb_v, alpha_v, shadow_v) = escape.color_nova(palette, 1.0, (1e-5_f64).ln());
1107
1108        // Both should return valid, normalised values.
1109        for ch in rgb_n.to_array() {
1110            assert!(
1111                (0.0..=1.0).contains(&ch),
1112                "newton channel out of range: {ch}"
1113            );
1114        }
1115        for ch in rgb_v.to_array() {
1116            assert!((0.0..=1.0).contains(&ch), "nova channel out of range: {ch}");
1117        }
1118        assert!(alpha_n >= 0.0 && shadow_n >= 0.0);
1119        assert!(alpha_v >= 0.0 && shadow_v >= 0.0);
1120    }
1121
1122    #[test]
1123    fn test_relaxed_escape_at_max_iter_returns_transparent() {
1124        let palette = crate::NEON_PALETTES[0];
1125        let escape = RelaxedEscape {
1126            iterations: 100,
1127            max_iterations: 100,
1128            diff_norm: 0.0,
1129            z_final: Complex::zero(),
1130        };
1131        let (_, alpha, shadow) = escape.color_newton(palette, 1.0, (1e-6_f64).ln());
1132        assert_eq!(alpha, 0.0);
1133        assert_eq!(shadow, 0.0);
1134    }
1135}