From 5e9a1b6ec7a95cd8e04d64849420d4c3d73a86d3 Mon Sep 17 00:00:00 2001 From: wstock <1394687+wstock@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:24:41 +0000 Subject: [PATCH] Visualize: the all-masks overlay shows the union of the masks, not the last one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Overlay::AllMasks` painted each mask's plane in turn, and every mask view rewrites the whole image from its alpha (White on Black writes `255·a`, Image on Black multiplies by `a`…), so each plane wiped the ones before it and only the last visible mask showed. The planes are now merged (per-pixel max) and painted once; the GPU path goes through the same `apply`. The test added with #919 could not pass: besides the above, its hidden-mask check expected the plain pixel under White on Black, where everything outside the masks is black by definition (as the single-mask test asserts). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NH3itd85jPuM6wNjgcGfEH --- crates/pipeline/src/visualize.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/pipeline/src/visualize.rs b/crates/pipeline/src/visualize.rs index 15c164bb1..8f3cf1884 100644 --- a/crates/pipeline/src/visualize.rs +++ b/crates/pipeline/src/visualize.rs @@ -199,13 +199,18 @@ pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>, } } Overlay::AllMasks { view, color, opacity } => { - if let Some(planes) = masks { - for a in planes { - if (a.width, a.height) == (img.width, img.height) { - mask_view(img, a, view, color, opacity); - } + // one view of the union of the masks: painting the planes one after another + // overwrites (White on Black, Image on Black…) or compounds (Color) the earlier ones + let mut union: Option = None; + for a in masks.unwrap_or_default().iter().filter(|a| (a.width, a.height) == (img.width, img.height)) { + match &mut union { + None => union = Some(a.clone()), + Some(u) => u.data.iter_mut().zip(&a.data).for_each(|(u, a)| *u = u.max(*a)), } } + if let Some(u) = &union { + mask_view(img, u, view, color, opacity); + } } } } @@ -425,7 +430,6 @@ mod tests { components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: right }], ..Default::default() }); - let plain = render(&src, &info, &s, &RenderRequest::fit(80, 40)).image; let o = Overlay::AllMasks { view: MaskView::WhiteOnBlack, color: [255, 0, 0], opacity: 100 }; let img = render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image; let (in_l, in_r, mid) = (20 * 80 + 12, 20 * 80 + 60, 20 * 80 + 40); @@ -435,7 +439,7 @@ mod tests { // a hidden mask is skipped s.masks[1].visible = false; let img2 = render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image; - assert_eq!(img2.data[in_r], plain.data[in_r], "hidden right mask not painted"); + assert_eq!(img2.data[in_r], [0, 0, 0, 255], "hidden right mask not painted: black like everything outside"); assert_eq!(img2.data[in_l], [255; 4], "visible left mask still painted"); // parts round trip and a stable, distinct cache key let (k, v) = o.to_parts();