M8: dots halfway between pixels stay put across pans, bands and patches

The board's dot grid puts each mark on the device pixel nearest its
page position, measured from the view's top-left. At a zoom of 1.0625 a
step of 24 is 25.5 pixels, so every second mark falls halfway between
two pixels, and a view a whole number of pixels on could put it on the
other one: `round` sends halves away from zero, so a mark left of the
view's origin went left and one right of it went right, and the origin
`(px + offset) / scale` reached the half with another rounding error.
A pan moved those marks by a pixel (up to 123 levels off).

Positions are now rounded to 1/65536 px and then half up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
samhyunandClaude Opus 5.5 committed 2026-11-01 18:21:00 +09:00
1 parent 53259cf50e
commit e39e563408
2 files changed
+34 -1

No files matched your search

+10 -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,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<u8>;
+24
View File
@@ -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}");
}
}
}
}
}