pixelcoords_core/snap.rs
1//! Edge snapping: pull a point onto the UI edges already in the frozen
2//! image.
3//!
4//! A frozen screen is the ideal substrate for this — the image cannot
5//! change under the detector, so a snap is reproducible and a test can
6//! assert exactly where it lands. Detection is pixels only: no
7//! accessibility tree, no UI toolkit introspection, which is what keeps
8//! it platform-free and equally honest on a native app, a game, and a
9//! screenshot of either.
10//!
11//! The two axes are independent. `x` snaps to **vertical** edges (a
12//! horizontal luma gradient) and `y` to **horizontal** ones, so dragging
13//! a rect corner onto a button corner is one gesture that happens to
14//! satisfy two separate searches.
15
16use crate::geometry::Point;
17use crate::locate::GrayImage;
18
19/// Gradient strength below which nothing is an edge, on the 0–255 scale
20/// `EdgeMap` quantizes to. Roughly a 3% luma step across two pixels: it
21/// keeps compression noise and subtle background gradients from
22/// capturing the cursor, while every real UI border clears it easily.
23///
24/// This is a floor under the adaptive threshold, not the threshold —
25/// see [`EdgeMap::threshold`].
26pub const MIN_GRADIENT: u8 = 20;
27
28/// The percentage of the frame an adaptive threshold sits above. UI
29/// screenshots are mostly flat, so the interesting gradients live in the
30/// last couple of percent — and on a busy frame, where far more than 2%
31/// of pixels carry *some* gradient, this is what keeps texture and
32/// anti-aliasing from being offered as edges.
33///
34/// A whole percent rather than a fraction so the percentile is integer
35/// arithmetic: the counts are exact, and a float round-trip through
36/// millions of samples would only add a way to be off by one.
37const EDGE_PERCENT: u64 = 98;
38
39/// Rows sampled either side of the query row when scoring a column (and
40/// columns either side when scoring a row). A real edge runs through all
41/// of them; a lone speckle is averaged away.
42const SCORE_HALF_SPAN: i32 = 2;
43
44/// How far the reported edge extent is traced before giving up. The span
45/// exists so the overlay can show *what* was snapped to; tracing a
46/// full-height window border to both screen edges would be honest but
47/// useless as feedback, and unbounded work per mouse move.
48const MAX_SPAN: i32 = 160;
49
50/// A snap that happened: where the point moved to, and how far the edge
51/// it landed on runs.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct SnapHit {
54 /// The snapped coordinate on the queried axis.
55 pub at: i32,
56 /// Inclusive extent of the edge along the *other* axis, for drawing
57 /// feedback. Always contains the query point's other coordinate.
58 pub span: (i32, i32),
59}
60
61/// The result of asking where a point wants to go. Each axis answers on
62/// its own: a corner snaps both, a vertical border snaps only `x`, and
63/// open space snaps neither.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
65pub struct Snap {
66 pub x: Option<SnapHit>,
67 pub y: Option<SnapHit>,
68}
69
70impl Snap {
71 /// `p` with each axis moved to its snapped value, leaving axes that
72 /// found nothing alone.
73 #[must_use]
74 pub fn apply(self, p: Point) -> Point {
75 Point::new(
76 self.x.map_or(p.x, |hit| hit.at),
77 self.y.map_or(p.y, |hit| hit.at),
78 )
79 }
80}
81
82/// Per-pixel edge strength for one frozen frame, precomputed once.
83///
84/// Two maps, one per axis, quantized to `u8` — a frame's worth of `f32`
85/// pairs is tens of megabytes per monitor, and the extra precision buys
86/// nothing when the answer is an integer pixel column.
87#[derive(Debug, Clone)]
88pub struct EdgeMap {
89 w: usize,
90 h: usize,
91 /// Horizontal gradient: high on **vertical** edges, so this is what
92 /// an `x` snap searches.
93 gx: Vec<u8>,
94 /// Vertical gradient: high on **horizontal** edges, searched by `y`.
95 gy: Vec<u8>,
96 threshold: u8,
97}
98
99impl EdgeMap {
100 /// Detect edges in a frozen frame.
101 ///
102 /// The operator is a **forward** difference — `I(x) - I(x-1)` —
103 /// weighted 3:10:3 across the neighbouring rows in the Scharr manner.
104 /// The weighting rejects single-pixel noise without smearing the
105 /// edge's position; the forward difference is what makes the position
106 /// unambiguous. A centered difference peaks equally on both pixels of
107 /// a one-pixel step, and a snap that lands on whichever of the two the
108 /// cursor happened to approach from is not an answer a user can rely
109 /// on. Position is the whole product here: an edge detected one pixel
110 /// off is worse than no edge at all, because the user trusted it.
111 ///
112 /// The convention this fixes is that a boundary sits on the **first
113 /// pixel of the new region**. Snapping both sides of a 40px-wide
114 /// button therefore gives 20 and 60, and the rect drawn between them
115 /// is 40 wide — the button's true width, not one pixel short.
116 #[must_use]
117 pub fn new(gray: &GrayImage) -> Self {
118 let (w, h) = (gray.w, gray.h);
119 let mut gx = vec![0u8; w * h];
120 let mut gy = vec![0u8; w * h];
121 // The border ring keeps its zero: a 3x3 operator has no answer
122 // there, and the screen edge is not a UI edge worth snapping to.
123 for y in 1..h.saturating_sub(1) {
124 for x in 1..w.saturating_sub(1) {
125 let at = |dx: usize, dy: usize| gray.px[(y + dy - 1) * w + (x + dx - 1)];
126 let (tl, tc, tr) = (at(0, 0), at(1, 0), at(2, 0));
127 let (ml, mc, mr) = (at(0, 1), at(1, 1), at(2, 1));
128 let (bl, bc) = (at(0, 2), at(1, 2));
129 let hx = 3.0f32.mul_add(tc - tl, 10.0f32.mul_add(mc - ml, 3.0 * (bc - bl)));
130 let hy = 3.0f32.mul_add(ml - tl, 10.0f32.mul_add(mc - tc, 3.0 * (mr - tr)));
131 gx[y * w + x] = quantize(hx);
132 gy[y * w + x] = quantize(hy);
133 }
134 }
135 let threshold = adaptive_threshold(&gx, &gy);
136 Self {
137 w,
138 h,
139 gx,
140 gy,
141 threshold,
142 }
143 }
144
145 /// The strength a gradient must reach to count as an edge: the
146 /// [`EDGE_PERCENT`] percentile of this frame's own gradients, floored
147 /// at [`MIN_GRADIENT`].
148 ///
149 /// Relative, because an absolute cut that works on a light theme
150 /// finds nothing on a dark one. Floored, because a nearly blank
151 /// screen's 98th percentile is noise, and snapping to noise is worse
152 /// than not snapping.
153 #[must_use]
154 pub const fn threshold(&self) -> u8 {
155 self.threshold
156 }
157
158 /// Where `p` wants to go, searching `radius` pixels either way on
159 /// each axis independently.
160 ///
161 /// A non-positive radius disables snapping outright rather than
162 /// searching a degenerate window.
163 #[must_use]
164 pub fn snap(&self, p: Point, radius: i32) -> Snap {
165 if radius <= 0 {
166 return Snap::default();
167 }
168 Snap {
169 x: self.snap_x(p, radius),
170 y: self.snap_y(p, radius),
171 }
172 }
173
174 /// Just the vertical-edge search, for callers that move one axis at
175 /// a time — sliding a shape sideways onto an alignment, say.
176 #[must_use]
177 pub fn snap_x(&self, p: Point, radius: i32) -> Option<SnapHit> {
178 (radius > 0).then(|| self.snap_axis(p, radius, Axis::X))?
179 }
180
181 /// Just the horizontal-edge search. See [`Self::snap_x`].
182 #[must_use]
183 pub fn snap_y(&self, p: Point, radius: i32) -> Option<SnapHit> {
184 (radius > 0).then(|| self.snap_axis(p, radius, Axis::Y))?
185 }
186
187 fn snap_axis(&self, p: Point, radius: i32, axis: Axis) -> Option<SnapHit> {
188 let (along, across) = match axis {
189 Axis::X => (p.x, p.y),
190 Axis::Y => (p.y, p.x),
191 };
192 let limit = match axis {
193 Axis::X => self.w,
194 Axis::Y => self.h,
195 };
196 let limit = i32::try_from(limit).unwrap_or(i32::MAX);
197 // The scan runs one past the radius on each side so a candidate
198 // at exactly the radius can still be compared against its outer
199 // neighbour and recognized as a local maximum.
200 let lo = (along - radius - 1).max(0);
201 let hi = (along + radius + 1).min(limit - 1);
202 // Scored once, then split: the local-maximum test compares
203 // strengths across neighbours, while only the winner's anchor is
204 // ever needed.
205 let scan: Vec<(u16, i32)> = (lo..=hi)
206 .map(|v| self.score(v, across, radius, axis))
207 .collect();
208 let scores: Vec<u16> = scan.iter().map(|&(strength, _)| strength).collect();
209 let mut best: Option<(i32, u16, i32, i32)> = None;
210 for (i, &score) in scores.iter().enumerate() {
211 let v = lo + i32::try_from(i).unwrap_or(0);
212 if (v - along).abs() > radius || u16::from(self.threshold) > score {
213 continue;
214 }
215 // A wide anti-aliased edge scores highly across two or three
216 // columns; without the local-maximum test the snap would
217 // land on whichever of them the cursor happened to be nearer,
218 // which is not a repeatable answer.
219 let left = i.checked_sub(1).map_or(0, |j| scores[j]);
220 let right = scores.get(i + 1).copied().unwrap_or(0);
221 if score < left || score < right {
222 continue;
223 }
224 let distance = (v - along).abs();
225 let better = best.is_none_or(|(_, best_score, best_distance, _)| {
226 // Nearest wins; a tie in distance breaks toward the
227 // stronger edge, so a corner does not wobble between two
228 // equidistant borders run to run. Two edges equally near
229 // *and* equally strong are a real tie, and the scan runs
230 // low to high, so the lower coordinate keeps it. That is
231 // arbitrary but deterministic, which is the property that
232 // matters — note it is also orientation-bearing: mirror
233 // the image and the mirror's lower coordinate wins, which
234 // is the reflection of the *other* edge.
235 distance < best_distance || (distance == best_distance && score > best_score)
236 });
237 if better {
238 best = Some((v, score, distance, scan[i].1));
239 }
240 }
241 let (at, _, _, anchor) = best?;
242 Some(SnapHit {
243 at,
244 span: self.trace_span(at, anchor, axis),
245 })
246 }
247
248 /// A column's (or row's) edge score near the query point.
249 ///
250 /// Two nested windows, and both are load-bearing. The inner one
251 /// averages [`SCORE_HALF_SPAN`] pixels either side so an edge that
252 /// survives a few pixels outscores an isolated bright one. The outer
253 /// takes the **best** such average anywhere within the snap radius,
254 /// which is what makes a corner reachable: approach one diagonally
255 /// from outside and neither edge passes through the query's own row
256 /// or column, so a score sampled only there would find nothing and
257 /// the corner — the single most valuable thing to snap to — would be
258 /// the one place snapping failed.
259 /// Returns the score and the position along the perpendicular axis
260 /// where it was found. The position is what `trace_span` starts from:
261 /// tracing from the *query* instead would begin off the edge whenever
262 /// the outer window is what found it — a corner approached from
263 /// outside — and stop immediately, reporting a one-pixel span and
264 /// drawing the user a dot instead of the edge that caught them.
265 fn score(&self, along: i32, across: i32, radius: i32, axis: Axis) -> (u16, i32) {
266 let mut best = (0u16, across);
267 for offset in -radius..=radius {
268 let center = across + offset;
269 let mut total = 0u32;
270 let mut count = 0u32;
271 for d in -SCORE_HALF_SPAN..=SCORE_HALF_SPAN {
272 let Some(g) = self.gradient(along, center + d, axis) else {
273 continue;
274 };
275 total += u32::from(g);
276 count += 1;
277 }
278 if count == 0 {
279 continue;
280 }
281 let score = u16::try_from(total / count).unwrap_or(u16::MAX);
282 // `>`, not `>=`: among equal windows the one nearest the
283 // query wins, since the scan starts at `-radius` and walks
284 // toward it. That keeps the drawn guide anchored beside the
285 // pointer rather than at the far end of a long border.
286 if score > best.0
287 || (score == best.0 && (center - across).abs() < (best.1 - across).abs())
288 {
289 best = (score, center);
290 }
291 }
292 best
293 }
294
295 /// How far the edge at `along` runs either side of `across`, stopping
296 /// where the gradient falls below half the threshold. Half, not the
297 /// threshold itself: an edge fades at its ends, and cutting at the
298 /// full threshold would draw feedback visibly shorter than what the
299 /// eye reads as the edge.
300 fn trace_span(&self, along: i32, across: i32, axis: Axis) -> (i32, i32) {
301 let floor = u16::from(self.threshold) / 2;
302 let mut lo = across;
303 let mut hi = across;
304 for step in 1..=MAX_SPAN {
305 if lo == across - step + 1
306 && self
307 .gradient(along, across - step, axis)
308 .is_some_and(|g| u16::from(g) >= floor)
309 {
310 lo = across - step;
311 }
312 if hi == across + step - 1
313 && self
314 .gradient(along, across + step, axis)
315 .is_some_and(|g| u16::from(g) >= floor)
316 {
317 hi = across + step;
318 }
319 }
320 (lo, hi)
321 }
322
323 fn gradient(&self, along: i32, across: i32, axis: Axis) -> Option<u8> {
324 let (x, y) = match axis {
325 Axis::X => (along, across),
326 Axis::Y => (across, along),
327 };
328 let x = usize::try_from(x).ok()?;
329 let y = usize::try_from(y).ok()?;
330 if x >= self.w || y >= self.h {
331 return None;
332 }
333 let index = y * self.w + x;
334 Some(match axis {
335 Axis::X => self.gx[index],
336 Axis::Y => self.gy[index],
337 })
338 }
339}
340
341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342enum Axis {
343 X,
344 Y,
345}
346
347fn quantize(gradient: f32) -> u8 {
348 // The Scharr kernel's weights sum to 16 on each side, so a full
349 // black-to-white step saturates at 16.0 in luma units of [0, 1].
350 let normalized = (gradient.abs() / 16.0).clamp(0.0, 1.0);
351 (normalized * 255.0).round() as u8
352}
353
354/// The [`EDGE_PERCENT`] percentile of the whole frame's gradients,
355/// floored at [`MIN_GRADIENT`].
356///
357/// Over *every* pixel, flat ones included. Excluding them would make the
358/// percentile a statistic about edge strengths, and on a frame whose
359/// edges are all roughly equal that lands on the strongest one and
360/// rejects the rest — including the slightly-diluted score a corner
361/// produces, which is precisely the case snapping exists for. Including
362/// them makes it a statistic about the frame: sparse frames fall through
363/// to the floor, and busy ones get a genuinely selective cut.
364///
365/// Histogrammed rather than sorted: the values are already `u8`, so 256
366/// buckets give the exact percentile in one pass instead of sorting
367/// millions of samples per frame.
368fn adaptive_threshold(gx: &[u8], gy: &[u8]) -> u8 {
369 let mut histogram = [0u64; 256];
370 let mut total = 0u64;
371 for &g in gx.iter().chain(gy) {
372 histogram[g as usize] += 1;
373 total += 1;
374 }
375 if total == 0 {
376 return MIN_GRADIENT;
377 }
378 let target = total * EDGE_PERCENT / 100;
379 let mut seen = 0u64;
380 for (value, &count) in histogram.iter().enumerate() {
381 seen += count;
382 if seen >= target {
383 return u8::try_from(value).unwrap_or(u8::MAX).max(MIN_GRADIENT);
384 }
385 }
386 MIN_GRADIENT
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 /// A dark frame with a light rectangle: four crisp edges at known
394 /// coordinates, which is exactly what a snap must find.
395 fn button(w: usize, h: usize, x0: usize, y0: usize, x1: usize, y1: usize) -> GrayImage {
396 let mut px = vec![0.1f32; w * h];
397 for y in y0..y1 {
398 for x in x0..x1 {
399 px[y * w + x] = 0.9;
400 }
401 }
402 GrayImage { w, h, px }
403 }
404
405 fn scaled(gray: &GrayImage, factor: usize) -> GrayImage {
406 let (w, h) = (gray.w * factor, gray.h * factor);
407 let mut px = vec![0.0f32; w * h];
408 for y in 0..h {
409 for x in 0..w {
410 px[y * w + x] = gray.px[(y / factor) * gray.w + (x / factor)];
411 }
412 }
413 GrayImage { w, h, px }
414 }
415
416 #[test]
417 fn a_corner_snaps_on_both_axes_from_any_approach() {
418 let map = EdgeMap::new(&button(80, 60, 20, 15, 60, 45));
419 for (dx, dy) in [(-4, -4), (4, 4), (-4, 4), (4, -4), (0, 3), (3, 0)] {
420 let snap = map.snap(Point::new(20 + dx, 15 + dy), 6);
421 assert_eq!(
422 snap.apply(Point::new(20 + dx, 15 + dy)),
423 Point::new(20, 15),
424 "approach ({dx}, {dy})"
425 );
426 }
427 }
428
429 #[test]
430 fn every_edge_of_the_button_is_found_on_its_own_axis() {
431 let map = EdgeMap::new(&button(80, 60, 20, 15, 60, 45));
432 // Left and right verticals: x snaps, y finds nothing mid-edge.
433 // The right boundary is 60, not 59: a boundary sits on the first
434 // pixel of the new region, so the snapped rect is 40 wide.
435 for x in [20, 60] {
436 let snap = map.snap(Point::new(x + 3, 30), 6);
437 assert_eq!(snap.x.map(|hit| hit.at), Some(x), "vertical at {x}");
438 assert_eq!(snap.y, None, "no horizontal edge at mid-height");
439 }
440 // Top and bottom horizontals.
441 for y in [15, 45] {
442 let snap = map.snap(Point::new(40, y + 3), 6);
443 assert_eq!(snap.y.map(|hit| hit.at), Some(y), "horizontal at {y}");
444 assert_eq!(snap.x, None, "no vertical edge at mid-width");
445 }
446 }
447
448 #[test]
449 fn nothing_outside_the_radius_captures_the_point() {
450 let map = EdgeMap::new(&button(80, 60, 20, 15, 60, 45));
451 let far = Point::new(35, 30);
452 assert_eq!(map.snap(far, 6), Snap::default());
453 assert_eq!(map.snap(far, 6).apply(far), far);
454 }
455
456 #[test]
457 fn a_low_contrast_edge_below_threshold_does_not_capture() {
458 // A 1% luma step: present, but not something a user pointed at.
459 let mut gray = GrayImage {
460 w: 80,
461 h: 60,
462 px: vec![0.50f32; 80 * 60],
463 };
464 for y in 0..60 {
465 for x in 30..80 {
466 gray.px[y * 80 + x] = 0.51;
467 }
468 }
469 let map = EdgeMap::new(&gray);
470 assert_eq!(map.snap(Point::new(28, 30), 6).x, None);
471 }
472
473 #[test]
474 fn snapping_survives_a_scale_change_landing_on_the_scaled_edge() {
475 let base = button(40, 30, 10, 8, 30, 22);
476 let map = EdgeMap::new(&scaled(&base, 2));
477 // The left edge is at 20 in the doubled image.
478 let snap = map.snap(Point::new(24, 30), 6);
479 assert_eq!(snap.x.map(|hit| hit.at), Some(20));
480 }
481
482 #[test]
483 fn a_flipped_image_flips_where_the_snap_lands() {
484 // Deliberately off-center, or mirroring would be a no-op.
485 let gray = button(80, 60, 15, 15, 45, 45);
486 let mut flipped = gray.clone();
487 for y in 0..60 {
488 for x in 0..80 {
489 flipped.px[y * 80 + x] = gray.px[y * 80 + (79 - x)];
490 }
491 }
492 let map = EdgeMap::new(&gray);
493 let mirror = EdgeMap::new(&flipped);
494 let hit = map.snap(Point::new(18, 30), 6).x.expect("left edge");
495 assert_eq!(hit.at, 15);
496 // A snapped coordinate is a boundary, not a pixel, and the two
497 // reflect differently: pixel `p` maps to `79 - p`, boundary `b`
498 // to `80 - b`. The query is a candidate boundary, so it reflects
499 // the second way — reflecting it as a pixel would land one off.
500 let mirrored = mirror.snap(Point::new(80 - 18, 30), 6).x.expect("mirrored");
501 assert_eq!(mirrored.at, 80 - hit.at);
502 }
503
504 #[test]
505 fn the_reported_span_covers_the_edge_and_stops_at_its_ends() {
506 let map = EdgeMap::new(&button(80, 60, 20, 15, 60, 45));
507 let hit = map.snap(Point::new(22, 30), 6).x.expect("left edge");
508 let (lo, hi) = hit.span;
509 assert!(lo <= 30 && hi >= 30, "span contains the query row");
510 // The button spans rows 15..45; the traced edge must not run the
511 // whole frame.
512 assert!(lo >= 12 && hi <= 47, "span {lo}..{hi} escaped the button");
513 }
514
515 #[test]
516 fn a_corner_approached_from_outside_still_reports_the_whole_edge() {
517 // The regression this exists for: approaching a corner
518 // diagonally from outside, the query's own row is off the edge
519 // entirely, so tracing the span from the query stopped
520 // immediately and reported a single pixel. The overlay then drew
521 // a dot instead of the edge that captured the point — the one
522 // case where seeing *what* caught you matters most.
523 let map = EdgeMap::new(&button(80, 60, 20, 15, 60, 45));
524 let outside = Point::new(16, 11);
525 let snap = map.snap(outside, 8);
526
527 let x = snap.x.expect("the left border");
528 assert_eq!(x.at, 20);
529 assert!(
530 x.span.1 - x.span.0 >= 20,
531 "vertical border runs ~30px, got {:?}",
532 x.span
533 );
534 let y = snap.y.expect("the top border");
535 assert_eq!(y.at, 15);
536 assert!(
537 y.span.1 - y.span.0 >= 30,
538 "horizontal border runs ~40px, got {:?}",
539 y.span
540 );
541 }
542
543 #[test]
544 fn a_flat_frame_offers_nothing_and_keeps_the_floor_threshold() {
545 let map = EdgeMap::new(&GrayImage {
546 w: 40,
547 h: 40,
548 px: vec![0.4f32; 40 * 40],
549 });
550 assert_eq!(map.threshold(), MIN_GRADIENT);
551 assert_eq!(map.snap(Point::new(20, 20), 8), Snap::default());
552 }
553
554 #[test]
555 fn a_nonpositive_radius_disables_snapping() {
556 let map = EdgeMap::new(&button(80, 60, 20, 15, 60, 45));
557 let on_the_edge = Point::new(21, 30);
558 assert_eq!(map.snap(on_the_edge, 0), Snap::default());
559 assert_eq!(map.snap(on_the_edge, -5), Snap::default());
560 assert_eq!(map.snap_x(on_the_edge, 0), None);
561 assert_eq!(map.snap_y(on_the_edge, -1), None);
562 }
563
564 #[test]
565 fn the_per_axis_searches_agree_with_the_combined_one() {
566 let map = EdgeMap::new(&button(80, 60, 20, 15, 60, 45));
567 let p = Point::new(23, 18);
568 let both = map.snap(p, 6);
569 assert_eq!(map.snap_x(p, 6), both.x);
570 assert_eq!(map.snap_y(p, 6), both.y);
571 }
572
573 #[test]
574 fn a_point_outside_the_frame_answers_without_panicking() {
575 let map = EdgeMap::new(&button(80, 60, 20, 15, 60, 45));
576 for p in [
577 Point::new(-100, -100),
578 Point::new(1000, 1000),
579 Point::new(-1, 30),
580 Point::new(79, 59),
581 ] {
582 let _ = map.snap(p, 8);
583 }
584 }
585
586 #[test]
587 fn a_one_pixel_frame_builds_an_empty_map() {
588 let map = EdgeMap::new(&GrayImage {
589 w: 1,
590 h: 1,
591 px: vec![0.5],
592 });
593 assert_eq!(map.snap(Point::new(0, 0), 4), Snap::default());
594 }
595
596 #[test]
597 fn snap_applies_only_the_axes_that_hit() {
598 let only_x = Snap {
599 x: Some(SnapHit {
600 at: 42,
601 span: (0, 9),
602 }),
603 y: None,
604 };
605 assert_eq!(only_x.apply(Point::new(40, 7)), Point::new(42, 7));
606 }
607
608 #[test]
609 fn equidistant_edges_break_toward_the_stronger_one() {
610 // A strong step at 25 and a weak one at 35, both 5px from the
611 // query — without a deliberate tiebreak a corner would wobble
612 // between them from one frame to the next.
613 let mut gray = GrayImage {
614 w: 60,
615 h: 40,
616 px: vec![0.5f32; 60 * 40],
617 };
618 for y in 0..40 {
619 for x in 0..25 {
620 gray.px[y * 60 + x] = 0.0;
621 }
622 for x in 35..60 {
623 gray.px[y * 60 + x] = 0.6;
624 }
625 }
626 let map = EdgeMap::new(&gray);
627 let hit = map.snap(Point::new(30, 20), 6).x.expect("an edge");
628 assert_eq!(hit.at, 25);
629 }
630
631 #[test]
632 fn the_nearer_of_two_equal_edges_wins() {
633 // A dark bar: both its edges are the same 0.5 luma step, so only
634 // distance can decide.
635 let mut gray = GrayImage {
636 w: 60,
637 h: 40,
638 px: vec![0.5f32; 60 * 40],
639 };
640 for y in 0..40 {
641 for x in 20..30 {
642 gray.px[y * 60 + x] = 0.0;
643 }
644 }
645 let map = EdgeMap::new(&gray);
646 // 6 from the left edge, 4 from the right, both inside the radius.
647 let hit = map.snap(Point::new(26, 20), 6).x.expect("an edge");
648 assert_eq!(hit.at, 30);
649 let other = map.snap(Point::new(24, 20), 6).x.expect("an edge");
650 assert_eq!(other.at, 20);
651 }
652}