Skip to main content

oxitext_sdf/
edt.rs

1//! Felzenszwalb-Huttenlocher signed distance field computation.
2//!
3//! Implements the 2D Euclidean distance transform (EDT) described in:
4//! "Distance Transforms of Sampled Functions",
5//! Felzenszwalb & Huttenlocher, Theory of Computing 2012.
6
7use rayon::prelude::*;
8
9/// Error type for SDF computation failures.
10#[derive(Debug)]
11pub enum SdfError {
12    /// The input coverage slice length does not match `width * height`.
13    InvalidInput(String),
14    /// Width or height was zero.
15    ZeroSize,
16    /// Font bytes could not be parsed by ttf-parser.
17    InvalidFont,
18    /// Binary data is malformed (bad magic, version mismatch, truncated payload).
19    InvalidData(String),
20    /// An I/O error occurred (e.g. during PNG export).
21    Io(String),
22}
23
24impl std::fmt::Display for SdfError {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        match self {
27            SdfError::InvalidInput(s) => write!(f, "invalid input: {s}"),
28            SdfError::ZeroSize => write!(f, "SDF dimensions must be non-zero"),
29            SdfError::InvalidFont => write!(f, "could not parse font data"),
30            SdfError::InvalidData(s) => write!(f, "invalid SDF binary data: {s}"),
31            SdfError::Io(s) => write!(f, "I/O error: {s}"),
32        }
33    }
34}
35
36impl std::error::Error for SdfError {}
37
38/// Compute 1D EDT in-place using the Felzenszwalb-Huttenlocher parabola-envelope algorithm.
39///
40/// On entry `f[i]` is `0.0` at seed positions and `INF` elsewhere.
41/// On exit `f[i]` contains the squared Euclidean distance to the nearest seed.
42#[cfg_attr(feature = "simd", allow(dead_code))]
43pub(crate) fn edt_1d(f: &mut [f32]) {
44    let n = f.len();
45    if n == 0 {
46        return;
47    }
48    let inf = f32::INFINITY;
49    // Scratch buffers for the parabola-envelope pass.
50    let mut d = vec![0.0f32; n];
51    let mut v = vec![0usize; n];
52    let mut z = vec![0.0f32; n + 1];
53    let mut k = 0usize;
54
55    v[0] = 0;
56    z[0] = -inf;
57    z[1] = inf;
58
59    for q in 1..n {
60        let fq = f[q];
61        loop {
62            let r = v[k];
63            let fr = f[r];
64            let s =
65                ((fq + (q * q) as f32) - (fr + (r * r) as f32)) / (2.0 * q as f32 - 2.0 * r as f32);
66            if s <= z[k] {
67                if k == 0 {
68                    break;
69                }
70                k -= 1;
71            } else {
72                k += 1;
73                v[k] = q;
74                z[k] = s;
75                z[k + 1] = inf;
76                break;
77            }
78        }
79    }
80
81    k = 0;
82    for (q, slot) in d.iter_mut().enumerate().take(n) {
83        while z[k + 1] < q as f32 {
84            k += 1;
85        }
86        let r = v[k];
87        let dr = q as f32 - r as f32;
88        *slot = dr * dr + f[r];
89    }
90
91    // Write results back in-place.
92    f.copy_from_slice(&d);
93}
94
95/// SIMD-accelerated 1D EDT.
96///
97/// Produces identical output to [`edt_1d`].  Active only when the `simd` feature
98/// is enabled; otherwise falls back to the scalar version.
99#[cfg(feature = "simd")]
100pub(crate) fn edt_1d_simd(f: &mut [f32]) {
101    use wide::f32x4;
102
103    let n = f.len();
104    if n == 0 {
105        return;
106    }
107    let inf = f32::INFINITY;
108
109    // Phase 1: parabola-envelope (scalar — must remain sequential).
110    let mut v = vec![0usize; n];
111    let mut z = vec![0.0f32; n + 1];
112    let mut k = 0usize;
113
114    v[0] = 0;
115    z[0] = -inf;
116    z[1] = inf;
117
118    for q in 1..n {
119        let fq = f[q];
120        loop {
121            let r = v[k];
122            let fr = f[r];
123            let s =
124                ((fq + (q * q) as f32) - (fr + (r * r) as f32)) / (2.0 * q as f32 - 2.0 * r as f32);
125            if s <= z[k] {
126                if k == 0 {
127                    break;
128                }
129                k -= 1;
130            } else {
131                k += 1;
132                v[k] = q;
133                z[k] = s;
134                z[k + 1] = inf;
135                break;
136            }
137        }
138    }
139
140    // Phase 2: distance-fill with SIMD where possible.
141    // We process 4 output positions at a time.  For each group of 4 positions q
142    // we find their responsible parabola vertex r (scalar binary-search style),
143    // then use f32x4 to compute (q - r)^2 + f[r] in parallel.
144    let mut out = vec![0.0f32; n];
145    let mut k_cursor = 0usize;
146
147    let chunks = n / 4;
148    let remainder_start = chunks * 4;
149
150    let mut q = 0usize;
151    for _c in 0..chunks {
152        // Advance the scalar cursor for the first position of this chunk.
153        while z[k_cursor + 1] < q as f32 {
154            k_cursor += 1;
155        }
156        let r0 = v[k_cursor];
157        let d0 = {
158            let dr = q as f32 - r0 as f32;
159            dr * dr + f[r0]
160        };
161
162        // For q+1 .. q+3 we need individual r values because the active
163        // parabola may advance.  We use a local cursor per lane.
164        let mut kk = k_cursor;
165        let r_vals = [
166            r0,
167            {
168                while z[kk + 1] < (q + 1) as f32 {
169                    kk += 1;
170                }
171                v[kk]
172            },
173            {
174                while z[kk + 1] < (q + 2) as f32 {
175                    kk += 1;
176                }
177                v[kk]
178            },
179            {
180                while z[kk + 1] < (q + 3) as f32 {
181                    kk += 1;
182                }
183                v[kk]
184            },
185        ];
186        // Advance the persistent cursor to the last lane's value.
187        k_cursor = kk;
188
189        // SIMD computation: (q_lane - r_lane)^2 + f[r_lane]
190        let q_vec = f32x4::from([q as f32, (q + 1) as f32, (q + 2) as f32, (q + 3) as f32]);
191        let r_vec = f32x4::from([
192            r_vals[0] as f32,
193            r_vals[1] as f32,
194            r_vals[2] as f32,
195            r_vals[3] as f32,
196        ]);
197        let fr_vec = f32x4::from([f[r_vals[0]], f[r_vals[1]], f[r_vals[2]], f[r_vals[3]]]);
198        let diff = q_vec - r_vec;
199        let dist = diff * diff + fr_vec;
200        let arr: [f32; 4] = dist.into();
201        // The first slot was computed scalar-first; keep it for clarity,
202        // but the SIMD value matches.
203        let _ = d0; // already captured in arr[0]
204        out[q] = arr[0];
205        out[q + 1] = arr[1];
206        out[q + 2] = arr[2];
207        out[q + 3] = arr[3];
208
209        q += 4;
210    }
211
212    // Handle remainder positions (< 4) with scalar fallback.
213    for (offset, slot) in out[remainder_start..n].iter_mut().enumerate() {
214        let qq = remainder_start + offset;
215        while z[k_cursor + 1] < qq as f32 {
216            k_cursor += 1;
217        }
218        let r = v[k_cursor];
219        let dr = qq as f32 - r as f32;
220        *slot = dr * dr + f[r];
221    }
222
223    f.copy_from_slice(&out);
224}
225
226/// Dispatch: use SIMD path when the `simd` feature is active, scalar otherwise.
227#[inline]
228fn edt_1d_dispatch(f: &mut [f32]) {
229    #[cfg(feature = "simd")]
230    edt_1d_simd(f);
231    #[cfg(not(feature = "simd"))]
232    edt_1d(f);
233}
234
235/// 2D EDT using separable passes: rows first, then columns.
236///
237/// `grid` contains per-pixel initial values:
238/// - `0.0` at pixels that are the "seed" (already at distance 0),
239/// - `INF` (or large) at pixels that need a distance computed.
240///
241/// Returns squared Euclidean distances.
242///
243/// Row passes are executed in parallel using rayon; the column pass uses a
244/// transposed buffer so that each column can also be processed in parallel.
245///
246/// The intermediate `tmp` allocation is eliminated: after the row pass the
247/// results are transposed in-place into a reused buffer, reducing peak
248/// memory to ~2 × N × M × 4 bytes instead of 3 ×.
249pub fn edt_2d(grid: &[f32], width: usize, height: usize) -> Vec<f32> {
250    // ── Row pass: process all rows in parallel ────────────────────────────────
251    let mut row_result: Vec<f32> = grid.to_vec();
252
253    row_result.par_chunks_mut(width).for_each(|row| {
254        edt_1d_dispatch(row);
255    });
256
257    // ── In-place column pass via transposed layout ────────────────────────────
258    // Transpose row_result (W×H) → transposed (H×W), where each "row" in
259    // transposed is one original column.
260    let mut transposed: Vec<f32> = vec![0.0; width * height];
261    for y in 0..height {
262        for x in 0..width {
263            transposed[x * height + y] = row_result[y * width + x];
264        }
265    }
266
267    transposed.par_chunks_mut(height).for_each(|col| {
268        edt_1d_dispatch(col);
269    });
270
271    // Transpose back into row_result (reuse the allocation).
272    for x in 0..width {
273        for y in 0..height {
274            row_result[y * width + x] = transposed[x * height + y];
275        }
276    }
277
278    row_result
279}
280
281/// Compute a signed SDF from a grayscale coverage map.
282///
283/// # Arguments
284/// - `coverage` — grayscale bitmap (`width × height` bytes); values > 127 are
285///   treated as "inside" the glyph outline.
286/// - `width`, `height` — bitmap dimensions.
287/// - `spread` — maximum SDF distance in pixels; maps ±spread to [0, 255].
288/// - `padding` — extra border (in pixels) added around the output to prevent
289///   atlas edge artefacts. The returned slice has dimensions
290///   `(width + 2*padding) × (height + 2*padding)`.  Pass `0` for no padding.
291///
292/// # Returns
293/// A `Vec<u8>` of dimensions `(width + 2*padding) × (height + 2*padding)`, where:
294/// - `< 128` = outside the outline,
295/// - `≈ 128` = near the outline (the 0.5 isovalue),
296/// - `> 128` = inside the outline.
297///
298/// # Errors
299/// Returns [`SdfError::InvalidInput`] if `coverage.len() != width * height`.
300pub fn compute_sdf(
301    coverage: &[u8],
302    width: usize,
303    height: usize,
304    spread: f32,
305    padding: u32,
306) -> Result<Vec<u8>, SdfError> {
307    if coverage.len() != width * height {
308        return Err(SdfError::InvalidInput(format!(
309            "coverage length {} != width({}) * height({})",
310            coverage.len(),
311            width,
312            height
313        )));
314    }
315
316    let pad = padding as usize;
317    let padded_w = width + 2 * pad;
318    let padded_h = height + 2 * pad;
319    let n_padded = padded_w * padded_h;
320    const LARGE: f32 = 1e10;
321
322    // Build inside and outside seed grids with padding:
323    // Padding border pixels are all "outside" (inside_grid = LARGE, outside_grid = 0).
324    let mut inside_grid = vec![LARGE; n_padded];
325    let mut outside_grid = vec![LARGE; n_padded];
326
327    for y in 0..height {
328        for x in 0..width {
329            let src_idx = y * width + x;
330            let dst_idx = (y + pad) * padded_w + (x + pad);
331            if coverage[src_idx] > 127 {
332                inside_grid[dst_idx] = 0.0;
333            } else {
334                outside_grid[dst_idx] = 0.0;
335            }
336        }
337    }
338    // Padding rows/columns are "outside".
339    for y in 0..padded_h {
340        for x in 0..padded_w {
341            let in_inner = x >= pad && x < pad + width && y >= pad && y < pad + height;
342            if !in_inner {
343                outside_grid[y * padded_w + x] = 0.0;
344            }
345        }
346    }
347
348    let dist_inside = edt_2d(&inside_grid, padded_w, padded_h);
349    let dist_outside = edt_2d(&outside_grid, padded_w, padded_h);
350
351    let out: Vec<u8> = (0..n_padded)
352        .map(|i| {
353            let d_in = dist_inside[i].sqrt();
354            let d_out = dist_outside[i].sqrt();
355            let signed = d_out - d_in;
356            let normalized = 0.5 + signed / (2.0 * spread);
357            let clamped = normalized.clamp(0.0, 1.0);
358            (clamped * 255.0).round() as u8
359        })
360        .collect();
361
362    Ok(out)
363}
364
365// ─── Tests ────────────────────────────────────────────────────────────────────
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[test]
372    fn solid_square_inside_everywhere() {
373        let w = 8usize;
374        let h = 8usize;
375        let coverage = vec![255u8; w * h];
376        let sdf = compute_sdf(&coverage, w, h, 4.0, 0).expect("compute_sdf solid square");
377        let center = sdf[(h / 2) * w + w / 2];
378        assert!(
379            center > 128,
380            "center of solid square should be inside (> 128), got {center}"
381        );
382    }
383
384    #[test]
385    fn empty_square_outside_everywhere() {
386        let w = 8usize;
387        let h = 8usize;
388        let coverage = vec![0u8; w * h];
389        let sdf = compute_sdf(&coverage, w, h, 4.0, 0).expect("compute_sdf empty square");
390        let center = sdf[(h / 2) * w + w / 2];
391        assert!(
392            center < 128,
393            "center of empty square should be outside (< 128), got {center}"
394        );
395    }
396
397    #[test]
398    fn ring_shape_inside_outside() {
399        let w = 8usize;
400        let h = 8usize;
401        let mut coverage = vec![0u8; w * h];
402        for x in 0..w {
403            coverage[x] = 255;
404            coverage[(h - 1) * w + x] = 255;
405        }
406        for y in 0..h {
407            coverage[y * w] = 255;
408            coverage[y * w + (w - 1)] = 255;
409        }
410        let sdf = compute_sdf(&coverage, w, h, 4.0, 0).expect("compute_sdf ring");
411        assert_eq!(sdf.len(), w * h, "sdf length should match w*h");
412    }
413
414    #[test]
415    fn padding_grows_output_size() {
416        let w = 4usize;
417        let h = 4usize;
418        let coverage = vec![128u8; w * h];
419        let sdf = compute_sdf(&coverage, w, h, 4.0, 2).expect("compute_sdf with padding");
420        assert_eq!(sdf.len(), (w + 4) * (h + 4));
421    }
422
423    #[test]
424    fn invalid_coverage_length_errors() {
425        let coverage = vec![128u8; 10];
426        assert!(compute_sdf(&coverage, 4, 4, 4.0, 0).is_err());
427    }
428
429    #[test]
430    fn test_edt_1d_simd_matches_scalar() {
431        #[cfg(feature = "simd")]
432        {
433            let input_scalar: Vec<f32> = (0..32)
434                .map(|i| if i % 8 == 0 { 0.0 } else { f32::INFINITY })
435                .collect();
436            let mut scalar = input_scalar.clone();
437            let mut simd_buf = input_scalar.clone();
438            edt_1d(&mut scalar);
439            edt_1d_simd(&mut simd_buf);
440            for (s, v) in scalar.iter().zip(simd_buf.iter()) {
441                assert!((s - v).abs() < 0.001, "scalar={s}, simd={v}");
442            }
443        }
444    }
445
446    // ─── SDF quality comparison tests ─────────────────────────────────────────
447    //
448    // These tests validate `compute_sdf` output analytically against known
449    // distance values derived from the Felzenszwalb-Huttenlocher EDT semantics.
450    //
451    // In the EDT, every coverage pixel is either "inside" (seed for inside_grid,
452    // d_in=0) or "outside" (seed for outside_grid, d_out=0).  Therefore the
453    // `signed = d_out - d_in` value is computed entirely from pixel-center distances
454    // to the nearest opposite-class pixel.
455    //
456    // At the boundary the minimum |signed| achievable is 1.0 (adjacent pixels in
457    // opposite classes), which maps to:
458    //   0.5 + 1.0/(2*spread)
459    // With spread=8.0 that gives 0.5625 → 143 (or 113 on the far side),
460    // so the boundary tolerance is ±20 (not ±5 as for a continuous outline SDF).
461
462    /// Build a coverage bitmap containing a filled square of side `sq_side` centred at (cx, cy).
463    ///
464    /// The square spans `[cx - half, cx + half]` **inclusive** on both axes so that the
465    /// integer grid is symmetric around `cx` and `cy` — a necessary condition for the
466    /// 4-fold symmetry test.  `sq_side` must be even; the inclusive half-extent is
467    /// `sq_side / 2`.
468    fn filled_square_coverage(w: usize, h: usize, cx: usize, cy: usize, sq_side: usize) -> Vec<u8> {
469        let half = sq_side / 2;
470        let x_min = cx.saturating_sub(half);
471        // Use exclusive bound cx + half + 1 so the range [x_min, x_max) covers
472        // [cx-half, cx+half] inclusive, giving a symmetric pixel grid.
473        let x_max = (cx + half + 1).min(w);
474        let y_min = cy.saturating_sub(half);
475        let y_max = (cy + half + 1).min(h);
476        let mut cov = vec![0u8; w * h];
477        for y in y_min..y_max {
478            for x in x_min..x_max {
479                cov[y * w + x] = 255;
480            }
481        }
482        cov
483    }
484
485    /// Build a coverage bitmap with a filled circle of the given radius.
486    fn circle_coverage_edt(w: usize, h: usize, cx: f32, cy: f32, r: f32) -> Vec<u8> {
487        (0..w * h)
488            .map(|i| {
489                let x = (i % w) as f32;
490                let y = (i / w) as f32;
491                // Use `d < r + 0.5` so the pixel whose centre is on the radius
492                // counts as inside, giving better-defined boundary pixels.
493                let d = ((x - cx).powi(2) + (y - cy).powi(2)).sqrt();
494                if d < r + 0.5 {
495                    255u8
496                } else {
497                    0u8
498                }
499            })
500            .collect()
501    }
502
503    /// Test 1: filled square SDF — symmetry, inside/outside, boundary.
504    ///
505    /// A 32×32 bitmap with a 16×16 filled square centred at (16,16).
506    /// Spread = 8.0 maps ±8 pixel distance to the [0,255] range.
507    ///
508    /// Expected values (from EDT discrete math):
509    /// - Center (16,16): 8 pixels from nearest outside edge → signed = +8.0
510    ///   → normalized = 0.5 + 8/(2*8) = 1.0 → clamped → 255.
511    /// - Corners (0,0): ~22 pixels from nearest inside edge → signed ≈ -22.0
512    ///   → normalized = 0.5 - 22/16 < 0 → clamped → 0.
513    /// - Boundary pixel (just outside, x=16 on the right edge where x=24 is the
514    ///   first outside pixel): 1 pixel from the nearest inside → signed = -(1)
515    ///   → 0.5 - 1/16 = 0.4375 → 111.  Still within ±20 of 128.
516    #[test]
517    fn sdf_quality_filled_square_symmetry() {
518        let w = 32usize;
519        let h = 32usize;
520        let cx = 16usize;
521        let cy = 16usize;
522        let sq_side = 16usize;
523        let spread = 8.0f32;
524
525        let coverage = filled_square_coverage(w, h, cx, cy, sq_side);
526        let sdf = compute_sdf(&coverage, w, h, spread, 0).expect("compute_sdf filled square");
527        assert_eq!(sdf.len(), w * h);
528
529        // 1a. Center of square must be inside (> 128).
530        let center_v = sdf[cy * w + cx];
531        assert!(
532            center_v > 128,
533            "center pixel should be inside (>128), got {center_v}"
534        );
535
536        // 1b. Corner must be outside (< 128).
537        let corner_v = sdf[0];
538        assert!(
539            corner_v < 128,
540            "corner pixel should be outside (<128), got {corner_v}"
541        );
542
543        // 1c. Boundary: the pixel just outside the right edge of the square.
544        //     The square spans x=8..=24 (inclusive), so x=25 is the first outside
545        //     pixel on the right.  It is 1 pixel from x=24 (last inside).
546        //     signed ≈ -(1) → normalized ≈ 0.44 → byte ≈ 112; within ±20 of 128.
547        let boundary_x = cx + sq_side / 2 + 1; // 25
548        let boundary_v = sdf[cy * w + boundary_x] as i32;
549        assert!(
550            (boundary_v - 128).abs() <= 20,
551            "boundary pixel at ({boundary_x},{cy}) should be within ±20 of 128 \
552             (first outside pixel on right edge), got {boundary_v}"
553        );
554
555        // 1d. 4-fold symmetry: pixel at +8 columns from center should equal pixel at -8 columns.
556        //     Both are 8 pixels inside from the nearest edge of the square.
557        let offset = 4usize;
558        let sym_right = sdf[cy * w + (cx + offset)] as i32;
559        let sym_left = sdf[cy * w + (cx - offset)] as i32;
560        assert_eq!(
561            sym_right,
562            sym_left,
563            "4-fold SDF symmetry broken: sdf[{},{}]={sym_right} vs sdf[{},{}]={sym_left}",
564            cx + offset,
565            cy,
566            cx - offset,
567            cy
568        );
569
570        let sym_down = sdf[(cy + offset) * w + cx] as i32;
571        let sym_up = sdf[(cy - offset) * w + cx] as i32;
572        assert_eq!(
573            sym_down,
574            sym_up,
575            "4-fold SDF symmetry broken: sdf[{},{}]={sym_down} vs sdf[{},{}]={sym_up}",
576            cx,
577            cy + offset,
578            cx,
579            cy - offset
580        );
581    }
582
583    /// Test 2: circular coverage SDF with analytically derived expected values.
584    ///
585    /// A 64×64 bitmap with a filled circle of radius 16 centred at (32,32).
586    /// Spread = 8.0.
587    ///
588    /// Expected values (from EDT discrete math):
589    /// - Center (32,32): nearest outside pixel is ~16 away.
590    ///   signed ≈ +16 → normalized = 0.5+16/16 = 1.5 → clamped → 255.
591    /// - Boundary pixel (32, 49) right at r+0.5 edge: 1 pixel inside or 1 pixel
592    ///   outside → |signed| ≈ 1 → normalized ≈ 0.5 ± 0.0625 → byte ≈ 112–143 (±20 of 128).
593    /// - Outside by ~4px (32, 53): nearest inside pixel ≈ 4px away.
594    ///   signed ≈ -4 → normalized = 0.5 - 4/16 = 0.25 → byte ≈ 64.
595    ///   With ±12 tolerance (EDT pixels are discrete): expect 52–76.
596    #[test]
597    fn sdf_quality_circular_coverage() {
598        let size = 64usize;
599        let cx = 32.0f32;
600        let cy = 32.0f32;
601        let r = 16.0f32;
602        let spread = 8.0f32;
603
604        let coverage = circle_coverage_edt(size, size, cx, cy, r);
605        let sdf = compute_sdf(&coverage, size, size, spread, 0).expect("compute_sdf circle");
606        assert_eq!(sdf.len(), size * size);
607
608        // 2a. Center — far inside, should hit the upper clamp.
609        let center_v = sdf[32 * size + 32];
610        assert!(
611            center_v > 200,
612            "circle center should be far inside (>200), got {center_v}"
613        );
614
615        // 2b. Boundary pixel at (32, cy+r as usize) — within ±20 of 128.
616        //     With r+0.5 coverage threshold, pixel at y=48 (32+16) is the last
617        //     inside pixel; pixel at y=49 is the first outside.
618        //     Check both sides and assert one is ≤128 and the other ≥128.
619        let y_inside = (cy + r - 1.0) as usize; // 47
620        let y_outside = (cy + r + 1.0) as usize; // 49
621        let inside_v = sdf[y_inside * size + 32] as i32;
622        let outside_v = sdf[y_outside * size + 32] as i32;
623        assert!(
624            inside_v >= 128,
625            "pixel just inside the circle boundary ({},32) should be >=128, got {inside_v}",
626            y_inside
627        );
628        assert!(
629            outside_v <= 128,
630            "pixel just outside the circle boundary ({},32) should be <=128, got {outside_v}",
631            y_outside
632        );
633
634        // 2c. Outside by ~4 pixels: (32, cy+r+4) ≈ (32, 52).
635        //     Nearest inside pixel ≈ ~4px away, signed ≈ -4.
636        //     normalized = 0.5 - 4/(2*8) = 0.25 → byte ≈ 64.
637        //     Discrete EDT noise: allow ±15 → range [49, 79].
638        let y_far_out = (cy + r + 4.0) as usize; // 52
639        let far_out_v = sdf[y_far_out * size + 32] as i32;
640        assert!(
641            far_out_v < 128,
642            "pixel outside circle by ~4px ({y_far_out},32) should be <128, got {far_out_v}"
643        );
644        assert!(
645            (far_out_v - 64).abs() <= 25,
646            "pixel outside circle by ~4px should be near 64 (±25), got {far_out_v}"
647        );
648    }
649}