use crate::types::{ScreenParams, ScreenType};
pub struct HalftoneScreen {
params: ScreenParams,
mat: Option<Vec<u8>>, size: usize,
size_m1: usize, log2_size: u32,
}
impl HalftoneScreen {
#[must_use]
pub const fn new(params: ScreenParams) -> Self {
Self {
params,
mat: None,
size: 0,
size_m1: 0,
log2_size: 0,
}
}
#[inline]
pub fn test(&mut self, x: i32, y: i32, value: u8) -> bool {
if self.mat.is_none() {
self.create_matrix();
}
let mat = self.mat.as_deref().unwrap_or(&[]);
debug_assert!(!mat.is_empty(), "test: mat must be set after create_matrix");
debug_assert!(
i32::try_from(self.size).is_ok(),
"test: size={} exceeds i32::MAX; rem_euclid modulus would be wrong",
self.size
);
#[expect(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
reason = "size always fits i32: bounded by params.size (i32) in create_matrix"
)]
let size_i32 = self.size as i32;
let xx = x.rem_euclid(size_i32) as usize;
let yy = y.rem_euclid(size_i32) as usize;
value >= mat[(yy << self.log2_size) + xx]
}
fn create_matrix(&mut self) {
let mut size = 2usize;
let mut log2 = 1u32;
while i32::try_from(size).unwrap_or(i32::MAX) < self.params.size {
size = size.saturating_mul(2);
log2 = log2.saturating_add(1);
}
if self.params.kind == ScreenType::StochasticClustered {
let min_size = usize::try_from(2 * self.params.dot_radius).unwrap_or(0);
while size < min_size {
size = size.saturating_mul(2);
log2 = log2.saturating_add(1);
}
}
self.size = size;
self.size_m1 = size - 1;
self.log2_size = log2;
debug_assert!(
size.checked_mul(size).is_some(),
"size*size overflow: size={size}"
);
let mut mat = vec![0u8; size * size];
match self.params.kind {
ScreenType::Dispersed => build_dispersed(&mut mat, size, log2),
ScreenType::Clustered => build_clustered(&mut mat, size),
ScreenType::StochasticClustered => {
let dot_radius = usize::try_from(self.params.dot_radius)
.expect("dot_radius must be non-negative");
build_stochastic_clustered(&mut mat, size, dot_radius);
}
}
clamp_min_one(&mut mat);
self.mat = Some(mat);
}
}
#[inline]
fn clamp_min_one(mat: &mut [u8]) {
for v in mat {
if *v == 0 {
*v = 1;
}
}
}
fn build_dispersed(mat: &mut [u8], size: usize, log2_size: u32) {
debug_assert!(size >= 2, "build_dispersed: size must be >= 2, got {size}");
debug_assert!(
u32::try_from(size * size).is_ok(),
"size*size={} exceeds u32::MAX",
size * size
);
let total = u32::try_from(size * size).expect("size*size verified to fit u32 above");
for y in 0..size {
for x in 0..size {
let v = bayer_index(x, y, log2_size);
mat[y * size + x] = u8::try_from(((v * 255 + total / 2) / total).clamp(1, 255))
.expect("clamped to [1, 255]; always fits u8");
}
}
}
fn bayer_index(mut x: usize, mut y: usize, log2_size: u32) -> u32 {
debug_assert!(
log2_size <= 15,
"bayer_index: log2_size={log2_size} would overflow step (max 15)"
);
let mut rank = 0u32;
let mut step = 1u32;
for _ in 0..log2_size {
let xi = x & 1;
let yi = y & 1;
let bits = match (xi, yi) {
(0, 0) => 0,
(1, 0) => 2,
(0, 1) => 3,
_ => 1,
};
rank += bits * step;
step = step.saturating_mul(4);
x >>= 1;
y >>= 1;
}
rank
}
fn build_clustered(mat: &mut [u8], size: usize) {
debug_assert!(size >= 2, "build_clustered: size must be >= 2, got {size}");
debug_assert!(
u32::try_from(size / 2).is_ok(),
"size/2={} exceeds u32::MAX",
size / 2
);
let half = f64::from(u32::try_from(size / 2).expect("size/2 verified to fit u32 above"));
let cx = half;
let cy = half;
let max_dist = cx * cx + cy * cy;
debug_assert!(
max_dist > 0.0,
"build_clustered: max_dist is zero (size={size})"
);
for y in 0..size {
for x in 0..size {
debug_assert!(
u32::try_from(x).is_ok() && u32::try_from(y).is_ok(),
"x={x} or y={y} exceeds u32::MAX"
);
let dx =
f64::from(u32::try_from(x).expect("x < size <= u32::MAX (verified above)")) - cx;
let dy =
f64::from(u32::try_from(y).expect("y < size <= u32::MAX (verified above)")) - cy;
let dist = dx.mul_add(dx, dy * dy);
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "value clamped to [0.5, 254.5] before cast; fits in u8"
)]
let v = (1.0_f64 - dist / max_dist)
.mul_add(254.0, 0.5)
.clamp(0.5, 254.5) as u8;
mat[y * size + x] = v.max(1);
}
}
}
fn build_stochastic_clustered(mat: &mut [u8], size: usize, dot_radius: usize) {
debug_assert!(
size >= 2,
"build_stochastic_clustered: size must be >= 2, got {size}"
);
let n = size * size;
debug_assert!(n >= 4, "build_stochastic_clustered: n={n} must be >= 4");
let mut thresholds: Vec<(usize, f64)> = (0..n)
.map(|i| {
let x = i % size;
let y = i / size;
let dist = nearest_dot_dist(x, y, size, dot_radius);
(i, dist)
})
.collect();
thresholds.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
for (rank, (idx, _)) in thresholds.iter().enumerate() {
mat[*idx] = u8::try_from((rank * 254 / n).clamp(0, 255))
.expect("rank * 254 / n ∈ [0, 254]; clamped to [0, 255]; fits u8")
.max(1);
}
}
fn nearest_dot_dist(x: usize, y: usize, size: usize, dot_radius: usize) -> f64 {
let step = (dot_radius * 2).max(1);
debug_assert!(step >= 1, "nearest_dot_dist: step must be >= 1");
let mut min_d = f64::INFINITY;
let mut cx = 0usize;
while cx < size {
let mut cy = 0usize;
while cy < size {
let dx = torus_dist(x, cx, size);
let dy = torus_dist(y, cy, size);
let d = dx.mul_add(dx, dy * dy);
if d < min_d {
min_d = d;
}
cy += step;
}
cx += step;
}
min_d
}
fn torus_dist(a: usize, b: usize, size: usize) -> f64 {
debug_assert!(a < size, "torus_dist: a={a} out of range [0, {size})");
debug_assert!(b < size, "torus_dist: b={b} out of range [0, {size})");
let straight = a.abs_diff(b);
let wrap = size.saturating_sub(straight);
let d = straight.min(wrap);
debug_assert!(
u32::try_from(d).is_ok(),
"torus_dist: d={d} exceeds u32::MAX"
);
f64::from(u32::try_from(d).expect("d <= size/2; size bounded by i32 params so d fits u32"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ScreenParams, ScreenType};
#[test]
fn test_wraps_by_size() {
let params = ScreenParams {
kind: ScreenType::Dispersed,
size: 4,
dot_radius: 2,
};
let mut screen = HalftoneScreen::new(params);
let v = 128u8;
let r0 = screen.test(0, 0, v);
let r4 = screen.test(4, 0, v);
assert_eq!(r0, r4);
}
#[test]
fn zero_is_always_black() {
let params = ScreenParams::default();
let mut screen = HalftoneScreen::new(params);
for y in 0..8i32 {
for x in 0..8i32 {
assert!(
!screen.test(x, y, 0),
"value=0 should be black at ({x},{y})"
);
}
}
}
#[test]
fn max_is_always_white() {
let params = ScreenParams::default();
let mut screen = HalftoneScreen::new(params);
for y in 0..8i32 {
for x in 0..8i32 {
assert!(
screen.test(x, y, 255),
"value=255 should be white at ({x},{y})"
);
}
}
}
#[test]
fn matrix_sum_nonzero() {
let params = ScreenParams {
kind: ScreenType::Dispersed,
size: 4,
dot_radius: 2,
};
let mut screen = HalftoneScreen::new(params);
screen.create_matrix();
let mat = screen.mat.as_ref().unwrap();
let sum: u32 = mat.iter().map(|&v| u32::from(v)).sum();
assert!(sum > 0);
assert_eq!(mat.len(), screen.size * screen.size);
}
#[test]
fn torus_dist_symmetric() {
let size = 8usize;
for a in 0..size {
for b in 0..size {
let d_ab = torus_dist(a, b, size);
let d_ba = torus_dist(b, a, size);
assert!(
(d_ab - d_ba).abs() < f64::EPSILON,
"torus_dist not symmetric: ({a},{b}) size={size}"
);
}
}
}
#[test]
fn torus_dist_wrap_shorter() {
let d = torus_dist(0, 7, 8);
assert!(
(d - 1.0).abs() < f64::EPSILON,
"expected wrap distance 1.0, got {d}"
);
}
#[test]
fn clamp_min_one_sets_zeros() {
let mut buf = vec![0u8, 1, 0, 255, 0];
clamp_min_one(&mut buf);
assert_eq!(buf, vec![1u8, 1, 1, 255, 1]);
}
#[test]
fn all_screen_types_produce_valid_matrices() {
for kind in [
ScreenType::Dispersed,
ScreenType::Clustered,
ScreenType::StochasticClustered,
] {
let params = ScreenParams {
kind,
size: 4,
dot_radius: 2,
};
let mut screen = HalftoneScreen::new(params);
screen.create_matrix();
let mat = screen.mat.as_ref().unwrap();
assert!(
mat.iter().all(|&v| v >= 1),
"matrix for {kind:?} contains zero entries"
);
assert_eq!(mat.len(), screen.size * screen.size);
}
}
#[test]
fn bayer_index_zero_at_origin() {
assert_eq!(bayer_index(0, 0, 2), 0);
}
#[test]
fn bayer_index_all_unique_2x2() {
let ranks: Vec<u32> = (0..2)
.flat_map(|y| (0..2).map(move |x| bayer_index(x, y, 1)))
.collect();
let mut sorted = ranks.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), 4, "expected 4 unique ranks, got {ranks:?}");
}
}