fix(color): match Photoshop blend corner values (#699)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
This commit is contained in:
Alex Gorevski
2026-10-07 16:00:27 -04:00
committed by GitHub
co-authored by Copilot
parent fe4243ba1f
commit 0c40ec17c6
2 changed files with 75 additions and 1 deletions
+51 -1
View File
@@ -164,6 +164,8 @@ pub fn blend_channel(mode: BlendMode, cb: f32, cs: f32) -> f32 {
BlendMode::Overlay => hard_light(cs, cb),
BlendMode::SoftLight => soft_light_ps(cb, cs),
BlendMode::HardLight => hard_light(cb, cs),
BlendMode::VividLight if cs <= 0.0 => 0.0,
BlendMode::VividLight if cs >= 1.0 => 1.0,
BlendMode::VividLight => {
if cs <= 0.5 {
color_burn(cb, 2.0 * cs)
@@ -180,7 +182,7 @@ pub fn blend_channel(mode: BlendMode, cb: f32, cs: f32) -> f32 {
}
}
BlendMode::HardMix => {
if cb + cs >= 1.0 - 1e-6 {
if vivid_light_hard_mix(cb, cs) >= 0.5 - 1e-6 {
1.0
} else {
0.0
@@ -201,6 +203,26 @@ pub fn blend_channel(mode: BlendMode, cb: f32, cs: f32) -> f32 {
}
}
#[inline]
fn vivid_light_hard_mix(cb: f32, cs: f32) -> f32 {
const EDGE: f32 = 1e-4;
if cs <= 0.5 {
if cb >= 1.0 - EDGE {
1.0
} else if cs <= 0.0 {
0.0
} else {
1.0 - ((1.0 - cb) / (2.0 * cs)).min(1.0)
}
} else if cb <= EDGE {
0.0
} else if cs >= 1.0 {
1.0
} else {
(cb / (2.0 * (1.0 - cs))).min(1.0)
}
}
#[inline]
fn color_burn(cb: f32, cs: f32) -> f32 {
if cb >= 1.0 {
@@ -409,6 +431,34 @@ mod tests {
assert!(close(blend_channel(BlendMode::HardMix, 0.6, 0.3), 0.0));
}
#[test]
fn vivid_light_source_extremes_and_interior_values() {
for cb in [0.0, 0.25, 0.5, 0.75, 1.0] {
assert_eq!(blend_channel(BlendMode::VividLight, cb, 0.0), 0.0);
assert_eq!(blend_channel(BlendMode::VividLight, cb, 1.0), 1.0);
}
assert!(close(blend_channel(BlendMode::VividLight, 0.75, 0.25), 0.5));
assert!(close(blend_channel(BlendMode::VividLight, 0.25, 0.75), 0.5));
}
#[test]
fn hard_mix_matches_photoshop_extremes_and_interior_values() {
assert_eq!(blend_channel(BlendMode::HardMix, 1.0, 0.0), 1.0);
assert_eq!(blend_channel(BlendMode::HardMix, 0.0, 1.0), 0.0);
assert_eq!(blend_channel(BlendMode::HardMix, 1.0 - 1e-5, 0.0), 1.0);
assert_eq!(blend_channel(BlendMode::HardMix, 1e-5, 1.0), 0.0);
assert_eq!(blend_channel(BlendMode::HardMix, 0.6, 0.4), 1.0);
assert_eq!(blend_channel(BlendMode::HardMix, 0.6, 0.3), 0.0);
}
#[test]
fn vivid_light_and_hard_mix_composite_extremes() {
assert_eq!(composite(BlendMode::VividLight, [1.0, 1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0], 1.0), [0.0, 0.0, 0.0, 1.0]);
assert_eq!(composite(BlendMode::VividLight, [0.0, 0.0, 0.0, 1.0], [1.0, 1.0, 1.0, 1.0], 1.0), [1.0, 1.0, 1.0, 1.0]);
assert_eq!(composite(BlendMode::HardMix, [0.0, 0.0, 0.0, 1.0], [1.0, 1.0, 1.0, 1.0], 1.0), [0.0, 0.0, 0.0, 1.0]);
assert_eq!(composite(BlendMode::HardMix, [1.0, 1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0], 1.0), [1.0, 1.0, 1.0, 1.0]);
}
#[test]
fn separable_outputs_stay_in_unit_range() {
let steps: Vec<f32> = (0..=20).map(|i| i as f32 / 20.0).collect();
+24
View File
@@ -258,4 +258,28 @@ mod tests {
}
}
}
#[test]
fn generic_vivid_light_and_hard_mix_composites_match_photoshop() {
let cases = [
(BlendMode::HardMix, 0.0, 1.0),
(BlendMode::HardMix, 1.0, 0.0),
(BlendMode::HardMix, 0.6, 0.4),
(BlendMode::HardMix, 0.6, 0.3),
(BlendMode::VividLight, 1.0, 0.0),
(BlendMode::VividLight, 0.0, 1.0),
(BlendMode::VividLight, 0.75, 0.25),
(BlendMode::VividLight, 0.25, 0.75),
];
for (mode, cb, cs) in cases {
let backdrop = [cb, cb, cb, 1.0];
let source = [cs, cs, cs, 1.0];
let generic = generic::composite(mode, backdrop, source, 1.0);
let photoshop = composite(mode, backdrop, source, 1.0);
for channel in 0..4 {
assert!((generic[channel] - photoshop[channel]).abs() < 1e-6, "{mode:?} Cb={cb} Cs={cs}: {generic:?} != {photoshop:?}");
}
}
}
}