Files
vectorcraft/crates/render/src/tests_isolation.rs
T
dotCooCoo e22b6a5d45 Render: a knockout group drawn over its backdrop counts an element's partial coverage once
A knockout group that is not isolated, and whose elements blend, is
drawn over a copy of the art below it when the renderer has no layer
open around it. Each element was drawn over the whole copy in a layer,
and the layer was cut to the element's knockout shape with DestIn.
Where the shape covers part of a pixel, the cut counted the element's
coverage a second time. With Page Knockout Group on and Page Isolated
Blending off, the page is drawn this way as soon as an object on it
outside an isolated group has a blend mode other than Normal, on itself
or on a fill or stroke. On a test page, adding a Multiply square
changed the half-covered edge of a blue square over orange from
[115, 115, 141] to [172, 159, 147] on white.

Each element is now drawn over the copy offscreen, over its own
bounds. With the DestOut pass before it, each pixel gets (1 - shape)
times what was below plus shape times the element composited with the
copy at a shape of 1, as ISO 32000-1 section 11.4.6 describes. Pixels
that the shape covers fully or not at all are drawn as before.
2026-10-16 02:21:47 +07:00

419 lines
20 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.
//! 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
}
/// A Multiply rectangle of color `c` at `r`.
fn multiply_in(d: &mut Document, r: Rect, c: [f32; 3]) -> Node {
let mut n = rect(d, r, c);
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.extras_mut().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 a_knockout_group_weights_a_partly_covered_pixel_by_the_coverage_once() {
// The upper Multiply square's left edge covers half of pixel column 40. There the upper square
// takes half of the pixel with its color composited against the backdrop, and the lower one
// keeps the other half (ISO 32000-1 §11.4.6). Right of x = 70 the backdrop is a stripe of
// another color, which the upper square multiplies with.
const T: [f32; 3] = [1.0, 0.4, 0.6];
const STRIPE: [f32; 3] = [0.3, 0.7, 0.9];
for isolate in [false, true] {
let mut 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.5, 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 stripe = rect(&mut d, Rect::new(70.0, 0.0, 100.0, 100.0), STRIPE);
let l = d.layers[0].id;
d.insert(Some(l), 1, stripe).unwrap();
let r = render(&d);
// Isolated, each square multiplies with nothing and shows its own color.
let multiplied = |b: [f32; 3], c: [f32; 3]| if isolate { c } else { blend_rgb(Blend::Multiply, b, c) };
assert_near(r.pixel(40, 50), lerp(multiplied(B, S), multiplied(B, T), 0.5), &format!("half-covered edge, isolate {isolate}"));
assert_near(r.pixel(80, 50), multiplied(STRIPE, T), &format!("over the stripe, 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");
}
/// A 300 × 300 page: the backdrop with a 60% stripe over its right half, then nine objects that
/// do not overlap. Each object copies its backdrop or is drawn between objects that do. Object `i`
/// is on the page when `with(i)` is true.
fn objects_apart(with: impl Fn(usize) -> bool) -> Document {
let mut d = Document::new(300.0, 300.0);
let below = rect(&mut d, Rect::new(0.0, 0.0, 300.0, 300.0), B);
let mut stripe = rect(&mut d, Rect::new(150.0, 0.0, 300.0, 300.0), [0.1, 0.3, 0.9]);
stripe.opacity = 0.6;
let cell = |x: f64, y: f64| Rect::new(x, y, x + 50.0, y + 50.0);
let mut objects = vec![];
// Objects 0 and 1 are a 50% group and a 70% Screen group, each holding a Multiply child.
let m = multiply_in(&mut d, cell(10.0, 10.0), S);
objects.push(group(&mut d, vec![m], 0.5, Blend::Normal, false));
let m = multiply_in(&mut d, cell(110.0, 10.0), S);
objects.push(group(&mut d, vec![m], 0.7, Blend::Screen, false));
// Object 2 is a masked group holding a Multiply child. Its mask art holds a 50% group with a
// Multiply child.
let m = multiply_in(&mut d, cell(210.0, 10.0), S);
let mut masked = group(&mut d, vec![m], 1.0, Blend::Normal, false);
let white = rect(&mut d, cell(205.0, 5.0), [1.0; 3]);
let gray = multiply_in(&mut d, Rect::new(220.0, 20.0, 250.0, 50.0), [0.5; 3]);
let half = group(&mut d, vec![gray], 0.5, Blend::Normal, false);
let art = group(&mut d, vec![white, half], 1.0, Blend::Normal, false);
masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true)));
objects.push(masked);
// Object 3 is a knockout group of two Multiply rectangles that overlap.
let lower = multiply_in(&mut d, Rect::new(10.0, 110.0, 45.0, 160.0), S);
let upper = multiply_in(&mut d, Rect::new(25.0, 110.0, 60.0, 160.0), [1.0, 0.4, 0.6]);
let mut knockout = group(&mut d, vec![lower, upper], 1.0, Blend::Normal, false);
knockout.knockout = Knockout::On;
objects.push(knockout);
// Object 4 is an isolated group that holds a 50% group with a Multiply child.
let green = rect(&mut d, cell(110.0, 110.0), [0.2, 0.9, 0.4]);
let m = multiply_in(&mut d, Rect::new(120.0, 120.0, 150.0, 150.0), S);
let half = group(&mut d, vec![m], 0.5, Blend::Normal, false);
objects.push(group(&mut d, vec![green, half], 1.0, Blend::Normal, true));
// Object 5 is a clip group that holds a 50% group with a Multiply child and a rectangle after
// the group.
let clip = clip_path(&mut d, Rect::new(215.0, 115.0, 255.0, 155.0));
let m = multiply_in(&mut d, cell(210.0, 110.0), S);
let half = group(&mut d, vec![m], 0.5, Blend::Normal, false);
let after = rect(&mut d, Rect::new(210.0, 140.0, 260.0, 160.0), [0.9, 0.9, 0.1]);
let children = [clip, half, after].into_iter().map(Arc::new).collect();
objects.push(Node::new(d.alloc_id(), NodeKind::Group { children, clip: true }));
// Object 6 is a 50% rectangle that is not in a group.
let mut plain = rect(&mut d, cell(10.0, 210.0), [0.3, 0.2, 0.8]);
plain.opacity = 0.5;
objects.push(plain);
// Object 7 is a Screen group at 100% holding a Multiply child.
let m = multiply_in(&mut d, cell(110.0, 210.0), S);
objects.push(group(&mut d, vec![m], 1.0, Blend::Screen, false));
// Object 8 is a 50% group whose Multiply child reaches past the page's corner.
let m = multiply_in(&mut d, Rect::new(260.0, 260.0, 330.0, 330.0), S);
objects.push(group(&mut d, vec![m], 0.5, Blend::Normal, false));
let l = d.layers[0].id;
let kept = objects.into_iter().enumerate().filter(|(i, _)| with(*i)).map(|(_, n)| n);
for n in [below, stripe].into_iter().chain(kept) {
d.insert(Some(l), usize::MAX, n).unwrap();
}
d
}
/// The renderer copies the backdrop of several objects in one frame, each from the picture of what
/// it drew before that object. The frame equals the page drawn with each object alone, pixel for
/// pixel, when it is drawn on one thread and on two, turned, composited in floating point, inside
/// an isolated page group, zoomed in until the first group fills the frame, and as the renderer's
/// next frame.
#[test]
fn objects_that_copy_their_backdrop_draw_as_they_do_alone() {
let (all, none) = (objects_apart(|_| true), objects_apart(|_| false));
let alone: Vec<Document> = (0..9).map(|i| objects_apart(|j| j == i)).collect();
let (plain, precise) = (RenderOptions::default(), RenderOptions { precise: true, ..Default::default() });
let turned = Affine::rotate_about(0.35, vectorcraft_geom::Point::new(150.0, 150.0));
let zoomed = Affine::translate((150.0, 150.0)) * Affine::scale(6.0) * Affine::translate((-35.0, -35.0));
for (what, threads, view, opts, isolate) in [
("one thread", 0, Affine::IDENTITY, &plain, false),
("two threads", 2, Affine::IDENTITY, &plain, false),
("turned", 0, turned, &plain, false),
("floating point", 0, Affine::IDENTITY, &precise, false),
("isolated page", 0, Affine::IDENTITY, &plain, true),
("zoomed", 0, zoomed, &plain, false),
] {
let mut r = Renderer::new();
r.threads = threads;
let mut draw = |d: &Document| {
let mut d = d.clone();
d.page_isolate = isolate;
r.render(&d, 300, 300, view, opts).pixels
};
let below = draw(&none);
let mut want = below.clone();
for d in &alone {
let one = draw(d);
for ((w, o), b) in want.as_chunks_mut::<4>().0.iter_mut().zip(one.as_chunks::<4>().0).zip(below.as_chunks::<4>().0) {
if o != b {
assert_eq!(*w, *b, "{what}: two objects draw on one pixel");
*w = *o;
}
}
}
for frame in 1..=2 {
let got = draw(&all);
let differ: Vec<usize> = (0..300 * 300).filter(|i| got[i * 4..i * 4 + 4] != want[i * 4..i * 4 + 4]).collect();
let at = differ.first().map(|i| (i % 300, i / 300));
assert!(differ.is_empty(), "{what}, frame {frame}: {} pixels differ, the first at {at:?}", differ.len());
}
}
}
/// The renderer copies the backdrop of twenty 50% groups with Multiply children on a 400 × 400
/// page. The images it draws in that frame add up to fewer pixels than the page: it draws no
/// picture of the page back after a copy.
#[test]
fn backdrop_copies_draw_no_picture_of_the_page_back() {
let mut d = Document::new(400.0, 400.0);
let l = d.layers[0].id;
let below = rect(&mut d, Rect::new(0.0, 0.0, 400.0, 400.0), B);
d.insert(Some(l), usize::MAX, below).unwrap();
for i in 0..20u32 {
let (x, y) = (10.0 + f64::from(i % 5) * 80.0, 10.0 + f64::from(i / 5) * 80.0);
let m = multiply_in(&mut d, Rect::new(x, y, x + 20.0, y + 20.0), S);
let g = group(&mut d, vec![m], 0.5, Blend::Normal, false);
d.insert(Some(l), usize::MAX, g).unwrap();
}
let drawn = || crate::group::PIXELS_DRAWN.with(std::cell::Cell::get);
for threads in [0, 2] {
let mut r = Renderer::new();
r.threads = threads;
let before = drawn();
let frame = r.render(&d, 400, 400, Affine::IDENTITY, &RenderOptions::default());
let images = drawn() - before;
assert!(images < 400 * 400, "threads {threads}: {images} pixels drawn as images");
assert_near(frame.pixel(20, 20), lerp(B, blend_rgb(Blend::Multiply, B, S), 0.5), "inside a group");
}
}
/// Isolation mode (#833): the art around the isolated group draws at half opacity, above it as
/// well as below; the group's own art as usual.
#[test]
fn isolation_mode_dims_the_art_around_the_isolated_group() {
let mut d = Document::new(100.0, 100.0);
let l = d.layers[0].id;
let below = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 40.0), [0.0, 0.0, 0.0]);
let inside = rect(&mut d, Rect::new(60.0, 60.0, 100.0, 100.0), [0.0, 0.0, 0.0]);
let above = rect(&mut d, Rect::new(60.0, 0.0, 100.0, 40.0), [0.0, 0.0, 0.0]);
let g = group(&mut d, vec![inside], 1.0, Blend::Normal, false);
let gid = g.id;
for n in [below, g, above] {
d.insert(Some(l), usize::MAX, n).unwrap();
}
let alpha = |img: &Rendered, x: usize, y: usize| img.pixels[(y * 100 + x) * 4 + 3];
let plain = render(&d);
let isolated = |id| render_with(&mut Renderer::new(), &d, &RenderOptions { isolated: Some(id), ..Default::default() });
let dimmed = isolated(gid);
assert_eq!(alpha(&dimmed, 80, 80), 255, "the isolated group's art");
for (x, y) in [(20, 20), (80, 20)] {
assert_eq!(alpha(&plain, x, y), 255);
assert!((120..=135).contains(&alpha(&dimmed, x, y)), "({x}, {y}): {}", alpha(&dimmed, x, y));
}
// An object that isn't in the document dims nothing.
assert_eq!(isolated(NodeId(u64::MAX)).pixels, plain.pixels);
}