Skip to main content

teksilo_render/
renderer.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use wgpu;
5
6use teksilo_canvas::RenderFrame;
7use teksilo_canvas::geometry::Transform2D;
8
9use crate::blur::{BlurPipelines, BlurPool};
10use crate::image_manager::ImageManager;
11use crate::path_atlas::PathAtlas;
12use crate::stream_buffer::StreamBuffers;
13use crate::vertex::{AnimQuadVertex, QuadVertex, RectVertex, SdfVertex, ShadowVertex};
14
15/// How many animated-quad slots the uniform buffer holds. Must match
16/// the array size in `shaders/anim_procedural.wgsl`. Bumping this
17/// requires updating the WGSL constant too (WGSL array sizes are
18/// static). 128 × 64 B = 8 KiB — well within UBO caps.
19const MAX_ANIM_SLOTS: usize = 128;
20
21/// GPU renderer that draws a RenderFrame using six shader pipelines.
22pub struct Renderer {
23    device: wgpu::Device,
24    queue: wgpu::Queue,
25    rect_pipeline: wgpu::RenderPipeline,
26    sdf_pipeline: wgpu::RenderPipeline,
27    quad_pipeline: wgpu::RenderPipeline,
28    shadow_pipeline: wgpu::RenderPipeline,
29    /// Gradient-filled path pipeline (Tier 3) — draws `PathEntry`s whose
30    /// `paint_data` is a gradient variant. Solid-filled paths keep using
31    /// the lean `quad_pipeline` above; see `path_gradient_quad_verts` /
32    /// `PathGradientVertex`. Shares its group(0) bind-group layout
33    /// (texture + sampler) with `quad_pipeline`, so it binds the same
34    /// `path_atlas_texture` bind group the solid path-quad batch uses.
35    path_gradient_pipeline: wgpu::RenderPipeline,
36    /// Procedural animated-quad pipeline — IndeterminateSweep and
37    /// future Pulse / Shimmer kinds. Binds group 0 to a uniform buffer
38    /// holding an array of `AnimParams` (one per slot).
39    anim_proc_pipeline: wgpu::RenderPipeline,
40    /// Sprite-atlas animated-quad pipeline — frame-cycling for
41    /// `AnimatedQuadKind::SpriteCycle`. Shares the same uniform buffer
42    /// as the procedural pipeline at group 0; group 1 carries the
43    /// per-atlas texture bind group. Reuses the quad_pipeline's
44    /// bind-group layout for group 1, so the bind groups that
45    /// `ImageManager` builds for static images are also usable here
46    /// without a second registration.
47    anim_sprite_pipeline: wgpu::RenderPipeline,
48    /// Uniform buffer backing both animated-quad pipelines' per-slot
49    /// state. Rewritten wholesale at the top of each `render()` from
50    /// `frame.anim_params`. Fixed size (`MAX_ANIM_SLOTS * 64 B`); the
51    /// tree's registry truncates if it ever exceeds.
52    anim_uniform_buffer: wgpu::Buffer,
53    /// Bind group for the animated pipelines (group 0 on both).
54    anim_uniform_bind_group: wgpu::BindGroup,
55    atlas_texture: Option<AtlasTexture>,
56    path_atlas: PathAtlas,
57    path_atlas_texture: Option<AtlasTexture>,
58    image_manager: ImageManager,
59    /// Persistent per-pipeline streaming buffers. Resized on demand at
60    /// the top of each `render()` call, then reused via `write_buffer`
61    /// for every batch flush in that frame — replaces the historical
62    /// per-flush `create_buffer_init` antipattern.
63    streams: StreamBuffers,
64    /// Dual-Kawase blur pipelines (downsample + upsample) and per-pass
65    /// uniform buffer. Built once at construction; consumed by the
66    /// `BeginBlurredSubtree` / `EndBlurredSubtree` handler in `render`.
67    blur_pipelines: BlurPipelines,
68    /// Recycled intermediate-texture pool for blur scopes. Begin-of-
69    /// frame resets per-texture in-use flags; textures unused for
70    /// several frames evict.
71    blur_pool: BlurPool,
72    /// Cached bind group layout for the quad pipeline's group(0)
73    /// (texture + sampler). Used to build per-frame bind groups that
74    /// expose blur-pool intermediates as image sources for the
75    /// compositing blit at the end of each blur scope.
76    quad_bind_group_layout: wgpu::BindGroupLayout,
77    /// Sampler used by the blur composite blit. Linear filtering so
78    /// the over-allocated bucket texture's used sub-rect samples
79    /// cleanly when composited onto a non-aligned target rect.
80    blur_composite_sampler: wgpu::Sampler,
81}
82
83struct AtlasTexture {
84    texture: wgpu::Texture,
85    bind_group: wgpu::BindGroup,
86    width: u32,
87    height: u32,
88}
89
90/// Active render target — the bottom of the stack is always the
91/// surface; intermediates push above it for the duration of a blur
92/// scope. Each entry tracks both the target's identity and per-target
93/// state that survives across multiple segment passes against the
94/// same target (e.g. when an inner blur scope ends and we re-open
95/// the parent intermediate to draw additional commands).
96struct ActiveTarget {
97    /// `None` ⇒ surface (the caller-provided texture view).
98    /// `Some(handle)` ⇒ a blur intermediate from `BlurPool`.
99    intermediate: Option<crate::blur::AcquiredTexture>,
100    /// Viewport dimensions for NDC conversion in this scope.
101    viewport_w: u32,
102    viewport_h: u32,
103    /// `false` until the first segment pass against this target runs;
104    /// controls whether the next pass uses Clear or Load.
105    opened: bool,
106    /// Blurred sub-tree results that nested scopes have queued for
107    /// compositing into THIS target on its next segment open. Drained
108    /// at the top of each segment.
109    pending_composites: Vec<PendingComposite>,
110    /// Intermediate-only metadata, populated when `intermediate.is_some()`.
111    /// Carried here (rather than in a separate `BlurScope` stack)
112    /// because End needs to look these up after popping the target.
113    blur_bounds: Option<teksilo_canvas::Rect>,
114    blur_radius_logical: Option<f32>,
115    used_w: Option<u32>,
116    used_h: Option<u32>,
117    bucket_w: Option<u32>,
118    bucket_h: Option<u32>,
119}
120
121impl ActiveTarget {
122    fn surface(viewport_w: u32, viewport_h: u32) -> Self {
123        Self {
124            intermediate: None,
125            viewport_w,
126            viewport_h,
127            opened: false,
128            pending_composites: Vec::new(),
129            blur_bounds: None,
130            blur_radius_logical: None,
131            used_w: None,
132            used_h: None,
133            bucket_w: None,
134            bucket_h: None,
135        }
136    }
137}
138
139/// One blurred sub-tree result waiting to be composited into a parent
140/// target's next render pass. Lives on `ActiveTarget::pending_composites`
141/// for the parent target.
142struct PendingComposite {
143    blurred_texture: crate::blur::AcquiredTexture,
144    used_w: u32,
145    used_h: u32,
146    bucket_w: u32,
147    bucket_h: u32,
148    bounds: teksilo_canvas::Rect,
149}
150
151impl Renderer {
152    /// Create a new renderer from an existing wgpu device and queue.
153    pub fn new(
154        device: wgpu::Device,
155        queue: wgpu::Queue,
156        surface_format: wgpu::TextureFormat,
157    ) -> Self {
158        let rect_pipeline = create_rect_pipeline(&device, surface_format);
159        let sdf_pipeline = create_sdf_pipeline(&device, surface_format);
160        let quad_pipeline = create_quad_pipeline(&device, surface_format);
161        // Must come after quad_pipeline — reuses its group(0) bind-group
162        // layout (texture + sampler) so the path atlas's bind group
163        // binds unchanged for both the solid and gradient path batches.
164        let path_gradient_pipeline = create_path_gradient_pipeline(
165            &device,
166            surface_format,
167            &quad_pipeline.get_bind_group_layout(0),
168        );
169        let shadow_pipeline = create_shadow_pipeline(&device, surface_format);
170        let (anim_proc_pipeline, anim_uniform_buffer, anim_uniform_bind_group, anim_uniform_layout) =
171            create_anim_proc_pipeline(&device, surface_format);
172        // Reuse the quad pipeline's texture/sampler layout so bind
173        // groups registered by `ImageManager` for static images work
174        // equally well as the sprite animation's atlas binding.
175        let quad_texture_layout = quad_pipeline.get_bind_group_layout(0);
176        let anim_sprite_pipeline = create_anim_sprite_pipeline(
177            &device,
178            surface_format,
179            &anim_uniform_layout,
180            &quad_texture_layout,
181        );
182
183        let quad_bind_group_layout = quad_pipeline.get_bind_group_layout(0);
184        let blur_pool = BlurPool::new(&device, surface_format);
185        let blur_pipelines =
186            BlurPipelines::new(&device, &blur_pool.bind_group_layout, surface_format);
187        let blur_composite_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
188            label: Some("blur_composite_sampler"),
189            address_mode_u: wgpu::AddressMode::ClampToEdge,
190            address_mode_v: wgpu::AddressMode::ClampToEdge,
191            address_mode_w: wgpu::AddressMode::ClampToEdge,
192            mag_filter: wgpu::FilterMode::Linear,
193            min_filter: wgpu::FilterMode::Linear,
194            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
195            ..Default::default()
196        });
197
198        Self {
199            device,
200            queue,
201            rect_pipeline,
202            sdf_pipeline,
203            quad_pipeline,
204            path_gradient_pipeline,
205            shadow_pipeline,
206            anim_proc_pipeline,
207            anim_sprite_pipeline,
208            anim_uniform_buffer,
209            anim_uniform_bind_group,
210            atlas_texture: None,
211            path_atlas: PathAtlas::new(512, 512),
212            path_atlas_texture: None,
213            image_manager: ImageManager::new(),
214            streams: StreamBuffers::new(),
215            blur_pipelines,
216            blur_pool,
217            quad_bind_group_layout,
218            blur_composite_sampler,
219        }
220    }
221
222    /// Upload atlas texture data from the text backend.
223    pub fn upload_atlas(&mut self, width: u32, height: u32, pixels: &[u8]) {
224        if width == 0 || height == 0 {
225            return;
226        }
227
228        let needs_recreate = self
229            .atlas_texture
230            .as_ref()
231            .is_none_or(|t| t.width != width || t.height != height);
232
233        if needs_recreate {
234            let texture = self.device.create_texture(&wgpu::TextureDescriptor {
235                label: Some("glyph_atlas"),
236                size: wgpu::Extent3d {
237                    width,
238                    height,
239                    depth_or_array_layers: 1,
240                },
241                mip_level_count: 1,
242                sample_count: 1,
243                dimension: wgpu::TextureDimension::D2,
244                format: wgpu::TextureFormat::Rgba8UnormSrgb,
245                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
246                view_formats: &[],
247            });
248
249            let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
250            // Linear, not Nearest: glyph quads can be drawn under a scale
251            // transform (SceneView zoom, Scale wrapper), where nearest
252            // magnification turns texels into hard squares. Glyph origins
253            // are fractional (shaping advances, scroll), so linear is NOT
254            // automatically a no-op at identity — quads that map 1:1 onto
255            // their atlas bitmap are pixel-snapped at vertex emission
256            // (`QuadVertex::from_glyph_quad_transformed`), which makes
257            // linear sampling exact there; only residually scaled quads
258            // (mid-bucket zoom) actually filter. Safe for tinted text —
259            // the monochrome shader path ignores sampled RGB — and the
260            // 1px atlas gutter bounds bilinear bleed.
261            let sampler = self.device.create_sampler(&wgpu::SamplerDescriptor {
262                mag_filter: wgpu::FilterMode::Linear,
263                min_filter: wgpu::FilterMode::Linear,
264                ..Default::default()
265            });
266
267            let bind_group_layout = self.quad_pipeline.get_bind_group_layout(0);
268            let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
269                label: Some("atlas_bind_group"),
270                layout: &bind_group_layout,
271                entries: &[
272                    wgpu::BindGroupEntry {
273                        binding: 0,
274                        resource: wgpu::BindingResource::TextureView(&view),
275                    },
276                    wgpu::BindGroupEntry {
277                        binding: 1,
278                        resource: wgpu::BindingResource::Sampler(&sampler),
279                    },
280                ],
281            });
282
283            self.atlas_texture = Some(AtlasTexture {
284                texture,
285                bind_group,
286                width,
287                height,
288            });
289        }
290
291        if let Some(atlas) = &self.atlas_texture {
292            self.queue.write_texture(
293                wgpu::TexelCopyTextureInfo {
294                    texture: &atlas.texture,
295                    mip_level: 0,
296                    origin: wgpu::Origin3d::ZERO,
297                    aspect: wgpu::TextureAspect::All,
298                },
299                pixels,
300                wgpu::TexelCopyBufferLayout {
301                    offset: 0,
302                    bytes_per_row: Some(width * 4),
303                    rows_per_image: Some(height),
304                },
305                wgpu::Extent3d {
306                    width,
307                    height,
308                    depth_or_array_layers: 1,
309                },
310            );
311        }
312    }
313
314    /// Render a frame to the given surface texture view.
315    pub fn render(
316        &mut self,
317        frame: &RenderFrame,
318        view: &wgpu::TextureView,
319        scale_factor: f32,
320        viewport_width: u32,
321        viewport_height: u32,
322        clear_color: [f32; 4],
323    ) {
324        // Begin frame for path atlas LRU tracking
325        self.path_atlas.begin_frame();
326        // Reset blur intermediate-texture pool — marks every texture
327        // available, evicts ones unused for too long.
328        self.blur_pool.begin_frame();
329
330        // Process pending images: upload textures for newly embedded resources
331        for pending in &frame.pending_images {
332            if !self.image_manager.contains(&pending.name) {
333                let layout = self.quad_pipeline.get_bind_group_layout(0);
334                self.image_manager.register_image(
335                    &pending.name,
336                    pending.width,
337                    pending.height,
338                    &pending.pixels,
339                    &self.device,
340                    &self.queue,
341                    &layout,
342                );
343            }
344        }
345
346        // Pre-rasterize all paths in this frame into the path atlas. Cosmetic
347        // (device-space) strokes must rasterize the body at the view zoom
348        // active *where the path is drawn* so the border holds a constant
349        // device-pixel width (see PathAtlas::lookup_or_rasterize). Zoom is only
350        // known by replaying the transform commands, so we walk `draw_order`
351        // with the same SetTransform / PushTransform / PopTransform bookkeeping
352        // the main render loop uses and rasterize each path at its effective
353        // zoom. `path_placements` is indexed by path index (one Path command per
354        // entry). Logical strokes ignore the zoom; a path inside a blurred
355        // subtree may get a slightly off zoom estimate (acceptably rare —
356        // positioning is unaffected, only raster sharpness).
357        let mut path_placements: Vec<Option<crate::path_atlas::PathPlacement>> =
358            vec![None; frame.paths.len()];
359        {
360            let mut ptf_stack: Vec<Transform2D> = vec![Transform2D::IDENTITY];
361            let mut ptf_current = Transform2D::IDENTITY;
362            let device_t = |t: &Transform2D| Transform2D {
363                m: [
364                    t.m[0],
365                    t.m[1],
366                    t.m[2],
367                    t.m[3],
368                    t.m[4] * scale_factor,
369                    t.m[5] * scale_factor,
370                ],
371            };
372            for cmd in &frame.draw_order {
373                match cmd {
374                    teksilo_canvas::DrawCommand::SetTransform(t) => {
375                        let stack_top = ptf_stack.last().copied().unwrap_or(Transform2D::IDENTITY);
376                        ptf_current = device_t(t).then(&stack_top);
377                    }
378                    teksilo_canvas::DrawCommand::PushTransform(t) => {
379                        let prev_top = ptf_stack.last().copied().unwrap_or(Transform2D::IDENTITY);
380                        let new_top = device_t(t).then(&prev_top);
381                        ptf_stack.push(new_top);
382                        ptf_current = new_top;
383                    }
384                    teksilo_canvas::DrawCommand::PopTransform => {
385                        if ptf_stack.len() > 1 {
386                            ptf_stack.pop();
387                        }
388                        ptf_current = ptf_stack.last().copied().unwrap_or(Transform2D::IDENTITY);
389                    }
390                    teksilo_canvas::DrawCommand::Path(idx) => {
391                        if let Some(entry) = frame.paths.get(*idx) {
392                            // Uniform scale of the linear part = view zoom
393                            // (no scale_factor — it lives only in the
394                            // translation column, see SetTransform handling).
395                            let zoom = ptf_current.m[0].hypot(ptf_current.m[1]);
396                            // Snap the quad to whole device pixels only when
397                            // nothing else is going to move it. Under the
398                            // identity transform (every dock, menu, button
399                            // and icon in a normal window — `PushTransform`
400                            // is not even emitted for an identity) the mask
401                            // can sample 1:1 and stay sharp; under a scale
402                            // or a translate animation it cannot, and
403                            // rounding would only make the path step between
404                            // pixels. See `PathAtlas::lookup_or_rasterize`.
405                            let snap = ptf_current == Transform2D::IDENTITY;
406                            path_placements[*idx] = self.path_atlas.lookup_or_rasterize(
407                                &entry.path,
408                                &entry.stroke_style,
409                                entry.fill_rule,
410                                entry.bounds,
411                                scale_factor,
412                                zoom,
413                                snap,
414                            );
415                        }
416                    }
417                    _ => {}
418                }
419            }
420        }
421
422        // Upload path atlas to GPU if dirty
423        if self.path_atlas.is_dirty() {
424            let (pw, ph) = self.path_atlas.size();
425            self.upload_path_atlas(pw, ph, self.path_atlas.pixels().to_vec());
426            self.path_atlas.mark_clean();
427        }
428
429        // Grow persistent streaming buffers to fit this frame's worst case.
430        let counts = stream_quad_counts(frame);
431        let StreamQuadCounts {
432            rect: rect_quads,
433            sdf: sdf_quads,
434            quad: quad_quads,
435            shadow: shadow_quads,
436            anim_proc: anim_proc_quads,
437            path_gradient: path_gradient_quads,
438        } = counts;
439        let max_quads = counts.max();
440
441        self.streams.rect.ensure_capacity(
442            &self.device,
443            (rect_quads * 4 * std::mem::size_of::<RectVertex>()) as u64,
444        );
445        self.streams.sdf.ensure_capacity(
446            &self.device,
447            (sdf_quads * 4 * std::mem::size_of::<SdfVertex>()) as u64,
448        );
449        self.streams.quad.ensure_capacity(
450            &self.device,
451            (quad_quads * 4 * std::mem::size_of::<QuadVertex>()) as u64,
452        );
453        self.streams.shadow.ensure_capacity(
454            &self.device,
455            (shadow_quads * 4 * std::mem::size_of::<ShadowVertex>()) as u64,
456        );
457        self.streams.anim_proc.ensure_capacity(
458            &self.device,
459            (anim_proc_quads * 4 * std::mem::size_of::<AnimQuadVertex>()) as u64,
460        );
461        self.streams.path_gradient.ensure_capacity(
462            &self.device,
463            (path_gradient_quads * 4 * std::mem::size_of::<crate::vertex::PathGradientVertex>())
464                as u64,
465        );
466        self.streams.index.ensure_capacity(
467            &self.device,
468            (max_quads * 6 * std::mem::size_of::<u32>()) as u64,
469        );
470        self.streams.reset();
471
472        // Upload animated-quad per-slot state for this frame. Truncate
473        // past MAX_ANIM_SLOTS — the registry currently caps at
474        // 128 slots and growing the buffer would require recreating
475        // the bind group, so we just drop excess slots and warn in
476        // debug builds. In practice, 128 is well beyond typical UIs.
477        if !frame.anim_params.is_empty() {
478            let n = frame.anim_params.len().min(MAX_ANIM_SLOTS);
479            debug_assert!(
480                frame.anim_params.len() <= MAX_ANIM_SLOTS,
481                "AnimParams exceeds MAX_ANIM_SLOTS ({}); tail will be dropped",
482                MAX_ANIM_SLOTS
483            );
484            let bytes: &[u8] = bytemuck::cast_slice(&frame.anim_params[..n]);
485            self.queue.write_buffer(&self.anim_uniform_buffer, 0, bytes);
486        }
487
488        // Upload the full quad index pattern once — 6 u32s per quad, shared
489        // across every quad-based pipeline this frame. u32 indices avoid the
490        // u16 vertex-index ceiling (16 384 quads) for large batches.
491        let index_data: Vec<u32> = crate::vertex::generate_quad_indices(max_quads);
492        let index_binding = self
493            .streams
494            .index
495            .write(&self.queue, bytemuck::cast_slice(&index_data));
496
497        let mut encoder = self
498            .device
499            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
500                label: Some("teksilo_render"),
501            });
502
503        // Per-frame mutable viewport — overridden inside blur scopes
504        // (the offscreen intermediate is sized differently from the
505        // surface). Restored on `EndBlurredSubtree`.
506        let mut viewport_width = viewport_width;
507        let mut viewport_height = viewport_height;
508
509        {
510            let surface_clear_color = wgpu::Color {
511                r: clear_color[0] as f64,
512                g: clear_color[1] as f64,
513                b: clear_color[2] as f64,
514                a: clear_color[3] as f64,
515            };
516
517            // Target stack — bottom is the surface (never popped),
518            // intermediates pushed on `BeginBlurredSubtree` and popped
519            // on `EndBlurredSubtree`. The active target is always
520            // `target_stack.last_mut()`. Each target carries:
521            //   - opened: false until the first segment runs against
522            //     it (controls Clear vs Load on the next open)
523            //   - viewport dimensions for NDC conversion in this scope
524            //   - pending_composites: blurred quads that nested scopes
525            //     have queued for compositing into THIS target on its
526            //     next segment
527            let mut target_stack: Vec<ActiveTarget> =
528                vec![ActiveTarget::surface(viewport_width, viewport_height)];
529
530            // Clip rect stack for nested scroll areas.
531            // Each SetClip pushes a rect; the effective clip is the intersection.
532            // ClearClip pops the top and restores the previous intersection.
533            let mut clip_stack: Vec<[u32; 4]> = Vec::new(); // [x, y, w, h]
534
535            // Opacity stack for nested opacity groups
536            let mut opacity_stack: Vec<f32> = vec![1.0];
537            let mut current_opacity: f32 = 1.0;
538
539            // Blend mode stack
540            let mut blend_stack: Vec<teksilo_canvas::BlendMode> = Vec::new();
541            let mut current_blend = teksilo_canvas::BlendMode::Normal;
542            let _ = current_blend; // used to track state for future pipeline switching
543
544            // Transform stack — applied CPU-side to pixel positions before NDC conversion.
545            // The stack tracks subtree-level transforms pushed by the render walker
546            // (`PushTransform` / `PopTransform`); `current_transform` is always the
547            // top of the stack composed with whatever the most recent `SetTransform`
548            // command set within the current scope.
549            let mut transform_stack: Vec<Transform2D> = vec![Transform2D::IDENTITY];
550            let mut current_transform = Transform2D::IDENTITY;
551
552            // --- Batched rendering ---
553            // Accumulate vertices per pipeline, flush on state/pipeline changes.
554            // This produces one GPU buffer + one draw call per contiguous batch
555            // instead of two buffers per quad.
556            let mut rect_batch: Vec<RectVertex> = Vec::new();
557            let mut sdf_batch: Vec<SdfVertex> = Vec::new();
558            let mut quad_batch: Vec<QuadVertex> = Vec::new();
559            let mut shadow_batch: Vec<ShadowVertex> = Vec::new();
560            let mut anim_proc_batch: Vec<AnimQuadVertex> = Vec::new();
561            let mut path_gradient_batch: Vec<crate::vertex::PathGradientVertex> = Vec::new();
562
563            // Which pipeline the current quad batch uses (glyph atlas, path atlas, or image).
564            // Flushed when the bind group source changes.
565            #[derive(Clone, Copy, PartialEq, Eq)]
566            enum QuadSource {
567                GlyphAtlas,
568                PathAtlas,
569            }
570            let mut quad_source: Option<QuadSource> = None;
571
572            // Flush helpers — each writes one batch into the persistent
573            // stream buffer and issues one draw call. The index buffer was
574            // written once at the top of `render()` and is shared.
575            //
576            // `$index_binding` is `Option<(&Buffer, u64 offset, u64 len)>`
577            // — `None` only if the frame had zero quads, in which case
578            // every batch is also empty and the flush is a no-op anyway.
579            macro_rules! flush_stream {
580                ($pass:expr, $queue:expr, $stream:expr, $pipeline:expr,
581                 $batch:expr, $index_binding:expr) => {
582                    if !$batch.is_empty() {
583                        let bytes: &[u8] = bytemuck::cast_slice(&$batch);
584                        if let (Some((vb, v_off, v_len)), Some((ib, _, _))) =
585                            ($stream.write($queue, bytes), $index_binding)
586                        {
587                            let quads = ($batch.len() / 4) as u32;
588                            let index_count = quads * 6;
589                            let index_bytes = (index_count as u64) * 4;
590                            $pass.set_pipeline($pipeline);
591                            $pass.set_vertex_buffer(0, vb.slice(v_off..v_off + v_len));
592                            $pass.set_index_buffer(
593                                ib.slice(0..index_bytes),
594                                wgpu::IndexFormat::Uint32,
595                            );
596                            $pass.draw_indexed(0..index_count, 0, 0..1);
597                        }
598                        $batch.clear();
599                    }
600                };
601            }
602
603            // Flush all pending batches (called on state changes).
604            macro_rules! flush_all {
605                ($pass:expr, $queue:expr, $streams:expr,
606                 $rp:expr, $sp:expr, $qp:expr, $pgp:expr, $shp:expr,
607                 $rb:expr, $sb:expr, $qb:expr, $pgb:expr, $shb:expr,
608                 $atlas:expr, $path_atlas:expr, $qs:expr, $index_binding:expr) => {
609                    flush_stream!($pass, $queue, &$streams.rect, $rp, $rb, $index_binding);
610                    flush_stream!($pass, $queue, &$streams.sdf, $sp, $sb, $index_binding);
611                    // Quad batch needs bind group
612                    if !$qb.is_empty() {
613                        let bg = match $qs {
614                            Some(QuadSource::PathAtlas) => {
615                                $path_atlas.as_ref().map(|a: &AtlasTexture| &a.bind_group)
616                            }
617                            _ => $atlas.as_ref().map(|a: &AtlasTexture| &a.bind_group),
618                        };
619                        if let (Some(bind_group), Some((ib, _, _))) = (bg, $index_binding) {
620                            let bytes: &[u8] = bytemuck::cast_slice(&$qb);
621                            if let Some((vb, v_off, v_len)) = $streams.quad.write($queue, bytes) {
622                                let quads = ($qb.len() / 4) as u32;
623                                let index_count = quads * 6;
624                                let index_bytes = (index_count as u64) * 4;
625                                $pass.set_pipeline($qp);
626                                $pass.set_bind_group(0, bind_group, &[]);
627                                $pass.set_vertex_buffer(0, vb.slice(v_off..v_off + v_len));
628                                $pass.set_index_buffer(
629                                    ib.slice(0..index_bytes),
630                                    wgpu::IndexFormat::Uint32,
631                                );
632                                $pass.draw_indexed(0..index_count, 0, 0..1);
633                            }
634                        }
635                        $qb.clear();
636                    }
637                    // Gradient-filled path batch. Binds the SAME path
638                    // atlas texture bind group the solid path-quad batch
639                    // above uses (`$path_atlas`) — the gradient pipeline
640                    // reuses `quad_pipeline`'s group(0) layout, so the
641                    // bind group is interchangeable.
642                    if !$pgb.is_empty() {
643                        if let (Some(bind_group), Some((ib, _, _))) = (
644                            $path_atlas.as_ref().map(|a: &AtlasTexture| &a.bind_group),
645                            $index_binding,
646                        ) {
647                            let bytes: &[u8] = bytemuck::cast_slice(&$pgb);
648                            if let Some((vb, v_off, v_len)) =
649                                $streams.path_gradient.write($queue, bytes)
650                            {
651                                let quads = ($pgb.len() / 4) as u32;
652                                let index_count = quads * 6;
653                                let index_bytes = (index_count as u64) * 4;
654                                $pass.set_pipeline($pgp);
655                                $pass.set_bind_group(0, bind_group, &[]);
656                                $pass.set_vertex_buffer(0, vb.slice(v_off..v_off + v_len));
657                                $pass.set_index_buffer(
658                                    ib.slice(0..index_bytes),
659                                    wgpu::IndexFormat::Uint32,
660                                );
661                                $pass.draw_indexed(0..index_count, 0, 0..1);
662                            }
663                        }
664                        $pgb.clear();
665                    }
666                    flush_stream!($pass, $queue, &$streams.shadow, $shp, $shb, $index_binding);
667                    // Animated-quad procedural batch. Unlike the shared
668                    // atlas quad pipeline above, this always binds the
669                    // same uniform bind group (per-slot state read by
670                    // shader) so there's no source-switching. Accesses
671                    // `self.anim_proc_pipeline` / `.anim_uniform_bind_group`
672                    // and the local `anim_proc_batch` via macro hygiene —
673                    // all three are in scope inside `render()` at every
674                    // flush_all! call site.
675                    if !anim_proc_batch.is_empty()
676                        && let Some((ib, _, _)) = $index_binding
677                    {
678                        let bytes: &[u8] = bytemuck::cast_slice(&anim_proc_batch);
679                        if let Some((vb, v_off, v_len)) = $streams.anim_proc.write($queue, bytes) {
680                            let quads = (anim_proc_batch.len() / 4) as u32;
681                            let index_count = quads * 6;
682                            let index_bytes = (index_count as u64) * 4;
683                            $pass.set_pipeline(&self.anim_proc_pipeline);
684                            $pass.set_bind_group(0, &self.anim_uniform_bind_group, &[]);
685                            $pass.set_vertex_buffer(0, vb.slice(v_off..v_off + v_len));
686                            $pass.set_index_buffer(
687                                ib.slice(0..index_bytes),
688                                wgpu::IndexFormat::Uint32,
689                            );
690                            $pass.draw_indexed(0..index_count, 0, 0..1);
691                        }
692                        anim_proc_batch.clear();
693                    }
694                };
695            }
696
697            // Draw in painter's order. Outer loop iterates render
698            // segments — one segment per `RenderPass`. A blur Begin/End
699            // boundary opens a new segment. The pass lives in its own
700            // scope so the encoder borrow is released at each boundary
701            // (allowing the next pass open or any in-between Kawase
702            // work on the encoder).
703            let mut cmd_idx = 0;
704            while cmd_idx <= frame.draw_order.len() {
705                // Resolve current target. We `match` the intermediate
706                // handle vs. surface here; the resulting `target_view`
707                // lifetime ties to one of self.blur_pool / `view` arg.
708                let (target_view, load_op): (&wgpu::TextureView, wgpu::LoadOp<wgpu::Color>) = {
709                    let t = target_stack
710                        .last_mut()
711                        .expect("surface target always present");
712                    let v: &wgpu::TextureView = match t.intermediate {
713                        Some(h) => self.blur_pool.view(h),
714                        None => view,
715                    };
716                    let lo = if t.opened {
717                        wgpu::LoadOp::Load
718                    } else if t.intermediate.is_none() {
719                        wgpu::LoadOp::Clear(surface_clear_color)
720                    } else {
721                        wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT)
722                    };
723                    t.opened = true;
724                    viewport_width = t.viewport_w;
725                    viewport_height = t.viewport_h;
726                    (v, lo)
727                };
728
729                // Drain pending composites — these are blurred sub-tree
730                // results from nested blur scopes that finished while
731                // we weren't drawing into THIS target. They paint first
732                // in the new segment so subsequent commands stack on
733                // top of the blurred quad.
734                let composites_to_draw: Vec<PendingComposite> = std::mem::take(
735                    &mut target_stack
736                        .last_mut()
737                        .expect("target_stack always has the surface target")
738                        .pending_composites,
739                );
740
741                {
742                    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
743                        label: Some("teksilo_segment_pass"),
744                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
745                            view: target_view,
746                            resolve_target: None,
747                            ops: wgpu::Operations {
748                                load: load_op,
749                                store: wgpu::StoreOp::Store,
750                            },
751                            depth_slice: None,
752                        })],
753                        depth_stencil_attachment: None,
754                        timestamp_writes: None,
755                        occlusion_query_set: None,
756                        multiview_mask: None,
757                    });
758
759                    // Composite pending blurred sub-trees first.
760                    for pc in &composites_to_draw {
761                        composite_blur_quad(
762                            &self.device,
763                            &self.queue,
764                            &mut pass,
765                            &self.blur_pool,
766                            &self.quad_pipeline,
767                            &self.quad_bind_group_layout,
768                            &self.blur_composite_sampler,
769                            &self.streams.quad,
770                            index_binding,
771                            pc.blurred_texture,
772                            pc.used_w,
773                            pc.used_h,
774                            pc.bucket_w,
775                            pc.bucket_h,
776                            pc.bounds,
777                            scale_factor,
778                            viewport_width,
779                            viewport_height,
780                        );
781                        // The composite uses the quad pipeline with a
782                        // fresh bind group → invalidate any cached
783                        // glyph/path-atlas binding for the next quad
784                        // batch.
785                        quad_source = None;
786                    }
787
788                    let pass = &mut pass;
789
790                    // Inner loop: process commands until we hit a blur
791                    // boundary or run out.
792                    while cmd_idx < frame.draw_order.len() {
793                        let cmd = &frame.draw_order[cmd_idx];
794                        if matches!(
795                            cmd,
796                            teksilo_canvas::DrawCommand::BeginBlurredSubtree { .. }
797                                | teksilo_canvas::DrawCommand::EndBlurredSubtree
798                        ) {
799                            break;
800                        }
801                        match cmd {
802                            teksilo_canvas::DrawCommand::Decoration(idx) => {
803                                flush_all!(
804                                    pass,
805                                    &self.queue,
806                                    self.streams,
807                                    &self.rect_pipeline,
808                                    &self.sdf_pipeline,
809                                    &self.quad_pipeline,
810                                    &self.path_gradient_pipeline,
811                                    &self.shadow_pipeline,
812                                    rect_batch,
813                                    sdf_batch,
814                                    quad_batch,
815                                    path_gradient_batch,
816                                    shadow_batch,
817                                    self.atlas_texture,
818                                    self.path_atlas_texture,
819                                    quad_source,
820                                    index_binding
821                                );
822                                quad_source = None;
823                                let Some(rect) = frame.decorations.get(*idx) else {
824                                    continue;
825                                };
826                                let verts = RectVertex::from_decoration(rect, scale_factor);
827                                for v in &verts {
828                                    let tp = apply_transform_pixel(v.position, &current_transform);
829                                    rect_batch.push(RectVertex {
830                                        position: pixel_to_ndc(tp, viewport_width, viewport_height),
831                                        color: [
832                                            v.color[0],
833                                            v.color[1],
834                                            v.color[2],
835                                            v.color[3] * current_opacity,
836                                        ],
837                                    });
838                                }
839                            }
840                            teksilo_canvas::DrawCommand::CosmeticLine(idx) => {
841                                flush_all!(
842                                    pass,
843                                    &self.queue,
844                                    self.streams,
845                                    &self.rect_pipeline,
846                                    &self.sdf_pipeline,
847                                    &self.quad_pipeline,
848                                    &self.path_gradient_pipeline,
849                                    &self.shadow_pipeline,
850                                    rect_batch,
851                                    sdf_batch,
852                                    quad_batch,
853                                    path_gradient_batch,
854                                    shadow_batch,
855                                    self.atlas_texture,
856                                    self.path_atlas_texture,
857                                    quad_source,
858                                    index_binding
859                                );
860                                quad_source = None;
861                                let Some(line) = frame.cosmetic_lines.get(*idx) else {
862                                    continue;
863                                };
864                                // Transform the endpoints (premultiplied by the
865                                // HiDPI scale_factor) through the active
866                                // transform, then apply a device-pixel thickness
867                                // that does NOT scale with the transform's zoom.
868                                let p0 = apply_transform_pixel(
869                                    [line.from[0] * scale_factor, line.from[1] * scale_factor],
870                                    &current_transform,
871                                );
872                                let p1 = apply_transform_pixel(
873                                    [line.to[0] * scale_factor, line.to[1] * scale_factor],
874                                    &current_transform,
875                                );
876                                let thickness = (line.width * scale_factor).max(1.0);
877                                let half = thickness * 0.5;
878                                let dx = p1[0] - p0[0];
879                                let dy = p1[1] - p0[1];
880                                let len = (dx * dx + dy * dy).sqrt();
881                                if len < 1e-3 {
882                                    continue;
883                                }
884                                // Perpendicular unit normal in device space.
885                                let nx = -dy / len;
886                                let ny = dx / len;
887                                // Pixel-snap axis-aligned lines (edge-aligned
888                                // center) for crispness; leave diagonals as-is.
889                                let (mut a0, mut a1) = (p0, p1);
890                                if dy.abs() < 0.5 {
891                                    let cy = ((p0[1] + p1[1]) * 0.5 - half).round() + half;
892                                    a0 = [p0[0], cy];
893                                    a1 = [p1[0], cy];
894                                } else if dx.abs() < 0.5 {
895                                    let cx = ((p0[0] + p1[0]) * 0.5 - half).round() + half;
896                                    a0 = [cx, p0[1]];
897                                    a1 = [cx, p1[1]];
898                                }
899                                let lin = crate::vertex::srgb_to_linear_rgba(line.color);
900                                let color = [lin[0], lin[1], lin[2], lin[3] * current_opacity];
901                                let corners = [
902                                    [a0[0] + nx * half, a0[1] + ny * half],
903                                    [a1[0] + nx * half, a1[1] + ny * half],
904                                    [a1[0] - nx * half, a1[1] - ny * half],
905                                    [a0[0] - nx * half, a0[1] - ny * half],
906                                ];
907                                for pos in corners {
908                                    rect_batch.push(RectVertex {
909                                        position: pixel_to_ndc(
910                                            pos,
911                                            viewport_width,
912                                            viewport_height,
913                                        ),
914                                        color,
915                                    });
916                                }
917                            }
918                            teksilo_canvas::DrawCommand::Shape(idx) => {
919                                flush_all!(
920                                    pass,
921                                    &self.queue,
922                                    self.streams,
923                                    &self.rect_pipeline,
924                                    &self.sdf_pipeline,
925                                    &self.quad_pipeline,
926                                    &self.path_gradient_pipeline,
927                                    &self.shadow_pipeline,
928                                    rect_batch,
929                                    sdf_batch,
930                                    quad_batch,
931                                    path_gradient_batch,
932                                    shadow_batch,
933                                    self.atlas_texture,
934                                    self.path_atlas_texture,
935                                    quad_source,
936                                    index_binding
937                                );
938                                quad_source = None;
939                                let Some(shape) = frame.shapes.get(*idx) else {
940                                    continue;
941                                };
942                                // Cosmetic (device-space) borders hold a
943                                // constant device-pixel width under zoom: the
944                                // body still scales via `current_transform`, but
945                                // the SDF stroke param is divided by the active
946                                // zoom (the uniform scale of the linear part,
947                                // which carries no scale_factor — see
948                                // SetTransform). Fills + logical strokes are
949                                // unchanged.
950                                let verts = if shape.stroke_space
951                                    == teksilo_canvas::StrokeSpace::Device
952                                    && shape.stroke_width > 0.0
953                                {
954                                    // Uniform scale of the linear part = view
955                                    // zoom (no scale_factor — it lives only in
956                                    // the translation column). `from_shape_quad_cosmetic`
957                                    // applies the divide-by-zero floor.
958                                    let zoom = current_transform.m[0].hypot(current_transform.m[1]);
959                                    SdfVertex::from_shape_quad_cosmetic(shape, scale_factor, zoom)
960                                } else {
961                                    SdfVertex::from_shape_quad(shape, scale_factor)
962                                };
963                                for v in &verts {
964                                    let tp = apply_transform_pixel(v.position, &current_transform);
965                                    sdf_batch.push(SdfVertex {
966                                        position: pixel_to_ndc(tp, viewport_width, viewport_height),
967                                        color: [
968                                            v.color[0],
969                                            v.color[1],
970                                            v.color[2],
971                                            v.color[3] * current_opacity,
972                                        ],
973                                        ..*v
974                                    });
975                                }
976                            }
977                            teksilo_canvas::DrawCommand::Glyph(idx) => {
978                                // Only flush when the quad source changes — consecutive
979                                // glyphs batch into one draw call.
980                                if quad_source != Some(QuadSource::GlyphAtlas) {
981                                    flush_all!(
982                                        pass,
983                                        &self.queue,
984                                        self.streams,
985                                        &self.rect_pipeline,
986                                        &self.sdf_pipeline,
987                                        &self.quad_pipeline,
988                                        &self.path_gradient_pipeline,
989                                        &self.shadow_pipeline,
990                                        rect_batch,
991                                        sdf_batch,
992                                        quad_batch,
993                                        path_gradient_batch,
994                                        shadow_batch,
995                                        self.atlas_texture,
996                                        self.path_atlas_texture,
997                                        quad_source,
998                                        index_binding
999                                    );
1000                                    quad_source = Some(QuadSource::GlyphAtlas);
1001                                }
1002                                if let Some(atlas) = &self.atlas_texture {
1003                                    let Some(glyph) = frame.glyphs.get(*idx) else {
1004                                        continue;
1005                                    };
1006                                    // Transform is applied (and 1:1 quads
1007                                    // pixel-snapped) inside the constructor.
1008                                    let verts = QuadVertex::from_glyph_quad_transformed(
1009                                        glyph,
1010                                        scale_factor,
1011                                        atlas.width,
1012                                        atlas.height,
1013                                        &current_transform,
1014                                    );
1015                                    for v in &verts {
1016                                        quad_batch.push(QuadVertex {
1017                                            position: pixel_to_ndc(
1018                                                v.position,
1019                                                viewport_width,
1020                                                viewport_height,
1021                                            ),
1022                                            color: [
1023                                                v.color[0],
1024                                                v.color[1],
1025                                                v.color[2],
1026                                                v.color[3] * current_opacity,
1027                                            ],
1028                                            ..*v
1029                                        });
1030                                    }
1031                                }
1032                            }
1033                            teksilo_canvas::DrawCommand::Shadow(idx) => {
1034                                flush_all!(
1035                                    pass,
1036                                    &self.queue,
1037                                    self.streams,
1038                                    &self.rect_pipeline,
1039                                    &self.sdf_pipeline,
1040                                    &self.quad_pipeline,
1041                                    &self.path_gradient_pipeline,
1042                                    &self.shadow_pipeline,
1043                                    rect_batch,
1044                                    sdf_batch,
1045                                    quad_batch,
1046                                    path_gradient_batch,
1047                                    shadow_batch,
1048                                    self.atlas_texture,
1049                                    self.path_atlas_texture,
1050                                    quad_source,
1051                                    index_binding
1052                                );
1053                                quad_source = None;
1054                                let Some(shadow) = frame.shadows.get(*idx) else {
1055                                    continue;
1056                                };
1057                                let verts = ShadowVertex::from_shadow_quad(shadow, scale_factor);
1058                                for v in &verts {
1059                                    let tp = apply_transform_pixel(v.position, &current_transform);
1060                                    shadow_batch.push(ShadowVertex {
1061                                        position: pixel_to_ndc(tp, viewport_width, viewport_height),
1062                                        shadow_color: [
1063                                            v.shadow_color[0],
1064                                            v.shadow_color[1],
1065                                            v.shadow_color[2],
1066                                            v.shadow_color[3] * current_opacity,
1067                                        ],
1068                                        ..*v
1069                                    });
1070                                }
1071                            }
1072                            teksilo_canvas::DrawCommand::Image(idx) => {
1073                                // Images use per-image bind groups — flush and draw individually
1074                                flush_all!(
1075                                    pass,
1076                                    &self.queue,
1077                                    self.streams,
1078                                    &self.rect_pipeline,
1079                                    &self.sdf_pipeline,
1080                                    &self.quad_pipeline,
1081                                    &self.path_gradient_pipeline,
1082                                    &self.shadow_pipeline,
1083                                    rect_batch,
1084                                    sdf_batch,
1085                                    quad_batch,
1086                                    path_gradient_batch,
1087                                    shadow_batch,
1088                                    self.atlas_texture,
1089                                    self.path_atlas_texture,
1090                                    quad_source,
1091                                    index_binding
1092                                );
1093                                quad_source = None;
1094                                let Some(image) = frame.images.get(*idx) else {
1095                                    continue;
1096                                };
1097                                self.draw_image(
1098                                    pass,
1099                                    image,
1100                                    scale_factor,
1101                                    viewport_width,
1102                                    viewport_height,
1103                                    current_opacity,
1104                                    &current_transform,
1105                                    index_binding,
1106                                );
1107                            }
1108                            teksilo_canvas::DrawCommand::Path(idx) => {
1109                                flush_all!(
1110                                    pass,
1111                                    &self.queue,
1112                                    self.streams,
1113                                    &self.rect_pipeline,
1114                                    &self.sdf_pipeline,
1115                                    &self.quad_pipeline,
1116                                    &self.path_gradient_pipeline,
1117                                    &self.shadow_pipeline,
1118                                    rect_batch,
1119                                    sdf_batch,
1120                                    quad_batch,
1121                                    path_gradient_batch,
1122                                    shadow_batch,
1123                                    self.atlas_texture,
1124                                    self.path_atlas_texture,
1125                                    quad_source,
1126                                    index_binding
1127                                );
1128                                quad_source = None;
1129                                if let Some(Some(placement)) = path_placements.get(*idx) {
1130                                    let Some(entry) = frame.paths.get(*idx) else {
1131                                        continue;
1132                                    };
1133                                    let Some(path_atlas) = self.path_atlas_texture.as_ref() else {
1134                                        continue;
1135                                    };
1136                                    if matches!(entry.paint_data, teksilo_canvas::PaintData::Solid)
1137                                    {
1138                                        // Solid fill or solid stroke: the lean
1139                                        // quad_pipeline, tinted by entry.color.
1140                                        // (A gradient *stroke* takes the branch
1141                                        // below — the pipeline choice follows the
1142                                        // paint, not fill-vs-stroke; the coverage
1143                                        // mask in the atlas is already whichever
1144                                        // one this entry rasterized.)
1145                                        quad_source = Some(QuadSource::PathAtlas);
1146                                        let verts = path_quad_verts(
1147                                            entry,
1148                                            placement,
1149                                            path_atlas.width,
1150                                            path_atlas.height,
1151                                            current_opacity,
1152                                            &current_transform,
1153                                        );
1154                                        for v in &verts {
1155                                            quad_batch.push(QuadVertex {
1156                                                position: pixel_to_ndc(
1157                                                    v.position,
1158                                                    viewport_width,
1159                                                    viewport_height,
1160                                                ),
1161                                                ..*v
1162                                            });
1163                                        }
1164                                    } else {
1165                                        // Gradient fill: the dedicated
1166                                        // path_gradient pipeline, which
1167                                        // samples the SAME atlas coverage
1168                                        // mask but computes an analytic
1169                                        // gradient color instead of a flat
1170                                        // tint.
1171                                        let verts = path_gradient_quad_verts(
1172                                            entry,
1173                                            placement,
1174                                            scale_factor,
1175                                            path_atlas.width,
1176                                            path_atlas.height,
1177                                            current_opacity,
1178                                            &current_transform,
1179                                        );
1180                                        for v in &verts {
1181                                            path_gradient_batch.push(
1182                                                crate::vertex::PathGradientVertex {
1183                                                    position: pixel_to_ndc(
1184                                                        v.position,
1185                                                        viewport_width,
1186                                                        viewport_height,
1187                                                    ),
1188                                                    ..*v
1189                                                },
1190                                            );
1191                                        }
1192                                    }
1193                                }
1194                            }
1195                            // --- State changes flush all batches ---
1196                            teksilo_canvas::DrawCommand::SetClip(rect) => {
1197                                flush_all!(
1198                                    pass,
1199                                    &self.queue,
1200                                    self.streams,
1201                                    &self.rect_pipeline,
1202                                    &self.sdf_pipeline,
1203                                    &self.quad_pipeline,
1204                                    &self.path_gradient_pipeline,
1205                                    &self.shadow_pipeline,
1206                                    rect_batch,
1207                                    sdf_batch,
1208                                    quad_batch,
1209                                    path_gradient_batch,
1210                                    shadow_batch,
1211                                    self.atlas_texture,
1212                                    self.path_atlas_texture,
1213                                    quad_source,
1214                                    index_binding
1215                                );
1216                                quad_source = None;
1217                                // Apply the current transform stack to the
1218                                // clip rect. Without this, a clip emitted
1219                                // inside a SceneView's view-transform scope
1220                                // (e.g. ScrollArea or nested SceneView as
1221                                // a heavyweight scene_rect widget) would
1222                                // mask the rendered content to the rect's
1223                                // PRE-transform position — the contents
1224                                // visually pan/zoom with the outer view but
1225                                // the clip mask stays fixed in screen
1226                                // space, "eating" the widget as the user
1227                                // pans or zooms out.
1228                                //
1229                                // Rotation-free transforms (the common case
1230                                // for SceneView pan + zoom) produce an
1231                                // axis-aligned transformed rect; for rotated
1232                                // transforms we take the AABB of the four
1233                                // corners, which over-clips slightly but
1234                                // remains correct for visibility.
1235                                let p_tl =
1236                                    apply_transform_pixel([rect.x, rect.y], &current_transform);
1237                                let p_tr = apply_transform_pixel(
1238                                    [rect.x + rect.width, rect.y],
1239                                    &current_transform,
1240                                );
1241                                let p_bl = apply_transform_pixel(
1242                                    [rect.x, rect.y + rect.height],
1243                                    &current_transform,
1244                                );
1245                                let p_br = apply_transform_pixel(
1246                                    [rect.x + rect.width, rect.y + rect.height],
1247                                    &current_transform,
1248                                );
1249                                let min_x = p_tl[0].min(p_tr[0]).min(p_bl[0]).min(p_br[0]);
1250                                let min_y = p_tl[1].min(p_tr[1]).min(p_bl[1]).min(p_br[1]);
1251                                let max_x = p_tl[0].max(p_tr[0]).max(p_bl[0]).max(p_br[0]);
1252                                let max_y = p_tl[1].max(p_tr[1]).max(p_bl[1]).max(p_br[1]);
1253                                let x = (min_x * scale_factor).max(0.0) as u32;
1254                                let y = (min_y * scale_factor).max(0.0) as u32;
1255                                let w = ((max_x - min_x) * scale_factor).ceil().max(0.0) as u32;
1256                                let h = ((max_y - min_y) * scale_factor).ceil().max(0.0) as u32;
1257                                // Clamp to viewport — wgpu requires x+w <= width, y+h <= height.
1258                                let x = x.min(viewport_width);
1259                                let y = y.min(viewport_height);
1260                                let w = w.min(viewport_width.saturating_sub(x));
1261                                let h = h.min(viewport_height.saturating_sub(y));
1262                                let clipped = if let Some(&[cx, cy, cw, ch]) = clip_stack.last() {
1263                                    let ix = x.max(cx);
1264                                    let iy = y.max(cy);
1265                                    let ir = (x + w).min(cx + cw);
1266                                    let ib = (y + h).min(cy + ch);
1267                                    [ix, iy, ir.saturating_sub(ix), ib.saturating_sub(iy)]
1268                                } else {
1269                                    [x, y, w, h]
1270                                };
1271                                clip_stack.push(clipped);
1272                                pass.set_scissor_rect(
1273                                    clipped[0], clipped[1], clipped[2], clipped[3],
1274                                );
1275                            }
1276                            teksilo_canvas::DrawCommand::ClearClip => {
1277                                flush_all!(
1278                                    pass,
1279                                    &self.queue,
1280                                    self.streams,
1281                                    &self.rect_pipeline,
1282                                    &self.sdf_pipeline,
1283                                    &self.quad_pipeline,
1284                                    &self.path_gradient_pipeline,
1285                                    &self.shadow_pipeline,
1286                                    rect_batch,
1287                                    sdf_batch,
1288                                    quad_batch,
1289                                    path_gradient_batch,
1290                                    shadow_batch,
1291                                    self.atlas_texture,
1292                                    self.path_atlas_texture,
1293                                    quad_source,
1294                                    index_binding
1295                                );
1296                                quad_source = None;
1297                                clip_stack.pop();
1298                                if let Some(&[x, y, w, h]) = clip_stack.last() {
1299                                    pass.set_scissor_rect(x, y, w, h);
1300                                } else {
1301                                    pass.set_scissor_rect(0, 0, viewport_width, viewport_height);
1302                                }
1303                            }
1304                            teksilo_canvas::DrawCommand::SetOpacity(opacity) => {
1305                                flush_all!(
1306                                    pass,
1307                                    &self.queue,
1308                                    self.streams,
1309                                    &self.rect_pipeline,
1310                                    &self.sdf_pipeline,
1311                                    &self.quad_pipeline,
1312                                    &self.path_gradient_pipeline,
1313                                    &self.shadow_pipeline,
1314                                    rect_batch,
1315                                    sdf_batch,
1316                                    quad_batch,
1317                                    path_gradient_batch,
1318                                    shadow_batch,
1319                                    self.atlas_texture,
1320                                    self.path_atlas_texture,
1321                                    quad_source,
1322                                    index_binding
1323                                );
1324                                quad_source = None;
1325                                opacity_stack.push(current_opacity);
1326                                current_opacity *= opacity;
1327                            }
1328                            teksilo_canvas::DrawCommand::RestoreOpacity => {
1329                                flush_all!(
1330                                    pass,
1331                                    &self.queue,
1332                                    self.streams,
1333                                    &self.rect_pipeline,
1334                                    &self.sdf_pipeline,
1335                                    &self.quad_pipeline,
1336                                    &self.path_gradient_pipeline,
1337                                    &self.shadow_pipeline,
1338                                    rect_batch,
1339                                    sdf_batch,
1340                                    quad_batch,
1341                                    path_gradient_batch,
1342                                    shadow_batch,
1343                                    self.atlas_texture,
1344                                    self.path_atlas_texture,
1345                                    quad_source,
1346                                    index_binding
1347                                );
1348                                quad_source = None;
1349                                current_opacity = opacity_stack.pop().unwrap_or(1.0);
1350                            }
1351                            teksilo_canvas::DrawCommand::Rasterized(_) => {}
1352                            teksilo_canvas::DrawCommand::AnimatedQuad(idx) => {
1353                                let Some(draw) = frame.animated_quads.get(*idx) else {
1354                                    continue;
1355                                };
1356                                // Flush every other pipeline first so painter's
1357                                // order is preserved across pipeline boundaries.
1358                                flush_all!(
1359                                    pass,
1360                                    &self.queue,
1361                                    self.streams,
1362                                    &self.rect_pipeline,
1363                                    &self.sdf_pipeline,
1364                                    &self.quad_pipeline,
1365                                    &self.path_gradient_pipeline,
1366                                    &self.shadow_pipeline,
1367                                    rect_batch,
1368                                    sdf_batch,
1369                                    quad_batch,
1370                                    path_gradient_batch,
1371                                    shadow_batch,
1372                                    self.atlas_texture,
1373                                    self.path_atlas_texture,
1374                                    quad_source,
1375                                    index_binding
1376                                );
1377                                quad_source = None;
1378                                match &draw.class {
1379                                    teksilo_canvas::AnimatedQuadClass::Procedural => {
1380                                        let verts =
1381                                            AnimQuadVertex::from_animated_quad(draw, scale_factor);
1382                                        for v in &verts {
1383                                            let tp = apply_transform_pixel(
1384                                                v.position,
1385                                                &current_transform,
1386                                            );
1387                                            anim_proc_batch.push(AnimQuadVertex {
1388                                                position: pixel_to_ndc(
1389                                                    tp,
1390                                                    viewport_width,
1391                                                    viewport_height,
1392                                                ),
1393                                                uv: v.uv,
1394                                                slot: v.slot,
1395                                                _pad: v._pad,
1396                                            });
1397                                        }
1398                                    }
1399                                    teksilo_canvas::AnimatedQuadClass::Sprite { image_name } => {
1400                                        // Sprite quads need a per-atlas bind
1401                                        // group, so each draws individually —
1402                                        // same shape as the static Image path.
1403                                        // Typical scene has ~1 animated sprite
1404                                        // icon at a time, so batching is moot.
1405                                        let Some(atlas_bg) =
1406                                            self.image_manager.get_bind_group(image_name)
1407                                        else {
1408                                            continue;
1409                                        };
1410                                        let verts =
1411                                            AnimQuadVertex::from_animated_quad(draw, scale_factor);
1412                                        let mut ndc_verts = [AnimQuadVertex {
1413                                            position: [0.0; 2],
1414                                            uv: [0.0; 2],
1415                                            slot: 0,
1416                                            _pad: 0,
1417                                        };
1418                                            4];
1419                                        for (i, v) in verts.iter().enumerate() {
1420                                            let tp = apply_transform_pixel(
1421                                                v.position,
1422                                                &current_transform,
1423                                            );
1424                                            ndc_verts[i] = AnimQuadVertex {
1425                                                position: pixel_to_ndc(
1426                                                    tp,
1427                                                    viewport_width,
1428                                                    viewport_height,
1429                                                ),
1430                                                uv: v.uv,
1431                                                slot: v.slot,
1432                                                _pad: v._pad,
1433                                            };
1434                                        }
1435                                        let bytes: &[u8] = bytemuck::cast_slice(&ndc_verts);
1436                                        if let (Some((vb, v_off, v_len)), Some((ib, _, _))) = (
1437                                            self.streams.anim_proc.write(&self.queue, bytes),
1438                                            index_binding,
1439                                        ) {
1440                                            let index_bytes: u64 = 6 * 4;
1441                                            pass.set_pipeline(&self.anim_sprite_pipeline);
1442                                            pass.set_bind_group(
1443                                                0,
1444                                                &self.anim_uniform_bind_group,
1445                                                &[],
1446                                            );
1447                                            pass.set_bind_group(1, atlas_bg, &[]);
1448                                            pass.set_vertex_buffer(
1449                                                0,
1450                                                vb.slice(v_off..v_off + v_len),
1451                                            );
1452                                            pass.set_index_buffer(
1453                                                ib.slice(0..index_bytes),
1454                                                wgpu::IndexFormat::Uint32,
1455                                            );
1456                                            pass.draw_indexed(0..6, 0, 0..1);
1457                                        }
1458                                    }
1459                                }
1460                            }
1461                            teksilo_canvas::DrawCommand::SetBlendMode(mode) => {
1462                                blend_stack.push(current_blend);
1463                                current_blend = *mode;
1464                            }
1465                            teksilo_canvas::DrawCommand::RestoreBlendMode => {
1466                                current_blend = blend_stack
1467                                    .pop()
1468                                    .unwrap_or(teksilo_canvas::BlendMode::Normal);
1469                            }
1470                            teksilo_canvas::DrawCommand::SetTransform(t) => {
1471                                flush_all!(
1472                                    pass,
1473                                    &self.queue,
1474                                    self.streams,
1475                                    &self.rect_pipeline,
1476                                    &self.sdf_pipeline,
1477                                    &self.quad_pipeline,
1478                                    &self.path_gradient_pipeline,
1479                                    &self.shadow_pipeline,
1480                                    rect_batch,
1481                                    sdf_batch,
1482                                    quad_batch,
1483                                    path_gradient_batch,
1484                                    shadow_batch,
1485                                    self.atlas_texture,
1486                                    self.path_atlas_texture,
1487                                    quad_source,
1488                                    index_binding
1489                                );
1490                                quad_source = None;
1491                                // Widgets author transforms in logical pixels, but
1492                                // vertices arrive pre-multiplied by scale_factor (HiDPI
1493                                // device pixels). Scale the translation column so the
1494                                // pivot lands at the same physical point in either
1495                                // coordinate space.
1496                                let device_t = Transform2D {
1497                                    m: [
1498                                        t.m[0],
1499                                        t.m[1],
1500                                        t.m[2],
1501                                        t.m[3],
1502                                        t.m[4] * scale_factor,
1503                                        t.m[5] * scale_factor,
1504                                    ],
1505                                };
1506                                // Compose with the current transform-stack top so a
1507                                // widget's canvas-local transform respects any wrapper
1508                                // transform pushed by the render walker. With an
1509                                // identity stack top this is identical to the old
1510                                // "absolute" semantics — backwards compatible for any
1511                                // widget not under a transform scope.
1512                                let stack_top = transform_stack
1513                                    .last()
1514                                    .copied()
1515                                    .unwrap_or(Transform2D::IDENTITY);
1516                                current_transform = device_t.then(&stack_top);
1517                            }
1518                            teksilo_canvas::DrawCommand::PushTransform(t) => {
1519                                flush_all!(
1520                                    pass,
1521                                    &self.queue,
1522                                    self.streams,
1523                                    &self.rect_pipeline,
1524                                    &self.sdf_pipeline,
1525                                    &self.quad_pipeline,
1526                                    &self.path_gradient_pipeline,
1527                                    &self.shadow_pipeline,
1528                                    rect_batch,
1529                                    sdf_batch,
1530                                    quad_batch,
1531                                    path_gradient_batch,
1532                                    shadow_batch,
1533                                    self.atlas_texture,
1534                                    self.path_atlas_texture,
1535                                    quad_source,
1536                                    index_binding
1537                                );
1538                                quad_source = None;
1539                                // See SetTransform: scale the translation column to
1540                                // device pixels before composing.
1541                                let device_t = Transform2D {
1542                                    m: [
1543                                        t.m[0],
1544                                        t.m[1],
1545                                        t.m[2],
1546                                        t.m[3],
1547                                        t.m[4] * scale_factor,
1548                                        t.m[5] * scale_factor,
1549                                    ],
1550                                };
1551                                let prev_top = transform_stack
1552                                    .last()
1553                                    .copied()
1554                                    .unwrap_or(Transform2D::IDENTITY);
1555                                let new_top = device_t.then(&prev_top);
1556                                transform_stack.push(new_top);
1557                                current_transform = new_top;
1558                            }
1559                            teksilo_canvas::DrawCommand::PopTransform => {
1560                                flush_all!(
1561                                    pass,
1562                                    &self.queue,
1563                                    self.streams,
1564                                    &self.rect_pipeline,
1565                                    &self.sdf_pipeline,
1566                                    &self.quad_pipeline,
1567                                    &self.path_gradient_pipeline,
1568                                    &self.shadow_pipeline,
1569                                    rect_batch,
1570                                    sdf_batch,
1571                                    quad_batch,
1572                                    path_gradient_batch,
1573                                    shadow_batch,
1574                                    self.atlas_texture,
1575                                    self.path_atlas_texture,
1576                                    quad_source,
1577                                    index_binding
1578                                );
1579                                quad_source = None;
1580                                if transform_stack.len() > 1 {
1581                                    transform_stack.pop();
1582                                }
1583                                current_transform = transform_stack
1584                                    .last()
1585                                    .copied()
1586                                    .unwrap_or(Transform2D::IDENTITY);
1587                            }
1588                            teksilo_canvas::DrawCommand::BeginBlurredSubtree { .. }
1589                            | teksilo_canvas::DrawCommand::EndBlurredSubtree => {
1590                                // Unreachable — the inner-loop guard above
1591                                // breaks before we enter the match for these.
1592                                unreachable!("blur boundaries are handled at the segment level");
1593                            }
1594                        }
1595                        cmd_idx += 1;
1596                    }
1597
1598                    // End-of-segment flush.
1599                    flush_all!(
1600                        pass,
1601                        &self.queue,
1602                        self.streams,
1603                        &self.rect_pipeline,
1604                        &self.sdf_pipeline,
1605                        &self.quad_pipeline,
1606                        &self.path_gradient_pipeline,
1607                        &self.shadow_pipeline,
1608                        rect_batch,
1609                        sdf_batch,
1610                        quad_batch,
1611                        path_gradient_batch,
1612                        shadow_batch,
1613                        self.atlas_texture,
1614                        self.path_atlas_texture,
1615                        quad_source,
1616                        index_binding
1617                    );
1618                    quad_source = None;
1619                } // pass dropped here, encoder borrow released
1620
1621                // Boundary handling. EOF, Begin, or End.
1622                if cmd_idx >= frame.draw_order.len() {
1623                    break;
1624                }
1625                match &frame.draw_order[cmd_idx] {
1626                    teksilo_canvas::DrawCommand::BeginBlurredSubtree { bounds, radius } => {
1627                        // Allocate intermediate sized to bounds × scale.
1628                        let device_w = (bounds.width * scale_factor).ceil().max(1.0) as u32;
1629                        let device_h = (bounds.height * scale_factor).ceil().max(1.0) as u32;
1630                        let intermediate = self.blur_pool.acquire(&self.device, device_w, device_h);
1631                        let (bucket_w, bucket_h) = self.blur_pool.dimensions(intermediate);
1632
1633                        // Push a translation so the subtree renders at
1634                        // (0, 0) of the intermediate. Device-pixel
1635                        // translation since vertices arrive pre-scaled
1636                        // (see SetTransform handler for the same trick).
1637                        let translate = Transform2D {
1638                            m: [
1639                                1.0,
1640                                0.0,
1641                                0.0,
1642                                1.0,
1643                                -bounds.x * scale_factor,
1644                                -bounds.y * scale_factor,
1645                            ],
1646                        };
1647                        let prev_top = transform_stack
1648                            .last()
1649                            .copied()
1650                            .unwrap_or(Transform2D::IDENTITY);
1651                        let new_top = translate.then(&prev_top);
1652                        transform_stack.push(new_top);
1653                        current_transform = new_top;
1654
1655                        target_stack.push(ActiveTarget {
1656                            intermediate: Some(intermediate),
1657                            viewport_w: bucket_w,
1658                            viewport_h: bucket_h,
1659                            opened: false,
1660                            pending_composites: Vec::new(),
1661                            blur_bounds: Some(*bounds),
1662                            blur_radius_logical: Some(*radius),
1663                            used_w: Some(device_w),
1664                            used_h: Some(device_h),
1665                            bucket_w: Some(bucket_w),
1666                            bucket_h: Some(bucket_h),
1667                        });
1668                    }
1669                    teksilo_canvas::DrawCommand::EndBlurredSubtree => {
1670                        let scope = target_stack
1671                            .pop()
1672                            .expect("EndBlurredSubtree without matching Begin");
1673                        debug_assert!(
1674                            scope.intermediate.is_some(),
1675                            "End popped the surface (impossible if walker is balanced)"
1676                        );
1677                        let intermediate = scope
1678                            .intermediate
1679                            .expect("blur scope intermediate set in BeginBlurredSubtree");
1680                        let bounds = scope
1681                            .blur_bounds
1682                            .expect("blur scope bounds set in BeginBlurredSubtree");
1683                        let radius = scope
1684                            .blur_radius_logical
1685                            .expect("blur scope radius set in BeginBlurredSubtree");
1686                        let used_w = scope
1687                            .used_w
1688                            .expect("blur scope used_w set in BeginBlurredSubtree");
1689                        let used_h = scope
1690                            .used_h
1691                            .expect("blur scope used_h set in BeginBlurredSubtree");
1692                        let bucket_w = scope
1693                            .bucket_w
1694                            .expect("blur scope bucket_w set in BeginBlurredSubtree");
1695                        let bucket_h = scope
1696                            .bucket_h
1697                            .expect("blur scope bucket_h set in BeginBlurredSubtree");
1698
1699                        // Pop the translation pushed in Begin.
1700                        if transform_stack.len() > 1 {
1701                            transform_stack.pop();
1702                        }
1703                        current_transform = transform_stack
1704                            .last()
1705                            .copied()
1706                            .unwrap_or(Transform2D::IDENTITY);
1707
1708                        // Run dual-Kawase. The chain begins its own
1709                        // sub-passes against pool textures — the outer
1710                        // segment's pass is already dropped.
1711                        let blurred = run_kawase_chain(
1712                            &self.device,
1713                            &self.queue,
1714                            &mut encoder,
1715                            &mut self.blur_pool,
1716                            &self.blur_pipelines,
1717                            intermediate,
1718                            used_w,
1719                            used_h,
1720                            bucket_w,
1721                            bucket_h,
1722                            radius * scale_factor,
1723                        );
1724
1725                        // Schedule a composite into the parent target's
1726                        // next segment open.
1727                        target_stack
1728                            .last_mut()
1729                            .expect("target_stack always has the surface target")
1730                            .pending_composites
1731                            .push(PendingComposite {
1732                                blurred_texture: blurred.texture,
1733                                used_w: blurred.used_w,
1734                                used_h: blurred.used_h,
1735                                bucket_w: blurred.bucket_w,
1736                                bucket_h: blurred.bucket_h,
1737                                bounds,
1738                            });
1739                    }
1740                    _ => unreachable!("inner loop only breaks on Begin/End"),
1741                }
1742                cmd_idx += 1;
1743            }
1744
1745            debug_assert!(
1746                target_stack.len() == 1,
1747                "target_stack not balanced at EOF — unmatched Begin/End in walker output"
1748            );
1749            // The remaining surface target may still have a pending
1750            // composite (an outermost blur scope ending at end-of-frame
1751            // with no further commands). Drain it in one final pass.
1752            let final_composites = std::mem::take(
1753                &mut target_stack
1754                    .last_mut()
1755                    .expect("target_stack always has the surface target")
1756                    .pending_composites,
1757            );
1758            if !final_composites.is_empty() {
1759                let surface = target_stack
1760                    .last_mut()
1761                    .expect("target_stack always has the surface target");
1762                let load_op = if surface.opened {
1763                    wgpu::LoadOp::Load
1764                } else {
1765                    wgpu::LoadOp::Clear(surface_clear_color)
1766                };
1767                surface.opened = true;
1768                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1769                    label: Some("teksilo_final_composite_pass"),
1770                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1771                        view,
1772                        resolve_target: None,
1773                        ops: wgpu::Operations {
1774                            load: load_op,
1775                            store: wgpu::StoreOp::Store,
1776                        },
1777                        depth_slice: None,
1778                    })],
1779                    depth_stencil_attachment: None,
1780                    timestamp_writes: None,
1781                    occlusion_query_set: None,
1782                    multiview_mask: None,
1783                });
1784                for pc in &final_composites {
1785                    composite_blur_quad(
1786                        &self.device,
1787                        &self.queue,
1788                        &mut pass,
1789                        &self.blur_pool,
1790                        &self.quad_pipeline,
1791                        &self.quad_bind_group_layout,
1792                        &self.blur_composite_sampler,
1793                        &self.streams.quad,
1794                        index_binding,
1795                        pc.blurred_texture,
1796                        pc.used_w,
1797                        pc.used_h,
1798                        pc.bucket_w,
1799                        pc.bucket_h,
1800                        pc.bounds,
1801                        scale_factor,
1802                        viewport_width,
1803                        viewport_height,
1804                    );
1805                }
1806            } else if !target_stack
1807                .last()
1808                .expect("target_stack always has the surface target")
1809                .opened
1810            {
1811                // Empty frame — open one pass to apply the clear.
1812                let _ = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1813                    label: Some("teksilo_empty_clear_pass"),
1814                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1815                        view,
1816                        resolve_target: None,
1817                        ops: wgpu::Operations {
1818                            load: wgpu::LoadOp::Clear(surface_clear_color),
1819                            store: wgpu::StoreOp::Store,
1820                        },
1821                        depth_slice: None,
1822                    })],
1823                    depth_stencil_attachment: None,
1824                    timestamp_writes: None,
1825                    occlusion_query_set: None,
1826                    multiview_mask: None,
1827                });
1828            }
1829        }
1830
1831        self.queue.submit(std::iter::once(encoder.finish()));
1832    }
1833
1834    // draw_rect, draw_sdf, draw_quad, draw_shadow, draw_path_quad removed —
1835    // replaced by batched rendering in render().
1836
1837    #[allow(clippy::too_many_arguments)]
1838    fn draw_image(
1839        &self,
1840        pass: &mut wgpu::RenderPass,
1841        image: &teksilo_canvas::ImageQuad,
1842        scale_factor: f32,
1843        viewport_width: u32,
1844        viewport_height: u32,
1845        opacity: f32,
1846        transform: &Transform2D,
1847        index_binding: Option<(&wgpu::Buffer, u64, u64)>,
1848    ) {
1849        let bind_group = match self.image_manager.get_bind_group(&image.name) {
1850            Some(bg) => bg,
1851            None => return,
1852        };
1853
1854        let [x, y, w, h] = image.screen;
1855        let sx = x * scale_factor;
1856        let sy = y * scale_factor;
1857        let sw = w * scale_factor;
1858        let sh = h * scale_factor;
1859
1860        // Tintable mode: image is an alpha mask tinted with the given color (flag=0).
1861        // Full-color mode: image RGB used directly (flag=1, existing behavior).
1862        let (color, flags) = if let Some(tint) = image.tint {
1863            // Tint colors are sRGB-encoded (from teksilo_tokens::Color) — linearize
1864            // for the Rgba8UnormSrgb surface, same as all other vertex colors.
1865            (
1866                crate::vertex::srgb_to_linear_rgba([tint[0], tint[1], tint[2], tint[3] * opacity]),
1867                0,
1868            )
1869        } else {
1870            (
1871                [1.0, 1.0, 1.0, opacity],
1872                crate::vertex::QUAD_FLAG_COLOR_GLYPH,
1873            )
1874        };
1875
1876        let verts = [
1877            QuadVertex {
1878                position: [sx, sy],
1879                tex_coord: [0.0, 0.0],
1880                color,
1881                flags,
1882                _pad: 0,
1883            },
1884            QuadVertex {
1885                position: [sx + sw, sy],
1886                tex_coord: [1.0, 0.0],
1887                color,
1888                flags,
1889                _pad: 0,
1890            },
1891            QuadVertex {
1892                position: [sx + sw, sy + sh],
1893                tex_coord: [1.0, 1.0],
1894                color,
1895                flags,
1896                _pad: 0,
1897            },
1898            QuadVertex {
1899                position: [sx, sy + sh],
1900                tex_coord: [0.0, 1.0],
1901                color,
1902                flags,
1903                _pad: 0,
1904            },
1905        ];
1906
1907        let ndc_verts: [QuadVertex; 4] = std::array::from_fn(|i| {
1908            let v = verts[i];
1909            let tp = apply_transform_pixel(v.position, transform);
1910            QuadVertex {
1911                position: pixel_to_ndc(tp, viewport_width, viewport_height),
1912                ..v
1913            }
1914        });
1915
1916        // Reuse the persistent quad stream buffer instead of allocating
1917        // a fresh vertex buffer per image. Indices come from the shared
1918        // index stream populated at the top of `render()`.
1919        let bytes: &[u8] = bytemuck::cast_slice(&ndc_verts);
1920        let Some((vb, v_off, v_len)) = self.streams.quad.write(&self.queue, bytes) else {
1921            return;
1922        };
1923        let Some((ib, _, _)) = index_binding else {
1924            return;
1925        };
1926
1927        pass.set_pipeline(&self.quad_pipeline);
1928        pass.set_bind_group(0, bind_group, &[]);
1929        pass.set_vertex_buffer(0, vb.slice(v_off..v_off + v_len));
1930        pass.set_index_buffer(ib.slice(0..24), wgpu::IndexFormat::Uint32);
1931        pass.draw_indexed(0..6, 0, 0..1);
1932    }
1933
1934    /// Upload path atlas texture data.
1935    fn upload_path_atlas(&mut self, width: u32, height: u32, pixels: Vec<u8>) {
1936        if width == 0 || height == 0 {
1937            return;
1938        }
1939
1940        let needs_recreate = self
1941            .path_atlas_texture
1942            .as_ref()
1943            .is_none_or(|t| t.width != width || t.height != height);
1944
1945        if needs_recreate {
1946            let texture = self.device.create_texture(&wgpu::TextureDescriptor {
1947                label: Some("path_atlas"),
1948                size: wgpu::Extent3d {
1949                    width,
1950                    height,
1951                    depth_or_array_layers: 1,
1952                },
1953                mip_level_count: 1,
1954                sample_count: 1,
1955                dimension: wgpu::TextureDimension::D2,
1956                format: wgpu::TextureFormat::Rgba8UnormSrgb,
1957                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1958                view_formats: &[],
1959            });
1960
1961            let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
1962            let sampler = self.device.create_sampler(&wgpu::SamplerDescriptor {
1963                mag_filter: wgpu::FilterMode::Linear,
1964                min_filter: wgpu::FilterMode::Linear,
1965                ..Default::default()
1966            });
1967
1968            let bind_group_layout = self.quad_pipeline.get_bind_group_layout(0);
1969            let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
1970                label: Some("path_atlas_bind_group"),
1971                layout: &bind_group_layout,
1972                entries: &[
1973                    wgpu::BindGroupEntry {
1974                        binding: 0,
1975                        resource: wgpu::BindingResource::TextureView(&view),
1976                    },
1977                    wgpu::BindGroupEntry {
1978                        binding: 1,
1979                        resource: wgpu::BindingResource::Sampler(&sampler),
1980                    },
1981                ],
1982            });
1983
1984            self.path_atlas_texture = Some(AtlasTexture {
1985                texture,
1986                bind_group,
1987                width,
1988                height,
1989            });
1990        }
1991
1992        if let Some(atlas) = &self.path_atlas_texture {
1993            self.queue.write_texture(
1994                wgpu::TexelCopyTextureInfo {
1995                    texture: &atlas.texture,
1996                    mip_level: 0,
1997                    origin: wgpu::Origin3d::ZERO,
1998                    aspect: wgpu::TextureAspect::All,
1999                },
2000                &pixels,
2001                wgpu::TexelCopyBufferLayout {
2002                    offset: 0,
2003                    bytes_per_row: Some(width * 4),
2004                    rows_per_image: Some(height),
2005                },
2006                wgpu::Extent3d {
2007                    width,
2008                    height,
2009                    depth_or_array_layers: 1,
2010                },
2011            );
2012        }
2013    }
2014
2015    pub fn device(&self) -> &wgpu::Device {
2016        &self.device
2017    }
2018
2019    pub fn queue(&self) -> &wgpu::Queue {
2020        &self.queue
2021    }
2022
2023    /// Register an image for rendering by name.
2024    pub fn register_image(&mut self, name: &str, width: u32, height: u32, pixels: &[u8]) {
2025        let layout = self.quad_pipeline.get_bind_group_layout(0);
2026        self.image_manager.register_image(
2027            name,
2028            width,
2029            height,
2030            pixels,
2031            &self.device,
2032            &self.queue,
2033            &layout,
2034        );
2035    }
2036
2037    /// Remove a registered image.
2038    pub fn remove_image(&mut self, name: &str) {
2039        self.image_manager.remove(name);
2040    }
2041}
2042
2043/// Convert pixel coordinates to NDC (-1..1).
2044/// Build 4 QuadVertex for a path entry (in pixel space, pre-NDC).
2045fn path_quad_verts(
2046    entry: &teksilo_canvas::PathEntry,
2047    placement: &crate::path_atlas::PathPlacement,
2048    atlas_width: u32,
2049    atlas_height: u32,
2050    opacity: f32,
2051    transform: &Transform2D,
2052) -> [QuadVertex; 4] {
2053    // The rect comes from the placement, never recomputed from
2054    // `entry.bounds` — the atlas baked its bitmap against this exact rect,
2055    // and a second derivation of it is how the two drifted apart before
2056    // (see `PathPlacement`).
2057    let region = &placement.region;
2058    let [sx, sy, sw, sh] = placement.device_rect;
2059
2060    let aw = atlas_width.max(1) as f32;
2061    let ah = atlas_height.max(1) as f32;
2062    let u0 = region.x as f32 / aw;
2063    let v0 = region.y as f32 / ah;
2064    let u1 = (region.x + region.w) as f32 / aw;
2065    let v1 = (region.y + region.h) as f32 / ah;
2066
2067    // The path atlas stores coverage in its alpha channel; the monochrome
2068    // quad path (`flags = 0`) tints with the vertex RGB and multiplies by
2069    // that coverage. The `Rgba8UnormSrgb` target expects linear RGB from the
2070    // shader, so linearize `entry.color` here exactly like every other
2071    // pipeline (rect / sdf / shadow / image) — otherwise paths render with a
2072    // gamma error against everything else.
2073    let lin = crate::vertex::srgb_to_linear_rgba(entry.color);
2074    let color = [lin[0], lin[1], lin[2], entry.color[3] * opacity];
2075
2076    let positions = [
2077        apply_transform_pixel([sx, sy], transform),
2078        apply_transform_pixel([sx + sw, sy], transform),
2079        apply_transform_pixel([sx + sw, sy + sh], transform),
2080        apply_transform_pixel([sx, sy + sh], transform),
2081    ];
2082    let uvs = [[u0, v0], [u1, v0], [u1, v1], [u0, v1]];
2083
2084    // The shader outputs `vertex.rgb * tex.a` for `flags = 0`, equivalent to
2085    // `linear_path_color * path_coverage`.
2086    [
2087        QuadVertex {
2088            position: positions[0],
2089            tex_coord: uvs[0],
2090            color,
2091            flags: 0,
2092            _pad: 0,
2093        },
2094        QuadVertex {
2095            position: positions[1],
2096            tex_coord: uvs[1],
2097            color,
2098            flags: 0,
2099            _pad: 0,
2100        },
2101        QuadVertex {
2102            position: positions[2],
2103            tex_coord: uvs[2],
2104            color,
2105            flags: 0,
2106            _pad: 0,
2107        },
2108        QuadVertex {
2109            position: positions[3],
2110            tex_coord: uvs[3],
2111            color,
2112            flags: 0,
2113            _pad: 0,
2114        },
2115    ]
2116}
2117
2118/// Build 4 [`PathGradientVertex`](crate::vertex::PathGradientVertex)es for a
2119/// gradient-filled path entry (in pixel space, pre-NDC). Same
2120/// bounds/atlas-UV/position math as [`path_quad_verts`] (the solid-path
2121/// counterpart) — the actual encoding lives on
2122/// `PathGradientVertex::from_path_entry` (mirrors the shared
2123/// `encode_paint_data`/`encode_stops` helpers used by [`SdfVertex`]); this
2124/// wrapper exists so the call site in `render()` reads symmetrically with
2125/// `path_quad_verts`.
2126fn path_gradient_quad_verts(
2127    entry: &teksilo_canvas::PathEntry,
2128    placement: &crate::path_atlas::PathPlacement,
2129    scale_factor: f32,
2130    atlas_width: u32,
2131    atlas_height: u32,
2132    current_opacity: f32,
2133    transform: &Transform2D,
2134) -> [crate::vertex::PathGradientVertex; 4] {
2135    crate::vertex::PathGradientVertex::from_path_entry(
2136        entry,
2137        placement,
2138        scale_factor,
2139        atlas_width,
2140        atlas_height,
2141        current_opacity,
2142        transform,
2143    )
2144}
2145
2146fn pixel_to_ndc(pixel: [f32; 2], viewport_width: u32, viewport_height: u32) -> [f32; 2] {
2147    let x = (pixel[0] / viewport_width as f32) * 2.0 - 1.0;
2148    let y = 1.0 - (pixel[1] / viewport_height as f32) * 2.0; // flip Y
2149    [x, y]
2150}
2151
2152/// Apply a 2D affine transform to pixel coordinates.
2153fn apply_transform_pixel(pixel: [f32; 2], transform: &Transform2D) -> [f32; 2] {
2154    let [a, b, c, d, tx, ty] = transform.m;
2155    [
2156        a * pixel[0] + c * pixel[1] + tx,
2157        b * pixel[0] + d * pixel[1] + ty,
2158    ]
2159}
2160
2161/// Result of running the dual-Kawase chain on a `BlurScope`'s
2162/// intermediate. The returned texture is the final upsampled level —
2163/// it shares the same bucket-size convention as the input (only
2164/// `(used_w, used_h)` of `(bucket_w, bucket_h)` holds rendered
2165/// content), so the caller maps UVs as `used / bucket`.
2166struct KawaseResult {
2167    texture: crate::blur::AcquiredTexture,
2168    used_w: u32,
2169    used_h: u32,
2170    bucket_w: u32,
2171    bucket_h: u32,
2172}
2173
2174/// Run a dual-Kawase blur chain on `source`. The chain depth is chosen
2175/// from the requested radius; each pass halves (downsample) or doubles
2176/// (upsample) the active region's size. Returns the final upsampled
2177/// texture handle (which may be the input handle itself if the chain
2178/// is a single round-trip).
2179#[allow(clippy::too_many_arguments)]
2180fn run_kawase_chain(
2181    device: &wgpu::Device,
2182    queue: &wgpu::Queue,
2183    encoder: &mut wgpu::CommandEncoder,
2184    pool: &mut crate::blur::BlurPool,
2185    pipelines: &crate::blur::BlurPipelines,
2186    source: crate::blur::AcquiredTexture,
2187    used_w: u32,
2188    used_h: u32,
2189    bucket_w: u32,
2190    bucket_h: u32,
2191    radius_device_px: f32,
2192) -> KawaseResult {
2193    let levels = crate::blur::kawase_levels(radius_device_px);
2194
2195    // Track the chain as (handle, used_w, used_h, bucket_w, bucket_h).
2196    // Each downsample halves used_w/h; the bucket size we sample from
2197    // is the *previous* level's bucket.
2198    let mut current = (source, used_w, used_h, bucket_w, bucket_h);
2199
2200    // Upsample needs to know all intermediate bucket sizes so we can
2201    // walk back up. Stash one entry per chain level (input + each
2202    // downsample target).
2203    let mut chain: Vec<(crate::blur::AcquiredTexture, u32, u32, u32, u32)> =
2204        Vec::with_capacity(levels as usize + 1);
2205    chain.push(current);
2206
2207    // Per-pass kernel offset multiplier. Bjørge's reference uses 0.5
2208    // for both passes; the actual blur radius this produces is
2209    // proportional to `2^levels * 0.5`, which roughly matches the
2210    // requested Gaussian-equivalent radius for typical UI values.
2211    const KERNEL_OFFSET: f32 = 0.5;
2212
2213    // Downsample chain: source → mip1 → mip2 → ...
2214    for _ in 0..levels {
2215        let (src_handle, src_used_w, src_used_h, src_bucket_w, src_bucket_h) = current;
2216        let dst_used_w = (src_used_w / 2).max(1);
2217        let dst_used_h = (src_used_h / 2).max(1);
2218        let dst = pool.acquire(device, dst_used_w, dst_used_h);
2219        let (dst_bucket_w, dst_bucket_h) = pool.dimensions(dst);
2220
2221        // Build per-pass uniforms: source-bucket UV-offset.
2222        let params = crate::blur::BlurParams {
2223            offset: crate::blur::kawase_offset(src_bucket_w, src_bucket_h, KERNEL_OFFSET),
2224        };
2225        queue.write_buffer(&pipelines.params_buffer, 0, bytemuck::bytes_of(&params));
2226        let bind_group = pool.make_bind_group(device, src_handle, &pipelines.params_buffer);
2227
2228        run_kawase_pass(
2229            encoder,
2230            &pipelines.down,
2231            &bind_group,
2232            pool.view(dst),
2233            dst_used_w,
2234            dst_used_h,
2235            "kawase_down_pass",
2236        );
2237
2238        current = (dst, dst_used_w, dst_used_h, dst_bucket_w, dst_bucket_h);
2239        chain.push(current);
2240    }
2241
2242    // Upsample chain: mipN → mipN-1 → ... → mip0 (a fresh allocation;
2243    // we don't write back into the source texture because some Kawase
2244    // implementations rely on the source bucket's content surviving).
2245    for level in (0..levels).rev() {
2246        let (src_handle, _src_used_w, _src_used_h, src_bucket_w, src_bucket_h) = current;
2247        let target = chain[level as usize];
2248        let dst_used_w = target.1;
2249        let dst_used_h = target.2;
2250        let dst = pool.acquire(device, dst_used_w, dst_used_h);
2251        let (dst_bucket_w, dst_bucket_h) = pool.dimensions(dst);
2252
2253        let params = crate::blur::BlurParams {
2254            offset: crate::blur::kawase_offset(src_bucket_w, src_bucket_h, KERNEL_OFFSET),
2255        };
2256        queue.write_buffer(&pipelines.params_buffer, 0, bytemuck::bytes_of(&params));
2257        let bind_group = pool.make_bind_group(device, src_handle, &pipelines.params_buffer);
2258
2259        run_kawase_pass(
2260            encoder,
2261            &pipelines.up,
2262            &bind_group,
2263            pool.view(dst),
2264            dst_used_w,
2265            dst_used_h,
2266            "kawase_up_pass",
2267        );
2268
2269        current = (dst, dst_used_w, dst_used_h, dst_bucket_w, dst_bucket_h);
2270    }
2271
2272    KawaseResult {
2273        texture: current.0,
2274        used_w: current.1,
2275        used_h: current.2,
2276        bucket_w: current.3,
2277        bucket_h: current.4,
2278    }
2279}
2280
2281/// Run one full-screen-triangle Kawase pass. The viewport is set to
2282/// `(used_w, used_h)` — the destination bucket may be larger but we
2283/// only write the upper-left sub-rect that the next pass will sample
2284/// from.
2285fn run_kawase_pass(
2286    encoder: &mut wgpu::CommandEncoder,
2287    pipeline: &wgpu::RenderPipeline,
2288    bind_group: &wgpu::BindGroup,
2289    target_view: &wgpu::TextureView,
2290    used_w: u32,
2291    used_h: u32,
2292    label: &str,
2293) {
2294    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2295        label: Some(label),
2296        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2297            view: target_view,
2298            resolve_target: None,
2299            ops: wgpu::Operations {
2300                load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
2301                store: wgpu::StoreOp::Store,
2302            },
2303            depth_slice: None,
2304        })],
2305        depth_stencil_attachment: None,
2306        timestamp_writes: None,
2307        occlusion_query_set: None,
2308        multiview_mask: None,
2309    });
2310    pass.set_pipeline(pipeline);
2311    pass.set_bind_group(0, bind_group, &[]);
2312    // Full-screen triangle covers the whole viewport — restricting the
2313    // viewport to the used sub-rect keeps the over-allocated bucket
2314    // clean and (more importantly) limits the fragment work.
2315    pass.set_viewport(0.0, 0.0, used_w as f32, used_h as f32, 0.0, 1.0);
2316    pass.draw(0..3, 0..1);
2317}
2318
2319/// Composite the final blurred intermediate onto the parent target as
2320/// a textured quad at `bounds` (logical pixels). Uses the same quad
2321/// pipeline as static images: builds 4 vertices in NDC with image
2322/// flag set, binds the intermediate texture + sampler, and issues one
2323/// indexed draw.
2324///
2325/// `index_binding` is the per-frame index buffer (the first 6 u16s
2326/// already encode the standard quad index pattern, so we slice 12
2327/// bytes off the front).
2328#[allow(clippy::too_many_arguments)]
2329fn composite_blur_quad(
2330    device: &wgpu::Device,
2331    queue: &wgpu::Queue,
2332    pass: &mut wgpu::RenderPass<'_>,
2333    pool: &crate::blur::BlurPool,
2334    quad_pipeline: &wgpu::RenderPipeline,
2335    quad_bind_group_layout: &wgpu::BindGroupLayout,
2336    sampler: &wgpu::Sampler,
2337    quad_stream: &crate::stream_buffer::StreamBuffer,
2338    index_binding: Option<(&wgpu::Buffer, u64, u64)>,
2339    blurred: crate::blur::AcquiredTexture,
2340    used_w: u32,
2341    used_h: u32,
2342    bucket_w: u32,
2343    bucket_h: u32,
2344    bounds: teksilo_canvas::Rect,
2345    scale_factor: f32,
2346    viewport_width: u32,
2347    viewport_height: u32,
2348) {
2349    let view = pool.view(blurred);
2350    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
2351        label: Some("blur_composite_bind_group"),
2352        layout: quad_bind_group_layout,
2353        entries: &[
2354            wgpu::BindGroupEntry {
2355                binding: 0,
2356                resource: wgpu::BindingResource::TextureView(view),
2357            },
2358            wgpu::BindGroupEntry {
2359                binding: 1,
2360                resource: wgpu::BindingResource::Sampler(sampler),
2361            },
2362        ],
2363    });
2364
2365    // Vertex positions in device pixels, converted to NDC.
2366    let sx = bounds.x * scale_factor;
2367    let sy = bounds.y * scale_factor;
2368    let sw = bounds.width * scale_factor;
2369    let sh = bounds.height * scale_factor;
2370
2371    // UVs map the used sub-rect inside the bucket. The bucket's
2372    // upper-left holds the rendered content; the rest is the
2373    // cleared-to-transparent padding from the bucket's allocation.
2374    let u_max = used_w as f32 / bucket_w as f32;
2375    let v_max = used_h as f32 / bucket_h as f32;
2376
2377    // Image flag (bit 0 = 1 → fragment shader uses tex.rgb directly).
2378    let flags = 1u32;
2379    let color = [1.0, 1.0, 1.0, 1.0];
2380
2381    let p_tl = pixel_to_ndc([sx, sy], viewport_width, viewport_height);
2382    let p_tr = pixel_to_ndc([sx + sw, sy], viewport_width, viewport_height);
2383    let p_br = pixel_to_ndc([sx + sw, sy + sh], viewport_width, viewport_height);
2384    let p_bl = pixel_to_ndc([sx, sy + sh], viewport_width, viewport_height);
2385
2386    let verts: [QuadVertex; 4] = [
2387        QuadVertex {
2388            position: p_tl,
2389            tex_coord: [0.0, 0.0],
2390            color,
2391            flags,
2392            _pad: 0,
2393        },
2394        QuadVertex {
2395            position: p_tr,
2396            tex_coord: [u_max, 0.0],
2397            color,
2398            flags,
2399            _pad: 0,
2400        },
2401        QuadVertex {
2402            position: p_br,
2403            tex_coord: [u_max, v_max],
2404            color,
2405            flags,
2406            _pad: 0,
2407        },
2408        QuadVertex {
2409            position: p_bl,
2410            tex_coord: [0.0, v_max],
2411            color,
2412            flags,
2413            _pad: 0,
2414        },
2415    ];
2416
2417    // Caller has already sized `quad_stream` for the worst-case quad
2418    // count *including composites* (see render()'s up-front sizing).
2419    // The index buffer's first 6 u16s = `[0, 1, 2, 0, 2, 3]` (the
2420    // standard quad pattern), reused here.
2421    let _ = device; // device is only used for bind-group creation above
2422    let Some((vb, v_off, v_len)) = quad_stream.write(queue, bytemuck::cast_slice(&verts)) else {
2423        return;
2424    };
2425    let Some((ib, _, _)) = index_binding else {
2426        return;
2427    };
2428    let composite_index_bytes: u64 = 6 * std::mem::size_of::<u32>() as u64;
2429
2430    pass.set_pipeline(quad_pipeline);
2431    pass.set_bind_group(0, &bind_group, &[]);
2432    pass.set_viewport(
2433        0.0,
2434        0.0,
2435        viewport_width as f32,
2436        viewport_height as f32,
2437        0.0,
2438        1.0,
2439    );
2440    pass.set_vertex_buffer(0, vb.slice(v_off..v_off + v_len));
2441    pass.set_index_buffer(
2442        ib.slice(0..composite_index_bytes),
2443        wgpu::IndexFormat::Uint32,
2444    );
2445    pass.draw_indexed(0..6, 0, 0..1);
2446}
2447
2448// --- Pipeline creation ---
2449
2450fn create_rect_pipeline(
2451    device: &wgpu::Device,
2452    format: wgpu::TextureFormat,
2453) -> wgpu::RenderPipeline {
2454    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
2455        label: Some("rect_shader"),
2456        source: wgpu::ShaderSource::Wgsl(include_str!("shaders/rect.wgsl").into()),
2457    });
2458
2459    let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2460        label: Some("rect_pipeline_layout"),
2461        bind_group_layouts: &[],
2462        immediate_size: 0,
2463    });
2464
2465    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
2466        label: Some("rect_pipeline"),
2467        layout: Some(&layout),
2468        vertex: wgpu::VertexState {
2469            module: &shader,
2470            entry_point: Some("vs_main"),
2471            buffers: &[Some(wgpu::VertexBufferLayout {
2472                array_stride: std::mem::size_of::<RectVertex>() as u64,
2473                step_mode: wgpu::VertexStepMode::Vertex,
2474                attributes: &[
2475                    wgpu::VertexAttribute {
2476                        offset: 0,
2477                        shader_location: 0,
2478                        format: wgpu::VertexFormat::Float32x2,
2479                    },
2480                    wgpu::VertexAttribute {
2481                        offset: 8,
2482                        shader_location: 1,
2483                        format: wgpu::VertexFormat::Float32x4,
2484                    },
2485                ],
2486            })],
2487            compilation_options: Default::default(),
2488        },
2489        fragment: Some(wgpu::FragmentState {
2490            module: &shader,
2491            entry_point: Some("fs_main"),
2492            targets: &[Some(wgpu::ColorTargetState {
2493                format,
2494                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
2495                write_mask: wgpu::ColorWrites::ALL,
2496            })],
2497            compilation_options: Default::default(),
2498        }),
2499        primitive: wgpu::PrimitiveState {
2500            topology: wgpu::PrimitiveTopology::TriangleList,
2501            ..Default::default()
2502        },
2503        depth_stencil: None,
2504        multisample: wgpu::MultisampleState::default(),
2505        multiview_mask: None,
2506        cache: None,
2507    })
2508}
2509
2510fn create_sdf_pipeline(device: &wgpu::Device, format: wgpu::TextureFormat) -> wgpu::RenderPipeline {
2511    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
2512        label: Some("sdf_shader"),
2513        source: wgpu::ShaderSource::Wgsl(include_str!("shaders/sdf.wgsl").into()),
2514    });
2515
2516    let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2517        label: Some("sdf_pipeline_layout"),
2518        bind_group_layouts: &[],
2519        immediate_size: 0,
2520    });
2521
2522    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
2523        label: Some("sdf_pipeline"),
2524        layout: Some(&layout),
2525        vertex: wgpu::VertexState {
2526            module: &shader,
2527            entry_point: Some("vs_main"),
2528            buffers: &[Some(wgpu::VertexBufferLayout {
2529                array_stride: std::mem::size_of::<SdfVertex>() as u64,
2530                step_mode: wgpu::VertexStepMode::Vertex,
2531                attributes: &[
2532                    wgpu::VertexAttribute {
2533                        offset: 0,
2534                        shader_location: 0,
2535                        format: wgpu::VertexFormat::Float32x2, // position
2536                    },
2537                    wgpu::VertexAttribute {
2538                        offset: 8,
2539                        shader_location: 1,
2540                        format: wgpu::VertexFormat::Float32x2, // local_uv
2541                    },
2542                    wgpu::VertexAttribute {
2543                        offset: 16,
2544                        shader_location: 2,
2545                        format: wgpu::VertexFormat::Float32x4, // color
2546                    },
2547                    wgpu::VertexAttribute {
2548                        offset: 32,
2549                        shader_location: 3,
2550                        format: wgpu::VertexFormat::Float32x4, // corner_radii
2551                    },
2552                    wgpu::VertexAttribute {
2553                        offset: 48,
2554                        shader_location: 4,
2555                        format: wgpu::VertexFormat::Float32x4, // shape_params
2556                    },
2557                    wgpu::VertexAttribute {
2558                        offset: 64,
2559                        shader_location: 5,
2560                        format: wgpu::VertexFormat::Float32x4, // gradient_geo
2561                    },
2562                    wgpu::VertexAttribute {
2563                        offset: 80,
2564                        shader_location: 6,
2565                        format: wgpu::VertexFormat::Float32x4, // gradient_color0
2566                    },
2567                    wgpu::VertexAttribute {
2568                        offset: 96,
2569                        shader_location: 7,
2570                        format: wgpu::VertexFormat::Float32x4, // gradient_color1
2571                    },
2572                    wgpu::VertexAttribute {
2573                        offset: 112,
2574                        shader_location: 8,
2575                        format: wgpu::VertexFormat::Float32x4, // gradient_color2
2576                    },
2577                    wgpu::VertexAttribute {
2578                        offset: 128,
2579                        shader_location: 9,
2580                        format: wgpu::VertexFormat::Float32x4, // gradient_color3
2581                    },
2582                    wgpu::VertexAttribute {
2583                        offset: 144,
2584                        shader_location: 10,
2585                        format: wgpu::VertexFormat::Float32x4, // gradient_offsets
2586                    },
2587                ],
2588            })],
2589            compilation_options: Default::default(),
2590        },
2591        fragment: Some(wgpu::FragmentState {
2592            module: &shader,
2593            entry_point: Some("fs_main"),
2594            targets: &[Some(wgpu::ColorTargetState {
2595                format,
2596                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
2597                write_mask: wgpu::ColorWrites::ALL,
2598            })],
2599            compilation_options: Default::default(),
2600        }),
2601        primitive: wgpu::PrimitiveState {
2602            topology: wgpu::PrimitiveTopology::TriangleList,
2603            ..Default::default()
2604        },
2605        depth_stencil: None,
2606        multisample: wgpu::MultisampleState::default(),
2607        multiview_mask: None,
2608        cache: None,
2609    })
2610}
2611
2612fn create_quad_pipeline(
2613    device: &wgpu::Device,
2614    format: wgpu::TextureFormat,
2615) -> wgpu::RenderPipeline {
2616    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
2617        label: Some("quad_shader"),
2618        source: wgpu::ShaderSource::Wgsl(include_str!("shaders/quad.wgsl").into()),
2619    });
2620
2621    let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
2622        label: Some("quad_bind_group_layout"),
2623        entries: &[
2624            wgpu::BindGroupLayoutEntry {
2625                binding: 0,
2626                visibility: wgpu::ShaderStages::FRAGMENT,
2627                ty: wgpu::BindingType::Texture {
2628                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
2629                    view_dimension: wgpu::TextureViewDimension::D2,
2630                    multisampled: false,
2631                },
2632                count: None,
2633            },
2634            wgpu::BindGroupLayoutEntry {
2635                binding: 1,
2636                visibility: wgpu::ShaderStages::FRAGMENT,
2637                ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
2638                count: None,
2639            },
2640        ],
2641    });
2642
2643    let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2644        label: Some("quad_pipeline_layout"),
2645        bind_group_layouts: &[Some(&bind_group_layout)],
2646        immediate_size: 0,
2647    });
2648
2649    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
2650        label: Some("quad_pipeline"),
2651        layout: Some(&layout),
2652        vertex: wgpu::VertexState {
2653            module: &shader,
2654            entry_point: Some("vs_main"),
2655            buffers: &[Some(wgpu::VertexBufferLayout {
2656                array_stride: std::mem::size_of::<QuadVertex>() as u64,
2657                step_mode: wgpu::VertexStepMode::Vertex,
2658                attributes: &[
2659                    wgpu::VertexAttribute {
2660                        offset: 0,
2661                        shader_location: 0,
2662                        format: wgpu::VertexFormat::Float32x2, // position
2663                    },
2664                    wgpu::VertexAttribute {
2665                        offset: 8,
2666                        shader_location: 1,
2667                        format: wgpu::VertexFormat::Float32x2, // tex_coord
2668                    },
2669                    wgpu::VertexAttribute {
2670                        offset: 16,
2671                        shader_location: 2,
2672                        format: wgpu::VertexFormat::Float32x4, // color
2673                    },
2674                    wgpu::VertexAttribute {
2675                        offset: 32,
2676                        shader_location: 3,
2677                        format: wgpu::VertexFormat::Uint32, // flags (bit 0 = color glyph)
2678                    },
2679                ],
2680            })],
2681            compilation_options: Default::default(),
2682        },
2683        fragment: Some(wgpu::FragmentState {
2684            module: &shader,
2685            entry_point: Some("fs_main"),
2686            targets: &[Some(wgpu::ColorTargetState {
2687                format,
2688                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
2689                write_mask: wgpu::ColorWrites::ALL,
2690            })],
2691            compilation_options: Default::default(),
2692        }),
2693        primitive: wgpu::PrimitiveState {
2694            topology: wgpu::PrimitiveTopology::TriangleList,
2695            ..Default::default()
2696        },
2697        depth_stencil: None,
2698        multisample: wgpu::MultisampleState::default(),
2699        multiview_mask: None,
2700        cache: None,
2701    })
2702}
2703
2704/// Build the gradient-filled path pipeline (Tier 3, gradient paint
2705/// only). Reuses `texture_bind_group_layout` — the SAME group(0) layout
2706/// the `quad_pipeline` exposes (texture + sampler) — as its own group 0,
2707/// so the path atlas's bind group (built once, shared with the solid
2708/// path quad batch) binds unchanged for both pipelines.
2709fn create_path_gradient_pipeline(
2710    device: &wgpu::Device,
2711    format: wgpu::TextureFormat,
2712    texture_bind_group_layout: &wgpu::BindGroupLayout,
2713) -> wgpu::RenderPipeline {
2714    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
2715        label: Some("path_gradient_shader"),
2716        source: wgpu::ShaderSource::Wgsl(include_str!("shaders/path_gradient.wgsl").into()),
2717    });
2718
2719    let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2720        label: Some("path_gradient_pipeline_layout"),
2721        bind_group_layouts: &[Some(texture_bind_group_layout)],
2722        immediate_size: 0,
2723    });
2724
2725    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
2726        label: Some("path_gradient_pipeline"),
2727        layout: Some(&layout),
2728        vertex: wgpu::VertexState {
2729            module: &shader,
2730            entry_point: Some("vs_main"),
2731            buffers: &[Some(wgpu::VertexBufferLayout {
2732                array_stride: std::mem::size_of::<crate::vertex::PathGradientVertex>() as u64,
2733                step_mode: wgpu::VertexStepMode::Vertex,
2734                attributes: &[
2735                    wgpu::VertexAttribute {
2736                        offset: 0,
2737                        shader_location: 0,
2738                        format: wgpu::VertexFormat::Float32x2, // position
2739                    },
2740                    wgpu::VertexAttribute {
2741                        offset: 8,
2742                        shader_location: 1,
2743                        format: wgpu::VertexFormat::Float32x2, // tex_coord
2744                    },
2745                    wgpu::VertexAttribute {
2746                        offset: 16,
2747                        shader_location: 2,
2748                        format: wgpu::VertexFormat::Float32x2, // local_uv
2749                    },
2750                    wgpu::VertexAttribute {
2751                        offset: 24,
2752                        shader_location: 3,
2753                        format: wgpu::VertexFormat::Uint32, // paint_type
2754                    },
2755                    // Offset 28 (_pad: u32) is skipped — no attribute.
2756                    wgpu::VertexAttribute {
2757                        offset: 32,
2758                        shader_location: 4,
2759                        format: wgpu::VertexFormat::Float32x4, // gradient_geo
2760                    },
2761                    wgpu::VertexAttribute {
2762                        offset: 48,
2763                        shader_location: 5,
2764                        format: wgpu::VertexFormat::Float32x4, // gradient_color0
2765                    },
2766                    wgpu::VertexAttribute {
2767                        offset: 64,
2768                        shader_location: 6,
2769                        format: wgpu::VertexFormat::Float32x4, // gradient_color1
2770                    },
2771                    wgpu::VertexAttribute {
2772                        offset: 80,
2773                        shader_location: 7,
2774                        format: wgpu::VertexFormat::Float32x4, // gradient_color2
2775                    },
2776                    wgpu::VertexAttribute {
2777                        offset: 96,
2778                        shader_location: 8,
2779                        format: wgpu::VertexFormat::Float32x4, // gradient_color3
2780                    },
2781                    wgpu::VertexAttribute {
2782                        offset: 112,
2783                        shader_location: 9,
2784                        format: wgpu::VertexFormat::Float32x4, // gradient_offsets
2785                    },
2786                ],
2787            })],
2788            compilation_options: Default::default(),
2789        },
2790        fragment: Some(wgpu::FragmentState {
2791            module: &shader,
2792            entry_point: Some("fs_main"),
2793            targets: &[Some(wgpu::ColorTargetState {
2794                format,
2795                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
2796                write_mask: wgpu::ColorWrites::ALL,
2797            })],
2798            compilation_options: Default::default(),
2799        }),
2800        primitive: wgpu::PrimitiveState {
2801            topology: wgpu::PrimitiveTopology::TriangleList,
2802            ..Default::default()
2803        },
2804        depth_stencil: None,
2805        multisample: wgpu::MultisampleState::default(),
2806        multiview_mask: None,
2807        cache: None,
2808    })
2809}
2810
2811fn create_shadow_pipeline(
2812    device: &wgpu::Device,
2813    format: wgpu::TextureFormat,
2814) -> wgpu::RenderPipeline {
2815    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
2816        label: Some("shadow_shader"),
2817        source: wgpu::ShaderSource::Wgsl(include_str!("shaders/shadow.wgsl").into()),
2818    });
2819
2820    let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2821        label: Some("shadow_pipeline_layout"),
2822        bind_group_layouts: &[],
2823        immediate_size: 0,
2824    });
2825
2826    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
2827        label: Some("shadow_pipeline"),
2828        layout: Some(&layout),
2829        vertex: wgpu::VertexState {
2830            module: &shader,
2831            entry_point: Some("vs_main"),
2832            buffers: &[Some(wgpu::VertexBufferLayout {
2833                array_stride: std::mem::size_of::<ShadowVertex>() as u64,
2834                step_mode: wgpu::VertexStepMode::Vertex,
2835                attributes: &[
2836                    wgpu::VertexAttribute {
2837                        offset: 0,
2838                        shader_location: 0,
2839                        format: wgpu::VertexFormat::Float32x2, // position
2840                    },
2841                    wgpu::VertexAttribute {
2842                        offset: 8,
2843                        shader_location: 1,
2844                        format: wgpu::VertexFormat::Float32x2, // local_uv
2845                    },
2846                    wgpu::VertexAttribute {
2847                        offset: 16,
2848                        shader_location: 2,
2849                        format: wgpu::VertexFormat::Float32x4, // shadow_color
2850                    },
2851                    wgpu::VertexAttribute {
2852                        offset: 32,
2853                        shader_location: 3,
2854                        format: wgpu::VertexFormat::Float32x4, // corner_radii
2855                    },
2856                    wgpu::VertexAttribute {
2857                        offset: 48,
2858                        shader_location: 4,
2859                        format: wgpu::VertexFormat::Float32x4, // shadow_params
2860                    },
2861                    wgpu::VertexAttribute {
2862                        offset: 64,
2863                        shader_location: 5,
2864                        format: wgpu::VertexFormat::Float32x4, // shape_offset
2865                    },
2866                ],
2867            })],
2868            compilation_options: Default::default(),
2869        },
2870        fragment: Some(wgpu::FragmentState {
2871            module: &shader,
2872            entry_point: Some("fs_main"),
2873            targets: &[Some(wgpu::ColorTargetState {
2874                format,
2875                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
2876                write_mask: wgpu::ColorWrites::ALL,
2877            })],
2878            compilation_options: Default::default(),
2879        }),
2880        primitive: wgpu::PrimitiveState {
2881            topology: wgpu::PrimitiveTopology::TriangleList,
2882            ..Default::default()
2883        },
2884        depth_stencil: None,
2885        multisample: wgpu::MultisampleState::default(),
2886        multiview_mask: None,
2887        cache: None,
2888    })
2889}
2890
2891/// Per-pipeline quad counts for one frame's stream-buffer sizing.
2892///
2893/// Upper bound per pipeline = `quads * 4 vertices` because every
2894/// drawable produces exactly 4 vertices. Every count here must match
2895/// what the draw walk actually writes into the corresponding
2896/// [`StreamBuffer`](crate::stream_buffer::StreamBuffer) — an
2897/// undercount overflows the buffer at write time (debug assert +
2898/// dropped draws; see `StreamBuffer::write`). Kept as a pure function
2899/// of the frame so the accounting is unit-testable headlessly (the
2900/// GPU path has no headless coverage).
2901#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2902pub(crate) struct StreamQuadCounts {
2903    pub rect: usize,
2904    pub sdf: usize,
2905    pub quad: usize,
2906    pub shadow: usize,
2907    pub anim_proc: usize,
2908    /// Gradient-filled path quads (Tier 3, `path_gradient_pipeline`).
2909    /// Split out of `quad` — see `stream_quad_counts`.
2910    pub path_gradient: usize,
2911}
2912
2913impl StreamQuadCounts {
2914    /// The largest per-pipeline count — sizes the shared index buffer
2915    /// so one index stream serves all pipelines.
2916    pub fn max(&self) -> usize {
2917        self.rect
2918            .max(self.sdf)
2919            .max(self.quad)
2920            .max(self.shadow)
2921            .max(self.anim_proc)
2922            .max(self.path_gradient)
2923    }
2924}
2925
2926/// Count the quads each pipeline's stream buffer must hold for `frame`.
2927///
2928/// - `rect` draws both `DrawCommand::Decoration` (Tier-1 rects) AND
2929///   `DrawCommand::CosmeticLine` (each hairline emits one 4-vertex quad
2930///   through the same rect stream — see the CosmeticLine arm in the
2931///   draw walk).
2932/// - `quad` covers glyphs, SOLID-filled paths, images, plus one
2933///   composite-blit quad per blur scope (`BeginBlurredSubtree`), emitted
2934///   on End. Gradient-filled paths are split out into `path_gradient`
2935///   instead (see below) — they draw through a different pipeline.
2936/// - `anim_proc` covers BOTH animated-quad classes: `Procedural` quads
2937///   batch into `anim_proc_batch`, but `Sprite` quads ALSO write their
2938///   4 vertices into the same `streams.anim_proc` buffer (one
2939///   individually-bound draw each). Counting only `Procedural` here
2940///   undersized the buffer whenever a sprite-animated icon was on
2941///   screen, overflowing the stream at write time.
2942/// - `path_gradient` covers `PathEntry`s whose `paint_data` is a
2943///   gradient variant (`LinearGradient`/`RadialGradient`/`ConicGradient`)
2944///   — drawn by the dedicated `path_gradient_pipeline` instead of the
2945///   shared `quad_pipeline`. Solid paths (`PaintData::Solid`, including
2946///   every stroke) stay counted under `quad`.
2947pub(crate) fn stream_quad_counts(frame: &RenderFrame) -> StreamQuadCounts {
2948    let composite_quads = frame
2949        .draw_order
2950        .iter()
2951        .filter(|c| matches!(c, teksilo_canvas::DrawCommand::BeginBlurredSubtree { .. }))
2952        .count();
2953    let gradient_paths = frame
2954        .paths
2955        .iter()
2956        .filter(|p| !matches!(p.paint_data, teksilo_canvas::PaintData::Solid))
2957        .count();
2958    let solid_paths = frame.paths.len() - gradient_paths;
2959    StreamQuadCounts {
2960        rect: frame.decorations.len() + frame.cosmetic_lines.len(),
2961        sdf: frame.shapes.len(),
2962        quad: frame.glyphs.len() + solid_paths + frame.images.len() + composite_quads,
2963        shadow: frame.shadows.len(),
2964        anim_proc: frame.animated_quads.len(),
2965        path_gradient: gradient_paths,
2966    }
2967}
2968
2969/// Build the procedural-animation pipeline plus its per-slot uniform
2970/// buffer, bind group, and bind-group layout. The layout is returned
2971/// so the sprite pipeline can reuse it as its `group 0`. Buffer is
2972/// sized for [`MAX_ANIM_SLOTS`] × `size_of::<teksilo_canvas::AnimParams>()`;
2973/// the tree's registry truncates writes past that cap.
2974fn create_anim_proc_pipeline(
2975    device: &wgpu::Device,
2976    format: wgpu::TextureFormat,
2977) -> (
2978    wgpu::RenderPipeline,
2979    wgpu::Buffer,
2980    wgpu::BindGroup,
2981    wgpu::BindGroupLayout,
2982) {
2983    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
2984        label: Some("anim_procedural_shader"),
2985        source: wgpu::ShaderSource::Wgsl(include_str!("shaders/anim_procedural.wgsl").into()),
2986    });
2987
2988    let buffer_size = (MAX_ANIM_SLOTS * std::mem::size_of::<teksilo_canvas::AnimParams>()) as u64;
2989    let anim_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
2990        label: Some("anim_uniform_buffer"),
2991        size: buffer_size,
2992        usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2993        mapped_at_creation: false,
2994    });
2995
2996    let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
2997        label: Some("anim_uniform_bind_group_layout"),
2998        entries: &[wgpu::BindGroupLayoutEntry {
2999            binding: 0,
3000            visibility: wgpu::ShaderStages::FRAGMENT,
3001            ty: wgpu::BindingType::Buffer {
3002                ty: wgpu::BufferBindingType::Uniform,
3003                has_dynamic_offset: false,
3004                min_binding_size: None,
3005            },
3006            count: None,
3007        }],
3008    });
3009
3010    let anim_uniform_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
3011        label: Some("anim_uniform_bind_group"),
3012        layout: &bind_group_layout,
3013        entries: &[wgpu::BindGroupEntry {
3014            binding: 0,
3015            resource: anim_uniform_buffer.as_entire_binding(),
3016        }],
3017    });
3018
3019    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
3020        label: Some("anim_proc_pipeline_layout"),
3021        bind_group_layouts: &[Some(&bind_group_layout)],
3022        immediate_size: 0,
3023    });
3024
3025    let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
3026        label: Some("anim_proc_pipeline"),
3027        layout: Some(&pipeline_layout),
3028        vertex: wgpu::VertexState {
3029            module: &shader,
3030            entry_point: Some("vs_main"),
3031            buffers: &[Some(anim_quad_vertex_layout())],
3032            compilation_options: Default::default(),
3033        },
3034        fragment: Some(wgpu::FragmentState {
3035            module: &shader,
3036            entry_point: Some("fs_main"),
3037            targets: &[Some(wgpu::ColorTargetState {
3038                format,
3039                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
3040                write_mask: wgpu::ColorWrites::ALL,
3041            })],
3042            compilation_options: Default::default(),
3043        }),
3044        primitive: wgpu::PrimitiveState {
3045            topology: wgpu::PrimitiveTopology::TriangleList,
3046            ..Default::default()
3047        },
3048        depth_stencil: None,
3049        multisample: wgpu::MultisampleState::default(),
3050        multiview_mask: None,
3051        cache: None,
3052    });
3053
3054    (
3055        pipeline,
3056        anim_uniform_buffer,
3057        anim_uniform_bind_group,
3058        bind_group_layout,
3059    )
3060}
3061
3062/// Build the sprite-atlas animation pipeline. Shares group 0 (the
3063/// per-slot uniform buffer) with the procedural pipeline; adds group
3064/// 1 = sprite atlas texture + sampler, resolved per-draw via
3065/// `ImageManager::get_bind_group(image_name)`. Returns the pipeline
3066/// and the texture bind-group layout (so `ImageManager` can register
3067/// images under the same layout).
3068fn create_anim_sprite_pipeline(
3069    device: &wgpu::Device,
3070    format: wgpu::TextureFormat,
3071    uniform_layout: &wgpu::BindGroupLayout,
3072    texture_layout: &wgpu::BindGroupLayout,
3073) -> wgpu::RenderPipeline {
3074    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
3075        label: Some("anim_sprite_shader"),
3076        source: wgpu::ShaderSource::Wgsl(include_str!("shaders/anim_sprite.wgsl").into()),
3077    });
3078
3079    let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
3080        label: Some("anim_sprite_pipeline_layout"),
3081        bind_group_layouts: &[Some(uniform_layout), Some(texture_layout)],
3082        immediate_size: 0,
3083    });
3084
3085    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
3086        label: Some("anim_sprite_pipeline"),
3087        layout: Some(&pipeline_layout),
3088        vertex: wgpu::VertexState {
3089            module: &shader,
3090            entry_point: Some("vs_main"),
3091            buffers: &[Some(anim_quad_vertex_layout())],
3092            compilation_options: Default::default(),
3093        },
3094        fragment: Some(wgpu::FragmentState {
3095            module: &shader,
3096            entry_point: Some("fs_main"),
3097            targets: &[Some(wgpu::ColorTargetState {
3098                format,
3099                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
3100                write_mask: wgpu::ColorWrites::ALL,
3101            })],
3102            compilation_options: Default::default(),
3103        }),
3104        primitive: wgpu::PrimitiveState {
3105            topology: wgpu::PrimitiveTopology::TriangleList,
3106            ..Default::default()
3107        },
3108        depth_stencil: None,
3109        multisample: wgpu::MultisampleState::default(),
3110        multiview_mask: None,
3111        cache: None,
3112    })
3113}
3114
3115/// Vertex buffer layout shared by both animated-quad pipelines.
3116fn anim_quad_vertex_layout() -> wgpu::VertexBufferLayout<'static> {
3117    const ATTRS: [wgpu::VertexAttribute; 3] = [
3118        wgpu::VertexAttribute {
3119            offset: 0,
3120            shader_location: 0,
3121            format: wgpu::VertexFormat::Float32x2,
3122        },
3123        wgpu::VertexAttribute {
3124            offset: 8,
3125            shader_location: 1,
3126            format: wgpu::VertexFormat::Float32x2,
3127        },
3128        wgpu::VertexAttribute {
3129            offset: 16,
3130            shader_location: 2,
3131            format: wgpu::VertexFormat::Uint32,
3132        },
3133    ];
3134    wgpu::VertexBufferLayout {
3135        array_stride: std::mem::size_of::<AnimQuadVertex>() as u64,
3136        step_mode: wgpu::VertexStepMode::Vertex,
3137        attributes: &ATTRS,
3138    }
3139}
3140
3141#[cfg(test)]
3142mod tests {
3143    use teksilo_canvas::RenderFrame;
3144    use teksilo_canvas::render_frame::{DrawCommand, GlyphQuad, PaintData, ShapeKind, ShapeQuad};
3145
3146    use super::*;
3147
3148    #[test]
3149    fn stream_quad_counts_includes_sprite_anim_quads() {
3150        // Regression test for the anim_proc undercount: Sprite-class
3151        // animated quads write 4 vertices into the SAME stream buffer
3152        // as Procedural ones (each sprite draws individually, but the
3153        // bytes land in `streams.anim_proc`). Sizing for Procedural
3154        // only overflowed the stream whenever a sprite-animated icon
3155        // was on screen.
3156        use teksilo_canvas::render_frame::{AnimatedQuadClass, AnimatedQuadDraw};
3157
3158        let mut frame = RenderFrame::new();
3159        for slot in 0..3 {
3160            frame.animated_quads.push(AnimatedQuadDraw {
3161                screen: [0.0, 0.0, 10.0, 10.0],
3162                slot,
3163                class: AnimatedQuadClass::Procedural,
3164            });
3165        }
3166        for slot in 3..5 {
3167            frame.animated_quads.push(AnimatedQuadDraw {
3168                screen: [0.0, 0.0, 10.0, 10.0],
3169                slot,
3170                class: AnimatedQuadClass::Sprite {
3171                    image_name: "icon".to_string(),
3172                },
3173            });
3174        }
3175        frame.glyphs.push(GlyphQuad {
3176            screen: [0.0, 0.0, 8.0, 8.0],
3177            atlas: [0.0, 0.0, 2.0, 2.0],
3178            color: [1.0; 4],
3179            is_color: false,
3180        });
3181
3182        let counts = stream_quad_counts(&frame);
3183        assert_eq!(
3184            counts.anim_proc, 5,
3185            "anim_proc stream must be sized for BOTH Procedural and Sprite quads"
3186        );
3187        assert_eq!(counts.quad, 1);
3188        assert_eq!(counts.rect, 0);
3189        assert_eq!(counts.sdf, 0);
3190        assert_eq!(counts.shadow, 0);
3191        assert_eq!(counts.max(), 5, "index buffer sizes to the largest stream");
3192    }
3193
3194    #[test]
3195    fn stream_quad_counts_splits_solid_and_gradient_paths() {
3196        // C4.5: gradient-filled paths draw through a different pipeline
3197        // (`path_gradient_pipeline`) than solid-filled ones (which stay
3198        // on `quad_pipeline`), so the two must size DIFFERENT stream
3199        // buffers — undercounting either overflows its `StreamBuffer`
3200        // at write time (see `StreamBuffer::write`'s debug_assert).
3201        use teksilo_canvas::render_frame::PathEntry;
3202        use teksilo_canvas::{FillRule, GradientStop, Path, StrokeStyle};
3203        use teksilo_tokens::Color;
3204
3205        let mut frame = RenderFrame::new();
3206        frame.paths.push(PathEntry {
3207            path: Path::new(),
3208            color: [1.0, 0.0, 0.0, 1.0],
3209            stroke_style: StrokeStyle::solid(0.0),
3210            fill_rule: FillRule::Winding,
3211            bounds: [0.0, 0.0, 10.0, 10.0],
3212            paint_data: PaintData::Solid,
3213        });
3214        frame.paths.push(PathEntry {
3215            path: Path::new(),
3216            color: [1.0, 1.0, 1.0, 1.0],
3217            stroke_style: StrokeStyle::solid(0.0),
3218            fill_rule: FillRule::Winding,
3219            bounds: [0.0, 0.0, 20.0, 20.0],
3220            paint_data: PaintData::LinearGradient {
3221                start: [0.0, 0.0],
3222                end: [20.0, 0.0],
3223                stops: vec![
3224                    GradientStop {
3225                        offset: 0.0,
3226                        color: Color::RED,
3227                    },
3228                    GradientStop {
3229                        offset: 1.0,
3230                        color: Color::BLUE,
3231                    },
3232                ],
3233            },
3234        });
3235
3236        let counts = stream_quad_counts(&frame);
3237        assert_eq!(counts.quad, 1, "the solid path counts toward quad");
3238        assert_eq!(
3239            counts.path_gradient, 1,
3240            "the gradient path counts toward path_gradient, not quad"
3241        );
3242        assert_eq!(counts.rect, 0);
3243        assert_eq!(counts.sdf, 0);
3244        assert_eq!(counts.shadow, 0);
3245        assert_eq!(counts.anim_proc, 0);
3246        assert_eq!(counts.max(), 1);
3247    }
3248
3249    #[test]
3250    fn gradient_path_renders_nonflat_on_gpu() {
3251        // #12 end-to-end GPU verification: a gradient-filled Tier-3 path must
3252        // flush through the dedicated `path_gradient` pipeline and produce a
3253        // real gradient (not a flat tint) on an actual device — and without
3254        // tripping `StreamBuffer::write`'s capacity debug_assert. This is the
3255        // one property headless-CPU tests structurally cannot prove; it needs
3256        // a real device + pixel readback.
3257        use teksilo_canvas::render_frame::PathEntry;
3258        use teksilo_canvas::{FillRule, GradientStop, Path, Rect, StrokeStyle};
3259        use teksilo_tokens::Color;
3260
3261        let Some((mut renderer, device, queue)) = pollster::block_on(
3262            crate::test_support::create_test_renderer("teksilo_render_gradient_path_device"),
3263        ) else {
3264            return; // no GPU adapter (headless CI) — skip.
3265        };
3266
3267        // A filled 30×30 square, horizontally red (left) → blue (right).
3268        let path = Path::rect(Rect::new(1.0, 1.0, 30.0, 30.0));
3269        let bounds = path.bounds();
3270        let mut frame = RenderFrame::new();
3271        frame.paths.push(PathEntry {
3272            path,
3273            color: [1.0, 1.0, 1.0, 1.0],
3274            stroke_style: StrokeStyle::solid(0.0),
3275            fill_rule: FillRule::Winding,
3276            bounds: [bounds.x, bounds.y, bounds.width, bounds.height],
3277            paint_data: PaintData::LinearGradient {
3278                start: [bounds.x, bounds.y],
3279                end: [bounds.x + bounds.width, bounds.y],
3280                stops: vec![
3281                    GradientStop {
3282                        offset: 0.0,
3283                        color: Color::RED,
3284                    },
3285                    GradientStop {
3286                        offset: 1.0,
3287                        color: Color::BLUE,
3288                    },
3289                ],
3290            },
3291        });
3292        frame.draw_order.push(DrawCommand::Path(0));
3293
3294        let texture = device.create_texture(&wgpu::TextureDescriptor {
3295            label: Some("teksilo_render_gradient_path_target"),
3296            size: wgpu::Extent3d {
3297                width: 32,
3298                height: 32,
3299                depth_or_array_layers: 1,
3300            },
3301            mip_level_count: 1,
3302            sample_count: 1,
3303            dimension: wgpu::TextureDimension::D2,
3304            format: wgpu::TextureFormat::Rgba8UnormSrgb,
3305            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
3306            view_formats: &[],
3307        });
3308        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
3309
3310        // Reaching here without a panic means the gradient batch flushed
3311        // without a `StreamBuffer` capacity overflow (the debug_assert the
3312        // count-split guards).
3313        renderer.render(&frame, &view, 1.0, 32, 32, [0.0, 0.0, 0.0, 0.0]);
3314
3315        let pixels = crate::test_support::read_texture_rgba(&device, &queue, &texture, 32, 32);
3316        let px = |x: usize, y: usize| {
3317            let i = (y * 32 + x) * 4;
3318            [pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]]
3319        };
3320        // Sample a row through the middle: near the red edge and the blue edge.
3321        let left = px(4, 16);
3322        let right = px(27, 16);
3323
3324        assert!(
3325            left[3] > 200 && right[3] > 200,
3326            "gradient square not covered (coverage-mask atlas broken): left={left:?} right={right:?}"
3327        );
3328        // Left red-dominant, right blue-dominant, ends clearly different — a
3329        // real interpolated gradient, not a single flat tint.
3330        assert!(
3331            left[0] as i32 > left[2] as i32 + 40,
3332            "left edge must be red-dominant, got {left:?}"
3333        );
3334        assert!(
3335            right[2] as i32 > right[0] as i32 + 40,
3336            "right edge must be blue-dominant, got {right:?}"
3337        );
3338        assert!(
3339            (left[0] as i32 - right[0] as i32).abs() > 60,
3340            "gradient looks flat (shader not sampling the gradient): left={left:?} right={right:?}"
3341        );
3342    }
3343
3344    #[test]
3345    fn gradient_path_partial_alpha_preserved() {
3346        // Regression for washed-out gradient fills: a gradient stop's alpha
3347        // must survive the path_gradient pipeline. Render a horizontal
3348        // green→green gradient whose LEFT stop is opaque (a=1.0) and RIGHT
3349        // stop is a=0.4, over a TRANSPARENT clear so the read-back alpha IS
3350        // the fill's alpha (no gamma/compositing confound). Left must stay
3351        // ~opaque, right must read ~0.4 (not ~0.24).
3352        use teksilo_canvas::render_frame::PathEntry;
3353        use teksilo_canvas::{FillRule, GradientStop, Path, Rect, StrokeStyle};
3354        use teksilo_tokens::Color;
3355
3356        let Some((mut renderer, device, queue)) = pollster::block_on(
3357            crate::test_support::create_test_renderer("teksilo_render_partial_alpha_device"),
3358        ) else {
3359            return;
3360        };
3361
3362        let path = Path::rect(Rect::new(0.0, 0.0, 32.0, 32.0));
3363        let bounds = path.bounds();
3364        let mut frame = RenderFrame::new();
3365        frame.paths.push(PathEntry {
3366            path,
3367            color: [1.0, 1.0, 1.0, 1.0],
3368            stroke_style: StrokeStyle::solid(0.0),
3369            fill_rule: FillRule::Winding,
3370            bounds: [bounds.x, bounds.y, bounds.width, bounds.height],
3371            paint_data: PaintData::LinearGradient {
3372                start: [0.0, 0.0],
3373                end: [32.0, 0.0],
3374                stops: vec![
3375                    GradientStop {
3376                        offset: 0.0,
3377                        color: Color::from_rgba(0.0, 0.62, 0.45, 1.0),
3378                    },
3379                    GradientStop {
3380                        offset: 1.0,
3381                        color: Color::from_rgba(0.0, 0.62, 0.45, 0.4),
3382                    },
3383                ],
3384            },
3385        });
3386        frame.draw_order.push(DrawCommand::Path(0));
3387
3388        let texture = device.create_texture(&wgpu::TextureDescriptor {
3389            label: Some("partial_alpha_target"),
3390            size: wgpu::Extent3d {
3391                width: 32,
3392                height: 32,
3393                depth_or_array_layers: 1,
3394            },
3395            mip_level_count: 1,
3396            sample_count: 1,
3397            dimension: wgpu::TextureDimension::D2,
3398            format: wgpu::TextureFormat::Rgba8UnormSrgb,
3399            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
3400            view_formats: &[],
3401        });
3402        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
3403        renderer.render(&frame, &view, 1.0, 32, 32, [0.0, 0.0, 0.0, 0.0]);
3404
3405        let px = crate::test_support::read_texture_rgba(&device, &queue, &texture, 32, 32);
3406        let alpha = |x: usize| px[(16 * 32 + x) * 4 + 3];
3407        let (left, right) = (alpha(2), alpha(29));
3408        // Diagnostic — surfaced on failure.
3409        assert!(
3410            left >= 240,
3411            "opaque (a=1.0) end must stay opaque, got {left} (/255)"
3412        );
3413        assert!(
3414            (90..=115).contains(&right),
3415            "a=0.4 stop must read ~102/255, got {right} — a value near ~61 means the \
3416             pipeline under-renders gradient stop alpha (washed-out fills)"
3417        );
3418    }
3419
3420    #[test]
3421    fn glyph_quad_renders_over_shape_in_offscreen_target() {
3422        let Some((mut renderer, device, queue)) = pollster::block_on(
3423            crate::test_support::create_test_renderer("teksilo_render_test_device"),
3424        ) else {
3425            return;
3426        };
3427
3428        renderer.upload_atlas(
3429            2,
3430            2,
3431            &[
3432                255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
3433            ],
3434        );
3435
3436        let mut frame = RenderFrame::new();
3437        frame.shapes.push(ShapeQuad {
3438            screen: [4.0, 4.0, 24.0, 24.0],
3439            color: [0.2, 0.6, 0.9, 1.0],
3440            shape: ShapeKind::RoundedRect,
3441            stroke_width: 0.0,
3442            stroke_space: teksilo_canvas::StrokeSpace::Logical,
3443            corner_radii: [0.0; 4],
3444            paint_data: PaintData::Solid,
3445        });
3446        frame.draw_order.push(DrawCommand::Shape(0));
3447
3448        frame.glyphs.push(GlyphQuad {
3449            screen: [10.0, 10.0, 8.0, 8.0],
3450            atlas: [0.0, 0.0, 2.0, 2.0],
3451            color: [1.0, 1.0, 1.0, 1.0],
3452            is_color: false,
3453        });
3454        frame.draw_order.push(DrawCommand::Glyph(0));
3455
3456        let texture = device.create_texture(&wgpu::TextureDescriptor {
3457            label: Some("teksilo_render_test_target"),
3458            size: wgpu::Extent3d {
3459                width: 32,
3460                height: 32,
3461                depth_or_array_layers: 1,
3462            },
3463            mip_level_count: 1,
3464            sample_count: 1,
3465            dimension: wgpu::TextureDimension::D2,
3466            format: wgpu::TextureFormat::Rgba8UnormSrgb,
3467            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
3468            view_formats: &[],
3469        });
3470        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
3471
3472        renderer.render(&frame, &view, 1.0, 32, 32, [0.0, 0.0, 0.0, 0.0]);
3473
3474        let pixels = crate::test_support::read_texture_rgba(&device, &queue, &texture, 32, 32);
3475        let center = ((14 * 32 + 14) * 4) as usize;
3476        let blue_only = [
3477            pixels[center],
3478            pixels[center + 1],
3479            pixels[center + 2],
3480            pixels[center + 3],
3481        ];
3482
3483        assert!(
3484            blue_only[0] > 200 && blue_only[1] > 200 && blue_only[2] > 200,
3485            "expected glyph pixel to be visible over shape, got {:?}",
3486            blue_only
3487        );
3488    }
3489
3490    #[test]
3491    fn fractional_origin_glyph_renders_pixel_exact() {
3492        // Regression test for the linear-sampler blur / bottom-row crop:
3493        // glyph origins are fractional (shaping advances, scroll), and
3494        // with a bilinear atlas sampler an unsnapped 1:1 quad feathers
3495        // every edge and fades its last bitmap row into the transparent
3496        // atlas gutter (visibly cropping the bottom of "c"/"e"). The
3497        // pixel snap in `from_glyph_quad_transformed` must land the quad
3498        // on the integer grid so linear sampling is exact: interior
3499        // pixels fully opaque, surrounding pixels fully transparent.
3500        let Some((mut renderer, device, queue)) = pollster::block_on(
3501            crate::test_support::create_test_renderer("teksilo_render_snap_test_device"),
3502        ) else {
3503            return;
3504        };
3505
3506        // 4×4 atlas: a 3×3 fully-opaque white glyph bitmap at (0,0); the
3507        // remaining row/column transparent (the allocator's 1px gutter).
3508        let mut atlas = [0u8; 4 * 4 * 4];
3509        for y in 0..3 {
3510            for x in 0..3 {
3511                let i = (y * 4 + x) * 4;
3512                atlas[i..i + 4].copy_from_slice(&[255, 255, 255, 255]);
3513            }
3514        }
3515        renderer.upload_atlas(4, 4, &atlas);
3516
3517        let mut frame = RenderFrame::new();
3518        // Fractional origin; the snap lands it at (10, 11).
3519        frame.glyphs.push(GlyphQuad {
3520            screen: [10.4, 10.6, 3.0, 3.0],
3521            atlas: [0.0, 0.0, 3.0, 3.0],
3522            color: [1.0, 1.0, 1.0, 1.0],
3523            is_color: false,
3524        });
3525        frame.draw_order.push(DrawCommand::Glyph(0));
3526
3527        let texture = device.create_texture(&wgpu::TextureDescriptor {
3528            label: Some("teksilo_render_snap_test_target"),
3529            size: wgpu::Extent3d {
3530                width: 32,
3531                height: 32,
3532                depth_or_array_layers: 1,
3533            },
3534            mip_level_count: 1,
3535            sample_count: 1,
3536            dimension: wgpu::TextureDimension::D2,
3537            format: wgpu::TextureFormat::Rgba8UnormSrgb,
3538            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
3539            view_formats: &[],
3540        });
3541        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
3542
3543        renderer.render(&frame, &view, 1.0, 32, 32, [0.0, 0.0, 0.0, 0.0]);
3544
3545        let pixels = crate::test_support::read_texture_rgba(&device, &queue, &texture, 32, 32);
3546        let alpha = |x: usize, y: usize| pixels[(y * 32 + x) * 4 + 3];
3547
3548        // Interior pixels exactly opaque — in particular the BOTTOM row
3549        // (y = 13), the one the unsnapped bilinear kernel used to fade
3550        // into the gutter.
3551        for y in 11..14 {
3552            for x in 10..13 {
3553                assert_eq!(
3554                    alpha(x, y),
3555                    255,
3556                    "interior pixel ({x},{y}) must be fully opaque — \
3557                     bilinear edge feathering means the snap did not fire"
3558                );
3559            }
3560        }
3561        // The one-pixel ring around the quad exactly transparent — no
3562        // feathered halo on any side.
3563        for y in 10..15 {
3564            for x in 9..14 {
3565                let inside = (10..13).contains(&x) && (11..14).contains(&y);
3566                if !inside {
3567                    assert_eq!(
3568                        alpha(x, y),
3569                        0,
3570                        "ring pixel ({x},{y}) must be untouched — \
3571                         the snapped quad must not bleed past its bitmap"
3572                    );
3573                }
3574            }
3575        }
3576    }
3577
3578    /// The same guarantee for Tier-3 paths, which did not have it.
3579    ///
3580    /// Every SVG icon in an app is a path, and a path's quad used to be
3581    /// derived from `entry.bounds × scale_factor` while its bitmap was baked
3582    /// on its own integer grid. `Rect::expand` alone puts a line-style 16 dp
3583    /// icon's bounds on a half pixel, so the two disagreed by half a texel
3584    /// and the linear sampler smeared every stroke: a 1 px hairline peaked
3585    /// at 48 % coverage instead of 100 %, and a dashed ring's sub-pixel gaps
3586    /// closed up into a grey haze.
3587    ///
3588    /// A 1 px vertical stroke must therefore land as exactly one fully
3589    /// opaque column with nothing either side of it.
3590    #[test]
3591    fn fractional_origin_path_renders_pixel_exact() {
3592        let Some((mut renderer, device, queue)) = pollster::block_on(
3593            crate::test_support::create_test_renderer("teksilo_render_path_snap_test_device"),
3594        ) else {
3595            return;
3596        };
3597
3598        // A hairline centred on x = 8.5, so it covers exactly device column
3599        // 8. Its stroke-expanded bounds start at x = 7.5: the half pixel.
3600        let mut path = teksilo_canvas::Path::new();
3601        path.move_to(teksilo_canvas::Point::new(8.5, 4.0));
3602        path.line_to(teksilo_canvas::Point::new(8.5, 12.0));
3603        let stroke_style = teksilo_canvas::StrokeStyle::solid(1.0);
3604        let bounds = path.bounds().expand(stroke_style.width);
3605        assert_eq!(bounds.x, 7.5, "the half-pixel origin this test is about");
3606
3607        let mut frame = RenderFrame::new();
3608        frame.paths.push(teksilo_canvas::PathEntry {
3609            path,
3610            color: [1.0, 1.0, 1.0, 1.0],
3611            stroke_style,
3612            fill_rule: teksilo_canvas::FillRule::Winding,
3613            bounds: bounds.to_array(),
3614            paint_data: teksilo_canvas::PaintData::Solid,
3615        });
3616        frame.draw_order.push(DrawCommand::Path(0));
3617
3618        let texture = device.create_texture(&wgpu::TextureDescriptor {
3619            label: Some("teksilo_render_path_snap_test_target"),
3620            size: wgpu::Extent3d {
3621                width: 32,
3622                height: 32,
3623                depth_or_array_layers: 1,
3624            },
3625            mip_level_count: 1,
3626            sample_count: 1,
3627            dimension: wgpu::TextureDimension::D2,
3628            format: wgpu::TextureFormat::Rgba8UnormSrgb,
3629            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
3630            view_formats: &[],
3631        });
3632        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
3633
3634        renderer.render(&frame, &view, 1.0, 32, 32, [0.0, 0.0, 0.0, 0.0]);
3635
3636        let pixels = crate::test_support::read_texture_rgba(&device, &queue, &texture, 32, 32);
3637        let alpha = |x: usize, y: usize| pixels[(y * 32 + x) * 4 + 3];
3638
3639        for y in 5..11 {
3640            assert_eq!(
3641                alpha(8, y),
3642                255,
3643                "the hairline's own column must be fully inked at y={y} — \
3644                 anything less means the quad was resampled off the pixel grid"
3645            );
3646            for x in [6, 7, 9, 10] {
3647                assert_eq!(
3648                    alpha(x, y),
3649                    0,
3650                    "({x},{y}) must be untouched — a 1 px stroke that leaks \
3651                     into its neighbours is the blur this snap removes"
3652                );
3653            }
3654        }
3655    }
3656}