use crate::aquarelle::{render_aquarelle_orb, AquarelleParams};
use crate::cluster::Cluster;
use image::RgbaImage;
use palette::{FromColor, Hsl, IntoColor, Srgb};
use tiny_skia::{
Color, FillRule, GradientStop, Paint, PathBuilder, Pixmap, Point, RadialGradient, SpreadMode,
Transform,
};
#[derive(Debug, Clone, Copy)]
pub enum OrbShape {
Circle,
Aquarelle(AquarelleParams),
}
impl Default for OrbShape {
fn default() -> Self {
Self::Circle
}
}
impl PartialEq for OrbShape {
fn eq(&self, other: &Self) -> bool {
matches!(
(self, other),
(OrbShape::Circle, OrbShape::Circle) | (OrbShape::Aquarelle(_), OrbShape::Aquarelle(_))
)
}
}
#[derive(Debug, Clone)]
pub struct RenderOptions {
pub width: u32,
pub height: u32,
pub orb_size: f32,
pub blur: f32,
pub saturation: f32,
pub background: [u8; 4],
pub shape: OrbShape,
}
impl Default for RenderOptions {
fn default() -> Self {
Self {
width: 1080,
height: 1920,
orb_size: 1.0,
blur: 0.5,
saturation: 1.0,
background: [0, 0, 0, 255],
shape: OrbShape::Circle,
}
}
}
pub fn render_static(clusters: &[Cluster], opts: &RenderOptions) -> RgbaImage {
let width = opts.width.max(1);
let height = opts.height.max(1);
let blur = opts.blur.clamp(0.0, 1.0);
let saturation = opts.saturation.max(0.0);
let orb_size = opts.orb_size.max(0.0);
let mut pixmap =
Pixmap::new(width, height).expect("pixmap allocation should succeed for >0 dimensions");
let [br, bg, bb, ba] = opts.background;
if ba > 0 {
pixmap.fill(Color::from_rgba8(br, bg, bb, ba));
}
let base_radius_unit = (width.min(height) as f32) * 0.25 * orb_size;
for (i, cluster) in clusters.iter().enumerate() {
let radius = base_radius_unit * cluster.weight.max(0.0).sqrt();
if radius <= 0.0 {
continue;
}
let cx = cluster.centroid.x.clamp(0.0, 1.0) * width as f32;
let cy = cluster.centroid.y.clamp(0.0, 1.0) * height as f32;
let [r, g, b] = adjust_saturation(cluster.color, saturation);
if let OrbShape::Aquarelle(params) = opts.shape {
render_aquarelle_orb(&mut pixmap, (cx, cy), radius, [r, g, b], i as u64, params);
continue;
}
let center_color = Color::from_rgba8(r, g, b, 255);
let mid_color = Color::from_rgba8(r, g, b, 128);
let edge_color = Color::TRANSPARENT;
let mid_stop = (1.0 - blur * 0.8).clamp(0.05, 0.95);
let stops = vec![
GradientStop::new(0.0, center_color),
GradientStop::new(mid_stop, 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 {
continue;
};
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,
);
}
}
let mut buf = pixmap.take(); for px in buf.chunks_exact_mut(4) {
let a = px[3];
if a == 0 {
px[0] = 0;
px[1] = 0;
px[2] = 0;
} else if a < 255 {
let inv = 255.0 / a as f32;
px[0] = (px[0] as f32 * inv).round().clamp(0.0, 255.0) as u8;
px[1] = (px[1] as f32 * inv).round().clamp(0.0, 255.0) as u8;
px[2] = (px[2] as f32 * inv).round().clamp(0.0, 255.0) as u8;
}
}
RgbaImage::from_raw(width, height, buf)
.expect("raw buffer length matches width * height * 4 by construction")
}
pub(crate) fn adjust_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,
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cluster::{Centroid, Cluster};
fn cluster(color: [u8; 3], cx: f32, cy: f32, weight: f32) -> Cluster {
Cluster {
color,
centroid: Centroid { x: cx, y: cy },
weight,
}
}
#[test]
fn single_cluster_renders_to_correct_dimensions() {
let opts = RenderOptions {
width: 100,
height: 100,
..Default::default()
};
let img = render_static(&[cluster([200, 100, 50], 0.5, 0.5, 1.0)], &opts);
assert_eq!(img.width(), 100);
assert_eq!(img.height(), 100);
}
#[test]
fn single_cluster_centered_color() {
let opts = RenderOptions {
width: 100,
height: 100,
blur: 0.5,
saturation: 1.0,
orb_size: 1.0,
..Default::default()
};
let img = render_static(&[cluster([255, 0, 0], 0.5, 0.5, 1.0)], &opts);
let center = img.get_pixel(50, 50);
assert!(
center[0] > 200,
"center red channel should be high, got {}",
center[0]
);
assert!(
center[1] < 50,
"center green channel should be low, got {}",
center[1]
);
assert!(
center[2] < 50,
"center blue channel should be low, got {}",
center[2]
);
}
#[test]
fn empty_clusters_returns_black_image() {
let opts = RenderOptions {
width: 32,
height: 32,
..Default::default()
};
let img = render_static(&[], &opts);
assert_eq!(img.width(), 32);
assert_eq!(img.height(), 32);
for px in img.pixels() {
assert_eq!(px[0], 0, "R should be 0");
assert_eq!(px[1], 0, "G should be 0");
assert_eq!(px[2], 0, "B should be 0");
assert_eq!(px[3], 255, "A should be 255 (opaque black)");
}
}
#[test]
fn respects_dimensions() {
let opts = RenderOptions {
width: 200,
height: 300,
..Default::default()
};
let img = render_static(&[cluster([10, 20, 30], 0.5, 0.5, 1.0)], &opts);
assert_eq!(img.width(), 200);
assert_eq!(img.height(), 300);
}
#[test]
fn zero_weight_cluster_skipped_yields_black() {
let opts = RenderOptions {
width: 16,
height: 16,
..Default::default()
};
let img = render_static(&[cluster([255, 255, 255], 0.5, 0.5, 0.0)], &opts);
for px in img.pixels() {
assert_eq!(px[0], 0);
assert_eq!(px[1], 0);
assert_eq!(px[2], 0);
assert_eq!(px[3], 255);
}
}
#[test]
fn saturation_zero_produces_grayscale_center() {
let opts = RenderOptions {
width: 100,
height: 100,
blur: 0.5,
saturation: 0.0,
orb_size: 1.0,
..Default::default()
};
let img = render_static(&[cluster([220, 30, 40], 0.5, 0.5, 1.0)], &opts);
let center = img.get_pixel(50, 50);
let r = center[0] as i32;
let g = center[1] as i32;
let b = center[2] as i32;
assert!(
(r - g).abs() <= 2 && (g - b).abs() <= 2 && (r - b).abs() <= 2,
"saturation=0 should produce grayscale, got R={} G={} B={}",
r,
g,
b
);
}
#[test]
fn blur_one_softens_edge_more_than_blur_zero() {
let base = RenderOptions {
width: 100,
height: 100,
saturation: 1.0,
orb_size: 1.0,
blur: 0.0,
..Default::default()
};
let opts_sharp = RenderOptions {
blur: 0.0,
..base.clone()
};
let opts_blurred = RenderOptions {
blur: 1.0,
..base.clone()
};
let c = cluster([255, 0, 0], 0.5, 0.5, 1.0);
let img_sharp = render_static(&[c.clone()], &opts_sharp);
let img_blurred = render_static(&[c], &opts_blurred);
let sx = 63u32;
let sy = 50u32;
let p_sharp = img_sharp.get_pixel(sx, sy);
let p_blurred = img_blurred.get_pixel(sx, sy);
assert!(
p_sharp[0] > p_blurred[0],
"blur=0 should keep red stronger at edge sample than blur=1, sharp R={} blurred R={}",
p_sharp[0],
p_blurred[0]
);
}
#[test]
fn renders_at_default_resolution() {
let opts = RenderOptions::default();
let img = render_static(&[cluster([100, 150, 200], 0.5, 0.5, 1.0)], &opts);
assert_eq!(img.width(), 1080);
assert_eq!(img.height(), 1920);
}
}