From dd83e063a4811149794ce8192267a347bad88f11 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 18:40:38 -0700 Subject: [PATCH] Render: pattern tiles draw undecoded-image marks when the view does Pattern tiles were drawn with the default render options, so an image whose pixels cannot be decoded drew nothing inside a pattern fill even when the render marks undecoded images, as the canvas does. Tiles now follow the caller's mark_undecoded_images setting, and the tile cache keys on it. An opacity mask with such a pattern now treats the mark as part of the mask, as it does for an undecodable image outside a pattern. --- crates/render/src/pattern.rs | 30 ++++++++----- crates/render/src/renderer/nodes.rs | 2 - crates/render/src/tests.rs | 68 +++++++++++++++++++++++++++-- 3 files changed, 85 insertions(+), 15 deletions(-) diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index ab1270317..f23786b49 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -4,7 +4,7 @@ //! ([`PatternDef::period`]) is rasterized at the device scale it is painted at, rounded up to a //! power of two and capped ([`raster_scale`]), and used as an image paint with `Extend::Repeat`, //! with the paint transform mapping pixels → pattern space → document. Tiles are cached per -//! pattern name and raster scale (and ink plane, see [`crate::ink`]) within +//! pattern name, raster scale, undecoded-image mark setting and ink plane (see [`crate::ink`]) within //! [`lru::PATTERN_BUDGET`]. A tile is used while the pattern's definition is the one it was //! rasterized from (art `Arc` identity, tile, tile type, overlap): the pattern's "revision", and, //! when its art shows more than itself (a symbol, another pattern, an image, type, a brush), while @@ -33,9 +33,9 @@ use crate::{Frame, RenderOptions, Rendered, Renderer}; /// Largest super-tile raster side (pixels). const MAX_TILE_PX: f64 = 2048.0; -/// What a tile is kept under: the pattern's name and the bits of its raster scale -/// ([`raster_scale`]). -type TileKey = (String, u64, Ink); +/// What a tile is kept under: the pattern's name, the bits of its raster scale +/// ([`raster_scale`]), ink plane and undecoded-image mark setting. +type TileKey = (String, u64, Ink, bool); /// What a tile was drawn from besides its pattern's own art, for art that shows more than itself: /// the art of every pattern and symbol and the pixels of every image in the document, the @@ -169,12 +169,20 @@ 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, bool) { - let (w, h) = (w.max(1), h.max(1)); +fn rasterize( + r: &mut Renderer, + doc: &Document, + def: &PatternDef, + region: Rect, + size: (u16, u16), + ink: Ink, + mark_undecoded_images: bool, +) -> (Pixmap, bool) { + let (w, h) = (size.0.max(1), size.1.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(); + let opts = RenderOptions { mark_undecoded_images, ..Default::default() }; r.image_left_out = Some(false); let pattern_images_left_out = images_left_out(); r.check_plugins(); @@ -254,7 +262,7 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, let (pw, ph) = def.period(); let scale = raster_scale(s, (pw, ph)); let params = (def.tile, def.tile_type, def.overlap); - let key = (name.to_string(), scale.to_bits(), ink); + let key = (name.to_string(), scale.to_bits(), ink, f.opts.mark_undecoded_images); let cms = vectorcraft_color::cms::active(); let hit = CACHE.with(|c| { let mut c = c.borrow_mut(); @@ -283,7 +291,9 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, 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, image_left_out)) = 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, f.opts.mark_undecoded_images)) + else { ctx.set_paint(missing()); return true; }; @@ -322,7 +332,7 @@ pub fn render_pattern_swatch(doc: &Document, name: &str, size: u32) -> Option pattern_images_left_out || (!f.opts.mark_undecoded_images && crate::paint::decode_failures() > failures); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 6f15ae313..767f1eda8 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -944,9 +944,33 @@ fn opacity_mask_with_missing_image_in_pattern_leaves_out_object() { 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 opacity_mask_with_marked_image_in_pattern_draws_object() { + use vectorcraft_doc::pattern::{PatternDef, pattern_paint}; + use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind, OpacityMask}; + + let mut d = Document::new(100.0, 100.0); + d.images.insert("cut-mask".into(), ImageBlob::new("image/png", b"not a PNG: pattern mask".to_vec())); + let image = + ImageObject { key: "cut-mask".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("Cut mask", 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("Cut mask"), 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 mut r = Renderer::new(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let off = r.render(&d, 100, 100, Affine::IDENTITY, &opts); + assert_eq!(off.pixel(70, 50), [255, 255, 255, 255], "an unmarked missing image leaves out the object"); + let on = r.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { mark_undecoded_images: true, ..opts }); + assert_eq!(on.pixel(70, 50), [255, 0, 0, 255], "the marked image keeps the mask complete"); + assert!(on.pixel(15, 15)[1] < 255, "the marked pattern contributes mask coverage"); } #[test] @@ -1488,6 +1512,44 @@ fn an_undecodable_image_is_tried_once_and_marked_on_screen() { assert_eq!((attempts() - tried, decode_failures() - failed), (1, 3), "and counts it without decoding it again"); } +#[test] +fn pattern_tile_follows_undecoded_image_mark_in_both_cache_orders() { + use vectorcraft_doc::pattern::{PatternDef, pattern_paint}; + use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind}; + + let mut d = Document::new(80.0, 80.0); + d.images.insert("cut-pattern".into(), ImageBlob::new("image/png", b"not a PNG: pattern fill".to_vec())); + let image = ImageObject { + key: "cut-pattern".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("Cut pattern", vec![Arc::new(image)]); + pattern.tile = Rect::new(0.0, 0.0, 20.0, 20.0); + d.patterns.push(pattern); + let shape = rect_node(&mut d, Rect::new(10.0, 10.0, 70.0, 70.0), pattern_paint("Cut pattern"), Paint::None, 0.0); + d.insert(Some(d.layers[0].id), 0, shape).unwrap(); + + let mut r = Renderer::new(); + for order in [[false, true, false], [true, false, true]] { + for mark in order { + let opts = RenderOptions { mark_undecoded_images: mark, ..Default::default() }; + let pixel = r.render(&d, 80, 80, Affine::IDENTITY, &opts).pixel(15, 15); + assert_eq!(pixel[3] > 0, mark, "mark_undecoded_images {mark}: {pixel:?}"); + if mark { + assert_eq!(pixel[0], pixel[1], "the pattern draws a gray mark"); + assert_eq!(pixel[1], pixel[2], "the pattern draws a gray mark"); + assert!((120..=140).contains(&pixel[0]), "the pattern draws a gray mark: {pixel:?}"); + } + } + } +} + /// The rasters a renderer keeps of an object's raster effects are made again when /// `mark_undecoded_images` changes: a blurred image whose pixels can't be decoded shows its blurred /// mark only while the option is on.