Merge pull request #2657 from dotCooCoo/fix/keep-shadow-images

Render: keep drop shadow and outer glow images while an object moves
This commit is contained in:
FredApps authored and GitHub committed 2026-11-12 18:06:07 +01:00
commit 04597b0652
3 files changed
+440 -21

No files matched your search

+4 -1
View File
@@ -15,6 +15,7 @@ impl Renderer {
drawing_items: Vec::new(),
cmyk_view: None,
cmyk_ran: false,
kept: Default::default(),
#[cfg(test)]
cmyk_passes: 0,
#[cfg(test)]
@@ -28,11 +29,12 @@ impl Renderer {
}
}
/// Drop cached glyph paths and the CMYK viewport (rebuilt on demand).
/// Drop cached glyph paths, effect images and the CMYK viewport (rebuilt on demand).
pub fn clear_caches(&mut self) {
self.cmyk_view = None;
self.glyphs.clear();
self.stories.clear();
self.kept.clear();
}
/// Number of text frames whose glyph paths are cached.
@@ -46,6 +48,7 @@ impl Renderer {
let (width, height) = size;
RICH_BLACK.with(|r| r.set(opts.rich_black));
PLATE.with(|p| p.set(opts.plate));
self.kept.begin(doc);
if let Some(hit) = self.cmyk_view.as_ref().filter(|entry| entry.matches(doc, spreads, width, height, view, opts, self.threads)) {
let image = hit.image();
self.stats = hit.stats();
+434 -20
View File
@@ -17,13 +17,16 @@
//! - Feather: the object is masked by its shape inset by half the width and blurred, so the
//! edge fades from ~0 at the path to full strength `width` inside.
use designcraft_doc::Item;
use std::collections::HashMap;
use std::sync::Arc;
use designcraft_doc::{Asset, AssetId, DocSettings, Document, InkManager, Item, ItemId, Layer, StrokeStyleDef};
use designcraft_geom::{Affine, BezPath, Rect, Shape, Vec2};
use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
use vello_cpu::peniko::{self, BlendMode, Compose, Mix};
use vello_cpu::{RenderContext, kurbo};
use vello_cpu::{Pixmap, RenderContext, kurbo};
use crate::{Frame, Renderer, color_of};
use crate::{Frame, RenderOptions, Renderer, color_of};
use designcraft_compose::ShownOn;
/// Shadow offset (spread units) for an InDesign angle (degrees, light direction) and distance.
@@ -113,16 +116,25 @@ impl Renderer {
let mut below = vec![];
if e.outer_glow.on {
let g = &e.outer_glow;
below.push((Vec2::ZERO, g.size, g.spread, g.color.clone(), g.opacity, g.mode));
below.push((Slot::OuterGlow, Vec2::ZERO, g.size, g.spread, g.color.clone(), g.opacity, g.mode));
}
if e.drop_shadow.on {
let d = &e.drop_shadow;
below.push((offset(doc.light_angle(d.angle, d.global_light), d.distance), d.size, d.spread, d.color.clone(), d.opacity, d.mode));
below.push((
Slot::DropShadow,
offset(doc.light_angle(d.angle, d.global_light), d.distance),
d.size,
d.spread,
d.color.clone(),
d.opacity,
d.mode,
));
}
for (off, size, pct, color, opacity, mode) in below {
for (slot, off, size, pct, color, opacity, mode) in below {
let Some(c) = doc.resolve_color(&color, 1.0) else { continue };
let (sigma, grow) = split(size, pct);
let filter = shadow(sigma, color_of(&c, opacity.clamp(0.0, 1.0)));
let paint = color_of(&c, opacity.clamp(0.0, 1.0));
let filter = shadow(sigma, paint);
// The offset moves the geometry, not the filter: vello drops layer content that lies
// entirely outside the viewport before filtering.
let r = (reach + off).inflate(grow, grow);
@@ -132,7 +144,8 @@ impl Renderer {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(BlendMode::new(mix, Compose::SrcOver)), None, None, None);
}
self.with_filters(ctx, f, r, sigma, None, |me, c, fr| {
let look = Look { slot, off, sigma, grow, paint: paint.components };
self.with_kept_filters(ctx, f, it, xf, page, look, r, |me, c, fr| {
let moved = Frame { view: fr.view * Affine::translate(off), ..*fr };
c.set_transform(moved.view);
c.push_layer(None, None, None, None, Some(filter));
@@ -448,23 +461,252 @@ impl Renderer {
return;
}
let Some(o) = offscreen(f.view.transform_rect_bbox(reach), spread, ctx.width(), ctx.height()) else { return };
// Clip where the result is at full resolution: offscreen unless that is reduced.
let (inner, outer) = if o.scale > 1.0 { (None, clip) } else { (clip, None) };
let pm = self.draw_offscreen(f, &o, inner, draw);
push_clip(ctx, f, outer);
place(ctx, o.x0, o.y0, o.scale, Arc::new(pm));
pop_clip(ctx, outer);
}
/// `draw` into the single-threaded offscreen image `o`.
fn draw_offscreen(
&mut self,
f: &Frame,
o: &Offscreen,
clip: Option<(&BezPath, Affine)>,
draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame),
) -> Pixmap {
let mut off = RenderContext::new_with(o.width, o.height, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
let view = Affine::scale(1.0 / o.scale) * Affine::translate((-o.x0, -o.y0)) * f.view;
let shifted = Frame { mt: false, view, px: f.px * o.scale, ..*f };
// Clip where the result is at full resolution: offscreen unless that is reduced.
let (inner, outer) = if o.scale > 1.0 { (None, clip) } else { (clip, None) };
push_clip(&mut off, &shifted, inner);
push_clip(&mut off, &shifted, clip);
draw(self, &mut off, &shifted);
pop_clip(&mut off, inner);
pop_clip(&mut off, clip);
off.flush();
let mut pm = vello_cpu::Pixmap::new(o.width, o.height);
let mut pm = Pixmap::new(o.width, o.height);
off.render(&mut pm, &mut self.resources);
push_clip(ctx, f, outer);
ctx.set_transform(Affine::translate((o.x0, o.y0)) * Affine::scale(o.scale));
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, f64::from(o.width), f64::from(o.height)));
pop_clip(ctx, outer);
ctx.set_transform(Affine::IDENTITY);
pm
}
/// [`Renderer::with_filters`] for a drop shadow or an outer glow of `it`, keeping its image
/// between renders. While the object, its effect, the view's scale and rotation, the render
/// options and the document's colors, styles, layers and images stay the same, a move of the object
/// or of the view by whole device pixels places the kept image again instead of drawing and
/// blurring it. Only multithreaded contexts (the canvas) keep images, and only of objects
/// without text or nested items.
#[allow(clippy::too_many_arguments)]
fn with_kept_filters(
&mut self,
ctx: &mut RenderContext,
f: &Frame,
it: &Item,
xf: Affine,
page: ShownOn,
look: Look,
reach: Rect,
draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame),
) {
let keepable = f.mt && it.text_frame().is_none() && it.children().is_empty() && it.media.is_none();
if !keepable {
self.with_filters(ctx, f, reach, look.sigma, None, draw);
return;
}
let [a, b, c, d, e, g] = (f.view * xf).as_coeffs();
let [va, vb, vc, vd, _, _] = f.view.as_coeffs();
let (linear, anchor) = ([a, b, c, d, va, vb, vc, vd], Vec2::new(e, g));
let plate = crate::PLATE.with(std::cell::Cell::get);
let rich_black = crate::color::RICH_BLACK.with(std::cell::Cell::get);
let key = (it.id, page, look.slot, plate);
let tick = self.kept.tick;
if let Some(k) = self.kept.entries.get_mut(&key)
&& k.look == look
&& k.linear == linear
&& k.px == f.px
&& k.rich_black == rich_black
&& k.opts == *f.opts
&& (Item { xf: k.item.xf, ..it.clone() }) == k.item
{
let shift = anchor - k.anchor;
let (dx, dy) = (shift.x.round(), shift.y.round());
if (shift.x - dx).abs() < 1e-3 && (shift.y - dy).abs() < 1e-3 {
k.used = tick;
let (x0, y0, scale, image) = (k.x0 + dx, k.y0 + dy, k.scale, k.image.clone());
place(ctx, x0, y0, scale, image);
return;
}
}
// The whole effect, not only the part in view, so that it can be placed anywhere later.
let spread = bound(look.sigma).max(0.0) * 3.0 / f.px + 2.0;
let whole = f.view.transform_rect_bbox(reach).inflate(spread, spread);
let covers = |o: &Offscreen| f64::from(o.width) * o.scale >= whole.x1 - o.x0 && f64::from(o.height) * o.scale >= whole.y1 - o.y0;
let Some(o) = fit(whole, spread).filter(|o| usize::from(o.width) * usize::from(o.height) <= KEEP_MAX_PIXELS && covers(o)) else {
self.with_filters(ctx, f, reach, look.sigma, None, draw);
return;
};
let image = Arc::new(self.draw_offscreen(f, &o, None, draw));
#[cfg(test)]
{
self.kept.drawn += 1;
}
place(ctx, o.x0, o.y0, o.scale, image.clone());
let kept = Kept {
item: it.clone(),
linear,
anchor,
look,
opts: f.opts.clone(),
px: f.px,
rich_black,
x0: o.x0,
y0: o.y0,
scale: o.scale,
image,
used: tick,
};
self.kept.insert(f.doc, key, kept);
}
}
/// Paint `image` with its top-left corner at device pixel (`x0`, `y0`), each of its pixels
/// covering `scale` device pixels.
fn place(ctx: &mut RenderContext, x0: f64, y0: f64, scale: f64, image: Arc<Pixmap>) {
let (w, h) = (f64::from(image.width()), f64::from(image.height()));
ctx.set_transform(Affine::translate((x0, y0)) * Affine::scale(scale));
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler: peniko::ImageSampler::default() });
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h));
ctx.set_transform(Affine::IDENTITY);
}
/// Pixels one kept effect image may have; a larger one is drawn each time.
const KEEP_MAX_PIXELS: usize = 4 << 20;
/// Pixels all kept effect images may have together (128 MiB).
const KEEP_BUDGET: usize = 32 << 20;
/// Renders an image no render has placed is kept for.
const KEEP_RENDERS: u64 = 256;
/// The effect below an object a kept image is of.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum Slot {
OuterGlow,
DropShadow,
}
/// What an effect image looks like apart from the object: its offset (spread space), blur,
/// growth and colour.
#[derive(Clone, Copy, PartialEq)]
struct Look {
slot: Slot,
off: Vec2,
sigma: f64,
grow: f64,
paint: [f32; 4],
}
/// One kept effect image and what it was drawn from.
struct Kept {
/// The object, with the transform it had.
item: Item,
/// The linear parts of the object's spread → device transform and of the view (the effect offset turns with the view).
linear: [f64; 8],
/// The device position of the object's origin.
anchor: Vec2,
look: Look,
opts: RenderOptions,
px: f64,
rich_black: bool,
x0: f64,
y0: f64,
scale: f64,
image: Arc<Pixmap>,
used: u64,
}
/// What every kept image depends on besides its object.
struct Deps {
colors: designcraft_color::cms::ColorSettings,
swatches: Vec<designcraft_color::Swatch>,
settings: DocSettings,
inks: InkManager,
stroke_styles: Vec<StrokeStyleDef>,
layers: Vec<Layer>,
/// Held, so that an asset changed in place or a new one at the same address is a new `Arc`.
assets: Vec<(AssetId, Arc<Asset>)>,
}
impl Deps {
fn of(doc: &Document) -> Self {
Deps {
colors: designcraft_color::cms::active_settings(),
swatches: doc.swatches.clone(),
settings: doc.settings.clone(),
inks: doc.inks.clone(),
stroke_styles: doc.stroke_styles.clone(),
layers: doc.layers.clone(),
assets: doc.assets.iter().map(|(id, a)| (*id, a.clone())).collect(),
}
}
fn same(&self, doc: &Document) -> bool {
self.colors == designcraft_color::cms::active_settings()
&& self.swatches == doc.swatches
&& self.settings == doc.settings
&& self.inks == doc.inks
&& self.stroke_styles == doc.stroke_styles
&& self.layers == doc.layers
&& self.assets.len() == doc.assets.len()
&& self.assets.iter().zip(&doc.assets).all(|((a, p), (b, x))| a == b && Arc::ptr_eq(p, x))
}
}
/// Drop shadows and outer glows kept between renders ([`Renderer::with_kept_filters`]), at most
/// [`KEEP_BUDGET`] pixels; the images used longest ago go first, and an image no render placed
/// in [`KEEP_RENDERS`] renders goes too.
#[derive(Default)]
pub(crate) struct KeptEffects {
deps: Option<Deps>,
entries: HashMap<(ItemId, ShownOn, Slot, Option<u8>), Kept>,
pixels: usize,
tick: u64,
/// Effect images drawn for keeping (tests).
#[cfg(test)]
pub(crate) drawn: usize,
}
impl KeptEffects {
/// Start a render of `doc`: drop every image when what they depend on changed.
pub(crate) fn begin(&mut self, doc: &Document) {
self.tick += 1;
if !self.deps.as_ref().is_some_and(|d| d.same(doc)) {
self.clear();
return;
}
let tick = self.tick;
let before = self.entries.len();
self.entries.retain(|_, k| k.used + KEEP_RENDERS >= tick);
if self.entries.len() != before {
self.pixels = self.entries.values().map(|k| k.image.width() as usize * k.image.height() as usize).sum();
}
}
pub(crate) fn clear(&mut self) {
self.entries.clear();
self.pixels = 0;
self.deps = None;
}
fn insert(&mut self, doc: &Document, key: (ItemId, ShownOn, Slot, Option<u8>), kept: Kept) {
if self.deps.is_none() {
self.deps = Some(Deps::of(doc));
}
let size = |k: &Kept| k.image.width() as usize * k.image.height() as usize;
self.pixels += size(&kept);
if let Some(old) = self.entries.insert(key, kept) {
self.pixels -= size(&old);
}
while self.pixels > KEEP_BUDGET {
let Some(oldest) = self.entries.iter().min_by_key(|(_, k)| k.used).map(|(key, _)| *key) else { break };
if let Some(old) = self.entries.remove(&oldest) {
self.pixels -= size(&old);
}
}
}
}
@@ -502,7 +744,15 @@ struct Offscreen {
/// [`MAX_BLUR_SPREAD`], at most `MAX_SIDE` a side. `None` when none of it shows.
fn offscreen(reach: Rect, spread: f64, width: u16, height: u16) -> Option<Offscreen> {
let screen = Rect::new(0.0, 0.0, f64::from(width), f64::from(height)).inflate(spread, spread);
let r = reach.inflate(spread, spread).intersect(screen);
fit(reach.inflate(spread, spread).intersect(screen), spread)
}
/// The offscreen image covering `r` (device pixels) for a blur of `spread`, or `None` when it is
/// empty, not finite or more than `MAX_SIDE` a side.
fn fit(r: Rect, spread: f64) -> Option<Offscreen> {
if !(r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite()) {
return None;
}
let (x0, y0) = (r.x0.floor(), r.y0.floor());
let scale = (spread / MAX_BLUR_SPREAD).max(1.0);
let side = |len: f64| (len / scale).ceil().min(crate::MAX_SIDE as f64);
@@ -607,4 +857,168 @@ mod tests {
// The transparent half shows the page: no shadow painted there.
assert!(lum(150) > 700, "nothing over the transparent part: {}", lum(150));
}
/// A document with one image frame casting a drop shadow, and the frame's id.
fn shadowed_image() -> (designcraft_doc::Document, designcraft_doc::ItemId) {
use designcraft_doc::build::NewDocument;
use designcraft_doc::{Document, SpreadRef};
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let img = image::RgbaImage::from_fn(64, 48, |x, y| image::Rgba([(x * 4) as u8, (y * 5) as u8, 128, 255]));
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let aid = designcraft_doc::AssetId(d.alloc());
d.assets.insert(
aid,
Arc::new(designcraft_doc::Asset {
id: aid,
name: "photo.png".into(),
mime: "image/png".into(),
data: Arc::new(png),
pixels: Some((64, 48)),
..Default::default()
}),
);
let id = designcraft_doc::ItemId(d.alloc());
let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 160.0, 120.0)));
it.xf = Affine::translate((100.0, 100.0));
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
layer_options: None,
asset: aid,
size: (160.0, 120.0),
xf: Affine::IDENTITY,
clip: None,
auto_fit: Default::default(),
fit_align: 4,
crop: [0.0; 4],
wrap: Default::default(),
fill: Default::default(),
});
let s = &mut it.effects.drop_shadow;
(s.on, s.color, s.opacity, s.size, s.distance) = (true, "[Black]".into(), 0.75, 12.0, 9.0);
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
(d, id)
}
/// Moving a shadowed frame, or the view, by whole pixels places the kept shadow again: the
/// shadow is drawn and blurred once, and every frame matches a render that draws it afresh.
/// A new effect setting, zoom, colour or a move by part of a pixel draws it again.
#[test]
fn a_moved_shadow_is_drawn_once_and_placed_again() {
use designcraft_doc::SpreadRef;
let (mut d, id) = shadowed_image();
let cache = crate::Cache::new();
let placed = [crate::Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }];
let opts = crate::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut kept = Renderer::new();
kept.threads = 2;
let render =
|r: &mut Renderer, d: &designcraft_doc::Document, size: (u32, u32), view: Affine| r.render(d, &cache, &placed, size, view, &opts);
let fresh = |d: &designcraft_doc::Document, size: (u32, u32), view: Affine| {
let mut r = Renderer::new();
r.threads = 2;
r.render(d, &crate::Cache::new(), &placed, size, view, &opts)
};
let close = |a: &crate::Rendered, b: &crate::Rendered, what: &str| {
let worst = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0);
assert!(a.pixels.len() == b.pixels.len() && worst <= 1, "{what}: off by {worst}");
};
let view = Affine::scale(1.5);
close(&render(&mut kept, &d, (500, 450), view), &fresh(&d, (500, 450), view), "first render");
assert_eq!(kept.kept.drawn, 1);
for step in 1..=4 {
let it = d.item_mut(id).unwrap();
it.xf = Affine::translate((2.0, 4.0 / 3.0)) * it.xf;
close(&render(&mut kept, &d, (500, 450), view), &fresh(&d, (500, 450), view), "after a move");
// A patch of the view, as the canvas repaints after an edit.
let patch = Affine::translate((-150.0, -140.0)) * view;
close(&render(&mut kept, &d, (120, 90), patch), &fresh(&d, (120, 90), patch), "a patch");
assert_eq!(kept.kept.drawn, 1, "step {step}: placed again, not drawn");
}
// Panning by whole pixels.
let panned = Affine::translate((-37.0, 11.0)) * view;
close(&render(&mut kept, &d, (500, 450), panned), &fresh(&d, (500, 450), panned), "panned");
assert_eq!(kept.kept.drawn, 1);
// Part of a pixel, another zoom, another size, another colour: drawn again.
let it = d.item_mut(id).unwrap();
it.xf = Affine::translate((0.25, 0.0)) * it.xf;
render(&mut kept, &d, (500, 450), view);
assert_eq!(kept.kept.drawn, 2);
render(&mut kept, &d, (500, 450), Affine::scale(2.0));
assert_eq!(kept.kept.drawn, 3);
d.item_mut(id).unwrap().effects.drop_shadow.size = 20.0;
close(&render(&mut kept, &d, (500, 450), view), &fresh(&d, (500, 450), view), "a new size");
assert_eq!(kept.kept.drawn, 4);
d.swatches.push(designcraft_color::Swatch::color("Extra", designcraft_color::Color::rgb8(1, 2, 3)));
render(&mut kept, &d, (500, 450), view);
assert_eq!(kept.kept.drawn, 5, "a swatch change drops kept images");
// An image changed in place (its only document owner editing it).
let asset = d.assets.values_mut().next().unwrap();
Arc::make_mut(asset).name = "edited.png".into();
render(&mut kept, &d, (500, 450), view);
assert_eq!(kept.kept.drawn, 6, "an edited image drops kept images");
// Single-threaded renders (exports) draw as before and keep nothing.
let mut single = Renderer::new();
single.threads = 0;
render(&mut single, &d, (500, 450), view);
assert_eq!(single.kept.drawn, 0);
}
/// In the CMYK blend space view the ink passes keep an image per plate, placed again on a move.
#[test]
fn the_ink_passes_keep_their_shadows() {
use designcraft_doc::SpreadRef;
let (mut d, id) = shadowed_image();
d.settings.blend_space = designcraft_doc::BlendSpace::Cmyk;
let cache = crate::Cache::new();
let placed = [crate::Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }];
let opts = crate::RenderOptions { blend_space_view: true, background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
r.threads = 2;
r.render(&d, &cache, &placed, (500, 450), Affine::IDENTITY, &opts);
// One for each ink pass, and one for the ordinary drawing around their area.
assert_eq!(r.kept.drawn, 3);
for _ in 0..3 {
let it = d.item_mut(id).unwrap();
it.xf = Affine::translate((3.0, 1.0)) * it.xf;
r.render(&d, &cache, &placed, (500, 450), Affine::IDENTITY, &opts);
}
assert_eq!(r.kept.drawn, 3);
// Renders served from the CMYK viewport snapshot count towards the expiry too.
assert!(!r.kept.entries.is_empty());
for _ in 0..=KEEP_RENDERS {
r.render(&d, &cache, &placed, (500, 450), Affine::IDENTITY, &opts);
}
assert_eq!(r.kept.drawn, 3, "the snapshot served every render");
assert!(r.kept.entries.is_empty(), "images unused for {KEEP_RENDERS} renders are dropped");
}
/// A view turned 90° with the frame turned back by 90° leaves the frame where it was on the
/// device, but turns the shadow's offset: the shadow is drawn again.
#[test]
fn a_turned_view_draws_the_shadow_again() {
use designcraft_doc::SpreadRef;
let (mut d, id) = shadowed_image();
let cache = crate::Cache::new();
let placed = [crate::Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }];
let opts = crate::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut kept = Renderer::new();
kept.threads = 2;
let view = Affine::scale(1.5);
kept.render(&d, &cache, &placed, (500, 450), view, &opts);
assert_eq!(kept.kept.drawn, 1);
// Exact quarter turns, so that the frame's spread → device transform stays the same.
let (turn, back) = (Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]), Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, 0.0]));
let it = d.item_mut(id).unwrap();
it.xf = back * it.xf;
assert_eq!((view * turn * it.xf).as_coeffs(), (view * Affine::translate((100.0, 100.0))).as_coeffs());
let turned = view * turn;
let image = kept.render(&d, &cache, &placed, (500, 450), turned, &opts);
let mut r = Renderer::new();
r.threads = 2;
let fresh = r.render(&d, &crate::Cache::new(), &placed, (500, 450), turned, &opts);
let worst = image.pixels.iter().zip(&fresh.pixels).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0);
assert!(worst <= 1, "off by {worst}");
assert_eq!(kept.kept.drawn, 2);
}
}
+2
View File
@@ -155,6 +155,8 @@ pub struct Renderer {
cmyk_view: Option<blendspace::CachedView>,
/// Whether the last render ran the CMYK ink passes (only then is its view worth keeping).
cmyk_ran: bool,
/// Drop shadow and outer glow images kept between renders.
kept: fx::KeptEffects,
#[cfg(test)]
cmyk_passes: usize,
/// Pixels the last ink pass covered (tests).