mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 19:57:05 -06:00
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:
+93
-21
@@ -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(®ion.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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user