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}