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, PartialEq, Eq, Default)]
pub enum OrbStyle {
#[default]
Rim,
Soft,
}
#[derive(Debug, Clone, Copy, Default)]
pub enum OrbShape {
#[default]
Circle,
Aquarelle(AquarelleParams),
}
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;
}
render_one_orb(
&mut pixmap,
(cx, cy),
radius,
[r, g, b],
blur,
1.0,
OrbStyle::Rim,
);
}
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 fn render_one_orb(
pixmap: &mut Pixmap,
center: (f32, f32),
radius: f32,
rgb: [u8; 3],
blur: f32,
opacity: f32,
style: OrbStyle,
) {
if radius <= 0.0 {
return;
}
let blur = blur.clamp(0.0, 1.0);
let opacity = opacity.clamp(0.0, 1.0);
let (cx, cy) = center;
let [r, g, b] = rgb;
let center_a = (opacity * 255.0).round().clamp(0.0, 255.0) as u8;
if center_a == 0 {
return;
}
let center_color = Color::from_rgba8(r, g, b, center_a);
let edge_color = Color::TRANSPARENT;
let stops = match style {
OrbStyle::Rim => {
let mid_a = ((opacity * 128.0).round().clamp(0.0, 255.0)) as u8;
let mid_color = Color::from_rgba8(r, g, b, mid_a);
let mid_stop = (1.0 - blur * 0.8).clamp(0.05, 0.95);
vec![
GradientStop::new(0.0, center_color),
GradientStop::new(mid_stop, mid_color),
GradientStop::new(1.0, edge_color),
]
}
OrbStyle::Soft => {
let hold_stop = (1.0 - blur).clamp(0.05, 0.95);
vec![
GradientStop::new(0.0, center_color),
GradientStop::new(hold_stop, center_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,
);
}
}
pub fn adjust_saturation_pub(rgb: [u8; 3], factor: f32) -> [u8; 3] {
adjust_saturation(rgb, factor)
}
pub(crate) fn adjust_saturation(rgb: [u8; 3], factor: f32) -> [u8; 3] {
if (factor - 1.0).abs() < 1e-4 {
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(std::slice::from_ref(&c), &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);
}
}