rasterrocket_render/screen.rs
1//! Halftone threshold screen for Mono1 (1-bit) output.
2//!
3//! Mirrors `SplashScreen` from `splash/SplashScreen.h/.cc`.
4//!
5//! The screen is a power-of-2 tiled threshold matrix. A pixel with value `v`
6//! is set to white (1) when `v >= mat[y % size][x % size]`, and black (0)
7//! otherwise. The matrix is built lazily on first `test()` call.
8//!
9//! Three matrix types:
10//! - **Dispersed** (Bayer-style): even dot distribution, low correlation.
11//! - **Clustered**: traditional clustered dot, good for offset printing.
12//! - **`StochasticClustered`**: randomized large-dot screen at ≥ 300 dpi.
13
14use crate::types::{ScreenParams, ScreenType};
15
16/// A halftone threshold screen.
17pub struct HalftoneScreen {
18 params: ScreenParams,
19 mat: Option<Vec<u8>>, // size × size, lazily allocated
20 size: usize,
21 size_m1: usize, // size - 1 (bitmask for wrapping)
22 log2_size: u32,
23}
24
25impl HalftoneScreen {
26 /// Create a screen with the given parameters.
27 /// The threshold matrix is built lazily on the first `test()` call.
28 #[must_use]
29 pub const fn new(params: ScreenParams) -> Self {
30 Self {
31 params,
32 mat: None,
33 size: 0,
34 size_m1: 0,
35 log2_size: 0,
36 }
37 }
38
39 /// Test whether pixel `(x, y)` with intensity `value` (0=black, 255=white)
40 /// should be rendered as white (returns `true`) or black (`false`).
41 ///
42 /// Matches `SplashScreen::test` in `SplashScreen.h`.
43 #[inline]
44 pub fn test(&mut self, x: i32, y: i32, value: u8) -> bool {
45 if self.mat.is_none() {
46 self.create_matrix();
47 }
48 // SAFETY: create_matrix always initialises self.mat; the is_none check
49 // above ensures create_matrix ran, so the unwrap_or branch is unreachable.
50 let mat = self.mat.as_deref().unwrap_or(&[]);
51 debug_assert!(!mat.is_empty(), "test: mat must be set after create_matrix");
52 // size always fits i32: create_matrix builds size by doubling from 2
53 // up to params.size (which is i32), so size ≤ i32::MAX + 1. The saturating
54 // loop prevents wrap; in practice size ≤ 2^31 always fits.
55 debug_assert!(
56 i32::try_from(self.size).is_ok(),
57 "test: size={} exceeds i32::MAX; rem_euclid modulus would be wrong",
58 self.size
59 );
60 #[expect(
61 clippy::cast_possible_truncation,
62 clippy::cast_possible_wrap,
63 reason = "size always fits i32: bounded by params.size (i32) in create_matrix"
64 )]
65 let size_i32 = self.size as i32;
66 // rem_euclid returns a value in [0, size_i32), which is always non-negative,
67 // so the cast to usize is safe. Clippy does not fire cast_sign_loss here
68 // because the compiler recognises rem_euclid's return type is i32 and the
69 // cast is unconditional — no annotation needed.
70 let xx = x.rem_euclid(size_i32) as usize;
71 let yy = y.rem_euclid(size_i32) as usize;
72 value >= mat[(yy << self.log2_size) + xx]
73 }
74
75 // ── Matrix construction ───────────────────────────────────────────────────
76
77 /// Build the threshold matrix, choosing the smallest power-of-2 size that
78 /// satisfies both `params.size` and (for `StochasticClustered`) the
79 /// minimum `2 × dot_radius` constraint.
80 fn create_matrix(&mut self) {
81 // Find smallest power-of-2 size ≥ params.size.
82 let mut size = 2usize;
83 let mut log2 = 1u32;
84 // i32::try_from(size) fits while size ≤ i32::MAX; once size exceeds
85 // i32::MAX the comparison saturates and the loop exits (params.size is i32).
86 while i32::try_from(size).unwrap_or(i32::MAX) < self.params.size {
87 size = size.saturating_mul(2);
88 log2 = log2.saturating_add(1);
89 }
90 // For stochastic clustered: ensure size ≥ 2 × dot_radius.
91 if self.params.kind == ScreenType::StochasticClustered {
92 // dot_radius is i32 ≥ 0 (validated by ScreenParams::validate); the
93 // product 2 * dot_radius fits in usize. unwrap_or(0) is a safe
94 // fallback for the impossible-negative case.
95 let min_size = usize::try_from(2 * self.params.dot_radius).unwrap_or(0);
96 while size < min_size {
97 size = size.saturating_mul(2);
98 log2 = log2.saturating_add(1);
99 }
100 }
101 self.size = size;
102 self.size_m1 = size - 1;
103 self.log2_size = log2;
104
105 // `size` is a power of 2 in [2, 2^31); size*size fits in usize on any
106 // 32-bit-or-wider target because size ≤ 2^15 in practice (params.size
107 // is an i32, so size ≤ i32::MAX+1 ≈ 2^31, but the loop starts at 2).
108 // The debug_assert catches the theoretical overflow on exotic targets.
109 debug_assert!(
110 size.checked_mul(size).is_some(),
111 "size*size overflow: size={size}"
112 );
113 let mut mat = vec![0u8; size * size];
114 match self.params.kind {
115 ScreenType::Dispersed => build_dispersed(&mut mat, size, log2),
116 ScreenType::Clustered => build_clustered(&mut mat, size),
117 ScreenType::StochasticClustered => {
118 // dot_radius ≥ 0 (ScreenParams invariant); try_from succeeds.
119 let dot_radius = usize::try_from(self.params.dot_radius)
120 .expect("dot_radius must be non-negative");
121 build_stochastic_clustered(&mut mat, size, dot_radius);
122 }
123 }
124 // Clamp all entries to ≥ 1 so that value=0 is always black.
125 clamp_min_one(&mut mat);
126 self.mat = Some(mat);
127 }
128}
129
130// ── Shared normalization helper ───────────────────────────────────────────────
131
132/// Clamp every element of `mat` to `≥ 1`.
133///
134/// This ensures that a pixel with `value = 0` is always rendered black,
135/// because the threshold comparison is `value >= mat[…]` and `0 >= 1`
136/// is always false.
137#[inline]
138fn clamp_min_one(mat: &mut [u8]) {
139 for v in mat {
140 if *v == 0 {
141 *v = 1;
142 }
143 }
144}
145
146// ── Dispersed (Bayer) matrix ──────────────────────────────────────────────────
147
148/// Recursive void-pointer dispersed dot (Bayer) matrix construction.
149/// Matches `SplashScreen::buildDispersedMatrix` in `SplashScreen.cc`.
150///
151/// Fills `mat` (length `size × size`) with threshold values in `[1, 255]`
152/// distributed in the classic Bayer ordered-dither pattern. The pattern
153/// ensures that dots are maximally dispersed — no two adjacent cells have
154/// similar thresholds.
155///
156/// # Panics (debug only)
157///
158/// Panics in debug builds if `size < 2`.
159fn build_dispersed(mat: &mut [u8], size: usize, log2_size: u32) {
160 debug_assert!(size >= 2, "build_dispersed: size must be >= 2, got {size}");
161 // `size` is a small power of 2 (≤ 2^15 in practice), so `size*size` fits
162 // in u32. The debug_assert in create_matrix has already verified this.
163 debug_assert!(
164 u32::try_from(size * size).is_ok(),
165 "size*size={} exceeds u32::MAX",
166 size * size
167 );
168 // debug_assert above verified size*size fits u32.
169 let total = u32::try_from(size * size).expect("size*size verified to fit u32 above");
170 for y in 0..size {
171 for x in 0..size {
172 // Compute the Bayer index for (x, y) at this size.
173 let v = bayer_index(x, y, log2_size);
174 // Scale to [1, 255]. The clamp guarantees the value fits in u8.
175 mat[y * size + x] = u8::try_from(((v * 255 + total / 2) / total).clamp(1, 255))
176 .expect("clamped to [1, 255]; always fits u8");
177 }
178 }
179}
180
181/// Compute the Bayer (interleaved) rank for position `(x, y)` in a
182/// `2^log2_size × 2^log2_size` matrix.
183///
184/// The algorithm visits each bit-level of the coordinates in turn. At each
185/// level the two low-order bits `(xi, yi)` contribute a 2-bit code using the
186/// standard Bayer mapping:
187///
188/// ```text
189/// (xi=0, yi=0) → 0 (xi=1, yi=0) → 2
190/// (xi=0, yi=1) → 3 (xi=1, yi=1) → 1
191/// ```
192///
193/// These codes are accumulated into `rank` with weight `4^level`, so the
194/// overall rank is a base-4 number whose digits are the per-level codes.
195/// The result is the canonical Bayer threshold order: at every scale the
196/// dots are arranged so that no two nearby pixels share a threshold value.
197///
198/// # Panics (debug only)
199///
200/// Panics in debug builds if `log2_size > 15` (which would make `step`
201/// exceed `u32::MAX` via `4^16 > 2^32`).
202fn bayer_index(mut x: usize, mut y: usize, log2_size: u32) -> u32 {
203 debug_assert!(
204 log2_size <= 15,
205 "bayer_index: log2_size={log2_size} would overflow step (max 15)"
206 );
207 let mut rank = 0u32;
208 let mut step = 1u32;
209 for _ in 0..log2_size {
210 let xi = x & 1;
211 let yi = y & 1;
212 // Standard Bayer: rank bits come from (xi XOR yi, yi).
213 let bits = match (xi, yi) {
214 (0, 0) => 0,
215 (1, 0) => 2,
216 (0, 1) => 3,
217 _ => 1,
218 };
219 rank += bits * step;
220 step = step.saturating_mul(4);
221 x >>= 1;
222 y >>= 1;
223 }
224 rank
225}
226
227// ── Clustered dot matrix ──────────────────────────────────────────────────────
228
229/// Simple clustered dot screen. Generates a radially clustered threshold
230/// matrix centred at `(size/2, size/2)`.
231///
232/// Each cell receives a threshold proportional to `1 − dist²/max_dist²`,
233/// so cells near the centre cluster together and darken before the
234/// periphery: this is the traditional analog halftone "dot" look.
235///
236/// # Panics (debug only)
237///
238/// Panics in debug builds if `size < 2`.
239fn build_clustered(mat: &mut [u8], size: usize) {
240 debug_assert!(size >= 2, "build_clustered: size must be >= 2, got {size}");
241 // size is a small power of 2 (≤ 64 in practice); cast through u32 to avoid
242 // cast_precision_loss lint (u32 → f64 is always lossless).
243 debug_assert!(
244 u32::try_from(size / 2).is_ok(),
245 "size/2={} exceeds u32::MAX",
246 size / 2
247 );
248 let half = f64::from(u32::try_from(size / 2).expect("size/2 verified to fit u32 above"));
249 let cx = half;
250 let cy = half;
251 let max_dist = cx * cx + cy * cy;
252 // size >= 2 ⟹ half >= 1.0 ⟹ max_dist >= 2.0; division by zero is
253 // impossible. Guard defensively for the debug build.
254 debug_assert!(
255 max_dist > 0.0,
256 "build_clustered: max_dist is zero (size={size})"
257 );
258 for y in 0..size {
259 for x in 0..size {
260 debug_assert!(
261 u32::try_from(x).is_ok() && u32::try_from(y).is_ok(),
262 "x={x} or y={y} exceeds u32::MAX"
263 );
264 let dx =
265 f64::from(u32::try_from(x).expect("x < size <= u32::MAX (verified above)")) - cx;
266 let dy =
267 f64::from(u32::try_from(y).expect("y < size <= u32::MAX (verified above)")) - cy;
268 let dist = dx.mul_add(dx, dy * dy);
269 // Known false-positive: value is clamped to [0.5, 254.5] so
270 // truncation to u8 is safe. #[expect] errors if clippy ever fixes
271 // the false-positive (github.com/rust-lang/rust-clippy/issues/7486).
272 #[expect(
273 clippy::cast_possible_truncation,
274 clippy::cast_sign_loss,
275 reason = "value clamped to [0.5, 254.5] before cast; fits in u8"
276 )]
277 let v = (1.0_f64 - dist / max_dist)
278 .mul_add(254.0, 0.5)
279 .clamp(0.5, 254.5) as u8;
280 mat[y * size + x] = v.max(1);
281 }
282 }
283}
284
285// ── Stochastic clustered matrix ───────────────────────────────────────────────
286
287/// Stochastic clustered dot screen. Places dots at semi-random positions
288/// with a minimum distance of `dot_radius` between dot centres.
289/// Matches `SplashScreen::buildSCDMatrix` in spirit.
290///
291/// Cells are sorted by their distance to the nearest jittered dot centre
292/// (using a torus/wrap-around metric so the matrix tiles seamlessly).
293/// Cells close to a dot centre receive low thresholds (they darken first);
294/// cells far from any centre receive high thresholds.
295///
296/// # Panics (debug only)
297///
298/// Panics in debug builds if `size < 2`.
299fn build_stochastic_clustered(mat: &mut [u8], size: usize, dot_radius: usize) {
300 debug_assert!(
301 size >= 2,
302 "build_stochastic_clustered: size must be >= 2, got {size}"
303 );
304 // Simplified version: place dot centres on a jittered grid and build
305 // a distance-based threshold matrix. The C++ version uses a void-pointer
306 // algorithm with a priority queue; this captures the same visual character.
307 let n = size * size;
308 // n >= 4 because size >= 2; division by n is safe.
309 debug_assert!(n >= 4, "build_stochastic_clustered: n={n} must be >= 4");
310 let mut thresholds: Vec<(usize, f64)> = (0..n)
311 .map(|i| {
312 let x = i % size;
313 let y = i / size;
314 // Nearest dot-centre distance (torus metric).
315 let dist = nearest_dot_dist(x, y, size, dot_radius);
316 (i, dist)
317 })
318 .collect();
319 thresholds.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
320 for (rank, (idx, _)) in thresholds.iter().enumerate() {
321 // rank < n, so rank * 254 / n is in [0, 254]; fits in u8.
322 // rank < n, so rank * 254 / n ∈ [0, 254]; `.clamp(0, 255)` makes the
323 // bound explicit for the type-checker; always fits u8.
324 mat[*idx] = u8::try_from((rank * 254 / n).clamp(0, 255))
325 .expect("rank * 254 / n ∈ [0, 254]; clamped to [0, 255]; fits u8")
326 .max(1);
327 }
328}
329
330/// Compute the squared distance from `(x, y)` to the nearest dot centre on a
331/// torus of side `size`.
332///
333/// Dot centres are placed on a regular grid with spacing `step = max(2 ×
334/// dot_radius, 1)`. The torus metric wraps coordinates so that the matrix
335/// tiles seamlessly with no visible seam at the edges.
336///
337/// Returns `f64::INFINITY` only if the grid has no centres — which cannot
338/// happen because `step ≥ 1` and `size ≥ 1`, so the loop always executes at
339/// least one iteration.
340fn nearest_dot_dist(x: usize, y: usize, size: usize, dot_radius: usize) -> f64 {
341 // step >= 1 ensures the while loops always advance; no infinite loop risk.
342 let step = (dot_radius * 2).max(1);
343 debug_assert!(step >= 1, "nearest_dot_dist: step must be >= 1");
344 let mut min_d = f64::INFINITY;
345 let mut cx = 0usize;
346 while cx < size {
347 let mut cy = 0usize;
348 while cy < size {
349 let dx = torus_dist(x, cx, size);
350 let dy = torus_dist(y, cy, size);
351 let d = dx.mul_add(dx, dy * dy);
352 if d < min_d {
353 min_d = d;
354 }
355 cy += step;
356 }
357 cx += step;
358 }
359 min_d
360}
361
362/// Compute the shortest (wrap-around) distance between positions `a` and `b`
363/// on a 1-D torus of circumference `size`.
364///
365/// Both `a` and `b` are valid pixel indices and therefore in `[0, size)`.
366/// The straight-line distance is `a.abs_diff(b)`; the wrap-around distance
367/// is `size - a.abs_diff(b)`. The torus distance is the minimum of the two.
368///
369/// # Panics (debug only)
370///
371/// Panics in debug builds if `a >= size` or `b >= size` (which would make
372/// `abs_diff(b) > size - 1`, causing `size - abs_diff` to wrap on overflow).
373fn torus_dist(a: usize, b: usize, size: usize) -> f64 {
374 debug_assert!(a < size, "torus_dist: a={a} out of range [0, {size})");
375 debug_assert!(b < size, "torus_dist: b={b} out of range [0, {size})");
376 // a, b < size ⟹ abs_diff <= size-1 < size ⟹ size - abs_diff >= 1;
377 // no wrapping possible on either subtraction.
378 let straight = a.abs_diff(b);
379 // Saturating handles the theoretical edge where size==0; the debug_asserts
380 // above already rule that out in practice.
381 let wrap = size.saturating_sub(straight);
382 let d = straight.min(wrap);
383 // d ≤ size/2 ≤ size ≤ i32::MAX in practice; cast through u32 to avoid the
384 // cast_precision_loss lint (u32 → f64 is always exact).
385 debug_assert!(
386 u32::try_from(d).is_ok(),
387 "torus_dist: d={d} exceeds u32::MAX"
388 );
389 f64::from(u32::try_from(d).expect("d <= size/2; size bounded by i32 params so d fits u32"))
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395 use crate::types::{ScreenParams, ScreenType};
396
397 #[test]
398 fn test_wraps_by_size() {
399 let params = ScreenParams {
400 kind: ScreenType::Dispersed,
401 size: 4,
402 dot_radius: 2,
403 };
404 let mut screen = HalftoneScreen::new(params);
405 // test at (0,0) and (4,0) must give same result (wrapping).
406 let v = 128u8;
407 let r0 = screen.test(0, 0, v);
408 let r4 = screen.test(4, 0, v);
409 assert_eq!(r0, r4);
410 }
411
412 #[test]
413 fn zero_is_always_black() {
414 let params = ScreenParams::default();
415 let mut screen = HalftoneScreen::new(params);
416 for y in 0..8i32 {
417 for x in 0..8i32 {
418 assert!(
419 !screen.test(x, y, 0),
420 "value=0 should be black at ({x},{y})"
421 );
422 }
423 }
424 }
425
426 #[test]
427 fn max_is_always_white() {
428 let params = ScreenParams::default();
429 let mut screen = HalftoneScreen::new(params);
430 for y in 0..8i32 {
431 for x in 0..8i32 {
432 assert!(
433 screen.test(x, y, 255),
434 "value=255 should be white at ({x},{y})"
435 );
436 }
437 }
438 }
439
440 #[test]
441 fn matrix_sum_nonzero() {
442 let params = ScreenParams {
443 kind: ScreenType::Dispersed,
444 size: 4,
445 dot_radius: 2,
446 };
447 let mut screen = HalftoneScreen::new(params);
448 screen.create_matrix();
449 let mat = screen.mat.as_ref().unwrap();
450 let sum: u32 = mat.iter().map(|&v| u32::from(v)).sum();
451 assert!(sum > 0);
452 assert_eq!(mat.len(), screen.size * screen.size);
453 }
454
455 #[test]
456 fn torus_dist_symmetric() {
457 // torus_dist(a, b, size) must equal torus_dist(b, a, size).
458 let size = 8usize;
459 for a in 0..size {
460 for b in 0..size {
461 let d_ab = torus_dist(a, b, size);
462 let d_ba = torus_dist(b, a, size);
463 assert!(
464 (d_ab - d_ba).abs() < f64::EPSILON,
465 "torus_dist not symmetric: ({a},{b}) size={size}"
466 );
467 }
468 }
469 }
470
471 #[test]
472 fn torus_dist_wrap_shorter() {
473 // In a size-8 torus, torus_dist(0, 7, 8) should be 1 (wrap), not 7.
474 let d = torus_dist(0, 7, 8);
475 assert!(
476 (d - 1.0).abs() < f64::EPSILON,
477 "expected wrap distance 1.0, got {d}"
478 );
479 }
480
481 #[test]
482 fn clamp_min_one_sets_zeros() {
483 let mut buf = vec![0u8, 1, 0, 255, 0];
484 clamp_min_one(&mut buf);
485 assert_eq!(buf, vec![1u8, 1, 1, 255, 1]);
486 }
487
488 #[test]
489 fn all_screen_types_produce_valid_matrices() {
490 for kind in [
491 ScreenType::Dispersed,
492 ScreenType::Clustered,
493 ScreenType::StochasticClustered,
494 ] {
495 let params = ScreenParams {
496 kind,
497 size: 4,
498 dot_radius: 2,
499 };
500 let mut screen = HalftoneScreen::new(params);
501 screen.create_matrix();
502 let mat = screen.mat.as_ref().unwrap();
503 assert!(
504 mat.iter().all(|&v| v >= 1),
505 "matrix for {kind:?} contains zero entries"
506 );
507 assert_eq!(mat.len(), screen.size * screen.size);
508 }
509 }
510
511 #[test]
512 fn bayer_index_zero_at_origin() {
513 // By definition the top-left corner of the Bayer matrix has rank 0.
514 assert_eq!(bayer_index(0, 0, 2), 0);
515 }
516
517 #[test]
518 fn bayer_index_all_unique_2x2() {
519 // In a 2×2 Bayer matrix (log2_size=1) all four ranks must be distinct.
520 let ranks: Vec<u32> = (0..2)
521 .flat_map(|y| (0..2).map(move |x| bayer_index(x, y, 1)))
522 .collect();
523 let mut sorted = ranks.clone();
524 sorted.sort_unstable();
525 sorted.dedup();
526 assert_eq!(sorted.len(), 4, "expected 4 unique ranks, got {ranks:?}");
527 }
528}