Render: effect rasters drawn after a pattern fill keep their place and size

fx.rs draws blur, shadow and glow rasters as image paints that map 1:1
onto their rect, but only set the geometry transform: the paint
transform a pattern fill (or a radial or freeform gradient) left on the
context still applied, so in supersampled renders (raster exports) the
effect came out tiny and displaced. Both draws now reset the paint
transform first.

Fixes #1536
This commit is contained in:
Gert Pellin 2026-10-13 01:52:47 +02:00 committed by bflatastic
parent 05e321f4e0
commit 05fdd15b45
2 changed files with 56 additions and 0 deletions

View File

@ -274,6 +274,9 @@ fn draw_scaled(ctx: &mut RenderContext, image: std::sync::Arc<vello_cpu::Pixmap>
// On whole pixels at full size the cheapest sampler is exact.
let quality = if k < 1.0 { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low };
let sampler = peniko::ImageSampler { quality, ..Default::default() };
// The raster maps 1:1 onto the rect: a paint transform left by an earlier fill (a pattern,
// radial or freeform gradient) would resample it (#1536).
ctx.reset_paint_transform();
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler });
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h));
ctx.set_transform(Affine::IDENTITY);
@ -617,6 +620,7 @@ impl Renderer {
ctx.set_transform(Affine::translate((x, y)));
ctx.set_blend_mode(blend_mode(mode));
let sampler = peniko::ImageSampler { quality: peniko::ImageQuality::Low, ..Default::default() };
ctx.reset_paint_transform();
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler });
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h));
ctx.set_blend_mode(blend_mode(vectorcraft_doc::color::BlendMode::Normal));

View File

@ -274,6 +274,58 @@ mod tests {
p[0] > 200 && p[1] > 200 && p[2] > 200
}
/// #1536: a raster effect drawn after a pattern fill is the same as without the pattern (the
/// pattern's paint transform must not resample the effect's raster).
#[test]
fn effects_after_a_pattern_fill_keep_their_place_and_size() {
let fx = |id: &str, params: serde_json::Value| vectorcraft_doc::Effect { id: id.into(), params, visible: true };
for effect in [
fx("blur.gaussian", serde_json::json!({"radius": 4.0})),
fx("stylize.dropShadow", serde_json::json!({"x": 6, "y": 6, "blur": 3, "opacity": 100})),
] {
let make = |with_pattern: bool| {
let mut d = doc_with_pattern(TileType::Grid);
let l = d.layers[0].id;
let filled = d.children(Some(l)).unwrap()[0].id;
d.remove(filled).unwrap();
if with_pattern {
let id = d.alloc_id();
let n = Node::path(
id,
shapes::rectangle(Rect::new(0.0, 0.0, 80.0, 80.0)),
Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0),
);
d.insert(Some(l), 0, n).unwrap();
}
let id = d.alloc_id();
let mut n = Node::path(
id,
shapes::ellipse(Rect::new(120.0, 110.0, 180.0, 170.0)),
Appearance::basic(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0),
);
n.appearance.effects.push(effect.clone());
let at = d.children(Some(l)).map_or(0, Vec::len);
d.insert(Some(l), at, n).unwrap();
d
};
// Supersampled, as raster exports draw.
let one = |d: &Document| {
let mut r = Renderer::new();
r.render(
d,
200,
200,
Affine::IDENTITY,
&RenderOptions { background: Some([255, 255, 255, 255]), supersample: true, ..Default::default() },
)
};
let (with, without) = (one(&make(true)), one(&make(false)));
for (x, y) in [(150, 140), (125, 140), (176, 160), (150, 172), (184, 176)] {
assert_eq!(with.pixel(x, y), without.pixel(x, y), "{} at ({x}, {y})", effect.id);
}
}
}
#[test]
fn pattern_fill_is_periodic() {
let d = doc_with_pattern(TileType::Grid);