From d20ffcc055aab088e7c25af259c73f5fdbbfd4db Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 22:23:34 -0800 Subject: [PATCH 1/5] Render: satin on an image follows its painted edge Satin on a frame holding an image used the frame's shape. Where the image is transparent, the satin color multiplied over whatever lay behind the frame, so text under a transparent part of the image turned darker. Satin now builds its two offset copies from what the object paints and keeps the result to the painted pixels, as the inner shadow, inner glow and bevel already do. --- crates/render/src/fx.rs | 118 ++++++++++++++++++++++++++++++++-------- 1 file changed, 94 insertions(+), 24 deletions(-) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 7c7c91fe1..501333471 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -287,6 +287,7 @@ impl Renderer { } } // Satin: the shape shifted both ways along the angle, blurred, where exactly one covers. + // With a graphic, the shape is what the object paints, as for the inner shadow. if e.satin.on && let Some(c) = doc.resolve_color(&e.satin.color, 1.0) { @@ -295,25 +296,56 @@ impl Renderer { let sigma = bound(st.size).max(0.0) / 2.0; let paint = color_of(&c, st.opacity.clamp(0.0, 1.0)); let invert = st.invert; - self.with_blended_filters(ctx, f, reach, sigma, Mix::Multiply, (bp, xf), |_, c, fr| { + self.with_blended_filters(ctx, f, reach, sigma, Mix::Multiply, (bp, xf), |me, c, fr| { + // One copy of the shape moved by `by`: the shape in the satin colour, or what the + // object paints (coloured below). + let copy = |me: &mut Renderer, c: &mut RenderContext, by: Vec2| match painted { + Some((it, page)) => { + let moved = Frame { view: fr.view * Affine::translate(by), ..*fr }; + me.draw_body(c, &moved, it, bp, xf, page); + } + None => { + c.set_transform(fr.view * Affine::translate(by) * xf); + c.set_paint(paint); + c.fill_path(bp); + } + }; c.set_transform(fr.view); + if painted.is_some() { + c.push_layer(None, None, None, None, None); + } c.push_layer(None, None, None, None, Some(blur(sigma))); + if painted.is_some() { + c.push_layer(None, None, None, None, None); + } if invert { - c.set_transform(fr.view * xf); - c.set_paint(paint); - c.fill_path(bp); + copy(me, c, Vec2::ZERO); } c.set_transform(Affine::IDENTITY); c.push_layer(None, Some(BlendMode::new(Mix::Normal, if invert { Compose::DestOut } else { Compose::SrcOver })), None, None, None); - c.set_transform(fr.view * Affine::translate(off) * xf); - c.set_paint(paint); - c.fill_path(bp); + copy(me, c, off); c.set_transform(Affine::IDENTITY); c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::Xor)), None, None, None); - c.set_transform(fr.view * Affine::translate(-off) * xf); - c.fill_path(bp); + copy(me, c, -off); c.pop_layer(); c.pop_layer(); + if let Some((it, page)) = painted { + // The satin colour where the copies cover, ... + let pad = sigma * 4.0 + off.hypot() + 4.0 * fr.px; + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::SrcIn)), None, None, None); + c.set_transform(fr.view); + c.set_paint(paint); + c.fill_rect(&reach.inflate(pad, pad)); + c.pop_layer(); + c.pop_layer(); + c.pop_layer(); + // ... blurred and kept only where the object paints. + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); + me.draw_body(c, fr, it, bp, xf, page); + c.pop_layer(); + } c.pop_layer(); }); } @@ -548,8 +580,52 @@ mod tests { /// lines the image's opaque part (its edge in the middle of the frame), not only the frame. #[test] fn an_inner_shadow_follows_a_transparent_images_edge() { + let (d, mut it) = half_image_frame(); + let s = &mut it.effects.inner_shadow; + s.on = true; + s.color = "[Black]".into(); + s.opacity = 1.0; + s.distance = 0.0; + s.size = 20.0; + let lum = render_row(d, it); + // The image's opaque part starts at x = 200: just inside it the shadow darkens it; in its + // middle (x = 250, 50 pt from every edge but the frame's right one) it stays light. + assert!(lum(204) + 60 < lum(250), "shadow at the image's own edge: {} vs {}", lum(204), lum(250)); + // The transparent half shows the page: no shadow painted there. + assert!(lum(150) > 700, "nothing over the transparent part: {}", lum(150)); + } + + /// Satin on a frame holding an image whose left half is transparent multiplies only over the + /// image's opaque part, plain or inverted: the page shows through the transparent half. + #[test] + fn satin_follows_a_transparent_images_edge() { + for invert in [false, true] { + let (d, mut it) = half_image_frame(); + let s = &mut it.effects.satin; + s.on = true; + s.color = "[Black]".into(); + s.opacity = 1.0; + s.angle = 0.0; + s.distance = 100.0; + s.size = 4.0; + s.invert = invert; + let lum = render_row(d, it); + // The copies are 50 pt to each side. Over the frame they leave the satin on 100 to 150 + // (plain) or 150 to 250 (inverted), over the transparent half of the image. + let clear = if invert { 175 } else { 125 }; + assert!(lum(clear) > 700, "invert {invert}: satin over the transparent part at x = {clear}: {}", lum(clear)); + // Over the image's opaque part the copies (150 to 250 and 250 to 350) leave plain satin on + // all of it and inverted satin on none of it. + let shaded = lum(225) < 100; + assert_eq!(shaded, !invert, "invert {invert}: lum at x = 225 is {}", lum(225)); + } + } + + /// A 200 pt frame at (100, 100) holding a 20 × 20 image with a transparent left half and an + /// opaque white right half, in a new document. + fn half_image_frame() -> (designcraft_doc::Document, Item) { + use designcraft_doc::Document; use designcraft_doc::build::NewDocument; - use designcraft_doc::{Document, SpreadRef}; let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); // 20 × 20: transparent left half, opaque white right half. @@ -586,25 +662,19 @@ mod tests { wrap: Default::default(), fill: Default::default(), }); - let s = &mut it.effects.inner_shadow; - s.on = true; - s.color = "[Black]".into(); - s.opacity = 1.0; - s.distance = 0.0; - s.size = 20.0; - d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + (d, it) + } + + /// The first page with `it` added, rendered at 100%: the sum of R, G and B at x on row 200. + fn render_row(mut d: designcraft_doc::Document, it: Item) -> impl Fn(u32) -> u32 { + d.insert_item(designcraft_doc::SpreadRef::Doc(0), it, None).unwrap(); let cache = crate::Cache::new(); let mut r = Renderer::new(); r.threads = 0; let page = r.render_page(&d, &cache, 0, 1.0, false, &crate::RenderOptions::default()).unwrap(); - let lum = |x: u32| { + move |x: u32| { let p = page.pixel(x, 200); u32::from(p[0]) + u32::from(p[1]) + u32::from(p[2]) - }; - // The image's opaque part starts at x = 200: just inside it the shadow darkens it; in its - // middle (x = 250, 50 pt from every edge but the frame's right one) it stays light. - assert!(lum(204) + 60 < lum(250), "shadow at the image's own edge: {} vs {}", lum(204), lum(250)); - // The transparent half shows the page: no shadow painted there. - assert!(lum(150) > 700, "nothing over the transparent part: {}", lum(150)); + } } } From 7a5924e971b82043b5619dbb212c544d238fbbe0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 22:52:07 -0800 Subject: [PATCH 2/5] Render: center inner glow on an image follows its painted edge Inner glow from the center on a frame holding an image filled the frame's shape, so it painted over the transparent parts of the image. It now draws the object's coverage once offscreen, shrinks it by the glow's band, colors and blurs it, and keeps the result to the painted pixels. Partly transparent pixels keep their coverage. --- crates/render/src/fx.rs | 99 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 501333471..6e49577e4 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -265,7 +265,48 @@ impl Renderer { let sigma = if g.technique == designcraft_doc::GlowTechnique::Precise { sigma * 0.4 } else { sigma }; let mix = crate::blend_mode(g.mode).mix; let paint = color_of(&c, g.opacity.clamp(0.0, 1.0)); - if g.center { + if g.center + && let Some((it, page)) = painted + { + // With a graphic, the glow fills what the object paints, shrunk by the band: the + // object's coverage is drawn once offscreen and eroded by the band there. + let band = (bound(g.size) - choke).max(0.5); + self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), |me, c, fr| { + let spread = (sigma * 3.0 + band) / fr.px + 2.0; + let Some(o) = offscreen(fr.view.transform_rect_bbox(reach), spread, c.width(), c.height()) else { return }; + 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)) * fr.view; + me.draw_body(&mut off, &Frame { mt: false, view, px: fr.px * o.scale, ..*fr }, it, bp, xf, page); + off.flush(); + let mut pm = vello_cpu::Pixmap::new(o.width, o.height); + off.render(&mut pm, &mut me.resources); + let mask = erode(&pm, band / (fr.px * o.scale)); + // The eroded coverage in the glow color, ... + c.set_transform(fr.view); + c.push_layer(None, None, None, None, None); + c.push_layer(None, None, None, None, Some(blur(sigma))); + c.set_transform(Affine::translate((o.x0, o.y0)) * Affine::scale(o.scale)); + c.set_paint(vello_cpu::Image { + image: vello_cpu::ImageSource::Pixmap(std::sync::Arc::new(mask)), + sampler: peniko::ImageSampler::default(), + }); + c.fill_rect(&Rect::new(0.0, 0.0, f64::from(o.width), f64::from(o.height))); + let pad = sigma * 4.0 + band + 4.0 * fr.px; + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::SrcIn)), None, None, None); + c.set_transform(fr.view); + c.set_paint(paint); + c.fill_rect(&reach.inflate(pad, pad)); + c.pop_layer(); + c.pop_layer(); + // ... blurred and kept only where the object paints. + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); + me.draw_body(c, fr, it, bp, xf, page); + c.pop_layer(); + c.pop_layer(); + }); + } else if g.center { self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), |_, c, fr| { c.set_transform(fr.view); c.push_layer(None, None, None, None, Some(blur(sigma))); @@ -542,6 +583,30 @@ fn offscreen(reach: Rect, spread: f64, width: u16, height: u16) -> Option= 1.0 && h >= 1.0).then_some(Offscreen { x0, y0, scale, width: w as u16, height: h as u16 }) } +/// `pm`'s coverage shrunk by `r` pixels: each pixel takes the smallest alpha among itself and the +/// points on circles of `r` and `r / 2` around it, as white premultiplied by that alpha. Pixels +/// outside `pm` count as transparent. +fn erode(pm: &vello_cpu::Pixmap, r: f64) -> vello_cpu::Pixmap { + let (w, h) = (i64::from(pm.width()), i64::from(pm.height())); + let src = pm.data(); + let mut taps = vec![(0_i64, 0_i64)]; + for k in 0..16_u8 { + let d = if k % 2 == 0 { r } else { r / 2.0 }; + let a = f64::from(k) * std::f64::consts::PI / 8.0; + taps.push(((a.cos() * d).round() as i64, (a.sin() * d).round() as i64)); + } + taps.dedup(); + let alpha = |x: i64, y: i64| if x < 0 || y < 0 || x >= w || y >= h { 0 } else { src[(y * w + x) as usize].a }; + let mut data = Vec::with_capacity(src.len()); + for y in 0..h { + for x in 0..w { + let a = taps.iter().map(|&(dx, dy)| alpha(x.saturating_add(dx), y.saturating_add(dy))).min().unwrap_or(0); + data.push(vello_cpu::color::PremulRgba8 { r: a, g: a, b: a, a }); + } + } + vello_cpu::Pixmap::from_parts(data, pm.width(), pm.height()) +} + #[cfg(test)] mod tests { #[test] @@ -621,6 +686,38 @@ mod tests { } } + /// An inner glow from the center on a frame holding an image whose left half is transparent + /// fills only the image's opaque part, fading toward that part's own edge. + #[test] + fn a_center_inner_glow_follows_a_transparent_images_edge() { + let (d, mut it) = half_image_frame(); + let g = &mut it.effects.inner_glow; + g.on = true; + g.center = true; + g.color = "[Black]".into(); + g.opacity = 1.0; + g.size = 20.0; + g.mode = Default::default(); + let lum = render_row(d, it); + // The transparent half shows the page: no glow painted there. + assert!(lum(150) > 700, "nothing over the transparent part: {}", lum(150)); + // The opaque part runs from x = 200 to 300: dark in its middle, light at its own left edge. + assert!(lum(250) + 60 < lum(204), "glow fades toward the image's own edge: {} vs {}", lum(250), lum(204)); + } + + /// Eroding keeps a half-transparent area at half coverage away from its edge and clears the + /// band along the edge. + #[test] + fn erosion_keeps_partial_coverage_and_clears_the_edge_band() { + let half = vello_cpu::color::PremulRgba8 { r: 128, g: 128, b: 128, a: 128 }; + let pm = vello_cpu::Pixmap::from_parts(vec![half; 40 * 40], 40, 40); + let out = erode(&pm, 5.0); + assert_eq!(out.sample(20, 20).a, 128); + assert_eq!(out.sample(2, 20).a, 0); + assert_eq!(out.sample(20, 37).a, 0); + assert_eq!(out.sample(6, 20).a, 128); + } + /// A 200 pt frame at (100, 100) holding a 20 × 20 image with a transparent left half and an /// opaque white right half, in a new document. fn half_image_frame() -> (designcraft_doc::Document, Item) { From c3a7deb21a34dc119198318049bff119aa32694a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 23:23:50 -0800 Subject: [PATCH 3/5] Render: satin on an image compares partial coverage Satin on a frame holding a graphic draws the object's coverage once offscreen and compares the two shifted copies pixel by pixel, taking the difference of their coverages. A partly transparent area that both copies cover equally gets no plain satin, so a uniformly half-transparent image no longer shows satin across its middle. Inverted satin keeps the object's coverage where the copies agree. The offscreen coverage for satin and for a center inner glow on a graphic now covers its whole area at a lower resolution when it would be wider than the largest raster side or hold more than 4096 x 4096 pixels. --- crates/render/src/fx.rs | 204 +++++++++++++++++++++++++++++++--------- 1 file changed, 162 insertions(+), 42 deletions(-) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 6e49577e4..ec4e1e60a 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -13,7 +13,8 @@ //! opacity rides on the filter colour's alpha. //! - Inner shadow: the inverse of the offset shape, blurred, clipped to the shape, on top. //! - Inner glow, bevel and emboss, satin: built like the inner shadow (an offset inverse of the -//! shape, blurred and clipped) with screen / multiply blending; satin XORs two offset copies. +//! shape, blurred and clipped) with screen / multiply blending; satin shows where two offset +//! copies' coverages differ. //! - 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. @@ -273,7 +274,7 @@ impl Renderer { let band = (bound(g.size) - choke).max(0.5); self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), |me, c, fr| { let spread = (sigma * 3.0 + band) / fr.px + 2.0; - let Some(o) = offscreen(fr.view.transform_rect_bbox(reach), spread, c.width(), c.height()) else { return }; + let Some(o) = mask_offscreen(fr.view.transform_rect_bbox(reach), spread, c.width(), c.height()) else { return }; 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)) * fr.view; me.draw_body(&mut off, &Frame { mt: false, view, px: fr.px * o.scale, ..*fr }, it, bp, xf, page); @@ -337,41 +338,32 @@ impl Renderer { let sigma = bound(st.size).max(0.0) / 2.0; let paint = color_of(&c, st.opacity.clamp(0.0, 1.0)); let invert = st.invert; - self.with_blended_filters(ctx, f, reach, sigma, Mix::Multiply, (bp, xf), |me, c, fr| { - // One copy of the shape moved by `by`: the shape in the satin colour, or what the - // object paints (coloured below). - let copy = |me: &mut Renderer, c: &mut RenderContext, by: Vec2| match painted { - Some((it, page)) => { - let moved = Frame { view: fr.view * Affine::translate(by), ..*fr }; - me.draw_body(c, &moved, it, bp, xf, page); - } - None => { - c.set_transform(fr.view * Affine::translate(by) * xf); - c.set_paint(paint); - c.fill_path(bp); - } - }; - c.set_transform(fr.view); - if painted.is_some() { + if let Some((it, page)) = painted { + // With a graphic, the object's coverage is drawn once offscreen and the two shifted + // copies are compared there, pixel by pixel, so a partly transparent pixel that + // both copies cover equally gets no satin. + self.with_blended_filters(ctx, f, reach, sigma, Mix::Multiply, (bp, xf), |me, c, fr| { + // The copies' shift on the device. + let shift = fr.view * off.to_point() - fr.view * kurbo::Point::ZERO; + let spread = (sigma * 3.0) / fr.px + shift.hypot() + 2.0; + let Some(o) = mask_offscreen(fr.view.transform_rect_bbox(reach), spread, c.width(), c.height()) else { return }; + let mut off_ctx = 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)) * fr.view; + me.draw_body(&mut off_ctx, &Frame { mt: false, view, px: fr.px * o.scale, ..*fr }, it, bp, xf, page); + off_ctx.flush(); + let mut pm = vello_cpu::Pixmap::new(o.width, o.height); + off_ctx.render(&mut pm, &mut me.resources); + let mask = satin_mask(&pm, shift / o.scale, invert); + // The satin colour where the copies differ, ... + c.set_transform(fr.view); c.push_layer(None, None, None, None, None); - } - c.push_layer(None, None, None, None, Some(blur(sigma))); - if painted.is_some() { - c.push_layer(None, None, None, None, None); - } - if invert { - copy(me, c, Vec2::ZERO); - } - c.set_transform(Affine::IDENTITY); - c.push_layer(None, Some(BlendMode::new(Mix::Normal, if invert { Compose::DestOut } else { Compose::SrcOver })), None, None, None); - copy(me, c, off); - c.set_transform(Affine::IDENTITY); - c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::Xor)), None, None, None); - copy(me, c, -off); - c.pop_layer(); - c.pop_layer(); - if let Some((it, page)) = painted { - // The satin colour where the copies cover, ... + c.push_layer(None, None, None, None, Some(blur(sigma))); + c.set_transform(Affine::translate((o.x0, o.y0)) * Affine::scale(o.scale)); + c.set_paint(vello_cpu::Image { + image: vello_cpu::ImageSource::Pixmap(std::sync::Arc::new(mask)), + sampler: peniko::ImageSampler::default(), + }); + c.fill_rect(&Rect::new(0.0, 0.0, f64::from(o.width), f64::from(o.height))); let pad = sigma * 4.0 + off.hypot() + 4.0 * fr.px; c.set_transform(Affine::IDENTITY); c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::SrcIn)), None, None, None); @@ -380,15 +372,37 @@ impl Renderer { c.fill_rect(&reach.inflate(pad, pad)); c.pop_layer(); c.pop_layer(); - c.pop_layer(); // ... blurred and kept only where the object paints. c.set_transform(Affine::IDENTITY); c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); me.draw_body(c, fr, it, bp, xf, page); c.pop_layer(); - } - c.pop_layer(); - }); + c.pop_layer(); + }); + } else { + self.with_blended_filters(ctx, f, reach, sigma, Mix::Multiply, (bp, xf), |_, c, fr| { + // One copy of the shape in the satin colour, moved by `by`. + let copy = |c: &mut RenderContext, by: Vec2| { + c.set_transform(fr.view * Affine::translate(by) * xf); + c.set_paint(paint); + c.fill_path(bp); + }; + c.set_transform(fr.view); + c.push_layer(None, None, None, None, Some(blur(sigma))); + if invert { + copy(c, Vec2::ZERO); + } + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, if invert { Compose::DestOut } else { Compose::SrcOver })), None, None, None); + copy(c, off); + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::Xor)), None, None, None); + copy(c, -off); + c.pop_layer(); + c.pop_layer(); + c.pop_layer(); + }); + } } // Bevel and Emboss (inner bevel): highlight from the lit side, shadow from the other. if e.bevel.on { @@ -583,6 +597,26 @@ fn offscreen(reach: Rect, spread: f64, width: u16, height: u16) -> Option= 1.0 && h >= 1.0).then_some(Offscreen { x0, y0, scale, width: w as u16, height: h as u16 }) } +/// The most pixels a coverage mask drawn offscreen ([`mask_offscreen`]) holds: 4096 × 4096. +const MAX_MASK_PIXELS: f64 = 4096.0 * 4096.0; + +/// The offscreen image an object's coverage is drawn in to build an effect's mask: `reach` (device +/// pixels) widened by `spread`, within the window widened the same way, as [`offscreen`] finds it, +/// at a resolution low enough that the whole area fits within [`crate::MAX_SIDE`] a side and +/// [`MAX_MASK_PIXELS`] in all. `None` when none of it shows. +fn mask_offscreen(reach: Rect, spread: f64, width: u16, height: u16) -> Option { + 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); + let (x0, y0) = (r.x0.floor(), r.y0.floor()); + let (w, h) = (r.x1.ceil() - x0, r.y1.ceil() - y0); + if !(w >= 1.0 && h >= 1.0) { + return None; + } + let scale = (spread / MAX_BLUR_SPREAD).max(1.0).max(w.max(h) / crate::MAX_SIDE as f64).max((w * h / MAX_MASK_PIXELS).sqrt()); + let side = |len: f64| (len / scale).ceil().clamp(1.0, crate::MAX_SIDE as f64); + Some(Offscreen { x0, y0, scale, width: side(w) as u16, height: side(h) as u16 }) +} + /// `pm`'s coverage shrunk by `r` pixels: each pixel takes the smallest alpha among itself and the /// points on circles of `r` and `r / 2` around it, as white premultiplied by that alpha. Pixels /// outside `pm` count as transparent. @@ -607,6 +641,35 @@ fn erode(pm: &vello_cpu::Pixmap, r: f64) -> vello_cpu::Pixmap { vello_cpu::Pixmap::from_parts(data, pm.width(), pm.height()) } +/// The satin coverage of `pm`'s two copies moved by `shift` and `-shift` pixels: at each pixel, +/// how much the copies' coverages differ, or with `invert`, `pm`'s own coverage where they +/// agree, as white premultiplied by that amount. Copies are sampled bilinearly; pixels outside +/// `pm` count as transparent. +fn satin_mask(pm: &vello_cpu::Pixmap, shift: Vec2, invert: bool) -> vello_cpu::Pixmap { + let (w, h) = (i64::from(pm.width()), i64::from(pm.height())); + let src = pm.data(); + let alpha = |x: i64, y: i64| if x < 0 || y < 0 || x >= w || y >= h { 0.0 } else { f64::from(src[(y * w + x) as usize].a) / 255.0 }; + let sample = |x: f64, y: f64| { + let (fx, fy) = (x.floor(), y.floor()); + let (tx, ty) = (x - fx, y - fy); + let (ix, iy) = (fx as i64, fy as i64); + let top = alpha(ix, iy) * (1.0 - tx) + alpha(ix.saturating_add(1), iy) * tx; + let bottom = alpha(ix, iy.saturating_add(1)) * (1.0 - tx) + alpha(ix.saturating_add(1), iy.saturating_add(1)) * tx; + top * (1.0 - ty) + bottom * ty + }; + let mut data = Vec::with_capacity(src.len()); + for y in 0..h { + for x in 0..w { + let (px, py) = (x as f64, y as f64); + let diff = (sample(px - shift.x, py - shift.y) - sample(px + shift.x, py + shift.y)).abs(); + let v = if invert { alpha(x, y) * (1.0 - diff) } else { diff }; + let a = (v.clamp(0.0, 1.0) * 255.0).round() as u8; + data.push(vello_cpu::color::PremulRgba8 { r: a, g: a, b: a, a }); + } + } + vello_cpu::Pixmap::from_parts(data, pm.width(), pm.height()) +} + #[cfg(test)] mod tests { #[test] @@ -641,6 +704,21 @@ mod tests { assert_eq!(offscreen(object, 15_002.0, 1440, 900), Some(wide)); } + /// A coverage mask covers the whole widened area at a lower resolution when it is wider than + /// `MAX_SIDE` or holds more than `MAX_MASK_PIXELS`; a small one stays at full resolution. + #[test] + fn a_coverage_mask_covers_its_whole_area_within_its_pixel_budget() { + let small = Offscreen { x0: 140.0, y0: 140.0, scale: 1.0, width: 220, height: 220 }; + assert_eq!(mask_offscreen(Rect::new(150.0, 150.0, 350.0, 350.0), 10.0, 1440, 900), Some(small)); + for (reach, w, h) in [(Rect::new(0.0, 0.0, 65_535.0, 100.0), u16::MAX, 100), (Rect::new(0.0, 0.0, 20_000.0, 20_000.0), 20_000, 20_000)] { + let o = mask_offscreen(reach, 10.0, w, h).unwrap(); + assert_eq!((o.x0, o.y0), (-10.0, -10.0)); + assert!(o.x0 + f64::from(o.width) * o.scale >= reach.x1 + 10.0, "{o:?}"); + assert!(o.y0 + f64::from(o.height) * o.scale >= reach.y1 + 10.0, "{o:?}"); + assert!(f64::from(o.width) * f64::from(o.height) <= MAX_MASK_PIXELS + f64::from(o.width) + f64::from(o.height), "{o:?}"); + } + } + /// An inner shadow on a frame holding an image whose left half is transparent: the shadow /// lines the image's opaque part (its edge in the middle of the frame), not only the frame. #[test] @@ -686,6 +764,43 @@ mod tests { } } + /// Satin on a frame holding a uniformly half-transparent image: where both copies cover the + /// same partial coverage (all of the image with a distance of 0, its middle with a short one), + /// plain satin leaves the image as it is; inverted satin shades it. + #[test] + fn satin_leaves_a_uniform_half_transparent_image_plain() { + for (distance, invert) in [(0.0, false), (40.0, false), (0.0, true)] { + let (d, mut it) = image_frame(|_| image::Rgba([255, 255, 255, 128])); + let s = &mut it.effects.satin; + s.on = true; + s.color = "[Black]".into(); + s.opacity = 1.0; + s.angle = 0.0; + s.distance = distance; + s.size = 4.0; + s.invert = invert; + let lum = render_row(d, it); + // The image is white over a white page; satin multiplies black over it. + let shaded = lum(200) < 700; + assert_eq!(shaded, invert, "distance {distance}, invert {invert}: lum at x = 200 is {}", lum(200)); + } + } + + /// The satin mask is the difference of the shifted coverages: none where both copies cover the + /// same half coverage, half along an edge only one copy reaches, and the inverse keeps the + /// pixmap's own coverage where they agree. + #[test] + fn the_satin_mask_compares_partial_coverage() { + let half = vello_cpu::color::PremulRgba8 { r: 128, g: 128, b: 128, a: 128 }; + let pm = vello_cpu::Pixmap::from_parts(vec![half; 40 * 40], 40, 40); + let plain = satin_mask(&pm, Vec2::new(3.0, 0.0), false); + assert_eq!(plain.sample(20, 20).a, 0); + assert_eq!(plain.sample(1, 20).a, 128); + let inverted = satin_mask(&pm, Vec2::new(3.0, 0.0), true); + assert_eq!(inverted.sample(20, 20).a, 128); + assert_eq!(inverted.sample(1, 20).a, 64); + } + /// An inner glow from the center on a frame holding an image whose left half is transparent /// fills only the image's opaque part, fading toward that part's own edge. #[test] @@ -721,12 +836,17 @@ mod tests { /// A 200 pt frame at (100, 100) holding a 20 × 20 image with a transparent left half and an /// opaque white right half, in a new document. fn half_image_frame() -> (designcraft_doc::Document, Item) { + image_frame(|x| if x < 10 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([255, 255, 255, 255]) }) + } + + /// A 200 pt frame at (100, 100) holding a 20 × 20 image whose pixels in column x are + /// `pixel(x)`, in a new document. + fn image_frame(pixel: impl Fn(u32) -> image::Rgba) -> (designcraft_doc::Document, Item) { use designcraft_doc::Document; use designcraft_doc::build::NewDocument; let mut d = Document::new(&NewDocument::default()); let lid = d.default_layer(); - // 20 × 20: transparent left half, opaque white right half. - let img = image::RgbaImage::from_fn(20, 20, |x, _| if x < 10 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([255, 255, 255, 255]) }); + let img = image::RgbaImage::from_fn(20, 20, |x, _| pixel(x)); 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()); From 7af4d75539a3618ed0fbddabf5e044777101e748 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:00:46 -0800 Subject: [PATCH 4/5] Render: plain satin with no distance on an image is not drawn Both shifted copies are the same at distance 0, so the coverage mask is empty. The effect is now skipped before the image is drawn offscreen and scanned. --- crates/render/src/fx.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index ec4e1e60a..9fae7d2e1 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -329,8 +329,10 @@ impl Renderer { } } // Satin: the shape shifted both ways along the angle, blurred, where exactly one covers. - // With a graphic, the shape is what the object paints, as for the inner shadow. + // With a graphic, the shape is what the object paints, as for the inner shadow. Plain + // satin on a graphic with no distance compares two equal copies and is not drawn. if e.satin.on + && !(painted.is_some() && !e.satin.invert && e.satin.distance == 0.0) && let Some(c) = doc.resolve_color(&e.satin.color, 1.0) { let st = &e.satin; From 5932810b1f302d2de2f957afd3f7a576aa3038ac Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:04:46 -0800 Subject: [PATCH 5/5] Render: a center inner glow on an image fades around small transparent holes The coverage was eroded by sampling two rings of points, which could miss a hole smaller than the gaps between them. Erosion now takes the smallest alpha within an octagon of the band's radius, in four passes along the axes and the diagonals. --- crates/render/src/fx.rs | 91 +++++++++++++++++++++++++++++++++-------- 1 file changed, 73 insertions(+), 18 deletions(-) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 9fae7d2e1..e74630644 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -619,28 +619,67 @@ fn mask_offscreen(reach: Rect, spread: f64, width: u16, height: u16) -> Option vello_cpu::Pixmap { - let (w, h) = (i64::from(pm.width()), i64::from(pm.height())); - let src = pm.data(); - let mut taps = vec![(0_i64, 0_i64)]; - for k in 0..16_u8 { - let d = if k % 2 == 0 { r } else { r / 2.0 }; - let a = f64::from(k) * std::f64::consts::PI / 8.0; - taps.push(((a.cos() * d).round() as i64, (a.sin() * d).round() as i64)); + let (w, h) = (pm.width() as usize, pm.height() as usize); + let radius = r.max(0.0).round().min(f64::from(u16::MAX)) as usize; + // The octagon is a square of half-width `side` widened by `diag` diagonal steps each way along + // both diagonals, so it reaches `radius` along the axes and about `radius` diagonally. + let diag = (radius as f64 * (1.0 - std::f64::consts::FRAC_1_SQRT_2)).round() as usize; + let side = radius.saturating_sub(2 * diag); + let mut alpha: Vec = pm.data().iter().map(|p| p.a).collect(); + for (step, k) in [((1, 0), side), ((0, 1), side), ((1, 1), diag), ((1, -1), diag)] { + alpha = erode_lines(&alpha, w, h, step, k); } - taps.dedup(); - let alpha = |x: i64, y: i64| if x < 0 || y < 0 || x >= w || y >= h { 0 } else { src[(y * w + x) as usize].a }; - let mut data = Vec::with_capacity(src.len()); - for y in 0..h { - for x in 0..w { - let a = taps.iter().map(|&(dx, dy)| alpha(x.saturating_add(dx), y.saturating_add(dy))).min().unwrap_or(0); - data.push(vello_cpu::color::PremulRgba8 { r: a, g: a, b: a, a }); + let data = alpha.into_iter().map(|a| vello_cpu::color::PremulRgba8 { r: a, g: a, b: a, a }).collect(); + vello_cpu::Pixmap::from_parts(data, pm.width(), pm.height()) +} + +/// `src` (`w` × `h`, row by row) with each value replaced by the smallest within `k` steps of it +/// along the lines that `step` walks. Steps past the edge count as 0. +fn erode_lines(src: &[u8], w: usize, h: usize, step: (isize, isize), k: usize) -> Vec { + if k == 0 { + return src.to_vec(); + } + let inside = |x: isize, y: isize| x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h; + let mut out = vec![0; src.len()]; + let mut line = Vec::new(); + let mut window = std::collections::VecDeque::new(); + for y in 0..h as isize { + for x in 0..w as isize { + // Each line starts at the pixel whose step back leaves the image. + if inside(x - step.0, y - step.1) { + continue; + } + line.clear(); + let (mut cx, mut cy) = (x, y); + while inside(cx, cy) { + line.push(cy as usize * w + cx as usize); + (cx, cy) = (cx + step.0, cy + step.1); + } + // A running minimum over each pixel's window, with the values still in it in rising order. + window.clear(); + let mut next = 0; + for i in 0..line.len() { + while next < line.len() && next <= i + k { + let v = src[line[next]]; + while window.back().is_some_and(|&(_, b)| b >= v) { + window.pop_back(); + } + window.push_back((next, v)); + next += 1; + } + while window.front().is_some_and(|&(j, _)| j + k < i) { + window.pop_front(); + } + let whole = i >= k && i + k < line.len(); + out[line[i]] = if whole { window.front().map_or(0, |&(_, v)| v) } else { 0 }; + } } } - vello_cpu::Pixmap::from_parts(data, pm.width(), pm.height()) + out } /// The satin coverage of `pm`'s two copies moved by `shift` and `-shift` pixels: at each pixel, @@ -835,6 +874,22 @@ mod tests { assert_eq!(out.sample(6, 20).a, 128); } + /// Eroding clears every pixel within the radius of a one-pixel hole, along the axes and the + /// diagonals, and keeps the pixels beyond it. + #[test] + fn erosion_clears_around_a_small_hole() { + let opaque = vello_cpu::color::PremulRgba8 { r: 255, g: 255, b: 255, a: 255 }; + let mut data = vec![opaque; 40 * 40]; + data[20 * 40 + 20] = vello_cpu::color::PremulRgba8 { r: 0, g: 0, b: 0, a: 0 }; + let out = erode(&vello_cpu::Pixmap::from_parts(data, 40, 40), 5.0); + for (x, y) in [(20, 20), (24, 20), (16, 20), (20, 24), (20, 16), (23, 23), (17, 17), (23, 17), (17, 23)] { + assert_eq!(out.sample(x, y).a, 0, "({x}, {y}) is within 5 pixels of the hole"); + } + for (x, y) in [(27, 20), (20, 13), (25, 25), (14, 14)] { + assert_eq!(out.sample(x, y).a, 255, "({x}, {y}) is more than 5 pixels from the hole"); + } + } + /// A 200 pt frame at (100, 100) holding a 20 × 20 image with a transparent left half and an /// opaque white right half, in a new document. fn half_image_frame() -> (designcraft_doc::Document, Item) {