Files
vectorcraft/crates/render/src/tests_blend.rs
T
bflatasticandClaude Opus 5.5 a1a5c79b03 M3.73: blend modes follow the reference formulas on screen, in SVG and in PDF
color::blend gains blend_rgb (the 16 blend functions, with the 0.3/0.59/0.11
luminance of the non-separable modes), composite (source-over with blending)
and the shared opacity-mask luminance weights. Exported SVG masks take
luminance from sRGB values (color-interpolation="sRGB"), as the canvas does.

Tests: a per-mode table on the canvas (object, fill and group blend modes,
within 1/255; Exclusion 2/255 in the rasteriser's 8-bit pipeline), the same
table through SVG export rendered by resvg, clip and invert masks against the
canvas, and every mode's PDF name read back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:27:31 +07:00

110 lines
4.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Blend-mode accuracy: every mode matches the reference formulas ([`blend_rgb`]) within 1/255
//! (see [`tolerance`]), whether it is an object's blend mode, a fill's or a group's.
use super::*;
use vectorcraft_color::blend::blend_rgb;
use vectorcraft_color::{BlendMode as Blend, Color, Paint};
use vectorcraft_doc::{Appearance, Node};
use vectorcraft_geom::shapes;
/// Backdrop / source pairs (8-bit, so the expected values carry no input rounding) that take every
/// branch of the formulas: dark and light backdrops, sources on both sides of ½, greys, extremes.
const PAIRS: [([u8; 3], [u8; 3]); 6] = [
([51, 128, 230], [153, 64, 128]),
([230, 26, 102], [77, 204, 179]),
([0, 255, 128], [255, 0, 127]),
([200, 180, 40], [20, 60, 250]),
([128, 128, 128], [240, 30, 90]),
([10, 90, 160], [128, 128, 128]),
];
fn rect(d: &mut Document, r: Rect, rgb: [u8; 3]) -> Node {
Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::rgb8(rgb[0], rgb[1], rgb[2])), Paint::None, 0.0))
}
/// Where the blend is set: on the object, on its fill, or on a group around it.
#[derive(Clone, Copy, Debug)]
enum Level {
Object,
Fill,
Group,
}
/// An opaque backdrop with the source on top of it, blended with `mode` at `level`.
fn doc(mode: Blend, level: Level, backdrop: [u8; 3], source: [u8; 3]) -> Document {
let mut d = Document::new(20.0, 20.0);
let below = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), backdrop);
let mut top = rect(&mut d, Rect::new(5.0, 5.0, 15.0, 15.0), source);
match level {
Level::Object => top.blend = mode,
Level::Fill => {
if let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = top.appearance.items.first_mut() {
f.blend = mode;
}
}
Level::Group => {}
}
let top = match level {
Level::Group => {
let mut g = Node::group(d.alloc_id(), vec![Arc::new(top)]);
g.blend = mode;
g
}
_ => top,
};
let l = d.layers[0].id;
d.insert(Some(l), usize::MAX, below).unwrap();
d.insert(Some(l), usize::MAX, top).unwrap();
d
}
pub(crate) fn expected(mode: Blend, backdrop: [u8; 3], source: [u8; 3]) -> [u8; 3] {
let f = |c: [u8; 3]| c.map(|v| v as f32 / 255.0);
blend_rgb(mode, f(backdrop), f(source)).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
}
/// Whether `a` is within `tol`/255 of `b` in every channel.
pub(crate) fn within(a: [u8; 3], b: [u8; 3], tol: i32) -> bool {
a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= tol)
}
/// Tolerance of `mode` on screen: 1/255, except Exclusion, which the rasteriser's 8-bit pipeline
/// rounds twice (b + s − 2bs).
fn tolerance(mode: Blend) -> i32 {
if mode == Blend::Exclusion { 2 } else { 1 }
}
#[test]
fn every_mode_matches_the_reference_formulas() {
let mut r = Renderer::new();
for mode in Blend::ALL {
for level in [Level::Object, Level::Fill, Level::Group] {
for (b, s) in PAIRS {
let px = r.render(&doc(mode, level, b, s), 20, 20, Affine::IDENTITY, &RenderOptions::default()).pixel(10, 10);
let want = expected(mode, b, s);
assert!(
within([px[0], px[1], px[2]], want, tolerance(mode)) && px[3] == 255,
"{mode:?} at {level:?}, {b:?} under {s:?}: {px:?}, want {want:?}"
);
}
}
}
}
#[test]
fn a_translucent_source_mixes_the_blend_by_its_opacity() {
let mut r = Renderer::new();
for mode in Blend::ALL {
let (b, s) = PAIRS[0];
let mut d = doc(mode, Level::Object, b, s);
let top = d.layers[0].children().unwrap()[1].id;
d.node_mut(top).unwrap().opacity = 0.6;
let px = r.render(&d, 20, 20, Affine::IDENTITY, &RenderOptions::default()).pixel(10, 10);
let f = |c: [u8; 3], a: f32| [c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, a];
let o = vectorcraft_color::blend::composite(mode, f(b, 1.0), f(s, 0.6));
let want = [o[0], o[1], o[2]].map(|v| (v * 255.0).round() as u8);
// The opacity is one more 8-bit step.
assert!(within([px[0], px[1], px[2]], want, tolerance(mode) + 1), "{mode:?}: {px:?}, want {want:?}");
}
}