Skip to main content

zenpixels/
policy.rs

1//! Conversion policy types for explicit control over lossy operations.
2//!
3//! All lossy pixel format conversions (alpha removal, depth reduction, etc.)
4//! require an explicit policy choice — there are no silent defaults.
5
6/// How to expand grayscale channels to RGB.
7///
8/// Used when converting from a grayscale layout to an RGB-family layout.
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10#[non_exhaustive]
11pub enum GrayExpand {
12    /// Channel broadcast: `v → (v, v, v)`. Lossless.
13    Broadcast,
14}
15
16/// Policy for alpha channel removal. Required when converting
17/// from a layout with alpha to one without.
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
19#[non_exhaustive]
20pub enum AlphaPolicy {
21    /// Discard only if every pixel is fully opaque. Returns error otherwise.
22    DiscardIfOpaque,
23    /// Discard unconditionally. Caller acknowledges data loss.
24    DiscardUnchecked,
25    /// Composite onto solid background (values in source range, 0–255 for U8).
26    CompositeOnto {
27        /// Red background value.
28        r: u8,
29        /// Green background value.
30        g: u8,
31        /// Blue background value.
32        b: u8,
33    },
34    /// Return error rather than dropping alpha.
35    Forbid,
36}
37
38/// Policy for bit depth reduction (U16→U8, etc.).
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
40#[non_exhaustive]
41pub enum DepthPolicy {
42    /// Round to nearest value.
43    Round,
44    /// Truncate (floor). Faster, biased toward lower values.
45    Truncate,
46    /// Return error rather than reducing depth.
47    Forbid,
48}
49
50/// Luma coefficients for RGB→Gray conversion.
51#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
52#[non_exhaustive]
53pub enum LumaCoefficients {
54    /// BT.709: `0.2126R + 0.7152G + 0.0722B` (HDTV, sRGB).
55    Bt709,
56    /// BT.601: `0.299R + 0.587G + 0.114B` (SDTV, JPEG).
57    Bt601,
58}
59
60/// Explicit options for pixel format conversion. All lossy
61/// operations require a policy choice — no silent defaults.
62///
63/// Construct via struct literal for full control, or use the convenience
64/// constructors and `with_*` builders for common patterns:
65///
66/// ```
67/// use zenpixels::{ConvertOptions, AlphaPolicy, DepthPolicy};
68///
69/// // Forbid all lossy operations (safe default)
70/// let strict = ConvertOptions::forbid_lossy();
71///
72/// // Allow common lossy operations with sensible defaults
73/// let permissive = ConvertOptions::permissive();
74///
75/// // Customize from a preset
76/// let custom = ConvertOptions::permissive()
77///     .with_alpha_policy(AlphaPolicy::CompositeOnto { r: 255, g: 255, b: 255 })
78///     .with_depth_policy(DepthPolicy::Truncate);
79/// ```
80#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
81pub struct ConvertOptions {
82    /// How to expand grayscale to RGB.
83    pub gray_expand: GrayExpand,
84    /// How to handle alpha removal.
85    pub alpha_policy: AlphaPolicy,
86    /// How to handle depth reduction.
87    pub depth_policy: DepthPolicy,
88    /// Luma coefficients for RGB→Gray conversion. `None` means
89    /// RGB→Gray is forbidden (returns `ConvertError::RgbToGray`).
90    pub luma: Option<LumaCoefficients>,
91}
92
93impl ConvertOptions {
94    /// Forbid all lossy operations.
95    ///
96    /// - Alpha removal: forbidden (returns error)
97    /// - Depth reduction: forbidden (returns error)
98    /// - RGB→Gray: forbidden (returns error)
99    /// - Gray→RGB: broadcast (lossless)
100    ///
101    /// Use this as a starting point when you want to ensure no data loss,
102    /// then selectively relax with `with_*` methods.
103    pub const fn forbid_lossy() -> Self {
104        Self {
105            gray_expand: GrayExpand::Broadcast,
106            alpha_policy: AlphaPolicy::Forbid,
107            depth_policy: DepthPolicy::Forbid,
108            luma: None,
109        }
110    }
111
112    /// Allow common lossy operations with sensible defaults.
113    ///
114    /// - Alpha removal: discard only if all pixels are opaque
115    /// - Depth reduction: round to nearest
116    /// - RGB→Gray: BT.709 luma coefficients
117    /// - Gray→RGB: broadcast (lossless)
118    pub const fn permissive() -> Self {
119        Self {
120            gray_expand: GrayExpand::Broadcast,
121            alpha_policy: AlphaPolicy::DiscardIfOpaque,
122            depth_policy: DepthPolicy::Round,
123            luma: Some(LumaCoefficients::Bt709),
124        }
125    }
126
127    /// Set the alpha removal policy.
128    pub const fn with_alpha_policy(mut self, policy: AlphaPolicy) -> Self {
129        self.alpha_policy = policy;
130        self
131    }
132
133    /// Set the depth reduction policy.
134    pub const fn with_depth_policy(mut self, policy: DepthPolicy) -> Self {
135        self.depth_policy = policy;
136        self
137    }
138
139    /// Set the grayscale expansion method.
140    pub const fn with_gray_expand(mut self, expand: GrayExpand) -> Self {
141        self.gray_expand = expand;
142        self
143    }
144
145    /// Set the luma coefficients for RGB→Gray conversion.
146    ///
147    /// Pass `None` to forbid RGB→Gray conversion.
148    pub const fn with_luma(mut self, luma: Option<LumaCoefficients>) -> Self {
149        self.luma = luma;
150        self
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use alloc::format;
158
159    #[test]
160    fn gray_expand_derive_traits() {
161        let a = GrayExpand::Broadcast;
162        let b = a;
163        #[allow(clippy::clone_on_copy)]
164        let c = a.clone();
165        assert_eq!(a, b);
166        assert_eq!(a, c);
167        let _ = format!("{a:?}");
168    }
169
170    #[test]
171    fn alpha_policy_variants() {
172        let discard = AlphaPolicy::DiscardIfOpaque;
173        let unchecked = AlphaPolicy::DiscardUnchecked;
174        let composite = AlphaPolicy::CompositeOnto {
175            r: 255,
176            g: 255,
177            b: 255,
178        };
179        let forbid = AlphaPolicy::Forbid;
180
181        assert_ne!(discard, unchecked);
182        assert_ne!(composite, forbid);
183
184        let composite2 = AlphaPolicy::CompositeOnto {
185            r: 255,
186            g: 255,
187            b: 255,
188        };
189        assert_eq!(composite, composite2);
190
191        let composite_diff = AlphaPolicy::CompositeOnto { r: 0, g: 0, b: 0 };
192        assert_ne!(composite, composite_diff);
193    }
194
195    #[test]
196    fn depth_policy_variants() {
197        assert_ne!(DepthPolicy::Round, DepthPolicy::Truncate);
198        assert_ne!(DepthPolicy::Round, DepthPolicy::Forbid);
199        let a = DepthPolicy::Truncate;
200        #[allow(clippy::clone_on_copy)]
201        let b = a.clone();
202        assert_eq!(a, b);
203    }
204
205    #[test]
206    fn luma_coefficients_variants() {
207        assert_ne!(LumaCoefficients::Bt709, LumaCoefficients::Bt601);
208        let a = LumaCoefficients::Bt709;
209        let b = a;
210        assert_eq!(a, b);
211    }
212
213    #[test]
214    fn convert_options_derive_traits() {
215        let opts = ConvertOptions {
216            gray_expand: GrayExpand::Broadcast,
217            alpha_policy: AlphaPolicy::DiscardUnchecked,
218            depth_policy: DepthPolicy::Round,
219            luma: Some(LumaCoefficients::Bt709),
220        };
221        #[allow(clippy::clone_on_copy)]
222        let opts2 = opts.clone();
223        assert_eq!(opts, opts2);
224        let _ = format!("{opts:?}");
225    }
226
227    #[test]
228    #[cfg(feature = "std")]
229    fn alpha_policy_hash() {
230        use core::hash::{Hash, Hasher};
231        let mut h1 = std::hash::DefaultHasher::new();
232        AlphaPolicy::Forbid.hash(&mut h1);
233        let mut h2 = std::hash::DefaultHasher::new();
234        AlphaPolicy::Forbid.hash(&mut h2);
235        assert_eq!(h1.finish(), h2.finish());
236    }
237
238    #[test]
239    #[cfg(feature = "std")]
240    fn convert_options_hash() {
241        use core::hash::{Hash, Hasher};
242        let opts = ConvertOptions {
243            gray_expand: GrayExpand::Broadcast,
244            alpha_policy: AlphaPolicy::Forbid,
245            depth_policy: DepthPolicy::Forbid,
246            luma: None,
247        };
248        let mut h = std::hash::DefaultHasher::new();
249        opts.hash(&mut h);
250        // Just verify it doesn't panic.
251        let _ = h.finish();
252    }
253}