From 54427d7a9264a7ffc42dd8531583966dfb017ce4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 00:03:18 +0700 Subject: [PATCH] M3.74: blending inside a group reaches the art below unless the group isolates it On screen, a group (or object) with opacity, a blend mode, an opacity mask or knockout whose content blends is drawn offscreen over a copy of its backdrop and mixed back in by its opacity, mask and clip (a group blend mode then applies to the backdrop-free result, as PDF defines it). Isolate Blending keeps the content starting from transparency. Clip groups no longer isolate their children (blending content is clipped per drawing), and non-isolated knockout groups composite their elements against the art below the group. Groups without blending inside still render as plain layers. In PDF, such groups are written as non-isolated transparency groups (a constant-alpha soft mask instead of the writer's isolated opacity group), knockout groups included. doc: Node::blends_through / children_blend. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/node.rs | 20 ++ crates/pdf/src/export.rs | 46 +++-- crates/pdf/src/lib.rs | 4 +- crates/pdf/src/tests_blend.rs | 55 ++++++ crates/render/src/brush_fx.rs | 21 +- crates/render/src/fx.rs | 31 +-- crates/render/src/group.rs | 275 +++++++++++++++++++++++++++ crates/render/src/lib.rs | 185 ++++++++++-------- crates/render/src/live.rs | 19 +- crates/render/src/pattern.rs | 18 +- crates/render/src/tests_isolation.rs | 215 +++++++++++++++++++++ docs/mcp.md | 8 +- 12 files changed, 755 insertions(+), 142 deletions(-) create mode 100644 crates/render/src/group.rs create mode 100644 crates/render/src/tests_isolation.rs diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index ac21bd3e..55a37261 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -752,6 +752,26 @@ impl Node { push(children, &mut out); out } + + /// Whether blending inside this object reaches the art below it unless the object isolates + /// it: a blend mode other than Normal on a child (or, in a leaf, on a fill or stroke), + /// directly or through children that don't isolate their own blending. + pub fn blends_through(&self) -> bool { + self.blends_through_with(&mut |c| c.blends_through()) + } + + /// [`Self::blends_through`], with `inner` answering it for the children (e.g. from a cache). + pub fn blends_through_with(&self, inner: &mut dyn FnMut(&Arc) -> bool) -> bool { + match self.children() { + Some(ch) if !matches!(self.kind, NodeKind::Compound { .. }) => Self::children_blend(ch, inner), + _ => self.appearance.items.iter().any(|i| i.visible() && i.blend() != BlendMode::Normal), + } + } + + /// [`Self::blends_through`] of a group made of `children` (`inner` answers it for each child). + pub fn children_blend(children: &[Arc], inner: &mut dyn FnMut(&Arc) -> bool) -> bool { + children.iter().any(|c| c.visible && (c.blend != BlendMode::Normal || (!c.isolate && inner(c)))) + } } /// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)? diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index f8046d51..54f5a110 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -208,18 +208,25 @@ fn color(c: &Color) -> krilla::color::Color { } } -/// Fill the page (with a margin) with an opaque grey level (0 = black, 255 = white): mask backdrops. -fn cover(s: &mut Surface, page: Rect, level: u8) { +/// Fill the page (with a margin) with a grey level (0 = black, 255 = white) at `opacity`: mask +/// backdrops. +fn cover(s: &mut Surface, page: Rect, level: u8, opacity: f32) { let Some(p) = to_path(&page.inflate(1.0, 1.0).to_path(0.1)) else { return }; s.set_stroke(None); - s.set_fill(Some(Fill { - paint: rgb::Color::new(level, level, level).into(), - opacity: NormalizedF32::ONE, - rule: krilla::paint::FillRule::NonZero, - })); + s.set_fill(Some(Fill { paint: rgb::Color::new(level, level, level).into(), opacity: norm(opacity), rule: krilla::paint::FillRule::NonZero })); s.draw_path(&p); } +/// An alpha mask of constant `alpha` over the page. A group drawn through it is a transparency +/// group of that opacity that, unlike the writer's opacity groups, is not isolated. +fn constant_mask(s: &mut Surface, page: Rect, alpha: f32) -> krilla::mask::Mask { + let mut sb = s.stream_builder(); + let mut ms = sb.surface(); + cover(&mut ms, page, 0, alpha); + ms.finish(); + krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Alpha) +} + fn rects_overlap(a: Rect, b: Rect) -> bool { a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 } @@ -356,7 +363,7 @@ impl Exporter<'_> { // An opaque backdrop when it matters: white outside the art (no clip), or black for the // inverting Difference pass below to turn white. if !m.clip || m.invert { - cover(&mut ms, page, if m.clip { 0 } else { 255 }); + cover(&mut ms, page, if m.clip { 0 } else { 255 }, 1.0); } // 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); @@ -364,7 +371,7 @@ impl Exporter<'_> { self.knockout = knockout; if m.invert { ms.push_blend_mode(krilla::blend::BlendMode::Difference); - cover(&mut ms, page, 255); + cover(&mut ms, page, 255, 1.0); ms.pop(); } ms.finish(); @@ -405,7 +412,7 @@ impl Exporter<'_> { let shape = if c.knockout_shape { c.clone() } else { Node { opacity: 1.0, mask: None, ..c.clone() } }; let mut sb = s.stream_builder(); let mut ms = sb.surface(); - cover(&mut ms, page, 255); + cover(&mut ms, page, 255, 1.0); let alpha = { let mut ab = ms.stream_builder(); let mut als = ab.surface(); @@ -414,7 +421,7 @@ impl Exporter<'_> { krilla::mask::Mask::new(ab.finish(), krilla::mask::MaskType::Alpha) }; ms.push_mask(alpha); - cover(&mut ms, page, 0); + cover(&mut ms, page, 0, 1.0); ms.pop(); ms.finish(); krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity) @@ -434,7 +441,7 @@ impl Exporter<'_> { } self.warn_raster(&n.appearance.effects); let container = n.is_container() && !matches!(n.kind, NodeKind::Compound { .. }); - // Whether this container's children knock each other out (a knockout group is isolated). + // Whether this container's children knock each other out (a knockout group is written as a group). let knockout = n.knocks_out(self.knockout); let enclosing = std::mem::replace(&mut self.knockout, knockout); let mut pushes = 0; @@ -442,11 +449,16 @@ impl Exporter<'_> { s.push_blend_mode(blend(n.blend)); pushes += 1; } - if n.opacity < 1.0 { - s.push_opacity(norm(n.opacity)); - pushes += 1; - } else if container && (n.isolate || n.blend != BlendMode::Normal || knockout) { - s.push_isolated(); + if n.opacity < 1.0 || (container && (n.isolate || n.blend != BlendMode::Normal || knockout)) { + if !n.isolate && n.blends_through() { + // Not isolated: blending inside reaches the art below the group. + let alpha = constant_mask(s, page, n.opacity); + s.push_mask(alpha); + } else if n.opacity < 1.0 { + s.push_opacity(norm(n.opacity)); + } else { + s.push_isolated(); + } pushes += 1; } if let Some(m) = n.mask.as_deref() diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index f8d0a240..1e6ec125 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -108,12 +108,12 @@ pub enum PdfError { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_blend; +#[cfg(test)] mod tests_dashalign; #[cfg(test)] mod tests_fx; #[cfg(test)] -mod tests_blend; -#[cfg(test)] mod tests_stroke; #[cfg(test)] mod tests_strokeout; diff --git a/crates/pdf/src/tests_blend.rs b/crates/pdf/src/tests_blend.rs index 985be16a..dbafa475 100644 --- a/crates/pdf/src/tests_blend.rs +++ b/crates/pdf/src/tests_blend.rs @@ -42,3 +42,58 @@ fn every_blend_mode_is_written_under_its_pdf_name_and_read_back() { assert_eq!(found, mode); } } + +/// The backdrop and a 50% group (isolated or not) holding a Multiply square. +fn half_group(isolate: bool) -> Document { + let mut d = Document::new(100.0, 100.0); + add(&mut d, rect(Rect::new(0.0, 0.0, 100.0, 100.0), (0.8, 0.6, 0.2))); + let mut m = rect(Rect::new(20.0, 20.0, 80.0, 80.0), (0.5, 0.5, 1.0)); + (m.id, m.blend) = (d.alloc_id(), BlendMode::Multiply); + let mut g = Node::group(NodeId(0), vec![std::sync::Arc::new(m)]); + (g.opacity, g.isolate) = (0.5, isolate); + add(&mut d, g); + d +} + +#[test] +fn a_group_is_written_non_isolated_unless_it_isolates_blending() { + let open = text(&half_group(false)); + // A transparency group without /I, its 50% carried by a constant alpha soft mask. + assert!(!open.contains("/Itrue"), "{open}"); + assert!(open.contains("/SMask") && open.contains("/Alpha") && open.contains("/BM/Multiply"), "{open}"); + let isolated = text(&half_group(true)); + assert!(isolated.contains("/Itrue") && isolated.contains("/ca0.5"), "{isolated}"); + assert!(!isolated.contains("/Alpha"), "{isolated}"); + // Either way the child's blend mode reads back. + for d in [half_group(false), half_group(true)] { + let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap(); + let mut bm = BlendMode::Normal; + back.walk(|n| { + if n.blend != BlendMode::Normal { + bm = n.blend; + } + }); + assert_eq!(bm, BlendMode::Multiply); + } +} + +#[test] +fn groups_without_blending_inside_stay_isolated_groups() { + let mut d = half_group(false); + let g = d.layers[0].children().unwrap()[1].id; + let m = d.node(g).unwrap().children().unwrap()[0].id; + d.node_mut(m).unwrap().blend = BlendMode::Normal; + let t = text(&d); + assert!(t.contains("/Itrue") && t.contains("/ca0.5") && !t.contains("/SMask"), "{t}"); +} + +#[test] +fn a_knockout_group_with_blending_elements_is_non_isolated_unless_isolated() { + for isolate in [false, true] { + let mut d = half_group(isolate); + let g = d.layers[0].children().unwrap()[1].id; + let n = d.node_mut(g).unwrap(); + (n.opacity, n.knockout) = (1.0, vectorcraft_doc::Knockout::On); + assert_eq!(text(&d).contains("/Itrue"), isolate, "isolate {isolate}"); + } +} diff --git a/crates/render/src/brush_fx.rs b/crates/render/src/brush_fx.rs index 2fa32741..d089cc60 100644 --- a/crates/render/src/brush_fx.rs +++ b/crates/render/src/brush_fx.rs @@ -13,7 +13,7 @@ use vectorcraft_doc::{AppearanceItem, Document, Node, StrokeLayer}; use vectorcraft_geom::{BezPath, Rect}; use vello_cpu::RenderContext; -use crate::{Frame, Renderer, blend_mode}; +use crate::{Frame, Renderer}; struct Entry { path: BezPath, @@ -98,16 +98,15 @@ impl Renderer { pieces } }; - let layered = st.opacity < 1.0 || st.blend != vectorcraft_color::BlendMode::Normal; - if layered { - ctx.set_transform(vectorcraft_geom::Affine::IDENTITY); - ctx.push_layer(None, Some(blend_mode(st.blend)), Some(st.opacity), None, None); - } - for p in pieces.iter() { - self.draw_node(ctx, f, p, true); - } - if layered { - ctx.pop_layer(); + let mut draw = |r: &mut Self, c: &mut RenderContext, fr: &Frame| { + for p in pieces.iter() { + r.draw_node(c, fr, p, true); + } + }; + if st.opacity < 1.0 || st.blend != vectorcraft_color::BlendMode::Normal { + self.group(ctx, f, crate::group::Composite { blend: st.blend, opacity: st.opacity, ..Default::default() }, &mut draw); + } else { + draw(self, ctx, f); } true } diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 7ecd9c73..643adede 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -284,7 +284,7 @@ impl Renderer { // Silhouette (the object's painted alpha), offset by the shadow distance. let mut off = crate::single_threaded_context(w, h); let crop = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f }; - paint(self, &mut off, &shifted(&crop, dx, dy)); + self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy))); off.flush(); let mut pm = vello_cpu::Pixmap::new(w, h); off.render(&mut pm, &mut self.resources); @@ -343,11 +343,19 @@ impl Renderer { /// the art standing for the object's silhouette. pub(crate) fn draw_object_fx(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc, cache: bool) { let opacity = self.opacity_of(a); - let layered = !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_doc::color::BlendMode::Normal || a.isolate); - if layered { - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, Some(blend_mode(a.blend)), Some(opacity), None, None); + if !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_doc::color::BlendMode::Normal || a.isolate) { + let blends = self.blends_through(a); + let bounds = if blends { self.bounds_of(a) } else { None }; + let comp = crate::group::Composite { blend: a.blend, opacity, isolated: a.isolate, blends, bounds, ..Default::default() }; + self.group(ctx, f, comp, &mut |r, c, fr| r.draw_object_fx_art(c, fr, a, cache)); + } else { + self.draw_object_fx_art(ctx, f, a, cache); } + self.stats.drawn += 1; + } + + /// What [`Self::draw_object_fx`] draws inside the object's transparency group. + fn draw_object_fx_art(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc, cache: bool) { let art = self.fx_art(f.doc, a, cache); let rfx = effects::raster_effects(&a.appearance.effects); match cull_bounds(&art) { @@ -363,10 +371,6 @@ impl Renderer { } _ => self.draw_node(ctx, f, &art, true), } - if layered { - ctx.pop_layer(); - } - self.stats.drawn += 1; } /// The art [`Self::draw_object_fx`] draws for `a` (cached by `Arc` identity when `cache`): @@ -535,7 +539,7 @@ impl Renderer { draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame, Option<(vectorcraft_doc::color::BlendMode, f32)>), ) { if !f.mt { - return draw(self, ctx, f, composite); + return self.inside_layer(|r| draw(r, ctx, f, composite)); } // 3σ of the Gaussian (σ = blur / 2) in pixels, plus a pixel of antialiasing. let spread = blur.max(0.0) * 1.5 / f.px + 2.0; @@ -549,7 +553,7 @@ impl Renderer { let (w, h) = (w as u16, h as u16); let mut off = crate::single_threaded_context(w, h); let shifted = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f }; - draw(self, &mut off, &shifted, None); + self.inside_layer(|r| draw(r, &mut off, &shifted, None)); off.flush(); let mut pm = vello_cpu::Pixmap::new(w, h); off.render(&mut pm, &mut self.resources); @@ -558,13 +562,10 @@ impl Renderer { ctx.set_transform(Affine::IDENTITY); ctx.push_layer(None, Some(blend_mode(m)), Some(o), None, None); } - ctx.set_transform(Affine::translate((x0, y0))); - ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(std::sync::Arc::new(pm)), sampler: peniko::ImageSampler::default() }); - ctx.fill_rect(&Rect::new(0.0, 0.0, w as f64, h as f64)); + crate::group::draw_pixmap(ctx, std::sync::Arc::new(pm), (x0, y0)); if layered { ctx.pop_layer(); } - ctx.set_transform(Affine::IDENTITY); } /// Paint the fills and strokes of `n` on geometry `g`, applying per-item geometry effects and diff --git a/crates/render/src/group.rs b/crates/render/src/group.rs new file mode 100644 index 00000000..7703411d --- /dev/null +++ b/crates/render/src/group.rs @@ -0,0 +1,275 @@ +//! Transparency groups: groups with opacity or a blend mode, clip groups, opacity masks, the page +//! group. Most are vello layers, which are isolated (their content starts from transparency). A +//! non-isolated group whose content blends is drawn offscreen over a copy of its backdrop, so its +//! children blend with the art below it, then mixed back in (backdrop-copy compositing). + +use std::sync::Arc; + +use vectorcraft_color::BlendMode as Blend; +use vectorcraft_doc::Node; +use vectorcraft_geom::{Affine, BezPath, FillRule, Rect}; +use vello_cpu::peniko::{self, BlendMode, Compose, Mix}; +use vello_cpu::{Pixmap, RenderContext}; + +use crate::{Frame, Renderer, blend_mode, fill_rule, single_threaded_context}; + +/// How a group composites onto what is below it. +pub(crate) struct Composite<'a> { + /// Clipping path (document space). + pub clip: Option<(&'a BezPath, FillRule)>, + pub blend: Blend, + pub opacity: f32, + /// Opacity mask (the context's size). + pub mask: Option, + /// The content starts from transparency instead of from the art below. + pub isolated: bool, + /// Blending inside reaches the group's backdrop ([`Node::blends_through`]). + pub blends: bool, + /// Area the content covers (document space); `None` = anywhere. + pub bounds: Option, +} + +impl Default for Composite<'_> { + fn default() -> Self { + Self { clip: None, blend: Blend::Normal, opacity: 1.0, mask: None, isolated: false, blends: false, bounds: None } + } +} + +/// Group content drawn into a context (called once, or twice for a non-isolated group with a blend +/// mode: over the backdrop copy, then alone for its shape). +pub(crate) type Content<'d> = dyn FnMut(&mut Renderer, &mut RenderContext, &Frame) + 'd; + +/// A pixel region of a context: origin and size. +#[derive(Clone, Copy)] +struct Region { + x: u16, + y: u16, + w: u16, + h: u16, +} + +impl Renderer { + /// Draw a group's content with `draw` and composite it as `c` says. Blending content is drawn + /// offscreen: over a copy of the backdrop when the group isn't isolated (possible only with + /// no layer open around it, else it is drawn isolated), from transparency when it is (so + /// non-isolated groups inside can copy their backdrop). + pub(crate) fn group(&mut self, ctx: &mut RenderContext, f: &Frame, c: Composite, draw: &mut Content) { + if c.blends + && !c.isolated + && c.opacity >= 1.0 + && c.blend == Blend::Normal + && c.mask.is_none() + && let Some((path, rule)) = c.clip + { + // A plain clip clips each drawing instead of compositing a layer: blending inside + // reaches the art below directly. + ctx.set_transform(f.view); + ctx.set_fill_rule(fill_rule(rule)); + ctx.push_clip_path(path); + self.clip_paths.push((path.clone(), rule, f.view)); + draw(self, ctx, f); + self.clip_paths.pop(); + ctx.pop_clip_path(); + return; + } + if c.blends && (c.isolated || self.nested == 0) { + return self.group_offscreen(ctx, f, c, draw); + } + ctx.set_transform(f.view); + if let Some((_, rule)) = c.clip { + ctx.set_fill_rule(fill_rule(rule)); + } + ctx.push_layer(c.clip.map(|(p, _)| p), Some(blend_mode(c.blend)), Some(c.opacity), c.mask, None); + self.nested += 1; + // The layer starts from transparency: no backdrop for the content. + let backdrop = self.backdrop.take(); + draw(self, ctx, f); + self.backdrop = backdrop; + self.nested -= 1; + ctx.pop_layer(); + } + + fn group_offscreen(&mut self, ctx: &mut RenderContext, f: &Frame, c: Composite, draw: &mut Content) { + let Composite { clip, blend, opacity, mask, isolated, bounds, .. } = c; + let Some(r) = region(ctx, f, bounds) else { return }; + let view = Affine::translate((-(r.x as f64), -(r.y as f64))) * f.view; + let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, r.w as f64, r.h as f64)); + let sub = Frame { mt: false, view, visible, ..*f }; + let backdrop = (!isolated).then(|| self.flatten(ctx, r)); + let content = self.offscreen(r, &sub, backdrop.clone(), draw); + let at = (r.x as f64, r.y as f64); + let push = |ctx: &mut RenderContext, mode: BlendMode, mask: Option| { + ctx.set_transform(f.view); + if let Some((_, rule)) = clip { + ctx.set_fill_rule(fill_rule(rule)); + } + ctx.push_layer(clip.map(|(p, _)| p), Some(mode), Some(opacity), mask, None); + }; + match backdrop { + // The backdrop under the content, mixed with it by opacity, mask and clip: take that + // share of the backdrop out, then add the same share of the content. + Some(_) if blend == Blend::Normal => { + push(ctx, BlendMode::new(Mix::Normal, Compose::DestOut), mask.clone()); + ctx.set_transform(Affine::IDENTITY); + ctx.set_paint(peniko::Color::BLACK); + ctx.fill_rect(&Rect::new(at.0, at.1, at.0 + r.w as f64, at.1 + r.h as f64)); + ctx.pop_layer(); + push(ctx, BlendMode::new(Mix::Normal, Compose::Plus), mask); + draw_pixmap(ctx, Arc::new(content), at); + } + // A blend mode applies to the group as an object of its own: take the backdrop's share + // out of the content (it needs the content's own coverage), then blend the rest. + Some(b) => { + let alone = self.offscreen(r, &sub, None, draw); + push(ctx, blend_mode(blend), mask); + draw_pixmap(ctx, Arc::new(remove_backdrop(content, &alone, &b)), at); + } + None => { + push(ctx, blend_mode(blend), mask); + draw_pixmap(ctx, Arc::new(content), at); + } + } + ctx.pop_layer(); + } + + /// Render what `ctx` holds so far, and start it again from that picture (so later drawing sees + /// it unchanged). Returns the pixels of `r`. + fn flatten(&mut self, ctx: &mut RenderContext, r: Region) -> Arc { + ctx.flush(); + let mut all = Pixmap::new(ctx.width(), ctx.height()); + ctx.render(&mut all, &mut self.resources); + ctx.reset(); + let crop = if (r.x, r.y, r.w, r.h) == (0, 0, all.width(), all.height()) { + None + } else { + let mut out = Pixmap::new(r.w, r.h); + let (src_w, w) = (all.width() as usize, r.w as usize); + for row in 0..r.h as usize { + let from = (r.y as usize + row) * src_w + r.x as usize; + out.data_mut()[row * w..(row + 1) * w].copy_from_slice(&all.data()[from..from + w]); + } + Some(Arc::new(out)) + }; + let all = Arc::new(all); + draw_pixmap(ctx, all.clone(), (0.0, 0.0)); + for (path, rule, xf) in &self.clip_paths { + ctx.set_transform(*xf); + ctx.set_fill_rule(fill_rule(*rule)); + ctx.push_clip_path(path); + } + crop.unwrap_or(all) + } + + /// `draw` rendered into a new context of region `r` (frame `sub`), over `backdrop` if any. + fn offscreen(&mut self, r: Region, sub: &Frame, backdrop: Option>, draw: &mut Content) -> Pixmap { + let mut ctx = single_threaded_context(r.w, r.h); + if let Some(b) = &backdrop { + draw_pixmap(&mut ctx, b.clone(), (0.0, 0.0)); + } + let outer = (std::mem::replace(&mut self.nested, 0), std::mem::replace(&mut self.backdrop, backdrop), std::mem::take(&mut self.clip_paths)); + draw(self, &mut ctx, sub); + (self.nested, self.backdrop, self.clip_paths) = outer; + ctx.flush(); + let mut pm = Pixmap::new(r.w, r.h); + ctx.render(&mut pm, &mut self.resources); + pm + } + + /// Run `draw` inside layers (or into another context) it opens itself: groups drawn there can't + /// copy their backdrop, and see none. + pub(crate) fn inside_layer(&mut self, draw: impl FnOnce(&mut Self) -> T) -> T { + self.nested += 1; + let backdrop = self.backdrop.take(); + let out = draw(self); + self.backdrop = backdrop; + self.nested -= 1; + out + } + + /// Whether blending inside `n` reaches its backdrop ([`Node::blends_through`], cached per + /// container). + pub(crate) fn blends_through(&mut self, n: &Node) -> bool { + n.blends_through_with(&mut |c| self.blends_of(c)) + } + + /// [`Self::blends_through`] of the group `children` make up. + pub(crate) fn children_blend(&mut self, children: &[Arc]) -> bool { + Node::children_blend(children, &mut |c| self.blends_of(c)) + } + + fn blends_of(&mut self, a: &Arc) -> bool { + if !a.is_container() { + return a.blends_through(); + } + let key = Arc::as_ptr(a) as usize; + if let Some(e) = self.blends.get_mut(&key) + && Arc::ptr_eq(&e.0, a) + { + e.2 = self.stamp; + return e.1; + } + let v = a.blends_through_with(&mut |c| self.blends_of(c)); + if self.blends.len() > 1024 { + let g = self.stamp; + self.blends.retain(|_, e| g - e.2 <= 3); + } + self.blends.insert(key, (a.clone(), v, self.stamp)); + v + } + + /// Draw knockout element `c` in a layer composited with `compose`: as its knockout shape + /// (`DestOut` erases what the elements below drew there, `DestIn` keeps only that part), or + /// as itself (`Plus` adds it). In a non-isolated knockout group (`backdrop`), the element is + /// laid over the group's backdrop and cut to its shape, so it composites against the art + /// below the group instead of the elements below it. + pub(crate) fn knockout_pass(&mut self, ctx: &mut RenderContext, f: &Frame, c: &Arc, compose: Compose, backdrop: Option<&Arc>) { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, compose)), None, None, None); + self.nested += 1; + if let Some(b) = backdrop { + draw_pixmap(ctx, b.clone(), (0.0, 0.0)); + } + let shape = compose != Compose::Plus && !c.knockout_shape; + self.shape_of = if shape { Arc::as_ptr(c) as usize } else { 0 }; + self.draw_arc(ctx, f, c); + self.shape_of = 0; + if backdrop.is_some() { + self.knockout_pass(ctx, f, c, Compose::DestIn, None); + } + self.nested -= 1; + ctx.pop_layer(); + } +} + +/// Draw `pm` with its top-left corner at pixel `at` (whole pixels: copied exactly). +pub(crate) fn draw_pixmap(ctx: &mut RenderContext, pm: Arc, at: (f64, f64)) { + let (w, h) = (pm.width() as f64, pm.height() as f64); + ctx.set_transform(Affine::translate(at)); + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() }); + ctx.fill_rect(&Rect::new(0.0, 0.0, w, h)); + ctx.set_transform(Affine::IDENTITY); +} + +/// The pixel region of `ctx` that `bounds` (document space) covers, with a margin for +/// antialiasing; `None` when it is off the context. +fn region(ctx: &RenderContext, f: &Frame, bounds: Option) -> Option { + let full = Rect::new(0.0, 0.0, ctx.width() as f64, ctx.height() as f64); + let r = match bounds { + Some(b) => f.view.transform_rect_bbox(b).inflate(2.0, 2.0).intersect(full), + None => full, + }; + let (x0, y0, x1, y1) = (r.x0.floor().max(0.0), r.y0.floor().max(0.0), r.x1.ceil().min(full.x1), r.y1.ceil().min(full.y1)); + (x1 > x0 && y1 > y0).then_some(Region { x: x0 as u16, y: y0 as u16, w: (x1 - x0) as u16, h: (y1 - y0) as u16 }) +} + +/// The group's own colour from `content` (the group drawn over `backdrop`) and `alone` (drawn +/// over transparency, for its coverage): content = group + (1 − group alpha) × backdrop, all +/// premultiplied. +fn remove_backdrop(mut content: Pixmap, alone: &Pixmap, backdrop: &Pixmap) -> Pixmap { + for ((c, a), b) in content.data_mut().iter_mut().zip(alone.data()).zip(backdrop.data()) { + let keep = 255 - a.a as u32; + let sub = |v: u8, bv: u8| (v as u32).saturating_sub((keep * bv as u32 + 127) / 255).min(a.a as u32) as u8; + (c.r, c.g, c.b, c.a) = (sub(c.r, b.r), sub(c.g, b.g), sub(c.b, b.b), a.a); + } + content +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d4181963..9eda6757 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -8,6 +8,7 @@ mod brush_fx; mod fx; +mod group; mod live; mod paint; mod pattern; @@ -23,6 +24,8 @@ use vello_cpu::kurbo; use vello_cpu::peniko::{self, BlendMode, Compose, Mix}; use vello_cpu::{Pixmap, RenderContext, Resources}; +use group::Composite; + pub use effects::stroke::width_outline; pub use live::expand_live; pub use pattern::render_pattern_swatch; @@ -237,6 +240,17 @@ pub struct Renderer { shape_of: usize, /// The art drawn for objects with effects that apply through it (see `fx::has_object_fx`). fx_arts: PtrMap, Arc)>, + /// Layers open around the drawing point in the current context: a non-isolated group can copy + /// its backdrop only when there are none (see [`Self::group`]). + nested: u32, + /// Copy of the backdrop of the non-isolated group being drawn offscreen, in its context's + /// pixels: the elements of a non-isolated knockout group composite against it. + backdrop: Option>, + /// [`Node::blends_through`] per container, keyed like `geom`. + blends: PtrMap, bool, u64)>, + /// Clip paths pushed on the current context (path, rule, transform): starting the context + /// again from a picture of it pushes them again (see [`Self::group`]). + clip_paths: Vec<(BezPath, FillRule, Affine)>, } #[derive(Clone, Copy, Debug, Default)] @@ -287,6 +301,10 @@ impl Renderer { knockout: false, shape_of: 0, fx_arts: PtrMap::default(), + nested: 0, + backdrop: None, + blends: PtrMap::default(), + clip_paths: vec![], } } @@ -327,40 +345,18 @@ impl Renderer { } } self.stamp += 1; - let trim = opts.trim && !doc.artboards.is_empty(); - if trim { + (self.nested, self.backdrop) = (0, None); + self.clip_paths.clear(); + if opts.trim && !doc.artboards.is_empty() { let mut clip = BezPath::new(); for ab in &doc.artboards { clip.extend(ab.rect.path_elements(0.1)); } - ctx.set_transform(view); - ctx.push_clip_layer(&clip); - } - 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); - 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::>()); - } 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(); + let blends = self.children_blend(&doc.layers); + let trim = Composite { clip: Some((&clip, FillRule::NonZero)), blends, ..Default::default() }; + self.group(&mut ctx, &frame, trim, &mut |r, c, fr| r.draw_page(c, fr)); + } else { + self.draw_page(&mut ctx, &frame); } // Drop cache entries not seen for a few frames. let g = self.stamp; @@ -387,6 +383,32 @@ impl Renderer { Rendered { width: w as u32, height: h as u32, pixels } } + /// The page's art: the layers (or the pattern being edited), inside the page group when the + /// page has one. + fn draw_page(&mut self, ctx: &mut RenderContext, f: &Frame) { + if self.draw_pattern_edit(ctx, f) { + return; + } + let doc = f.doc; + // While editing an opacity mask its art is seen only through the mask. + let mask_layer = doc.mask_edit.map(|m| m.layer); + let layers: Vec> = doc.layers.iter().filter(|l| Some(l.id) != mask_layer).cloned().collect(); + // Page Isolated Blending / Page Knockout Group: the page is a group of its own. + let page_group = !f.opts.outline && (doc.page_isolate || doc.page_knockout); + let knockout = page_group && doc.page_knockout; + let mut draw = |r: &mut Self, c: &mut RenderContext, fr: &Frame| { + r.knockout = knockout; + r.draw_children(c, fr, &layers, knockout); + r.knockout = false; + }; + if page_group { + let blends = self.children_blend(&layers); + self.group(ctx, f, Composite { isolated: doc.page_isolate, blends, ..Default::default() }, &mut draw); + } else { + draw(self, ctx, f); + } + } + /// Render one artboard (or any document rect) at `scale` pixels per point, transparent or on white, /// as exported: template layers are left out. pub fn render_region(&mut self, doc: &Document, region: Rect, scale: f64, white: bool) -> Rendered { @@ -415,7 +437,8 @@ 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.knockout, self.nested, self.backdrop) = (false, 0, None); + self.clip_paths.clear(); self.draw_node(&mut ctx, &frame, n, true); ctx.flush(); let mut pm = Pixmap::new(w, w); @@ -508,11 +531,11 @@ impl Renderer { && !f.opts.outline && !self.is_shape(a) { - let mask = self.opacity_mask(f, m, ctx.width(), ctx.height()); - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, None, None, Some(mask), None); - self.draw_arc_body(ctx, f, a); - ctx.pop_layer(); + // A masked object is a non-isolated group of its own. + let mask = Some(self.opacity_mask(f, m, ctx.width(), ctx.height())); + let blends = a.blend != vectorcraft_color::BlendMode::Normal || (!a.isolate && self.blends_through(a)); + let bounds = if blends { self.bounds_of(a) } else { None }; + self.group(ctx, f, Composite { mask, blends, bounds, ..Default::default() }, &mut |r, c, fr| r.draw_arc_body(c, fr, a)); return; } self.draw_arc_body(ctx, f, a); @@ -522,9 +545,10 @@ impl Renderer { 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); + let outer = + (std::mem::take(&mut self.knockout), std::mem::take(&mut self.nested), self.backdrop.take(), std::mem::take(&mut self.clip_paths)); self.draw_node(&mut mctx, &Frame { mt: false, ..*f }, &m.art, true); - self.knockout = knockout; + (self.knockout, self.nested, self.backdrop, self.clip_paths) = outer; mctx.flush(); let mut pm = Pixmap::new(w, h); mctx.render(&mut pm, &mut self.resources); @@ -598,45 +622,42 @@ impl Renderer { return self.draw_object_fx(ctx, f, &Arc::new(n.clone()), false); } 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 { 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). + // Whether this container's children knock each other out. 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; + let blends = self.blends_through(n); + let bounds = if blends { self.node_bounds(n) } else { None }; + let comp = Composite { blend: n.blend, opacity, isolated: n.isolate, blends, bounds, ..Default::default() }; + self.group(ctx, f, comp, &mut |r, c, fr| r.draw_content(c, fr, n, knockout)); + } else { + self.draw_content(ctx, f, n, knockout); } + self.knockout = enclosing; + self.stats.drawn += 1; + } + + /// What `n` draws inside its transparency group (`knockout`: its children knock each other out). + fn draw_content(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, knockout: bool) { 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); - } - } - NodeKind::Group { children, clip: true } if outline => { + NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => self.draw_children(ctx, f, children, knockout), + NodeKind::Group { children, clip: true } if f.opts.outline => { for c in children { self.draw_node(ctx, f, c, false); } } NodeKind::Group { children, clip: true } => { - // Nothing to clip by hides the clipped art (as in the SVG and PDF output). + // Nothing to clip by hides the clipped art (as in the SVG and PDF output). A clip + // group doesn't isolate: blending inside it reaches the art below. if let Some((clip, rest)) = children.split_first() && let Some(region) = self.clip_of(clip) { - ctx.set_transform(f.view); - ctx.set_fill_rule(fill_rule(region.1)); - ctx.push_clip_layer(®ion.0); - if knockout { - self.draw_knockout(ctx, f, rest); - } else { - for c in rest { - self.draw_arc(ctx, f, c); - } - } - ctx.pop_layer(); + let blends = self.blends_through(n); + let bounds = if blends { Some(region.0.bounding_box()) } else { None }; + let comp = Composite { clip: Some((®ion.0, region.1)), blends, bounds, ..Default::default() }; + self.group(ctx, f, comp, &mut |r, c, fr| r.draw_children(c, fr, rest, knockout)); } } NodeKind::Path { path, rule, guide, .. } => { @@ -667,10 +688,25 @@ 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 group: as the elements of a knockout group when `knockout`. + fn draw_children(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc], knockout: bool) { + if knockout { + self.draw_knockout(ctx, f, children); + } else { + for c in children { + self.draw_arc(ctx, f, c); + } + } + } + + /// Cull bounds of `n`: cached when it is a node of the tree being drawn. + fn node_bounds(&mut self, n: &Node) -> Option { + match self.geom.get(&(n as *const Node as usize)) { + // The entry keeps its node alive, so the address is that node's. + Some(e) if std::ptr::eq(Arc::as_ptr(&e.node), n) => e.bounds, + _ => fx::cull_bounds(n), } } @@ -678,21 +714,18 @@ impl Renderer { /// 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. + /// over the elements below it (the art below the group when the group is non-isolated and + /// drawn over a copy of it, see [`Self::group`]). fn draw_knockout(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc]) { + let backdrop = self.backdrop.take(); 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(); - } + self.knockout_pass(ctx, f, c, Compose::DestOut, None); + self.knockout_pass(ctx, f, c, Compose::Plus, backdrop.as_ref()); } + self.backdrop = backdrop; } /// Whether `n` is the knockout element being drawn as its shape (see [`Self::draw_knockout`]). @@ -1106,6 +1139,8 @@ mod tests_blend; #[cfg(test)] mod tests_clip; #[cfg(test)] +mod tests_isolation; +#[cfg(test)] mod tests_knockout; #[cfg(test)] mod tests_objectfx; diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index d4e7ed7a..1d1a1f7e 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -11,11 +11,11 @@ use std::sync::Arc; use vectorcraft_doc::live::{self, GradientMesh, MeshQuad}; use vectorcraft_doc::{Node, NodeKind}; -use vectorcraft_geom::{Affine, BezPath, PathData, Point}; +use vectorcraft_geom::{BezPath, PathData, Point}; use vello_cpu::RenderContext; use vello_cpu::peniko; -use crate::{Frame, Renderer, blend_mode, text_geom}; +use crate::{Frame, Renderer, text_geom}; type Expanded = Arc>>; type MeshEntry = (Arc, Arc>, u64); @@ -113,14 +113,13 @@ impl Renderer { let stamp = self.stamp; self.live.tick(stamp); 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(opacity), None, None); - } - self.draw_live_body(ctx, f, a, true); - if layered { - ctx.pop_layer(); + if !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_color::BlendMode::Normal || a.isolate) { + let blends = self.blends_through(a); + let bounds = if blends { self.bounds_of(a) } else { None }; + let comp = crate::group::Composite { blend: a.blend, opacity, isolated: a.isolate, blends, bounds, ..Default::default() }; + self.group(ctx, f, comp, &mut |r, c, fr| r.draw_live_body(c, fr, a, true)); + } else { + self.draw_live_body(ctx, f, a, true); } self.stats.drawn += 1; } diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index 714696a5..8ca8fc09 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -183,17 +183,15 @@ impl Renderer { let children: Vec> = layer.children().cloned().unwrap_or_default(); if layer.visible && !children.is_empty() { let dim = (def.dim_copies / 100.0).clamp(0.0, 1.0); - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, None, Some(dim), None, None); - for o in def.preview_offsets() { - let view = f.view * Affine::translate(o); - let visible = f.visible - o; - let frame = Frame { view, visible, ..*f }; - for c in &children { - self.draw_arc(ctx, &frame, c); + let comp = crate::group::Composite { opacity: dim, ..Default::default() }; + self.group(ctx, f, comp, &mut |r, c, fr| { + for o in def.preview_offsets() { + let frame = Frame { view: fr.view * Affine::translate(o), visible: fr.visible - o, ..*fr }; + for a in &children { + r.draw_arc(c, &frame, a); + } } - } - ctx.pop_layer(); + }); } self.draw_arc(ctx, f, layer); if def.show_tile_edge { diff --git a/crates/render/src/tests_isolation.rs b/crates/render/src/tests_isolation.rs new file mode 100644 index 00000000..5494eefb --- /dev/null +++ b/crates/render/src/tests_isolation.rs @@ -0,0 +1,215 @@ +//! 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)) +} + +/// 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 +} + +/// 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, 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 = rect(d, Rect::new(30.0, 30.0, 70.0, 70.0), [0.0; 3]); + 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 = rect(d, Rect::new(30.0, 30.0, 70.0, 70.0), [0.0; 3]); + 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.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"); +} diff --git a/docs/mcp.md b/docs/mcp.md index 102d6b40..d0291c19 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -203,8 +203,12 @@ neutral, the default). In a knockout group each child hides what it covers of th pass the enclosing group's setting to their children, off groups never knock out. `knockoutShape: true` makes the object's opacity and opacity mask scale how much it knocks out. `transparency.togglePageKnockoutGroup` and `transparency.togglePageIsolatedBlending` (`{value?}`) treat the whole page as a knockout or isolated group; they are -saved with the document and undoable, and `transparency.info` reports them. Knockout groups render as isolated groups; -PDF and SVG write them as soft-masked groups with the same look (the PDF export reports a warning). +saved with the document and undoable, and `transparency.info` reports them. PDF and SVG write knockout groups as +soft-masked groups with the same look (the PDF export reports a warning). + +Groups are not isolated unless Isolate Blending is on: a blend mode inside a group with opacity, a blend mode, an +opacity mask, knockout or a clip reaches the art below the group, on screen and in PDF (a knockout group's elements +then composite against the art below it). SVG has no non-isolated groups, so there such groups stay isolated. ## Clipping masks