Merge wp/F2: knockout groups, knockout shape, page groups (M3.46-M3.48)

# Conflicts:
#	crates/pdf/src/export.rs
#	crates/svg/src/export.rs
This commit is contained in:
bflatastic
2026-10-04 23:15:10 +07:00
22 changed files with 888 additions and 113 deletions
+93 -21
View File
@@ -230,6 +230,11 @@ pub struct Renderer {
live: live::LiveCache,
/// Blurred, tinted drop shadow / outer glow rasters per object and effect (see `fx`).
shadows: PtrMap<(usize, usize), fx::ShadowEntry>,
/// Whether the group being drawn is a knockout group (what its neutral children inherit).
knockout: bool,
/// Address of the knockout-group element being drawn as its knockout shape: at full object
/// opacity and without its opacity mask (see [`Self::draw_knockout`]); 0 = none.
shape_of: usize,
}
#[derive(Clone, Copy, Debug, Default)]
@@ -277,6 +282,8 @@ impl Renderer {
brushes: Default::default(),
shadows: PtrMap::default(),
live: live::LiveCache::default(),
knockout: false,
shape_of: 0,
}
}
@@ -329,9 +336,25 @@ impl Renderer {
if !self.draw_pattern_edit(&mut ctx, &frame) {
// While editing an opacity mask its art is seen only through the mask (Illustrator).
let mask_layer = doc.mask_edit.map(|m| m.layer);
for layer in doc.layers.iter().filter(|l| Some(l.id) != mask_layer) {
self.draw_arc(&mut ctx, &frame, layer);
let layers = doc.layers.iter().filter(|l| Some(l.id) != mask_layer);
// Page Isolated Blending / Page Knockout Group: the page is a group of its own.
let page_group = !opts.outline && (doc.page_isolate || doc.page_knockout);
self.knockout = page_group && doc.page_knockout;
if page_group {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, None, None, None, None);
}
if self.knockout {
self.draw_knockout(&mut ctx, &frame, &layers.cloned().collect::<Vec<_>>());
} else {
for layer in layers {
self.draw_arc(&mut ctx, &frame, layer);
}
}
if page_group {
ctx.pop_layer();
}
self.knockout = false;
}
if trim {
ctx.pop_layer();
@@ -389,6 +412,7 @@ impl Renderer {
ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, w as f64));
}
let frame = Frame { mt: false, doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts: &RenderOptions::default() };
self.knockout = false;
self.draw_node(&mut ctx, &frame, n, true);
ctx.flush();
let mut pm = Pixmap::new(w, w);
@@ -453,30 +477,33 @@ impl Renderer {
Some(p)
}
fn draw_arc(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>) {
/// Whether `a` is left out of this frame: hidden, a skipped template, or culled.
fn skipped(&mut self, f: &Frame, a: &Arc<Node>) -> bool {
let skipped_template = f.opts.skip_templates && matches!(a.kind, NodeKind::Layer { template: true, .. });
if !a.visible || skipped_template || f.opts.hidden.contains(&a.id) {
return;
return true;
}
match self.bounds_of(a) {
Some(b) => {
let pad = f.px * 2.0;
if !rects_overlap(b.inflate(pad, pad), f.visible) {
self.stats.culled += 1;
return;
}
// Level of detail: leaves smaller than a quarter pixel are invisible.
if !a.is_container() && b.width() < f.px * 0.25 && b.height() < f.px * 0.25 {
self.stats.culled += 1;
return;
}
let culled =
!rects_overlap(b.inflate(pad, pad), f.visible) || (!a.is_container() && b.width() < f.px * 0.25 && b.height() < f.px * 0.25);
self.stats.culled += culled as usize;
culled
}
None if !a.is_container() => return,
None => {}
None => !a.is_container(),
}
}
fn draw_arc(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>) {
if self.skipped(f, a) {
return;
}
if let Some(m) = a.mask.as_deref()
&& !m.disabled
&& !f.opts.outline
&& !self.is_shape(a)
{
let mask = self.opacity_mask(f, m, ctx.width(), ctx.height());
ctx.set_transform(Affine::IDENTITY);
@@ -491,7 +518,10 @@ impl Renderer {
/// Render an opacity mask's art offscreen and turn its luminance into a coverage mask.
fn opacity_mask(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> vello_cpu::Mask {
let mut mctx = single_threaded_context(w, h);
// Mask art is a picture of its own: it takes no part in a knockout group around the object.
let knockout = std::mem::take(&mut self.knockout);
self.draw_node(&mut mctx, &Frame { mt: false, ..*f }, &m.art, true);
self.knockout = knockout;
mctx.flush();
let mut pm = Pixmap::new(w, h);
mctx.render(&mut pm, &mut self.resources);
@@ -510,10 +540,10 @@ impl Renderer {
&& (!blended || single_plain_fill(a))
&& !a.isolate
&& !fx::has_fx(a)
&& (a.opacity >= 1.0 || painted_items(a) == 1)
&& (self.opacity_of(a) >= 1.0 || painted_items(a) == 1)
&& let Some(bp) = self.path_of(a)
{
self.alpha = a.opacity.clamp(0.0, 1.0);
self.alpha = self.opacity_of(a).clamp(0.0, 1.0);
self.cur = Some(a.clone());
if blended {
ctx.set_blend_mode(blend_mode(a.blend));
@@ -528,7 +558,7 @@ impl Renderer {
return;
}
if let NodeKind::Text(t) = &a.kind
&& a.opacity >= 1.0
&& self.opacity_of(a) >= 1.0
&& a.blend == vectorcraft_color::BlendMode::Normal
&& !fx::has_fx(a)
{
@@ -561,13 +591,17 @@ impl Renderer {
let outline = f.opts.outline;
let mut layers = 0;
let template_dim = matches!(n.kind, NodeKind::Layer { template: true, .. }) && f.opts.dim_templates;
let opacity = if template_dim { n.opacity * 0.5 } else { n.opacity };
if !outline && (opacity < 1.0 || n.blend != vectorcraft_color::BlendMode::Normal || n.isolate) {
let opacity = if template_dim { self.opacity_of(n) * 0.5 } else { self.opacity_of(n) };
// Whether this container's children knock each other out (a knockout group is isolated).
let knockout = !outline && n.knocks_out(self.knockout);
let enclosing = std::mem::replace(&mut self.knockout, knockout);
if !outline && (opacity < 1.0 || n.blend != vectorcraft_color::BlendMode::Normal || n.isolate || knockout) {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(n.blend)), Some(opacity), None, None);
layers += 1;
}
match &n.kind {
NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } if knockout => self.draw_knockout(ctx, f, children),
NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => {
for c in children {
self.draw_arc(ctx, f, c);
@@ -586,8 +620,12 @@ impl Renderer {
ctx.set_transform(f.view);
ctx.set_fill_rule(fill_rule(region.1));
ctx.push_clip_layer(&region.0);
for c in rest {
self.draw_arc(ctx, f, c);
if knockout {
self.draw_knockout(ctx, f, rest);
} else {
for c in rest {
self.draw_arc(ctx, f, c);
}
}
ctx.pop_layer();
}
@@ -620,12 +658,44 @@ impl Renderer {
}
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => self.draw_live_node(ctx, f, n),
}
self.knockout = enclosing;
self.stats.drawn += 1;
for _ in 0..layers {
ctx.pop_layer();
}
}
/// The children of a knockout group ([`Node::knockout_elements`]). Each element first erases
/// what the elements below it drew wherever it paints (its knockout shape: its coverage at full
/// object opacity without its opacity mask, or as drawn when its opacity and mask define the
/// knockout shape), then adds itself: it composites against the group's backdrop instead of
/// over the elements below it.
fn draw_knockout(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc<Node>]) {
for c in Node::knockout_elements(children) {
if self.skipped(f, c) {
continue;
}
for (compose, shape) in [(Compose::DestOut, !c.knockout_shape), (Compose::Plus, false)] {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, compose)), None, None, None);
self.shape_of = if shape { Arc::as_ptr(c) as usize } else { 0 };
self.draw_arc(ctx, f, c);
self.shape_of = 0;
ctx.pop_layer();
}
}
}
/// Whether `n` is the knockout element being drawn as its shape (see [`Self::draw_knockout`]).
fn is_shape(&self, n: &Node) -> bool {
self.shape_of == n as *const Node as usize
}
/// The object opacity `n` is drawn with (full while it is drawn as its knockout shape).
pub(crate) fn opacity_of(&self, n: &Node) -> f32 {
if self.is_shape(n) { 1.0 } else { n.opacity }
}
fn hairline(&mut self, ctx: &mut RenderContext, f: &Frame, bp: &BezPath, rgba: [u8; 4]) {
ctx.set_transform(Affine::IDENTITY);
let mut screen = bp.clone();
@@ -1012,3 +1082,5 @@ fn now() -> u64 {
mod tests;
#[cfg(test)]
mod tests_clip;
#[cfg(test)]
mod tests_knockout;
+3 -2
View File
@@ -117,10 +117,11 @@ impl Renderer {
pub(crate) fn draw_live(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>) {
let stamp = self.stamp;
self.live.tick(stamp);
let layered = !f.opts.outline && (a.opacity < 1.0 || a.blend != vectorcraft_color::BlendMode::Normal || a.isolate);
let opacity = self.opacity_of(a);
let layered = !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_color::BlendMode::Normal || a.isolate);
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(a.blend)), Some(a.opacity), None, None);
ctx.push_layer(None, Some(blend_mode(a.blend)), Some(opacity), None, None);
}
self.draw_live_body(ctx, f, a, true);
if layered {
+128
View File
@@ -0,0 +1,128 @@
//! 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));
}