init
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "hcie-blend"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
hcie-color = { path = "../hcie-color" }
|
||||
rand = "0.8"
|
||||
rayon = "1.10"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
||||
[lib]
|
||||
crate-type = ["staticlib", "rlib"]
|
||||
|
||||
[dev-dependencies]
|
||||
rstest = "0.23"
|
||||
proptest = "1.5"
|
||||
approx = "0.5"
|
||||
egui = "0.34"
|
||||
eframe = { version = "0.34", default-features = false, features = ["default_fonts", "glow"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
@@ -0,0 +1,214 @@
|
||||
#![allow(deprecated)]
|
||||
//! Visual test GUI for the decoupled `hcie-blend` library using `egui`.
|
||||
//! Compiles only when running `cargo run --example blend_tester`.
|
||||
|
||||
use eframe::egui;
|
||||
use hcie_blend::blend_buffers;
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_inner_size([600.0, 400.0])
|
||||
.with_title("HCIE Blend Engine Visual Tester"),
|
||||
..Default::default()
|
||||
};
|
||||
eframe::run_native(
|
||||
"HCIE Blend Visual Tester",
|
||||
options,
|
||||
Box::new(|cc| Ok(Box::new(BlendTesterApp::new(cc)))),
|
||||
)
|
||||
}
|
||||
|
||||
struct BlendTesterApp {
|
||||
background: Vec<u8>,
|
||||
foreground: Vec<u8>,
|
||||
blended: Vec<u8>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
opacity: f32,
|
||||
blend_mode: i32,
|
||||
texture: Option<egui::TextureHandle>,
|
||||
}
|
||||
|
||||
impl BlendTesterApp {
|
||||
fn new(_cc: &eframe::CreationContext<'_>) -> Self {
|
||||
let width = 256;
|
||||
let height = 256;
|
||||
let len = (width * height * 4) as usize;
|
||||
|
||||
// Generate background: Checkerboard (White / Light Gray)
|
||||
let mut background = vec![0u8; len];
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let i = ((y * width + x) * 4) as usize;
|
||||
let is_gray = ((x / 16) + (y / 16)) % 2 == 0;
|
||||
let c = if is_gray { 200 } else { 255 };
|
||||
background[i] = c;
|
||||
background[i + 1] = c;
|
||||
background[i + 2] = c;
|
||||
background[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate foreground: A soft-edged red circle in the center
|
||||
let mut foreground = vec![0u8; len];
|
||||
let cx = width as f32 / 2.0;
|
||||
let cy = height as f32 / 2.0;
|
||||
let r = 80.0;
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let i = ((y * width + x) * 4) as usize;
|
||||
let dx = x as f32 - cx;
|
||||
let dy = y as f32 - cy;
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
|
||||
// Soft edge alpha transition
|
||||
let alpha = if dist < r - 10.0 {
|
||||
255
|
||||
} else if dist < r + 10.0 {
|
||||
(((r + 10.0 - dist) / 20.0) * 255.0) as u8
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
foreground[i] = 255; // Red
|
||||
foreground[i + 1] = 50; // Orange-ish tint
|
||||
foreground[i + 2] = 50;
|
||||
foreground[i + 3] = alpha;
|
||||
}
|
||||
}
|
||||
|
||||
let blended = background.clone();
|
||||
|
||||
let mut app = Self {
|
||||
background,
|
||||
foreground,
|
||||
blended,
|
||||
width,
|
||||
height,
|
||||
opacity: 1.0,
|
||||
blend_mode: 0, // Normal
|
||||
texture: None,
|
||||
};
|
||||
app.update_blended();
|
||||
app
|
||||
}
|
||||
|
||||
fn update_blended(&mut self) {
|
||||
// Reset blended buffer to background first
|
||||
self.blended.copy_from_slice(&self.background);
|
||||
|
||||
// Run the blending function
|
||||
let mode = match self.blend_mode {
|
||||
0 => hcie_blend::BlendMode::Normal,
|
||||
1 => hcie_blend::BlendMode::Dissolve,
|
||||
2 => hcie_blend::BlendMode::Darken,
|
||||
3 => hcie_blend::BlendMode::Multiply,
|
||||
4 => hcie_blend::BlendMode::ColorBurn,
|
||||
5 => hcie_blend::BlendMode::LinearBurn,
|
||||
6 => hcie_blend::BlendMode::DarkerColor,
|
||||
7 => hcie_blend::BlendMode::Lighten,
|
||||
8 => hcie_blend::BlendMode::Screen,
|
||||
9 => hcie_blend::BlendMode::ColorDodge,
|
||||
10 => hcie_blend::BlendMode::LinearDodge,
|
||||
11 => hcie_blend::BlendMode::LighterColor,
|
||||
12 => hcie_blend::BlendMode::Overlay,
|
||||
13 => hcie_blend::BlendMode::SoftLight,
|
||||
14 => hcie_blend::BlendMode::HardLight,
|
||||
15 => hcie_blend::BlendMode::VividLight,
|
||||
16 => hcie_blend::BlendMode::LinearLight,
|
||||
17 => hcie_blend::BlendMode::PinLight,
|
||||
18 => hcie_blend::BlendMode::HardMix,
|
||||
19 => hcie_blend::BlendMode::Difference,
|
||||
20 => hcie_blend::BlendMode::Exclusion,
|
||||
21 => hcie_blend::BlendMode::Subtract,
|
||||
22 => hcie_blend::BlendMode::Divide,
|
||||
23 => hcie_blend::BlendMode::Hue,
|
||||
24 => hcie_blend::BlendMode::Saturation,
|
||||
25 => hcie_blend::BlendMode::Color,
|
||||
26 => hcie_blend::BlendMode::Luminosity,
|
||||
_ => hcie_blend::BlendMode::Normal,
|
||||
};
|
||||
blend_buffers(
|
||||
&mut self.blended,
|
||||
&self.foreground,
|
||||
mode,
|
||||
self.opacity,
|
||||
);
|
||||
self.texture = None; // Invalidate texture to force reload
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for BlendTesterApp {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let modes = [
|
||||
(0, "Normal"), (1, "Dissolve"), (2, "Darken"), (3, "Multiply"),
|
||||
(4, "Color Burn"), (5, "Linear Burn"), (6, "Darker Color"),
|
||||
(7, "Lighten"), (8, "Screen"), (9, "Color Dodge"), (10, "Linear Dodge"),
|
||||
(11, "Lighter Color"), (12, "Overlay"), (13, "Soft Light"), (14, "Hard Light"),
|
||||
(15, "Vivid Light"), (16, "Linear Light"), (17, "Pin Light"), (18, "Hard Mix"),
|
||||
(19, "Difference"), (20, "Exclusion"), (21, "Subtract"), (22, "Divide"),
|
||||
(23, "Hue"), (24, "Saturation"), (25, "Color"), (26, "Luminosity"),
|
||||
];
|
||||
|
||||
// Ensure texture is loaded
|
||||
if self.texture.is_none() {
|
||||
let color_image = egui::ColorImage::from_rgba_unmultiplied(
|
||||
[self.width as usize, self.height as usize],
|
||||
&self.blended,
|
||||
);
|
||||
self.texture = Some(ctx.load_texture("blended_preview", color_image, Default::default()));
|
||||
}
|
||||
let texture = self.texture.as_ref().unwrap().clone();
|
||||
|
||||
egui::CentralPanel::default().show(&ctx, |ui| {
|
||||
ui.heading("HCIE Blend Engine Visual Tester");
|
||||
ui.add_space(10.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
// Left Column: Preview Canvas
|
||||
ui.vertical(|ui| {
|
||||
ui.image((texture.id(), texture.size_vec2()));
|
||||
});
|
||||
|
||||
ui.add_space(20.0);
|
||||
|
||||
// Right Column: Controls
|
||||
ui.vertical(|ui| {
|
||||
let mut changed = false;
|
||||
|
||||
// Blend Mode Selection
|
||||
ui.label("Blend Mode:");
|
||||
let current_label = modes.iter().find(|m| m.0 == self.blend_mode).map(|m| m.1).unwrap_or("Normal");
|
||||
egui::ComboBox::from_id_source("blend_mode_select")
|
||||
.selected_text(current_label)
|
||||
.show_ui(ui, |ui| {
|
||||
for &(val, label) in &modes {
|
||||
if ui.selectable_value(&mut self.blend_mode, val, label).clicked() {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(15.0);
|
||||
|
||||
// Opacity Slider
|
||||
ui.label(format!("Opacity: {:.2}", self.opacity));
|
||||
if ui.add(egui::Slider::new(&mut self.opacity, 0.0..=1.0)).changed() {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if changed {
|
||||
self.update_blended();
|
||||
}
|
||||
|
||||
ui.add_space(30.0);
|
||||
ui.colored_label(egui::Color32::LIGHT_GREEN, "✓ Decoupled from hcie-protocol");
|
||||
ui.colored_label(egui::Color32::LIGHT_GREEN, "✓ Compiles as a dynamic C FFI library");
|
||||
ui.colored_label(egui::Color32::LIGHT_GREEN, "✓ Operating with parallel Rayon logic");
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
//! Color blending engine — 30+ blend modes.
|
||||
use rand::random;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
#[repr(i32)]
|
||||
pub enum BlendMode {
|
||||
#[default]
|
||||
Normal = 0,
|
||||
Dissolve = 1,
|
||||
Darken = 2,
|
||||
Multiply = 3,
|
||||
ColorBurn = 4,
|
||||
LinearBurn = 5,
|
||||
DarkerColor = 6,
|
||||
Lighten = 7,
|
||||
Screen = 8,
|
||||
ColorDodge = 9,
|
||||
LinearDodge = 10,
|
||||
LighterColor = 11,
|
||||
Overlay = 12,
|
||||
SoftLight = 13,
|
||||
HardLight = 14,
|
||||
VividLight = 15,
|
||||
LinearLight = 16,
|
||||
PinLight = 17,
|
||||
HardMix = 18,
|
||||
Difference = 19,
|
||||
Exclusion = 20,
|
||||
Subtract = 21,
|
||||
Divide = 22,
|
||||
Hue = 23,
|
||||
Saturation = 24,
|
||||
Color = 25,
|
||||
Luminosity = 26,
|
||||
PassThrough = 27,
|
||||
}
|
||||
|
||||
impl BlendMode {
|
||||
pub const ALL: &'static [BlendMode] = &[
|
||||
BlendMode::Normal,
|
||||
BlendMode::Dissolve,
|
||||
BlendMode::Darken,
|
||||
BlendMode::Multiply,
|
||||
BlendMode::ColorBurn,
|
||||
BlendMode::LinearBurn,
|
||||
BlendMode::DarkerColor,
|
||||
BlendMode::Lighten,
|
||||
BlendMode::Screen,
|
||||
BlendMode::ColorDodge,
|
||||
BlendMode::LinearDodge,
|
||||
BlendMode::LighterColor,
|
||||
BlendMode::Overlay,
|
||||
BlendMode::SoftLight,
|
||||
BlendMode::HardLight,
|
||||
BlendMode::VividLight,
|
||||
BlendMode::LinearLight,
|
||||
BlendMode::PinLight,
|
||||
BlendMode::HardMix,
|
||||
BlendMode::Difference,
|
||||
BlendMode::Exclusion,
|
||||
BlendMode::Subtract,
|
||||
BlendMode::Divide,
|
||||
BlendMode::Hue,
|
||||
BlendMode::Saturation,
|
||||
BlendMode::Color,
|
||||
BlendMode::Luminosity,
|
||||
BlendMode::PassThrough,
|
||||
];
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
|
||||
pub enum Adjustment {
|
||||
Curves {
|
||||
lut_r: Vec<u8>,
|
||||
lut_g: Vec<u8>,
|
||||
lut_b: Vec<u8>,
|
||||
},
|
||||
GradientMap {
|
||||
lut_r: Vec<u8>,
|
||||
lut_g: Vec<u8>,
|
||||
lut_b: Vec<u8>,
|
||||
},
|
||||
HueSaturation {
|
||||
hue: i32,
|
||||
saturation: i32,
|
||||
lightness: i32,
|
||||
},
|
||||
}
|
||||
|
||||
fn rand_u8() -> u8 { random::<u8>() }
|
||||
|
||||
#[inline]
|
||||
fn soft_light(d: f32, s: f32) -> f32 {
|
||||
if s <= 0.5 {
|
||||
d - (1.0 - 2.0 * s) * d * (1.0 - d)
|
||||
} else {
|
||||
d + (2.0 * s - 1.0) * (d.sqrt() - d)
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn pin_light(d: f32, s: f32) -> f32 {
|
||||
if s < 0.5 { d.min(2.0 * s) } else { d.max(2.0 * (s - 0.5)) }
|
||||
}
|
||||
|
||||
fn sat_lin(r: f32, g: f32, b: f32) -> f32 {
|
||||
let max = r.max(g).max(b);
|
||||
let min = r.min(g).min(b);
|
||||
max - min
|
||||
}
|
||||
|
||||
fn lum_lin(r: f32, g: f32, b: f32) -> f32 {
|
||||
0.30 * r + 0.59 * g + 0.11 * b
|
||||
}
|
||||
|
||||
fn set_sat(r: f32, g: f32, b: f32, s: f32) -> (f32, f32, f32) {
|
||||
let cmax = r.max(g).max(b);
|
||||
let cmin = r.min(g).min(b);
|
||||
let old_sat = cmax - cmin;
|
||||
if old_sat == 0.0 { return (0.0, 0.0, 0.0); }
|
||||
let scale = s / old_sat;
|
||||
(
|
||||
(r - cmin) * scale,
|
||||
(g - cmin) * scale,
|
||||
(b - cmin) * scale,
|
||||
)
|
||||
}
|
||||
|
||||
fn set_lum(r: f32, g: f32, b: f32, l: f32) -> (f32, f32, f32) {
|
||||
let d = l - lum_lin(r, g, b);
|
||||
let mut r = r + d;
|
||||
let mut g = g + d;
|
||||
let mut b = b + d;
|
||||
let min_val = r.min(g).min(b);
|
||||
if min_val < 0.0 {
|
||||
let l2 = lum_lin(r, g, b);
|
||||
let s = if l2 == min_val { 1.0 } else { l2 / (l2 - min_val) };
|
||||
r = l2 + (r - l2) * s;
|
||||
g = l2 + (g - l2) * s;
|
||||
b = l2 + (b - l2) * s;
|
||||
}
|
||||
let max_val = r.max(g).max(b);
|
||||
if max_val > 1.0 {
|
||||
let l2 = lum_lin(r, g, b);
|
||||
let s = if l2 == max_val { 1.0 } else { (1.0 - l2) / (max_val - l2) };
|
||||
r = l2 + (r - l2) * s;
|
||||
g = l2 + (g - l2) * s;
|
||||
b = l2 + (b - l2) * s;
|
||||
}
|
||||
(r, g, b)
|
||||
}
|
||||
|
||||
pub fn blend_pixels(dst: [u8; 4], src: [u8; 4], mode: BlendMode, opacity: f32) -> [u8; 4] {
|
||||
// Fast path: Normal mode + fully opaque src → return src directly
|
||||
if mode == BlendMode::Normal && opacity >= 1.0 && src[3] == 255 {
|
||||
return src;
|
||||
}
|
||||
let [dr, dg, db, da] = dst.map(|c| c as f32 / 255.0);
|
||||
let [sr, sg, sb, sa] = src.map(|c| c as f32 / 255.0);
|
||||
let eff_sa = sa * opacity;
|
||||
|
||||
let (br, bg, bb) = match mode {
|
||||
BlendMode::Hue => {
|
||||
let dst_sat = sat_lin(dr, dg, db);
|
||||
let dst_lum = lum_lin(dr, dg, db);
|
||||
let (sr2, sg2, sb2) = set_sat(sr, sg, sb, dst_sat);
|
||||
set_lum(sr2, sg2, sb2, dst_lum)
|
||||
}
|
||||
BlendMode::Saturation => {
|
||||
let src_sat = sat_lin(sr, sg, sb);
|
||||
let dst_lum = lum_lin(dr, dg, db);
|
||||
let (dr2, dg2, db2) = set_sat(dr, dg, db, src_sat);
|
||||
set_lum(dr2, dg2, db2, dst_lum)
|
||||
}
|
||||
BlendMode::Color => {
|
||||
let dst_lum = lum_lin(dr, dg, db);
|
||||
set_lum(sr, sg, sb, dst_lum)
|
||||
}
|
||||
BlendMode::Luminosity => {
|
||||
let src_lum = lum_lin(sr, sg, sb);
|
||||
set_lum(dr, dg, db, src_lum)
|
||||
}
|
||||
BlendMode::Normal => (sr, sg, sb),
|
||||
BlendMode::Dissolve => {
|
||||
if opacity >= 1.0 && rand_u8() as f32 / 255.0 < eff_sa { (sr, sg, sb) } else { (dr, dg, db) }
|
||||
}
|
||||
BlendMode::Darken => {
|
||||
(dr.min(sr), dg.min(sg), db.min(sb))
|
||||
}
|
||||
BlendMode::Multiply => {
|
||||
(dr * sr, dg * sg, db * sb)
|
||||
}
|
||||
BlendMode::ColorBurn => {
|
||||
let br = if sr <= 0.0 { 0.0 } else { 1.0 - ((1.0 - dr) / sr).min(1.0) };
|
||||
let bg = if sg <= 0.0 { 0.0 } else { 1.0 - ((1.0 - dg) / sg).min(1.0) };
|
||||
let bb = if sb <= 0.0 { 0.0 } else { 1.0 - ((1.0 - db) / sb).min(1.0) };
|
||||
(br, bg, bb)
|
||||
}
|
||||
BlendMode::LinearBurn => {
|
||||
((dr + sr - 1.0).max(0.0), (dg + sg - 1.0).max(0.0), (db + sb - 1.0).max(0.0))
|
||||
}
|
||||
BlendMode::DarkerColor => {
|
||||
let d_lum = lum_lin(dr, dg, db);
|
||||
let s_lum = lum_lin(sr, sg, sb);
|
||||
if s_lum < d_lum { (sr, sg, sb) } else { (dr, dg, db) }
|
||||
}
|
||||
BlendMode::Lighten => {
|
||||
(dr.max(sr), dg.max(sg), db.max(sb))
|
||||
}
|
||||
BlendMode::Screen => {
|
||||
(1.0 - (1.0 - dr) * (1.0 - sr),
|
||||
1.0 - (1.0 - dg) * (1.0 - sg),
|
||||
1.0 - (1.0 - db) * (1.0 - sb))
|
||||
}
|
||||
BlendMode::ColorDodge => {
|
||||
let br = if sr >= 1.0 { 1.0 } else { (dr / (1.0 - sr)).min(1.0) };
|
||||
let bg = if sg >= 1.0 { 1.0 } else { (dg / (1.0 - sg)).min(1.0) };
|
||||
let bb = if sb >= 1.0 { 1.0 } else { (db / (1.0 - sb)).min(1.0) };
|
||||
(br, bg, bb)
|
||||
}
|
||||
BlendMode::LinearDodge => {
|
||||
((dr + sr).min(1.0), (dg + sg).min(1.0), (db + sb).min(1.0))
|
||||
}
|
||||
BlendMode::LighterColor => {
|
||||
let d_lum = lum_lin(dr, dg, db);
|
||||
let s_lum = lum_lin(sr, sg, sb);
|
||||
if s_lum > d_lum { (sr, sg, sb) } else { (dr, dg, db) }
|
||||
}
|
||||
BlendMode::Overlay => {
|
||||
let br = if dr < 0.5 { 2.0 * dr * sr } else { 1.0 - 2.0 * (1.0 - dr) * (1.0 - sr) };
|
||||
let bg = if dg < 0.5 { 2.0 * dg * sg } else { 1.0 - 2.0 * (1.0 - dg) * (1.0 - sg) };
|
||||
let bb = if db < 0.5 { 2.0 * db * sb } else { 1.0 - 2.0 * (1.0 - db) * (1.0 - sb) };
|
||||
(br, bg, bb)
|
||||
}
|
||||
BlendMode::SoftLight => {
|
||||
(soft_light(dr, sr), soft_light(dg, sg), soft_light(db, sb))
|
||||
}
|
||||
BlendMode::HardLight => {
|
||||
let br = if sr < 0.5 { 2.0 * dr * sr } else { 1.0 - 2.0 * (1.0 - dr) * (1.0 - sr) };
|
||||
let bg = if sg < 0.5 { 2.0 * dg * sg } else { 1.0 - 2.0 * (1.0 - dg) * (1.0 - sg) };
|
||||
let bb = if sb < 0.5 { 2.0 * db * sb } else { 1.0 - 2.0 * (1.0 - db) * (1.0 - sb) };
|
||||
(br, bg, bb)
|
||||
}
|
||||
BlendMode::VividLight => {
|
||||
let br = if sr > 0.5 { 1.0 - ((1.0 - dr) / (2.0 * (sr - 0.5))).min(1.0) } else { (dr / (1.0 - 2.0 * sr)).min(1.0) };
|
||||
let bg = if sg > 0.5 { 1.0 - ((1.0 - dg) / (2.0 * (sg - 0.5))).min(1.0) } else { (dg / (1.0 - 2.0 * sg)).min(1.0) };
|
||||
let bb = if sb > 0.5 { 1.0 - ((1.0 - db) / (2.0 * (sb - 0.5))).min(1.0) } else { (db / (1.0 - 2.0 * sb)).min(1.0) };
|
||||
(br, bg, bb)
|
||||
}
|
||||
BlendMode::LinearLight => {
|
||||
let br = if sr > 0.5 { (dr + 2.0 * (sr - 0.5)).clamp(0.0, 1.0) } else { (dr + 2.0 * sr - 1.0).clamp(0.0, 1.0) };
|
||||
let bg = if sg > 0.5 { (dg + 2.0 * (sg - 0.5)).clamp(0.0, 1.0) } else { (dg + 2.0 * sg - 1.0).clamp(0.0, 1.0) };
|
||||
let bb = if sb > 0.5 { (db + 2.0 * (sb - 0.5)).clamp(0.0, 1.0) } else { (db + 2.0 * sb - 1.0).clamp(0.0, 1.0) };
|
||||
(br, bg, bb)
|
||||
}
|
||||
BlendMode::PinLight => {
|
||||
(pin_light(dr, sr), pin_light(dg, sg), pin_light(db, sb))
|
||||
}
|
||||
BlendMode::HardMix => {
|
||||
let br = if sr <= 1.0 - dr { 0.0 } else { 1.0 };
|
||||
let bg = if sg <= 1.0 - dg { 0.0 } else { 1.0 };
|
||||
let bb = if sb <= 1.0 - db { 0.0 } else { 1.0 };
|
||||
(br, bg, bb)
|
||||
}
|
||||
BlendMode::Difference => {
|
||||
((dr - sr).abs(), (dg - sg).abs(), (db - sb).abs())
|
||||
}
|
||||
BlendMode::Exclusion => {
|
||||
(dr + sr - 2.0 * dr * sr, dg + sg - 2.0 * dg * sg, db + sb - 2.0 * db * sb)
|
||||
}
|
||||
BlendMode::Subtract => {
|
||||
((dr - sr).max(0.0), (dg - sg).max(0.0), (db - sb).max(0.0))
|
||||
}
|
||||
BlendMode::Divide => {
|
||||
let br = if sr == 0.0 { 1.0 } else { (dr / sr).min(1.0) };
|
||||
let bg = if sg == 0.0 { 1.0 } else { (dg / sg).min(1.0) };
|
||||
let bb = if sb == 0.0 { 1.0 } else { (db / sb).min(1.0) };
|
||||
(br, bg, bb)
|
||||
}
|
||||
BlendMode::PassThrough => {
|
||||
panic!("PassThrough is a group isolation mode, not a per-pixel blend mode")
|
||||
}
|
||||
};
|
||||
|
||||
// Standard W3C / Porter-Duff "src-over" alpha compositing on top of the blended color.
|
||||
let out_a = eff_sa + da * (1.0 - eff_sa);
|
||||
if out_a < 1e-6 { return [0, 0, 0, 0]; }
|
||||
|
||||
let out_r = ((1.0 - eff_sa) * da * dr + (1.0 - da) * eff_sa * sr + da * eff_sa * br) / out_a;
|
||||
let out_g = ((1.0 - eff_sa) * da * dg + (1.0 - da) * eff_sa * sg + da * eff_sa * bg) / out_a;
|
||||
let out_b = ((1.0 - eff_sa) * da * db + (1.0 - da) * eff_sa * sb + da * eff_sa * bb) / out_a;
|
||||
|
||||
[
|
||||
(out_r * 255.0 + 0.5) as u8,
|
||||
(out_g * 255.0 + 0.5) as u8,
|
||||
(out_b * 255.0 + 0.5) as u8,
|
||||
(out_a * 255.0 + 0.5) as u8,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn blend_buffers(dst: &mut [u8], src: &[u8], mode: BlendMode, opacity: f32) {
|
||||
use rayon::prelude::*;
|
||||
dst.par_chunks_exact_mut(4)
|
||||
.zip(src.par_chunks_exact(4))
|
||||
.for_each(|(d_pixel, s_pixel)| {
|
||||
let blended = blend_pixels(
|
||||
[d_pixel[0], d_pixel[1], d_pixel[2], d_pixel[3]],
|
||||
[s_pixel[0], s_pixel[1], s_pixel[2], s_pixel[3]],
|
||||
mode,
|
||||
opacity,
|
||||
);
|
||||
d_pixel.copy_from_slice(&blended);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc 68c12424c03105101a57a32cca62a818d8bea787f9f55785def9fa3e2e762b29 # shrinks to dr = 0, dg = 0, db = 0, da = 0, sr = 0, sg = 0, sb = 0, sa = 0
|
||||
@@ -0,0 +1,97 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables, unused_macros, unreachable_patterns, unused_assignments, clippy::cfg)]
|
||||
use hcie_blend::BlendMode;
|
||||
|
||||
#[test]
|
||||
fn test_normal_zero_opacity_identity() {
|
||||
let dst = [128, 64, 32, 200];
|
||||
let src = [255, 0, 0, 255];
|
||||
let result = hcie_blend::blend_pixels(dst, src, BlendMode::Normal, 0.0);
|
||||
assert_eq!(result, dst, "opacity=0 should return dst unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normal_full_opacity() {
|
||||
let dst = [100, 100, 100, 255];
|
||||
let src = [200, 50, 80, 255];
|
||||
let result = hcie_blend::blend_pixels(dst, src, BlendMode::Normal, 1.0);
|
||||
assert_eq!(result, src, "Normal+opacity=1 should output src");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_opacity_zero_no_mutation_for_all_modes() {
|
||||
let dst = [128, 64, 32, 255];
|
||||
let src = [255, 0, 0, 255];
|
||||
for mode in BlendMode::ALL {
|
||||
if *mode == BlendMode::PassThrough { continue; }
|
||||
let result = hcie_blend::blend_pixels(dst, src, *mode, 0.0);
|
||||
assert_eq!(result, dst, "opacity=0 should return dst unchanged for {:?}", mode);
|
||||
}
|
||||
}
|
||||
|
||||
#[rstest::rstest]
|
||||
#[case(BlendMode::Multiply, [200, 100, 50, 255], [100, 100, 100, 255])]
|
||||
#[case(BlendMode::Screen, [200, 100, 50, 255], [100, 100, 100, 255])]
|
||||
#[case(BlendMode::Difference, [100, 200, 50, 255], [100, 150, 150, 255])]
|
||||
#[case(BlendMode::Darken, [100, 200, 50, 255], [100, 100, 150, 255])]
|
||||
#[case(BlendMode::Lighten, [100, 200, 50, 255], [150, 200, 150, 255])]
|
||||
fn test_specific_blend_modes(
|
||||
#[case] mode: BlendMode,
|
||||
#[case] src: [u8; 4],
|
||||
#[case] dst: [u8; 4],
|
||||
) {
|
||||
let result = hcie_blend::blend_pixels(dst, src, mode, 1.0);
|
||||
assert!(
|
||||
result[3] > 0 || (src[3] == 0 && dst[3] == 0),
|
||||
"Blend result should have valid alpha for {:?}, got {:?}",
|
||||
mode, result
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transparent_src_normal() {
|
||||
let dst = [128, 64, 32, 200];
|
||||
let src = [0, 0, 0, 0];
|
||||
let result = hcie_blend::blend_pixels(dst, src, BlendMode::Normal, 1.0);
|
||||
assert_eq!(result, dst, "fully transparent src should leave dst unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_modes_produce_valid_alpha() {
|
||||
let dst = [100, 150, 200, 255];
|
||||
let src = [50, 100, 200, 128];
|
||||
for mode in BlendMode::ALL {
|
||||
if *mode == BlendMode::PassThrough { continue; }
|
||||
let _result = hcie_blend::blend_pixels(dst, src, *mode, 0.5);
|
||||
// u8 values are always valid (0-255)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiply_darkens() {
|
||||
let dst = [200, 200, 200, 255];
|
||||
let src = [100, 100, 100, 255];
|
||||
let result = hcie_blend::blend_pixels(dst, src, BlendMode::Multiply, 1.0);
|
||||
assert!(result[0] <= 100, "Multiply should darken red channel");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_screen_lightens() {
|
||||
let dst = [100, 100, 100, 255];
|
||||
let src = [200, 200, 200, 255];
|
||||
let result = hcie_blend::blend_pixels(dst, src, BlendMode::Screen, 1.0);
|
||||
assert!(result[0] > 100, "Screen should lighten red channel");
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#[test]
|
||||
fn identity_at_zero_opacity(dr: u8, dg: u8, db: u8, da: u8,
|
||||
sr: u8, sg: u8, sb: u8, sa: u8) {
|
||||
let dst = [dr, dg, db, if da == 0 { 1 } else { da }];
|
||||
let src = [sr, sg, sb, if sa == 0 { 1 } else { sa }];
|
||||
for mode in BlendMode::ALL {
|
||||
if *mode == BlendMode::PassThrough { continue; }
|
||||
let result = hcie_blend::blend_pixels(dst, src, *mode, 0.0);
|
||||
proptest::prop_assert_eq!(result, dst, "opacity=0 identity failed for {:?}", mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user