221 lines
9.3 KiB
Rust
221 lines
9.3 KiB
Rust
//! Non-isolated transparency groups: blending inside a group with opacity or a blend mode reaches
|
||
//! the art below the group unless Isolate Blending is on (checked against the reference formulas).
|
||
|
||
use super::*;
|
||
use vectorcraft_color::blend::{blend_rgb, composite};
|
||
use vectorcraft_color::{BlendMode as Blend, Color, Paint};
|
||
use vectorcraft_doc::{Appearance, Knockout, Node};
|
||
use vectorcraft_geom::shapes;
|
||
|
||
/// The backdrop colour (a full-page rectangle) and the blended child's colour.
|
||
const B: [f32; 3] = [0.8, 0.6, 0.2];
|
||
const S: [f32; 3] = [0.5, 0.5, 1.0];
|
||
|
||
fn rect(d: &mut Document, r: Rect, c: [f32; 3]) -> Node {
|
||
Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::rgb(c[0], c[1], c[2])), Paint::None, 0.0))
|
||
}
|
||
|
||
/// An unpainted clipping path, as Make Clipping Mask leaves it (a painted one paints too).
|
||
fn clip_path(d: &mut Document, r: Rect) -> Node {
|
||
Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::None, Paint::None, 0.0))
|
||
}
|
||
|
||
/// A Multiply rectangle at 20..80 (the group's content).
|
||
fn multiply(d: &mut Document) -> Node {
|
||
let mut n = rect(d, Rect::new(20.0, 20.0, 80.0, 80.0), S);
|
||
n.blend = Blend::Multiply;
|
||
n
|
||
}
|
||
|
||
/// The backdrop, then `top` (built by `make`), in a 100 × 100 document.
|
||
fn doc(make: impl FnOnce(&mut Document) -> Node) -> Document {
|
||
let mut d = Document::new(100.0, 100.0);
|
||
let below = rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), B);
|
||
let top = make(&mut d);
|
||
let l = d.layers[0].id;
|
||
d.insert(Some(l), usize::MAX, below).unwrap();
|
||
d.insert(Some(l), usize::MAX, top).unwrap();
|
||
d
|
||
}
|
||
|
||
/// A group of `children` with `opacity`, `blend` and isolation.
|
||
fn group(d: &mut Document, children: Vec<Node>, opacity: f32, blend: Blend, isolate: bool) -> Node {
|
||
let mut g = Node::group(d.alloc_id(), children.into_iter().map(Arc::new).collect());
|
||
(g.opacity, g.blend, g.isolate) = (opacity, blend, isolate);
|
||
g
|
||
}
|
||
|
||
fn render_with(r: &mut Renderer, d: &Document, opts: &RenderOptions) -> Rendered {
|
||
r.render(d, 100, 100, Affine::IDENTITY, opts)
|
||
}
|
||
|
||
fn render(d: &Document) -> Rendered {
|
||
render_with(&mut Renderer::new(), d, &RenderOptions::default())
|
||
}
|
||
|
||
fn rgb(p: [u8; 4]) -> [u8; 3] {
|
||
[p[0], p[1], p[2]]
|
||
}
|
||
|
||
fn to8(c: [f32; 3]) -> [u8; 3] {
|
||
c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
|
||
}
|
||
|
||
fn lerp(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
|
||
[0, 1, 2].map(|i| a[i] + (b[i] - a[i]) * t)
|
||
}
|
||
|
||
fn assert_near(got: [u8; 4], want: [f32; 3], what: &str) {
|
||
let w = to8(want);
|
||
assert!(rgb(got).iter().zip(w).all(|(a, b)| (*a as i32 - b as i32).abs() <= 2) && got[3] == 255, "{what}: {got:?}, want {w:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_multiply_child_of_a_half_opaque_group_multiplies_with_the_backdrop() {
|
||
let multiplied = blend_rgb(Blend::Multiply, B, S);
|
||
for isolate in [false, true] {
|
||
let d = doc(|d| {
|
||
let m = multiply(d);
|
||
group(d, vec![m], 0.5, Blend::Normal, isolate)
|
||
});
|
||
let r = render(&d);
|
||
// Non-isolated: the child multiplies with the backdrop, then the group is half opaque.
|
||
// Isolated: the child multiplies with nothing, so the group shows its plain colour.
|
||
let inside = if isolate { lerp(B, S, 0.5) } else { lerp(B, multiplied, 0.5) };
|
||
assert_near(r.pixel(50, 50), inside, &format!("isolate {isolate}"));
|
||
assert_near(r.pixel(10, 10), B, "outside the group");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_group_blend_mode_applies_again_to_the_non_isolated_result() {
|
||
// The child (opaque) multiplies with the backdrop; the group then screens that result onto the
|
||
// backdrop at 60%.
|
||
let d = doc(|d| {
|
||
let m = multiply(d);
|
||
group(d, vec![m], 0.6, Blend::Screen, false)
|
||
});
|
||
let g = blend_rgb(Blend::Multiply, B, S);
|
||
let o = composite(Blend::Screen, [B[0], B[1], B[2], 1.0], [g[0], g[1], g[2], 0.6]);
|
||
assert_near(render(&d).pixel(50, 50), [o[0], o[1], o[2]], "screened multiply");
|
||
}
|
||
|
||
#[test]
|
||
fn a_clip_group_does_not_isolate_its_children() {
|
||
let d = doc(|d| {
|
||
let clip = clip_path(d, Rect::new(30.0, 30.0, 70.0, 70.0));
|
||
let m = multiply(d);
|
||
Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(m)], clip: true })
|
||
});
|
||
let r = render(&d);
|
||
assert_near(r.pixel(50, 50), blend_rgb(Blend::Multiply, B, S), "inside the clip");
|
||
assert_near(r.pixel(25, 25), B, "clipped away");
|
||
}
|
||
|
||
#[test]
|
||
fn a_non_isolated_group_inside_a_clip_group_stays_clipped() {
|
||
// The half-opaque group copies its backdrop while the clip is in force: the clip still applies
|
||
// to it and to what follows.
|
||
let d = doc(|d| {
|
||
let clip = clip_path(d, Rect::new(30.0, 30.0, 70.0, 70.0));
|
||
let m = multiply(d);
|
||
let g = group(d, vec![m], 0.5, Blend::Normal, false);
|
||
let after = rect(d, Rect::new(0.0, 60.0, 100.0, 100.0), S);
|
||
let children = [clip, g, after].into_iter().map(Arc::new).collect();
|
||
Node::new(d.alloc_id(), NodeKind::Group { children, clip: true })
|
||
});
|
||
let r = render(&d);
|
||
assert_near(r.pixel(50, 40), lerp(B, blend_rgb(Blend::Multiply, B, S), 0.5), "inside the clip");
|
||
assert_near(r.pixel(25, 50), B, "group clipped away");
|
||
assert_near(r.pixel(50, 65), S, "later art inside the clip");
|
||
assert_near(r.pixel(50, 85), B, "later art clipped away");
|
||
}
|
||
|
||
#[test]
|
||
fn a_masked_object_blends_with_the_backdrop() {
|
||
let d = doc(|d| {
|
||
let m = multiply(d);
|
||
let mut g = group(d, vec![m], 1.0, Blend::Normal, false);
|
||
// A 50% grey mask over the whole group.
|
||
let art = rect(d, Rect::new(0.0, 0.0, 100.0, 100.0), [0.5; 3]);
|
||
g.mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true)));
|
||
g
|
||
});
|
||
assert_near(render(&d).pixel(50, 50), lerp(B, blend_rgb(Blend::Multiply, B, S), 0.5), "masked multiply");
|
||
}
|
||
|
||
#[test]
|
||
fn a_non_isolated_group_inside_an_isolated_one_blends_with_its_siblings_below() {
|
||
// An isolated group holding a green square, then a 50% group with the Multiply child: the
|
||
// child multiplies with the green (the isolated group's own art), not with the backdrop.
|
||
const G: [f32; 3] = [0.2, 0.9, 0.4];
|
||
let d = doc(|d| {
|
||
let green = rect(d, Rect::new(10.0, 10.0, 90.0, 90.0), G);
|
||
let m = multiply(d);
|
||
let inner = group(d, vec![m], 0.5, Blend::Normal, false);
|
||
group(d, vec![green, inner], 1.0, Blend::Normal, true)
|
||
});
|
||
assert_near(render(&d).pixel(50, 50), lerp(G, blend_rgb(Blend::Multiply, G, S), 0.5), "multiplied with the sibling");
|
||
}
|
||
|
||
#[test]
|
||
fn a_non_isolated_knockout_group_composites_its_children_against_the_backdrop() {
|
||
// Two Multiply squares overlapping at 40..60 in a knockout group: in the overlap the top one
|
||
// multiplies with the backdrop only (it knocks out the one below).
|
||
const T: [f32; 3] = [1.0, 0.4, 0.6];
|
||
for isolate in [false, true] {
|
||
let d = doc(|d| {
|
||
let mut lower = rect(d, Rect::new(10.0, 10.0, 60.0, 90.0), S);
|
||
let mut upper = rect(d, Rect::new(40.0, 10.0, 90.0, 90.0), T);
|
||
(lower.blend, upper.blend) = (Blend::Multiply, Blend::Multiply);
|
||
let mut g = group(d, vec![lower, upper], 1.0, Blend::Normal, isolate);
|
||
g.knockout = Knockout::On;
|
||
g
|
||
});
|
||
let r = render(&d);
|
||
let (overlap, alone) = if isolate { (T, S) } else { (blend_rgb(Blend::Multiply, B, T), blend_rgb(Blend::Multiply, B, S)) };
|
||
assert_near(r.pixel(50, 50), overlap, &format!("overlap, isolate {isolate}"));
|
||
assert_near(r.pixel(20, 50), alone, &format!("lower alone, isolate {isolate}"));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn trim_view_page_group_and_threads_keep_the_non_isolated_look() {
|
||
let d = doc(|d| {
|
||
let m = multiply(d);
|
||
group(d, vec![m], 0.5, Blend::Normal, false)
|
||
});
|
||
let want = lerp(B, blend_rgb(Blend::Multiply, B, S), 0.5);
|
||
let trim = RenderOptions { trim: true, ..Default::default() };
|
||
assert_near(render_with(&mut Renderer::new(), &d, &trim).pixel(50, 50), want, "trim view");
|
||
let mut paged = d.clone();
|
||
paged.page_isolate = true;
|
||
assert_near(render(&paged).pixel(50, 50), want, "isolated page group");
|
||
let mut threaded = Renderer::new();
|
||
threaded.threads = 2;
|
||
let single = render(&d);
|
||
let multi = render_with(&mut threaded, &d, &RenderOptions::default());
|
||
assert_eq!(single.pixels, multi.pixels, "multithreaded rendering copies the backdrop the same way");
|
||
}
|
||
|
||
#[test]
|
||
fn copying_the_backdrop_changes_nothing_without_blending() {
|
||
// A plain 50% group renders the same with or without an invisible blending group drawn first
|
||
// (which makes the renderer start again from a picture of the page and mix it back in).
|
||
let plain = doc(|d| {
|
||
let c = rect(d, Rect::new(20.0, 20.0, 80.0, 80.0), S);
|
||
group(d, vec![c], 0.5, Blend::Normal, false)
|
||
});
|
||
let mut flattened = plain.clone();
|
||
let m = multiply(&mut flattened);
|
||
let g = group(&mut flattened, vec![m], 0.5, Blend::Normal, false);
|
||
let l = flattened.layers[0].id;
|
||
// Behind everything but the backdrop, and fully transparent: only the copy happens.
|
||
let mut g = g;
|
||
g.children_mut().unwrap().iter_mut().for_each(|c| Arc::make_mut(c).opacity = 0.0);
|
||
flattened.insert(Some(l), 1, g).unwrap();
|
||
let (a, b) = (render(&plain).pixels, render(&flattened).pixels);
|
||
let worst = a.iter().zip(&b).map(|(x, y)| (*x as i32 - *y as i32).abs()).max().unwrap();
|
||
assert!(worst <= 1, "differs by {worst}/255");
|
||
}
|