Files
vectorcraft/crates/render/src/tests_fxzoom.rs
T
bflatasticandClaude Opus 5.5 584f08ae70 Fix #755: blurred type no longer shifts as the view moves
Gaussian Blur and Feather drew through a filter layer whose blur works on
a coarser grid tied to the canvas: the blurred art moved by up to about
a pixel, and its steps showed, as the view moved by parts of a pixel
(scrolling, zooming about the pointer). They now blur the content's own
pixels on the CPU, as the Photoshop-style effects already do: the same
look wherever the view is, cached while only the view moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:49 +07:00

143 lines
6.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Raster effects stay where they are, and look the same, at every zoom and pan (#755).
use serde_json::json;
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_doc::{Appearance, Effect, Node, NodeKind};
use vectorcraft_geom::{Point, shapes};
use super::*;
/// Type blurred by `radius` points (Gaussian Blur, or Feather when `feather`) over a rectangle
/// that overlays at half opacity; without the type when `radius` is `None`.
fn scene(radius: Option<f64>, feather: bool) -> Document {
let mut d = Document::new(200.0, 100.0);
let l = d.layers[0].id;
let mut back = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)),
Appearance::basic(Paint::solid(Color::rgb(0.2, 0.4, 0.9)), Paint::None, 0.0),
);
back.blend = BlendMode::Overlay;
back.opacity = 0.5;
d.insert(Some(l), 0, back).unwrap();
if let Some(radius) = radius {
let style = CharStyle { size: 36.0, ..CharStyle::default() };
let mut t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(30.0, 60.0), "Hello", style))));
let id = if feather { "stylize.feather" } else { "blur.gaussian" };
t.appearance.effects.push(Effect { id: id.into(), params: json!({"radius": radius}), visible: true });
d.insert(Some(l), 1, t).unwrap();
}
d
}
/// `d` with its layer's art in one group: the overlay and the type grouped, as in #755's report.
fn grouped(mut d: Document) -> Document {
let l = d.layers[0].id;
let id = d.alloc_id();
let members = d.children_mut(Some(l)).map(std::mem::take).unwrap();
d.insert(Some(l), 0, Node::new(id, NodeKind::Group { children: members, clip: false })).unwrap();
d
}
/// The canvas's options: art on nothing, no artboards.
fn screen() -> RenderOptions {
RenderOptions { background: None, artboards: false, ..Default::default() }
}
fn render(d: &Document, threads: u16, view: Affine, (w, h): (u32, u32)) -> Rendered {
let mut r = Renderer::new();
r.threads = threads;
r.render(d, w, h, view, &screen())
}
/// Where the type's ink is on the document (its centroid), rendered `threads`-threaded through
/// `view` on a `w` × `h` canvas.
fn ink_centre(radius: f64, threads: u16, view: Affine, size: (u32, u32)) -> Point {
let with = render(&scene(Some(radius), false), threads, view, size);
let without = render(&scene(None, false), threads, view, size);
let (mut sx, mut sy, mut sw) = (0.0, 0.0, 0.0);
for (i, (p, q)) in with.pixels.chunks(4).zip(without.pixels.chunks(4)).enumerate() {
let d: f64 = (0..4).map(|c| f64::from(p[c].abs_diff(q[c]))).sum();
let (x, y) = ((i % size.0 as usize) as f64 + 0.5, (i / size.0 as usize) as f64 + 0.5);
sx += d * x;
sy += d * y;
sw += d;
}
assert!(sw > 0.0, "the type shows");
view.inverse() * Point::new(sx / sw, sy / sw)
}
#[test]
fn blurred_type_stays_in_place_at_every_zoom_and_pan() {
for (radius, threads) in [(3.0, 0), (3.0, 4), (12.0, 0), (12.0, 4)] {
let base = ink_centre(radius, threads, Affine::IDENTITY, (200, 100));
// The whole document in view, at other zooms and moved by parts of a pixel.
for (zoom, pan) in
[(1.0, (13.3, 7.6)), (2.0, (41.7, 20.2)), (3.3, (15.25, 9.5)), (0.5, (10.4, 5.1)), (6.0, (30.6, 25.3)), (1.37, (0.5, 0.25))]
{
let view = Affine::translate(pan) * Affine::scale(zoom);
let size = ((200.0 * zoom + 2.0 * pan.0).ceil() as u32, (100.0 * zoom + 2.0 * pan.1).ceil() as u32);
let at = ink_centre(radius, threads, view, size);
assert!(
(at.x - base.x).abs() < 1.0 && (at.y - base.y).abs() < 1.0,
"radius {radius}, threads {threads}, zoom {zoom}, pan {pan:?}: {at:?} vs {base:?}"
);
}
}
}
/// Scrolling moves the blurred type with the page and nothing else: two views a whole number of
/// pixels apart draw the same pixels where they overlap, whether the type fits on the canvas or
/// runs off it.
#[test]
fn scrolling_moves_blurred_type_without_changing_it() {
for (feather, group) in [(false, false), (true, false), (false, true)] {
let d = scene(Some(12.0), feather);
let d = if group { grouped(d) } else { d };
for threads in [0, 4] {
for (zoom, (w, h)) in [(1.0, (240, 140)), (4.0, (500, 300)), (8.0, (400, 300))] {
let at = |dx: f64, dy: f64| render(&d, threads, Affine::translate((-dx, -dy)) * Affine::scale(zoom), (w, h));
let a = at(20.0, 10.0);
for (sx, sy) in [(1u32, 0u32), (3, 2), (37, 23), (101, 57)] {
let b = at(20.0 + f64::from(sx), 10.0 + f64::from(sy));
let mut worst = 0;
for y in sy..h {
for x in sx..w {
let i = ((y * w + x) * 4) as usize;
let j = (((y - sy) * w + x - sx) * 4) as usize;
for c in 0..4 {
worst = worst.max(a.pixels[i + c].abs_diff(b.pixels[j + c]));
}
}
}
assert!(
worst <= 3,
"feather {feather}, grouped {group}, threads {threads}, zoom {zoom}, scrolled by ({sx}, {sy}): a pixel changed by {worst}"
);
}
}
}
}
}
/// Scrolling by parts of a pixel (a trackpad, a zoom about the pointer) moves heavily blurred type
/// by just that: its ink stays put on the page within a tenth of a pixel, at any zoom.
#[test]
fn blurred_type_doesnt_wobble_as_the_view_moves_by_parts_of_a_pixel() {
for threads in [0, 4] {
for zoom in [1.0, 4.0] {
let at = |p: f64| {
ink_centre(30.0, threads, Affine::translate((400.0 - 75.0 * zoom + p, 330.0 - 48.0 * zoom + p)) * Affine::scale(zoom), (800, 660))
};
let base = at(0.0);
for k in 1..10 {
let c = at(f64::from(k) * 0.1);
// How far it moved on the screen, besides the view's own move.
let (dx, dy) = ((c.x - base.x) * zoom, (c.y - base.y) * zoom);
assert!(dx.abs() < 0.1 && dy.abs() < 0.1, "threads {threads}, zoom {zoom}, moved by {}: off by ({dx}, {dy}) px", f64::from(k) * 0.1);
}
}
}
}