1use 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
29pub const ROTATION_STEPS: usize = 16;
31
32pub trait ImageEffect: Sync + Send {
41 fn apply(&self, rgb_img: &mut RgbImage);
43
44 fn info(&self) -> String;
46
47 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#[derive(Debug, Clone)]
68pub struct FractalConfig {
69 pub scan_iterations: u32,
71 pub color_palette: NeonColor,
73 pub zoom: f64,
75 pub rotation: Complex,
77}
78
79pub trait FractalDescriptor {
85 fn config(&self) -> &FractalConfig;
87
88 fn center(&self) -> Complex;
93
94 #[inline(always)]
98 fn is_julia(&self) -> bool {
99 false
100 }
101
102 fn render_pixel(&self, z_init: Complex, scale: f64, max_radius: f64) -> (ColorRGB, f64, f64);
106
107 fn info_text(&self) -> String;
109}
110
111impl<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#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
137#[serde(rename_all = "lowercase")]
138pub enum ProceduralEffect {
139 #[value(name = "none")]
141 #[default]
142 None,
143
144 #[value(name = "julia")]
155 JuliaSet,
156
157 #[value(name = "mandelbrot")]
169 Mandelbrot,
170
171 #[value(name = "newton")]
182 NewtonBasins,
183
184 #[value(name = "nova")]
195 NovaJulia,
196
197 #[value(name = "aurora")]
201 CosmicAurora,
202
203 #[value(name = "star")]
207 Starfield,
208
209 #[value(name = "fractal")]
211 Fractal,
212
213 #[value(name = "polynomial")]
215 Polynomial,
216
217 #[value(name = "random")]
219 Random,
220}
221
222impl ProceduralEffect {
223 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 pub fn resolve(self) -> Self {
244 match self {
245 Self::Random => match get_random_integer(0, 5) {
246 0 => Self::JuliaSet,
248 1 => Self::Mandelbrot,
249 2 => Self::NewtonBasins,
251 3 => Self::NovaJulia,
252 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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
301pub struct FractalPreset {
302 pub center: Complex,
304 pub fractal_name: std::borrow::Cow<'static, str>,
306 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#[derive(Debug, Clone, Copy)]
326pub struct RelaxedViewportConfig {
327 pub width: u32,
329 pub height: u32,
331 pub search_limit: f64,
333 pub steps: usize,
335 pub zoom_range: [f64; 2],
337 pub rand_range: [f64; 2],
339 pub fallback_range: [f64; 2],
341}
342
343#[derive(Debug, Clone, Copy, PartialEq)]
348pub struct RelaxedEscape {
349 pub iterations: u32,
351 pub max_iterations: u32,
353 pub diff_norm: f64,
355 pub z_final: Complex,
357}
358
359impl RelaxedEscape {
360 #[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 #[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 #[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 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 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
479pub 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
495pub 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#[inline(always)]
510pub fn stretch_potential(raw_t: f64) -> f64 {
511 raw_t.clamp(0.0, 1.0).powf(0.35)
512}
513
514#[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#[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#[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#[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#[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#[inline(always)]
606pub fn mandelbrot_escape(c: Complex, max_iter: u32) -> (u32, Complex, Complex) {
607 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
627pub struct ViewportSpecs {
633 pub center: Complex,
635 pub zoom: f64,
637 pub rotation: Complex,
639 pub is_julia: bool,
641}
642
643pub struct Viewport {
647 pub start: Complex,
649 pub dx: Complex,
651 pub dy: Complex,
653}
654
655impl Viewport {
656 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 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 #[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#[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; let shadow_radius = max_radius * 1.25; if dist_pixels >= shadow_radius {
711 return (ColorRGB::default(), 0.0, 0.0);
712 }
713
714 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 let light_dir = Complex::new(-FRAC_1_SQRT_2, FRAC_1_SQRT_2);
728
729 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 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 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 let profile = core * 0.70 + nested_detail * 0.15 + glow * 0.15;
755
756 let norm_shadow = (dist_pixels / shadow_radius).clamp(0.0, 1.0);
759 let shadow_intensity = 0.65; let shadow_shading = (1.0 - dot * 0.45).clamp(0.1, 1.5); let shadow_profile =
762 (1.0 - norm_shadow * norm_shadow).powi(3) * shadow_intensity * shadow_shading;
763
764 let light_emboss = 0.85 + 0.35 * dot; 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 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
792pub 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
831pub 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
871pub 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 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 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 accumulated_fractal = accumulated_fractal + f_rgb.gamma2() * alpha;
920 accumulated_alpha += alpha;
921 accumulated_shadow += s_alpha;
922 }
923
924 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
942pub 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#[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 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 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 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 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 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 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}