diff --git a/crates/render/src/dots.rs b/crates/render/src/dots.rs index b13cbdd57..f3effd1f1 100644 --- a/crates/render/src/dots.rs +++ b/crates/render/src/dots.rs @@ -57,7 +57,7 @@ pub(crate) fn draw(pix: &mut Pixmap, view: &View, d: &Dots) { let strong = |k: i64| k.rem_euclid(major) == 0; // The device-pixel span of the mark on line `k` along an axis `len` pixels long from `o`. let span = |k: i64, o: f64, len: i64| { - let a = ((k as f64 * d.step * s - o).round() as i64).saturating_sub(lead); + let a = (nearest_px(k as f64 * d.step * s - o) as i64).saturating_sub(lead); (a.max(0), a.saturating_add(side).min(len)) }; let row = w as usize * 4; @@ -92,6 +92,15 @@ pub(crate) fn draw(pix: &mut Pixmap, view: &View, d: &Dots) { } } +/// The device pixel nearest `p` pixels from the view's top-left. A mark halfway between two pixels +/// goes to the right (or lower) one on either side of the origin, and `p` is first rounded to +/// 1/65536 px: a view a whole number of pixels off (a pan, a band, a patch) reaches it with another +/// rounding error, and would otherwise put the marks that fall halfway on the other pixel. +fn nearest_px(p: f64) -> f64 { + const STEPS: f64 = 65_536.0; + ((p * STEPS).round() / STEPS + 0.5).floor() +} + /// Which line covers each device pixel along an axis: 0 none, 1 a faded one, 2 a strong one. type Cover = Vec; diff --git a/crates/render/src/dots_tests.rs b/crates/render/src/dots_tests.rs index 53d052062..a5ff2edc9 100644 --- a/crates/render/src/dots_tests.rs +++ b/crates/render/src/dots_tests.rs @@ -134,3 +134,27 @@ fn graph_paper_is_strong_every_major_line() { assert_eq!(at(x, -195), strong, "a strong line at {x}"); } } + +/// At a zoom of 1.0625, a step of 24 is 25.5 pixels: every second dot falls halfway between two +/// pixels. A view a whole number of pixels on (a pan, a band, a patch) has to put it on the same +/// one, on whichever side of that view's origin it is. +#[test] +fn dots_halfway_between_pixels_stay_put() { + let (d, page) = page_with_square(); + for lines in [false, true] { + let opts = Options { dots: Some(Dots { lines, ..dots() }), ..Default::default() }; + let s = 1.0625; + let full = View { origin: (0.0, 0.0), scale: s, width: 400, height: 300 }; + let a = render_view_single(&d, page, &full, &opts).unwrap(); + for dx in 1..100u32 { + let dy = (dx * 37) % 100; + let v = View { origin: (f64::from(dx) / s, f64::from(dy) / s), scale: s, width: 250, height: 180 }; + let b = render_view_single(&d, page, &v, &opts).unwrap(); + for y in 0..180 { + for x in 0..250 { + assert_eq!(rgba(&b, x, y), rgba(&a, x + dx, y + dy), "lines {lines}, a pan by ({dx}, {dy}), at {x}, {y}"); + } + } + } + } +}