1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
//! Fractal overlay renderer for geodesic-wallpaper.
//!
//! Renders Mandelbrot, Julia, and Burning Ship fractals as normalised float
//! fields and blends them over a base wallpaper image.
// ── FractalType ───────────────────────────────────────────────────────────────
/// Which fractal escape-time algorithm to render.
#[derive(Debug, Clone)]
pub enum FractalType {
/// The classic Mandelbrot set: iterate z → z² + c where c = pixel.
Mandelbrot,
/// Julia set: iterate z → z² + c where c is fixed.
Julia {
/// Real part of the constant c.
c_re: f64,
/// Imaginary part of the constant c.
c_im: f64,
},
/// Burning Ship fractal: iterate z → (|Re(z)| + i|Im(z)|)² + c.
BurningShip,
}
// ── FractalRenderer ───────────────────────────────────────────────────────────
/// Renders escape-time fractals to a normalised float field in [0, 1].
pub struct FractalRenderer;
impl FractalRenderer {
/// Render a fractal to a float field.
///
/// Returns a `Vec<f32>` of length `width * height`. Each value is in
/// `[0, 1]`, where 0 = inside the set and 1 = escaped fastest.
///
/// Smooth colouring formula applied:
/// `smooth = iter + 1 - log2(log2(|z|))`
/// (clamped and normalised to [0, 1]).
///
/// # Parameters
/// - `fractal`: which fractal to render.
/// - `width`, `height`: output dimensions in pixels.
/// - `center_x`, `center_y`: centre of the view in fractal coordinates.
/// - `zoom`: zoom level (larger = closer in).
/// - `max_iter`: maximum number of iterations before declaring "inside".
pub fn render(
fractal: &FractalType,
width: u32,
height: u32,
center_x: f64,
center_y: f64,
zoom: f64,
max_iter: u32,
) -> Vec<f32> {
let w = width as usize;
let h = height as usize;
let mut field = vec![0.0f32; w * h];
let scale = 1.0 / (zoom.max(1e-10) * (width.min(height) as f64) * 0.5);
let mut max_smooth: f32 = 0.0;
for py in 0..h {
for px in 0..w {
let cx = center_x + (px as f64 + 0.5 - width as f64 * 0.5) * scale;
let cy = center_y + (py as f64 + 0.5 - height as f64 * 0.5) * scale;
let smooth = Self::escape_smooth(fractal, cx, cy, max_iter);
field[py * w + px] = smooth;
if smooth > max_smooth {
max_smooth = smooth;
}
}
}
// Normalise to [0, 1]
if max_smooth > 0.0 {
for v in &mut field {
*v /= max_smooth;
}
}
field
}
/// Compute the smooth escape time for pixel (cx, cy).
/// Returns 0.0 for points inside the set.
fn escape_smooth(fractal: &FractalType, cx: f64, cy: f64, max_iter: u32) -> f32 {
let (mut zx, mut zy, cfx, cfy) = match fractal {
FractalType::Mandelbrot => (0.0, 0.0, cx, cy),
FractalType::Julia { c_re, c_im } => (cx, cy, *c_re, *c_im),
FractalType::BurningShip => (0.0, 0.0, cx, cy),
};
for iter in 0..max_iter {
let (zx2, zy2) = match fractal {
FractalType::Mandelbrot | FractalType::Julia { .. } => {
let nx = zx * zx - zy * zy + cfx;
let ny = 2.0 * zx * zy + cfy;
(nx, ny)
}
FractalType::BurningShip => {
let nx = zx * zx - zy * zy + cfx;
let ny = 2.0 * zx.abs() * zy.abs() + cfy;
(nx, ny)
}
};
zx = zx2;
zy = zy2;
let r2 = zx * zx + zy * zy;
if r2 > 4.0 {
// Smooth iteration count: iter + 1 - log2(log2(sqrt(r2)))
let log_r = r2.ln() * 0.5; // ln(|z|)
let smooth = iter as f32 + 1.0 - (log_r.ln() / std::f64::consts::LN_2) as f32;
return smooth.max(0.0);
}
}
0.0 // inside the set
}
}
// ── FractalOverlay ────────────────────────────────────────────────────────────
/// Blends a fractal field over a base image.
pub struct FractalOverlay;
impl FractalOverlay {
/// Blend `base` colours with a hue shift driven by `fractal_field`.
///
/// For each pixel:
/// - The base hue is rotated by `fractal_value * blend * 360°`.
/// - `blend = 0.0` → base unchanged; `blend = 1.0` → full hue rotation.
pub fn apply(base: &[[u8; 3]], fractal_field: &[f32], blend: f32) -> Vec<[u8; 3]> {
assert_eq!(
base.len(),
fractal_field.len(),
"base and field must be the same length"
);
let blend = blend.clamp(0.0, 1.0);
base.iter()
.zip(fractal_field.iter())
.map(|(&pixel, &fval)| {
let [r, g, b] = pixel;
let rf = r as f32 / 255.0;
let gf = g as f32 / 255.0;
let bf = b as f32 / 255.0;
let [h, s, v] = rgb_to_hsv(rf, gf, bf);
let hue_shift = fval * blend; // [0, blend]
let new_h = (h + hue_shift).rem_euclid(1.0);
let [nr, ng, nb] = hsv_to_rgb(new_h, s, v);
[
(nr * 255.0).clamp(0.0, 255.0) as u8,
(ng * 255.0).clamp(0.0, 255.0) as u8,
(nb * 255.0).clamp(0.0, 255.0) as u8,
]
})
.collect()
}
}
// ── Colour helpers ────────────────────────────────────────────────────────────
fn rgb_to_hsv(r: f32, g: f32, b: f32) -> [f32; 3] {
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let delta = max - min;
let v = max;
let s = if max < 1e-6 { 0.0 } else { delta / max };
let h = if delta < 1e-6 {
0.0
} else if max == r {
((g - b) / delta).rem_euclid(6.0) / 6.0
} else if max == g {
((b - r) / delta + 2.0) / 6.0
} else {
((r - g) / delta + 4.0) / 6.0
};
[h, s, v]
}
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] {
let i = (h * 6.0).floor() as i32;
let f = h * 6.0 - i as f32;
let p = v * (1.0 - s);
let q = v * (1.0 - f * s);
let t = v * (1.0 - (1.0 - f) * s);
let (r, g, b) = match i % 6 {
0 => (v, t, p),
1 => (q, v, p),
2 => (p, v, t),
3 => (p, q, v),
4 => (t, p, v),
_ => (v, p, q),
};
[r, g, b]
}
// ── tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
// 1. Mandelbrot field has correct length
#[test]
fn mandelbrot_field_length() {
let field = FractalRenderer::render(&FractalType::Mandelbrot, 32, 32, 0.0, 0.0, 1.0, 64);
assert_eq!(field.len(), 32 * 32);
}
// 2. All values in [0, 1]
#[test]
fn mandelbrot_values_in_range() {
let field = FractalRenderer::render(&FractalType::Mandelbrot, 32, 32, 0.0, 0.0, 1.0, 64);
for v in &field {
assert!(*v >= 0.0 && *v <= 1.0, "value out of range: {}", v);
}
}
// 3. Julia field has correct length
#[test]
fn julia_field_length() {
let field = FractalRenderer::render(
&FractalType::Julia {
c_re: -0.7,
c_im: 0.27,
},
32,
32,
0.0,
0.0,
1.0,
64,
);
assert_eq!(field.len(), 32 * 32);
}
// 4. Julia values in [0, 1]
#[test]
fn julia_values_in_range() {
let field = FractalRenderer::render(
&FractalType::Julia {
c_re: -0.7,
c_im: 0.27,
},
32,
32,
0.0,
0.0,
1.0,
64,
);
for v in &field {
assert!(*v >= 0.0 && *v <= 1.0);
}
}
// 5. Burning Ship field length
#[test]
fn burning_ship_field_length() {
let field = FractalRenderer::render(&FractalType::BurningShip, 32, 32, -0.5, -0.5, 1.0, 64);
assert_eq!(field.len(), 32 * 32);
}
// 6. Burning Ship values in [0, 1]
#[test]
fn burning_ship_values_in_range() {
let field = FractalRenderer::render(&FractalType::BurningShip, 32, 32, -0.5, -0.5, 1.0, 64);
for v in &field {
assert!(*v >= 0.0 && *v <= 1.0);
}
}
// 7. Mandelbrot: pixel at origin (0,0) in complex plane is inside set
// (centre of the set), should be 0.0 (or close to it, since we normalise)
#[test]
fn mandelbrot_origin_inside() {
// Render a tiny image centred exactly at (0,0) with high zoom
let field = FractalRenderer::render(&FractalType::Mandelbrot, 1, 1, 0.0, 0.0, 1.0, 256);
// A single pixel at the centre of the Mandelbrot set: 1x1 image,
// centre=(0,0), scale is 1/(1*0.5)=2, so the pixel maps to cx=0, cy=0.
// Origin is inside the set → should return 0.0 → normalised stays 0.0.
assert_eq!(field[0], 0.0);
}
// 8. Mandelbrot: points far from origin should escape (non-zero)
#[test]
fn mandelbrot_far_point_escapes() {
// Render at centre (3, 3) — far outside Mandelbrot set
let field = FractalRenderer::render(&FractalType::Mandelbrot, 4, 4, 3.0, 3.0, 0.1, 64);
// Most pixels should be non-zero (escaped)
let nonzero = field.iter().filter(|&&v| v > 0.0).count();
assert!(nonzero > 0, "expected non-zero escape values");
}
// 9. Smooth iteration formula: non-zero for escaping pixels
#[test]
fn smooth_iter_nonzero_for_escape() {
let smooth = FractalRenderer::escape_smooth(
&FractalType::Mandelbrot,
2.5,
0.0, // Far outside the set
256,
);
assert!(
smooth > 0.0,
"expected non-zero smooth value for escaped point"
);
}
// 10. Overlay output length matches base
#[test]
fn overlay_length_matches_base() {
let base = vec![[128u8; 3]; 64];
let field = vec![0.5f32; 64];
let result = FractalOverlay::apply(&base, &field, 0.3);
assert_eq!(result.len(), 64);
}
// 11. Overlay with blend=0 leaves base unchanged
#[test]
fn overlay_blend_zero_unchanged() {
let base = vec![[100u8, 150u8, 200u8]; 16];
let field = vec![0.8f32; 16];
let result = FractalOverlay::apply(&base, &field, 0.0);
for (r, b) in result.iter().zip(base.iter()) {
// Allow rounding of ±1
assert!((r[0] as i32 - b[0] as i32).abs() <= 1);
assert!((r[1] as i32 - b[1] as i32).abs() <= 1);
assert!((r[2] as i32 - b[2] as i32).abs() <= 1);
}
}
// 12. Overlay pixel values are in [0, 255]
#[test]
fn overlay_values_in_byte_range() {
let base = vec![[200u8, 100u8, 50u8]; 32];
let field: Vec<f32> = (0..32).map(|i| i as f32 / 32.0).collect();
let result = FractalOverlay::apply(&base, &field, 0.5);
for p in &result {
for &c in p {
assert!(c <= 255); // u8 is always <= 255
}
}
}
// 13. Mandelbrot render with max_iter=1 produces mostly escaped pixels
#[test]
fn mandelbrot_low_iter() {
let field = FractalRenderer::render(&FractalType::Mandelbrot, 32, 32, 2.0, 2.0, 1.0, 1);
assert_eq!(field.len(), 32 * 32);
// All values should be in [0, 1]
for v in &field {
assert!(*v >= 0.0 && *v <= 1.0);
}
}
// 14. Smooth formula: log2(log2(r)) is finite for r > 4
#[test]
fn smooth_formula_finite() {
// z = 3 + 0i after 0 iterations (r² = 9 > 4)
let zx = 3.0f64;
let zy = 0.0f64;
let r2 = zx * zx + zy * zy;
let log_r = r2.ln() * 0.5;
let smooth = 0.0f32 + 1.0 - (log_r.ln() / std::f64::consts::LN_2) as f32;
assert!(smooth.is_finite());
}
// 15. Julia with c=(0,0) behaves like magnitude circle (many escape)
#[test]
fn julia_c_zero_escapes() {
let field = FractalRenderer::render(
&FractalType::Julia {
c_re: 0.0,
c_im: 0.0,
},
16,
16,
0.0,
0.0,
0.3,
64,
);
// With c=0, |z|^2^n grows for |z|>1; the edges should escape
let nonzero = field.iter().filter(|&&v| v > 0.0).count();
assert!(nonzero > 0);
}
}