Skip to main content

rasterrocket_render/pipe/
mod.rs

1//! Compositing pipeline — replaces `SplashPipe` and its `pipeRun*` family.
2//!
3//! The C++ design uses a struct with a function-pointer field (`pipe->run`) selected at
4//! `pipeInit` time.  Here each paint operation calls a span-level function that is
5//! monomorphized over `P: Pixel` at compile time, eliminating per-pixel vtable overhead.
6//!
7//! # Three pipeline variants
8//!
9//! | Variant | When selected | C++ equivalent |
10//! |---------|--------------|----------------|
11//! | `simple` | `a_input == 255`, no soft mask, `BlendMode::Normal` | `pipeRunSimple*` |
12//! | `aa`     | shape byte present, no soft mask, `BlendMode::Normal`, no group correction | `pipeRunAA*` |
13//! | `general`| everything else — soft mask, blend mode, non-isolated/knockout groups | `pipeRun` |
14
15pub mod aa;
16pub mod blend;
17pub mod general;
18pub mod simple;
19
20use crate::state::TransferSet;
21use crate::types::BlendMode;
22use color::Pixel;
23
24/// Source colour for a single paint operation.
25///
26/// The source is resolved once per span and may be a flat colour (most common) or
27/// a dynamic [`Pattern`] queried per-pixel.
28pub enum PipeSrc<'a> {
29    /// Flat colour: `ncomps` bytes, already in device space.
30    Solid(&'a [u8]),
31    /// Dynamic pattern source — `fill_span` is called per output row.
32    Pattern(&'a dyn Pattern),
33}
34
35/// A source of per-pixel colour for use with the compositing pipeline.
36///
37/// Implementors return raw device-space byte sequences into the provided buffer.
38/// The buffer length is `(x1 - x0 + 1) * ncomps` where `ncomps` is the pixel size
39/// for the target bitmap mode.
40pub trait Pattern: Send + Sync {
41    /// Fill `out` with colour bytes for pixels `x0..=x1` on scanline `y`.
42    ///
43    /// `out.len()` is guaranteed to be `(x1 - x0 + 1) * ncomps`.
44    fn fill_span(&self, y: i32, x0: i32, x1: i32, out: &mut [u8]);
45
46    /// Return `true` if this pattern yields the same colour at every coordinate.
47    /// When `true`, `fill_span` will be called once and the result reused across
48    /// the whole span (optimisation hint only — correctness is not affected).
49    fn is_static_color(&self) -> bool {
50        false
51    }
52}
53
54/// Immutable parameters for one paint operation, built once per fill/stroke/glyph call.
55///
56/// `'bmp` borrows slices out of the destination bitmap's alpha plane and the
57/// current graphics state's transfer tables.
58#[derive(Copy, Clone, Debug)]
59pub struct PipeState<'bmp> {
60    /// Compositing blend mode.
61    pub blend_mode: BlendMode,
62
63    /// Source opacity, pre-scaled: `state.fill_alpha * 255.0` rounded to `u8`.
64    /// For stroke operations the caller passes `stroke_alpha * 255.0`.
65    pub a_input: u8,
66
67    /// Overprint mask: bit `k` set means channel `k` is painted.
68    /// `0xFFFF_FFFF` means all channels are painted (the default).
69    pub overprint_mask: u32,
70
71    /// If `true`, overprinted channels are additively blended (ink accumulation)
72    /// rather than replaced.  Corresponds to `state.overprint_additive`.
73    pub overprint_additive: bool,
74
75    /// Transfer function tables for the current pixel mode, borrowed from
76    /// `GraphicsState`.  Applied to the composited result before writing.
77    pub transfer: TransferSet<'bmp>,
78
79    /// Soft mask alpha plane for the current transparency group, if any.
80    /// One byte per bitmap pixel; shares the bitmap's row stride.
81    /// When `Some`, `a_input` is multiplied by the soft mask value per pixel.
82    pub soft_mask: Option<&'bmp [u8]>,
83
84    /// Group alpha0 plane (non-isolated transparency group).
85    /// One byte per bitmap pixel; `None` for isolated groups and normal painting.
86    pub alpha0: Option<&'bmp [u8]>,
87
88    /// `true` if this is a knockout group: later objects replace earlier objects
89    /// within the group rather than compositing on top of them.
90    pub knockout: bool,
91
92    /// For knockout compositing: the accumulated group opacity threshold.
93    pub knockout_opacity: u8,
94
95    /// `true` if we are inside a non-isolated group.
96    pub non_isolated_group: bool,
97}
98
99impl PipeState<'_> {
100    /// Returns `true` when the simple (no-transparency) fast path is applicable.
101    ///
102    /// Matches C++ `pipe->noTransparency`:
103    /// `a_input == 255 && no soft_mask && no shape && !in_non_isolated_group && !in_knockout_group`
104    ///
105    /// Overprint must be excluded: the simple path overwrites `dst_pixels` before
106    /// reading the original destination, making channel-selective restore impossible.
107    #[must_use]
108    pub const fn no_transparency(&self, uses_shape: bool) -> bool {
109        self.a_input == 255
110            && self.soft_mask.is_none()
111            && !uses_shape
112            && !self.non_isolated_group
113            && !self.knockout
114            && self.alpha0.is_none()
115            && self.overprint_mask == 0xFFFF_FFFF
116    }
117
118    /// Returns `true` when the AA (shape-only) fast path is applicable.
119    ///
120    /// Matches C++ `pipeRunAA*` selection condition:
121    /// no pattern, not noTransparency, no `soft_mask`, usesShape,
122    /// no alpha0, `BlendMode::Normal`, not `non_isolated_group`.
123    #[must_use]
124    pub fn use_aa_path(&self) -> bool {
125        self.soft_mask.is_none()
126            && self.alpha0.is_none()
127            && self.blend_mode == BlendMode::Normal
128            && !self.non_isolated_group
129    }
130}
131
132/// Select and run the appropriate pipeline variant for a horizontal span.
133///
134/// - `pipe`: compositing parameters for this paint operation.
135/// - `src`: source colour (solid or pattern).
136/// - `dst_pixels`: raw pixel bytes for the destination row, starting at `x0`.
137/// - `dst_alpha`: alpha plane bytes for the destination row, starting at `x0`.
138///   `None` means the destination has no separate alpha (Mono1, or solid-color bitmaps
139///   without transparency).
140/// - `soft_mask_row`: per-pixel soft-mask byte for this row, starting at `x0`.
141/// - `alpha0_row`: per-pixel alpha0 byte for this row, starting at `x0`.
142/// - `shape`: per-pixel AA shape byte, or `None` for the simple path.
143/// - `screen`: optional halftone screen for Mono1 dithering.
144/// - `x0`, `x1`: inclusive pixel coordinate range within the row.
145/// - `y`: scanline y coordinate (used by pattern and halftone screen).
146/// - `ncomps`: bytes per pixel (must match `P::BYTES`).
147///
148/// # Panics
149///
150/// Panics in debug mode if `x0 > x1` or `P::BYTES == 0` (Mono1 must be handled
151/// by the caller).  Also panics if `dst_pixels.len()` does not equal
152/// `(x1 - x0 + 1) * P::BYTES`.
153#[expect(
154    clippy::too_many_arguments,
155    reason = "render_span mirrors the C++ SplashPipe API; all arguments are necessary"
156)]
157pub fn render_span<P: Pixel>(
158    pipe: &PipeState<'_>,
159    src: &PipeSrc<'_>,
160    dst_pixels: &mut [u8],
161    dst_alpha: Option<&mut [u8]>,
162    shape: Option<&[u8]>,
163    x0: i32,
164    x1: i32,
165    y: i32,
166) {
167    debug_assert!(x0 <= x1, "render_span: x0={x0} > x1={x1}");
168    debug_assert!(P::BYTES > 0, "render_span: Mono1 must be handled by caller");
169
170    #[expect(
171        clippy::cast_sign_loss,
172        reason = "x1 >= x0 is a precondition, so x1 - x0 + 1 >= 1 > 0"
173    )]
174    let count = (x1 - x0 + 1) as usize;
175    debug_assert_eq!(
176        dst_pixels.len(),
177        count * P::BYTES,
178        "render_span: dst_pixels length mismatch"
179    );
180
181    let uses_shape = shape.is_some();
182
183    if pipe.no_transparency(uses_shape) && pipe.blend_mode == BlendMode::Normal {
184        simple::render_span_simple::<P>(pipe, src, dst_pixels, dst_alpha, x0, x1, y);
185    } else if uses_shape && pipe.use_aa_path() {
186        aa::render_span_aa::<P>(
187            pipe,
188            src,
189            dst_pixels,
190            dst_alpha,
191            shape.expect("use_aa_path requires shape"),
192            x0,
193            x1,
194            y,
195        );
196    } else {
197        general::render_span_general::<P>(pipe, src, dst_pixels, dst_alpha, shape, x0, x1, y);
198    }
199}
200
201// ── Shared transfer helpers ───────────────────────────────────────────────────
202//
203// Identical transfer logic was duplicated across `aa`, `simple`, and `general`.
204// These `pub(crate)` functions are the single canonical implementations.
205
206/// Apply the per-channel transfer LUTs to `src` and write the result into `dst`.
207///
208/// Both slices must have the same length and match the pixel mode:
209/// 1 byte → gray, 3 → RGB, 4 → CMYK/XBGR, 8 → `DeviceN`.
210#[expect(
211    clippy::inline_always,
212    reason = "called per-pixel in the innermost compositing loop; 10-15 instructions, must inline across crate boundary"
213)]
214#[inline(always)]
215pub(crate) fn apply_transfer_pixel(pipe: &PipeState<'_>, src: &[u8], dst: &mut [u8]) {
216    debug_assert_eq!(
217        src.len(),
218        dst.len(),
219        "apply_transfer_pixel: length mismatch"
220    );
221    let t = &pipe.transfer;
222    match src.len() {
223        1 => dst[0] = t.gray[src[0] as usize],
224        3 => {
225            dst[0] = t.rgb[0][src[0] as usize];
226            dst[1] = t.rgb[1][src[1] as usize];
227            dst[2] = t.rgb[2][src[2] as usize];
228        }
229        4 => {
230            dst[0] = t.cmyk[0][src[0] as usize];
231            dst[1] = t.cmyk[1][src[1] as usize];
232            dst[2] = t.cmyk[2][src[2] as usize];
233            dst[3] = t.cmyk[3][src[3] as usize];
234        }
235        8 => {
236            for (i, (&s, d)) in src.iter().zip(dst.iter_mut()).enumerate() {
237                *d = t.device_n[i][s as usize];
238            }
239        }
240        n => {
241            debug_assert!(false, "apply_transfer_pixel: unexpected ncomps={n}");
242            dst.copy_from_slice(src);
243        }
244    }
245}
246
247/// Apply transfer LUTs in-place to a single pixel slice.
248#[expect(
249    clippy::inline_always,
250    reason = "called per-pixel in the innermost compositing loop; delegates to apply_transfer_pixel, must inline"
251)]
252#[inline(always)]
253pub(crate) fn apply_transfer_in_place(pipe: &PipeState<'_>, px: &mut [u8]) {
254    // Avoid allocating: for ncomps ≤ 8 copy into a stack buffer, apply, copy back.
255    let n = px.len();
256    debug_assert!(n <= 8, "apply_transfer_in_place: ncomps={n} > 8");
257    let mut tmp = [0u8; 8];
258    tmp[..n].copy_from_slice(px);
259    apply_transfer_pixel(pipe, &tmp[..n], px);
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::testutil::make_pipe;
266    use crate::types::BlendMode;
267    use color::Rgb8;
268
269    #[test]
270    fn no_transparency_opaque_normal() {
271        let pipe = make_pipe(255, BlendMode::Normal);
272        assert!(pipe.no_transparency(false));
273        assert!(!pipe.no_transparency(true)); // shape disables it
274    }
275
276    #[test]
277    fn no_transparency_alpha_less_than_255() {
278        let pipe = make_pipe(200, BlendMode::Normal);
279        assert!(!pipe.no_transparency(false));
280    }
281
282    #[test]
283    fn use_aa_path_requires_no_soft_mask() {
284        let pipe = make_pipe(200, BlendMode::Normal);
285        assert!(pipe.use_aa_path());
286    }
287
288    #[test]
289    fn use_aa_path_false_for_blend_mode() {
290        let pipe = make_pipe(200, BlendMode::Multiply);
291        assert!(!pipe.use_aa_path());
292    }
293
294    #[test]
295    fn render_span_simple_solid_opaque() {
296        // Simple path: a_input=255, Normal, no shape.
297        let pipe = make_pipe(255, BlendMode::Normal);
298        let src_color = [200u8, 100, 50];
299        let src = PipeSrc::Solid(&src_color);
300
301        let mut dst = vec![0u8; 3 * 4]; // 4 pixels
302        let mut alpha = vec![0u8; 4];
303        render_span::<Rgb8>(&pipe, &src, &mut dst, Some(&mut alpha), None, 0, 3, 0);
304
305        // All pixels should be set to src color.
306        for i in 0..4 {
307            assert_eq!(dst[i * 3], 200, "pixel {i} R");
308            assert_eq!(dst[i * 3 + 1], 100, "pixel {i} G");
309            assert_eq!(dst[i * 3 + 2], 50, "pixel {i} B");
310            assert_eq!(alpha[i], 255, "pixel {i} alpha");
311        }
312    }
313
314    #[test]
315    fn render_span_aa_blends_with_dst() {
316        // AA path: shape=128 (~50% coverage), src=255 white, dst=0 black (fully opaque).
317        let pipe = make_pipe(255, BlendMode::Normal);
318        let src_color = [255u8, 255, 255];
319        let src = PipeSrc::Solid(&src_color);
320        let shape = vec![128u8; 4];
321
322        let mut dst = vec![0u8; 3 * 4];
323        let mut alpha = vec![255u8; 4]; // fully opaque dst so blending is visible
324        render_span::<Rgb8>(
325            &pipe,
326            &src,
327            &mut dst,
328            Some(&mut alpha),
329            Some(&shape),
330            0,
331            3,
332            0,
333        );
334
335        // a_src = div255(255*128) ≈ 128; a_dst=255; blends white over black at ~50%.
336        for i in 0..4 {
337            let v = dst[i * 3];
338            assert!(v > 100 && v < 160, "pixel {i} R={v} expected ~128");
339        }
340    }
341
342    #[test]
343    fn no_transparency_false_with_overprint() {
344        // Overprint must not use the simple path (dst channels already overwritten).
345        let mut pipe = make_pipe(255, BlendMode::Normal);
346        pipe.overprint_mask = 0x0000_0001; // only channel 0 painted
347        assert!(
348            !pipe.no_transparency(false),
349            "overprint must route to general pipe"
350        );
351    }
352}