Merge pull request #1796 from samhyun/fix/dots-half-pixel

M8: dots halfway between pixels stay put across pans, bands and patches
This commit is contained in:
BFlat
2026-11-07 12:07:14 +08:00
committed by GitHub
2 changed files with 117 additions and 1 deletions
+17 -1
View File
@@ -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<u8>;
+100
View File
@@ -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}");
}
}