mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 21:27:00 -06:00
Merge pull request #4780 from dotCooCoo/fix/render-mask-image-failure
Render: leave out an object whose opacity mask lost an image
This commit is contained in:
@@ -43,11 +43,12 @@ pub(super) enum KeptImage {
|
||||
/// The image on its own grid, as [`Renderer::rasterize_image`] makes it, and how many images
|
||||
/// in the content failed to decode while it was made ([`crate::paint::decode_failures`]):
|
||||
/// each draw of the image from here counts them again ([`crate::paint::add_decode_failures`]).
|
||||
Grid(std::rc::Rc<(Vec<u8>, usize, usize)>, u64),
|
||||
/// The last value records whether an image was left out while making the grid.
|
||||
Grid(std::rc::Rc<(Vec<u8>, usize, usize)>, u64, bool),
|
||||
/// An image over [`Renderer::rasterize_image`]'s limits: the content's raster with the
|
||||
/// Rasterize applied, as the first draw made it, and the transform from its pixels to
|
||||
/// document space.
|
||||
Raster(Arc<vello_cpu::Pixmap>, Affine),
|
||||
/// document space. The remaining values record decode failures and whether an image was left out.
|
||||
Raster(Arc<vello_cpu::Pixmap>, Affine, u64, bool),
|
||||
}
|
||||
|
||||
/// A cached shadow / glow raster: premultiplied, already tinted and faded, positioned relative to
|
||||
@@ -71,6 +72,12 @@ pub(crate) struct ShadowEntry {
|
||||
/// Made for the object before it moved ([`Renderer::move_shadow`]), or made over rasters
|
||||
/// made before their objects moved: only a frame rendered while interacting draws it.
|
||||
pub(super) moved: bool,
|
||||
/// How many images in the silhouette failed to decode while the raster was made, as
|
||||
/// [`PixelEntry::failed`].
|
||||
pub(super) failed: u64,
|
||||
/// Whether an image in the silhouette failed to draw while the raster was made, as
|
||||
/// [`PixelEntry::image_left_out`].
|
||||
pub(super) image_left_out: bool,
|
||||
pub(crate) stamp: u64,
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,11 @@ impl Renderer {
|
||||
) {
|
||||
let chain: Vec<&RasterFx> = rfx.iter().filter(content_changing).collect();
|
||||
// When the first draw of this content kept its raster, that raster is drawn.
|
||||
if let Some(KeptImage::Raster(image, place)) = content.kept.and_then(|k| k.image.borrow().clone()) {
|
||||
if let Some(KeptImage::Raster(image, place, failed, image_left_out)) = content.kept.and_then(|k| k.image.borrow().clone()) {
|
||||
crate::paint::add_decode_failures(failed);
|
||||
if image_left_out {
|
||||
self.image_left_out = self.image_left_out.map(|_| true);
|
||||
}
|
||||
draw_placed(ctx, image, f.view * place);
|
||||
return;
|
||||
}
|
||||
@@ -131,6 +135,9 @@ impl Renderer {
|
||||
let p = f.view * e.anchor;
|
||||
let (image, at, k) = (e.image.clone(), ((p.x + e.rel.0).round(), (p.y + e.rel.1).round()), e.k);
|
||||
crate::paint::add_decode_failures(e.failed);
|
||||
if e.image_left_out {
|
||||
self.image_left_out = self.image_left_out.map(|_| true);
|
||||
}
|
||||
draw_scaled(ctx, image, at, k);
|
||||
return;
|
||||
}
|
||||
@@ -151,23 +158,32 @@ impl Renderer {
|
||||
let frame = Frame { mt: false, view, visible: to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)), px, ..*f };
|
||||
let line = 72.0 / ppi;
|
||||
let (before, decoded) = (self.stats.moved, crate::paint::decode_failures());
|
||||
let outer_image_left_out = self.image_left_out.replace(false);
|
||||
let pattern_images_left_out = crate::pattern::images_left_out();
|
||||
// A Rasterize first in the chain draws the content on its image's own grid instead, unless
|
||||
// that image is too large: the content is drawn once.
|
||||
let mut rasterized = match chain.first() {
|
||||
Some(RasterFx::Rasterize(r)) => {
|
||||
let rect = r.image(content.bounds.unwrap_or(content.reach), line);
|
||||
let kept = content.kept.and_then(|k| match &*k.image.borrow() {
|
||||
Some(KeptImage::Grid(image, failed)) => {
|
||||
Some(KeptImage::Grid(image, failed, image_left_out)) => {
|
||||
crate::paint::add_decode_failures(*failed);
|
||||
if *image_left_out {
|
||||
self.image_left_out = self.image_left_out.map(|_| true);
|
||||
}
|
||||
Some(image.clone())
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
let image = kept.or_else(|| {
|
||||
let failures = crate::paint::decode_failures();
|
||||
let outer_image_left_out = self.image_left_out.replace(false);
|
||||
let pattern_images_left_out = crate::pattern::images_left_out();
|
||||
let image = self.rasterize_image(f, r, rect, paint).map(std::rc::Rc::new);
|
||||
let left_out = self.image_left_out.unwrap_or(false) || crate::pattern::images_left_out() > pattern_images_left_out;
|
||||
self.image_left_out = outer_image_left_out.map(|outer| outer || left_out);
|
||||
if let (Some(k), Some(image)) = (content.kept, &image) {
|
||||
*k.image.borrow_mut() = Some(KeptImage::Grid(image.clone(), crate::paint::decode_failures() - failures));
|
||||
*k.image.borrow_mut() = Some(KeptImage::Grid(image.clone(), crate::paint::decode_failures() - failures, left_out));
|
||||
}
|
||||
image
|
||||
});
|
||||
@@ -190,6 +206,8 @@ impl Renderer {
|
||||
off.render(&mut pm, &mut self.resources);
|
||||
}
|
||||
let failed = crate::paint::decode_failures() - decoded;
|
||||
let image_left_out = self.image_left_out.unwrap_or(false) || crate::pattern::images_left_out() > pattern_images_left_out;
|
||||
self.image_left_out = outer_image_left_out.map(|outer| outer || image_left_out);
|
||||
// Content drawn over moved rasters makes a moved raster.
|
||||
let moved = self.stats.moved > before;
|
||||
// What the raster is made from, for its object once it moves (`move_pixel_content`): a
|
||||
@@ -258,7 +276,8 @@ impl Renderer {
|
||||
let rel = (x0 - p.x, y0 - p.y);
|
||||
let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node)));
|
||||
let node = Drawn::of(content.node, owner.as_ref());
|
||||
let entry = PixelEntry { node, linear, anchor, rel, k, image: image.clone(), view: in_view, made, inputs, moved, failed, stamp };
|
||||
let entry =
|
||||
PixelEntry { node, linear, anchor, rel, k, image: image.clone(), view: in_view, made, inputs, moved, failed, image_left_out, stamp };
|
||||
self.pixel_fx.insert(key, entry);
|
||||
self.trim_fx_rasters(FX_RASTER_BUDGET);
|
||||
}
|
||||
@@ -266,7 +285,7 @@ impl Renderer {
|
||||
match content.kept.filter(|kept| kept.image.borrow().is_none()) {
|
||||
Some(kept) => {
|
||||
let place = f.view.inverse() * Affine::translate((x0, y0)) * Affine::scale(1.0 / k);
|
||||
*kept.image.borrow_mut() = Some(KeptImage::Raster(image.clone(), place));
|
||||
*kept.image.borrow_mut() = Some(KeptImage::Raster(image.clone(), place, failed, image_left_out));
|
||||
if f.view == call { draw_scaled(ctx, image, (x0, y0), k) } else { draw_placed(ctx, image, call * place) }
|
||||
}
|
||||
None => draw_scaled(ctx, image, (x0, y0), k),
|
||||
|
||||
@@ -89,7 +89,7 @@ pub(crate) struct PixelEntry {
|
||||
pub(super) anchor: vectorcraft_geom::Point,
|
||||
pub(super) rel: (f64, f64),
|
||||
pub(super) k: f64,
|
||||
pub(super) image: std::sync::Arc<vello_cpu::Pixmap>,
|
||||
pub(crate) image: std::sync::Arc<vello_cpu::Pixmap>,
|
||||
/// For a raster of the content's whole reach at full size, `None`: it draws after a pan too.
|
||||
/// For one cropped to the view, or made at a lower resolution, the view and the rect it was
|
||||
/// made for: it draws only there, so dragging other art over the object keeps it (a wide blur,
|
||||
@@ -107,6 +107,9 @@ pub(crate) struct PixelEntry {
|
||||
/// ([`crate::paint::decode_failures`]): each frame that draws the raster counts them
|
||||
/// again ([`crate::paint::add_decode_failures`]).
|
||||
pub(super) failed: u64,
|
||||
/// Whether an image in the content failed to draw while the raster was made: each frame that
|
||||
/// draws the raster inside opacity-mask art notes it again.
|
||||
pub(crate) image_left_out: bool,
|
||||
pub(crate) stamp: u64,
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,10 @@ impl Renderer {
|
||||
Some(e) => {
|
||||
e.stamp = stamp;
|
||||
self.stats.moved += usize::from(e.moved);
|
||||
crate::paint::add_decode_failures(e.failed);
|
||||
if e.image_left_out {
|
||||
self.image_left_out = self.image_left_out.map(|_| true);
|
||||
}
|
||||
let p = f.view * e.anchor;
|
||||
(e.image.clone(), (p.x + e.rel.0).round(), (p.y + e.rel.1).round(), e.scale, e.moved, e.view.map(|v| v.served(f.view)))
|
||||
}
|
||||
@@ -118,7 +122,13 @@ impl Renderer {
|
||||
let ink = darkness.map_or(f.ink, |d| f.ink.darkened(d));
|
||||
let crop = Frame { mt: false, view, visible, ink, ..*f };
|
||||
let before = self.stats.moved;
|
||||
let decoded = crate::paint::decode_failures();
|
||||
let outer_image_left_out = self.image_left_out.replace(false);
|
||||
let pattern_images_left_out = crate::pattern::images_left_out();
|
||||
self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy)));
|
||||
let failed = crate::paint::decode_failures() - decoded;
|
||||
let image_left_out = self.image_left_out.unwrap_or(false) || crate::pattern::images_left_out() > pattern_images_left_out;
|
||||
self.image_left_out = outer_image_left_out.map(|outer| outer || image_left_out);
|
||||
// A silhouette drawn over moved rasters makes a moved shadow.
|
||||
let moved = self.stats.moved > before;
|
||||
off.flush();
|
||||
@@ -165,7 +175,10 @@ impl Renderer {
|
||||
let node = Drawn::of(n, owner);
|
||||
let rel = (x0 - p.x, y0 - p.y);
|
||||
let view = cropped.map(|room| ViewCrop::of(f.view, room));
|
||||
self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel, image: image.clone(), scale, view, made, inputs, moved, stamp });
|
||||
self.shadows.insert(
|
||||
key,
|
||||
ShadowEntry { node, linear, anchor, rel, image: image.clone(), scale, view, made, inputs, moved, failed, image_left_out, stamp },
|
||||
);
|
||||
self.trim_fx_rasters(FX_RASTER_BUDGET);
|
||||
(image, x0, y0, scale, moved, cropped)
|
||||
}
|
||||
|
||||
@@ -147,6 +147,8 @@ pub struct Renderer {
|
||||
/// The picture [`Self::flatten`] started the current context again from, with 8-bit
|
||||
/// compositing: [`Self::finish`] renders the context over it.
|
||||
picture: Option<Arc<Pixmap>>,
|
||||
/// Whether an image failed to draw while opacity-mask art is being drawn.
|
||||
image_left_out: Option<bool>,
|
||||
/// Pixels of the groups being drawn offscreen (contexts and backdrop copies), and the most
|
||||
/// they may hold (set from the frame when the first opens, see [`Self::group`]); both reset
|
||||
/// at the start of every frame.
|
||||
|
||||
@@ -93,6 +93,9 @@ struct TileEntry {
|
||||
/// ([`crate::paint::decode_failures`]): each frame that draws from the tile counts them
|
||||
/// again ([`crate::paint::add_decode_failures`]).
|
||||
failed: u64,
|
||||
/// Whether an image in the art failed to draw while the tile was drawn: each frame that draws
|
||||
/// from the tile counts it again ([`images_left_out`]).
|
||||
image_left_out: bool,
|
||||
}
|
||||
|
||||
struct PatternCache {
|
||||
@@ -129,6 +132,13 @@ impl PatternCache {
|
||||
|
||||
thread_local! {
|
||||
static CACHE: RefCell<PatternCache> = RefCell::new(PatternCache::default());
|
||||
static IMAGES_LEFT_OUT: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// How many pattern tiles drawn on this thread so far left out an image of their art: opacity-mask
|
||||
/// art compares the count before and after it draws ([`Renderer::mask_values`]).
|
||||
pub(crate) fn images_left_out() -> u64 {
|
||||
IMAGES_LEFT_OUT.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
fn same_art(a: &[Arc<Node>], b: &[Arc<Node>]) -> bool {
|
||||
@@ -159,12 +169,14 @@ pub(crate) fn raster_scale(s: f64, period: (f64, f64)) -> f64 {
|
||||
|
||||
/// Rasterize `region` (pattern space) of the infinite tiling into a `w`×`h` pixmap, colours as
|
||||
/// `ink` paints them.
|
||||
fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w: u16, h: u16, ink: Ink) -> Pixmap {
|
||||
fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w: u16, h: u16, ink: Ink) -> (Pixmap, bool) {
|
||||
let (w, h) = (w.max(1), h.max(1));
|
||||
let mut ctx = crate::single_threaded_context(w, h);
|
||||
let view = Affine::scale_non_uniform(w as f64 / region.width().max(1e-9), h as f64 / region.height().max(1e-9))
|
||||
* Affine::translate(-region.origin().to_vec2());
|
||||
let opts = RenderOptions::default();
|
||||
r.image_left_out = Some(false);
|
||||
let pattern_images_left_out = images_left_out();
|
||||
r.check_plugins();
|
||||
r.check_symbols(doc);
|
||||
let symbols = doc.symbol_boxes().quick();
|
||||
@@ -187,7 +199,7 @@ fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w
|
||||
r.draw_arc(&mut ctx, &frame, a);
|
||||
}
|
||||
}
|
||||
r.finish(&mut ctx, r.raster)
|
||||
(r.finish(&mut ctx, r.raster), r.image_left_out.take().unwrap_or(false) || images_left_out() > pattern_images_left_out)
|
||||
}
|
||||
|
||||
/// Run `f` with the shared off-screen renderer (None when nested too deep: a pattern whose art
|
||||
@@ -257,22 +269,28 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
|
||||
&& std::ptr::eq(e.cms.as_ptr(), Arc::as_ptr(&cms))
|
||||
&& e.deps.as_ref().is_none_or(|d| d.current(doc))
|
||||
})
|
||||
.map(|e| (e.pixmap.clone(), e.sx, e.sy, e.failed))
|
||||
.map(|e| (e.pixmap.clone(), e.sx, e.sy, e.failed, e.image_left_out))
|
||||
});
|
||||
let (pm, sx, sy) = match hit {
|
||||
Some((pm, sx, sy, failed)) => {
|
||||
Some((pm, sx, sy, failed, image_left_out)) => {
|
||||
crate::paint::add_decode_failures(failed);
|
||||
if image_left_out {
|
||||
IMAGES_LEFT_OUT.with(|n| n.set(n.get().saturating_add(1)));
|
||||
}
|
||||
(pm, sx, sy)
|
||||
}
|
||||
None => {
|
||||
let w = (pw * scale).ceil().clamp(1.0, 4096.0) as u16;
|
||||
let h = (ph * scale).ceil().clamp(1.0, 4096.0) as u16;
|
||||
let before = crate::paint::decode_failures();
|
||||
let Some(pm) = with_tile_renderer(|r| rasterize(r, doc, def, Rect::new(0.0, 0.0, pw, ph), w, h, ink)) else {
|
||||
let Some((pm, image_left_out)) = with_tile_renderer(|r| rasterize(r, doc, def, Rect::new(0.0, 0.0, pw, ph), w, h, ink)) else {
|
||||
ctx.set_paint(missing());
|
||||
return true;
|
||||
};
|
||||
let failed = crate::paint::decode_failures() - before;
|
||||
if image_left_out {
|
||||
IMAGES_LEFT_OUT.with(|n| n.set(n.get().saturating_add(1)));
|
||||
}
|
||||
let pm = Arc::new(pm);
|
||||
let (sx, sy) = (w as f64 / pw, h as f64 / ph);
|
||||
let deps = crate::fx::Reads::of_all(def.art.iter().map(|a| &**a)).any().then(|| Deps::of(doc));
|
||||
@@ -284,7 +302,8 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
|
||||
let mut c = c.borrow_mut();
|
||||
let tiles = &mut c.tiles[ink.index()];
|
||||
let (t, bytes) = (tiles.tick(), size_of::<TileEntry>() + w as usize * h as usize * 4);
|
||||
let entry = TileEntry { art: def.art.clone(), deps, cms: Arc::downgrade(&cms), params, sx, sy, pixmap: pm.clone(), failed };
|
||||
let entry =
|
||||
TileEntry { art: def.art.clone(), deps, cms: Arc::downgrade(&cms), params, sx, sy, pixmap: pm.clone(), failed, image_left_out };
|
||||
tiles.insert(key, entry, bytes, t);
|
||||
});
|
||||
(pm, sx, sy)
|
||||
@@ -303,7 +322,7 @@ pub fn render_pattern_swatch(doc: &Document, name: &str, size: u32) -> Option<Re
|
||||
let side = w.max(h);
|
||||
let region = Rect::from_center_size((w / 2.0, h / 2.0), (side, side));
|
||||
let px = size.clamp(1, 1024) as u16;
|
||||
let pm = with_tile_renderer(|r| rasterize(r, doc, def, region, px, px, Ink::Display))?;
|
||||
let (pm, _) = with_tile_renderer(|r| rasterize(r, doc, def, region, px, px, Ink::Display))?;
|
||||
Some(Rendered { width: px as u32, height: px as u32, pixels: pm.data_as_u8_slice().to_vec() })
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ impl Renderer {
|
||||
blends: PtrMap::default(),
|
||||
clip_paths: vec![],
|
||||
picture: None,
|
||||
image_left_out: None,
|
||||
offscreen_live: 0,
|
||||
offscreen_budget: 0,
|
||||
symbol_nodes: 0,
|
||||
@@ -120,7 +121,12 @@ impl Renderer {
|
||||
self.begin_frame();
|
||||
// View Opacity Mask: the mask's coverage in greyscale instead of the artwork.
|
||||
if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.extras().mask.as_deref()) {
|
||||
let pixels = self.mask_values(&frame, m, w, h).into_iter().flat_map(|v| [v, v, v, 255]).collect();
|
||||
let pixels = self
|
||||
.mask_values(&frame, m, w, h)
|
||||
.unwrap_or_else(|| vec![0; w as usize * h as usize])
|
||||
.into_iter()
|
||||
.flat_map(|v| [v, v, v, 255])
|
||||
.collect();
|
||||
self.end_frame();
|
||||
self.stats.symbol_art = symbols.art_measured();
|
||||
self.stats.micros = now().saturating_sub(start);
|
||||
|
||||
@@ -121,6 +121,8 @@ impl Renderer {
|
||||
}
|
||||
} else if f.opts.mark_undecoded_images && !pending && (!outline || f.doc.setup.outline_images) {
|
||||
self.draw_undecoded_mark(ctx, f, im, area);
|
||||
} else if !pending && !outline {
|
||||
self.image_left_out = self.image_left_out.map(|_| true);
|
||||
}
|
||||
if outline {
|
||||
let mut p = rect.to_path(0.1);
|
||||
|
||||
@@ -166,9 +166,9 @@ impl Renderer {
|
||||
// mask is rendered over those only.
|
||||
let bounds = self.bounds_of(f.symbols, a);
|
||||
let Some((r, sub)) = group::area(ctx, f, bounds) else { return };
|
||||
let mask = Some(self.mask_values(&sub, m, r.w, r.h));
|
||||
let Some(mask) = self.mask_values(&sub, m, r.w, r.h) else { return };
|
||||
let blends = a.blend != vectorcraft_color::BlendMode::Normal || (!a.isolate && self.blends_through(a));
|
||||
self.group(ctx, f, Composite { mask, blends, bounds, ..Default::default() }, &mut |r, c, fr| r.draw_arc_body(c, fr, a));
|
||||
self.group(ctx, f, Composite { mask: Some(mask), blends, bounds, ..Default::default() }, &mut |r, c, fr| r.draw_arc_body(c, fr, a));
|
||||
return;
|
||||
}
|
||||
self.draw_arc_body(ctx, f, a);
|
||||
@@ -176,7 +176,7 @@ impl Renderer {
|
||||
|
||||
/// The coverage of opacity mask `m` per output pixel (row-major): its art rendered offscreen,
|
||||
/// as luminance ([`mask_value`]).
|
||||
pub(super) fn mask_values(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> Vec<u8> {
|
||||
pub(super) fn mask_values(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> Option<Vec<u8>> {
|
||||
let mut mctx = f.offscreen_context(w, h);
|
||||
// Mask art is a picture of its own: it takes no part in a knockout group around the object,
|
||||
// and its luminance is that of its screen colours.
|
||||
@@ -187,10 +187,22 @@ impl Renderer {
|
||||
std::mem::take(&mut self.clip_paths),
|
||||
self.picture.take(),
|
||||
);
|
||||
let image_left_out = self.image_left_out.replace(false);
|
||||
let failures = crate::paint::decode_failures();
|
||||
let pattern_images_left_out = crate::pattern::images_left_out();
|
||||
self.draw_kept_node(&mut mctx, &Frame { mt: false, ink: Ink::Display, ..*f }, &m.art);
|
||||
// Pattern tiles are drawn without undecoded-image marks, so an image a tile left out counts
|
||||
// with the marks on too.
|
||||
let incomplete = self.image_left_out.unwrap_or(false)
|
||||
|| crate::pattern::images_left_out() > pattern_images_left_out
|
||||
|| (!f.opts.mark_undecoded_images && crate::paint::decode_failures() > failures);
|
||||
self.image_left_out = image_left_out.map(|outer| outer || incomplete);
|
||||
let pm = self.finish(&mut mctx, self.raster);
|
||||
(self.knockout, self.nested, self.backdrop, self.clip_paths, self.picture) = outer;
|
||||
pm.data().iter().map(|p| mask_value(p.r, p.g, p.b, p.a, m.clip, m.invert)).collect()
|
||||
if incomplete {
|
||||
return None;
|
||||
}
|
||||
Some(pm.data().iter().map(|p| mask_value(p.r, p.g, p.b, p.a, m.clip, m.invert)).collect())
|
||||
}
|
||||
|
||||
/// Everything [`Self::draw_arc`] does after culling.
|
||||
|
||||
@@ -747,6 +747,229 @@ fn opacity_mask_without_clip_and_inverted() {
|
||||
assert_eq!(img.pixel(80, 50), [255, 0, 0, 255], "inverted clip background → visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_mask_with_undecodable_image_leaves_out_object() {
|
||||
use vectorcraft_doc::{Effect, ImageBlob, ImageObject, NodeKind, OpacityMask};
|
||||
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.images.insert("broken-mask-image".into(), ImageBlob::new("image/png", b"not a PNG".to_vec()));
|
||||
let mut object = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
let image = ImageObject {
|
||||
key: "broken-mask-image".into(),
|
||||
width: 20,
|
||||
height: 20,
|
||||
xf: Affine::translate((30.0, 30.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let mut image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)));
|
||||
image.appearance.effects.push(Effect { id: "blur.gaussian".into(), params: serde_json::json!({"radius": 4.0}), visible: true });
|
||||
let white = rect_node(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0), Paint::solid(Color::WHITE), Paint::None, 0.0);
|
||||
let art = Node::group(d.alloc_id(), vec![Arc::new(white), Arc::new(image)]);
|
||||
object.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
|
||||
let object_id = object.id;
|
||||
d.insert(Some(d.layers[0].id), 0, object).unwrap();
|
||||
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let pending = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { kept_images_only: true, ..opts.clone() });
|
||||
assert_eq!(pending.pixel(70, 70), [255, 0, 0, 255], "an image awaiting decoding does not invalidate the mask");
|
||||
let mut renderer = Renderer::new();
|
||||
let img = renderer.render(&d, 100, 100, Affine::IDENTITY, &opts);
|
||||
assert_eq!(img.pixel(70, 70), [255, 255, 255, 255], "missing mask image leaves out the object");
|
||||
let again = renderer.render(&d, 100, 100, Affine::IDENTITY, &opts);
|
||||
assert_eq!(again.pixel(70, 70), [255, 255, 255, 255], "the cached raster keeps the mask incomplete");
|
||||
let mask_view = renderer.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { mask_view: Some(object_id), ..opts.clone() });
|
||||
assert_eq!(mask_view.pixel(70, 70), [0, 0, 0, 255], "an incomplete mask view has no coverage");
|
||||
let marked = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { mark_undecoded_images: true, ..opts.clone() });
|
||||
assert_eq!(marked.pixel(70, 70), [255, 0, 0, 255], "the undecoded-image mark remains part of the mask");
|
||||
let kept = RenderOptions { kept_images_only: true, ..opts.clone() };
|
||||
for frame in 0..2 {
|
||||
let img = renderer.render(&d, 100, 100, Affine::IDENTITY, &kept);
|
||||
assert_eq!(img.pixel(70, 70), [255, 255, 255, 255], "an image already known undecodable leaves out the object on frame {frame}");
|
||||
}
|
||||
|
||||
let working = Renderer::new().render(&masked_doc(false, false), 100, 100, Affine::IDENTITY, &opts);
|
||||
assert_eq!(working.pixel(80, 50), [255, 0, 0, 255], "a complete mask still draws the object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_mask_with_missing_image_in_pattern_leaves_out_object() {
|
||||
use vectorcraft_doc::pattern::{PatternDef, pattern_paint};
|
||||
use vectorcraft_doc::{ImageObject, NodeKind, OpacityMask};
|
||||
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let image = ImageObject {
|
||||
key: "missing-pattern-image".into(),
|
||||
width: 20,
|
||||
height: 20,
|
||||
xf: Affine::IDENTITY,
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)));
|
||||
let mut pattern = PatternDef::new("Missing image", vec![Arc::new(image)]);
|
||||
pattern.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
|
||||
d.patterns.push(pattern);
|
||||
let art = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), pattern_paint("Missing image"), Paint::None, 0.0);
|
||||
let mut object = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
object.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
|
||||
d.insert(Some(d.layers[0].id), 0, object).unwrap();
|
||||
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
for frame in 0..2 {
|
||||
let img = r.render(&d, 100, 100, Affine::IDENTITY, &opts);
|
||||
assert_eq!(img.pixel(70, 50), [255, 255, 255, 255], "frame {frame}: incomplete pattern mask leaves out the object");
|
||||
}
|
||||
// Pattern tiles draw no undecoded-image mark, so the object stays out with the marks on too.
|
||||
let marked = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { mark_undecoded_images: true, ..opts.clone() });
|
||||
assert_eq!(marked.pixel(70, 50), [255, 255, 255, 255], "a tile without the mark leaves out the object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_image_mask_with_blur_reuses_cached_raster() {
|
||||
use vectorcraft_doc::{Effect, ImageBlob, ImageObject, NodeKind, OpacityMask};
|
||||
|
||||
let mut png = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(20, 20, image::Rgba([255, 255, 255, 255])))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.images.insert("complete-mask-image".into(), ImageBlob::new("image/png", png));
|
||||
let image = ImageObject {
|
||||
key: "complete-mask-image".into(),
|
||||
width: 20,
|
||||
height: 20,
|
||||
xf: Affine::translate((30.0, 30.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let mut image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)));
|
||||
image.appearance.effects.push(Effect { id: "blur.gaussian".into(), params: serde_json::json!({"radius": 4.0}), visible: true });
|
||||
let mut object = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
let white = rect_node(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0), Paint::solid(Color::WHITE), Paint::None, 0.0);
|
||||
let art = Node::group(d.alloc_id(), vec![Arc::new(white), Arc::new(image)]);
|
||||
object.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
|
||||
d.insert(Some(d.layers[0].id), 0, object).unwrap();
|
||||
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
let first = r.render(&d, 100, 100, Affine::IDENTITY, &opts);
|
||||
assert_eq!(first.pixel(70, 70), [255, 0, 0, 255]);
|
||||
let raster = r.pixel_fx.values().next().unwrap().image.clone();
|
||||
let second = r.render(&d, 100, 100, Affine::IDENTITY, &opts);
|
||||
assert_eq!(second.pixels, first.pixels);
|
||||
assert!(Arc::ptr_eq(&raster, &r.pixel_fx.values().next().unwrap().image), "the second frame draws the image mask from its cached effect raster");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_image_mask_with_blur_stays_incomplete_from_cached_raster() {
|
||||
use vectorcraft_doc::{Effect, ImageObject, NodeKind, OpacityMask};
|
||||
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let image = ImageObject {
|
||||
key: "missing-blurred-mask-image".into(),
|
||||
width: 20,
|
||||
height: 20,
|
||||
xf: Affine::translate((30.0, 30.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let mut image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)));
|
||||
image.appearance.effects.push(Effect { id: "blur.gaussian".into(), params: serde_json::json!({"radius": 4.0}), visible: true });
|
||||
let white = rect_node(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0), Paint::solid(Color::WHITE), Paint::None, 0.0);
|
||||
let art = Node::group(d.alloc_id(), vec![Arc::new(white), Arc::new(image)]);
|
||||
let mut object = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
object.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
|
||||
d.insert(Some(d.layers[0].id), 0, object).unwrap();
|
||||
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
let first = r.render(&d, 100, 100, Affine::IDENTITY, &opts);
|
||||
assert_eq!(first.pixel(70, 70), [255, 255, 255, 255]);
|
||||
let raster = r.pixel_fx.values().next().unwrap().image.clone();
|
||||
let second = r.render(&d, 100, 100, Affine::IDENTITY, &opts);
|
||||
assert_eq!(second.pixel(70, 70), [255, 255, 255, 255]);
|
||||
assert!(Arc::ptr_eq(&raster, &r.pixel_fx.values().next().unwrap().image), "the incomplete raster is reused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_image_mask_with_rasterize_and_shadow_keeps_incomplete_content_raster() {
|
||||
use vectorcraft_doc::{Effect, ImageObject, NodeKind, OpacityMask};
|
||||
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let image = ImageObject {
|
||||
key: "missing-rasterized-mask-image".into(),
|
||||
width: 20,
|
||||
height: 20,
|
||||
xf: Affine::translate((30.0, 30.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let mut image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)));
|
||||
image.appearance.effects = [
|
||||
("rasterize", serde_json::json!({"background": "white"})),
|
||||
("stylize.dropShadow", serde_json::json!({"mode": "normal", "opacity": 100, "x": 2, "y": 2, "blur": 1, "color": "#0000ff"})),
|
||||
("blur.gaussian", serde_json::json!({"radius": 4.0})),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(id, params)| Effect { id: id.into(), params, visible: true })
|
||||
.collect();
|
||||
let white = rect_node(&mut d, Rect::new(10.0, 10.0, 20.0, 20.0), Paint::solid(Color::WHITE), Paint::None, 0.0);
|
||||
let art = Node::group(d.alloc_id(), vec![Arc::new(white), Arc::new(image)]);
|
||||
let mut object = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
object.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
|
||||
d.insert(Some(d.layers[0].id), 0, object).unwrap();
|
||||
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let mut r = Renderer::new();
|
||||
let mut first_raster = None;
|
||||
for frame in 0..2 {
|
||||
let img = r.render(&d, 100, 100, Affine::IDENTITY, &opts);
|
||||
assert_eq!(img.pixel(70, 70), [255, 255, 255, 255], "frame {frame}: the incomplete mask leaves out the object");
|
||||
let raster = r.pixel_fx.values().next().unwrap();
|
||||
assert!(raster.image_left_out, "frame {frame}: the content raster records the missing image");
|
||||
if let Some(first) = &first_raster {
|
||||
assert!(Arc::ptr_eq(first, &raster.image), "the second frame reuses the incomplete content raster");
|
||||
} else {
|
||||
first_raster = Some(raster.image.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undecodable_image_in_nested_opacity_mask_leaves_out_outer_object() {
|
||||
use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind, OpacityMask};
|
||||
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.images.insert("broken-nested-mask-image".into(), ImageBlob::new("image/png", b"not a nested PNG".to_vec()));
|
||||
let mut outer = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
let mut inner = rect_node(&mut d, Rect::new(20.0, 20.0, 80.0, 80.0), Paint::solid(Color::WHITE), Paint::None, 0.0);
|
||||
let image = ImageObject {
|
||||
key: "broken-nested-mask-image".into(),
|
||||
width: 20,
|
||||
height: 20,
|
||||
xf: Affine::translate((30.0, 30.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)));
|
||||
inner.extras_mut().mask = Some(Box::new(OpacityMask::new(image, false)));
|
||||
let art = Node::group(d.alloc_id(), vec![Arc::new(inner)]);
|
||||
outer.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
|
||||
d.insert(Some(d.layers[0].id), 0, outer).unwrap();
|
||||
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts);
|
||||
assert_eq!(img.pixel(70, 70), [255, 255, 255, 255], "the incomplete inner mask invalidates the outer mask");
|
||||
}
|
||||
|
||||
/// #3580: each opacity mask is rendered over its object's pixels, not over the whole frame. 300
|
||||
/// masked 4 × 4 pt squares on a 1000 × 1000 page draw offscreen in proportion to their bounds, and
|
||||
/// still draw where their masks show them.
|
||||
|
||||
Reference in New Issue
Block a user