Skip to main content

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}