diff --git a/crates/render/src/fx/entries.rs b/crates/render/src/fx/entries.rs index cd9acb109..ec223173b 100644 --- a/crates/render/src/fx/entries.rs +++ b/crates/render/src/fx/entries.rs @@ -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, usize, usize)>, u64), + /// The last value records whether an image was left out while making the grid. + Grid(std::rc::Rc<(Vec, 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, Affine), + /// document space. The remaining values record decode failures and whether an image was left out. + Raster(Arc, 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, } diff --git a/crates/render/src/fx/pixels.rs b/crates/render/src/fx/pixels.rs index 900e435bc..eb5c69801 100644 --- a/crates/render/src/fx/pixels.rs +++ b/crates/render/src/fx/pixels.rs @@ -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), diff --git a/crates/render/src/fx/raster.rs b/crates/render/src/fx/raster.rs index ca6a2fd37..70bbafec5 100644 --- a/crates/render/src/fx/raster.rs +++ b/crates/render/src/fx/raster.rs @@ -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, + pub(crate) image: std::sync::Arc, /// 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, } diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index 949732c52..9512d89d6 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -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) } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 9343d1c2f..d5c601dc1 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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>, + /// Whether an image failed to draw while opacity-mask art is being drawn. + image_left_out: Option, /// 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. diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index c8f202cbe..ab1270317 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -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 = RefCell::new(PatternCache::default()); + static IMAGES_LEFT_OUT: std::cell::Cell = 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], b: &[Arc]) -> 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::() + 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 Vec { + pub(super) fn mask_values(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> Option> { 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. diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index df65005ed..6b6ec3e79 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -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.