Skip to main content

agg_gui/gfx_ctx/
layers.rs

1//! Offscreen compositing layer support for `GfxCtx`.
2//!
3//! Layers let widgets and SVG groups render into temporary transparent
4//! framebuffers before compositing the result back into the active target.
5
6use super::*;
7
8impl GfxCtx<'_> {
9    // -------------------------------------------------------------------------
10    // Layer compositing
11    // -------------------------------------------------------------------------
12
13    /// Begin an offscreen compositing layer of `width × height` **logical**
14    /// pixels.
15    ///
16    /// All draw calls until the matching `pop_layer` are redirected into a fresh
17    /// transparent `Framebuffer`.  The current CTM's translation records the
18    /// layer's screen-space (physical) origin; the CTM's **scale is preserved**
19    /// so the subtree rasterises at physical resolution inside the layer buffer.
20    pub fn push_layer(&mut self, width: f64, height: f64) {
21        self.push_layer_with_alpha(width, height, 1.0);
22    }
23
24    pub fn push_layer_with_alpha(&mut self, width: f64, height: f64, alpha: f64) {
25        let origin_x = self.state.transform.tx;
26        let origin_y = self.state.transform.ty;
27        // Preserve the CTM's scale across the layer boundary.  Without this the
28        // layer buffer would be sized in logical pixels and drawing would use an
29        // identity transform, so on a HiDPI display (device scale > 1) a
30        // composited subtree rendered at half size into the physical-pixel
31        // target — wrong size and position.  We size the buffer in physical
32        // pixels (logical × scale) and start the layer's local transform at that
33        // pure scale (translation reset to 0, since `origin_x/origin_y` already
34        // records where the layer lands in the parent).  This mirrors the wgpu
35        // backend's `push_layer_with_alpha_impl`.  Rotation/shear in the CTM are
36        // intentionally not carried into the layer — the compositing-layer
37        // callers (opacity groups, widget backbuffers) are always axis-aligned.
38        let (scale_x, scale_y) = self.state.transform.scaling_abs();
39        let scale_x = scale_x.max(1e-6);
40        let scale_y = scale_y.max(1e-6);
41        let phys_w = (width * scale_x).ceil().max(1.0) as u32;
42        let phys_h = (height * scale_y).ceil().max(1.0) as u32;
43        let saved_state = self.state.clone();
44        let saved_stack = std::mem::take(&mut self.state_stack);
45        let layer_fb = Framebuffer::new(phys_w, phys_h);
46        self.layer_stack.push(LayerEntry {
47            fb: layer_fb,
48            saved_state,
49            saved_stack,
50            origin_x,
51            origin_y,
52            alpha: alpha.clamp(0.0, 1.0),
53            clip_mask: None,
54            clip_saved_path: None,
55        });
56        // Reset to local-space origin, keeping the device/UX scale so content
57        // rasterises 1:1 onto the physical-pixel layer buffer.
58        self.state.transform = TransAffine::new_scaling(scale_x, scale_y);
59        self.state.clip = None;
60    }
61
62    /// Intersect the clip with the current path (canvas-2D `clip()`).
63    ///
64    /// The path is rasterised — with the current CTM and fill rule — into an
65    /// 8-bit anti-aliased coverage mask, and a **clip layer** of the path's
66    /// (clipped) device-space bounding box is pushed.  Subsequent drawing goes
67    /// into that layer in the *same* coordinate space (the CTM is only shifted
68    /// by the layer origin, rotation and scale are preserved), and the matching
69    /// `restore()` multiplies the layer by the mask before compositing it back.
70    ///
71    /// `reset_clip()` inside a clip layer only clears the rectangular scissor;
72    /// the path mask stays in force until the matching `restore()`.
73    ///
74    /// Like canvas `clip()` this does **not** clear the current path.  Because
75    /// drawing inside the clip layer may replace it, the path as of this call
76    /// is snapshotted and put back when the layer pops, so the canvas-port
77    /// idiom `begin_path(); <outline>; fill(); save(); clip_path();
78    /// <interior>; restore(); stroke();` strokes the outline.  (Canvas leaves
79    /// whatever the interior built; restoring the outline is the more useful
80    /// behaviour here and is what the wgpu backend does too.)
81    pub fn clip_path(&mut self) {
82        let ctm = self.state.transform;
83        let fill_rule = self.state.fill_rule;
84
85        // Device-space bounds of the path under the current CTM.
86        let bounds = {
87            let mut curves = ConvCurve::new(&mut self.path);
88            let mut transformed = ConvTransform::new(&mut curves, ctm);
89            bounding_rect_single(&mut transformed, 0)
90        };
91        let target_w = active_fb(self.base_fb, &mut self.layer_stack).width() as f64;
92        let target_h = active_fb(self.base_fb, &mut self.layer_stack).height() as f64;
93
94        let mut empty = true;
95        let (mut x1, mut y1, mut x2, mut y2) = (0.0, 0.0, 0.0, 0.0);
96        if let Some(r) = bounds {
97            x1 = r.x1;
98            y1 = r.y1;
99            x2 = r.x2;
100            y2 = r.y2;
101            // Intersect with the active rectangular scissor and the target.
102            if let Some((cx, cy, cw, ch)) = self.state.clip {
103                x1 = x1.max(cx);
104                y1 = y1.max(cy);
105                x2 = x2.min(cx + cw);
106                y2 = y2.min(cy + ch);
107            }
108            x1 = x1.max(0.0);
109            y1 = y1.max(0.0);
110            x2 = x2.min(target_w);
111            y2 = y2.min(target_h);
112            empty = !(x2 > x1 && y2 > y1);
113        }
114
115        let origin_x = if empty { 0.0 } else { x1.floor() };
116        let origin_y = if empty { 0.0 } else { y1.floor() };
117        let w = if empty {
118            1
119        } else {
120            (x2.ceil() - origin_x).max(1.0) as u32
121        };
122        let h = if empty {
123            1
124        } else {
125            (y2.ceil() - origin_y).max(1.0) as u32
126        };
127
128        // The layer-local CTM is the parent CTM shifted by the layer origin —
129        // rotation and scale are deliberately preserved so drawing continues in
130        // exactly the same space.
131        let mut local = ctm;
132        local.tx -= origin_x;
133        local.ty -= origin_y;
134
135        // Rasterise the mask: coverage lands in the alpha channel.
136        let mask = if empty {
137            vec![0u8; (w * h) as usize]
138        } else {
139            let mut mask_fb = Framebuffer::new(w, h);
140            let white = agg_rust::color::Rgba8::new(255, 255, 255, 255);
141            rasterize_fill(
142                &mut mask_fb,
143                &mut self.path,
144                &white,
145                CompOp::SrcOver,
146                None,
147                fill_rule,
148                &local,
149            );
150            mask_fb.pixels().chunks_exact(4).map(|p| p[3]).collect()
151        };
152
153        let saved_state = self.state.clone();
154        let saved_stack = std::mem::take(&mut self.state_stack);
155        self.layer_stack.push(LayerEntry {
156            fb: Framebuffer::new(w, h),
157            saved_state,
158            saved_stack,
159            origin_x,
160            origin_y,
161            alpha: 1.0,
162            clip_mask: Some(mask),
163            // Canvas `clip()` leaves the current path alone; drawing inside the
164            // clip layer may replace it, so the snapshot is put back on pop and
165            // `save(); clip_path(); …; restore(); stroke();` strokes the
166            // outline that was clipped with.
167            clip_saved_path: Some(self.path.clone()),
168        });
169        self.state.transform = local;
170        self.state.clip = None;
171    }
172
173    /// SrcOver-composite the current layer into the previous render target, then
174    /// restore the graphics state that was active at the matching `push_layer`.
175    pub fn pop_layer(&mut self) {
176        let Some(mut layer) = self.layer_stack.pop() else {
177            return;
178        };
179        // Clip layer: fold the path coverage into the layer's premultiplied
180        // alpha before compositing, giving anti-aliased clip edges.
181        if let Some(mask) = layer.clip_mask.take() {
182            if let Some(path) = layer.clip_saved_path.take() {
183                self.path = path;
184            }
185            let px = layer.fb.pixels_mut();
186            // The mask is rasterised at exactly the layer framebuffer's size in
187            // `clip_path`; a mismatch means the layer was resized behind our
188            // back and the clip would silently mask the wrong pixels.
189            debug_assert_eq!(mask.len(), px.len() / 4);
190            for (i, cov) in mask.iter().take(px.len() / 4).enumerate() {
191                let di = i * 4;
192                if *cov == 255 {
193                    continue;
194                }
195                let m = *cov as u32;
196                for k in 0..4 {
197                    px[di + k] = ((px[di + k] as u32 * m + 127) / 255) as u8;
198                }
199            }
200        }
201        let ox = layer.origin_x as i32;
202        let oy = layer.origin_y as i32;
203        self.state = layer.saved_state;
204        self.state_stack = layer.saved_stack;
205        // Clip the composite to the parent scissor restored above — a layer
206        // sized larger than its clipped content must not overpaint siblings
207        // (e.g. a window's opacity group spilling over the title bar).
208        let parent_clip = self.state.clip;
209        // Composite: src = layer.fb, dst = now-active framebuffer.
210        if let Some(top) = self.layer_stack.last_mut() {
211            composite_framebuffers(&mut top.fb, &layer.fb, ox, oy, layer.alpha, parent_clip);
212        } else {
213            composite_framebuffers(self.base_fb, &layer.fb, ox, oy, layer.alpha, parent_clip);
214        }
215    }
216}