rlx_core/render/metrics.rs
1//! Pure image metrics over [`CaptureImage`]s (Plan 0013): pixel and shape
2//! difference plus coverage/spread, shared by the differential visual-QA tests
3//! and the `shot` CLI report.
4//!
5//! Everything here is a pure function of its input pixels — no GPU, no clock, no
6//! allocation beyond the small working buffers. Not a per-frame hot path, but it
7//! lives under `render/` so it carries the panic-denial pragma (and the hygiene
8//! guard needs it): written index- and panic-free throughout.
9
10#![deny(
11 clippy::unwrap_used,
12 clippy::expect_used,
13 clippy::indexing_slicing,
14 clippy::panic,
15 clippy::unreachable
16)]
17
18use super::CaptureImage;
19
20/// Grid the shape metric downscales to before edge detection (~32×32).
21const STRUCT_GRID: usize = 32;
22
23/// Mean absolute per-channel (RGB) difference between two images, normalized to
24/// `0.0..=1.0` (0 = identical, 1 = every channel maximally different). Mismatched
25/// dimensions read as fully different (`1.0`). Alpha is ignored — the capture
26/// background is opaque, so alpha carries no signal.
27pub fn frame_diff(a: &CaptureImage, b: &CaptureImage) -> f32 {
28 if a.width != b.width || a.height != b.height || a.rgba.len() != b.rgba.len() {
29 return 1.0;
30 }
31 let mut sum: u64 = 0;
32 let mut count: u64 = 0;
33 for (pa, pb) in a.rgba.chunks_exact(4).zip(b.rgba.chunks_exact(4)) {
34 for c in 0..3 {
35 if let (Some(&x), Some(&y)) = (pa.get(c), pb.get(c)) {
36 sum += x.abs_diff(y) as u64;
37 count += 1;
38 }
39 }
40 }
41 if count == 0 {
42 return 0.0;
43 }
44 sum as f32 / (count as f32 * 255.0)
45}
46
47/// Mean absolute per-channel (RGB) difference measured **over the union of lit
48/// pixels in the two frames** rather than over the whole frame, normalized to
49/// `0.0..=1.0` — the footprint statistic of ADR-0091 (Plan 0077 Phase 1).
50///
51/// [`frame_diff`] is a mean over every pixel, so a sparse figure's motion is
52/// averaged against the empty frame around it and the statistic scores
53/// *occupancy* — which Plan 0067 Phase 1d measured to be scale-invariant, so no
54/// render size recovers the dilution. This is the masked form ADR-0091 offers
55/// (chosen over `frame_diff / max(occupancy, eps)` because the quotient form
56/// keeps the whole-frame numerator, so backdrop drift outside the figure still
57/// leaks into a statistic that claims to be about the figure): a pixel is in
58/// the mask if it differs from `bg` by more than `eps` on any RGB channel in
59/// **either** frame, and the mean is taken over the mask only.
60///
61/// The denominator is floored at `min_lit_frac` of the frame's pixels — the
62/// guard ADR-0091 requires, without which a one-pixel flicker in a nearly-empty
63/// frame reads as strong animation (one full-swing pixel over a mask of one is
64/// `1.0`). Callers state their bound and its derivation; a mask at or under the
65/// floor means the statistic is reporting "near-invisible at this size" rather
66/// than measuring motion, which is a finding for the *coverage* gate, not this
67/// one.
68///
69/// Mismatched dimensions read as fully different (`1.0`); an empty mask over a
70/// zero floor reads `0.0` (nothing lit in either frame is no motion, not a
71/// division). Alpha is ignored, as in [`frame_diff`].
72pub fn footprint_diff(
73 a: &CaptureImage,
74 b: &CaptureImage,
75 bg: [u8; 4],
76 eps: u8,
77 min_lit_frac: f32,
78) -> f32 {
79 if a.width != b.width || a.height != b.height || a.rgba.len() != b.rgba.len() {
80 return 1.0;
81 }
82 let mut sum: u64 = 0;
83 let mut mask: u64 = 0;
84 let mut total: u64 = 0;
85 for (pa, pb) in a.rgba.chunks_exact(4).zip(b.rgba.chunks_exact(4)) {
86 total += 1;
87 if !is_lit(pa, bg, eps) && !is_lit(pb, bg, eps) {
88 continue;
89 }
90 mask += 1;
91 for c in 0..3 {
92 if let (Some(&x), Some(&y)) = (pa.get(c), pb.get(c)) {
93 sum += x.abs_diff(y) as u64;
94 }
95 }
96 }
97 let floor = (total as f32 * min_lit_frac.clamp(0.0, 1.0)).ceil() as u64;
98 let denom = mask.max(floor);
99 if denom == 0 {
100 return 0.0;
101 }
102 sum as f32 / (denom as f32 * 3.0 * 255.0)
103}
104
105/// Shape-aware difference in `0.0..=1.0`: downscale each image to a small
106/// grayscale grid, take the Sobel edge magnitude, normalize each edge map by its
107/// own peak, and mean-abs-diff them. Normalizing per-image cancels overall
108/// contrast, so a **recolor of the same shape** scores low while a **different
109/// shape** scores high — the near-duplicate probe (an approximation of SSIM).
110pub fn struct_diff(a: &CaptureImage, b: &CaptureImage) -> f32 {
111 let ea = normalize_max(&sobel(&downscale_gray(a)));
112 let eb = normalize_max(&sobel(&downscale_gray(b)));
113 let mut sum = 0.0f32;
114 let mut count = 0.0f32;
115 for (x, y) in ea.iter().zip(eb.iter()) {
116 sum += (x - y).abs();
117 count += 1.0;
118 }
119 if count == 0.0 {
120 return 0.0;
121 }
122 (sum / count).clamp(0.0, 1.0)
123}
124
125/// Fraction of pixels whose RGB differs from `bg` by more than `eps` on any
126/// channel — "how much of the frame is lit" (`0.0..=1.0`). Alpha is ignored.
127pub fn coverage(img: &CaptureImage, bg: [u8; 4], eps: u8) -> f32 {
128 let mut lit: u64 = 0;
129 let mut total: u64 = 0;
130 for px in img.rgba.chunks_exact(4) {
131 total += 1;
132 if is_lit(px, bg, eps) {
133 lit += 1;
134 }
135 }
136 if total == 0 {
137 return 0.0;
138 }
139 lit as f32 / total as f32
140}
141
142/// How many of the four image quadrants contain at least one lit pixel
143/// (`0..=4`) — a cheap "not just a dot in one corner" spread check.
144pub fn quadrant_spread(img: &CaptureImage, bg: [u8; 4], eps: u8) -> u8 {
145 let w = img.width as usize;
146 let h = img.height as usize;
147 if w == 0 || h == 0 {
148 return 0;
149 }
150 let mut hit = [false; 4];
151 for (i, px) in img.rgba.chunks_exact(4).enumerate() {
152 if !is_lit(px, bg, eps) {
153 continue;
154 }
155 let x = i % w;
156 let y = i / w;
157 let qx = usize::from(x >= w / 2);
158 let qy = usize::from(y >= h / 2);
159 if let Some(slot) = hit.get_mut(qy * 2 + qx) {
160 *slot = true;
161 }
162 }
163 hit.iter().filter(|&&b| b).count() as u8
164}
165
166/// Luminance buckets [`tonal_flatness`] histograms into. 16 over the 0..255
167/// range makes each bucket 16 levels wide — narrow enough that a figure with any
168/// modelling at all spreads across several, wide enough that dithering and
169/// 8-bit quantization do not split one tone in two.
170pub const TONE_BANDS: usize = 16;
171
172/// Share of the **lit** figure whose luminance falls inside the single most
173/// populated narrow luminance band (`0.0..=1.0`) — "does this picture have any
174/// tonal structure".
175///
176/// `coverage` and `quadrant_spread` answer *is something there* and *is it more
177/// than a dot*, and a fully saturated single-tone mass satisfies both: it is a
178/// real shape, of the right size, in every quadrant. This asks the question they
179/// cannot — whether the shape has any interior. A figure with falloff, depth or
180/// modelling spreads across several buckets; one driven past the tonemap knee
181/// collapses into one and reads near `1.0`.
182///
183/// Measured over lit pixels only, against the frame's own sampled background,
184/// for the same reason `coverage` is: a sparse figure on a wide ground would
185/// otherwise report the *background's* flatness, which is total by construction
186/// and says nothing about the scene.
187///
188/// `0.0` for a frame with no lit pixels at all — an empty picture makes no claim
189/// here, and `coverage` is the metric that already convicts it.
190pub fn tonal_flatness(img: &CaptureImage, bg: [u8; 4], eps: u8) -> f32 {
191 let mut buckets = [0u64; TONE_BANDS];
192 let mut lit: u64 = 0;
193 for px in img.rgba.chunks_exact(4) {
194 if !is_lit(px, bg, eps) {
195 continue;
196 }
197 lit += 1;
198 let bucket = ((luma(px) / 256.0) * TONE_BANDS as f32) as usize;
199 if let Some(slot) = buckets.get_mut(bucket.min(TONE_BANDS - 1)) {
200 *slot += 1;
201 }
202 }
203 if lit == 0 {
204 return 0.0;
205 }
206 buckets.iter().copied().max().unwrap_or(0) as f32 / lit as f32
207}
208
209/// Perimeter of the lit figure over its area: the share of lit pixels having at
210/// least one **unlit** 4-neighbour (`0.0..=1.0`) — "is the lit set a solid mass,
211/// or does it have interior?"
212///
213/// This is the statistic under the second term of the flatness gate (ADR-0128,
214/// settled by ADR-0130); [`assigned_boundary_density`] is what that term calls,
215/// because **which** reference this is handed decides what it measures (ADR-0200).
216/// [`tonal_flatness`] asks whether the figure has any *tonal* structure and
217/// convicts a two-ink print for having exactly two tones, which is what that
218/// idiom is; this asks the orthogonal question, and a picture is called a blot
219/// only when both say so.
220///
221/// A solid mass carries only its rim on the boundary and reads low; a hatched,
222/// stroked or tiled figure is almost all rim and reads near one. **Both halves of
223/// that are claims at one capture size and not properties of the figure** — the
224/// resolution paragraph below is what qualifies them, and a solid mass small
225/// enough reads `1.0000` exactly as a hatched one does.
226/// **The denominator is the lit area, not the frame's**,
227/// which is what keeps the statistic asking one question: normalizing by frame
228/// area would make a frame score higher merely for having more lit material,
229/// and *how much is lit* is [`coverage`], another term of the same gate.
230///
231/// Frame edges count as **unlit**, so a figure running off the frame counts that
232/// as boundary. The alternative — edges as lit — would let a fullscreen fill
233/// read as having no perimeter at all, which is the one answer this statistic
234/// must not give.
235///
236/// **It is bound to the capture's resolution and is comparable only at a fixed
237/// one.** Perimeter over area goes as ~`1/L` in the capture's linear size, so
238/// the same scene at 192×192 reads roughly half what it reads at 96×96, and a
239/// solid disc of radius `r` px reads about `2/r` — which is why a 4×4 solid
240/// block reads `1.0000` and a large one does not. Every floor derived from this
241/// statistic is measured at the sanity suite's 96×96 capture, and neither the
242/// numbers nor the ordering carry to another size.
243///
244/// It reads pixel-scale perimeter, so a **ragged** mass defeats it: a particle
245/// blot noisier than the fixture the threshold was measured on has more
246/// perimeter per lit pixel than a composition does. That is the known decay mode
247/// and ADR-0130 records it as accepted rather than solved.
248///
249/// `0.0` for a frame with no lit pixels — the convention [`tonal_flatness`]
250/// uses, and [`coverage`] is the metric that already convicts an empty picture.
251pub fn boundary_density(img: &CaptureImage, bg: [u8; 4], eps: u8) -> f32 {
252 let (w, h) = (img.width as usize, img.height as usize);
253 if w == 0 || h == 0 {
254 return 0.0;
255 }
256 let mask: Vec<bool> = img
257 .rgba
258 .chunks_exact(4)
259 .map(|px| is_lit(px, bg, eps))
260 .collect();
261 let at = |x: isize, y: isize| -> bool {
262 if x < 0 || y < 0 || x >= w as isize || y >= h as isize {
263 return false;
264 }
265 mask.get(y as usize * w + x as usize)
266 .copied()
267 .unwrap_or(false)
268 };
269 let (mut lit, mut edge) = (0u64, 0u64);
270 for y in 0..h as isize {
271 for x in 0..w as isize {
272 if !at(x, y) {
273 continue;
274 }
275 lit += 1;
276 if !at(x - 1, y) || !at(x + 1, y) || !at(x, y - 1) || !at(x, y + 1) {
277 edge += 1;
278 }
279 }
280 }
281 if lit == 0 {
282 return 0.0;
283 }
284 edge as f32 / lit as f32
285}
286
287/// Ratio of the frame's **peak** departure from its background luminance to the
288/// **mean** departure over every pixel — the crest factor, and the direct
289/// reading of *has the population piled onto a few places?* (Plan 0085 Phase 1).
290///
291/// A pixel's departure is `|luma - luma(bg)|`, and zero for any pixel
292/// [`coverage`] would not call lit (so 8-bit dither and a vignette's own
293/// gradient do not inflate the denominator). The **absolute** difference, not
294/// the signed one, because a two-tone world draws its figure *darker* than its
295/// ground (ADR-0106) and an ink figure piling up is the same event as a
296/// particle field piling up. The mean is taken over **every** pixel, not over
297/// the lit ones — that is what makes concentration visible. Move a fixed amount
298/// of contrast from many pixels into few and the sum barely changes while the
299/// peak rises, so the ratio rises with it; spread it back out and it falls
300/// toward `1.0`.
301///
302/// Range: `1.0` for a perfectly uniform lit frame, up to the frame's pixel count
303/// for a single lit pixel, and exactly `0.0` for a frame that does not depart
304/// from its own background at all — an empty picture makes no claim about
305/// concentration, the same convention [`tonal_flatness`] uses. Total by
306/// construction: the peak is itself part of the sum, so a non-zero peak
307/// guarantees a non-zero denominator.
308///
309/// **It saturates, and a caller must know that.** The peak is 8-bit, so once the
310/// brightest pixel reaches white the numerator stops growing and further piling
311/// registers only through the falling mean. Read it as a *trend* — which is why
312/// the horizon mode reports a series and never a threshold (ADR-0099).
313pub fn peak_to_mean(img: &CaptureImage, bg: [u8; 4], eps: u8) -> f32 {
314 let bg_luma = luma(&bg);
315 let mut peak = 0.0f32;
316 let mut sum = 0.0f64;
317 let mut total: u64 = 0;
318 for px in img.rgba.chunks_exact(4) {
319 total += 1;
320 if !is_lit(px, bg, eps) {
321 continue;
322 }
323 let departure = (luma(px) - bg_luma).abs();
324 peak = peak.max(departure);
325 sum += f64::from(departure);
326 }
327 if total == 0 || peak <= 0.0 {
328 return 0.0;
329 }
330 let mean = (sum / total as f64) as f32;
331 // `peak` is one of the terms of `sum`, so `mean >= peak / total > 0` here.
332 peak / mean
333}
334
335/// Mean **linear light** over the lit set — the level statistic (ADR-0150), in
336/// `0.0..=1.0`. `0.0` for a frame with no lit pixels, the convention
337/// [`tonal_flatness`] and [`boundary_density`] use.
338///
339/// Every other statistic in this module answers a *shape* question. This one
340/// answers *how much light does the picture carry*, which is the question a
341/// retune asks when it wants to hold level constant across a change, and it is
342/// the one question that cannot be asked on the stored bytes: sRGB's transfer
343/// curve is concave, so an encoded mean under-reports a trim by roughly half.
344/// `linear_diff` carries the same reasoning for a two-frame comparison. It is
345/// private, so this names it rather than linking it.
346///
347/// **The lit predicate is [`coverage`]'s, so this is linear light over a
348/// CODE-SPACE-selected set.** ADR-0150 records why that seam is accepted rather
349/// than solved: a lit predicate in linear light would change `coverage` itself
350/// and move blessed baselines across the whole suite, for no gain to the
351/// question being asked here. A reader who does not know that will eventually
352/// "fix" the wrong half.
353///
354/// **Restricted to the lit set, not the frame**, which is the substantive half.
355/// A preset's background is a deliberate authored constant; folding it into the
356/// level makes the reading mostly a measurement of that constant, and a frame
357/// mean read a 30 % source trim as 3 % on the fixture that motivated this.
358/// The corollary is a blind spot: a preset that goes wrong *by changing its
359/// background* is invisible here.
360///
361/// Luminance weights are Rec.709 (`0.2126/0.7152/0.0722`), not the Rec.601 that
362/// `luma` applies to code values — those are the luminance coefficients of
363/// sRGB's own primaries, and they are what every other linear-light reading in
364/// this workspace uses.
365pub fn mean_lit_level(img: &CaptureImage, bg: [u8; 4], eps: u8) -> f32 {
366 let lut = srgb_decode_lut();
367 let decode = |px: &[u8], c: usize| -> f32 {
368 lut.get(px.get(c).copied().unwrap_or(0) as usize)
369 .copied()
370 .unwrap_or(0.0)
371 };
372 let mut sum = 0.0f64;
373 let mut lit: u64 = 0;
374 for px in img.rgba.chunks_exact(4) {
375 if !is_lit(px, bg, eps) {
376 continue;
377 }
378 lit += 1;
379 sum += f64::from(0.2126 * decode(px, 0) + 0.7152 * decode(px, 1) + 0.0722 * decode(px, 2));
380 }
381 if lit == 0 {
382 return 0.0;
383 }
384 (sum / lit as f64) as f32
385}
386
387/// Rec.601 luma of a pixel's first three channels — the same weights
388/// [`downscale_gray`] and [`tonal_flatness`] use, so "luminance" means one thing
389/// across this module. Tolerates a short slice (missing channels read zero).
390fn luma(px: &[u8]) -> f32 {
391 0.299 * px.first().copied().unwrap_or(0) as f32
392 + 0.587 * px.get(1).copied().unwrap_or(0) as f32
393 + 0.114 * px.get(2).copied().unwrap_or(0) as f32
394}
395
396// ---------------------------------------------------------------------------
397// The frame's own ground (Plan 0116 Phase 3, ADR-0126)
398// ---------------------------------------------------------------------------
399
400/// The reference tone [`modal_ground`] returns for a frame that has no ground —
401/// black, the same value a caller with no ground of its own supplies.
402///
403/// A groundless frame is therefore measured against black either way, so the
404/// fallback is a no-op rather than a second behaviour to reason about.
405pub const NO_GROUND: [u8; 4] = [0, 0, 0, 255];
406
407/// Minimum share of a frame that its modal luminance band must hold before that
408/// band is called a ground.
409///
410/// **Derived, not tuned.** A uniform luminance distribution puts exactly
411/// `1 / TONE_BANDS` of the frame in each band, so a modal band holding no more
412/// than that share is the definition of *no band is dominant* — there is
413/// nothing to call a ground, and [`modal_ground`] returns [`NO_GROUND`].
414///
415/// **It is a floor on a maximum, so it is inert against real content, and that
416/// is the honest reading of it.** The largest of `TONE_BANDS` counts is at least
417/// their mean, with equality only for a perfectly flat histogram, so this fires
418/// only on a frame whose luminance is near-exactly uniform. Measured over the
419/// shipped library at `LOUD` (Plan 0116 Phase 3, 2026-08-26): the smallest modal
420/// band share is `Clifford`'s `0.1590`, two and a half times this line, and
421/// **no shipped preset falls back**. The rule defines the boundary case rather
422/// than reaching any content — which is what Phase 3 asked for, a behaviour
423/// defined in code rather than discovered later.
424pub const MIN_GROUND_SHARE: f32 = 1.0 / TONE_BANDS as f32;
425
426/// The frame's own ground: the mean RGB of its most populous luminance band, or
427/// [`NO_GROUND`] when no band holds more than [`MIN_GROUND_SHARE`] of it.
428///
429/// Everything built on `is_lit` — [`coverage`], [`quadrant_spread`],
430/// [`radial_shell_occupancy`], [`tonal_flatness`] — asks *how far does this
431/// pixel depart from the ground*, and passing a constant `BLACK` encodes an
432/// unstated precondition: that the scene draws light onto a ground it does not
433/// own. A scene that paints its own paper breaks it, and reads `coverage`
434/// exactly `1.0` whatever it drew (ADR-0126). This derives the reference from
435/// the frame instead, so the same question is asked correctly in both worlds.
436///
437/// **The mean of the band's members, not the band's centre.** An ink-on-paper
438/// world's paper is a specific off-white; rounding it to the middle of a
439/// 16-level band would hand `is_lit` a reference the frame does not contain,
440/// and at `EPS`-scale tolerances that is the difference between a ground and a
441/// second figure.
442///
443/// **Luminance, not RGB.** Plan 0116 Phase 1 tabled a coarse-RGB cluster and a
444/// border-only band beside this one over the whole shipped library: all three
445/// re-based the same presets, cost the same zero verdict changes, and repaired
446/// the same nothing. This is the simplest of the three that measured
447/// equivalent — the border variant assumes the ground reaches the frame edge,
448/// and the RGB variant buys a sparser histogram, neither for any measured
449/// return.
450///
451/// Ties resolve to the **brightest** tied band (the last maximum), which is
452/// arbitrary but deterministic — a duotone at equal populations has two grounds
453/// and no estimator over one histogram can pick between them.
454pub fn modal_ground(img: &CaptureImage) -> [u8; 4] {
455 pooled_modal_ground(std::slice::from_ref(img))
456}
457
458/// [`modal_ground`] over **one histogram summed across every image** — one
459/// ground for a whole series, from the same band mean and the same
460/// [`MIN_GROUND_SHARE`] rule.
461///
462/// For a caller that must hold one reference across many frames (the `shot`
463/// horizon, whose ruler may not move between rows) and cannot trust any single
464/// frame to carry it: a cellular world's first frame is a seed soup near a 50 %
465/// tie, and pooling lets the settled frames outvote it. Every pixel weighs the
466/// same, so images of different sizes pool by pixel count, not per image.
467///
468/// [`modal_ground`] is this over a one-image slice, so the two cannot drift. An
469/// empty slice has no pixels and returns [`NO_GROUND`].
470pub fn pooled_modal_ground(images: &[CaptureImage]) -> [u8; 4] {
471 let mut counts = [0u64; TONE_BANDS];
472 let mut sums = [[0u64; 3]; TONE_BANDS];
473 let mut total: u64 = 0;
474 for px in images.iter().flat_map(|img| img.rgba.chunks_exact(4)) {
475 total += 1;
476 let band = ((luma(px) / 256.0) * TONE_BANDS as f32) as usize;
477 let band = band.min(TONE_BANDS - 1);
478 if let (Some(count), Some(sum)) = (counts.get_mut(band), sums.get_mut(band)) {
479 *count += 1;
480 for c in 0..3 {
481 if let (Some(slot), Some(&v)) = (sum.get_mut(c), px.get(c)) {
482 *slot += u64::from(v);
483 }
484 }
485 }
486 }
487 let Some((best, &n)) = counts.iter().enumerate().max_by_key(|&(_, &n)| n) else {
488 return NO_GROUND;
489 };
490 // `n * TONE_BANDS <= total` is `n / total <= MIN_GROUND_SHARE` without the
491 // division — exact in integers, where the f32 quotient is not.
492 if n == 0 || n * TONE_BANDS as u64 <= total {
493 return NO_GROUND;
494 }
495 match sums.get(best) {
496 Some(s) => [
497 (s.first().copied().unwrap_or(0) / n) as u8,
498 (s.get(1).copied().unwrap_or(0) / n) as u8,
499 (s.get(2).copied().unwrap_or(0) / n) as u8,
500 255,
501 ],
502 None => NO_GROUND,
503 }
504}
505
506/// Which of the frame's large tone populations is the **figure**: [`coverage`]
507/// against [`NO_GROUND`] over [`coverage`] against [`modal_ground`].
508///
509/// [`modal_ground`] finds the frame's majority tone, and that is the right
510/// reference exactly while the majority tone is the *ground*. A mass stacked past
511/// the additive ceiling until it clips to one tone **is its own modal band**, so
512/// every statistic conditioned on that reference is then handed the mass's
513/// leftovers — its fringe — rather than the mass. No ground estimator can see
514/// that, because the majority tone is the majority tone either way; the question
515/// is about the band's role, and this is the reading of it.
516///
517/// A scene drawing light onto darkness has the same reference under both lenses,
518/// so the two coverages coincide and the ratio sits at `1.0`. A frame whose modal
519/// band is its figure departs from that band almost nowhere and from black almost
520/// everywhere, so the denominator collapses while the numerator stays near `1.0`
521/// and the ratio climbs without bound.
522///
523/// Both terms are coverages of one frame under two references, so this is a ratio
524/// of one kind of quantity rather than a comparison of two (ADR-0074). Unlike
525/// [`boundary_density`] it is an **areal** share on both sides and carries no
526/// resolution binding: the two frozen blot anchors read 27.58 / 1.2718 at 96×96
527/// and 27.70 / 1.2734 at 192×192.
528///
529/// [`f32::INFINITY`] when the frame does not depart from its own modal band at
530/// all — the maximal reading of *the modal band is the figure*, which keeps the
531/// function monotone in the direction it classifies.
532pub fn figure_ground_ratio(img: &CaptureImage, eps: u8) -> f32 {
533 let derived = coverage(img, modal_ground(img), eps);
534 if derived <= 0.0 {
535 return f32::INFINITY;
536 }
537 coverage(img, NO_GROUND, eps) / derived
538}
539
540/// [`boundary_density`] against the reference [`figure_ground_ratio`] assigns —
541/// [`NO_GROUND`] at or above `figure_cut`, where the modal band is the figure,
542/// and [`modal_ground`] below it (ADR-0200).
543///
544/// This is the flatness conjunction's second term. The statistic is unchanged and
545/// so is everything ADR-0130 established about it; what the role classifier
546/// supplies is the *reference*, which is the half ADR-0161 found wrong. Pointed
547/// at a blot's mass it reads the `2/r` perimeter of a solid disc; pointed at a
548/// print's ink it reads the ink's perimeter rather than the paper's perforation.
549///
550/// **`figure_cut` is a measurement the caller owns, and so is any floor compared
551/// against the value returned.** A cut is a bound over a population — the lowest
552/// blot against the highest non-blot among the frames a first term can reach —
553/// and there is no value defensible outside the population it was read from.
554///
555/// **The two halves have different resolution bindings, which is the trap.** The
556/// ratio is areal and near size-invariant; the density is perimeter over area and
557/// goes as ~`1/L` in the capture's linear size, so a floor on it halves with a
558/// doubling of the capture. A caller reading at another size re-derives its floor
559/// from its own anchors and does not scale one in (ADR-0071).
560pub fn assigned_boundary_density(img: &CaptureImage, eps: u8, figure_cut: f32) -> f32 {
561 let reference = if figure_ground_ratio(img, eps) >= figure_cut {
562 NO_GROUND
563 } else {
564 modal_ground(img)
565 };
566 boundary_density(img, reference, eps)
567}
568
569/// Concentric annuli [`radial_shell_occupancy`] divides the frame's inscribed
570/// disc into. Ten equal-radius shells is the granularity the Plan 0065 lane's
571/// one-off prototype measured with when it separated the four-ring mandala from
572/// the bare rosette (9 shells against 1, design-backlog 0072), and it is kept:
573/// coarse enough that a 96×96 capture gives the innermost shell a usable pixel
574/// count (~70), fine enough that "occupies most shells" cannot be satisfied by
575/// one ring and a halo.
576pub const RADIAL_SHELLS: usize = 10;
577
578/// Minimum share of a shell's own pixels that must be lit for the shell to
579/// count as occupied in [`radial_shell_occupancy`].
580///
581/// **Checked against both sides by measurement** (Plan 0075 Phase 1). The
582/// failure this guards against is a stray near-threshold pixel marking an
583/// empty shell occupied; the content it must not disenfranchise is a hairline
584/// stroke crossing a shell. At the sanity suite's 96×96 capture, shell `k`
585/// holds ~72·(2k+1) pixels (~72 innermost, ~1370 outermost), so 2 % asks for
586/// roughly 2–28 lit pixels per shell — above stray-pixel scale, far below any
587/// real stroke. Measured at this threshold: the three honest ring-mandala
588/// tunings (backlog 0072's evidence — `glow = 1.0`, no `trails`) read
589/// **10 / 10 / 9** occupied shells, every shipped preset reads ≥ 3, and the
590/// frozen renders-nothing defect (the pre-repair Spectrum Ridge fixture in the
591/// sanity suite, its contour off frame) reads exactly **0** — the threshold separates honest-thin from
592/// absent by the measure's whole range.
593pub const MIN_SHELL_LIT: f32 = 0.02;
594
595/// How many of [`RADIAL_SHELLS`] concentric equal-radius annuli over the
596/// frame's inscribed disc contain a meaningful share of lit pixels
597/// (`0..=RADIAL_SHELLS`) — a **structural occupancy** measure: *at how many
598/// radii does this picture exist?*
599///
600/// [`coverage`] counts lit pixels, which at capture size measures a thin-stroke
601/// figure's halo rather than its geometry: at 96×96 the bare rosette and a
602/// 46×-denser four-ring mandala score identically, and 54 % more geometry moves
603/// the number 2.6 % (design-backlog 0072). This asks the question that actually
604/// separates them — the mandala exists at nine of ten radii, the rosette's
605/// interlace band at one to three — and it cannot be bought with `glow` or
606/// `trails`, because inflating the halo around a stroke does not move which
607/// shells the stroke lives in.
608///
609/// Pixels outside the inscribed disc (the frame's corners) are ignored: the
610/// measure is radial, and the corners exist at radii only a diagonal figure
611/// reaches. `0` for a frame with no lit pixels — a scene that renders nothing
612/// occupies nothing, which is the one conviction the coverage floor demonstrably
613/// gets right and this measure must preserve.
614pub fn radial_shell_occupancy(img: &CaptureImage, bg: [u8; 4], eps: u8) -> usize {
615 let w = img.width as usize;
616 let h = img.height as usize;
617 if w == 0 || h == 0 {
618 return 0;
619 }
620 let (cx, cy) = (w as f32 * 0.5, h as f32 * 0.5);
621 let radius = w.min(h) as f32 * 0.5;
622 let mut lit = [0u32; RADIAL_SHELLS];
623 let mut total = [0u32; RADIAL_SHELLS];
624 for (i, px) in img.rgba.chunks_exact(4).enumerate() {
625 let x = (i % w) as f32 + 0.5;
626 let y = (i / w) as f32 + 0.5;
627 let r = ((x - cx).powi(2) + (y - cy).powi(2)).sqrt() / radius;
628 if r >= 1.0 {
629 continue;
630 }
631 let shell = ((r * RADIAL_SHELLS as f32) as usize).min(RADIAL_SHELLS - 1);
632 if let Some(t) = total.get_mut(shell) {
633 *t += 1;
634 }
635 if is_lit(px, bg, eps)
636 && let Some(l) = lit.get_mut(shell)
637 {
638 *l += 1;
639 }
640 }
641 lit.iter()
642 .zip(total.iter())
643 .filter(|&(&l, &t)| t > 0 && l as f32 / t as f32 >= MIN_SHELL_LIT)
644 .count()
645}
646
647// ---------------------------------------------------------------------------
648// Step response — how fast the frame reaches its new steady state (Plan 0037)
649// ---------------------------------------------------------------------------
650
651/// Fraction of a step's total change the response must reach to count as
652/// settled. 0.9 is the textbook rise-time convention, and it is the one the
653/// one-pole arithmetic in ADR-0019 is quoted against: a smoother with time
654/// constant `tau` reaches it at `t = tau * ln(10) = 2.303 * tau`.
655pub const SETTLE_FRAC: f32 = 0.9;
656
657/// How many frames a captured response took to settle after a step up and after
658/// the matching step down (Plan 0037, ADR-0039).
659///
660/// The whole point of ADR-0035's `{ attack, release }` pair is that these two
661/// differ; a scalar `[smoothing]` entry makes them equal by construction.
662#[derive(Debug, Clone, Copy, PartialEq, Eq)]
663pub struct StepResponse {
664 /// Frames from the step up until the frame settled at [`SETTLE_FRAC`].
665 pub rise_frames: u32,
666 /// Frames from the step down until the frame settled at [`SETTLE_FRAC`].
667 pub fall_frames: u32,
668}
669
670impl StepResponse {
671 /// `fall / rise` — the asymmetry, which is the number that reads.
672 ///
673 /// **This is a pixel-domain ratio, not a parameter-domain one.** A scene's
674 /// response to its own parameter is rarely linear, so the value differs from
675 /// the ratio of the `[smoothing]` constants themselves (ADR-0039); only its
676 /// distance from 1.0 is meaningful. A frame that never moved reports
677 /// `0.0` rather than dividing by zero.
678 pub fn ratio(self) -> f32 {
679 self.fall_frames as f32 / self.rise_frames.max(1) as f32
680 }
681}
682
683/// Measure a step response from two captured segments: `rise` starting at the
684/// last frame *before* the step up, `fall` starting at the last frame before the
685/// step down. Each segment's own last frame is taken as its settled state.
686///
687/// Both segments should be the **same length**, because each is normalized
688/// against its own final frame: a segment that has not fully settled
689/// underestimates the total change and so settles early.
690///
691/// **Equal windows do not make that bias cancel** (Plan 0038 Phase 8 corrected
692/// the reverse claim, which had been written here). Cancellation would need both
693/// directions to be truncated by the same fraction, which is exactly what an `{
694/// attack, release }` pair is built not to do: at `attack = 0.02` against a
695/// `release = 0.5` the rise finishes in 80 τ and carries **no** bias at all, so
696/// the fall's has nothing to cancel against and passes straight into
697/// [`StepResponse::ratio`]. That is not hypothetical — it is how this repo's own
698/// asymmetric probe reported a fall of 61 frames where the settled answer is 69.
699///
700/// Equal windows remain the right default. They are just not a guarantee:
701/// **gate on [`segment_settled`] before trusting either number.**
702pub fn step_response(rise: &[CaptureImage], fall: &[CaptureImage]) -> StepResponse {
703 StepResponse {
704 rise_frames: frames_to_settle(rise, SETTLE_FRAC),
705 fall_frames: frames_to_settle(fall, SETTLE_FRAC),
706 }
707}
708
709/// Index of the first frame in `segment` whose distance from `segment[0]` has
710/// reached `settle_frac` of the distance between the first and last frames.
711///
712/// `segment[0]` is the state at the step and the last entry is the settled
713/// state, so the answer is in frames-since-the-step. A segment that never moves
714/// (total change at or below the float epsilon) reports `0` — the honest answer
715/// for a preset the stimulus does not reach, and the one that keeps
716/// [`StepResponse::ratio`] finite.
717pub fn frames_to_settle(segment: &[CaptureImage], settle_frac: f32) -> u32 {
718 let (Some(start), Some(end)) = (segment.first(), segment.last()) else {
719 return 0;
720 };
721 let total = linear_diff(start, end);
722 if total <= f32::EPSILON {
723 return 0;
724 }
725 let target = total * settle_frac.clamp(0.0, 1.0);
726 for (i, img) in segment.iter().enumerate() {
727 if linear_diff(start, img) >= target {
728 return i as u32;
729 }
730 }
731 segment.len().saturating_sub(1) as u32
732}
733
734/// Whether `segment`'s last frame is close enough to its asymptote for
735/// [`frames_to_settle`] to mean anything — the question that function cannot
736/// answer about itself (Plan 0038 Phase 7).
737///
738/// **Why this is needed at all.** [`frames_to_settle`] normalizes against the
739/// segment's *own last frame*. When that frame is still travelling, the measured
740/// total is short and every threshold is crossed early — and the returned frame
741/// count is a plausible-looking number rather than an obvious failure, because
742/// normalizing against the last frame *guarantees* the threshold is reached
743/// inside the segment. So `frames_to_settle(seg, f) < seg.len()` is a tautology,
744/// not a check, and a caller has no way to tell *settled at frame k* from *still
745/// moving at frame k*. Plan 0038 Phase 3 read a truncated window as a shape
746/// difference between two orderings on exactly this basis; see ADR-0040's
747/// Outcome.
748///
749/// **The rule.** A settling response's change per unit time decays
750/// geometrically, so the tail beyond the last frame can be extrapolated without
751/// knowing the time constant. Sample three points at equal spacing `h` — the
752/// first frame `A`, the midpoint `B`, the last `C` — and for an exponential
753/// approach `|C - B| / |B - A|` is `exp(-h/tau)`, whatever `tau` is. The travel
754/// still to come after `C` is then `|C - B| * rho / (1 - rho)`. Settled means
755/// that estimate is under `tol` of the change measured so far.
756///
757/// **The three points are spread across the whole segment on purpose, not taken
758/// from the end.** Captures are 8-bit, and a response slow enough to outrun its
759/// window moves by *less than one code value per frame* near the end — the
760/// residual is large but each individual step is sub-quantum, so consecutive
761/// frames decode as identical and any estimator reading adjacent deltas concludes
762/// "flat, therefore settled" precisely in the case worth catching. Half a segment
763/// of travel is always far above the quantum. (Measured while building this: at
764/// `tau` = 2 s over a 2 s window the per-frame step is ~0.003 linear against a
765/// ~0.004 quantum at that brightness, and the adjacent-frame version of this
766/// function reported the response settled with 37 % of its travel left.)
767///
768/// Assumes a monotone approach, which every one-pole in this engine is — a
769/// response that overshoots is outside what this can judge.
770///
771/// This deliberately does **not** change [`frames_to_settle`] or
772/// [`step_response`], whose numbers `shot --report` publishes for the whole
773/// shipped library. Use it as the gate *before* trusting one of those numbers.
774pub fn segment_settled(segment: &[CaptureImage], tol: f32) -> bool {
775 let (Some(start), Some(end)) = (segment.first(), segment.last()) else {
776 return true; // Nothing captured: no claim to invalidate.
777 };
778 let total = linear_diff(start, end);
779 if total <= f32::EPSILON {
780 return true; // Never moved; `frames_to_settle` reports 0 and says so.
781 }
782 // Equal spacing is what makes the ratio below a pure function of tau.
783 let Some(mid) = segment.get(segment.len() / 2) else {
784 return false; // Too short to see a trend — assume nothing.
785 };
786 let (first_half, second_half) = (linear_diff(start, mid), linear_diff(mid, end));
787 if first_half <= f32::EPSILON {
788 return false; // No motion in the first half: nothing to extrapolate from.
789 }
790 let rho = second_half / first_half;
791 if !(0.0..1.0).contains(&rho) {
792 return false; // Not decaying: still ramping, or accelerating.
793 }
794 let remaining = second_half * rho / (1.0 - rho);
795 remaining <= tol * total
796}
797
798/// Mean absolute per-channel difference between two images in **linear light**,
799/// normalized to `0.0..=1.0`. Mismatched dimensions read as fully different.
800///
801/// [`frame_diff`] works on the stored sRGB bytes, which is right for "how
802/// different do these two look". It is *wrong* for a step response: sRGB's
803/// transfer curve is concave, so a parameter easing linearly toward its target
804/// crosses 90 % of its pixel change early on the way up and late on the way
805/// down, and a symmetric `[smoothing]` entry would measure asymmetric. Decoding
806/// first makes the probe's response proportional to the parameter for a scene
807/// whose shader is, which is exactly what the purpose-built easing fixtures are.
808fn linear_diff(a: &CaptureImage, b: &CaptureImage) -> f32 {
809 if a.width != b.width || a.height != b.height || a.rgba.len() != b.rgba.len() {
810 return 1.0;
811 }
812 let lut = srgb_decode_lut();
813 let mut sum = 0.0f64;
814 let mut count: u64 = 0;
815 for (pa, pb) in a.rgba.chunks_exact(4).zip(b.rgba.chunks_exact(4)) {
816 for c in 0..3 {
817 let (Some(&x), Some(&y)) = (pa.get(c), pb.get(c)) else {
818 continue;
819 };
820 let lx = lut.get(x as usize).copied().unwrap_or(0.0);
821 let ly = lut.get(y as usize).copied().unwrap_or(0.0);
822 sum += f64::from((lx - ly).abs());
823 count += 1;
824 }
825 }
826 if count == 0 {
827 return 0.0;
828 }
829 (sum / count as f64) as f32
830}
831
832/// The 256-entry sRGB→linear decode table, built once — the workspace's one
833/// sRGB decode. A table rather than a `powf` per channel because
834/// [`frames_to_settle`] runs a full-frame difference per captured frame, and the
835/// probe is a whole sequence of them.
836///
837/// Index it by the stored byte: `lut[b]` is the linear light of code value `b`.
838/// `linear_diff`'s doc comment carries why a level comparison decodes first
839/// (private, hence named rather than linked).
840pub fn srgb_decode_lut() -> &'static [f32; 256] {
841 static LUT: std::sync::OnceLock<[f32; 256]> = std::sync::OnceLock::new();
842 LUT.get_or_init(|| {
843 let mut table = [0.0f32; 256];
844 for (i, slot) in table.iter_mut().enumerate() {
845 let c = i as f32 / 255.0;
846 *slot = if c <= 0.040_45 {
847 c / 12.92
848 } else {
849 ((c + 0.055) / 1.055).powf(2.4)
850 };
851 }
852 table
853 })
854}
855
856/// Whether a pixel's RGB differs from `bg` by more than `eps` on any channel.
857fn is_lit(px: &[u8], bg: [u8; 4], eps: u8) -> bool {
858 px.iter()
859 .zip(bg.iter())
860 .take(3)
861 .any(|(&c, &b)| c.abs_diff(b) > eps)
862}
863
864/// Box-average an image down to a `STRUCT_GRID`×`STRUCT_GRID` grid of grayscale
865/// luma in `0.0..=1.0`.
866fn downscale_gray(img: &CaptureImage) -> Vec<f32> {
867 let g = STRUCT_GRID;
868 let mut cells = vec![0.0f32; g * g];
869 let mut counts = vec![0u32; g * g];
870 let w = img.width as usize;
871 let h = img.height as usize;
872 if w == 0 || h == 0 {
873 return cells;
874 }
875 for (i, px) in img.rgba.chunks_exact(4).enumerate() {
876 let x = i % w;
877 let y = i / w;
878 let cx = (x * g / w).min(g - 1);
879 let cy = (y * g / h).min(g - 1);
880 let idx = cy * g + cx;
881 let luma = 0.299 * px.first().copied().unwrap_or(0) as f32
882 + 0.587 * px.get(1).copied().unwrap_or(0) as f32
883 + 0.114 * px.get(2).copied().unwrap_or(0) as f32;
884 if let (Some(cell), Some(cnt)) = (cells.get_mut(idx), counts.get_mut(idx)) {
885 *cell += luma;
886 *cnt += 1;
887 }
888 }
889 for (cell, cnt) in cells.iter_mut().zip(counts.iter()) {
890 if *cnt > 0 {
891 *cell /= *cnt as f32 * 255.0;
892 }
893 }
894 cells
895}
896
897/// Sobel gradient magnitude over a `STRUCT_GRID`×`STRUCT_GRID` grayscale grid.
898/// Border cells stay zero (no wrap).
899fn sobel(gray: &[f32]) -> Vec<f32> {
900 let g = STRUCT_GRID;
901 let mut edges = vec![0.0f32; g * g];
902 let at = |x: usize, y: usize| -> f32 { gray.get(y * g + x).copied().unwrap_or(0.0) };
903 for y in 1..g.saturating_sub(1) {
904 for x in 1..g.saturating_sub(1) {
905 let gx = at(x + 1, y - 1) + 2.0 * at(x + 1, y) + at(x + 1, y + 1)
906 - at(x - 1, y - 1)
907 - 2.0 * at(x - 1, y)
908 - at(x - 1, y + 1);
909 let gy = at(x - 1, y + 1) + 2.0 * at(x, y + 1) + at(x + 1, y + 1)
910 - at(x - 1, y - 1)
911 - 2.0 * at(x, y - 1)
912 - at(x + 1, y - 1);
913 if let Some(e) = edges.get_mut(y * g + x) {
914 *e = (gx * gx + gy * gy).sqrt();
915 }
916 }
917 }
918 edges
919}
920
921/// Scale a map so its peak is 1.0; an all-zero map is returned unchanged.
922fn normalize_max(v: &[f32]) -> Vec<f32> {
923 let max = v.iter().copied().fold(0.0f32, f32::max);
924 if max <= f32::EPSILON {
925 return v.to_vec();
926 }
927 v.iter().map(|x| x / max).collect()
928}
929
930#[cfg(test)]
931mod tests;
932
933// ---------------------------------------------------------------------------
934// The in-frame geometry diagnostic (ADR-0083)
935// ---------------------------------------------------------------------------
936
937/// How much of the drawn segment length landed inside the render target, summed
938/// over one [`LineRenderer::draw`](crate::render::scenes::lines::LineRenderer::draw)
939/// call (Plan 0069, ADR-0083).
940///
941/// Pixel coverage cannot see an over-scaled figure: a comb roots every bar on a
942/// shared baseline and a corona roots every spoke at a centre, so clipping the
943/// tips costs a rounding error of lit pixels and the statistic goes the *wrong
944/// way*. Length does see it — a bar that overshoots loses in-frame length in
945/// exact proportion to the overshoot.
946///
947/// **Length, not area.** The stroke's width and the ADR-0041 join extensions are
948/// not counted, so a thick stroke leaving the frame is under-counted. That is
949/// the right measure for *overshoot* and a poor one for anything else.
950///
951/// **Arcs count too** (Plan 0087 Phase 2). An
952/// [`ArcInstance`](crate::render::scenes::lines::ArcInstance) contributes its
953/// own arc length, `|sweep| * radius`, to both sums. This is a correctness
954/// obligation of the arc primitive rather than a feature: an arc contributing
955/// nothing would shrink the denominator, and every arc-drawing preset would
956/// read better-framed than it is — the more so as the primitive replaces whole
957/// motifs, where the missing length is most of the figure.
958#[derive(Clone, Copy, Debug, Default, PartialEq)]
959pub struct DrawExtent {
960 /// World-space length of every segment actually drawn (post view transform).
961 pub total_len: f32,
962 /// The share of that length lying inside `[-aspect, aspect] x [-1, 1]`.
963 pub in_frame_len: f32,
964}
965
966impl DrawExtent {
967 /// The in-frame fraction — exactly `1.0` when nothing was clipped, exactly
968 /// `0.0` when the whole figure is outside.
969 ///
970 /// `None` when nothing was drawn at all: that is a `0/0`, and inventing a
971 /// number for it is what made Plan 0058's table print `inf`. "Nothing drawn"
972 /// is the *total* case and `core/tests/sanity.rs` is its instrument, not this
973 /// one.
974 pub fn fraction(self) -> Option<f32> {
975 (self.total_len > 0.0).then(|| self.in_frame_len / self.total_len)
976 }
977}
978
979// **Thread-local rather than a field on anything**, and the reason is a
980// reachability one. The measurement happens inside `LineRenderer::draw`, which
981// the four line scenes reach through an `Rc<RefCell<..>>` owned by the scene
982// registry (`scenes::create_all`); nothing outside `render` holds a handle to
983// it, and no `&mut` path runs from the `Renderer` down to that call without a
984// `Scene` trait parameter for a diagnostic that is off in every shipped frame.
985// Thread-local rather than a global: the renderer is single-threaded by
986// construction (`Rc`), so this is the cheapest correct sink, and it keeps one
987// test's switch out of another's capture when the harness runs test threads in
988// parallel.
989//
990// It lives here rather than beside the measuring code so that the shell reads a
991// diagnostic out of `render::metrics`, where every other reading it takes comes
992// from, instead of reaching five modules deep into a scene's renderer.
993thread_local! {
994 /// Whether `draw` measures. **Off in the shipped render path** — that is the
995 /// whole of the switch, and `core/tests/suite/geometry_extent.rs` asserts "off"
996 /// means byte-identical output.
997 static EXTENT_ON: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
998 /// The most recent measured draw, if any.
999 static LAST_EXTENT: std::cell::Cell<Option<DrawExtent>> = const { std::cell::Cell::new(None) };
1000}
1001
1002/// Turn the in-frame geometry diagnostic on or off for **this thread**, clearing
1003/// any measurement already recorded. Off by default; the shipped render path
1004/// never calls this.
1005pub fn set_extent_diagnostic(on: bool) {
1006 EXTENT_ON.with(|flag| flag.set(on));
1007 LAST_EXTENT.with(|slot| slot.set(None));
1008}
1009
1010/// Take the extent of the **most recent** measured `draw`, leaving the slot
1011/// empty. `None` when no line scene has drawn since the diagnostic was enabled
1012/// (or when it is off) — distinct from a recorded draw whose
1013/// [`fraction`](DrawExtent::fraction) is `None` because nothing was drawn.
1014///
1015/// A frame usually holds one line draw ([`scenes::shares_resources`] forbids
1016/// two *roster* line scenes in a frame), and then "the most recent draw" is
1017/// "this frame's figure". A preset may layer a second line scene (Plan 0076)
1018/// through its own per-preset `LineRenderer`
1019/// (`scenes::create_layer_scene`) — the layer draws **after** the main
1020/// scene, so on a layered line-on-line preset this slot holds the *layer's*
1021/// figure. The harness reads this around single-figure captures; a consumer
1022/// measuring a layered preset must know which draw it is measuring.
1023///
1024/// [`scenes::shares_resources`]: crate::render::scenes
1025pub fn take_draw_extent() -> Option<DrawExtent> {
1026 LAST_EXTENT.with(|slot| slot.take())
1027}
1028
1029/// Whether the in-frame geometry diagnostic is measuring on this thread.
1030///
1031/// Read once per `LineRenderer::draw`. Off in every shipped frame, which is what
1032/// `core/tests/suite/geometry_extent.rs` asserts by comparing output with the switch
1033/// off against the committed goldens.
1034pub fn extent_diagnostic_on() -> bool {
1035 EXTENT_ON.with(std::cell::Cell::get)
1036}
1037
1038/// Record one measured draw, replacing whatever the slot held.
1039pub fn record_draw_extent(extent: DrawExtent) {
1040 LAST_EXTENT.with(|slot| slot.set(Some(extent)));
1041}