use palette::{FromColor, Hsl, IntoColor, Srgb};
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;
use std::f32::consts::TAU;
use tiny_skia::{
Color, FillRule, GradientStop, Paint, PathBuilder, Pixmap, Point, RadialGradient, SpreadMode,
Transform,
};
#[derive(Debug, Clone, Copy)]
pub struct AquarelleParams {
pub bleed: f32,
pub bloom: f32,
pub offset: f32,
pub halo: f32,
}
impl Default for AquarelleParams {
fn default() -> Self {
Self {
bleed: 0.5,
bloom: 0.5,
offset: 0.5,
halo: 0.5,
}
}
}
impl AquarelleParams {
pub fn clamped(self) -> Self {
Self {
bleed: self.bleed.clamp(0.0, 1.0),
bloom: self.bloom.clamp(0.0, 1.0),
offset: self.offset.clamp(0.0, 1.0),
halo: self.halo.clamp(0.0, 1.0),
}
}
}
pub fn render_aquarelle_orb(
pixmap: &mut Pixmap,
center: (f32, f32),
radius: f32,
color: [u8; 3],
seed: u64,
params: AquarelleParams,
) {
if radius <= 0.0 {
return;
}
let p = params.clamped();
let mut rng = ChaCha8Rng::seed_from_u64(seed);
let offset_dist = radius * 0.25 * p.offset;
let theta: f32 = rng.gen_range(0.0..TAU);
let cx = center.0 + offset_dist * theta.cos();
let cy = center.1 + offset_dist * theta.sin();
let halo_color = boost_saturation(color, 1.0 + 0.6 * p.halo);
draw_radial(
pixmap,
cx,
cy,
radius,
color_with_alpha(color, 255),
color_with_alpha(halo_color, 128),
color_with_alpha(halo_color, 0),
0.55,
);
let bleed_count = (3.0 * p.bleed).round() as u32;
for _ in 0..bleed_count {
let bleed_theta: f32 = rng.gen_range(0.0..TAU);
let bleed_dist = radius * rng.gen_range(0.4..0.9);
let bx = center.0 + bleed_dist * bleed_theta.cos();
let by = center.1 + bleed_dist * bleed_theta.sin();
let bleed_radius = radius * rng.gen_range(0.2..0.4) * (0.5 + 0.5 * p.bleed);
let bleed_color = boost_saturation(color, 1.0 + 0.4 * p.halo);
draw_radial(
pixmap,
bx,
by,
bleed_radius,
color_with_alpha(bleed_color, 100),
color_with_alpha(bleed_color, 50),
color_with_alpha(bleed_color, 0),
0.5,
);
}
if p.bloom > 0.0 {
let core_radius = radius * 0.3 * p.bloom;
if core_radius > 0.0 {
let mix_amount = 0.7;
let bloom_color = mix_with_white(color, mix_amount);
draw_radial(
pixmap,
cx,
cy,
core_radius,
color_with_alpha(bloom_color, 255),
color_with_alpha(bloom_color, 128),
color_with_alpha(bloom_color, 0),
0.55,
);
}
}
}
#[inline]
fn color_with_alpha(rgb: [u8; 3], a: u8) -> [u8; 4] {
[rgb[0], rgb[1], rgb[2], a]
}
#[allow(clippy::too_many_arguments)]
fn draw_radial(
pixmap: &mut Pixmap,
cx: f32,
cy: f32,
radius: f32,
inner_rgba: [u8; 4],
mid_rgba: [u8; 4],
edge_rgba: [u8; 4],
mid_stop: f32,
) {
let center_color =
Color::from_rgba8(inner_rgba[0], inner_rgba[1], inner_rgba[2], inner_rgba[3]);
let mid_color = Color::from_rgba8(mid_rgba[0], mid_rgba[1], mid_rgba[2], mid_rgba[3]);
let edge_color = Color::from_rgba8(edge_rgba[0], edge_rgba[1], edge_rgba[2], edge_rgba[3]);
let stops = vec![
GradientStop::new(0.0, center_color),
GradientStop::new(mid_stop.clamp(0.05, 0.95), mid_color),
GradientStop::new(1.0, edge_color),
];
let Some(shader) = RadialGradient::new(
Point::from_xy(cx, cy),
Point::from_xy(cx, cy),
radius,
stops,
SpreadMode::Pad,
Transform::identity(),
) else {
return;
};
let paint = Paint {
shader,
anti_alias: true,
..Default::default()
};
let mut pb = PathBuilder::new();
pb.push_circle(cx, cy, radius * 1.5);
if let Some(path) = pb.finish() {
pixmap.fill_path(
&path,
&paint,
FillRule::Winding,
Transform::identity(),
None,
);
}
}
fn boost_saturation(rgb: [u8; 3], factor: f32) -> [u8; 3] {
if (factor - 1.0).abs() < f32::EPSILON {
return rgb;
}
let srgb = Srgb::new(
rgb[0] as f32 / 255.0,
rgb[1] as f32 / 255.0,
rgb[2] as f32 / 255.0,
);
let mut hsl: Hsl = Hsl::from_color(srgb);
hsl.saturation = (hsl.saturation * factor).clamp(0.0, 1.0);
let out: Srgb = hsl.into_color();
[
(out.red.clamp(0.0, 1.0) * 255.0).round() as u8,
(out.green.clamp(0.0, 1.0) * 255.0).round() as u8,
(out.blue.clamp(0.0, 1.0) * 255.0).round() as u8,
]
}
#[derive(Debug, Clone, Copy)]
pub struct AquarelleBleedParams {
pub radius: f32,
pub intensity: f32,
pub halo: f32,
}
impl Default for AquarelleBleedParams {
fn default() -> Self {
Self {
radius: 3.0,
intensity: 0.5,
halo: 0.3,
}
}
}
impl AquarelleBleedParams {
pub fn clamped(self) -> Self {
Self {
radius: self.radius.max(0.0),
intensity: self.intensity.clamp(0.0, 1.0),
halo: self.halo.clamp(0.0, 1.0),
}
}
}
pub fn render_aquarelle_bleed_pass(pixmap: &mut Pixmap, params: AquarelleBleedParams, seed: u64) {
let p = params.clamped();
if p.radius <= 0.0 || p.intensity <= 0.0 {
return;
}
let width = pixmap.width() as usize;
let height = pixmap.height() as usize;
if width == 0 || height == 0 {
return;
}
let original: Vec<u8> = pixmap.data().to_vec();
let mut blurred = original.clone();
let box_width = (p.radius * 1.15).round().max(1.0) as usize;
let box_radius = box_width.max(1);
let mut scratch = vec![0u8; blurred.len()];
for _ in 0..3 {
box_blur_horizontal(&blurred, &mut scratch, width, height, box_radius);
box_blur_vertical(&scratch, &mut blurred, width, height, box_radius);
}
if p.halo > 0.0 {
boost_saturation_buffer(&mut blurred, 1.0 + 0.6 * p.halo);
}
let mut rng = ChaCha8Rng::seed_from_u64(seed);
let noise_amp = 0.1 * p.intensity;
if noise_amp > 0.0 {
for px in blurred.chunks_exact_mut(4) {
let n = 1.0 + (rng.gen_range(-1.0..=1.0_f32)) * noise_amp;
for c in &mut px[..3] {
*c = ((*c as f32) * n).clamp(0.0, 255.0).round() as u8;
}
let a = px[3];
px[0] = px[0].min(a);
px[1] = px[1].min(a);
px[2] = px[2].min(a);
}
}
let t = p.intensity;
let inv = 1.0 - t;
let dst = pixmap.data_mut();
for (d, (o, b)) in dst
.chunks_exact_mut(4)
.zip(original.chunks_exact(4).zip(blurred.chunks_exact(4)))
{
for i in 0..4 {
let v = (o[i] as f32) * inv + (b[i] as f32) * t;
d[i] = v.clamp(0.0, 255.0).round() as u8;
}
}
}
fn box_blur_horizontal(src: &[u8], dst: &mut [u8], width: usize, height: usize, radius: usize) {
let radius = radius.min(width - 1);
let window = (radius * 2 + 1) as f32;
for y in 0..height {
let row = y * width * 4;
let mut sum = [0f32; 4];
for k in 0..=radius {
let i = row + k * 4;
for c in 0..4 {
sum[c] += src[i + c] as f32;
}
}
for c in 0..4 {
sum[c] += src[row + c] as f32 * radius as f32;
}
for x in 0..width {
let oi = row + x * 4;
for c in 0..4 {
dst[oi + c] = (sum[c] / window).clamp(0.0, 255.0).round() as u8;
}
let left_idx = x.saturating_sub(radius);
let right_idx = if x + radius + 1 < width {
x + radius + 1
} else {
width - 1
};
let lp = row + left_idx * 4;
let rp = row + right_idx * 4;
for c in 0..4 {
sum[c] += src[rp + c] as f32 - src[lp + c] as f32;
}
}
}
}
fn box_blur_vertical(src: &[u8], dst: &mut [u8], width: usize, height: usize, radius: usize) {
let radius = radius.min(height - 1);
let window = (radius * 2 + 1) as f32;
let stride = width * 4;
for x in 0..width {
let col = x * 4;
let mut sum = [0f32; 4];
for k in 0..=radius {
let i = col + k * stride;
for c in 0..4 {
sum[c] += src[i + c] as f32;
}
}
for c in 0..4 {
sum[c] += src[col + c] as f32 * radius as f32;
}
for y in 0..height {
let oi = col + y * stride;
for c in 0..4 {
dst[oi + c] = (sum[c] / window).clamp(0.0, 255.0).round() as u8;
}
let top_idx = y.saturating_sub(radius);
let bot_idx = if y + radius + 1 < height {
y + radius + 1
} else {
height - 1
};
let tp = col + top_idx * stride;
let bp = col + bot_idx * stride;
for c in 0..4 {
sum[c] += src[bp + c] as f32 - src[tp + c] as f32;
}
}
}
}
fn boost_saturation_buffer(buf: &mut [u8], factor: f32) {
if (factor - 1.0).abs() < f32::EPSILON {
return;
}
for px in buf.chunks_exact_mut(4) {
let a = px[3];
if a == 0 {
continue;
}
let af = a as f32 / 255.0;
let r = (px[0] as f32 / 255.0) / af;
let g = (px[1] as f32 / 255.0) / af;
let b = (px[2] as f32 / 255.0) / af;
let srgb = Srgb::new(r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0));
let mut hsl: Hsl = Hsl::from_color(srgb);
hsl.saturation = (hsl.saturation * factor).clamp(0.0, 1.0);
let out: Srgb = hsl.into_color();
px[0] = (out.red.clamp(0.0, 1.0) * af * 255.0).round() as u8;
px[1] = (out.green.clamp(0.0, 1.0) * af * 255.0).round() as u8;
px[2] = (out.blue.clamp(0.0, 1.0) * af * 255.0).round() as u8;
}
}
fn mix_with_white(rgb: [u8; 3], amount: f32) -> [u8; 3] {
let a = amount.clamp(0.0, 1.0);
[
(rgb[0] as f32 * (1.0 - a) + 255.0 * a).round() as u8,
(rgb[1] as f32 * (1.0 - a) + 255.0 * a).round() as u8,
(rgb[2] as f32 * (1.0 - a) + 255.0 * a).round() as u8,
]
}
#[cfg(test)]
mod tests {
use super::*;
use tiny_skia::Pixmap;
fn fresh_pixmap(w: u32, h: u32) -> Pixmap {
let mut p = Pixmap::new(w, h).expect("pixmap");
p.fill(Color::from_rgba8(0, 0, 0, 255));
p
}
fn count_non_black(pix: &Pixmap) -> u64 {
pix.data()
.chunks_exact(4)
.filter(|px| px[0] > 0 || px[1] > 0 || px[2] > 0)
.count() as u64
}
#[test]
fn aquarelle_renders_visible_orb() {
let mut pix = fresh_pixmap(64, 64);
render_aquarelle_orb(
&mut pix,
(32.0, 32.0),
16.0,
[200, 100, 50],
42,
AquarelleParams::default(),
);
assert!(
count_non_black(&pix) > 0,
"aquarelle orb should produce visible pixels"
);
}
#[test]
fn aquarelle_zero_radius_is_noop() {
let mut pix = fresh_pixmap(32, 32);
render_aquarelle_orb(
&mut pix,
(16.0, 16.0),
0.0,
[200, 100, 50],
1,
AquarelleParams::default(),
);
assert_eq!(count_non_black(&pix), 0);
}
#[test]
fn bloom_brightens_center() {
let mut a = fresh_pixmap(64, 64);
let mut b = fresh_pixmap(64, 64);
let zero_bloom = AquarelleParams {
bleed: 0.0,
bloom: 0.0,
offset: 0.0,
halo: 0.0,
};
let full_bloom = AquarelleParams {
bleed: 0.0,
bloom: 1.0,
offset: 0.0,
halo: 0.0,
};
render_aquarelle_orb(&mut a, (32.0, 32.0), 24.0, [200, 100, 50], 1, zero_bloom);
render_aquarelle_orb(&mut b, (32.0, 32.0), 24.0, [200, 100, 50], 1, full_bloom);
let pa = a.pixel(32, 32).expect("center pixel exists");
let pb = b.pixel(32, 32).expect("center pixel exists");
assert!(
pb.blue() > pa.blue(),
"bloom should raise blue at center: zero={} full={}",
pa.blue(),
pb.blue()
);
}
#[test]
fn params_individually_change_output() {
let base = AquarelleParams {
bleed: 0.0,
bloom: 0.0,
offset: 0.0,
halo: 0.0,
};
let mut p_base = fresh_pixmap(64, 64);
render_aquarelle_orb(&mut p_base, (32.0, 32.0), 20.0, [200, 100, 50], 7, base);
let base_data: Vec<u8> = p_base.data().to_vec();
for (name, modified) in [
("bleed", AquarelleParams { bleed: 1.0, ..base }),
("bloom", AquarelleParams { bloom: 1.0, ..base }),
(
"offset",
AquarelleParams {
offset: 1.0,
..base
},
),
("halo", AquarelleParams { halo: 1.0, ..base }),
] {
let mut p = fresh_pixmap(64, 64);
render_aquarelle_orb(&mut p, (32.0, 32.0), 20.0, [200, 100, 50], 7, modified);
assert_ne!(
p.data(),
&base_data[..],
"{name}=1.0 should change rendered orb"
);
}
}
#[test]
fn deterministic_with_seed() {
let mut a = fresh_pixmap(64, 64);
let mut b = fresh_pixmap(64, 64);
let params = AquarelleParams::default();
render_aquarelle_orb(&mut a, (32.0, 32.0), 20.0, [200, 100, 50], 12345, params);
render_aquarelle_orb(&mut b, (32.0, 32.0), 20.0, [200, 100, 50], 12345, params);
assert_eq!(
a.data(),
b.data(),
"same seed + inputs must produce identical output"
);
}
fn fresh_white(w: u32, h: u32) -> Pixmap {
let mut p = Pixmap::new(w, h).expect("pixmap");
p.fill(Color::from_rgba8(255, 255, 255, 255));
p
}
fn draw_black_dot(pix: &mut Pixmap, cx: f32, cy: f32, r: f32) {
let mut paint = tiny_skia::Paint::default();
paint.set_color_rgba8(0, 0, 0, 255);
let mut pb = PathBuilder::new();
pb.push_circle(cx, cy, r);
let path = pb.finish().expect("path");
pix.fill_path(
&path,
&paint,
FillRule::Winding,
Transform::identity(),
None,
);
}
#[test]
fn bleed_pass_zero_radius_is_noop() {
let mut pix = fresh_white(32, 32);
draw_black_dot(&mut pix, 16.0, 16.0, 4.0);
let snapshot = pix.data().to_vec();
render_aquarelle_bleed_pass(
&mut pix,
AquarelleBleedParams {
radius: 0.0,
..AquarelleBleedParams::default()
},
42,
);
assert_eq!(pix.data(), &snapshot[..]);
}
#[test]
fn bleed_pass_zero_intensity_is_noop() {
let mut pix = fresh_white(32, 32);
draw_black_dot(&mut pix, 16.0, 16.0, 4.0);
let snapshot = pix.data().to_vec();
render_aquarelle_bleed_pass(
&mut pix,
AquarelleBleedParams {
intensity: 0.0,
..AquarelleBleedParams::default()
},
42,
);
assert_eq!(pix.data(), &snapshot[..]);
}
#[test]
fn bleed_pass_changes_surrounding_pixels() {
let mut pix = fresh_white(64, 64);
draw_black_dot(&mut pix, 32.0, 32.0, 3.0);
let before = pix.pixel(32, 38).expect("pixel");
render_aquarelle_bleed_pass(&mut pix, AquarelleBleedParams::default(), 42);
let after = pix.pixel(32, 38).expect("pixel");
assert!(
after.red() < before.red(),
"halo should darken nearby pixel: before={} after={}",
before.red(),
after.red()
);
}
#[test]
fn bleed_pass_is_deterministic() {
let mut a = fresh_white(48, 48);
let mut b = fresh_white(48, 48);
draw_black_dot(&mut a, 24.0, 24.0, 3.0);
draw_black_dot(&mut b, 24.0, 24.0, 3.0);
render_aquarelle_bleed_pass(&mut a, AquarelleBleedParams::default(), 12345);
render_aquarelle_bleed_pass(&mut b, AquarelleBleedParams::default(), 12345);
assert_eq!(a.data(), b.data());
}
#[test]
fn bleed_params_clamped_caps_out_of_range() {
let p = AquarelleBleedParams {
radius: -1.0,
intensity: 2.0,
halo: -0.5,
}
.clamped();
assert_eq!(p.radius, 0.0);
assert_eq!(p.intensity, 1.0);
assert_eq!(p.halo, 0.0);
}
#[test]
fn clamped_caps_out_of_range() {
let p = AquarelleParams {
bleed: 2.0,
bloom: -0.5,
offset: 10.0,
halo: -10.0,
}
.clamped();
assert_eq!(p.bleed, 1.0);
assert_eq!(p.bloom, 0.0);
assert_eq!(p.offset, 1.0);
assert_eq!(p.halo, 0.0);
}
#[test]
fn bleed_pass_default_values_match_spec() {
let d = AquarelleBleedParams::default();
assert_eq!(d.radius, 3.0);
assert_eq!(d.intensity, 0.5);
assert_eq!(d.halo, 0.3);
}
#[test]
fn bleed_pass_uniform_input_is_invariant() {
let mut pix = Pixmap::new(32, 32).expect("pixmap");
pix.fill(Color::from_rgba8(128, 64, 32, 255));
let before = pix.data().to_vec();
render_aquarelle_bleed_pass(
&mut pix,
AquarelleBleedParams {
radius: 3.0,
intensity: 1.0,
halo: 0.0,
},
0,
);
let after = pix.data();
for (b, a) in before.chunks_exact(4).zip(after.chunks_exact(4)) {
for i in 0..4 {
let diff = (b[i] as i32 - a[i] as i32).abs();
assert!(
diff <= 26,
"uniform input should stay close: channel {i} before={} after={} diff={}",
b[i],
a[i],
diff
);
}
}
}
#[test]
fn bleed_pass_intensity_monotonic() {
fn render_at_intensity(intensity: f32) -> u8 {
let mut pix = fresh_white(64, 64);
draw_black_dot(&mut pix, 32.0, 32.0, 0.5);
render_aquarelle_bleed_pass(
&mut pix,
AquarelleBleedParams {
radius: 3.0,
intensity,
halo: 0.5,
},
42,
);
pix.pixel(34, 32).expect("pixel").red()
}
let r0 = render_at_intensity(0.0);
let r05 = render_at_intensity(0.5);
let r1 = render_at_intensity(1.0);
assert!(
r0 >= r05 && r05 >= r1,
"red should decrease monotonically: r0={r0} r05={r05} r1={r1}"
);
}
#[test]
fn bleed_pass_halo_changes_output() {
let mut a = fresh_white(64, 64);
draw_black_dot(&mut a, 32.0, 32.0, 3.0);
let mut paint = tiny_skia::Paint::default();
paint.set_color_rgba8(200, 50, 50, 255);
let mut pb = PathBuilder::new();
pb.push_circle(32.0, 32.0, 3.0);
let path = pb.finish().expect("path");
a.fill_path(
&path,
&paint,
FillRule::Winding,
Transform::identity(),
None,
);
let mut b = a.clone();
render_aquarelle_bleed_pass(
&mut a,
AquarelleBleedParams {
radius: 3.0,
intensity: 0.5,
halo: 0.0,
},
42,
);
render_aquarelle_bleed_pass(
&mut b,
AquarelleBleedParams {
radius: 3.0,
intensity: 0.5,
halo: 1.0,
},
42,
);
assert_ne!(
a.data(),
b.data(),
"halo=0 and halo=1 should produce different output"
);
}
#[test]
fn bleed_pass_different_seeds_differ() {
let mut a = fresh_white(64, 64);
let mut b = fresh_white(64, 64);
draw_black_dot(&mut a, 32.0, 32.0, 3.0);
draw_black_dot(&mut b, 32.0, 32.0, 3.0);
render_aquarelle_bleed_pass(&mut a, AquarelleBleedParams::default(), 1);
render_aquarelle_bleed_pass(&mut b, AquarelleBleedParams::default(), 2);
assert_ne!(a.data(), b.data(), "different seeds should perturb noise");
}
#[test]
fn bleed_pass_seed_irrelevant_when_intensity_zero() {
let mut a = fresh_white(48, 48);
let mut b = fresh_white(48, 48);
draw_black_dot(&mut a, 24.0, 24.0, 3.0);
draw_black_dot(&mut b, 24.0, 24.0, 3.0);
let params = AquarelleBleedParams {
intensity: 0.0,
..AquarelleBleedParams::default()
};
render_aquarelle_bleed_pass(&mut a, params, 1);
render_aquarelle_bleed_pass(&mut b, params, 99999);
assert_eq!(
a.data(),
b.data(),
"intensity=0 must short-circuit before seed is consumed"
);
}
#[test]
fn bleed_pass_tall_pixmap_does_not_panic() {
let mut pix = Pixmap::new(1, 100).expect("pixmap");
pix.fill(Color::from_rgba8(255, 255, 255, 255));
render_aquarelle_bleed_pass(&mut pix, AquarelleBleedParams::default(), 42);
}
#[test]
fn bleed_pass_wide_pixmap_does_not_panic() {
let mut pix = Pixmap::new(100, 1).expect("pixmap");
pix.fill(Color::from_rgba8(255, 255, 255, 255));
render_aquarelle_bleed_pass(&mut pix, AquarelleBleedParams::default(), 42);
}
#[test]
fn bleed_pass_1x1_pixmap_does_not_panic() {
let mut pix = Pixmap::new(1, 1).expect("pixmap");
pix.fill(Color::from_rgba8(255, 255, 255, 255));
render_aquarelle_bleed_pass(&mut pix, AquarelleBleedParams::default(), 42);
}
#[test]
fn bleed_pass_huge_radius_does_not_panic() {
let mut pix = fresh_white(16, 16);
draw_black_dot(&mut pix, 8.0, 8.0, 2.0);
render_aquarelle_bleed_pass(
&mut pix,
AquarelleBleedParams {
radius: 1000.0,
intensity: 1.0,
halo: 0.0,
},
42,
);
let data = pix.data();
let mut max_r = 0u8;
let mut min_r = 255u8;
for px in data.chunks_exact(4) {
max_r = max_r.max(px[0]);
min_r = min_r.min(px[0]);
}
let spread = max_r as i32 - min_r as i32;
assert!(
spread <= 30,
"huge radius should converge to near-uniform color, but red spread={spread}"
);
}
#[test]
fn bleed_pass_fully_transparent_pixmap_does_not_panic() {
let mut pix = Pixmap::new(32, 32).expect("pixmap");
render_aquarelle_bleed_pass(
&mut pix,
AquarelleBleedParams {
radius: 3.0,
intensity: 0.5,
halo: 1.0,
},
42,
);
for px in pix.data().chunks_exact(4) {
assert_eq!(px[3], 0, "alpha must remain 0 on transparent input");
}
}
#[test]
fn bleed_pass_repeated_application_is_stable() {
let mut pix = fresh_white(64, 64);
draw_black_dot(&mut pix, 32.0, 32.0, 3.0);
render_aquarelle_bleed_pass(&mut pix, AquarelleBleedParams::default(), 42);
render_aquarelle_bleed_pass(&mut pix, AquarelleBleedParams::default(), 42);
let corner = pix.pixel(0, 0).expect("pixel");
assert!(
corner.red() > 200 && corner.green() > 200 && corner.blue() > 200,
"far corner should stay near white after 2x bleed: r={} g={} b={}",
corner.red(),
corner.green(),
corner.blue()
);
}
#[test]
fn bleed_pass_negative_radius_is_noop() {
let mut pix = fresh_white(32, 32);
draw_black_dot(&mut pix, 16.0, 16.0, 4.0);
let snapshot = pix.data().to_vec();
render_aquarelle_bleed_pass(
&mut pix,
AquarelleBleedParams {
radius: -5.0,
intensity: 0.5,
halo: 0.3,
},
42,
);
assert_eq!(
pix.data(),
&snapshot[..],
"negative radius should clamp to 0 and short-circuit"
);
}
#[test]
fn bleed_pass_intensity_one_replaces_with_blurred() {
let mut pix = fresh_white(64, 64);
draw_black_dot(&mut pix, 32.0, 32.0, 3.0);
render_aquarelle_bleed_pass(
&mut pix,
AquarelleBleedParams {
radius: 3.0,
intensity: 1.0,
halo: 0.0,
},
42,
);
let center = pix.pixel(32, 32).expect("pixel");
assert!(
center.red() > 0,
"center red should be lifted off zero by blurred white neighbors, got {}",
center.red()
);
}
}