mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
New Document.page_isolate / page_knockout, set by
transparency.togglePageIsolatedBlending / togglePageKnockoutGroup {value?}
(one undo step each, saved with the document, reported by
transparency.info) and the Transparency panel menu's check items. The page
renders as an isolated group (top-level blend modes no longer blend with
what lies under the page) and/or a knockout group whose elements are the
layers (neutral layers pass their objects through). PDF and SVG wrap the
page content in an isolated group and write page knockout as soft masks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
129 lines
5.7 KiB
Rust
129 lines
5.7 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.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));
|
|
}
|