mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 18:27:02 -06:00
A transparency group whose members blend with the art below it, and a masked object with a blend mode, are drawn over a copy of that art. For each such group in each frame, the renderer rendered everything drawn so far into a new pixmap the size of its context (on the canvas, the whole view), started that context again and drew that pixmap back into it as an image, which the next copy and the frame rendered again. At 2880x1800, a document with 50 translucent groups, each holding a Multiply rectangle, took 299 ms a frame on 8 threads. Every render that composites in 8 bits, the canvas included, now keeps that picture and renders what the context draws next over it. The backdrop is still cut from the picture. The 50 groups take 65 ms a frame, and the frames have the same pixels. Renders that composite in floating point (raster exports, Save for Web, Object › Rasterize and Crop Image) still draw the picture back. Docs: docs/target-app-parity.md.
391 lines
18 KiB
Rust
391 lines
18 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
|
||
}
|
||
|
||
/// 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 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);
|
||
}
|