mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
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.
187 lines
9.1 KiB
Rust
187 lines
9.1 KiB
Rust
//! Knockout groups, the knockout shape and the page group (Transparency panel).
|
|
|
|
use super::*;
|
|
use vectorcraft_color::{BlendMode as Blend, Color, Paint};
|
|
use vectorcraft_doc::{Appearance, Knockout, Node};
|
|
use vectorcraft_geom::shapes;
|
|
|
|
/// A 50% opaque rectangle: red at 10..60, blue at 40..90 (they overlap at 40..60).
|
|
fn half(d: &mut Document, x: f64, rgb: (f32, f32, f32)) -> Node {
|
|
let id = d.alloc_id();
|
|
let mut n = Node::path(
|
|
id,
|
|
shapes::rectangle(Rect::new(x, 10.0, x + 50.0, 90.0)),
|
|
Appearance::basic(Paint::solid(Color::rgb(rgb.0, rgb.1, rgb.2)), Paint::None, 0.0),
|
|
);
|
|
n.opacity = 0.5;
|
|
n
|
|
}
|
|
|
|
/// A document whose layer holds a group (`knockout`) of a 50% red and a 50% blue rectangle,
|
|
/// after `tweak` adjusts the group.
|
|
fn doc(knockout: Knockout, tweak: impl FnOnce(&mut Document, &mut Node)) -> Document {
|
|
let mut d = Document::new(100.0, 100.0);
|
|
let red = half(&mut d, 10.0, (1.0, 0.0, 0.0));
|
|
let blue = half(&mut d, 40.0, (0.0, 0.0, 1.0));
|
|
let id = d.alloc_id();
|
|
let mut g = Node::group(id, vec![Arc::new(red), Arc::new(blue)]);
|
|
g.knockout = knockout;
|
|
tweak(&mut d, &mut g);
|
|
d.insert(Some(d.layers[0].id), 0, g).unwrap();
|
|
d
|
|
}
|
|
|
|
fn render(d: &Document) -> Rendered {
|
|
Renderer::new().render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
|
|
}
|
|
|
|
fn close(a: [u8; 4], b: [u8; 4]) -> bool {
|
|
a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= 3)
|
|
}
|
|
|
|
const BLUE_OVER_WHITE: [u8; 4] = [128, 128, 255, 255];
|
|
/// 50% blue over 50% red over white.
|
|
const BLUE_OVER_RED: [u8; 4] = [128, 64, 191, 255];
|
|
/// The overlap with half of the red knocked out by an opaque blue: 0.5 · (50% red over white) +
|
|
/// 0.5 · blue (for Normal blending, the same as 50% blue over the red).
|
|
const HALF_KNOCKED: [u8; 4] = [128, 64, 191, 255];
|
|
|
|
#[test]
|
|
fn knockout_group_shows_the_top_object_over_the_backdrop() {
|
|
let on = render(&doc(Knockout::On, |_, _| {}));
|
|
assert!(close(on.pixel(50, 50), BLUE_OVER_WHITE), "overlap: {:?}", on.pixel(50, 50));
|
|
assert!(close(on.pixel(20, 50), [255, 128, 128, 255]), "red alone: {:?}", on.pixel(20, 50));
|
|
for k in [Knockout::Off, Knockout::Neutral] {
|
|
let r = render(&doc(k, |_, _| {}));
|
|
assert!(close(r.pixel(50, 50), BLUE_OVER_RED), "{k:?}: {:?}", r.pixel(50, 50));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn opacity_defines_the_knockout_shape() {
|
|
// The blue object's 50% opacity knocks out only half of the red below it.
|
|
let r = render(&doc(Knockout::On, |_, g| {
|
|
let blue = Arc::make_mut(&mut g.children_mut().unwrap()[1]);
|
|
blue.knockout_shape = true;
|
|
}));
|
|
assert!(close(r.pixel(50, 50), HALF_KNOCKED), "{:?}", r.pixel(50, 50));
|
|
// Its opacity mask counts too when it defines the shape, and is ignored otherwise.
|
|
for shape in [false, true] {
|
|
let r = render(&doc(Knockout::On, |d, g| {
|
|
let id = d.alloc_id();
|
|
let white = Node::path(
|
|
id,
|
|
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)),
|
|
Appearance::basic(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0),
|
|
);
|
|
let blue = Arc::make_mut(&mut g.children_mut().unwrap()[1]);
|
|
blue.opacity = 1.0;
|
|
blue.knockout_shape = shape;
|
|
blue.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(white, true)));
|
|
}));
|
|
let want = if shape { HALF_KNOCKED } else { BLUE_OVER_WHITE };
|
|
// A mid-grey mask is ~50% (luminance 0.5, not gamma-corrected): allow a little more slack.
|
|
let px = r.pixel(50, 50);
|
|
assert!(px.iter().zip(want).all(|(a, b)| (*a as i32 - b as i32).abs() <= 6), "shape {shape}: {px:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn neutral_groups_pass_knockout_through() {
|
|
// Red and blue in a neutral group inside a knockout group knock each other out…
|
|
let nested = |inner: Knockout| {
|
|
doc(Knockout::On, |d, g| {
|
|
let children = std::mem::take(g.children_mut().unwrap());
|
|
let mut inner_group = Node::group(d.alloc_id(), children);
|
|
inner_group.knockout = inner;
|
|
*g.children_mut().unwrap() = vec![Arc::new(inner_group)];
|
|
})
|
|
};
|
|
assert!(close(render(&nested(Knockout::Neutral)).pixel(50, 50), BLUE_OVER_WHITE));
|
|
// …an Off group's do not…
|
|
assert!(close(render(&nested(Knockout::Off)).pixel(50, 50), BLUE_OVER_RED));
|
|
// …and with no knockout group around it, a neutral group knocks out nothing.
|
|
let mut plain = nested(Knockout::Neutral);
|
|
Arc::make_mut(&mut Arc::make_mut(&mut plain.layers[0]).children_mut().unwrap()[0]).knockout = Knockout::Neutral;
|
|
assert!(close(render(&plain).pixel(50, 50), BLUE_OVER_RED));
|
|
}
|
|
|
|
#[test]
|
|
fn page_knockout_and_isolated_blending() {
|
|
// Page Knockout Group: the neutral layer passes its objects through to the page.
|
|
let mut d = doc(Knockout::Neutral, |_, _| {});
|
|
d.page_knockout = true;
|
|
assert!(close(render(&d).pixel(50, 50), BLUE_OVER_WHITE), "{:?}", render(&d).pixel(50, 50));
|
|
// A layer set to Off keeps its objects from knocking each other out.
|
|
Arc::make_mut(&mut d.layers[0]).knockout = Knockout::Off;
|
|
assert!(close(render(&d).pixel(50, 50), BLUE_OVER_RED));
|
|
|
|
// Page Isolated Blending: a Difference object no longer blends with the white under the page.
|
|
let mut d = Document::new(100.0, 100.0);
|
|
let mut red = half(&mut d, 10.0, (1.0, 0.0, 0.0));
|
|
red.opacity = 1.0;
|
|
red.blend = Blend::Difference;
|
|
d.insert(Some(d.layers[0].id), 0, red).unwrap();
|
|
assert!(close(render(&d).pixel(30, 50), [0, 255, 255, 255]), "inverts the white: {:?}", render(&d).pixel(30, 50));
|
|
d.page_isolate = true;
|
|
assert!(close(render(&d).pixel(30, 50), [255, 0, 0, 255]), "isolated: {:?}", render(&d).pixel(30, 50));
|
|
}
|
|
|
|
#[test]
|
|
fn page_knockout_looks_the_same_when_another_object_blends() {
|
|
// With Page Knockout Group on, a Multiply square on the page makes the renderer draw the page
|
|
// over a copy of what is below it (white, or nothing). Four objects knock out part of the
|
|
// orange page: a blue square whose left edge covers half of pixel column 10, a 50% blue square
|
|
// whose opacity defines its knockout shape, a 50% blue square, and an isolated group of a green
|
|
// square and a 50% group holding a Multiply square, which draws its middle half transparent.
|
|
// Each looks the same with and without the Multiply square.
|
|
let rect = |d: &mut Document, r: Rect, (red, green, blue): (f32, f32, f32)| {
|
|
Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::rgb(red, green, blue)), Paint::None, 0.0))
|
|
};
|
|
let page = |blending: bool| {
|
|
let mut d = Document::new(100.0, 100.0);
|
|
d.page_knockout = true;
|
|
let orange = rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), (0.8, 0.6, 0.2));
|
|
let edge = rect(&mut d, Rect::new(10.5, 10.0, 30.5, 30.0), (0.1, 0.3, 0.9));
|
|
let mut shaped = rect(&mut d, Rect::new(40.0, 10.0, 60.0, 30.0), (0.1, 0.3, 0.9));
|
|
(shaped.opacity, shaped.knockout_shape) = (0.5, true);
|
|
let mut translucent = rect(&mut d, Rect::new(70.0, 10.0, 90.0, 30.0), (0.1, 0.3, 0.9));
|
|
translucent.opacity = 0.5;
|
|
let green = rect(&mut d, Rect::new(10.0, 40.0, 60.0, 90.0), (0.2, 0.9, 0.4));
|
|
let mut multiply = rect(&mut d, Rect::new(20.0, 50.0, 50.0, 80.0), (0.5, 0.5, 1.0));
|
|
multiply.blend = Blend::Multiply;
|
|
let mut inner = Node::group(d.alloc_id(), vec![Arc::new(multiply)]);
|
|
inner.opacity = 0.5;
|
|
let mut isolated = Node::group(d.alloc_id(), vec![Arc::new(green), Arc::new(inner)]);
|
|
isolated.isolate = true;
|
|
let mut objects = vec![orange, edge, shaped, translucent, isolated];
|
|
if blending {
|
|
let mut other = rect(&mut d, Rect::new(70.0, 70.0, 90.0, 90.0), (0.5, 0.5, 1.0));
|
|
other.blend = Blend::Multiply;
|
|
objects.push(other);
|
|
}
|
|
let l = d.layers[0].id;
|
|
for n in objects {
|
|
d.insert(Some(l), usize::MAX, n).unwrap();
|
|
}
|
|
d
|
|
};
|
|
// Half of the orange stays and blue takes the other half: 0.5 · orange + 0.5 · blue. The 50%
|
|
// square knocks out all of the orange and shows over what is below the page: 0.5 · white +
|
|
// 0.5 · blue, or blue at 50% over nothing.
|
|
let half_blue = [115, 115, 140, 255];
|
|
for (below, half_square) in [(Some([255, 255, 255, 255]), [140, 166, 242, 255]), (None, [26, 77, 230, 128])] {
|
|
let opts = RenderOptions { background: below, ..Default::default() };
|
|
let draw = |d: &Document| Renderer::new().render(d, 100, 100, Affine::IDENTITY, &opts);
|
|
let (plain, blending) = (draw(&page(false)), draw(&page(true)));
|
|
let cases = [("half-covered edge", 10, 20, half_blue), ("knockout shape", 50, 20, half_blue), ("50%", 80, 20, half_square)];
|
|
for (what, x, y, want) in cases {
|
|
for r in [&plain, &blending] {
|
|
assert!(close(r.pixel(x, y), want), "{what} over {below:?}: {:?}", r.pixel(x, y));
|
|
}
|
|
}
|
|
let (a, b) = (plain.pixel(35, 65), blending.pixel(35, 65));
|
|
assert!(close(a, b), "the group's middle over {below:?}: {a:?} alone, {b:?} with the Multiply square");
|
|
}
|
|
}
|