mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-14 14:22:39 +00:00
Merge pull request #2671 from dotCooCoo/fix/satin-follows-image-edge
Render: satin and center inner glow on an image follow its painted edge
This commit is contained in:
1 file changed
+383
-39
+383
-39
@@ -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.
|
||||
|
||||
@@ -278,7 +279,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) = 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);
|
||||
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)));
|
||||
@@ -300,7 +342,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. 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;
|
||||
@@ -308,27 +353,71 @@ 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| {
|
||||
c.set_transform(fr.view);
|
||||
c.push_layer(None, None, None, None, Some(blur(sigma)));
|
||||
if invert {
|
||||
c.set_transform(fr.view * xf);
|
||||
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)));
|
||||
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);
|
||||
c.set_transform(fr.view);
|
||||
c.set_paint(paint);
|
||||
c.fill_path(bp);
|
||||
}
|
||||
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);
|
||||
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);
|
||||
c.pop_layer();
|
||||
c.pop_layer();
|
||||
c.pop_layer();
|
||||
});
|
||||
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 {
|
||||
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 {
|
||||
@@ -760,6 +849,118 @@ fn fit(r: Rect, spread: f64) -> Option<Offscreen> {
|
||||
(w >= 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<Offscreen> {
|
||||
let screen = Rect::new(0.0, 0.0, f64::from(width), f64::from(height)).inflate(spread, spread);
|
||||
let r = reach.inflate(spread, spread).intersect(screen);
|
||||
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 within an octagon of
|
||||
/// radius `r` 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) = (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<u8> = 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);
|
||||
}
|
||||
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<u8> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 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]
|
||||
@@ -794,16 +995,165 @@ 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]
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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<u8>) -> (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.
|
||||
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());
|
||||
@@ -836,26 +1186,20 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
/// A document with one image frame casting a drop shadow, and the frame's id.
|
||||
|
||||
Reference in new issue
Block a user