Skip to main content

agg_gui/
lcd_coverage.rs

1//! LCD subpixel text as a **per-channel coverage mask** that composites
2//! onto arbitrary backgrounds — no bg pre-fill, no destination-color
3//! knowledge required at rasterization time.
4//!
5//! # Why this replaces the pre-fill approach
6//!
7//! The older `PixfmtRgba32Lcd` path baked the caller's background colour
8//! into the rasterised output via a per-channel src-over against the
9//! pre-filled framebuffer.  That coupled the LCD glyphs to one specific
10//! destination and forced us to know that destination everywhere text is
11//! drawn — driving the walk / sample / push / pop complexity.
12//!
13//! Instead, we keep the **three subpixel coverage values independent**:
14//! the output of the rasteriser is three 8-bit channels per pixel
15//! `(cov_r, cov_g, cov_b)` describing how much of each subpixel the glyph
16//! covered.  At composite time a per-channel Porter-Duff `over` blend
17//! mixes the TEXT COLOUR into the live destination:
18//!
19//! ```text
20//! dst.r = src.r * cov.r + dst.r * (1 - cov.r)
21//! dst.g = src.g * cov.g + dst.g * (1 - cov.g)
22//! dst.b = src.b * cov.b + dst.b * (1 - cov.b)
23//! ```
24//!
25//! The coverage mask is the same regardless of where it lands; the blend
26//! naturally produces the correct LCD chroma against any background.
27//!
28//! See `lcd-subpixel-compositing.md` at the repository root for the full
29//! derivation.
30//!
31//! # Pipeline
32//!
33//! ```text
34//! shape_text (rustybuzz kerning + fallback chain — unchanged)
35//!   │
36//! per-glyph PathStorage → ConvTransform(scale_x_3) → PixfmtGray8
37//!   (8-bit grayscale coverage at 3× horizontal resolution)
38//!   │
39//! 5-tap low-pass filter per output channel
40//!   │
41//! packed (cov_r, cov_g, cov_b) 3-byte mask
42//! ```
43
44use agg_rust::path_storage::PathStorage;
45use agg_rust::trans_affine::TransAffine;
46
47use crate::color::Color;
48use crate::draw_ctx::FillRule;
49
50// ---------------------------------------------------------------------------
51// LcdBuffer — opaque 3-byte-per-pixel RGB render target
52// ---------------------------------------------------------------------------
53//
54// Analogue of `Framebuffer` for widgets that opt into
55// [`crate::widget::BackbufferMode::LcdCoverage`].  Every fill into an
56// `LcdBuffer` goes through the 3× horizontal supersample + 5-tap filter
57// pipeline and composites per-channel via Porter-Duff src-over.  The
58// buffer has no alpha channel — it's intended to be fully covered by
59// opaque fills and blitted as an opaque RGB texture.
60
61/// LCD coverage buffer, row 0 = bottom (matches `Framebuffer` convention).
62///
63/// **Two planes, 3 bytes per pixel each:**
64///
65/// - `color`: per-channel **premultiplied** RGB colour accumulated from
66///   every paint so far.  `(R_color, G_color, B_color)` where each byte
67///   is `channel_color * channel_alpha`.
68/// - `alpha`: per-channel alpha/coverage accumulated from every paint so
69///   far.  `(R_alpha, G_alpha, B_alpha)` where each byte is the combined
70///   opacity of that subpixel column (0 = untouched, 255 = fully opaque).
71///
72/// **Why per-channel alpha?**  LCD subpixel rendering produces a distinct
73/// coverage value per R/G/B channel, so a single per-pixel alpha can't
74/// represent the output correctly at glyph edges and fractional image
75/// boundaries.  Splitting alpha per-channel gives each subpixel its own
76/// Porter-Duff state: paints accumulate independently through the same
77/// premultiplied src-over math you'd use for a normal RGBA surface, just
78/// three streams instead of one.  A cached `LcdBuffer` with partial
79/// coverage can be composited onto any destination without the "black
80/// rect where unpainted" failure mode that killed the first-cut design.
81pub struct LcdBuffer {
82    color: Vec<u8>,
83    alpha: Vec<u8>,
84    width: u32,
85    height: u32,
86}
87
88impl LcdBuffer {
89    /// Allocate a fully-transparent buffer (color zero, alpha zero
90    /// everywhere).  "Transparent" here means the per-channel alpha is
91    /// 0, so composite-onto-destination leaves the destination
92    /// unchanged wherever no paint has landed yet.
93    pub fn new(width: u32, height: u32) -> Self {
94        // Safety net: refuse to honour an obviously-pathological size
95        // rather than let the allocator try for gigabytes.  Returning a
96        // 1×1 buffer means the caller's text doesn't render this
97        // frame, but the app keeps running and the offending widget's
98        // bounds get clamped naturally on the next layout pass.  A
99        // debug build prints the caller info; release silently clamps.
100        const MAX_BYTES: usize = 512 * 1024 * 1024; // 512 MB per plane
101        let bytes = (width as usize)
102            .saturating_mul(height as usize)
103            .saturating_mul(3);
104        if bytes > MAX_BYTES {
105            #[cfg(debug_assertions)]
106            eprintln!(
107                "[LcdBuffer] clamped pathological size ({}, {}); \
108                 widget bounds likely skipped a size cap",
109                width, height,
110            );
111            return Self {
112                color: vec![0u8; 3],
113                alpha: vec![0u8; 3],
114                width: 1,
115                height: 1,
116            };
117        }
118        Self {
119            color: vec![0u8; bytes],
120            alpha: vec![0u8; bytes],
121            width,
122            height,
123        }
124    }
125
126    #[inline]
127    pub fn width(&self) -> u32 {
128        self.width
129    }
130    #[inline]
131    pub fn height(&self) -> u32 {
132        self.height
133    }
134
135    #[inline]
136    pub fn color_plane(&self) -> &[u8] {
137        &self.color
138    }
139    #[inline]
140    pub fn alpha_plane(&self) -> &[u8] {
141        &self.alpha
142    }
143    #[inline]
144    pub fn color_plane_mut(&mut self) -> &mut [u8] {
145        &mut self.color
146    }
147    #[inline]
148    pub fn alpha_plane_mut(&mut self) -> &mut [u8] {
149        &mut self.alpha
150    }
151
152    /// Both planes mutably in one borrow — for inner loops that update
153    /// a pixel's colour and alpha together (image blit, manual composite).
154    #[inline]
155    pub fn planes_mut(&mut self) -> (&mut [u8], &mut [u8]) {
156        (&mut self.color, &mut self.alpha)
157    }
158
159    /// Consume the buffer, returning the owned `(color, alpha)` planes
160    /// as a pair — used when moving the painted pixels into `Arc`s for
161    /// a widget's backbuffer cache or for GPU texture upload.
162    pub fn into_planes(self) -> (Vec<u8>, Vec<u8>) {
163        (self.color, self.alpha)
164    }
165
166    /// Top-row-first copy of the colour plane, suitable for a plain
167    /// RGB8 upload or CPU blit.  Row 0 of the output is the VISUAL
168    /// top of the buffer (Y-up → Y-down flip).
169    pub fn color_plane_flipped(&self) -> Vec<u8> {
170        flip_plane(&self.color, self.width, self.height)
171    }
172
173    /// Top-row-first copy of the alpha plane.
174    pub fn alpha_plane_flipped(&self) -> Vec<u8> {
175        flip_plane(&self.alpha, self.width, self.height)
176    }
177
178    /// Collapse both planes into a single top-row-first straight-alpha
179    /// RGBA8 image suitable for the existing blit pipeline (one texture,
180    /// standard `SRC_ALPHA, ONE_MINUS_SRC_ALPHA` blend).
181    ///
182    /// The per-channel alphas get collapsed to a single per-pixel alpha
183    /// via `max(R_alpha, G_alpha, B_alpha)`; RGB is recovered by dividing
184    /// the premult colour by that max alpha (straight-alpha form).  This
185    /// conversion is **lossy** when the three subpixel alphas diverge
186    /// (the whole point of the per-channel representation is lost under
187    /// collapse).  It's correct for typical monochrome-text cases where
188    /// all three alphas agree, and degrades gracefully otherwise —
189    /// Phase 5.2's two-plane blit path preserves the full per-channel
190    /// information through upload and shader.
191    pub fn to_rgba8_top_down_collapsed(&self) -> Vec<u8> {
192        let w = self.width as usize;
193        let h = self.height as usize;
194        let mut out = vec![0u8; w * h * 4];
195        for y in 0..h {
196            let src_y = h - 1 - y;
197            for x in 0..w {
198                let si = (src_y * w + x) * 3;
199                let di = (y * w + x) * 4;
200                let ra = self.alpha[si];
201                let ga = self.alpha[si + 1];
202                let ba = self.alpha[si + 2];
203                let a = ra.max(ga).max(ba);
204                if a == 0 {
205                    continue;
206                } // fully transparent → keep RGBA zero
207                let af = a as f32 / 255.0;
208                let rc = self.color[si] as f32 / 255.0;
209                let gc = self.color[si + 1] as f32 / 255.0;
210                let bc = self.color[si + 2] as f32 / 255.0;
211                out[di] = ((rc / af) * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
212                out[di + 1] = ((gc / af) * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
213                out[di + 2] = ((bc / af) * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
214                out[di + 3] = a;
215            }
216        }
217        out
218    }
219
220    // ── Paint primitives ────────────────────────────────────────────────────
221    //
222    // These are the foundation operations every higher layer (LcdGfxCtx,
223    // path-fill helpers, image blit) eventually composes into.  They write
224    // directly into the 3-byte-per-pixel coverage store with no intermediate
225    // allocation.
226
227    /// Fill the entire buffer with a solid colour.  Every subpixel gets
228    /// the same premultiplied colour contribution and the same alpha —
229    /// a flat clear has no per-subpixel differentiation, so the three
230    /// alpha channels are all set to `color.a` and the three colour
231    /// channels to `color.rgb * color.a`.
232    pub fn clear(&mut self, color: Color) {
233        let a = color.a.clamp(0.0, 1.0);
234        let r_c = ((color.r.clamp(0.0, 1.0) * a) * 255.0 + 0.5) as u8;
235        let g_c = ((color.g.clamp(0.0, 1.0) * a) * 255.0 + 0.5) as u8;
236        let b_c = ((color.b.clamp(0.0, 1.0) * a) * 255.0 + 0.5) as u8;
237        let a_byte = (a * 255.0 + 0.5) as u8;
238        for px in self.color.chunks_exact_mut(3) {
239            px[0] = r_c;
240            px[1] = g_c;
241            px[2] = b_c;
242        }
243        for px in self.alpha.chunks_exact_mut(3) {
244            px[0] = a_byte;
245            px[1] = a_byte;
246            px[2] = a_byte;
247        }
248    }
249
250    /// Fill an AGG path through the LCD pipeline: rasterize at 3× X
251    /// resolution → 5-tap filter → per-channel src-over composite into
252    /// this buffer.  `transform` is applied to `path` before the 3× X
253    /// scale (typically the caller's CTM); the path's coordinates are
254    /// in the buffer's pixel space (Y-up, origin = bottom-left).
255    /// Optional `clip` is a screen-space rect (post-CTM, in mask pixel
256    /// coords) — pixels outside it are unaffected.
257    ///
258    /// First non-text primitive on the buffer.  Future fill / stroke /
259    /// image-blit entry points either call this directly (for solid
260    /// fills / outlines) or open their own `LcdMaskBuilder` scope when
261    /// they need to batch many paths into one mask.
262    ///
263    /// First-cut implementation: rasterizes at the buffer's full size.
264    /// A later optimization can compute the path's bbox and size the
265    /// scratch tightly — measurable win for small paths in large
266    /// buffers, but architecturally identical and not required for
267    /// correctness.
268    pub fn fill_path(
269        &mut self,
270        path: &mut PathStorage,
271        color: Color,
272        transform: &TransAffine,
273        clip: Option<(f64, f64, f64, f64)>,
274        fill_rule: FillRule,
275    ) {
276        if self.width == 0 || self.height == 0 {
277            return;
278        }
279        let mut builder = LcdMaskBuilder::new(self.width, self.height)
280            .with_clip(clip)
281            .with_fill_rule(fill_rule);
282        builder.with_paths(transform, |add| {
283            add(path);
284        });
285        let mask = builder.finalize();
286        // Convert clip → integer pixel rect for composite-time enforcement.
287        // The gray-buffer raster clip should already have zeroed coverage
288        // outside, but the 5-tap filter can leak ±2 subpixels at clip
289        // edges; composite-time clip catches that.
290        let clip_i = clip.map(rect_to_pixel_clip);
291        self.composite_mask(&mask, color, 0, 0, clip_i);
292    }
293
294    /// Composite an [`LcdMask`] into this buffer using per-channel
295    /// **premultiplied** Porter-Duff src-over.  Each subpixel column's
296    /// effective alpha is `src.a × mask.channel_coverage`, and colour +
297    /// alpha both accumulate under the standard premult src-over:
298    ///
299    /// ```text
300    /// eff_a_c        = src.a * mask.c
301    /// buf.color_c   := src.c * eff_a_c + buf.color_c * (1 - eff_a_c)
302    /// buf.alpha_c   := eff_a_c         + buf.alpha_c * (1 - eff_a_c)
303    /// ```
304    ///
305    /// `(dst_x, dst_y)` is the mask's bottom-left in this buffer's Y-up
306    /// pixel grid; mask row `my` writes to buffer row `dst_y + my`.
307    /// Optional `clip` (in this buffer's integer pixel coords:
308    /// `(x1, y1, x2, y2)`, half-open) suppresses writes outside its
309    /// bounds — used by widgets that paint inside a clipping parent.
310    pub fn composite_mask(
311        &mut self,
312        mask: &LcdMask,
313        src: Color,
314        dst_x: i32,
315        dst_y: i32,
316        clip: Option<(i32, i32, i32, i32)>,
317    ) {
318        if mask.width == 0 || mask.height == 0 {
319            return;
320        }
321        let sa = src.a.clamp(0.0, 1.0);
322        let sr = src.r.clamp(0.0, 1.0);
323        let sg = src.g.clamp(0.0, 1.0);
324        let sb = src.b.clamp(0.0, 1.0);
325        let dst_w_i = self.width as i32;
326        let dst_h_i = self.height as i32;
327        let dst_w_u = self.width as usize;
328        let mw = mask.width as i32;
329        let mh = mask.height as i32;
330        let (cx1, cy1, cx2, cy2) = match clip {
331            Some((cx1, cy1, cx2, cy2)) => {
332                (cx1.max(0), cy1.max(0), cx2.min(dst_w_i), cy2.min(dst_h_i))
333            }
334            None => (0, 0, dst_w_i, dst_h_i),
335        };
336        if cx1 >= cx2 || cy1 >= cy2 {
337            return;
338        }
339
340        for my in 0..mh {
341            let dy = dst_y + my;
342            if dy < cy1 || dy >= cy2 {
343                continue;
344            }
345            let dy_u = dy as usize;
346            for mx in 0..mw {
347                let dx = dst_x + mx;
348                if dx < cx1 || dx >= cx2 {
349                    continue;
350                }
351                let mi = ((my * mw + mx) * 3) as usize;
352                // Per-channel effective alpha = src colour alpha × mask coverage.
353                let ea_r = sa * (mask.data[mi] as f32 / 255.0);
354                let ea_g = sa * (mask.data[mi + 1] as f32 / 255.0);
355                let ea_b = sa * (mask.data[mi + 2] as f32 / 255.0);
356                if ea_r == 0.0 && ea_g == 0.0 && ea_b == 0.0 {
357                    continue;
358                }
359
360                let di = (dy_u * dst_w_u + (dx as usize)) * 3;
361                // Read existing premult colour + per-channel alpha.
362                let bc_r = self.color[di] as f32 / 255.0;
363                let bc_g = self.color[di + 1] as f32 / 255.0;
364                let bc_b = self.color[di + 2] as f32 / 255.0;
365                let ba_r = self.alpha[di] as f32 / 255.0;
366                let ba_g = self.alpha[di + 1] as f32 / 255.0;
367                let ba_b = self.alpha[di + 2] as f32 / 255.0;
368                // Premult src-over per channel.  `src.c × eff_a` is the
369                // premultiplied source colour contribution; it adds to
370                // the buffer's existing premult colour, weighted by
371                // (1 - eff_a).  Alpha stream does the same Porter-Duff
372                // composite independently per channel.
373                let rc_r = sr * ea_r + bc_r * (1.0 - ea_r);
374                let rc_g = sg * ea_g + bc_g * (1.0 - ea_g);
375                let rc_b = sb * ea_b + bc_b * (1.0 - ea_b);
376                let ra_r = ea_r + ba_r * (1.0 - ea_r);
377                let ra_g = ea_g + ba_g * (1.0 - ea_g);
378                let ra_b = ea_b + ba_b * (1.0 - ea_b);
379
380                self.color[di] = (rc_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
381                self.color[di + 1] = (rc_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
382                self.color[di + 2] = (rc_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
383                self.alpha[di] = (ra_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
384                self.alpha[di + 1] = (ra_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
385                self.alpha[di + 2] = (ra_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
386            }
387        }
388    }
389
390    /// Composite an [`LcdMask`] using a per-pixel source colour callback.
391    ///
392    /// The callback receives destination pixel coordinates in this buffer's
393    /// Y-up pixel space.  This keeps the LCD coverage pipeline shared for
394    /// solid and gradient fills while allowing colour to vary across the mask.
395    pub fn composite_mask_with_color<F>(
396        &mut self,
397        mask: &LcdMask,
398        dst_x: i32,
399        dst_y: i32,
400        clip: Option<(i32, i32, i32, i32)>,
401        mut color_at: F,
402    ) where
403        F: FnMut(i32, i32) -> Color,
404    {
405        if mask.width == 0 || mask.height == 0 {
406            return;
407        }
408        let dst_w_i = self.width as i32;
409        let dst_h_i = self.height as i32;
410        let dst_w_u = self.width as usize;
411        let mw = mask.width as i32;
412        let mh = mask.height as i32;
413        let (cx1, cy1, cx2, cy2) = match clip {
414            Some((cx1, cy1, cx2, cy2)) => {
415                (cx1.max(0), cy1.max(0), cx2.min(dst_w_i), cy2.min(dst_h_i))
416            }
417            None => (0, 0, dst_w_i, dst_h_i),
418        };
419        if cx1 >= cx2 || cy1 >= cy2 {
420            return;
421        }
422
423        for my in 0..mh {
424            let dy = dst_y + my;
425            if dy < cy1 || dy >= cy2 {
426                continue;
427            }
428            let dy_u = dy as usize;
429            for mx in 0..mw {
430                let dx = dst_x + mx;
431                if dx < cx1 || dx >= cx2 {
432                    continue;
433                }
434                let mi = ((my * mw + mx) * 3) as usize;
435                let src = color_at(dx, dy);
436                let sa = src.a.clamp(0.0, 1.0);
437                let sr = src.r.clamp(0.0, 1.0);
438                let sg = src.g.clamp(0.0, 1.0);
439                let sb = src.b.clamp(0.0, 1.0);
440                let ea_r = sa * (mask.data[mi] as f32 / 255.0);
441                let ea_g = sa * (mask.data[mi + 1] as f32 / 255.0);
442                let ea_b = sa * (mask.data[mi + 2] as f32 / 255.0);
443                if ea_r == 0.0 && ea_g == 0.0 && ea_b == 0.0 {
444                    continue;
445                }
446
447                let di = (dy_u * dst_w_u + (dx as usize)) * 3;
448                let bc_r = self.color[di] as f32 / 255.0;
449                let bc_g = self.color[di + 1] as f32 / 255.0;
450                let bc_b = self.color[di + 2] as f32 / 255.0;
451                let ba_r = self.alpha[di] as f32 / 255.0;
452                let ba_g = self.alpha[di + 1] as f32 / 255.0;
453                let ba_b = self.alpha[di + 2] as f32 / 255.0;
454
455                let rc_r = sr * ea_r + bc_r * (1.0 - ea_r);
456                let rc_g = sg * ea_g + bc_g * (1.0 - ea_g);
457                let rc_b = sb * ea_b + bc_b * (1.0 - ea_b);
458                let ra_r = ea_r + ba_r * (1.0 - ea_r);
459                let ra_g = ea_g + ba_g * (1.0 - ea_g);
460                let ra_b = ea_b + ba_b * (1.0 - ea_b);
461
462                self.color[di] = (rc_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
463                self.color[di + 1] = (rc_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
464                self.color[di + 2] = (rc_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
465                self.alpha[di] = (ra_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
466                self.alpha[di + 1] = (ra_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
467                self.alpha[di + 2] = (ra_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
468            }
469        }
470    }
471
472    /// Composite `src` onto this buffer at offset `(dst_x, dst_y)` via
473    /// **per-channel premultiplied src-over** — the buffer-level
474    /// analogue of [`Self::composite_mask`].  Each of the three
475    /// subpixel columns applies `src.ch_alpha` as its own
476    /// Porter-Duff weight:
477    ///
478    /// ```text
479    /// buf.color_c := src.color_c + buf.color_c * (1 - src.alpha_c)
480    /// buf.alpha_c := src.alpha_c + buf.alpha_c * (1 - src.alpha_c)
481    /// ```
482    ///
483    /// Untouched source pixels (alpha zero on every channel) don't
484    /// change the buffer at all — exactly the semantic that makes a
485    /// popped layer leave unpainted areas alone, no seed trick needed.
486    pub fn composite_buffer(
487        &mut self,
488        src: &LcdBuffer,
489        dst_x: i32,
490        dst_y: i32,
491        clip: Option<(i32, i32, i32, i32)>,
492    ) {
493        if src.width == 0 || src.height == 0 {
494            return;
495        }
496        let dst_w_i = self.width as i32;
497        let dst_h_i = self.height as i32;
498        let dst_w_u = self.width as usize;
499        let src_w_u = src.width as usize;
500        let sw = src.width as i32;
501        let sh = src.height as i32;
502        let (cx1, cy1, cx2, cy2) = match clip {
503            Some((x1, y1, x2, y2)) => (x1.max(0), y1.max(0), x2.min(dst_w_i), y2.min(dst_h_i)),
504            None => (0, 0, dst_w_i, dst_h_i),
505        };
506        if cx1 >= cx2 || cy1 >= cy2 {
507            return;
508        }
509
510        for sy in 0..sh {
511            let dy = dst_y + sy;
512            if dy < cy1 || dy >= cy2 {
513                continue;
514            }
515            let dy_u = dy as usize;
516            let sy_u = sy as usize;
517            for sx in 0..sw {
518                let dx = dst_x + sx;
519                if dx < cx1 || dx >= cx2 {
520                    continue;
521                }
522                let si = (sy_u * src_w_u + sx as usize) * 3;
523                let di = (dy_u * dst_w_u + dx as usize) * 3;
524
525                let sa_r = src.alpha[si] as f32 / 255.0;
526                let sa_g = src.alpha[si + 1] as f32 / 255.0;
527                let sa_b = src.alpha[si + 2] as f32 / 255.0;
528                if sa_r == 0.0 && sa_g == 0.0 && sa_b == 0.0 {
529                    continue;
530                }
531
532                let sc_r = src.color[si] as f32 / 255.0;
533                let sc_g = src.color[si + 1] as f32 / 255.0;
534                let sc_b = src.color[si + 2] as f32 / 255.0;
535
536                let bc_r = self.color[di] as f32 / 255.0;
537                let bc_g = self.color[di + 1] as f32 / 255.0;
538                let bc_b = self.color[di + 2] as f32 / 255.0;
539                let ba_r = self.alpha[di] as f32 / 255.0;
540                let ba_g = self.alpha[di + 1] as f32 / 255.0;
541                let ba_b = self.alpha[di + 2] as f32 / 255.0;
542
543                // src is already premultiplied, so `sc + bc*(1-sa)` is the
544                // plain Porter-Duff expression — no additional modulation.
545                let rc_r = sc_r + bc_r * (1.0 - sa_r);
546                let rc_g = sc_g + bc_g * (1.0 - sa_g);
547                let rc_b = sc_b + bc_b * (1.0 - sa_b);
548                let ra_r = sa_r + ba_r * (1.0 - sa_r);
549                let ra_g = sa_g + ba_g * (1.0 - sa_g);
550                let ra_b = sa_b + ba_b * (1.0 - sa_b);
551
552                self.color[di] = (rc_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
553                self.color[di + 1] = (rc_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
554                self.color[di + 2] = (rc_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
555                self.alpha[di] = (ra_r * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
556                self.alpha[di + 1] = (ra_g * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
557                self.alpha[di + 2] = (ra_b * 255.0 + 0.5).clamp(0.0, 255.0) as u8;
558            }
559        }
560    }
561}
562
563// ── helpers ───────────────────────────────────────────────────────────────
564
565/// Y-flip a 3-byte/pixel plane (Y-up row 0 = bottom → top-row-first).
566fn flip_plane(src: &[u8], width: u32, height: u32) -> Vec<u8> {
567    let row_bytes = (width * 3) as usize;
568    let mut out = vec![0u8; src.len()];
569    for y in 0..height as usize {
570        let dst_y = height as usize - 1 - y;
571        out[dst_y * row_bytes..(dst_y + 1) * row_bytes]
572            .copy_from_slice(&src[y * row_bytes..(y + 1) * row_bytes]);
573    }
574    out
575}
576
577mod mask;
578#[cfg(test)]
579mod tests;
580
581pub use mask::{
582    composite_lcd_mask, identity_xform, rasterize_lcd_mask, rasterize_lcd_mask_multi,
583    rasterize_text_lcd_cached, rect_to_pixel_clip, CachedLcdText, LcdMask, LcdMaskBuilder,
584};