Skip to main content

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}