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}