diff --git a/crates/render/src/dots.rs b/crates/render/src/dots.rs index b13cbdd57..c33e89a22 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,22 @@ pub(crate) fn draw(pix: &mut Pixmap, view: &View, d: &Dots) { } } +/// The first device pixel of a mark whose edge is `at` pixels from the page origin, in a view whose +/// top-left is `o` pixels from it: the pixel whose left (or top) edge is nearest, and the right (or +/// lower) one when two are as near. A view a whole number of pixels off (a pan, a band, a patch) +/// gets `o` back from its origin with a rounding error, so `at` and `o` are each rounded to +/// 1/65536 px first: the mark's own position decides the pixel, and the view only counts it out +/// from its corner. The mark's halves go up and the origin's down: far out, where the origin's +/// error is half a step, it still comes back to the whole pixel. Past 2³⁷ px the step is finer +/// than `f64` and values are used as they are. +fn nearest_px(at: f64, o: f64) -> f64 { + const STEPS: f64 = 65_536.0; + const EXACT: f64 = 137_438_953_472.0; // 2³⁷ + let up = |v: f64| if v.abs() < EXACT { (v * STEPS + 0.5).floor() / STEPS } else { v }; + let down = |v: f64| if v.abs() < EXACT { (v * STEPS - 0.5).ceil() / STEPS } else { v }; + (up(at) - down(o) + 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..75932a2bf 100644 --- a/crates/render/src/dots_tests.rs +++ b/crates/render/src/dots_tests.rs @@ -134,3 +134,103 @@ 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}"); + } + } + } + } +} + +/// A mark that rounds to a half step of 1/65536 px goes the same way on both sides of a view's +/// origin too: at a step of 25.5 − 2⁻¹⁷ px, the first mark is where 1/65536 px rounds up. +#[test] +fn dots_on_the_rounding_step_stay_put() { + let (d, page) = page_with_square(); + let step = 25.5 - 2f64.powi(-17); + for lines in [false, true] { + let opts = Options { dots: Some(Dots { step, lines, ..dots() }), ..Default::default() }; + let full = View { origin: (0.0, 0.0), scale: 1.0, width: 300, height: 200 }; + let a = render_view_single(&d, page, &full, &opts).unwrap(); + for pan in 1..60u32 { + let v = View { origin: (f64::from(pan), f64::from(pan)), scale: 1.0, width: 200, height: 120 }; + let b = render_view_single(&d, page, &v, &opts).unwrap(); + for y in 0..120 { + for x in 0..200 { + assert_eq!(rgba(&b, x, y), rgba(&a, x + pan, y + pan), "lines {lines}, a pan by {pan}, at {x}, {y}"); + } + } + } + } +} + +/// The view's origin is quantized apart from the marks: converted to pixels, a whole-pixel origin +/// comes back a hair off, and taken together with a mark one step short of a half it would push the +/// mark across. +#[test] +fn dots_one_rounding_step_short_of_a_half_stay_put() { + let (d, page) = page_with_square(); + let s = (75.5 - 2f64.powi(-17)) / 24.0; + for lines in [false, true] { + let opts = Options { dots: Some(Dots { lines, ..dots() }), ..Default::default() }; + let a = render_view_single(&d, page, &View { origin: (0.0, 0.0), scale: s, width: 300, height: 200 }, &opts).unwrap(); + for pan in 1..100u32 { + let v = View { origin: (f64::from(pan) / s, f64::from(pan) / s), scale: s, width: 200, height: 100 }; + let b = render_view_single(&d, page, &v, &opts).unwrap(); + for y in 0..100 { + for x in 0..200 { + assert_eq!(rgba(&b, x, y), rgba(&a, x + pan, y + pan), "lines {lines}, a pan by {pan}, at {x}, {y}"); + } + } + } + } +} + +/// Far out (some 4 × 10¹⁰ pixels), a whole-pixel origin comes back from page units exactly half a +/// rounding step off: rounded up, it would move the marks that fall halfway between pixels. +#[test] +fn far_out_dots_stay_put() { + let (d, page) = page_with_square(); + let s = 0.3; + let at = 42_536_140_695.0; + let opts = Options { dots: Some(Dots { step: 5.0, ..dots() }), ..Default::default() }; + let a = render_view_single(&d, page, &View { origin: (at / s, 0.0), scale: s, width: 420, height: 40 }, &opts).unwrap(); + for dx in 1..200u32 { + let v = View { origin: ((at + f64::from(dx)) / s, 0.0), scale: s, width: 200, height: 40 }; + let b = render_view_single(&d, page, &v, &opts).unwrap(); + for y in 0..40 { + for x in 0..200 { + assert_eq!(rgba(&b, x, y), rgba(&a, x + dx, y), "a pan by {dx}, at {x}, {y}"); + } + } + } +} + +/// Positions too far out to quantize draw nothing rather than a mark at the corner. +#[test] +fn dots_out_past_any_page_draw_nothing() { + let (d, page) = page_with_square(); + let plain = render_view_single(&d, page, &View { origin: (1e304, 0.0), scale: 1.0, width: 40, height: 40 }, &Options::default()).unwrap(); + for (step, x) in [(1e290, 1e304), (1e299, 1e305), (3e303, 1.5e308)] { + let opts = Options { dots: Some(Dots { step, ..dots() }), ..Default::default() }; + let p = render_view_single(&d, page, &View { origin: (x, 0.0), scale: 1.0, width: 40, height: 40 }, &opts).unwrap(); + assert_eq!(p.data(), plain.data(), "a step of {step} at {x}"); + } +}