Skip to main content

blinc_app/
context.rs

1//! Render context for blinc_app
2//!
3//! Wraps the GPU rendering pipeline with a clean API.
4
5use blinc_core::{
6    Brush, Color, CornerRadius, DrawCommand, DrawContext, DrawContextExt, Rect, Stroke,
7};
8use blinc_gpu::{
9    FontRegistry, GenericFont as GpuGenericFont, GpuGlyph, GpuImage, GpuImageInstance,
10    GpuPaintContext, GpuPrimitive, GpuRenderer, ImageRenderingContext, PendingMesh, PrimitiveBatch,
11    TextAlignment, TextAnchor, TextRenderingContext,
12};
13use blinc_layout::div::{FontFamily, FontWeight, GenericFont, TextAlign, TextVerticalAlign};
14use blinc_layout::prelude::*;
15use blinc_layout::render_state::Overlay;
16use blinc_layout::renderer::ElementType;
17use blinc_svg::{RasterizedSvg, SvgDocument};
18use lru::LruCache;
19use std::collections::hash_map::DefaultHasher;
20use std::hash::{Hash, Hasher};
21use std::num::NonZeroUsize;
22use std::sync::{Arc, Mutex};
23
24use crate::error::Result;
25use crate::svg_atlas::SvgAtlas;
26
27/// Maximum number of images to keep in cache (prevents unbounded memory growth).
28///
29/// Sized to comfortably hold the simultaneously-visible image set of typical
30/// content-heavy views (galleries, emoji grids, chat backlogs). Going below
31/// the visible-set size causes scroll-driven thrashing where currently-visible
32/// images are evicted to make room for newly-loaded ones.
33const IMAGE_CACHE_CAPACITY: usize = 256;
34
35/// Maximum number of parsed SVG documents to cache
36const SVG_CACHE_CAPACITY: usize = 128;
37
38/// Intersect two axis-aligned clip rects [x, y, w, h], returning their overlap.
39fn intersect_clip_rects(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
40    let x1 = a[0].max(b[0]);
41    let y1 = a[1].max(b[1]);
42    let x2 = (a[0] + a[2]).min(b[0] + b[2]);
43    let y2 = (a[1] + a[3]).min(b[1] + b[3]);
44    [x1, y1, (x2 - x1).max(0.0), (y2 - y1).max(0.0)]
45}
46
47/// Merge a new clip rect with an optional existing one via intersection.
48fn merge_scroll_clip(new_clip: [f32; 4], existing: Option<[f32; 4]>) -> Option<[f32; 4]> {
49    match existing {
50        Some(ex) => Some(intersect_clip_rects(new_clip, ex)),
51        None => Some(new_clip),
52    }
53}
54
55/// Compute effective clip for elements that support only a single clip rect (text, SVG).
56/// Intersects primary clip and scroll clip so nested scroll containers are respected.
57fn effective_single_clip(primary: Option<[f32; 4]>, scroll: Option<[f32; 4]>) -> Option<[f32; 4]> {
58    match (primary, scroll) {
59        (Some(c), Some(s)) => Some(intersect_clip_rects(c, s)),
60        (c, s) => c.or(s),
61    }
62}
63
64// Rasterized SVG textures are now packed into SvgAtlas (single shared GPU texture)
65
66/// Internal render context that manages GPU resources and rendering
67pub struct RenderContext {
68    renderer: GpuRenderer,
69    pub(crate) text_ctx: TextRenderingContext,
70    image_ctx: ImageRenderingContext,
71    device: Arc<wgpu::Device>,
72    queue: Arc<wgpu::Queue>,
73    sample_count: u32,
74    // Single texture for glass backdrop (rendered to and sampled from)
75    backdrop_texture: Option<CachedTexture>,
76    // Cached MSAA texture for anti-aliased rendering
77    msaa_texture: Option<CachedTexture>,
78    // LRU cache for images (prevents unbounded memory growth)
79    image_cache: LruCache<String, GpuImage>,
80    // Tracks when each image first appeared in the cache (for fade-in animation)
81    image_load_times: std::collections::HashMap<String, web_time::Instant>,
82    // LRU cache for parsed SVG documents (avoids re-parsing)
83    svg_cache: LruCache<u64, SvgDocument>,
84    // Texture atlas for rasterized SVGs (single shared GPU texture, shelf-packed)
85    svg_atlas: SvgAtlas,
86    // Scratch buffers for per-frame allocations (reused to avoid allocations)
87    scratch_glyphs: Vec<GpuGlyph>,
88    scratch_texts: Vec<TextElement>,
89    scratch_svgs: Vec<SvgElement>,
90    scratch_images: Vec<ImageElement>,
91    // Current cursor position in physical pixels (for @flow pointer input)
92    cursor_pos: [f32; 2],
93    // Whether the last render contained @flow shader elements (triggers continuous redraw)
94    has_active_flows: bool,
95    // Frame counter for periodic cache stats logging
96    frame_count: u64,
97}
98
99struct CachedTexture {
100    texture: wgpu::Texture,
101    view: wgpu::TextureView,
102    width: u32,
103    height: u32,
104}
105
106/// Info about a 3D-transformed ancestor layer. When text/SVGs/images are inside a parent
107/// with `perspective` + `rotate-x`/`rotate-y`, this info is used to render them to an
108/// offscreen texture and blit with the same perspective transform.
109#[derive(Clone, Debug)]
110struct Transform3DLayerInfo {
111    /// Node ID of the 3D-transformed ancestor (used as layer grouping key)
112    node_id: LayoutNodeId,
113    /// Screen-space bounds of the 3D layer [x, y, w, h] (DPI-scaled)
114    layer_bounds: [f32; 4],
115    /// Perspective transform parameters
116    transform_3d: blinc_core::Transform3DParams,
117    /// Layer opacity
118    opacity: f32,
119}
120
121/// Text element data for rendering
122#[derive(Clone)]
123struct TextElement {
124    content: String,
125    x: f32,
126    y: f32,
127    width: f32,
128    height: f32,
129    font_size: f32,
130    color: [f32; 4],
131    align: TextAlign,
132    weight: FontWeight,
133    /// Whether to use italic style
134    italic: bool,
135    /// Vertical alignment within bounding box
136    v_align: TextVerticalAlign,
137    /// Clip bounds from parent scroll container (x, y, width, height)
138    clip_bounds: Option<[f32; 4]>,
139    /// Motion opacity inherited from parent motion container
140    motion_opacity: f32,
141    /// Whether to wrap text at container bounds
142    wrap: bool,
143    /// Line height multiplier
144    line_height: f32,
145    /// Measured width (before layout constraints) - used to determine if wrap is needed
146    measured_width: f32,
147    /// Font family category
148    font_family: FontFamily,
149    /// Word spacing in pixels (0.0 = normal)
150    word_spacing: f32,
151    /// Letter spacing in pixels (0.0 = normal)
152    letter_spacing: f32,
153    /// Z-index for rendering order (higher = on top)
154    z_index: u32,
155    /// Font ascender in pixels (distance from baseline to top)
156    ascender: f32,
157    /// Whether text has strikethrough decoration
158    strikethrough: bool,
159    /// Whether text has underline decoration
160    underline: bool,
161    /// CSS text-decoration-color override (RGBA)
162    decoration_color: Option<[f32; 4]>,
163    /// CSS text-decoration-thickness override in pixels
164    decoration_thickness: Option<f32>,
165    /// Inherited CSS transform from ancestor elements (full 6-element affine in layout coords)
166    /// [a, b, c, d, tx, ty] where new_x = a*x + c*y + tx, new_y = b*x + d*y + ty
167    css_affine: Option<[f32; 6]>,
168    /// Text shadow (offset_x, offset_y, blur, color) from CSS text-shadow property
169    text_shadow: Option<blinc_core::Shadow>,
170    /// 3D layer info if this text is inside a perspective-transformed parent
171    transform_3d_layer: Option<Transform3DLayerInfo>,
172    /// Whether this text is inside a foreground-layer element (rendered after foreground primitives)
173    is_foreground: bool,
174}
175
176/// Image element data for rendering
177#[derive(Clone)]
178struct ImageElement {
179    source: String,
180    x: f32,
181    y: f32,
182    width: f32,
183    height: f32,
184    object_fit: u8,
185    object_position: [f32; 2],
186    opacity: f32,
187    border_radius: f32,
188    tint: [f32; 4],
189    /// Clip bounds from parent (x, y, width, height)
190    clip_bounds: Option<[f32; 4]>,
191    /// Clip corner radii (tl, tr, br, bl)
192    clip_radius: [f32; 4],
193    /// Which layer this image renders in
194    layer: RenderLayer,
195    /// Loading strategy: 0 = Eager (load immediately), 1 = Lazy (load when visible)
196    loading_strategy: u8,
197    /// Placeholder type: 0 = None, 1 = Color, 2 = Image, 3 = Skeleton
198    placeholder_type: u8,
199    /// Placeholder color [r, g, b, a]
200    placeholder_color: [f32; 4],
201    /// Placeholder image source (only used when placeholder_type == 2)
202    placeholder_image: Option<String>,
203    /// Fade-in duration in milliseconds (0 = no fade)
204    fade_duration_ms: u32,
205    /// Z-layer index for interleaved rendering with primitives
206    z_index: u32,
207    /// Border width (0 = no border)
208    border_width: f32,
209    /// Border color
210    border_color: blinc_core::Color,
211    /// CSS transform as 6-element affine [a, b, c, d, tx, ty] (None = no transform)
212    css_affine: Option<[f32; 6]>,
213    /// Drop shadow from CSS
214    shadow: Option<blinc_core::Shadow>,
215    /// CSS filter A (grayscale, invert, sepia, hue_rotate_rad) — identity = [0,0,0,0]
216    filter_a: [f32; 4],
217    /// CSS filter B (brightness, contrast, saturate, unused) — identity = [1,1,1,0]
218    filter_b: [f32; 4],
219    /// Secondary clip (scroll container boundary) — sharp rect, no radius.
220    /// Kept separate from primary clip_bounds so rounded corners don't morph
221    /// when the primary clip rect shrinks at scroll boundaries.
222    scroll_clip: Option<[f32; 4]>,
223    /// Mask gradient params: linear=(x1,y1,x2,y2), radial=(cx,cy,r,0) in OBB space
224    mask_params: [f32; 4],
225    /// Mask info: [mask_type, start_alpha, end_alpha, 0] (0=none, 1=linear, 2=radial)
226    mask_info: [f32; 4],
227    /// 3D layer info if this image is inside a perspective-transformed parent
228    transform_3d_layer: Option<Transform3DLayerInfo>,
229}
230
231/// SVG element data for rendering
232#[derive(Clone)]
233struct SvgElement {
234    source: Arc<str>,
235    x: f32,
236    y: f32,
237    width: f32,
238    height: f32,
239    /// Tint color to apply to SVG fill/stroke (from CSS `color`)
240    tint: Option<blinc_core::Color>,
241    /// CSS `fill` override for SVG
242    fill: Option<blinc_core::Color>,
243    /// CSS `stroke` override for SVG
244    stroke: Option<blinc_core::Color>,
245    /// CSS `stroke-width` override for SVG
246    stroke_width: Option<f32>,
247    /// CSS `stroke-dasharray` pattern for SVG
248    stroke_dasharray: Option<Vec<f32>>,
249    /// CSS `stroke-dashoffset` for SVG
250    stroke_dashoffset: Option<f32>,
251    /// SVG path `d` attribute data (for path morphing)
252    svg_path_data: Option<String>,
253    /// Clip bounds from parent scroll container (x, y, width, height)
254    clip_bounds: Option<[f32; 4]>,
255    /// Motion opacity inherited from parent motion container
256    motion_opacity: f32,
257    /// Inherited CSS transform from ancestor elements (full 6-element affine in layout coords)
258    /// [a, b, c, d, tx, ty] where new_x = a*x + c*y + tx, new_y = b*x + d*y + ty
259    css_affine: Option<[f32; 6]>,
260    /// Per-SVG-tag style overrides from CSS tag-name selectors (e.g., `path { fill: red; }`)
261    tag_overrides: std::collections::HashMap<String, blinc_layout::element::SvgTagStyle>,
262    /// 3D layer info if this SVG is inside a perspective-transformed parent
263    transform_3d_layer: Option<Transform3DLayerInfo>,
264}
265
266/// Flow shader element — an element with `flow: <name>` that renders via a custom GPU pipeline
267#[derive(Clone)]
268struct FlowElement {
269    /// Name referencing a @flow DAG in the stylesheet
270    flow_name: String,
271    /// Direct FlowGraph (from `flow!` macro), bypasses stylesheet lookup
272    flow_graph: Option<std::sync::Arc<blinc_core::FlowGraph>>,
273    /// Bounds in physical pixels (DPI-scaled)
274    x: f32,
275    y: f32,
276    width: f32,
277    height: f32,
278    /// Z-layer for rendering order
279    z_index: u32,
280    /// Corner radius in physical pixels
281    corner_radius: f32,
282}
283
284/// Debug bounds element for layout visualization
285#[derive(Clone)]
286struct DebugBoundsElement {
287    x: f32,
288    y: f32,
289    width: f32,
290    height: f32,
291    /// Element type name for labeling
292    element_type: String,
293    /// Depth in the tree (for color coding)
294    depth: u32,
295}
296
297impl RenderContext {
298    /// Create a new render context
299    pub(crate) fn new(
300        renderer: GpuRenderer,
301        text_ctx: TextRenderingContext,
302        device: Arc<wgpu::Device>,
303        queue: Arc<wgpu::Queue>,
304        sample_count: u32,
305    ) -> Self {
306        let image_ctx = ImageRenderingContext::new(device.clone(), queue.clone());
307        let svg_atlas = SvgAtlas::new(&device);
308        Self {
309            renderer,
310            text_ctx,
311            image_ctx,
312            device,
313            queue,
314            sample_count,
315            backdrop_texture: None,
316            msaa_texture: None,
317            image_cache: LruCache::new(NonZeroUsize::new(IMAGE_CACHE_CAPACITY).unwrap()),
318            image_load_times: std::collections::HashMap::new(),
319            svg_cache: LruCache::new(NonZeroUsize::new(SVG_CACHE_CAPACITY).unwrap()),
320            svg_atlas,
321            scratch_glyphs: Vec::with_capacity(1024), // Pre-allocate for typical text
322            scratch_texts: Vec::with_capacity(64),    // Pre-allocate for text elements
323            scratch_svgs: Vec::with_capacity(32),     // Pre-allocate for SVG elements
324            scratch_images: Vec::with_capacity(32),   // Pre-allocate for image elements
325            cursor_pos: [0.0; 2],
326            has_active_flows: false,
327            frame_count: 0,
328        }
329    }
330
331    /// Update the current cursor position in physical pixels (for @flow pointer input)
332    /// Register a custom render pass with the GPU renderer.
333    ///
334    /// Scene3D-stage passes run inside the mesh HDR pipeline with
335    /// camera context (view_proj, inv_view_proj, camera_pos) populated
336    /// on `RenderPassContext`. PreRender/PostProcess stages run at
337    /// their existing points in the frame.
338    pub fn register_custom_pass(
339        &mut self,
340        pass: Box<dyn blinc_gpu::custom_pass::CustomRenderPass>,
341    ) {
342        self.renderer.register_custom_pass(pass);
343    }
344
345    pub fn set_cursor_position(&mut self, x: f32, y: f32) {
346        self.cursor_pos = [x, y];
347    }
348
349    /// Whether the last render frame contained @flow shader elements.
350    /// Used to trigger continuous redraws for animated flow shaders.
351    pub fn has_active_flows(&self) -> bool {
352        self.has_active_flows
353    }
354
355    /// Set the current render target texture for blend mode two-pass compositing.
356    /// Must be called before rendering when the batch may use non-Normal blend modes.
357    pub fn set_blend_target(&mut self, texture: &wgpu::Texture) {
358        self.renderer.set_blend_target(texture);
359    }
360
361    /// Clear the blend target texture reference after rendering.
362    pub fn clear_blend_target(&mut self) {
363        self.renderer.clear_blend_target();
364    }
365
366    /// Load font data into the text rendering registry
367    ///
368    /// This adds fonts that will be available for text rendering.
369    /// Returns the number of font faces loaded.
370    pub fn load_font_data_to_registry(&mut self, data: Vec<u8>) -> usize {
371        self.text_ctx.load_font_data_to_registry(data)
372    }
373
374    /// Render a layout tree to a texture view
375    ///
376    /// Handles everything automatically - glass, text, SVG, MSAA.
377    pub fn render_tree(
378        &mut self,
379        tree: &RenderTree,
380        width: u32,
381        height: u32,
382        target: &wgpu::TextureView,
383    ) -> Result<()> {
384        // Get scale factor for HiDPI rendering
385        let scale_factor = tree.scale_factor();
386
387        // Create paint contexts for each layer with text rendering support
388        let mut bg_ctx =
389            GpuPaintContext::with_text_context(width as f32, height as f32, &mut self.text_ctx);
390
391        // Render layout layers (background and glass go to bg_ctx)
392        tree.render_to_layer(&mut bg_ctx, RenderLayer::Background);
393        tree.render_to_layer(&mut bg_ctx, RenderLayer::Glass);
394
395        // Take the batch from bg_ctx before we can reuse text_ctx for fg_ctx
396        let mut bg_batch = bg_ctx.take_batch();
397
398        // Create foreground context with text rendering support
399        let mut fg_ctx =
400            GpuPaintContext::with_text_context(width as f32, height as f32, &mut self.text_ctx);
401        tree.render_to_layer(&mut fg_ctx, RenderLayer::Foreground);
402
403        // Take the batch from fg_ctx before reusing text_ctx for text elements
404        let mut fg_batch = fg_ctx.take_batch();
405
406        // Collect text, SVG, image, and flow elements
407        let (texts, svgs, images, _flows) = self.collect_render_elements(tree);
408
409        // Pre-load all images into cache before rendering
410        self.preload_images(&images, width as f32, height as f32);
411
412        // Prepare text glyphs
413        let mut all_glyphs = Vec::new();
414        let mut css_transformed_text_prims: Vec<GpuPrimitive> = Vec::new();
415        for text in &texts {
416            // Convert layout TextAlign to GPU TextAlignment
417            let alignment = match text.align {
418                TextAlign::Left => TextAlignment::Left,
419                TextAlign::Center => TextAlignment::Center,
420                TextAlign::Right => TextAlignment::Right,
421            };
422
423            // Vertical alignment:
424            // - Center: Use TextAnchor::Center with y at vertical center of bounds.
425            //   This ensures text appears visually centered (by cap-height) rather than
426            //   mathematically centered by the full bounding box (which includes descenders).
427            // - Top: Text is centered within its layout box (items_center works).
428            // - Baseline: Position text so baseline aligns at the font's actual baseline.
429            //   Using the actual ascender from font metrics ensures all fonts align by
430            //   their true baseline regardless of font family.
431            let (anchor, y_pos, use_layout_height) = match text.v_align {
432                TextVerticalAlign::Center => {
433                    (TextAnchor::Center, text.y + text.height / 2.0, false)
434                }
435                TextVerticalAlign::Top => (TextAnchor::Top, text.y, true),
436                TextVerticalAlign::Baseline => {
437                    // Use the actual font ascender for baseline positioning.
438                    // This ensures each font aligns by its true baseline.
439                    let baseline_y = text.y + text.ascender;
440                    (TextAnchor::Baseline, baseline_y, false)
441                }
442            };
443
444            // Determine wrap width: use clip bounds if available (parent constraint),
445            // otherwise use the text element's own layout width
446            let wrap_width = if text.wrap {
447                if let Some(clip) = text.clip_bounds {
448                    // clip[2] is the clip width - use it if smaller than text width
449                    clip[2].min(text.width)
450                } else {
451                    text.width
452                }
453            } else {
454                text.width
455            };
456
457            // Convert font family to GPU types
458            let font_name = text.font_family.name.as_deref();
459            let generic = to_gpu_generic_font(text.font_family.generic);
460            let font_weight = text.weight.weight();
461
462            // Only pass layout_height when we want centering within the box
463            let layout_height = if use_layout_height {
464                Some(text.height)
465            } else {
466                None
467            };
468
469            match self.text_ctx.prepare_text_with_style(
470                &text.content,
471                text.x,
472                y_pos,
473                text.font_size,
474                text.color,
475                anchor,
476                alignment,
477                Some(wrap_width),
478                text.wrap,
479                font_name,
480                generic,
481                font_weight,
482                text.italic,
483                layout_height,
484                text.letter_spacing,
485            ) {
486                Ok(mut glyphs) => {
487                    tracing::trace!(
488                        "Prepared {} glyphs for text '{}' (font={:?}, generic={:?})",
489                        glyphs.len(),
490                        text.content,
491                        font_name,
492                        generic
493                    );
494                    // Apply clip bounds to all glyphs if the text element has clip bounds
495                    if let Some(clip) = text.clip_bounds {
496                        for glyph in &mut glyphs {
497                            glyph.clip_bounds = clip;
498                        }
499                    }
500
501                    if let Some(affine) = text.css_affine {
502                        // CSS-transformed text: convert to SDF primitives with local_affine
503                        let [a, b, c, d, tx, ty] = affine;
504                        let tx_scaled = tx * scale_factor;
505                        let ty_scaled = ty * scale_factor;
506                        for glyph in &glyphs {
507                            let gc_x = glyph.bounds[0] + glyph.bounds[2] / 2.0;
508                            let gc_y = glyph.bounds[1] + glyph.bounds[3] / 2.0;
509                            let new_gc_x = a * gc_x + c * gc_y + tx_scaled;
510                            let new_gc_y = b * gc_x + d * gc_y + ty_scaled;
511                            let mut prim = GpuPrimitive::from_glyph(glyph);
512                            prim.bounds = [
513                                new_gc_x - glyph.bounds[2] / 2.0,
514                                new_gc_y - glyph.bounds[3] / 2.0,
515                                glyph.bounds[2],
516                                glyph.bounds[3],
517                            ];
518                            prim.local_affine = [a, b, c, d];
519                            css_transformed_text_prims.push(prim);
520                        }
521                    } else {
522                        all_glyphs.extend(glyphs);
523                    }
524                }
525                Err(e) => {
526                    tracing::warn!("Failed to prepare text '{}': {:?}", text.content, e);
527                }
528            }
529        }
530
531        tracing::trace!(
532            "Text rendering: {} texts collected, {} total glyphs prepared",
533            texts.len(),
534            all_glyphs.len()
535        );
536
537        // SVGs are rendered as rasterized images (not tessellated paths) for better anti-aliasing
538        // They will be rendered later via render_rasterized_svgs
539
540        self.renderer.resize(width, height);
541
542        // If we have CSS-transformed text, push text prims into the background batch
543        // and bind the real glyph atlas to the SDF pipeline for ALL render paths.
544        if !css_transformed_text_prims.is_empty() {
545            if let (Some(atlas), Some(color_atlas)) =
546                (self.text_ctx.atlas_view(), self.text_ctx.color_atlas_view())
547            {
548                bg_batch.primitives.append(&mut css_transformed_text_prims);
549                self.renderer.set_glyph_atlas(atlas, color_atlas);
550            }
551        }
552
553        let has_glass = bg_batch.glass_count() > 0;
554
555        // Only allocate glass textures when glass is actually used
556        if has_glass {
557            self.ensure_glass_textures(width, height);
558        }
559        let use_msaa_overlay = self.sample_count > 1;
560
561        // Background layer uses SDF rendering (shader-based AA, no MSAA needed)
562        // Foreground layer (SVGs as tessellated paths) uses MSAA for smooth edges
563
564        if has_glass {
565            // Split images by layer: background images go behind glass (get blurred),
566            // glass/foreground images render on top of glass (not blurred)
567            let (bg_images, fg_images): (Vec<_>, Vec<_>) = images
568                .iter()
569                .partition(|img| img.layer == RenderLayer::Background);
570
571            // Pre-render background images to both backdrop and target so glass can blur them
572            let has_bg_images = !bg_images.is_empty();
573            if has_bg_images {
574                // Take backdrop temporarily to avoid borrow conflict with render_images_ref(&mut self)
575                let backdrop_tex = self.backdrop_texture.take().unwrap();
576                self.renderer
577                    .clear_target(&backdrop_tex.view, wgpu::Color::TRANSPARENT);
578                self.renderer.clear_target(target, wgpu::Color::BLACK);
579                self.render_images_ref(&backdrop_tex.view, &bg_images);
580                self.render_images_ref(target, &bg_images);
581                self.backdrop_texture = Some(backdrop_tex);
582            }
583
584            // Glass path - batched rendering for reduced command buffer overhead:
585            // Steps 1-3 are batched into a single encoder submission
586            {
587                let backdrop = self.backdrop_texture.as_ref().unwrap();
588                self.renderer.render_glass_frame(
589                    target,
590                    &backdrop.view,
591                    (backdrop.width, backdrop.height),
592                    &bg_batch,
593                    has_bg_images,
594                );
595            }
596
597            // Render background paths with MSAA for smooth edges on curved shapes like notch
598            // (render_glass_frame uses 1x sampled path rendering, so we need MSAA overlay)
599            if use_msaa_overlay && bg_batch.has_paths() {
600                self.renderer
601                    .render_paths_overlay_msaa(target, &bg_batch, self.sample_count);
602            }
603
604            // Render remaining bg images to target (only if not already pre-rendered)
605            if !has_bg_images {
606                self.render_images_ref(target, &bg_images);
607            }
608
609            // Step 5: Render glass/foreground-layer images (on top of glass, NOT blurred)
610            self.render_images_ref(target, &fg_images);
611
612            // Step 5b: Render dynamic RGBA images (video frames, camera preview)
613            if !bg_batch.dynamic_images.is_empty() {
614                self.renderer
615                    .render_dynamic_images(target, &bg_batch.dynamic_images);
616            }
617            if !fg_batch.dynamic_images.is_empty() {
618                self.renderer
619                    .render_dynamic_images(target, &fg_batch.dynamic_images);
620            }
621
622            // Step 6: Render foreground and text
623            // Use batch-based rendering when layer effects are present
624            let has_layer_effects = fg_batch.has_layer_effects();
625            if has_layer_effects {
626                // Layer effects require batch-based rendering to process layer commands
627                fg_batch.convert_glyphs_to_primitives();
628                if !fg_batch.is_empty() {
629                    // Pre-load any mask images referenced by layer effects
630                    self.preload_mask_images(&fg_batch);
631                    self.renderer.render_overlay(target, &fg_batch);
632                }
633                // Render SVGs as rasterized images for high-quality anti-aliasing
634                if !svgs.is_empty() {
635                    self.render_rasterized_svgs(target, &svgs, scale_factor);
636                }
637            } else if self.renderer.unified_text_rendering() {
638                // Unified rendering: combine text glyphs with foreground primitives.
639                // See the simple-path branch below for the rationale on
640                // extending `unified_primitives` with the local
641                // `all_glyphs` — `get_unified_foreground_primitives()`
642                // reads from `fg_batch.glyphs`, which is empty here.
643                let mut unified_primitives = fg_batch.get_unified_foreground_primitives();
644                for glyph in &all_glyphs {
645                    unified_primitives.push(GpuPrimitive::from_glyph(glyph));
646                }
647                if !unified_primitives.is_empty() {
648                    self.render_unified(target, &unified_primitives);
649                }
650
651                // Render paths with MSAA for smooth edges (paths are not included in unified primitives)
652                if use_msaa_overlay && fg_batch.has_paths() {
653                    self.renderer
654                        .render_paths_overlay_msaa(target, &fg_batch, self.sample_count);
655                }
656
657                // Render SVGs as rasterized images for high-quality anti-aliasing
658                if !svgs.is_empty() {
659                    self.render_rasterized_svgs(target, &svgs, scale_factor);
660                }
661            } else {
662                // Legacy rendering: separate foreground and text passes
663                if !fg_batch.is_empty() {
664                    if use_msaa_overlay {
665                        self.renderer
666                            .render_overlay_msaa(target, &fg_batch, self.sample_count);
667                    } else {
668                        self.renderer.render_overlay(target, &fg_batch);
669                    }
670                }
671
672                // Step 7: Render text
673                if !all_glyphs.is_empty() {
674                    self.render_text(target, &all_glyphs);
675                }
676
677                // Render SVGs as rasterized images for high-quality anti-aliasing
678                if !svgs.is_empty() {
679                    self.render_rasterized_svgs(target, &svgs, scale_factor);
680                }
681            }
682
683            // Step 8: Render text decorations (strikethrough, underline)
684            let decorations_by_layer = generate_text_decoration_primitives_by_layer(&texts);
685            for primitives in decorations_by_layer.values() {
686                if !primitives.is_empty() {
687                    self.render_unified(target, primitives);
688                }
689            }
690        } else {
691            // Simple path (no glass):
692            // Background uses SDF rendering (no MSAA needed)
693            // Foreground uses MSAA for smooth SVG edges
694
695            // Render background directly to target
696            // Use opaque black clear - transparent clear can cause issues with window surfaces
697            self.renderer
698                .render_with_clear(target, &bg_batch, [0.0, 0.0, 0.0, 1.0]);
699
700            // Render background paths with MSAA for smooth edges on curved shapes like notch
701            if use_msaa_overlay && bg_batch.has_paths() {
702                self.renderer
703                    .render_paths_overlay_msaa(target, &bg_batch, self.sample_count);
704            }
705
706            // Render images after background primitives
707            self.render_images(target, &images, width as f32, height as f32, scale_factor);
708
709            // Render foreground and text
710            // Use batch-based rendering when layer effects are present to preserve
711            // layer commands for effect processing
712            let has_layer_effects = fg_batch.has_layer_effects();
713            if has_layer_effects {
714                // Layer effects require batch-based rendering to process layer commands
715                // First convert glyphs to primitives so they're included in the batch
716                fg_batch.convert_glyphs_to_primitives();
717
718                // Use render_overlay which supports layer effect processing
719                if !fg_batch.is_empty() {
720                    self.renderer.render_overlay(target, &fg_batch);
721                }
722                // Render SVGs as rasterized images for high-quality anti-aliasing
723                if !svgs.is_empty() {
724                    self.render_rasterized_svgs(target, &svgs, scale_factor);
725                }
726            } else if self.renderer.unified_text_rendering() {
727                // Unified rendering: combine text glyphs with foreground primitives
728                // This ensures text and shapes transform together during animations.
729                //
730                // `get_unified_foreground_primitives()` reads from
731                // `fg_batch.glyphs`, which is empty here — the glyph
732                // preparation loop above writes into the local
733                // `all_glyphs` vec, not into the batch. We have to
734                // extend the unified primitive list with our local
735                // glyphs ourselves, otherwise the unified path silently
736                // drops every text element. (The `render_tree_with_motion`
737                // variant doesn't hit this because it pushes glyphs
738                // through a different intermediate buffer.)
739                let mut unified_primitives = fg_batch.get_unified_foreground_primitives();
740                for glyph in &all_glyphs {
741                    unified_primitives.push(GpuPrimitive::from_glyph(glyph));
742                }
743                if !unified_primitives.is_empty() {
744                    self.render_unified(target, &unified_primitives);
745                }
746
747                // Render paths with MSAA for smooth edges (paths are not included in unified primitives)
748                if use_msaa_overlay && fg_batch.has_paths() {
749                    self.renderer
750                        .render_paths_overlay_msaa(target, &fg_batch, self.sample_count);
751                }
752
753                // Render SVGs as rasterized images for high-quality anti-aliasing
754                if !svgs.is_empty() {
755                    self.render_rasterized_svgs(target, &svgs, scale_factor);
756                }
757            } else {
758                // Legacy rendering: separate foreground and text passes
759                if !fg_batch.is_empty() {
760                    if use_msaa_overlay {
761                        self.renderer
762                            .render_overlay_msaa(target, &fg_batch, self.sample_count);
763                    } else {
764                        self.renderer.render_overlay(target, &fg_batch);
765                    }
766                }
767
768                // Render text
769                if !all_glyphs.is_empty() {
770                    self.render_text(target, &all_glyphs);
771                }
772
773                // Render SVGs as rasterized images for high-quality anti-aliasing
774                if !svgs.is_empty() {
775                    self.render_rasterized_svgs(target, &svgs, scale_factor);
776                }
777            }
778
779            // Render text decorations (strikethrough, underline)
780            let decorations_by_layer = generate_text_decoration_primitives_by_layer(&texts);
781            for primitives in decorations_by_layer.values() {
782                if !primitives.is_empty() {
783                    self.render_unified(target, primitives);
784                }
785            }
786        }
787
788        // Return scratch buffers for reuse on next frame
789        self.return_scratch_elements(texts, svgs, images);
790
791        // Poll the device to free completed command buffers and prevent memory accumulation
792        self.renderer.poll();
793
794        Ok(())
795    }
796
797    /// Return element vectors to scratch pool for reuse
798    #[inline]
799    fn return_scratch_elements(
800        &mut self,
801        mut texts: Vec<TextElement>,
802        mut svgs: Vec<SvgElement>,
803        mut images: Vec<ImageElement>,
804    ) {
805        // Clear and keep capacity for reuse
806        texts.clear();
807        svgs.clear();
808        images.clear();
809        self.scratch_texts = texts;
810        self.scratch_svgs = svgs;
811        self.scratch_images = images;
812    }
813
814    /// Log cache statistics (called every 300 frames, ~5 seconds at 60fps).
815    /// Visible at RUST_LOG=blinc_app=debug level.
816    fn log_cache_stats(&mut self) {
817        self.frame_count += 1;
818        if self.frame_count % 300 != 1 {
819            return;
820        }
821        let (aw, ah) = self.text_ctx.atlas_dimensions();
822        let (caw, cah) = self.text_ctx.color_atlas_dimensions();
823        let atlas_glyphs = self.text_ctx.atlas().glyph_count();
824        let atlas_util = self.text_ctx.atlas().utilization();
825        let color_glyphs = self.text_ctx.color_atlas().glyph_count();
826        let color_util = self.text_ctx.color_atlas().utilization();
827        let glyph_cache = self.text_ctx.glyph_cache_len();
828        let glyph_cap = self.text_ctx.glyph_cache_capacity();
829        let color_cache = self.text_ctx.color_glyph_cache_len();
830        let color_cap = self.text_ctx.color_glyph_cache_capacity();
831        let img_cache = self.image_cache.len();
832        let svg_cache = self.svg_cache.len();
833        let svg_atlas_entries = self.svg_atlas.entry_count();
834        let svg_atlas_util = self.svg_atlas.utilization();
835        let (svg_aw, svg_ah) = (self.svg_atlas.width(), self.svg_atlas.height());
836
837        tracing::info!(
838            "Cache stats [frame {}]: \
839             atlas={}x{} ({} glyphs, {:.1}% used), \
840             color_atlas={}x{} ({} glyphs, {:.1}% used), \
841             glyph_lru={}/{}, color_glyph_lru={}/{}, \
842             image={}/{}, svg_doc={}/{}, svg_atlas={}x{} ({} entries, {:.1}% used)",
843            self.frame_count,
844            aw,
845            ah,
846            atlas_glyphs,
847            atlas_util * 100.0,
848            caw,
849            cah,
850            color_glyphs,
851            color_util * 100.0,
852            glyph_cache,
853            glyph_cap,
854            color_cache,
855            color_cap,
856            img_cache,
857            IMAGE_CACHE_CAPACITY,
858            svg_cache,
859            SVG_CACHE_CAPACITY,
860            svg_aw,
861            svg_ah,
862            svg_atlas_entries,
863            svg_atlas_util * 100.0,
864        );
865    }
866
867    /// Ensure glass-related textures exist and are the right size.
868    /// Only called when glass elements are present in the scene.
869    ///
870    /// We use a single texture for both rendering and sampling (backdrop_texture).
871    /// The texture is rendered at half resolution to save memory (blur doesn't need full res).
872    fn ensure_glass_textures(&mut self, width: u32, height: u32) {
873        // Use the same texture format as the renderer's pipelines
874        let format = self.renderer.texture_format();
875
876        // Use half resolution for glass backdrop - blur effect doesn't need full resolution
877        // This saves 75% of texture memory (e.g., 2.5MB -> 0.6MB for 900x700 window)
878        let backdrop_width = (width / 2).max(1);
879        let backdrop_height = (height / 2).max(1);
880
881        let needs_backdrop = self
882            .backdrop_texture
883            .as_ref()
884            .map(|t| t.width != backdrop_width || t.height != backdrop_height)
885            .unwrap_or(true);
886
887        if needs_backdrop {
888            // Single texture that can be both rendered to AND sampled from
889            let texture = self.device.create_texture(&wgpu::TextureDescriptor {
890                label: Some("Glass Backdrop"),
891                size: wgpu::Extent3d {
892                    width: backdrop_width,
893                    height: backdrop_height,
894                    depth_or_array_layers: 1,
895                },
896                mip_level_count: 1,
897                sample_count: 1,
898                dimension: wgpu::TextureDimension::D2,
899                format,
900                usage: wgpu::TextureUsages::RENDER_ATTACHMENT
901                    | wgpu::TextureUsages::TEXTURE_BINDING,
902                view_formats: &[],
903            });
904            let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
905            self.backdrop_texture = Some(CachedTexture {
906                texture,
907                view,
908                width: backdrop_width,
909                height: backdrop_height,
910            });
911        }
912    }
913
914    /// Render text glyphs
915    fn render_text(&mut self, target: &wgpu::TextureView, glyphs: &[GpuGlyph]) {
916        if let (Some(atlas_view), Some(color_atlas_view)) =
917            (self.text_ctx.atlas_view(), self.text_ctx.color_atlas_view())
918        {
919            self.renderer.render_text(
920                target,
921                glyphs,
922                atlas_view,
923                color_atlas_view,
924                self.text_ctx.sampler(),
925            );
926        }
927    }
928
929    /// Render SDF primitives and text glyphs in a unified pass
930    ///
931    /// This ensures text and shapes transform together during animations,
932    /// preventing visual lag when parent containers have motion transforms.
933    ///
934    /// **Glyph atlas binding is required.** The unified rendering path
935    /// converts text glyphs into `GpuPrimitive`s with `prim_type =
936    /// PRIM_TEXT`, which the SDF shader's `case PRIM_TEXT:` arm
937    /// samples from `glyph_atlas` / `color_glyph_atlas`. The default
938    /// SDF bind group has 1×1 placeholder textures bound to those
939    /// slots — without explicitly binding the real atlases via
940    /// `render_primitives_overlay_with_glyphs`, every text quad
941    /// samples a transparent placeholder pixel and the text renders
942    /// invisibly.
943    ///
944    /// `render_tree_with_motion` (the desktop / mobile path) handles
945    /// this via the same call. Skipping it was a `render_tree`
946    /// (headless / web) path bug — text was being correctly converted
947    /// to primitives but the placeholder atlas was producing zero
948    /// output for every glyph quad.
949    fn render_unified(&mut self, target: &wgpu::TextureView, primitives: &[GpuPrimitive]) {
950        if primitives.is_empty() {
951            return;
952        }
953
954        if let (Some(atlas_view), Some(color_atlas_view)) =
955            (self.text_ctx.atlas_view(), self.text_ctx.color_atlas_view())
956        {
957            self.renderer.render_primitives_overlay_with_glyphs(
958                target,
959                primitives,
960                atlas_view,
961                color_atlas_view,
962            );
963        } else {
964            // No atlas available — fall back to plain primitive
965            // rendering. Text quads will sample placeholder pixels
966            // and render invisibly, but at least non-text primitives
967            // still render.
968            self.renderer.render_primitives_overlay(target, primitives);
969        }
970    }
971
972    /// Render text decorations for a specific z-layer
973    fn render_text_decorations_for_layer(
974        &mut self,
975        target: &wgpu::TextureView,
976        decorations_by_layer: &std::collections::HashMap<u32, Vec<GpuPrimitive>>,
977        z_layer: u32,
978    ) {
979        if let Some(primitives) = decorations_by_layer.get(&z_layer) {
980            if !primitives.is_empty() {
981                self.renderer.render_primitives_overlay(target, primitives);
982            }
983        }
984    }
985
986    /// Render debug visualization overlays for text elements
987    ///
988    /// When `BLINC_DEBUG=text` (or `1`, `all`, `true`) is set, this renders:
989    /// - Cyan: Text bounding box outline
990    /// - Magenta: Baseline position
991    /// - Green: Top of bounding box (ascender reference)
992    /// - Yellow: Bottom of bounding box (descender reference)
993    fn render_text_debug(&mut self, target: &wgpu::TextureView, texts: &[TextElement]) {
994        let debug_primitives = generate_text_debug_primitives(texts);
995        if !debug_primitives.is_empty() {
996            self.renderer
997                .render_primitives_overlay(target, &debug_primitives);
998        }
999    }
1000
1001    /// Render debug visualization overlays for all layout elements
1002    ///
1003    /// When `BLINC_DEBUG=layout` (or `all`) is set, this renders:
1004    /// - Semi-transparent colored rectangles for each element's bounding box
1005    /// - Colors cycle based on tree depth to distinguish nested elements
1006    fn render_layout_debug(&mut self, target: &wgpu::TextureView, tree: &RenderTree, scale: f32) {
1007        let debug_bounds = collect_debug_bounds(tree, scale);
1008        let debug_primitives = generate_layout_debug_primitives(&debug_bounds);
1009        if !debug_primitives.is_empty() {
1010            self.renderer
1011                .render_primitives_overlay(target, &debug_primitives);
1012        }
1013    }
1014
1015    /// Render debug visualization for motion/animations
1016    ///
1017    /// When `BLINC_DEBUG=motion` (or `all`) is set, this renders:
1018    /// - Top-right corner overlay showing animation stats
1019    /// - Number of active visual animations, layout animations, etc.
1020    fn render_motion_debug(
1021        &mut self,
1022        target: &wgpu::TextureView,
1023        tree: &RenderTree,
1024        width: u32,
1025        _height: u32,
1026    ) {
1027        let stats = tree.debug_stats();
1028        let mut debug_primitives = Vec::new();
1029
1030        // Background for the debug panel
1031        let panel_width = 200.0;
1032        let panel_height = 100.0;
1033        let panel_x = width as f32 - panel_width - 10.0;
1034        let panel_y = 10.0;
1035
1036        // Semi-transparent dark background
1037        debug_primitives.push(
1038            GpuPrimitive::rect(panel_x, panel_y, panel_width, panel_height)
1039                .with_color(0.1, 0.1, 0.15, 0.85)
1040                .with_corner_radius(6.0),
1041        );
1042
1043        // Status indicator - green if any animations active
1044        let has_active = stats.visual_animation_count > 0
1045            || stats.layout_animation_count > 0
1046            || stats.animated_bounds_count > 0;
1047
1048        let (r, g, b, a) = if has_active {
1049            (0.2, 0.9, 0.3, 1.0) // Green when animating
1050        } else {
1051            (0.4, 0.4, 0.5, 1.0) // Gray when idle
1052        };
1053
1054        debug_primitives.push(
1055            GpuPrimitive::rect(panel_x + 10.0, panel_y + 12.0, 10.0, 10.0)
1056                .with_color(r, g, b, a)
1057                .with_corner_radius(5.0),
1058        );
1059
1060        // Visual bars showing animation counts
1061        let bar_x = panel_x + 12.0;
1062        let bar_width = panel_width - 24.0;
1063        let bar_height = 6.0;
1064
1065        // Visual animations bar (cyan)
1066        let visual_ratio = (stats.visual_animation_count as f32).min(10.0) / 10.0;
1067        if visual_ratio > 0.0 {
1068            debug_primitives.push(
1069                GpuPrimitive::rect(bar_x, panel_y + 35.0, bar_width * visual_ratio, bar_height)
1070                    .with_color(0.0, 0.8, 0.9, 0.9)
1071                    .with_corner_radius(3.0),
1072            );
1073        }
1074
1075        // Layout animations bar (magenta)
1076        let layout_ratio = (stats.layout_animation_count as f32).min(10.0) / 10.0;
1077        if layout_ratio > 0.0 {
1078            debug_primitives.push(
1079                GpuPrimitive::rect(bar_x, panel_y + 50.0, bar_width * layout_ratio, bar_height)
1080                    .with_color(0.9, 0.2, 0.8, 0.9)
1081                    .with_corner_radius(3.0),
1082            );
1083        }
1084
1085        // Animated bounds bar (yellow)
1086        let bounds_ratio = (stats.animated_bounds_count as f32).min(50.0) / 50.0;
1087        if bounds_ratio > 0.0 {
1088            debug_primitives.push(
1089                GpuPrimitive::rect(bar_x, panel_y + 65.0, bar_width * bounds_ratio, bar_height)
1090                    .with_color(0.95, 0.85, 0.2, 0.9)
1091                    .with_corner_radius(3.0),
1092            );
1093        }
1094
1095        // Scroll physics indicator (orange dots)
1096        let scroll_count = stats.scroll_physics_count.min(8);
1097        for i in 0..scroll_count {
1098            debug_primitives.push(
1099                GpuPrimitive::rect(bar_x + (i as f32 * 14.0), panel_y + 80.0, 8.0, 8.0)
1100                    .with_color(1.0, 0.6, 0.2, 0.9)
1101                    .with_corner_radius(4.0),
1102            );
1103        }
1104
1105        if !debug_primitives.is_empty() {
1106            self.renderer
1107                .render_primitives_overlay(target, &debug_primitives);
1108        }
1109    }
1110
1111    /// Render images to the backdrop texture (for images that should be blurred by glass)
1112    fn render_images_to_backdrop(&mut self, images: &[&ImageElement]) {
1113        let Some(ref backdrop) = self.backdrop_texture else {
1114            return;
1115        };
1116        // Create a new view to avoid borrow conflicts
1117        let target = backdrop
1118            .texture
1119            .create_view(&wgpu::TextureViewDescriptor::default());
1120        self.render_images_ref(&target, images);
1121    }
1122
1123    /// Pre-load images into cache (call before rendering)
1124    ///
1125    /// Images with lazy loading strategy are only loaded when visible in the viewport.
1126    /// A buffer zone extends the viewport to preload images that are about to become visible.
1127    fn preload_images(
1128        &mut self,
1129        images: &[ImageElement],
1130        viewport_width: f32,
1131        viewport_height: f32,
1132    ) {
1133        // Buffer zone: load images that are within 100px of becoming visible
1134        const VISIBILITY_BUFFER: f32 = 100.0;
1135
1136        // Eagerly load placeholder images for any lazy element with placeholder_type == 2
1137        // (so the placeholder is already in cache when we go to render it).
1138        // Use get() instead of contains() so cached placeholders are promoted to
1139        // MRU and survive eviction pressure from the main image puts below.
1140        for image in images {
1141            if image.placeholder_type == 2 {
1142                if let Some(ref placeholder_src) = image.placeholder_image {
1143                    if self.image_cache.get(placeholder_src).is_none() {
1144                        let source = blinc_image::ImageSource::from_uri(placeholder_src);
1145                        if let Ok(data) = blinc_image::ImageData::load(source) {
1146                            let gpu_image = self.image_ctx.create_image_labeled(
1147                                data.pixels(),
1148                                data.width(),
1149                                data.height(),
1150                                placeholder_src,
1151                            );
1152                            self.image_cache.put(placeholder_src.clone(), gpu_image);
1153                        }
1154                    }
1155                }
1156            }
1157        }
1158
1159        for image in images {
1160            // Use get() (not contains()) so the cache hit promotes the entry
1161            // to MRU. Without this, the LRU order is set entirely by insertion
1162            // order, and any new put() during scroll evicts the oldest visible
1163            // image first — which is exactly the row at the top of the viewport.
1164            // Promoting on hit during preload guarantees the eviction victims
1165            // are non-visible entries at the back of the cache.
1166            if self.image_cache.get(&image.source).is_some() {
1167                continue;
1168            }
1169
1170            // Check if lazy loading is enabled (loading_strategy == 1)
1171            if image.loading_strategy == 1 {
1172                // If image has clip bounds from a scroll container, use those for visibility check
1173                // The clip bounds represent the visible area of the parent scroll container
1174                let is_visible = if let Some([clip_x, clip_y, clip_w, clip_h]) = image.clip_bounds {
1175                    // Check if image intersects with its clip region (+ buffer for prefetching)
1176                    let clip_left = clip_x - VISIBILITY_BUFFER;
1177                    let clip_top = clip_y - VISIBILITY_BUFFER;
1178                    let clip_right = clip_x + clip_w + VISIBILITY_BUFFER;
1179                    let clip_bottom = clip_y + clip_h + VISIBILITY_BUFFER;
1180
1181                    let image_right = image.x + image.width;
1182                    let image_bottom = image.y + image.height;
1183
1184                    image.x < clip_right
1185                        && image_right > clip_left
1186                        && image.y < clip_bottom
1187                        && image_bottom > clip_top
1188                } else {
1189                    // No clip bounds - check against viewport
1190                    let viewport_left = -VISIBILITY_BUFFER;
1191                    let viewport_top = -VISIBILITY_BUFFER;
1192                    let viewport_right = viewport_width + VISIBILITY_BUFFER;
1193                    let viewport_bottom = viewport_height + VISIBILITY_BUFFER;
1194
1195                    let image_right = image.x + image.width;
1196                    let image_bottom = image.y + image.height;
1197
1198                    image.x < viewport_right
1199                        && image_right > viewport_left
1200                        && image.y < viewport_bottom
1201                        && image_bottom > viewport_top
1202                };
1203
1204                if !is_visible {
1205                    // Skip loading - image is not yet visible
1206                    continue;
1207                }
1208            }
1209
1210            // Try to load the image - use from_uri to handle emoji://, data:, and file paths
1211            let source = blinc_image::ImageSource::from_uri(&image.source);
1212            let image_data = match blinc_image::ImageData::load(source) {
1213                Ok(data) => data,
1214                Err(e) => {
1215                    tracing::trace!("Failed to load image '{}': {:?}", image.source, e);
1216                    continue; // Skip images that fail to load
1217                }
1218            };
1219
1220            // Create GPU texture
1221            let gpu_image = self.image_ctx.create_image_labeled(
1222                image_data.pixels(),
1223                image_data.width(),
1224                image_data.height(),
1225                &image.source,
1226            );
1227
1228            // LruCache::put evicts oldest entry if at capacity
1229            self.image_cache.put(image.source.clone(), gpu_image);
1230            // Record load time for fade-in animation
1231            self.image_load_times
1232                .insert(image.source.clone(), web_time::Instant::now());
1233        }
1234    }
1235
1236    /// Pre-load mask images referenced in a primitive batch's layer effects
1237    fn preload_mask_images(&mut self, batch: &PrimitiveBatch) {
1238        use blinc_core::LayerEffect;
1239        for entry in &batch.layer_commands {
1240            if let blinc_gpu::primitives::LayerCommand::Push { config } = &entry.command {
1241                for effect in &config.effects {
1242                    if let LayerEffect::MaskImage { image_url, .. } = effect {
1243                        if self.renderer.has_mask_image(image_url) {
1244                            continue;
1245                        }
1246                        let source = blinc_image::ImageSource::from_uri(image_url);
1247                        if let Ok(data) = blinc_image::ImageData::load(source) {
1248                            self.renderer.load_mask_image_rgba(
1249                                image_url,
1250                                data.pixels(),
1251                                data.width(),
1252                                data.height(),
1253                            );
1254                        }
1255                    }
1256                }
1257            }
1258        }
1259    }
1260
1261    /// Convert a CssFilter into filter_a/filter_b arrays for the image shader.
1262    /// Returns (filter_a, filter_b) where identity = ([0,0,0,0], [1,1,1,0]).
1263    /// Extract mask gradient params and info from a MaskImage gradient.
1264    /// Returns ([mask_params], [mask_info]) or zero arrays if not a gradient.
1265    fn mask_image_to_arrays(mask: Option<&blinc_core::MaskImage>) -> ([f32; 4], [f32; 4]) {
1266        match mask {
1267            Some(blinc_core::MaskImage::Gradient(gradient)) => match gradient {
1268                blinc_core::Gradient::Linear {
1269                    start, end, stops, ..
1270                } => {
1271                    let (sa, ea) = Self::extract_mask_alphas_from_stops(stops);
1272                    ([start.x, start.y, end.x, end.y], [1.0, sa, ea, 0.0])
1273                }
1274                blinc_core::Gradient::Radial {
1275                    center,
1276                    radius,
1277                    stops,
1278                    ..
1279                } => {
1280                    let (sa, ea) = Self::extract_mask_alphas_from_stops(stops);
1281                    ([center.x, center.y, *radius, 0.0], [2.0, sa, ea, 0.0])
1282                }
1283                blinc_core::Gradient::Conic { center, stops, .. } => {
1284                    let (sa, ea) = Self::extract_mask_alphas_from_stops(stops);
1285                    ([center.x, center.y, 0.5, 0.0], [2.0, sa, ea, 0.0])
1286                }
1287            },
1288            _ => ([0.0; 4], [0.0; 4]),
1289        }
1290    }
1291
1292    fn extract_mask_alphas_from_stops(stops: &[blinc_core::GradientStop]) -> (f32, f32) {
1293        if stops.is_empty() {
1294            return (1.0, 0.0);
1295        }
1296        (stops[0].color.a, stops[stops.len() - 1].color.a)
1297    }
1298
1299    fn css_filter_to_arrays(
1300        filter: &blinc_layout::element_style::CssFilter,
1301    ) -> ([f32; 4], [f32; 4]) {
1302        (
1303            [
1304                filter.grayscale,
1305                filter.invert,
1306                filter.sepia,
1307                filter.hue_rotate.to_radians(),
1308            ],
1309            [filter.brightness, filter.contrast, filter.saturate, 0.0],
1310        )
1311    }
1312
1313    /// Transform clip bounds and radii by a CSS affine.
1314    /// When a parent div has a CSS transform (e.g. `scale(1.08)` on hover), the image
1315    /// clip must follow the same transform so the image fills the visually-scaled parent.
1316    fn transform_clip_by_affine(
1317        clip: [f32; 4],
1318        clip_radius: [f32; 4],
1319        affine: [f32; 6],
1320        scale_factor: f32,
1321    ) -> ([f32; 4], [f32; 4]) {
1322        let [a, b, c, d, tx, ty] = affine;
1323        let tx_s = tx * scale_factor;
1324        let ty_s = ty * scale_factor;
1325        // Transform clip center through the affine
1326        let ccx = clip[0] + clip[2] * 0.5;
1327        let ccy = clip[1] + clip[3] * 0.5;
1328        let new_cx = a * ccx + c * ccy + tx_s;
1329        let new_cy = b * ccx + d * ccy + ty_s;
1330        // Uniform scale for dimensions
1331        let s = (a * d - b * c).abs().sqrt().max(1e-6);
1332        let new_clip = [
1333            new_cx - clip[2] * s * 0.5,
1334            new_cy - clip[3] * s * 0.5,
1335            clip[2] * s,
1336            clip[3] * s,
1337        ];
1338        let new_radius = [
1339            clip_radius[0] * s,
1340            clip_radius[1] * s,
1341            clip_radius[2] * s,
1342            clip_radius[3] * s,
1343        ];
1344        (new_clip, new_radius)
1345    }
1346
1347    /// Decompose a CSS affine [a,b,c,d,tx,ty] into position and 2x2 transform for image rendering.
1348    /// Input: original rect (already DPI-scaled), affine (layout coords), scale_factor.
1349    /// Returns: (draw_x, draw_y, draw_w, draw_h, transform_a, transform_b, transform_c, transform_d)
1350    /// The 2x2 matrix [a, b, c, d] is passed to the shader for full affine support (rotation, scale, skew).
1351    fn decompose_image_affine(
1352        x: f32,
1353        y: f32,
1354        w: f32,
1355        h: f32,
1356        affine: [f32; 6],
1357        scale_factor: f32,
1358    ) -> (f32, f32, f32, f32, f32, f32, f32, f32) {
1359        let [a, b, c, d, tx, ty] = affine;
1360        // DPI-scale the translation components
1361        let tx_s = tx * scale_factor;
1362        let ty_s = ty * scale_factor;
1363        // Transform center through the affine (positions are already in screen space)
1364        let cx = x + w * 0.5;
1365        let cy = y + h * 0.5;
1366        let new_cx = a * cx + c * cy + tx_s;
1367        let new_cy = b * cx + d * cy + ty_s;
1368        // Pass original bounds — the 2x2 transform is applied in the shader around the center
1369        (new_cx - w * 0.5, new_cy - h * 0.5, w, h, a, b, c, d)
1370    }
1371
1372    /// Render images to target (images must be preloaded first)
1373    fn render_images(
1374        &mut self,
1375        target: &wgpu::TextureView,
1376        images: &[ImageElement],
1377        viewport_width: f32,
1378        viewport_height: f32,
1379        scale_factor: f32,
1380    ) {
1381        use blinc_image::{calculate_fit_rects, src_rect_to_uv, ObjectFit, ObjectPosition};
1382
1383        for image in images {
1384            // Get cached GPU image
1385            let gpu_image = self.image_cache.get(&image.source);
1386
1387            // Compute fade-in opacity multiplier from load time + duration
1388            // Returns 1.0 if no fade configured or fade complete; <1.0 during fade
1389            let fade_factor = if image.fade_duration_ms > 0 && gpu_image.is_some() {
1390                if let Some(loaded_at) = self.image_load_times.get(&image.source) {
1391                    let elapsed_ms = loaded_at.elapsed().as_secs_f32() * 1000.0;
1392                    (elapsed_ms / image.fade_duration_ms as f32).clamp(0.0, 1.0)
1393                } else {
1394                    1.0
1395                }
1396            } else {
1397                1.0
1398            };
1399            if fade_factor < 1.0 {
1400                // Force continuous redraw while fade is in progress
1401                self.has_active_flows = true;
1402            }
1403
1404            // If image is not loaded and has a placeholder, render placeholder
1405            if gpu_image.is_none() && image.placeholder_type != 0 {
1406                match image.placeholder_type {
1407                    // Type 1: Solid color
1408                    1 => {
1409                        let color = blinc_core::Color::rgba(
1410                            image.placeholder_color[0],
1411                            image.placeholder_color[1],
1412                            image.placeholder_color[2],
1413                            image.placeholder_color[3],
1414                        );
1415                        let mut ctx = GpuPaintContext::new(viewport_width, viewport_height);
1416                        let rect =
1417                            blinc_core::Rect::new(image.x, image.y, image.width, image.height);
1418                        ctx.fill_rounded_rect(
1419                            rect,
1420                            blinc_core::CornerRadius::uniform(image.border_radius),
1421                            color,
1422                        );
1423                        let batch = ctx.take_batch();
1424                        self.renderer.render_overlay(target, &batch);
1425                    }
1426                    // Type 2: Image placeholder (e.g., low-res thumbnail or blur hash)
1427                    2 => {
1428                        if let Some(ref placeholder_src) = image.placeholder_image {
1429                            if let Some(placeholder_gpu) = self.image_cache.get(placeholder_src) {
1430                                let (src_rect, dst_rect) = calculate_fit_rects(
1431                                    placeholder_gpu.width(),
1432                                    placeholder_gpu.height(),
1433                                    image.width,
1434                                    image.height,
1435                                    ObjectFit::Cover,
1436                                    ObjectPosition::new(0.5, 0.5),
1437                                );
1438                                let src_uv = src_rect_to_uv(
1439                                    src_rect,
1440                                    placeholder_gpu.width(),
1441                                    placeholder_gpu.height(),
1442                                );
1443                                let instance = GpuImageInstance::new(
1444                                    image.x + dst_rect[0],
1445                                    image.y + dst_rect[1],
1446                                    dst_rect[2],
1447                                    dst_rect[3],
1448                                )
1449                                .with_src_uv(src_uv[0], src_uv[1], src_uv[2], src_uv[3])
1450                                .with_border_radius(image.border_radius)
1451                                .with_opacity(image.opacity);
1452                                self.renderer.render_images(
1453                                    target,
1454                                    placeholder_gpu.view(),
1455                                    &[instance],
1456                                );
1457                            }
1458                        }
1459                    }
1460                    // Type 3: Skeleton shimmer (animated gradient sweep)
1461                    3 => {
1462                        let t =
1463                            self.frame_count.saturating_mul(16).rem_euclid(2400) as f32 / 2400.0;
1464                        let base_a = image.placeholder_color[3].max(0.4);
1465                        let base = blinc_core::Color::rgba(
1466                            image.placeholder_color[0],
1467                            image.placeholder_color[1],
1468                            image.placeholder_color[2],
1469                            base_a,
1470                        );
1471                        let highlight_a = (base_a + 0.25).min(1.0);
1472                        let highlight = blinc_core::Color::rgba(
1473                            (image.placeholder_color[0] + 0.15).min(1.0),
1474                            (image.placeholder_color[1] + 0.15).min(1.0),
1475                            (image.placeholder_color[2] + 0.15).min(1.0),
1476                            highlight_a,
1477                        );
1478                        let mut ctx = GpuPaintContext::new(viewport_width, viewport_height);
1479                        let rect =
1480                            blinc_core::Rect::new(image.x, image.y, image.width, image.height);
1481                        // Base background
1482                        ctx.fill_rounded_rect(
1483                            rect,
1484                            blinc_core::CornerRadius::uniform(image.border_radius),
1485                            base,
1486                        );
1487                        // Shimmer band — narrow vertical strip swept horizontally
1488                        let band_w = (image.width * 0.25).max(40.0);
1489                        let band_x = image.x + (image.width + band_w) * t - band_w;
1490                        let band_rect =
1491                            blinc_core::Rect::new(band_x, image.y, band_w, image.height);
1492                        ctx.fill_rounded_rect(
1493                            band_rect,
1494                            blinc_core::CornerRadius::uniform(image.border_radius),
1495                            highlight,
1496                        );
1497                        let batch = ctx.take_batch();
1498                        self.renderer.render_overlay(target, &batch);
1499                        // Mark frame as needing redraw for animation
1500                        self.has_active_flows = true;
1501                    }
1502                    _ => {}
1503                }
1504                continue;
1505            }
1506
1507            let Some(gpu_image) = gpu_image else {
1508                continue; // Skip images that failed to load
1509            };
1510
1511            // Convert object_fit byte to ObjectFit enum
1512            let object_fit = match image.object_fit {
1513                0 => ObjectFit::Cover,
1514                1 => ObjectFit::Contain,
1515                2 => ObjectFit::Fill,
1516                3 => ObjectFit::ScaleDown,
1517                4 => ObjectFit::None,
1518                _ => ObjectFit::Cover,
1519            };
1520
1521            // Create ObjectPosition from array
1522            let object_position =
1523                ObjectPosition::new(image.object_position[0], image.object_position[1]);
1524
1525            // Calculate fit rectangles
1526            let (src_rect, dst_rect) = calculate_fit_rects(
1527                gpu_image.width(),
1528                gpu_image.height(),
1529                image.width,
1530                image.height,
1531                object_fit,
1532                object_position,
1533            );
1534
1535            // Convert src_rect to UV coordinates
1536            let src_uv = src_rect_to_uv(src_rect, gpu_image.width(), gpu_image.height());
1537
1538            // Apply CSS affine transform if present
1539            let base_x = image.x + dst_rect[0];
1540            let base_y = image.y + dst_rect[1];
1541            let base_w = dst_rect[2];
1542            let base_h = dst_rect[3];
1543
1544            let (draw_x, draw_y, draw_w, draw_h, ta, tb, tc, td) = if let Some(affine) =
1545                image.css_affine
1546            {
1547                Self::decompose_image_affine(base_x, base_y, base_w, base_h, affine, scale_factor)
1548            } else {
1549                (base_x, base_y, base_w, base_h, 1.0, 0.0, 0.0, 1.0)
1550            };
1551
1552            // Pre-compute effective clip (transformed by CSS affine if present)
1553            let effective_clip = image.clip_bounds.map(|clip| {
1554                if let Some(affine) = image.css_affine {
1555                    Self::transform_clip_by_affine(clip, image.clip_radius, affine, scale_factor)
1556                } else {
1557                    (clip, image.clip_radius)
1558                }
1559            });
1560
1561            // Render shadow before image if present
1562            if let Some(ref shadow) = image.shadow {
1563                let mut shadow_ctx = GpuPaintContext::new(viewport_width, viewport_height);
1564                // Push scroll/parent clip so shadow doesn't escape the container
1565                if let Some(clip) = image.clip_bounds {
1566                    shadow_ctx.push_clip(blinc_core::ClipShape::RoundedRect {
1567                        rect: blinc_core::Rect::new(clip[0], clip[1], clip[2], clip[3]),
1568                        corner_radius: blinc_core::CornerRadius {
1569                            top_left: image.clip_radius[0],
1570                            top_right: image.clip_radius[1],
1571                            bottom_right: image.clip_radius[2],
1572                            bottom_left: image.clip_radius[3],
1573                        },
1574                    });
1575                }
1576                let shadow_rect =
1577                    blinc_core::Rect::new(image.x, image.y, image.width, image.height);
1578                let shadow_radius = blinc_core::CornerRadius::uniform(image.border_radius);
1579                shadow_ctx.draw_shadow(shadow_rect, shadow_radius, *shadow);
1580                let shadow_batch = shadow_ctx.take_batch();
1581                self.renderer.render_overlay(target, &shadow_batch);
1582            }
1583
1584            // Create GPU instance with proper positioning
1585            let mut instance = GpuImageInstance::new(draw_x, draw_y, draw_w, draw_h)
1586                .with_src_uv(src_uv[0], src_uv[1], src_uv[2], src_uv[3])
1587                .with_tint(image.tint[0], image.tint[1], image.tint[2], image.tint[3])
1588                .with_border_radius(image.border_radius)
1589                .with_opacity(image.opacity * fade_factor)
1590                .with_transform(ta, tb, tc, td)
1591                .with_filter(image.filter_a, image.filter_b);
1592
1593            // Render border inside the image shader (same SDF, perfect transform alignment)
1594            if image.border_width > 0.0 {
1595                instance = instance.with_image_border(
1596                    image.border_width,
1597                    image.border_color.r,
1598                    image.border_color.g,
1599                    image.border_color.b,
1600                    image.border_color.a,
1601                );
1602            }
1603
1604            // Apply mask gradient
1605            if image.mask_info[0] > 0.5 {
1606                instance.mask_params = image.mask_params;
1607                instance.mask_info = image.mask_info;
1608            }
1609
1610            // Apply clip bounds (primary rounded clip)
1611            if let Some((clip, clip_r)) = effective_clip {
1612                instance = instance.with_clip_rounded_rect_corners(
1613                    clip[0], clip[1], clip[2], clip[3], clip_r[0], clip_r[1], clip_r[2], clip_r[3],
1614                );
1615            }
1616            // Apply secondary scroll clip (sharp rect)
1617            if let Some(sc) = image.scroll_clip {
1618                instance = instance.with_clip2_rect(sc[0], sc[1], sc[2], sc[3]);
1619            }
1620
1621            // Render the image
1622            self.renderer
1623                .render_images(target, gpu_image.view(), &[instance]);
1624        }
1625    }
1626
1627    /// Render images to target from references (images must be preloaded first)
1628    fn render_images_ref(&mut self, target: &wgpu::TextureView, images: &[&ImageElement]) {
1629        use blinc_image::{calculate_fit_rects, src_rect_to_uv, ObjectFit, ObjectPosition};
1630
1631        for image in images {
1632            // Get cached GPU image
1633            let Some(gpu_image) = self.image_cache.get(&image.source) else {
1634                continue; // Skip images that failed to load
1635            };
1636
1637            // Compute fade-in opacity multiplier
1638            let fade_factor = if image.fade_duration_ms > 0 {
1639                if let Some(loaded_at) = self.image_load_times.get(&image.source) {
1640                    let elapsed_ms = loaded_at.elapsed().as_secs_f32() * 1000.0;
1641                    (elapsed_ms / image.fade_duration_ms as f32).clamp(0.0, 1.0)
1642                } else {
1643                    1.0
1644                }
1645            } else {
1646                1.0
1647            };
1648            if fade_factor < 1.0 {
1649                self.has_active_flows = true;
1650            }
1651
1652            // Convert object_fit byte to ObjectFit enum
1653            let object_fit = match image.object_fit {
1654                0 => ObjectFit::Cover,
1655                1 => ObjectFit::Contain,
1656                2 => ObjectFit::Fill,
1657                3 => ObjectFit::ScaleDown,
1658                4 => ObjectFit::None,
1659                _ => ObjectFit::Cover,
1660            };
1661
1662            // Create ObjectPosition from array
1663            let object_position =
1664                ObjectPosition::new(image.object_position[0], image.object_position[1]);
1665
1666            // Calculate fit rectangles
1667            let (src_rect, dst_rect) = calculate_fit_rects(
1668                gpu_image.width(),
1669                gpu_image.height(),
1670                image.width,
1671                image.height,
1672                object_fit,
1673                object_position,
1674            );
1675
1676            // Convert src_rect to UV coordinates
1677            let src_uv = src_rect_to_uv(src_rect, gpu_image.width(), gpu_image.height());
1678
1679            // Apply CSS affine transform if present
1680            let base_x = image.x + dst_rect[0];
1681            let base_y = image.y + dst_rect[1];
1682            let base_w = dst_rect[2];
1683            let base_h = dst_rect[3];
1684
1685            // render_images_ref is called for backdrop images; no scale_factor available,
1686            // but affine translation is already in screen coords for backdrop path
1687            let (draw_x, draw_y, draw_w, draw_h, ta, tb, tc, td) =
1688                if let Some(affine) = image.css_affine {
1689                    Self::decompose_image_affine(base_x, base_y, base_w, base_h, affine, 1.0)
1690                } else {
1691                    (base_x, base_y, base_w, base_h, 1.0, 0.0, 0.0, 1.0)
1692                };
1693
1694            // Pre-compute effective clip (transformed by CSS affine if present)
1695            let effective_clip = image.clip_bounds.map(|clip| {
1696                if let Some(affine) = image.css_affine {
1697                    Self::transform_clip_by_affine(clip, image.clip_radius, affine, 1.0)
1698                } else {
1699                    (clip, image.clip_radius)
1700                }
1701            });
1702
1703            // Create GPU instance with proper positioning
1704            let mut instance = GpuImageInstance::new(draw_x, draw_y, draw_w, draw_h)
1705                .with_src_uv(src_uv[0], src_uv[1], src_uv[2], src_uv[3])
1706                .with_tint(image.tint[0], image.tint[1], image.tint[2], image.tint[3])
1707                .with_border_radius(image.border_radius)
1708                .with_opacity(image.opacity * fade_factor)
1709                .with_transform(ta, tb, tc, td)
1710                .with_filter(image.filter_a, image.filter_b);
1711
1712            // Render border inside the image shader (same SDF, perfect transform alignment)
1713            if image.border_width > 0.0 {
1714                instance = instance.with_image_border(
1715                    image.border_width,
1716                    image.border_color.r,
1717                    image.border_color.g,
1718                    image.border_color.b,
1719                    image.border_color.a,
1720                );
1721            }
1722
1723            // Apply mask gradient
1724            if image.mask_info[0] > 0.5 {
1725                instance.mask_params = image.mask_params;
1726                instance.mask_info = image.mask_info;
1727            }
1728
1729            // Apply clip bounds (primary rounded clip)
1730            if let Some((clip, clip_r)) = effective_clip {
1731                instance = instance.with_clip_rounded_rect_corners(
1732                    clip[0], clip[1], clip[2], clip[3], clip_r[0], clip_r[1], clip_r[2], clip_r[3],
1733                );
1734            }
1735            // Apply secondary scroll clip (sharp rect)
1736            if let Some(sc) = image.scroll_clip {
1737                instance = instance.with_clip2_rect(sc[0], sc[1], sc[2], sc[3]);
1738            }
1739
1740            // Render the image
1741            self.renderer
1742                .render_images(target, gpu_image.view(), &[instance]);
1743        }
1744    }
1745
1746    /// Render an SVG element with clipping and opacity support
1747    fn render_svg_element(&mut self, ctx: &mut GpuPaintContext, svg: &SvgElement) {
1748        // Skip completely transparent SVGs
1749        if svg.motion_opacity <= 0.001 {
1750            return;
1751        }
1752
1753        // Skip SVGs completely outside their clip bounds
1754        if let Some([clip_x, clip_y, clip_w, clip_h]) = svg.clip_bounds {
1755            let svg_right = svg.x + svg.width;
1756            let svg_bottom = svg.y + svg.height;
1757            let clip_right = clip_x + clip_w;
1758            let clip_bottom = clip_y + clip_h;
1759
1760            // Check if SVG is completely outside clip bounds
1761            if svg.x >= clip_right
1762                || svg_right <= clip_x
1763                || svg.y >= clip_bottom
1764                || svg_bottom <= clip_y
1765            {
1766                return;
1767            }
1768        }
1769
1770        // Hash the SVG source for cache lookup (faster than using string as key)
1771        let svg_hash = {
1772            let mut hasher = DefaultHasher::new();
1773            svg.source.hash(&mut hasher);
1774            hasher.finish()
1775        };
1776
1777        // Try cache lookup first, parse only on miss
1778        let doc = if let Some(cached) = self.svg_cache.get(&svg_hash) {
1779            cached.clone()
1780        } else {
1781            let Ok(parsed) = SvgDocument::from_str(&svg.source) else {
1782                return;
1783            };
1784            self.svg_cache.put(svg_hash, parsed.clone());
1785            parsed
1786        };
1787
1788        // Apply clipping if present
1789        if let Some([clip_x, clip_y, clip_w, clip_h]) = svg.clip_bounds {
1790            ctx.push_clip(blinc_core::ClipShape::rect(Rect::new(
1791                clip_x, clip_y, clip_w, clip_h,
1792            )));
1793        }
1794
1795        // Apply opacity if not fully opaque
1796        if svg.motion_opacity < 1.0 {
1797            ctx.push_opacity(svg.motion_opacity);
1798        }
1799
1800        // Render the SVG with optional CSS overrides
1801        let has_css_overrides = svg.tint.is_some()
1802            || svg.fill.is_some()
1803            || svg.stroke.is_some()
1804            || svg.stroke_width.is_some();
1805        if has_css_overrides {
1806            self.render_svg_with_overrides(
1807                ctx,
1808                &doc,
1809                svg.x,
1810                svg.y,
1811                svg.width,
1812                svg.height,
1813                svg.tint,
1814                svg.fill,
1815                svg.stroke,
1816                svg.stroke_width,
1817            );
1818        } else {
1819            doc.render_fit(ctx, Rect::new(svg.x, svg.y, svg.width, svg.height));
1820        }
1821
1822        // Pop opacity if applied
1823        if svg.motion_opacity < 1.0 {
1824            ctx.pop_opacity();
1825        }
1826
1827        // Pop clip if applied
1828        if svg.clip_bounds.is_some() {
1829            ctx.pop_clip();
1830        }
1831    }
1832
1833    /// Render an SVG with CSS overrides for fill, stroke, stroke-width, and tint
1834    #[allow(clippy::too_many_arguments)]
1835    fn render_svg_with_overrides(
1836        &self,
1837        ctx: &mut GpuPaintContext,
1838        doc: &SvgDocument,
1839        x: f32,
1840        y: f32,
1841        width: f32,
1842        height: f32,
1843        tint: Option<blinc_core::Color>,
1844        fill: Option<blinc_core::Color>,
1845        stroke: Option<blinc_core::Color>,
1846        stroke_width: Option<f32>,
1847    ) {
1848        use blinc_svg::SvgDrawCommand;
1849
1850        // Calculate scale to fit within bounds while maintaining aspect ratio
1851        let scale_x = width / doc.width;
1852        let scale_y = height / doc.height;
1853        let scale = scale_x.min(scale_y);
1854
1855        // Center within bounds
1856        let scaled_width = doc.width * scale;
1857        let scaled_height = doc.height * scale;
1858        let offset_x = x + (width - scaled_width) / 2.0;
1859        let offset_y = y + (height - scaled_height) / 2.0;
1860
1861        let commands = doc.commands();
1862
1863        for cmd in commands {
1864            match cmd {
1865                SvgDrawCommand::FillPath { path, brush } => {
1866                    let scaled = scale_and_translate_path(&path, offset_x, offset_y, scale);
1867                    // Priority: fill > tint > original brush
1868                    let fill_brush = if let Some(f) = fill {
1869                        Brush::Solid(f)
1870                    } else if let Some(t) = tint {
1871                        Brush::Solid(t)
1872                    } else {
1873                        brush.clone()
1874                    };
1875                    ctx.fill_path(&scaled, fill_brush);
1876                }
1877                SvgDrawCommand::StrokePath {
1878                    path,
1879                    stroke: orig_stroke,
1880                    brush,
1881                } => {
1882                    let scaled = scale_and_translate_path(&path, offset_x, offset_y, scale);
1883                    // Apply stroke-width override or scale original
1884                    let sw = stroke_width.unwrap_or(orig_stroke.width) * scale;
1885                    let scaled_stroke = Stroke::new(sw)
1886                        .with_cap(orig_stroke.cap)
1887                        .with_join(orig_stroke.join);
1888                    // Priority: stroke > tint > original brush
1889                    let stroke_brush = if let Some(s) = stroke {
1890                        Brush::Solid(s)
1891                    } else if let Some(t) = tint {
1892                        Brush::Solid(t)
1893                    } else {
1894                        brush.clone()
1895                    };
1896                    ctx.stroke_path(&scaled, &scaled_stroke, stroke_brush);
1897                }
1898            }
1899        }
1900    }
1901
1902    /// Render SVG elements using CPU rasterization for high-quality anti-aliased output
1903    ///
1904    /// This method rasterizes SVGs using resvg/tiny-skia and renders them as textures,
1905    /// providing much better anti-aliasing than tessellation-based path rendering.
1906    ///
1907    /// The `scale_factor` parameter is the display's DPI scale (e.g., 2.0 for Retina).
1908    /// SVGs are rasterized at physical pixel resolution for crisp rendering on HiDPI displays.
1909    fn render_rasterized_svgs(
1910        &mut self,
1911        target: &wgpu::TextureView,
1912        svgs: &[SvgElement],
1913        scale_factor: f32,
1914    ) {
1915        // Evict stale atlas entries from the previous frame BEFORE
1916        // the loop so every UV coordinate computed below stays valid
1917        // for the entire render pass. Doing this mid-loop (inside
1918        // `insert`) would repack surviving entries to new shelf
1919        // positions, invalidating UVs already pushed into the
1920        // instance buffer → visible blink on animated SVGs.
1921        self.svg_atlas.begin_frame(&self.device);
1922
1923        // Collect all instances for a single batched draw call
1924        let mut instances: Vec<GpuImageInstance> = Vec::with_capacity(svgs.len());
1925
1926        for svg in svgs {
1927            // Skip completely transparent SVGs
1928            if svg.motion_opacity <= 0.001 {
1929                continue;
1930            }
1931
1932            // Skip SVGs completely outside their clip bounds
1933            if let Some([clip_x, clip_y, clip_w, clip_h]) = svg.clip_bounds {
1934                let svg_right = svg.x + svg.width;
1935                let svg_bottom = svg.y + svg.height;
1936                let clip_right = clip_x + clip_w;
1937                let clip_bottom = clip_y + clip_h;
1938
1939                if svg.x >= clip_right
1940                    || svg_right <= clip_x
1941                    || svg.y >= clip_bottom
1942                    || svg_bottom <= clip_y
1943                {
1944                    continue;
1945                }
1946            }
1947
1948            // Rasterize at physical pixel resolution.
1949            //
1950            // `svg.width` / `svg.height` come out of `collect_elements_recursive`
1951            // already multiplied by `tree.scale_factor()` (see the SVG branch
1952            // around line 3066: `base_width = bounds.width * scale`), so they
1953            // are in *physical* pixels, not logical pixels — the same units
1954            // the GPU draw quad will be sized in. Multiplying by `scale_factor`
1955            // a second time here used to rasterize each icon at 4× its drawn
1956            // area on Retina (9× on 3× DPR), bloating the SVG atlas
1957            // (`cn_demo` was hitting the 4096×4096 ceiling on the first frame
1958            // and burning ~134 MB of CPU+GPU memory between the two mirror
1959            // buffers in `svg_atlas.rs`). resvg already does sub-pixel AA at
1960            // the target resolution, so 1:1 physical-pixel rasterization is
1961            // sharp enough; if a future workload turns up edge cases that
1962            // need supersampling, gate it behind an explicit knob rather than
1963            // a silent multiply.
1964            let raster_width = (svg.width.ceil() as u32).max(1);
1965            let raster_height = (svg.height.ceil() as u32).max(1);
1966
1967            // Detect tintable SVGs: simple currentColor icons that can use shader tinting
1968            // instead of CPU re-rasterization per color variant.
1969            // Tintable = has tint, no other overrides, source uses currentColor.
1970            let is_tintable = svg.tint.is_some()
1971                && svg.fill.is_none()
1972                && svg.stroke.is_none()
1973                && svg.stroke_width.is_none()
1974                && svg.stroke_dasharray.is_none()
1975                && svg.stroke_dashoffset.is_none()
1976                && svg.svg_path_data.is_none()
1977                && svg.tag_overrides.is_empty()
1978                && svg.source.contains("currentColor");
1979
1980            // Compute cache key: hash of (svg_source, width, height, scale, tint, fill, stroke, stroke_width)
1981            // For tintable SVGs, exclude tint from hash so all color variants share one texture.
1982            let cache_key = {
1983                let mut hasher = DefaultHasher::new();
1984                svg.source.hash(&mut hasher);
1985                raster_width.hash(&mut hasher);
1986                raster_height.hash(&mut hasher);
1987                if is_tintable {
1988                    // Sentinel byte to distinguish from non-tintable hashes
1989                    255u8.hash(&mut hasher);
1990                } else if let Some(tint) = &svg.tint {
1991                    tint.r.to_bits().hash(&mut hasher);
1992                    tint.g.to_bits().hash(&mut hasher);
1993                    tint.b.to_bits().hash(&mut hasher);
1994                    tint.a.to_bits().hash(&mut hasher);
1995                }
1996                if let Some(fill) = &svg.fill {
1997                    1u8.hash(&mut hasher);
1998                    fill.r.to_bits().hash(&mut hasher);
1999                    fill.g.to_bits().hash(&mut hasher);
2000                    fill.b.to_bits().hash(&mut hasher);
2001                    fill.a.to_bits().hash(&mut hasher);
2002                }
2003                if let Some(stroke) = &svg.stroke {
2004                    2u8.hash(&mut hasher);
2005                    stroke.r.to_bits().hash(&mut hasher);
2006                    stroke.g.to_bits().hash(&mut hasher);
2007                    stroke.b.to_bits().hash(&mut hasher);
2008                    stroke.a.to_bits().hash(&mut hasher);
2009                }
2010                if let Some(sw) = &svg.stroke_width {
2011                    3u8.hash(&mut hasher);
2012                    sw.to_bits().hash(&mut hasher);
2013                }
2014                if let Some(ref da) = svg.stroke_dasharray {
2015                    4u8.hash(&mut hasher);
2016                    for v in da {
2017                        v.to_bits().hash(&mut hasher);
2018                    }
2019                }
2020                if let Some(offset) = &svg.stroke_dashoffset {
2021                    5u8.hash(&mut hasher);
2022                    offset.to_bits().hash(&mut hasher);
2023                }
2024                if let Some(ref path_data) = svg.svg_path_data {
2025                    6u8.hash(&mut hasher);
2026                    path_data.hash(&mut hasher);
2027                }
2028                // Hash per-tag style overrides
2029                if !svg.tag_overrides.is_empty() {
2030                    7u8.hash(&mut hasher);
2031                    // Sort keys for deterministic hashing
2032                    let mut keys: Vec<&String> = svg.tag_overrides.keys().collect();
2033                    keys.sort();
2034                    for key in keys {
2035                        key.hash(&mut hasher);
2036                        if let Some(ts) = svg.tag_overrides.get(key) {
2037                            if let Some(f) = &ts.fill {
2038                                for v in f {
2039                                    v.to_bits().hash(&mut hasher);
2040                                }
2041                            }
2042                            if let Some(s) = &ts.stroke {
2043                                for v in s {
2044                                    v.to_bits().hash(&mut hasher);
2045                                }
2046                            }
2047                            if let Some(sw) = &ts.stroke_width {
2048                                sw.to_bits().hash(&mut hasher);
2049                            }
2050                            if let Some(op) = &ts.opacity {
2051                                op.to_bits().hash(&mut hasher);
2052                            }
2053                        }
2054                    }
2055                }
2056                hasher.finish()
2057            };
2058
2059            // Check atlas first — skip string manipulation entirely on cache hit
2060            if self.svg_atlas.get(cache_key).is_none() {
2061                // Cache miss: build SVG source with inline attribute overrides
2062                let has_overrides = svg.tint.is_some()
2063                    || svg.fill.is_some()
2064                    || svg.stroke.is_some()
2065                    || svg.stroke_width.is_some()
2066                    || svg.stroke_dasharray.is_some()
2067                    || svg.stroke_dashoffset.is_some()
2068                    || svg.svg_path_data.is_some()
2069                    || !svg.tag_overrides.is_empty();
2070
2071                fn color_val(c: blinc_core::Color) -> String {
2072                    if c.a < 1.0 {
2073                        format!(
2074                            "rgba({},{},{},{})",
2075                            (c.r * 255.0) as u8,
2076                            (c.g * 255.0) as u8,
2077                            (c.b * 255.0) as u8,
2078                            c.a
2079                        )
2080                    } else {
2081                        format!(
2082                            "#{:02x}{:02x}{:02x}",
2083                            (c.r * 255.0) as u8,
2084                            (c.g * 255.0) as u8,
2085                            (c.b * 255.0) as u8
2086                        )
2087                    }
2088                }
2089
2090                let effective_source = if has_overrides {
2091                    // Build attribute string to inject into the root <svg> tag
2092                    let mut svg_attrs = String::new();
2093                    if let Some(fill) = svg.fill {
2094                        svg_attrs.push_str(&format!(r#" fill="{}""#, color_val(fill)));
2095                    }
2096                    if let Some(stroke) = svg.stroke {
2097                        svg_attrs.push_str(&format!(r#" stroke="{}""#, color_val(stroke)));
2098                    }
2099                    if let Some(sw) = svg.stroke_width {
2100                        svg_attrs.push_str(&format!(r#" stroke-width="{}""#, sw));
2101                    }
2102                    if let Some(ref da) = svg.stroke_dasharray {
2103                        let da_str = da
2104                            .iter()
2105                            .map(|v| v.to_string())
2106                            .collect::<Vec<_>>()
2107                            .join(",");
2108                        svg_attrs.push_str(&format!(r#" stroke-dasharray="{}""#, da_str));
2109                    }
2110                    if let Some(offset) = svg.stroke_dashoffset {
2111                        svg_attrs.push_str(&format!(r#" stroke-dashoffset="{}""#, offset));
2112                    }
2113
2114                    // Strip existing attribute from a tag region in the SVG string.
2115                    fn strip_attr(s: &mut String, tag_start: usize, tag_end: usize, attr: &str) {
2116                        let region = &s[tag_start..tag_end];
2117                        let attr_eq = format!("{}=", attr);
2118                        if let Some(attr_offset) = region.find(&attr_eq) {
2119                            let abs_attr = tag_start + attr_offset;
2120                            let after_eq = abs_attr + attr.len() + 1;
2121                            if after_eq < s.len() {
2122                                let quote = s.as_bytes()[after_eq];
2123                                if quote == b'"' || quote == b'\'' {
2124                                    if let Some(end_quote) = s[after_eq + 1..].find(quote as char) {
2125                                        let remove_end = after_eq + 1 + end_quote + 1;
2126                                        let remove_start =
2127                                            if abs_attr > 0 && s.as_bytes()[abs_attr - 1] == b' ' {
2128                                                abs_attr - 1
2129                                            } else {
2130                                                abs_attr
2131                                            };
2132                                        s.replace_range(remove_start..remove_end, "");
2133                                    }
2134                                }
2135                            }
2136                        }
2137                    }
2138
2139                    let mut modified = String::from(&*svg.source);
2140
2141                    // Strip existing attributes from the <svg> tag
2142                    if let Some(svg_close) = modified.find('>') {
2143                        if svg.stroke.is_some() {
2144                            strip_attr(&mut modified, 0, svg_close, "stroke");
2145                        }
2146                        if svg.fill.is_some() {
2147                            let svg_close = modified.find('>').unwrap_or(0);
2148                            strip_attr(&mut modified, 0, svg_close, "fill");
2149                        }
2150                        if svg.stroke_width.is_some() {
2151                            let svg_close = modified.find('>').unwrap_or(0);
2152                            strip_attr(&mut modified, 0, svg_close, "stroke-width");
2153                        }
2154                        if svg.stroke_dasharray.is_some() {
2155                            let svg_close = modified.find('>').unwrap_or(0);
2156                            strip_attr(&mut modified, 0, svg_close, "stroke-dasharray");
2157                        }
2158                        if svg.stroke_dashoffset.is_some() {
2159                            let svg_close = modified.find('>').unwrap_or(0);
2160                            strip_attr(&mut modified, 0, svg_close, "stroke-dashoffset");
2161                        }
2162                    }
2163
2164                    // Insert new attributes into the opening <svg tag
2165                    if !svg_attrs.is_empty() {
2166                        if let Some(pos) = modified.find('>') {
2167                            let insert_pos = if pos > 0 && modified.as_bytes()[pos - 1] == b'/' {
2168                                pos - 1
2169                            } else {
2170                                pos
2171                            };
2172                            modified.insert_str(insert_pos, &svg_attrs);
2173                        }
2174                    }
2175
2176                    // Override fill/stroke on individual shape elements
2177                    let shape_tags = [
2178                        "<path",
2179                        "<circle",
2180                        "<rect",
2181                        "<polygon",
2182                        "<line",
2183                        "<ellipse",
2184                        "<polyline",
2185                    ];
2186                    for tag in &shape_tags {
2187                        let tag_name = tag.trim_start_matches('<');
2188                        let tag_style = svg.tag_overrides.get(tag_name);
2189
2190                        // Per-tag overrides take priority over global element-level overrides
2191                        let effective_fill: Option<blinc_core::Color> = tag_style
2192                            .and_then(|ts| ts.fill)
2193                            .map(|c| blinc_core::Color::rgba(c[0], c[1], c[2], c[3]))
2194                            .or(svg.fill);
2195                        let effective_stroke: Option<blinc_core::Color> = tag_style
2196                            .and_then(|ts| ts.stroke)
2197                            .map(|c| blinc_core::Color::rgba(c[0], c[1], c[2], c[3]))
2198                            .or(svg.stroke);
2199                        let effective_stroke_width: Option<f32> = tag_style
2200                            .and_then(|ts| ts.stroke_width)
2201                            .or(svg.stroke_width);
2202                        let effective_dasharray: Option<Vec<f32>> = tag_style
2203                            .and_then(|ts| ts.stroke_dasharray.clone())
2204                            .or_else(|| svg.stroke_dasharray.clone());
2205                        let effective_dashoffset: Option<f32> = tag_style
2206                            .and_then(|ts| ts.stroke_dashoffset)
2207                            .or(svg.stroke_dashoffset);
2208                        let effective_opacity: Option<f32> = tag_style.and_then(|ts| ts.opacity);
2209
2210                        let mut search_from = 0;
2211                        while let Some(tag_start) = modified[search_from..].find(tag) {
2212                            let abs_tag = search_from + tag_start;
2213                            let abs_start = abs_tag + tag.len();
2214                            if let Some(close) = modified[abs_start..].find('>') {
2215                                let abs_close = abs_start + close;
2216
2217                                if effective_stroke.is_some() {
2218                                    strip_attr(&mut modified, abs_tag, abs_close, "stroke-width");
2219                                    let new_close = abs_start
2220                                        + modified[abs_start..].find('>').unwrap_or(close);
2221                                    strip_attr(&mut modified, abs_tag, new_close, "stroke");
2222                                }
2223                                if effective_fill.is_some() {
2224                                    let new_close = abs_start
2225                                        + modified[abs_start..].find('>').unwrap_or(close);
2226                                    strip_attr(&mut modified, abs_tag, new_close, "fill");
2227                                }
2228                                if effective_stroke_width.is_some() {
2229                                    let new_close = abs_start
2230                                        + modified[abs_start..].find('>').unwrap_or(close);
2231                                    strip_attr(&mut modified, abs_tag, new_close, "stroke-width");
2232                                }
2233                                if effective_dasharray.is_some() {
2234                                    let new_close = abs_start
2235                                        + modified[abs_start..].find('>').unwrap_or(close);
2236                                    strip_attr(
2237                                        &mut modified,
2238                                        abs_tag,
2239                                        new_close,
2240                                        "stroke-dasharray",
2241                                    );
2242                                }
2243                                if effective_dashoffset.is_some() {
2244                                    let new_close = abs_start
2245                                        + modified[abs_start..].find('>').unwrap_or(close);
2246                                    strip_attr(
2247                                        &mut modified,
2248                                        abs_tag,
2249                                        new_close,
2250                                        "stroke-dashoffset",
2251                                    );
2252                                }
2253                                if effective_opacity.is_some() {
2254                                    let new_close = abs_start
2255                                        + modified[abs_start..].find('>').unwrap_or(close);
2256                                    strip_attr(&mut modified, abs_tag, new_close, "opacity");
2257                                }
2258                                if svg.svg_path_data.is_some() && *tag == "<path" {
2259                                    let new_close = abs_start
2260                                        + modified[abs_start..].find('>').unwrap_or(close);
2261                                    strip_attr(&mut modified, abs_tag, new_close, "d");
2262                                }
2263
2264                                // Recompute close position after stripping
2265                                let abs_close =
2266                                    abs_start + modified[abs_start..].find('>').unwrap_or(0);
2267                                let is_self_close =
2268                                    abs_close > 0 && modified.as_bytes()[abs_close - 1] == b'/';
2269                                let insert_at = if is_self_close {
2270                                    abs_close - 1
2271                                } else {
2272                                    abs_close
2273                                };
2274                                let mut elem_attrs = String::new();
2275                                if let Some(fill) = effective_fill {
2276                                    elem_attrs.push_str(&format!(r#" fill="{}""#, color_val(fill)));
2277                                }
2278                                if let Some(stroke) = effective_stroke {
2279                                    elem_attrs
2280                                        .push_str(&format!(r#" stroke="{}""#, color_val(stroke)));
2281                                }
2282                                if let Some(sw) = effective_stroke_width {
2283                                    elem_attrs.push_str(&format!(r#" stroke-width="{}""#, sw));
2284                                }
2285                                if let Some(ref da) = effective_dasharray {
2286                                    let da_str = da
2287                                        .iter()
2288                                        .map(|v| v.to_string())
2289                                        .collect::<Vec<_>>()
2290                                        .join(",");
2291                                    elem_attrs
2292                                        .push_str(&format!(r#" stroke-dasharray="{}""#, da_str));
2293                                }
2294                                if let Some(offset) = effective_dashoffset {
2295                                    elem_attrs
2296                                        .push_str(&format!(r#" stroke-dashoffset="{}""#, offset));
2297                                }
2298                                if let Some(opacity) = effective_opacity {
2299                                    elem_attrs.push_str(&format!(r#" opacity="{}""#, opacity));
2300                                }
2301                                if let Some(ref path_data) = svg.svg_path_data {
2302                                    if *tag == "<path" {
2303                                        elem_attrs.push_str(&format!(r#" d="{}""#, path_data));
2304                                    }
2305                                }
2306                                modified.insert_str(insert_at, &elem_attrs);
2307                                search_from = insert_at + elem_attrs.len() + 1;
2308                            } else {
2309                                break;
2310                            }
2311                        }
2312                    }
2313
2314                    std::borrow::Cow::Owned(modified)
2315                } else {
2316                    std::borrow::Cow::Borrowed(&*svg.source)
2317                };
2318
2319                // Resolve currentColor references in SVG source.
2320                // For tintable SVGs: rasterize as white — color applied via shader tint.
2321                // For non-tintable: replace with actual tint color for CPU rasterization.
2322                // For SVGs that have a tint but no currentColor at all (e.g.
2323                // hard-coded `fill="white"`), the post-rasterize `apply_tint`
2324                // path below handles it instead.
2325                let has_current_color = effective_source.contains("currentColor");
2326                let needs_post_raster_tint =
2327                    !is_tintable && svg.tint.is_some() && !has_current_color;
2328                let final_source = if is_tintable {
2329                    std::borrow::Cow::Owned(effective_source.replace("currentColor", "#ffffff"))
2330                } else if let Some(tint) = svg.tint {
2331                    if has_current_color {
2332                        std::borrow::Cow::Owned(
2333                            effective_source.replace("currentColor", &color_val(tint)),
2334                        )
2335                    } else {
2336                        effective_source
2337                    }
2338                } else {
2339                    effective_source
2340                };
2341
2342                let rasterized =
2343                    RasterizedSvg::from_str(&final_source, raster_width, raster_height);
2344
2345                let mut rasterized = match rasterized {
2346                    Ok(r) => r,
2347                    Err(e) => {
2348                        tracing::warn!("Failed to rasterize SVG: {}", e);
2349                        continue;
2350                    }
2351                };
2352
2353                // When a tint color is set but the SVG source doesn't
2354                // use `currentColor` (e.g. hard-coded `fill="white"`),
2355                // the currentColor replacement above was a no-op and
2356                // the rasterized pixels still carry the original fill.
2357                // Apply the tint as a post-rasterization color replace:
2358                // every non-transparent pixel gets its RGB replaced
2359                // with the tint color while preserving the original
2360                // alpha. This makes `.color(Color::RED)` work on any
2361                // SVG regardless of how its fills are authored.
2362                if needs_post_raster_tint {
2363                    rasterized.apply_tint(svg.tint.unwrap());
2364                }
2365
2366                // Insert into atlas (handles grow/clear internally)
2367                if self
2368                    .svg_atlas
2369                    .insert(
2370                        cache_key,
2371                        rasterized.width,
2372                        rasterized.height,
2373                        rasterized.data(),
2374                        &self.device,
2375                    )
2376                    .is_none()
2377                {
2378                    tracing::warn!(
2379                        "SVG atlas full, could not allocate {}x{}",
2380                        raster_width,
2381                        raster_height
2382                    );
2383                    continue;
2384                }
2385            }
2386
2387            // Get the atlas region for this SVG
2388            let Some(region) = self.svg_atlas.get(cache_key) else {
2389                continue;
2390            };
2391            let src_uv = region.uv_bounds(self.svg_atlas.width(), self.svg_atlas.height());
2392            self.svg_atlas.mark_used(cache_key);
2393
2394            // Apply CSS affine transform to SVG bounds if present.
2395            // Pass full 2x2 affine to shader for rotation, scale, and skew support.
2396            let (draw_x, draw_y, draw_w, draw_h, ta, tb, tc, td) =
2397                if let Some([a, b, c, d, tx, ty]) = svg.css_affine {
2398                    // DPI-scale the translation components
2399                    let tx_s = tx * scale_factor;
2400                    let ty_s = ty * scale_factor;
2401
2402                    // Transform center through the affine (in screen space)
2403                    let cx = svg.x + svg.width * 0.5;
2404                    let cy = svg.y + svg.height * 0.5;
2405                    let new_cx = a * cx + c * cy + tx_s;
2406                    let new_cy = b * cx + d * cy + ty_s;
2407
2408                    // Pass original bounds — the 2x2 transform is applied in the shader
2409                    (
2410                        new_cx - svg.width * 0.5,
2411                        new_cy - svg.height * 0.5,
2412                        svg.width,
2413                        svg.height,
2414                        a,
2415                        b,
2416                        c,
2417                        d,
2418                    )
2419                } else {
2420                    (svg.x, svg.y, svg.width, svg.height, 1.0, 0.0, 0.0, 1.0)
2421                };
2422
2423            // Create instance with atlas UV coordinates
2424            let mut instance = GpuImageInstance::new(draw_x, draw_y, draw_w, draw_h)
2425                .with_src_uv(src_uv[0], src_uv[1], src_uv[2], src_uv[3])
2426                .with_opacity(svg.motion_opacity)
2427                .with_transform(ta, tb, tc, td);
2428
2429            // For tintable SVGs, apply color via shader tint multiplication
2430            // (white texture * tint = correctly colored output)
2431            if is_tintable {
2432                if let Some(tint) = svg.tint {
2433                    instance = instance.with_tint(tint.r, tint.g, tint.b, tint.a);
2434                }
2435            }
2436
2437            // Apply clip bounds if specified
2438            if let Some([clip_x, clip_y, clip_w, clip_h]) = svg.clip_bounds {
2439                instance = instance.with_clip_rect(clip_x, clip_y, clip_w, clip_h);
2440            }
2441
2442            instances.push(instance);
2443        }
2444
2445        // Upload atlas to GPU if dirty, then batch-render all SVG instances
2446        if !instances.is_empty() {
2447            self.svg_atlas.upload(&self.queue);
2448            self.renderer
2449                .render_images(target, self.svg_atlas.view(), &instances);
2450        }
2451    }
2452
2453    /// Collect text, SVG, and image elements from the render tree
2454    fn collect_render_elements(
2455        &mut self,
2456        tree: &RenderTree,
2457    ) -> (
2458        Vec<TextElement>,
2459        Vec<SvgElement>,
2460        Vec<ImageElement>,
2461        Vec<FlowElement>,
2462    ) {
2463        self.collect_render_elements_with_state(tree, None)
2464    }
2465
2466    /// Collect text, SVG, and image elements with motion state
2467    fn collect_render_elements_with_state(
2468        &mut self,
2469        tree: &RenderTree,
2470        render_state: Option<&blinc_layout::RenderState>,
2471    ) -> (
2472        Vec<TextElement>,
2473        Vec<SvgElement>,
2474        Vec<ImageElement>,
2475        Vec<FlowElement>,
2476    ) {
2477        // Reuse scratch buffers - take them, clear, populate, and return
2478        // On next call they'll be reallocated if not returned
2479        let mut texts = std::mem::take(&mut self.scratch_texts);
2480        let mut svgs = std::mem::take(&mut self.scratch_svgs);
2481        let mut images = std::mem::take(&mut self.scratch_images);
2482        let mut flows = Vec::new();
2483        texts.clear();
2484        svgs.clear();
2485        images.clear();
2486
2487        // Get the scale factor from the tree for DPI scaling
2488        let scale = tree.scale_factor();
2489
2490        if let Some(root) = tree.root() {
2491            let mut z_layer = 0u32;
2492            self.collect_elements_recursive(
2493                tree,
2494                root,
2495                (0.0, 0.0),
2496                false,      // inside_glass
2497                false,      // inside_foreground
2498                None,       // No initial clip bounds
2499                None,       // No initial clip radius
2500                1.0,        // Initial motion opacity
2501                (0.0, 0.0), // Initial motion translate offset
2502                (1.0, 1.0), // Initial motion scale
2503                None,       // No initial motion scale center
2504                render_state,
2505                scale,
2506                &mut z_layer,
2507                &mut texts,
2508                &mut svgs,
2509                &mut images,
2510                &mut flows,
2511                None, // No initial CSS transform
2512                1.0,  // Initial inherited CSS opacity
2513                None, // No parent node
2514                None, // No initial scroll clip
2515                None, // No 3D layer ancestor
2516            );
2517        }
2518
2519        // Sort texts by z_index (z_layer) to ensure correct rendering order with primitives
2520        texts.sort_by_key(|t| t.z_index);
2521
2522        (texts, svgs, images, flows)
2523    }
2524
2525    #[allow(clippy::too_many_arguments, clippy::only_used_in_recursion)]
2526    fn collect_elements_recursive(
2527        &self,
2528        tree: &RenderTree,
2529        node: LayoutNodeId,
2530        parent_offset: (f32, f32),
2531        inside_glass: bool,
2532        inside_foreground: bool,
2533        current_clip: Option<[f32; 4]>,
2534        current_clip_radius: Option<[f32; 4]>,
2535        inherited_motion_opacity: f32,
2536        inherited_motion_translate: (f32, f32),
2537        inherited_motion_scale: (f32, f32),
2538        // Center point for motion scale (in layout coordinates, before DPI scaling)
2539        // When a parent has motion scale, children should scale around the parent's center
2540        inherited_motion_scale_center: Option<(f32, f32)>,
2541        render_state: Option<&blinc_layout::RenderState>,
2542        scale: f32,
2543        z_layer: &mut u32,
2544        texts: &mut Vec<TextElement>,
2545        svgs: &mut Vec<SvgElement>,
2546        images: &mut Vec<ImageElement>,
2547        flows: &mut Vec<FlowElement>,
2548        // Accumulated CSS transform from ancestors as a 6-element affine [a,b,c,d,tx,ty]
2549        // in layout coordinates. Maps pre-transform coords to post-transform visual coords.
2550        inherited_css_affine: Option<[f32; 6]>,
2551        // Accumulated CSS opacity from ancestors (compounds multiplicatively).
2552        // CSS `opacity` applies to the element and its entire visual subtree.
2553        inherited_css_opacity: f32,
2554        // Parent node ID for inheriting non-cascading CSS props (border, shadow, filter)
2555        // to child images that render separately from the SDF pipeline.
2556        parent_node: Option<LayoutNodeId>,
2557        // Scroll container clip — sharp rect kept separate from the primary rounded clip.
2558        // This prevents corner radius morphing when a rounded element (card) is partially
2559        // scrolled past a sharp scroll boundary.
2560        current_scroll_clip: Option<[f32; 4]>,
2561        // 3D layer info if inside a perspective-transformed ancestor.
2562        // Text/SVGs/images inside 3D layers are rendered to offscreen textures
2563        // and blitted with the same perspective transform.
2564        inside_3d_layer: Option<Transform3DLayerInfo>,
2565    ) {
2566        use blinc_layout::Material;
2567
2568        // Use animated bounds if this node has layout animation, otherwise use layout bounds
2569        // This ensures children are positioned correctly during layout animation transitions
2570        let Some(bounds) = tree.get_render_bounds(node, parent_offset) else {
2571            return;
2572        };
2573
2574        let abs_x = bounds.x;
2575        let abs_y = bounds.y;
2576
2577        // Get motion values for this node from RenderState (entry/exit animations)
2578        let motion_values = render_state.and_then(|rs| {
2579            // Try stable motion first, then node-based
2580            if let Some(render_node) = tree.get_render_node(node) {
2581                if let Some(ref stable_key) = render_node.props.motion_stable_id {
2582                    return rs.get_stable_motion_values(stable_key);
2583                }
2584            }
2585            rs.get_motion_values(node)
2586        });
2587
2588        // Get motion bindings from RenderTree (continuous AnimatedValue animations)
2589        // NOTE: binding_transform (translate) is NOT added to effective_motion_translate
2590        // because it's already included in new_offset for child positioning (see line ~1250).
2591        // Only RenderState motion values need to be inherited through effective_motion_translate.
2592        let binding_scale = tree.get_motion_scale(node);
2593        let binding_opacity = tree.get_motion_opacity(node);
2594
2595        // Calculate motion opacity for this node (combine both sources)
2596        let node_motion_opacity = motion_values
2597            .and_then(|m| m.opacity)
2598            .unwrap_or_else(|| binding_opacity.unwrap_or(1.0));
2599
2600        // Get motion translate for this node from RenderState only
2601        // (binding translate is handled via new_offset in recursive calls)
2602        let node_motion_translate = motion_values
2603            .map(|m| m.resolved_translate())
2604            .unwrap_or((0.0, 0.0));
2605
2606        // Get motion scale for this node from RenderState
2607        let node_motion_scale = motion_values
2608            .map(|m| m.resolved_scale())
2609            .unwrap_or((1.0, 1.0));
2610
2611        // Combine with binding scale
2612        let binding_scale_values = binding_scale.unwrap_or((1.0, 1.0));
2613
2614        // Combine with inherited values
2615        // NOTE: effective_motion_translate only includes RenderState motion values,
2616        // NOT binding transforms (which are already in the position via new_offset)
2617        let effective_motion_opacity = inherited_motion_opacity * node_motion_opacity;
2618        let effective_motion_translate = (
2619            inherited_motion_translate.0 + node_motion_translate.0,
2620            inherited_motion_translate.1 + node_motion_translate.1,
2621        );
2622        // Scale compounds multiplicatively (including binding scale)
2623        let effective_motion_scale = (
2624            inherited_motion_scale.0 * node_motion_scale.0 * binding_scale_values.0,
2625            inherited_motion_scale.1 * node_motion_scale.1 * binding_scale_values.1,
2626        );
2627
2628        // Determine the motion scale center for children
2629        // If this node has motion scale (from RenderState or binding), use its center as the scale center
2630        // Otherwise, inherit the parent's scale center
2631        let this_node_has_scale = (node_motion_scale.0 - 1.0).abs() > 0.001
2632            || (node_motion_scale.1 - 1.0).abs() > 0.001
2633            || (binding_scale_values.0 - 1.0).abs() > 0.001
2634            || (binding_scale_values.1 - 1.0).abs() > 0.001;
2635
2636        let effective_motion_scale_center = if this_node_has_scale {
2637            // This node has motion scale - compute its center in absolute layout coordinates
2638            let center_x = abs_x + bounds.width / 2.0;
2639            let center_y = abs_y + bounds.height / 2.0;
2640            Some((center_x, center_y))
2641        } else {
2642            // No scale on this node - inherit the parent's scale center
2643            inherited_motion_scale_center
2644        };
2645
2646        // Skip if completely transparent
2647        if effective_motion_opacity <= 0.001 {
2648            return;
2649        }
2650
2651        // CSS visibility: hidden — skip rendering but preserve layout space
2652        if let Some(render_node) = tree.get_render_node(node) {
2653            if !render_node.props.visible {
2654                return;
2655            }
2656        }
2657
2658        // Determine if this node is a glass element
2659        let is_glass = tree
2660            .get_render_node(node)
2661            .map(|n| matches!(n.props.material, Some(Material::Glass(_))))
2662            .unwrap_or(false);
2663
2664        // Track if children should be considered inside glass
2665        let children_inside_glass = inside_glass || is_glass;
2666
2667        // Track if we're inside a foreground-layer element
2668        let is_foreground_node = tree
2669            .get_render_node(node)
2670            .map(|n| n.props.layer == RenderLayer::Foreground)
2671            .unwrap_or(false);
2672        let children_inside_foreground = inside_foreground || is_foreground_node;
2673
2674        // Check if this node clips its children (e.g., scroll containers)
2675        let clips_content = tree
2676            .get_render_node(node)
2677            .map(|n| n.props.clips_content)
2678            .unwrap_or(false);
2679
2680        // Check if this node has an active layout animation (also needs clipping)
2681        // Layout animations need to clip children to animated bounds
2682        let has_layout_animation = tree.is_layout_animating(node);
2683
2684        // Check if this is a Stack layer - if so, increment z_layer for proper z-ordering
2685        let is_stack_layer = tree
2686            .get_render_node(node)
2687            .map(|n| n.props.is_stack_layer)
2688            .unwrap_or(false);
2689        if is_stack_layer {
2690            *z_layer += 1;
2691        }
2692
2693        // Apply CSS z-index to z_layer for stacking order
2694        let saved_z_layer = *z_layer;
2695        let node_z_index = tree
2696            .get_render_node(node)
2697            .map(|n| n.props.z_index)
2698            .unwrap_or(0);
2699        if node_z_index > 0 {
2700            *z_layer = node_z_index as u32;
2701        }
2702
2703        // Update clip bounds for children if this node clips (either via clips_content or layout animation)
2704        // When a node clips, we INTERSECT its bounds with any existing clip
2705        // This ensures nested clipping works correctly (inner clips can't expand outer clips)
2706        let should_clip = clips_content || has_layout_animation;
2707        let (child_clip, child_clip_radius, child_scroll_clip) = if should_clip {
2708            // For layout animation, use animated bounds for clipping
2709            // This ensures content is clipped to the animating size during transition
2710            let clip_bounds = if has_layout_animation {
2711                // Get animated bounds - these are the interpolated bounds during animation
2712                tree.get_render_bounds(node, parent_offset)
2713                    .map(|b| [b.x, b.y, b.width, b.height])
2714                    .unwrap_or([abs_x, abs_y, bounds.width, bounds.height])
2715            } else {
2716                [abs_x, abs_y, bounds.width, bounds.height]
2717            };
2718            // Inset clip by border-width only.  Per CSS spec, overflow clips
2719            // at the padding box (inside border, but padding area is visible).
2720            // Padding affects layout positioning, not clipping.
2721            let bw = tree
2722                .get_render_node(node)
2723                .map(|n| n.props.border_width)
2724                .unwrap_or(0.0);
2725            let this_clip = [
2726                clip_bounds[0] + bw,
2727                clip_bounds[1] + bw,
2728                (clip_bounds[2] - bw * 2.0).max(0.0),
2729                (clip_bounds[3] - bw * 2.0).max(0.0),
2730            ];
2731
2732            // Extract border radius from this node for rounded clipping.
2733            // Inner corner radius = max(outer_radius − border_width, 0)
2734            let this_clip_radius = tree.get_render_node(node).map(|n| {
2735                let r = &n.props.border_radius;
2736                [
2737                    (r.top_left - bw).max(0.0),
2738                    (r.top_right - bw).max(0.0),
2739                    (r.bottom_right - bw).max(0.0),
2740                    (r.bottom_left - bw).max(0.0),
2741                ]
2742            });
2743
2744            let this_has_radius = this_clip_radius
2745                .map(|r| r.iter().any(|&v| v > 0.5))
2746                .unwrap_or(false);
2747            let parent_has_radius = current_clip_radius
2748                .map(|r| r.iter().any(|&v| v > 0.5))
2749                .unwrap_or(false);
2750
2751            if let Some(parent_clip) = current_clip {
2752                if this_has_radius && !parent_has_radius {
2753                    // This node is rounded (card), parent is sharp (scroll container).
2754                    // Keep them separate to avoid SDF radius clamping/morphing.
2755                    // Primary clip = this node's rounded clip (full card bounds).
2756                    // Scroll clip = parent's sharp clip intersected with any existing scroll clip.
2757                    (
2758                        Some(this_clip),
2759                        this_clip_radius,
2760                        merge_scroll_clip(parent_clip, current_scroll_clip),
2761                    )
2762                } else if !this_has_radius && parent_has_radius {
2763                    // This node is sharp (scroll), parent is rounded (card).
2764                    // Keep parent as primary rounded clip, this as scroll clip
2765                    // intersected with any existing scroll clip.
2766                    (
2767                        current_clip,
2768                        current_clip_radius,
2769                        merge_scroll_clip(this_clip, current_scroll_clip),
2770                    )
2771                } else {
2772                    // Both have same kind of radius — intersect normally.
2773                    let x1 = parent_clip[0].max(this_clip[0]);
2774                    let y1 = parent_clip[1].max(this_clip[1]);
2775                    let parent_right = parent_clip[0] + parent_clip[2];
2776                    let parent_bottom = parent_clip[1] + parent_clip[3];
2777                    let this_right = this_clip[0] + this_clip[2];
2778                    let this_bottom = this_clip[1] + this_clip[3];
2779                    let x2 = parent_right.min(this_right);
2780                    let y2 = parent_bottom.min(this_bottom);
2781                    let w = (x2 - x1).max(0.0);
2782                    let h = (y2 - y1).max(0.0);
2783                    let clip = Some([x1, y1, w, h]);
2784
2785                    let child_r = this_clip_radius.unwrap_or([0.0; 4]);
2786                    let parent_r = current_clip_radius.unwrap_or([0.0; 4]);
2787                    let radius = Some([
2788                        child_r[0].max(parent_r[0]),
2789                        child_r[1].max(parent_r[1]),
2790                        child_r[2].max(parent_r[2]),
2791                        child_r[3].max(parent_r[3]),
2792                    ]);
2793
2794                    (clip, radius, current_scroll_clip)
2795                }
2796            } else {
2797                // No parent clip — this is the first clip level.
2798                if this_has_radius {
2799                    // Rounded clip becomes primary, scroll clip passes through.
2800                    (Some(this_clip), this_clip_radius, current_scroll_clip)
2801                } else {
2802                    // Sharp clip becomes scroll clip; intersect with existing scroll clip
2803                    // so nested sharp clips (scroll + stack wrapper) don't lose the outer boundary.
2804                    let new_scroll_clip = if let Some(existing) = current_scroll_clip {
2805                        let x1 = existing[0].max(this_clip[0]);
2806                        let y1 = existing[1].max(this_clip[1]);
2807                        let x2 = (existing[0] + existing[2]).min(this_clip[0] + this_clip[2]);
2808                        let y2 = (existing[1] + existing[3]).min(this_clip[1] + this_clip[3]);
2809                        [x1, y1, (x2 - x1).max(0.0), (y2 - y1).max(0.0)]
2810                    } else {
2811                        this_clip
2812                    };
2813                    (None, None, Some(new_scroll_clip))
2814                }
2815            }
2816        } else {
2817            (current_clip, current_clip_radius, current_scroll_clip)
2818        };
2819
2820        // Compute this node's CSS affine: compose its own CSS transform with inherited.
2821        // This must happen BEFORE the element-type match block so that SVGs, text, and images
2822        // get their own transform applied (not just the parent's inherited transform).
2823        // NOTE: 3D rotations (rotate-x/rotate-y/perspective) are NOT included here — they
2824        // can't be accurately represented as a 2D affine (perspective is projective, not linear).
2825        // Proper 3D text compositing requires layer-based rendering (render to texture, then
2826        // apply 3D transform to the composite). For now, text stays flat under 3D parents.
2827        let node_css_affine = if let Some(render_node) = tree.get_render_node(node) {
2828            let has_non_identity = if let Some(blinc_core::Transform::Affine2D(affine)) =
2829                &render_node.props.transform
2830            {
2831                let [a, b, c, d, tx, ty] = affine.elements;
2832                !((a - 1.0).abs() < 0.0001
2833                    && b.abs() < 0.0001
2834                    && c.abs() < 0.0001
2835                    && (d - 1.0).abs() < 0.0001
2836                    && tx.abs() < 0.0001
2837                    && ty.abs() < 0.0001)
2838            } else {
2839                false
2840            };
2841
2842            if has_non_identity {
2843                let affine = match &render_node.props.transform {
2844                    Some(blinc_core::Transform::Affine2D(a)) => a.elements,
2845                    _ => unreachable!(),
2846                };
2847                let [a, b, c, d, tx, ty] = affine;
2848                // Compute transform center in absolute layout coords
2849                let (cx, cy) = if let Some([ox_pct, oy_pct]) = render_node.props.transform_origin {
2850                    (
2851                        abs_x + bounds.width * ox_pct / 100.0,
2852                        abs_y + bounds.height * oy_pct / 100.0,
2853                    )
2854                } else {
2855                    (abs_x + bounds.width / 2.0, abs_y + bounds.height / 2.0)
2856                };
2857                // Build full 6-element affine: T(center) * [a,b,c,d,tx,ty] * T(-center)
2858                // = [a, b, c, d, cx*(1-a) - cy*c + tx, cy*(1-d) - cx*b + ty]
2859                let this_affine = [
2860                    a,
2861                    b,
2862                    c,
2863                    d,
2864                    cx * (1.0 - a) - cy * c + tx,
2865                    cy * (1.0 - d) - cx * b + ty,
2866                ];
2867                match inherited_css_affine {
2868                    Some(parent) => {
2869                        let [pa, pb, pc, pd, ptx, pty] = parent;
2870                        Some([
2871                            a * pa + c * pb,
2872                            b * pa + d * pb,
2873                            a * pc + c * pd,
2874                            b * pc + d * pd,
2875                            a * ptx + c * pty + this_affine[4],
2876                            b * ptx + d * pty + this_affine[5],
2877                        ])
2878                    }
2879                    None => Some(this_affine),
2880                }
2881            } else {
2882                inherited_css_affine
2883            }
2884        } else {
2885            inherited_css_affine
2886        };
2887
2888        if let Some(render_node) = tree.get_render_node(node) {
2889            // Determine effective layer: children inside glass render in Foreground
2890            let effective_layer = if inside_glass && !is_glass {
2891                RenderLayer::Foreground
2892            } else if is_glass {
2893                RenderLayer::Glass
2894            } else {
2895                render_node.props.layer
2896            };
2897
2898            match &render_node.element_type {
2899                ElementType::Text(text_data) => {
2900                    // Apply DPI scale factor FIRST to match shape rendering order
2901                    // In render_with_motion, DPI scale is pushed at root level before any other transforms
2902                    // So we must: scale base positions first, then apply motion transforms
2903                    let base_x = abs_x * scale;
2904                    let base_y = abs_y * scale;
2905                    let base_width = bounds.width * scale;
2906                    let base_height = bounds.height * scale;
2907
2908                    // Scale motion translate by DPI factor (motion values are in layout coordinates)
2909                    let scaled_motion_tx = effective_motion_translate.0 * scale;
2910                    let scaled_motion_ty = effective_motion_translate.1 * scale;
2911
2912                    // Apply motion scale and translation
2913                    // When there's a motion scale center (from parent Motion container),
2914                    // we must scale around THAT center, not the text element's own center.
2915                    // This matches how shapes are rendered - the scale transform is pushed
2916                    // at the Motion container level and affects all children relative to
2917                    // the container's center.
2918                    let (scaled_x, scaled_y, scaled_width, scaled_height) =
2919                        if let Some((motion_center_x, motion_center_y)) =
2920                            effective_motion_scale_center
2921                        {
2922                            // Scale position around the motion container's center (in DPI-scaled coordinates)
2923                            let motion_center_x_scaled = motion_center_x * scale;
2924                            let motion_center_y_scaled = motion_center_y * scale;
2925
2926                            // Calculate position relative to motion center
2927                            let rel_x = base_x - motion_center_x_scaled;
2928                            let rel_y = base_y - motion_center_y_scaled;
2929
2930                            // Apply scale to relative position and size
2931                            let scaled_rel_x = rel_x * effective_motion_scale.0;
2932                            let scaled_rel_y = rel_y * effective_motion_scale.1;
2933                            let scaled_w = base_width * effective_motion_scale.0;
2934                            let scaled_h = base_height * effective_motion_scale.1;
2935
2936                            // Apply motion translation and convert back to absolute position
2937                            let final_x = motion_center_x_scaled + scaled_rel_x + scaled_motion_tx;
2938                            let final_y = motion_center_y_scaled + scaled_rel_y + scaled_motion_ty;
2939
2940                            (final_x, final_y, scaled_w, scaled_h)
2941                        } else {
2942                            // No motion scale center - just apply translation (no scale effect)
2943                            let final_x = base_x + scaled_motion_tx;
2944                            let final_y = base_y + scaled_motion_ty;
2945                            (final_x, final_y, base_width, base_height)
2946                        };
2947
2948                    // Use CSS-overridden font size if available (from stylesheet/animation/transition)
2949                    let base_font_size = render_node.props.font_size.unwrap_or(text_data.font_size);
2950                    let scaled_font_size = base_font_size * effective_motion_scale.1 * scale;
2951                    let scaled_measured_width =
2952                        text_data.measured_width * effective_motion_scale.0 * scale;
2953
2954                    // Intersect primary clip with scroll clip — text only supports
2955                    // a single clip rect so we must merge both boundaries.
2956                    let effective_clip = effective_single_clip(current_clip, current_scroll_clip);
2957                    let scaled_clip = effective_clip
2958                        .map(|[cx, cy, cw, ch]| [cx * scale, cy * scale, cw * scale, ch * scale]);
2959
2960                    // Log motion values if non-trivial (for debugging text/shape sync issues)
2961                    if effective_motion_translate.0.abs() > 0.1
2962                        || effective_motion_translate.1.abs() > 0.1
2963                        || (effective_motion_scale.0 - 1.0).abs() > 0.01
2964                        || (effective_motion_scale.1 - 1.0).abs() > 0.01
2965                    {
2966                        tracing::trace!(
2967                            "Text '{}': motion_translate=({:.1}, {:.1}), motion_scale=({:.2}, {:.2}), base=({:.1}, {:.1}), final=({:.1}, {:.1})",
2968                            text_data.content,
2969                            effective_motion_translate.0,
2970                            effective_motion_translate.1,
2971                            effective_motion_scale.0,
2972                            effective_motion_scale.1,
2973                            base_x,
2974                            base_y,
2975                            scaled_x,
2976                            scaled_y,
2977                        );
2978                    }
2979                    tracing::trace!(
2980                        "Text '{}': abs=({:.1}, {:.1}), size=({:.1}x{:.1}), font={:.1}, align={:?}, v_align={:?}, z_layer={}",
2981                        text_data.content,
2982                        scaled_x,
2983                        scaled_y,
2984                        scaled_width,
2985                        scaled_height,
2986                        scaled_font_size,
2987                        text_data.align,
2988                        text_data.v_align,
2989                        *z_layer
2990                    );
2991
2992                    // Apply text-overflow: ellipsis truncation if needed.
2993                    // Check both text_data.wrap (set at build time) and render_node.props.white_space
2994                    // (set by CSS after build). CSS white-space: nowrap overrides the builder wrap setting.
2995                    let is_nowrap = !text_data.wrap
2996                        || matches!(
2997                            render_node.props.white_space,
2998                            Some(blinc_layout::element_style::WhiteSpace::Nowrap)
2999                                | Some(blinc_layout::element_style::WhiteSpace::Pre)
3000                        );
3001                    let content = if is_nowrap
3002                        && matches!(
3003                            render_node.props.text_overflow,
3004                            Some(blinc_layout::element_style::TextOverflow::Ellipsis)
3005                        )
3006                        && scaled_measured_width > scaled_width
3007                        && scaled_width > 0.0
3008                    {
3009                        // Measure with the same options used for layout
3010                        let mut options = blinc_layout::text_measure::TextLayoutOptions::new();
3011                        options.font_name = text_data.font_family.name.clone();
3012                        options.generic_font = text_data.font_family.generic;
3013                        options.font_weight =
3014                            match render_node.props.font_weight.unwrap_or(text_data.weight) {
3015                                FontWeight::Bold => 700,
3016                                FontWeight::Normal => 400,
3017                                FontWeight::Light => 300,
3018                                _ => 400,
3019                            };
3020                        options.letter_spacing = render_node
3021                            .props
3022                            .letter_spacing
3023                            .unwrap_or(text_data.letter_spacing);
3024
3025                        // Measure "..." to know reserved width
3026                        let ellipsis = "\u{2026}";
3027                        let ellipsis_w = blinc_layout::text_measure::measure_text_with_options(
3028                            ellipsis,
3029                            scaled_font_size / scale,
3030                            &options,
3031                        )
3032                        .width
3033                            * scale;
3034                        let target_width = scaled_width - ellipsis_w;
3035
3036                        if target_width > 0.0 {
3037                            // Binary search for the right truncation point
3038                            let chars: Vec<char> = text_data.content.chars().collect();
3039                            let mut lo = 0usize;
3040                            let mut hi = chars.len();
3041                            while lo < hi {
3042                                #[allow(clippy::manual_div_ceil)]
3043                                let mid = (lo + hi + 1) / 2;
3044                                let sub: String = chars[..mid].iter().collect();
3045                                let w = blinc_layout::text_measure::measure_text_with_options(
3046                                    &sub,
3047                                    scaled_font_size / scale,
3048                                    &options,
3049                                )
3050                                .width
3051                                    * scale;
3052                                if w <= target_width {
3053                                    lo = mid;
3054                                } else {
3055                                    hi = mid - 1;
3056                                }
3057                            }
3058                            let truncated: String = chars[..lo].iter().collect();
3059                            format!("{}{}", truncated.trim_end(), ellipsis)
3060                        } else {
3061                            ellipsis.to_string()
3062                        }
3063                    } else {
3064                        text_data.content.clone()
3065                    };
3066
3067                    texts.push(TextElement {
3068                        content,
3069                        x: scaled_x,
3070                        y: scaled_y,
3071                        width: scaled_width,
3072                        height: scaled_height,
3073                        font_size: scaled_font_size,
3074                        color: render_node.props.text_color.unwrap_or(text_data.color),
3075                        align: text_data.align,
3076                        weight: render_node.props.font_weight.unwrap_or(text_data.weight),
3077                        italic: text_data.italic,
3078                        v_align: text_data.v_align,
3079                        clip_bounds: scaled_clip,
3080                        motion_opacity: effective_motion_opacity
3081                            * render_node.props.opacity
3082                            * inherited_css_opacity,
3083                        wrap: !is_nowrap && text_data.wrap,
3084                        line_height: text_data.line_height,
3085                        measured_width: scaled_measured_width,
3086                        font_family: text_data.font_family.clone(),
3087                        word_spacing: text_data.word_spacing,
3088                        letter_spacing: render_node
3089                            .props
3090                            .letter_spacing
3091                            .unwrap_or(text_data.letter_spacing),
3092                        z_index: *z_layer,
3093                        ascender: text_data.ascender * effective_motion_scale.1 * scale,
3094                        strikethrough: render_node.props.text_decoration.map_or(
3095                            text_data.strikethrough,
3096                            |td| {
3097                                matches!(
3098                                    td,
3099                                    blinc_layout::element_style::TextDecoration::LineThrough
3100                                )
3101                            },
3102                        ),
3103                        underline: render_node.props.text_decoration.map_or(
3104                            text_data.underline,
3105                            |td| {
3106                                matches!(td, blinc_layout::element_style::TextDecoration::Underline)
3107                            },
3108                        ),
3109                        decoration_color: render_node.props.text_decoration_color,
3110                        decoration_thickness: render_node.props.text_decoration_thickness,
3111                        css_affine: node_css_affine,
3112                        text_shadow: render_node.props.text_shadow,
3113                        transform_3d_layer: inside_3d_layer.clone(),
3114                        is_foreground: children_inside_foreground,
3115                    });
3116                }
3117                ElementType::Svg(svg_data) => {
3118                    // Apply DPI scale factor FIRST to match shape rendering order
3119                    let base_x = abs_x * scale;
3120                    let base_y = abs_y * scale;
3121                    let base_width = bounds.width * scale;
3122                    let base_height = bounds.height * scale;
3123
3124                    // Scale motion translate by DPI factor
3125                    let scaled_motion_tx = effective_motion_translate.0 * scale;
3126                    let scaled_motion_ty = effective_motion_translate.1 * scale;
3127
3128                    // Apply motion scale and translation (same logic as Text)
3129                    let (scaled_x, scaled_y, scaled_width, scaled_height) =
3130                        if let Some((motion_center_x, motion_center_y)) =
3131                            effective_motion_scale_center
3132                        {
3133                            let motion_center_x_scaled = motion_center_x * scale;
3134                            let motion_center_y_scaled = motion_center_y * scale;
3135
3136                            let rel_x = base_x - motion_center_x_scaled;
3137                            let rel_y = base_y - motion_center_y_scaled;
3138
3139                            let scaled_rel_x = rel_x * effective_motion_scale.0;
3140                            let scaled_rel_y = rel_y * effective_motion_scale.1;
3141                            let scaled_w = base_width * effective_motion_scale.0;
3142                            let scaled_h = base_height * effective_motion_scale.1;
3143
3144                            let final_x = motion_center_x_scaled + scaled_rel_x + scaled_motion_tx;
3145                            let final_y = motion_center_y_scaled + scaled_rel_y + scaled_motion_ty;
3146
3147                            (final_x, final_y, scaled_w, scaled_h)
3148                        } else {
3149                            let final_x = base_x + scaled_motion_tx;
3150                            let final_y = base_y + scaled_motion_ty;
3151                            (final_x, final_y, base_width, base_height)
3152                        };
3153
3154                    // Intersect primary clip with scroll clip — text/SVG only support
3155                    // a single clip rect so we must merge both boundaries.
3156                    let effective_clip = effective_single_clip(current_clip, current_scroll_clip);
3157                    let scaled_clip = effective_clip
3158                        .map(|[cx, cy, cw, ch]| [cx * scale, cy * scale, cw * scale, ch * scale]);
3159
3160                    // Tint resolves `currentColor` references in SVG source.
3161                    // CSS fill/stroke are explicit overrides injected as SVG attributes.
3162                    // Both can coexist: tint handles currentColor, CSS handles specifics.
3163                    svgs.push(SvgElement {
3164                        source: svg_data.source.clone(),
3165                        x: scaled_x,
3166                        y: scaled_y,
3167                        width: scaled_width,
3168                        height: scaled_height,
3169                        tint: svg_data.tint.or_else(|| {
3170                            render_node
3171                                .props
3172                                .text_color
3173                                .map(|c| blinc_core::Color::rgba(c[0], c[1], c[2], c[3]))
3174                        }),
3175                        fill: render_node
3176                            .props
3177                            .fill
3178                            .map(|c| blinc_core::Color::rgba(c[0], c[1], c[2], c[3]))
3179                            .or(svg_data.fill),
3180                        stroke: render_node
3181                            .props
3182                            .stroke
3183                            .map(|c| blinc_core::Color::rgba(c[0], c[1], c[2], c[3]))
3184                            .or(svg_data.stroke),
3185                        stroke_width: render_node.props.stroke_width.or(svg_data.stroke_width),
3186                        stroke_dasharray: render_node.props.stroke_dasharray.clone(),
3187                        stroke_dashoffset: render_node.props.stroke_dashoffset,
3188                        svg_path_data: render_node.props.svg_path_data.clone(),
3189                        clip_bounds: scaled_clip,
3190                        motion_opacity: effective_motion_opacity
3191                            * render_node.props.opacity
3192                            * inherited_css_opacity,
3193                        css_affine: node_css_affine,
3194                        tag_overrides: render_node.props.svg_tag_styles.clone(),
3195                        transform_3d_layer: inside_3d_layer.clone(),
3196                    });
3197                }
3198                ElementType::Image(image_data) => {
3199                    // Apply DPI scale factor to image positions and sizes
3200                    let scaled_clip = current_clip
3201                        .map(|[cx, cy, cw, ch]| [cx * scale, cy * scale, cw * scale, ch * scale]);
3202
3203                    // Scale clip radius by DPI factor (radius values are in layout coordinates)
3204                    let scaled_clip_radius = current_clip_radius
3205                        .map(|[tl, tr, br, bl]| [tl * scale, tr * scale, br * scale, bl * scale])
3206                        .unwrap_or([0.0; 4]);
3207
3208                    // Scale scroll clip by DPI factor
3209                    let scaled_scroll_clip = current_scroll_clip
3210                        .map(|[cx, cy, cw, ch]| [cx * scale, cy * scale, cw * scale, ch * scale]);
3211
3212                    // Look up parent render props for CSS property inheritance.
3213                    // Images render via a separate pipeline and don't inherit parent CSS
3214                    // properties automatically — we must propagate them explicitly.
3215                    let parent_props = parent_node
3216                        .and_then(|pid| tree.get_render_node(pid))
3217                        .map(|pn| &pn.props);
3218
3219                    // Opacity: own CSS opacity * inherited CSS opacity chain * builder * motion
3220                    let own_css_opacity = render_node.props.opacity;
3221                    let final_opacity = image_data.opacity
3222                        * own_css_opacity
3223                        * inherited_css_opacity
3224                        * effective_motion_opacity;
3225
3226                    // Border-radius: prefer own CSS, then builder.
3227                    // Parent clip (now at content-box) handles corner rounding.
3228                    let own_br = render_node.props.border_radius.top_left;
3229                    let final_border_radius = if own_br > 0.0 {
3230                        own_br * scale
3231                    } else {
3232                        image_data.border_radius * scale
3233                    };
3234
3235                    // Border: use image's own CSS border (parent border renders via SDF,
3236                    // visible because clip now insets by border-width)
3237                    let border_width = render_node.props.border_width * scale;
3238                    let border_color = render_node
3239                        .props
3240                        .border_color
3241                        .unwrap_or(blinc_core::Color::TRANSPARENT);
3242
3243                    // Shadow: use image's own (parent shadow renders via SDF)
3244                    let shadow = render_node.props.shadow;
3245
3246                    // Filter: prefer own, fall back to parent
3247                    let own_filter = &render_node.props.filter;
3248                    let parent_filter = parent_props.and_then(|p| p.filter.as_ref());
3249                    let effective_filter = own_filter.as_ref().or(parent_filter);
3250                    let filter_a = effective_filter
3251                        .map(|f| Self::css_filter_to_arrays(f).0)
3252                        .unwrap_or([0.0, 0.0, 0.0, 0.0]);
3253                    let filter_b = effective_filter
3254                        .map(|f| Self::css_filter_to_arrays(f).1)
3255                        .unwrap_or([1.0, 1.0, 1.0, 0.0]);
3256
3257                    // object-fit / object-position: CSS overrides builder values
3258                    let final_object_fit = render_node
3259                        .props
3260                        .object_fit
3261                        .unwrap_or(image_data.object_fit);
3262                    let final_object_position = render_node
3263                        .props
3264                        .object_position
3265                        .unwrap_or(image_data.object_position);
3266
3267                    // CSS overrides for lazy loading properties
3268                    let final_loading_strategy = render_node
3269                        .props
3270                        .loading_strategy
3271                        .unwrap_or(image_data.loading_strategy);
3272                    let final_placeholder_type = render_node
3273                        .props
3274                        .placeholder_type
3275                        .unwrap_or(image_data.placeholder_type);
3276                    let final_placeholder_color = render_node
3277                        .props
3278                        .placeholder_color
3279                        .unwrap_or(image_data.placeholder_color);
3280                    let final_placeholder_image = render_node
3281                        .props
3282                        .placeholder_image
3283                        .clone()
3284                        .or_else(|| image_data.placeholder_image.clone());
3285                    let final_fade_duration = render_node
3286                        .props
3287                        .fade_duration_ms
3288                        .unwrap_or(image_data.fade_duration_ms);
3289
3290                    // Mask: prefer own, fall back to parent
3291                    let own_mask = render_node.props.mask_image.as_ref();
3292                    let parent_mask = parent_props.and_then(|p| p.mask_image.as_ref());
3293                    let effective_mask = own_mask.or(parent_mask);
3294                    let (mask_params, mask_info) = Self::mask_image_to_arrays(effective_mask);
3295
3296                    images.push(ImageElement {
3297                        source: image_data.source.clone(),
3298                        x: abs_x * scale,
3299                        y: abs_y * scale,
3300                        width: bounds.width * scale,
3301                        height: bounds.height * scale,
3302                        object_fit: final_object_fit,
3303                        object_position: final_object_position,
3304                        opacity: final_opacity,
3305                        border_radius: final_border_radius,
3306                        tint: image_data.tint,
3307                        clip_bounds: scaled_clip,
3308                        clip_radius: scaled_clip_radius,
3309                        layer: effective_layer,
3310                        loading_strategy: final_loading_strategy,
3311                        placeholder_type: final_placeholder_type,
3312                        placeholder_color: final_placeholder_color,
3313                        placeholder_image: final_placeholder_image,
3314                        fade_duration_ms: final_fade_duration,
3315                        z_index: *z_layer,
3316                        border_width,
3317                        border_color,
3318                        css_affine: node_css_affine,
3319                        shadow,
3320                        filter_a,
3321                        filter_b,
3322                        scroll_clip: scaled_scroll_clip,
3323                        mask_params,
3324                        mask_info,
3325                        transform_3d_layer: inside_3d_layer.clone(),
3326                    });
3327                }
3328                // Canvas elements are rendered inline during tree traversal (in render_layer)
3329                ElementType::Canvas(_) => {}
3330                ElementType::Div => {
3331                    // Check if this div has a background image brush
3332                    if let Some(blinc_core::Brush::Image(ref img_brush)) =
3333                        render_node.props.background
3334                    {
3335                        let scaled_clip = current_clip.map(|[cx, cy, cw, ch]| {
3336                            [cx * scale, cy * scale, cw * scale, ch * scale]
3337                        });
3338                        let scaled_clip_radius = current_clip_radius
3339                            .map(|[tl, tr, br, bl]| {
3340                                [tl * scale, tr * scale, br * scale, bl * scale]
3341                            })
3342                            .unwrap_or([0.0; 4]);
3343                        let scaled_scroll_clip_bg = current_scroll_clip.map(|[cx, cy, cw, ch]| {
3344                            [cx * scale, cy * scale, cw * scale, ch * scale]
3345                        });
3346
3347                        images.push(ImageElement {
3348                            source: img_brush.source.clone(),
3349                            x: abs_x * scale,
3350                            y: abs_y * scale,
3351                            width: bounds.width * scale,
3352                            height: bounds.height * scale,
3353                            object_fit: match img_brush.fit {
3354                                blinc_core::ImageFit::Cover => 0,
3355                                blinc_core::ImageFit::Contain => 1,
3356                                blinc_core::ImageFit::Fill => 2,
3357                                blinc_core::ImageFit::Tile => 0,
3358                            },
3359                            object_position: [img_brush.position.x, img_brush.position.y],
3360                            opacity: img_brush.opacity
3361                                * render_node.props.opacity
3362                                * inherited_css_opacity
3363                                * effective_motion_opacity,
3364                            border_radius: render_node.props.border_radius.top_left * scale,
3365                            tint: [
3366                                img_brush.tint.r,
3367                                img_brush.tint.g,
3368                                img_brush.tint.b,
3369                                img_brush.tint.a,
3370                            ],
3371                            clip_bounds: scaled_clip,
3372                            clip_radius: scaled_clip_radius,
3373                            layer: effective_layer,
3374                            loading_strategy: 0, // Eager
3375                            placeholder_type: 0, // None
3376                            placeholder_color: [0.0; 4],
3377                            placeholder_image: None,
3378                            fade_duration_ms: 0,
3379                            z_index: *z_layer,
3380                            border_width: 0.0,
3381                            border_color: blinc_core::Color::TRANSPARENT,
3382                            css_affine: node_css_affine,
3383                            shadow: render_node.props.shadow,
3384                            filter_a: render_node
3385                                .props
3386                                .filter
3387                                .as_ref()
3388                                .map(|f| Self::css_filter_to_arrays(f).0)
3389                                .unwrap_or([0.0, 0.0, 0.0, 0.0]),
3390                            filter_b: render_node
3391                                .props
3392                                .filter
3393                                .as_ref()
3394                                .map(|f| Self::css_filter_to_arrays(f).1)
3395                                .unwrap_or([1.0, 1.0, 1.0, 0.0]),
3396                            scroll_clip: scaled_scroll_clip_bg,
3397                            mask_params: {
3398                                let (mp, _) = Self::mask_image_to_arrays(
3399                                    render_node.props.mask_image.as_ref(),
3400                                );
3401                                mp
3402                            },
3403                            mask_info: {
3404                                let (_, mi) = Self::mask_image_to_arrays(
3405                                    render_node.props.mask_image.as_ref(),
3406                                );
3407                                mi
3408                            },
3409                            transform_3d_layer: inside_3d_layer.clone(),
3410                        });
3411                    }
3412                }
3413                // StyledText: render text with inline styling using multiple TextElements
3414                ElementType::StyledText(styled_data) => {
3415                    // Apply DPI scale factor first
3416                    let base_x = abs_x * scale;
3417                    let base_y = abs_y * scale;
3418                    let base_width = bounds.width * scale;
3419                    let base_height = bounds.height * scale;
3420
3421                    // Scale motion translate by DPI factor
3422                    let scaled_motion_tx = effective_motion_translate.0 * scale;
3423                    let scaled_motion_ty = effective_motion_translate.1 * scale;
3424
3425                    // Apply motion scale and translation (same logic as Text)
3426                    let (scaled_x, scaled_y, scaled_width, scaled_height) =
3427                        if let Some((motion_center_x, motion_center_y)) =
3428                            effective_motion_scale_center
3429                        {
3430                            let motion_center_x_scaled = motion_center_x * scale;
3431                            let motion_center_y_scaled = motion_center_y * scale;
3432
3433                            let rel_x = base_x - motion_center_x_scaled;
3434                            let rel_y = base_y - motion_center_y_scaled;
3435
3436                            let scaled_rel_x = rel_x * effective_motion_scale.0;
3437                            let scaled_rel_y = rel_y * effective_motion_scale.1;
3438                            let scaled_w = base_width * effective_motion_scale.0;
3439                            let scaled_h = base_height * effective_motion_scale.1;
3440
3441                            let final_x = motion_center_x_scaled + scaled_rel_x + scaled_motion_tx;
3442                            let final_y = motion_center_y_scaled + scaled_rel_y + scaled_motion_ty;
3443
3444                            (final_x, final_y, scaled_w, scaled_h)
3445                        } else {
3446                            let final_x = base_x + scaled_motion_tx;
3447                            let final_y = base_y + scaled_motion_ty;
3448                            (final_x, final_y, base_width, base_height)
3449                        };
3450
3451                    // Use CSS-overridden font size if available (from stylesheet/animation/transition)
3452                    let base_styled_font_size =
3453                        render_node.props.font_size.unwrap_or(styled_data.font_size);
3454                    let scaled_font_size = base_styled_font_size * effective_motion_scale.1 * scale;
3455                    // Intersect primary clip with scroll clip for styled text
3456                    let effective_clip = effective_single_clip(current_clip, current_scroll_clip);
3457                    let scaled_clip = effective_clip
3458                        .map(|[cx, cy, cw, ch]| [cx * scale, cy * scale, cw * scale, ch * scale]);
3459
3460                    // Build non-overlapping segments from potentially overlapping spans
3461                    // This handles nested tags like <span color="red"><b>text</b></span>
3462                    let content = &styled_data.content;
3463                    let content_len = content.len();
3464
3465                    // Get default styles from element config
3466                    let default_bold = styled_data.weight == FontWeight::Bold;
3467                    let default_italic = styled_data.italic;
3468
3469                    // Collect all boundary positions where style might change
3470                    let mut boundaries: Vec<usize> = vec![0, content_len];
3471                    for span in &styled_data.spans {
3472                        if span.start < content_len {
3473                            boundaries.push(span.start);
3474                        }
3475                        if span.end <= content_len {
3476                            boundaries.push(span.end);
3477                        }
3478                    }
3479                    boundaries.sort();
3480                    boundaries.dedup();
3481
3482                    // Build segments between boundaries
3483                    #[allow(clippy::type_complexity)]
3484                    let mut segments: Vec<(
3485                        usize,
3486                        usize,
3487                        [f32; 4],
3488                        bool,
3489                        bool,
3490                        bool,
3491                        bool,
3492                    )> = Vec::new();
3493
3494                    for window in boundaries.windows(2) {
3495                        let seg_start = window[0];
3496                        let seg_end = window[1];
3497                        if seg_start >= seg_end {
3498                            continue;
3499                        }
3500
3501                        // Determine style for this segment by merging all overlapping spans
3502                        let mut color: Option<[f32; 4]> = None;
3503                        let mut bold = default_bold;
3504                        let mut italic = default_italic;
3505                        let mut underline = false;
3506                        let mut strikethrough = false;
3507
3508                        for span in &styled_data.spans {
3509                            // Check if span overlaps this segment
3510                            if span.start <= seg_start && span.end >= seg_end {
3511                                // This span covers this segment - merge styles
3512                                if span.bold {
3513                                    bold = true;
3514                                }
3515                                if span.italic {
3516                                    italic = true;
3517                                }
3518                                if span.underline {
3519                                    underline = true;
3520                                }
3521                                if span.strikethrough {
3522                                    strikethrough = true;
3523                                }
3524                                // Use color if span has explicit color (not transparent)
3525                                if span.color[3] > 0.0 {
3526                                    color = Some(span.color);
3527                                }
3528                            }
3529                        }
3530
3531                        // CSS text_color override takes precedence over span colors
3532                        let default_color = render_node
3533                            .props
3534                            .text_color
3535                            .unwrap_or(styled_data.default_color);
3536                        let final_color = color.unwrap_or(default_color);
3537                        segments.push((
3538                            seg_start,
3539                            seg_end,
3540                            final_color,
3541                            bold,
3542                            italic,
3543                            underline,
3544                            strikethrough,
3545                        ));
3546                    }
3547
3548                    // Use consistent ascender from element for baseline alignment
3549                    let scaled_ascender = styled_data.ascender * scale;
3550
3551                    // Calculate x offsets for each segment and push as TextElements
3552                    let mut x_offset = 0.0f32;
3553                    for (start, end, color, bold, italic, underline, strikethrough) in segments {
3554                        if start >= end || start >= content.len() {
3555                            continue;
3556                        }
3557                        let segment_text = &content[start..end.min(content.len())];
3558                        if segment_text.is_empty() {
3559                            continue;
3560                        }
3561
3562                        // Measure segment width for positioning
3563                        let mut options = blinc_layout::text_measure::TextLayoutOptions::new();
3564                        options.font_name = styled_data.font_family.name.clone();
3565                        options.generic_font = styled_data.font_family.generic;
3566                        options.font_weight = if bold { 700 } else { 400 };
3567                        options.italic = italic;
3568                        let metrics = blinc_layout::text_measure::measure_text_with_options(
3569                            segment_text,
3570                            styled_data.font_size,
3571                            &options,
3572                        );
3573                        // Apply both DPI scale and motion scale to segment width
3574                        let segment_width = metrics.width * scale * effective_motion_scale.0;
3575
3576                        texts.push(TextElement {
3577                            content: segment_text.to_string(),
3578                            x: scaled_x + x_offset,
3579                            y: scaled_y,
3580                            width: segment_width,
3581                            height: scaled_height,
3582                            font_size: scaled_font_size,
3583                            color,
3584                            align: TextAlign::Left, // Always left-align segments
3585                            weight: if bold {
3586                                FontWeight::Bold
3587                            } else {
3588                                FontWeight::Normal
3589                            },
3590                            italic,
3591                            v_align: styled_data.v_align,
3592                            clip_bounds: scaled_clip,
3593                            motion_opacity: effective_motion_opacity
3594                                * render_node.props.opacity
3595                                * inherited_css_opacity,
3596                            wrap: false, // Don't wrap individual segments
3597                            line_height: styled_data.line_height,
3598                            measured_width: segment_width,
3599                            font_family: styled_data.font_family.clone(),
3600                            word_spacing: 0.0,
3601                            letter_spacing: render_node.props.letter_spacing.unwrap_or(0.0),
3602                            z_index: *z_layer,
3603                            ascender: scaled_ascender * effective_motion_scale.1, // Scale ascender with motion
3604                            strikethrough,
3605                            underline,
3606                            decoration_color: render_node.props.text_decoration_color,
3607                            decoration_thickness: render_node.props.text_decoration_thickness,
3608                            css_affine: node_css_affine,
3609                            text_shadow: render_node.props.text_shadow,
3610                            transform_3d_layer: inside_3d_layer.clone(),
3611                            is_foreground: children_inside_foreground,
3612                        });
3613
3614                        x_offset += segment_width;
3615                    }
3616                }
3617            }
3618
3619            // Collect flow element if this node has a @flow shader reference.
3620            // Flow elements render via custom GPU pipelines instead of (or on top of) the SDF path.
3621            if let Some(ref flow_name) = render_node.props.flow {
3622                flows.push(FlowElement {
3623                    flow_name: flow_name.clone(),
3624                    flow_graph: render_node.props.flow_graph.clone(),
3625                    x: abs_x * scale,
3626                    y: abs_y * scale,
3627                    width: bounds.width * scale,
3628                    height: bounds.height * scale,
3629                    z_index: *z_layer,
3630                    corner_radius: render_node.props.border_radius.top_left * scale,
3631                });
3632            }
3633        }
3634
3635        // Include scroll offset and motion offset when calculating child positions
3636        let scroll_offset = tree.get_scroll_offset(node);
3637        let static_motion_offset = tree
3638            .get_motion_transform(node)
3639            .map(|t| match t {
3640                blinc_core::Transform::Affine2D(a) => (a.elements[4], a.elements[5]),
3641                _ => (0.0, 0.0),
3642            })
3643            .unwrap_or((0.0, 0.0));
3644
3645        let new_offset = (
3646            abs_x + scroll_offset.0 + static_motion_offset.0,
3647            abs_y + scroll_offset.1 + static_motion_offset.1,
3648        );
3649
3650        // Compute inherited CSS opacity for children: compound this node's CSS opacity
3651        // CSS `opacity` applies to the element AND its visual subtree
3652        let child_css_opacity = if let Some(rn) = tree.get_render_node(node) {
3653            inherited_css_opacity * rn.props.opacity
3654        } else {
3655            inherited_css_opacity
3656        };
3657
3658        // Detect 3D layer: if this node has rotate-x/rotate-y/perspective,
3659        // create a Transform3DLayerInfo for children to inherit.
3660        let child_3d_layer = if let Some(rn) = tree.get_render_node(node) {
3661            let has_3d = rn.props.rotate_x.is_some()
3662                || rn.props.rotate_y.is_some()
3663                || rn.props.perspective.is_some();
3664            if has_3d {
3665                let rx = rn.props.rotate_x.unwrap_or(0.0).to_radians();
3666                let ry = rn.props.rotate_y.unwrap_or(0.0).to_radians();
3667                let d = rn.props.perspective.unwrap_or(800.0) * scale;
3668                Some(Transform3DLayerInfo {
3669                    node_id: node,
3670                    layer_bounds: [
3671                        abs_x * scale,
3672                        abs_y * scale,
3673                        bounds.width * scale,
3674                        bounds.height * scale,
3675                    ],
3676                    transform_3d: blinc_core::Transform3DParams {
3677                        sin_rx: rx.sin(),
3678                        cos_rx: rx.cos(),
3679                        sin_ry: ry.sin(),
3680                        cos_ry: ry.cos(),
3681                        perspective_d: d,
3682                    },
3683                    opacity: rn.props.opacity,
3684                })
3685            } else {
3686                inside_3d_layer.clone()
3687            }
3688        } else {
3689            inside_3d_layer.clone()
3690        };
3691
3692        for child_id in tree.layout().children(node) {
3693            self.collect_elements_recursive(
3694                tree,
3695                child_id,
3696                new_offset,
3697                children_inside_glass,
3698                children_inside_foreground,
3699                child_clip,
3700                child_clip_radius,
3701                effective_motion_opacity,
3702                effective_motion_translate,
3703                effective_motion_scale,
3704                effective_motion_scale_center,
3705                render_state,
3706                scale,
3707                z_layer,
3708                texts,
3709                svgs,
3710                images,
3711                flows,
3712                node_css_affine,
3713                child_css_opacity,
3714                Some(node), // pass current node as parent for children
3715                child_scroll_clip,
3716                child_3d_layer.clone(),
3717            );
3718        }
3719
3720        // Restore z_layer after this subtree
3721        if node_z_index > 0 {
3722            *z_layer = saved_z_layer;
3723        }
3724    }
3725
3726    /// Get device arc
3727    pub fn device(&self) -> &Arc<wgpu::Device> {
3728        &self.device
3729    }
3730
3731    /// Get queue arc
3732    pub fn queue(&self) -> &Arc<wgpu::Queue> {
3733        &self.queue
3734    }
3735
3736    /// Whether the GPU adapter supports storage buffers.
3737    /// False on WebGL2 (GL adapter) — the renderer uses data textures instead.
3738    pub fn has_storage_buffers(&self) -> bool {
3739        self.renderer.has_storage_buffers()
3740    }
3741
3742    /// Get the shared font registry
3743    ///
3744    /// This can be used to share fonts between text measurement and rendering,
3745    /// ensuring consistent font loading and metrics.
3746    pub fn font_registry(&self) -> Arc<Mutex<FontRegistry>> {
3747        self.text_ctx.font_registry()
3748    }
3749
3750    /// Get the texture format used by the renderer
3751    pub fn texture_format(&self) -> wgpu::TextureFormat {
3752        self.renderer.texture_format()
3753    }
3754
3755    /// Create a new wgpu surface for an additional window (multi-window support)
3756    pub fn create_surface<W>(
3757        &self,
3758        window: Arc<W>,
3759    ) -> std::result::Result<wgpu::Surface<'static>, blinc_gpu::RendererError>
3760    where
3761        W: raw_window_handle::HasWindowHandle
3762            + raw_window_handle::HasDisplayHandle
3763            + Send
3764            + Sync
3765            + 'static,
3766    {
3767        self.renderer.create_surface(window)
3768    }
3769
3770    /// Render a layout tree with dynamic render state overlays
3771    ///
3772    /// This method renders:
3773    /// 1. The stable RenderTree (element hierarchy and layout)
3774    /// 2. RenderState overlays (cursors, selections, focus rings)
3775    ///
3776    /// The RenderState overlays are drawn on top of the tree without requiring
3777    /// tree rebuilds. This enables smooth cursor blinking and animations.
3778    pub fn render_tree_with_state(
3779        &mut self,
3780        tree: &RenderTree,
3781        render_state: &blinc_layout::RenderState,
3782        width: u32,
3783        height: u32,
3784        target: &wgpu::TextureView,
3785    ) -> Result<()> {
3786        // First render the tree as normal
3787        self.render_tree(tree, width, height, target)?;
3788
3789        // Then render overlays from RenderState
3790        self.render_overlays(render_state, width, height, target);
3791
3792        Ok(())
3793    }
3794
3795    /// Render a layout tree with motion animations from RenderState
3796    ///
3797    /// This method renders:
3798    /// 1. The RenderTree with motion animations applied (opacity, scale, translate)
3799    /// 2. RenderState overlays (cursors, selections, focus rings)
3800    ///
3801    /// Use this method when you have elements wrapped in motion() containers
3802    /// for enter/exit animations.
3803    pub fn render_tree_with_motion(
3804        &mut self,
3805        tree: &RenderTree,
3806        render_state: &blinc_layout::RenderState,
3807        width: u32,
3808        height: u32,
3809        target: &wgpu::TextureView,
3810    ) -> Result<()> {
3811        // Get scale factor for HiDPI rendering
3812        let scale_factor = tree.scale_factor();
3813
3814        // Create a single paint context for all layers with text rendering support
3815        let mut ctx =
3816            GpuPaintContext::with_text_context(width as f32, height as f32, &mut self.text_ctx);
3817
3818        // Render with motion animations applied (all layers to same context)
3819        tree.render_with_motion(&mut ctx, render_state);
3820
3821        // Take the batch (mutable so CSS-transformed text primitives can be added)
3822        let mut batch = ctx.take_batch();
3823
3824        // Take any 3D mesh draws captured via `ctx.draw_mesh_data(...)`
3825        // inside canvas callbacks. These are dispatched after all 2D
3826        // content lands so the mesh composites on top of the UI — see
3827        // the `render_mesh_data` dispatch loop near the end of this
3828        // function. Drained here (not at the dispatch site) so
3829        // `ctx` can drop right after `take_batch`/`take_pending_meshes`
3830        // and the rest of the frame runs without holding onto it.
3831        let pending_meshes = ctx.take_pending_meshes();
3832
3833        // Collect text, SVG, image, and flow elements WITH motion state
3834        let (all_texts, all_svgs, all_images, flow_elements) =
3835            self.collect_render_elements_with_state(tree, Some(render_state));
3836
3837        // Partition elements into normal (no 3D ancestor) and 3D-layer groups.
3838        // Elements inside a 3D-transformed parent need to be rendered to an offscreen
3839        // texture and blitted with the same perspective transform.
3840        let mut texts = Vec::new();
3841        let mut fg_texts = Vec::new();
3842        let mut layer_3d_texts: std::collections::HashMap<
3843            LayoutNodeId,
3844            (Transform3DLayerInfo, Vec<TextElement>),
3845        > = std::collections::HashMap::new();
3846        for text in all_texts {
3847            if let Some(ref info) = text.transform_3d_layer {
3848                layer_3d_texts
3849                    .entry(info.node_id)
3850                    .or_insert_with(|| (info.clone(), Vec::new()))
3851                    .1
3852                    .push(text);
3853            } else if text.is_foreground {
3854                fg_texts.push(text);
3855            } else {
3856                texts.push(text);
3857            }
3858        }
3859
3860        let mut svgs = Vec::new();
3861        let mut layer_3d_svgs: std::collections::HashMap<LayoutNodeId, Vec<SvgElement>> =
3862            std::collections::HashMap::new();
3863        for svg in all_svgs {
3864            if let Some(ref info) = svg.transform_3d_layer {
3865                layer_3d_svgs.entry(info.node_id).or_default().push(svg);
3866            } else {
3867                svgs.push(svg);
3868            }
3869        }
3870
3871        let mut images = Vec::new();
3872        let mut layer_3d_images: std::collections::HashMap<LayoutNodeId, Vec<ImageElement>> =
3873            std::collections::HashMap::new();
3874        for image in all_images {
3875            if let Some(ref info) = image.transform_3d_layer {
3876                layer_3d_images.entry(info.node_id).or_default().push(image);
3877            } else {
3878                images.push(image);
3879            }
3880        }
3881
3882        // Collect unique 3D layer IDs for rendering
3883        let layer_3d_ids: Vec<LayoutNodeId> = layer_3d_texts.keys().cloned().collect();
3884
3885        // Pre-load all images into cache before rendering (both normal and 3D-layer)
3886        self.preload_images(&images, width as f32, height as f32);
3887        for layer_imgs in layer_3d_images.values() {
3888            self.preload_images(layer_imgs, width as f32, height as f32);
3889        }
3890
3891        // Prepare text glyphs with z_layer information
3892        // Store (z_layer, glyphs) to enable interleaved rendering
3893        let mut glyphs_by_layer: std::collections::BTreeMap<u32, Vec<GpuGlyph>> =
3894            std::collections::BTreeMap::new();
3895        let mut css_transformed_text_prims: Vec<GpuPrimitive> = Vec::new();
3896        for text in &texts {
3897            // Skip text that's completely outside its clip bounds (visibility culling)
3898            // This prevents loading emoji fonts for off-screen text in scroll containers
3899            if let Some([clip_x, clip_y, clip_w, clip_h]) = text.clip_bounds {
3900                let text_right = text.x + text.width;
3901                let text_bottom = text.y + text.height;
3902                let clip_right = clip_x + clip_w;
3903                let clip_bottom = clip_y + clip_h;
3904
3905                // Check if text is completely outside clip bounds
3906                if text.x >= clip_right
3907                    || text_right <= clip_x
3908                    || text.y >= clip_bottom
3909                    || text_bottom <= clip_y
3910                {
3911                    // Text is not visible, skip rendering entirely
3912                    continue;
3913                }
3914            }
3915
3916            let alignment = match text.align {
3917                TextAlign::Left => TextAlignment::Left,
3918                TextAlign::Center => TextAlignment::Center,
3919                TextAlign::Right => TextAlignment::Right,
3920            };
3921
3922            // Apply motion opacity to text color
3923            let color = if text.motion_opacity < 1.0 {
3924                [
3925                    text.color[0],
3926                    text.color[1],
3927                    text.color[2],
3928                    text.color[3] * text.motion_opacity,
3929                ]
3930            } else {
3931                text.color
3932            };
3933
3934            // Determine wrap width:
3935            // 1. If clip bounds exist and are smaller than measured width, use clip width
3936            //    (this handles scroll containers where layout width isn't constrained)
3937            // 2. Otherwise, if layout width is smaller than measured, use layout width
3938            // 3. Otherwise, don't wrap (text fits naturally)
3939            let effective_width = if let Some(clip) = text.clip_bounds {
3940                // Use clip width if it constrains the text
3941                clip[2].min(text.width)
3942            } else {
3943                text.width
3944            };
3945
3946            // Wrap if effective width is significantly smaller than measured width
3947            let needs_wrap = text.wrap && effective_width < text.measured_width - 2.0;
3948
3949            // Always pass width for alignment - the layout engine needs max_width
3950            // to calculate center/right alignment offsets
3951            let wrap_width = Some(text.width);
3952
3953            // Convert font family to GPU types
3954            let font_name = text.font_family.name.as_deref();
3955            let generic = to_gpu_generic_font(text.font_family.generic);
3956            let font_weight = text.weight.weight();
3957
3958            // Map vertical alignment to text anchor
3959            let (anchor, y_pos, use_layout_height) = match text.v_align {
3960                TextVerticalAlign::Center => {
3961                    (TextAnchor::Center, text.y + text.height / 2.0, false)
3962                }
3963                TextVerticalAlign::Top => (TextAnchor::Top, text.y, true),
3964                TextVerticalAlign::Baseline => {
3965                    let baseline_y = text.y + text.ascender;
3966                    (TextAnchor::Baseline, baseline_y, false)
3967                }
3968            };
3969            let layout_height = if use_layout_height {
3970                Some(text.height)
3971            } else {
3972                None
3973            };
3974
3975            // Render text shadow first (behind text) if present
3976            if let Some(shadow) = &text.text_shadow {
3977                let shadow_color = [
3978                    shadow.color.r,
3979                    shadow.color.g,
3980                    shadow.color.b,
3981                    shadow.color.a * text.motion_opacity,
3982                ];
3983                let shadow_x = text.x + shadow.offset_x * scale_factor;
3984                let shadow_y = y_pos + shadow.offset_y * scale_factor;
3985                if let Ok(mut shadow_glyphs) = self.text_ctx.prepare_text_with_style(
3986                    &text.content,
3987                    shadow_x,
3988                    shadow_y,
3989                    text.font_size,
3990                    shadow_color,
3991                    anchor,
3992                    alignment,
3993                    wrap_width,
3994                    needs_wrap,
3995                    font_name,
3996                    generic,
3997                    font_weight,
3998                    text.italic,
3999                    layout_height,
4000                    text.letter_spacing,
4001                ) {
4002                    if let Some(clip) = text.clip_bounds {
4003                        for glyph in &mut shadow_glyphs {
4004                            glyph.clip_bounds = clip;
4005                        }
4006                    }
4007                    if let Some(affine) = text.css_affine {
4008                        let [a, b, c, d, tx, ty] = affine;
4009                        let tx_scaled = tx * scale_factor;
4010                        let ty_scaled = ty * scale_factor;
4011                        for glyph in &shadow_glyphs {
4012                            let gc_x = glyph.bounds[0] + glyph.bounds[2] / 2.0;
4013                            let gc_y = glyph.bounds[1] + glyph.bounds[3] / 2.0;
4014                            let new_gc_x = a * gc_x + c * gc_y + tx_scaled;
4015                            let new_gc_y = b * gc_x + d * gc_y + ty_scaled;
4016                            let mut prim = GpuPrimitive::from_glyph(glyph);
4017                            prim.bounds = [
4018                                new_gc_x - glyph.bounds[2] / 2.0,
4019                                new_gc_y - glyph.bounds[3] / 2.0,
4020                                glyph.bounds[2],
4021                                glyph.bounds[3],
4022                            ];
4023                            prim.local_affine = [a, b, c, d];
4024                            prim.set_z_layer(text.z_index);
4025                            css_transformed_text_prims.push(prim);
4026                        }
4027                    } else {
4028                        glyphs_by_layer
4029                            .entry(text.z_index)
4030                            .or_default()
4031                            .extend(shadow_glyphs);
4032                    }
4033                }
4034            }
4035
4036            match self.text_ctx.prepare_text_with_style(
4037                &text.content,
4038                text.x,
4039                y_pos,
4040                text.font_size,
4041                color,
4042                anchor,
4043                alignment,
4044                wrap_width,
4045                needs_wrap,
4046                font_name,
4047                generic,
4048                font_weight,
4049                text.italic,
4050                layout_height,
4051                text.letter_spacing,
4052            ) {
4053                Ok(mut glyphs) => {
4054                    tracing::trace!(
4055                        "render_tree_with_motion: prepared {} glyphs for '{}' (font={:?})",
4056                        glyphs.len(),
4057                        text.content,
4058                        font_name
4059                    );
4060                    // Apply clip bounds if present
4061                    if let Some(clip) = text.clip_bounds {
4062                        for glyph in &mut glyphs {
4063                            glyph.clip_bounds = clip;
4064                        }
4065                    }
4066
4067                    if let Some(affine) = text.css_affine {
4068                        // CSS-transformed text: convert glyphs to SDF primitives with local_affine
4069                        let [a, b, c, d, tx, ty] = affine;
4070                        let tx_scaled = tx * scale_factor;
4071                        let ty_scaled = ty * scale_factor;
4072                        for glyph in &glyphs {
4073                            // Transform glyph center through the affine
4074                            let gc_x = glyph.bounds[0] + glyph.bounds[2] / 2.0;
4075                            let gc_y = glyph.bounds[1] + glyph.bounds[3] / 2.0;
4076                            let new_gc_x = a * gc_x + c * gc_y + tx_scaled;
4077                            let new_gc_y = b * gc_x + d * gc_y + ty_scaled;
4078                            let mut prim = GpuPrimitive::from_glyph(glyph);
4079                            prim.bounds = [
4080                                new_gc_x - glyph.bounds[2] / 2.0,
4081                                new_gc_y - glyph.bounds[3] / 2.0,
4082                                glyph.bounds[2],
4083                                glyph.bounds[3],
4084                            ];
4085                            prim.local_affine = [a, b, c, d];
4086                            prim.set_z_layer(text.z_index);
4087                            css_transformed_text_prims.push(prim);
4088                        }
4089                    } else {
4090                        // Normal text: add to glyph pipeline
4091                        glyphs_by_layer
4092                            .entry(text.z_index)
4093                            .or_default()
4094                            .extend(glyphs);
4095                    }
4096                }
4097                Err(e) => {
4098                    tracing::warn!(
4099                        "render_tree_with_motion: failed to prepare text '{}': {:?}",
4100                        text.content,
4101                        e
4102                    );
4103                }
4104            }
4105        }
4106
4107        // Prepare foreground text glyphs (rendered after foreground primitives)
4108        let mut fg_glyphs: Vec<GpuGlyph> = Vec::new();
4109        for text in &fg_texts {
4110            if let Some([clip_x, clip_y, clip_w, clip_h]) = text.clip_bounds {
4111                let text_right = text.x + text.width;
4112                let text_bottom = text.y + text.height;
4113                let clip_right = clip_x + clip_w;
4114                let clip_bottom = clip_y + clip_h;
4115                if text.x >= clip_right
4116                    || text_right <= clip_x
4117                    || text.y >= clip_bottom
4118                    || text_bottom <= clip_y
4119                {
4120                    continue;
4121                }
4122            }
4123
4124            let alignment = match text.align {
4125                TextAlign::Left => TextAlignment::Left,
4126                TextAlign::Center => TextAlignment::Center,
4127                TextAlign::Right => TextAlignment::Right,
4128            };
4129
4130            let color = if text.motion_opacity < 1.0 {
4131                [
4132                    text.color[0],
4133                    text.color[1],
4134                    text.color[2],
4135                    text.color[3] * text.motion_opacity,
4136                ]
4137            } else {
4138                text.color
4139            };
4140
4141            let effective_width = if let Some(clip) = text.clip_bounds {
4142                clip[2].min(text.width)
4143            } else {
4144                text.width
4145            };
4146            let needs_wrap = text.wrap && effective_width < text.measured_width - 2.0;
4147            let wrap_width = Some(text.width);
4148            let font_name = text.font_family.name.as_deref();
4149            let generic = to_gpu_generic_font(text.font_family.generic);
4150            let font_weight = text.weight.weight();
4151
4152            let (anchor, y_pos, use_layout_height) = match text.v_align {
4153                TextVerticalAlign::Center => {
4154                    (TextAnchor::Center, text.y + text.height / 2.0, false)
4155                }
4156                TextVerticalAlign::Top => (TextAnchor::Top, text.y, true),
4157                TextVerticalAlign::Baseline => {
4158                    let baseline_y = text.y + text.ascender;
4159                    (TextAnchor::Baseline, baseline_y, false)
4160                }
4161            };
4162            let layout_height = if use_layout_height {
4163                Some(text.height)
4164            } else {
4165                None
4166            };
4167
4168            if let Ok(mut glyphs) = self.text_ctx.prepare_text_with_style(
4169                &text.content,
4170                text.x,
4171                y_pos,
4172                text.font_size,
4173                color,
4174                anchor,
4175                alignment,
4176                wrap_width,
4177                needs_wrap,
4178                font_name,
4179                generic,
4180                font_weight,
4181                text.italic,
4182                layout_height,
4183                text.letter_spacing,
4184            ) {
4185                if let Some(clip) = text.clip_bounds {
4186                    for glyph in &mut glyphs {
4187                        glyph.clip_bounds = clip;
4188                    }
4189                }
4190                fg_glyphs.extend(glyphs);
4191            }
4192        }
4193
4194        // Generate decoration primitives for foreground text once so the
4195        // three render paths below can each render them after their
4196        // `render_text(target, &fg_glyphs)` call. Without this, any
4197        // strikethrough / underline on a `.foreground()` element is
4198        // silently dropped.
4199        let fg_decorations_by_layer = generate_text_decoration_primitives_by_layer(&fg_texts);
4200
4201        tracing::trace!(
4202            "render_tree_with_motion: {} texts, {} fg texts, {} z-layers with glyphs, {} css-transformed",
4203            texts.len(),
4204            fg_texts.len(),
4205            glyphs_by_layer.len(),
4206            css_transformed_text_prims.len()
4207        );
4208
4209        // SVGs are rendered as rasterized images (not tessellated paths) for better anti-aliasing
4210        // They will be rendered later via render_rasterized_svgs
4211
4212        self.renderer.resize(width, height);
4213
4214        // If we have CSS-transformed text, push text prims into the main batch
4215        // and bind the real glyph atlas to the SDF pipeline for ALL render paths.
4216        if !css_transformed_text_prims.is_empty() {
4217            if let (Some(atlas), Some(color_atlas)) =
4218                (self.text_ctx.atlas_view(), self.text_ctx.color_atlas_view())
4219            {
4220                batch.primitives.append(&mut css_transformed_text_prims);
4221                self.renderer.set_glyph_atlas(atlas, color_atlas);
4222            }
4223        }
4224
4225        let has_glass = batch.glass_count() > 0;
4226        let has_layer_effects_in_batch = batch.has_layer_effects();
4227
4228        // Only allocate glass textures when glass is actually used
4229        if has_glass {
4230            self.ensure_glass_textures(width, height);
4231        }
4232        let use_msaa_overlay = self.sample_count > 1;
4233
4234        if has_glass {
4235            // Glass path with layer effects support
4236            let (bg_images, fg_images): (Vec<_>, Vec<_>) = images
4237                .iter()
4238                .partition(|img| img.layer == RenderLayer::Background);
4239
4240            // Pre-render background images to both backdrop and target so glass can blur them
4241            let has_bg_images = !bg_images.is_empty();
4242            if has_bg_images {
4243                let backdrop_tex = self.backdrop_texture.take().unwrap();
4244                self.renderer
4245                    .clear_target(&backdrop_tex.view, wgpu::Color::TRANSPARENT);
4246                self.renderer.clear_target(target, wgpu::Color::BLACK);
4247                self.render_images_ref(&backdrop_tex.view, &bg_images);
4248                self.render_images_ref(target, &bg_images);
4249                self.backdrop_texture = Some(backdrop_tex);
4250            }
4251
4252            if has_layer_effects_in_batch {
4253                // When we have layer effects, we need a more complex render path:
4254                // 1. Render backdrop for glass blur sampling (with pre-rendered images if any)
4255                // 2. Use render_with_clear which handles layer effects
4256                // 3. Render background images to target (after clear, before glass)
4257                // 4. Render glass primitives on top
4258                {
4259                    let backdrop = self.backdrop_texture.as_ref().unwrap();
4260                    self.renderer.render_to_backdrop(
4261                        &backdrop.view,
4262                        (backdrop.width, backdrop.height),
4263                        &batch,
4264                        has_bg_images,
4265                    );
4266                }
4267
4268                // Then use render_with_clear which handles layer effects
4269                self.renderer
4270                    .render_with_clear(target, &batch, [0.0, 0.0, 0.0, 1.0]);
4271
4272                // Render dynamic images (video frames from draw_rgba_pixels)
4273                if !batch.dynamic_images.is_empty() {
4274                    self.renderer
4275                        .render_dynamic_images(target, &batch.dynamic_images);
4276                }
4277
4278                // Render background images to target after clear (so they're visible behind glass)
4279                if has_bg_images {
4280                    self.render_images_ref(target, &bg_images);
4281                }
4282
4283                // Finally render glass primitives on top
4284                if batch.glass_count() > 0 {
4285                    let backdrop = self.backdrop_texture.as_ref().unwrap();
4286                    self.renderer.render_glass(target, &backdrop.view, &batch);
4287                }
4288            } else {
4289                // No layer effects, use optimized glass frame rendering
4290                let backdrop = self.backdrop_texture.as_ref().unwrap();
4291                self.renderer.render_glass_frame(
4292                    target,
4293                    &backdrop.view,
4294                    (backdrop.width, backdrop.height),
4295                    &batch,
4296                    has_bg_images,
4297                );
4298            }
4299
4300            // Render paths with MSAA for smooth edges on curved shapes like notch
4301            // (render_glass_frame uses 1x sampled path rendering)
4302            if use_msaa_overlay && batch.has_paths() {
4303                self.renderer
4304                    .render_paths_overlay_msaa(target, &batch, self.sample_count);
4305            }
4306
4307            // Render remaining bg images (only if not already pre-rendered for glass)
4308            if !has_bg_images {
4309                self.render_images_ref(target, &bg_images);
4310            }
4311            self.render_images_ref(target, &fg_images);
4312
4313            // Interleaved z-layer rendering for proper text z-ordering in glass path
4314            let max_z = batch.max_z_layer();
4315            let max_text_z = glyphs_by_layer.keys().cloned().max().unwrap_or(0);
4316            let decorations_by_layer = generate_text_decoration_primitives_by_layer(&texts);
4317            let max_decoration_z = decorations_by_layer.keys().cloned().max().unwrap_or(0);
4318            let max_glass_layer = max_z.max(max_text_z).max(max_decoration_z);
4319
4320            // Render z=0 text first (before any z>0 primitives)
4321            {
4322                let mut scratch = std::mem::take(&mut self.scratch_glyphs);
4323                scratch.clear();
4324                if let Some(glyphs) = glyphs_by_layer.get(&0) {
4325                    scratch.extend_from_slice(glyphs);
4326                }
4327                if !scratch.is_empty() {
4328                    self.render_text(target, &scratch);
4329                }
4330                self.scratch_glyphs = scratch;
4331            }
4332            self.render_text_decorations_for_layer(target, &decorations_by_layer, 0);
4333
4334            if max_glass_layer > 0 {
4335                let effect_indices = batch.effect_layer_indices();
4336                for z in 1..=max_glass_layer {
4337                    // Render primitives for this layer
4338                    let layer_primitives = if effect_indices.is_empty() {
4339                        batch.primitives_for_layer(z)
4340                    } else {
4341                        batch.primitives_for_layer_excluding_effects(z, &effect_indices)
4342                    };
4343                    if !layer_primitives.is_empty() {
4344                        self.renderer
4345                            .render_primitives_overlay(target, &layer_primitives);
4346                    }
4347
4348                    // Render text for this layer (interleaved for proper z-order)
4349                    {
4350                        let mut scratch = std::mem::take(&mut self.scratch_glyphs);
4351                        scratch.clear();
4352                        if let Some(glyphs) = glyphs_by_layer.get(&z) {
4353                            scratch.extend_from_slice(glyphs);
4354                        }
4355                        if !scratch.is_empty() {
4356                            self.render_text(target, &scratch);
4357                        }
4358                        self.scratch_glyphs = scratch;
4359                    }
4360                    self.render_text_decorations_for_layer(target, &decorations_by_layer, z);
4361                }
4362            }
4363
4364            // Render SVGs as rasterized images for high-quality anti-aliasing
4365            if !svgs.is_empty() {
4366                self.render_rasterized_svgs(target, &svgs, scale_factor);
4367            }
4368
4369            // Render foreground text (inside foreground-layer elements, after everything else)
4370            if !fg_glyphs.is_empty() {
4371                self.render_text(target, &fg_glyphs);
4372            }
4373            // Render foreground text decorations (strikethrough / underline)
4374            // for every z-layer present in the foreground decoration index.
4375            for &z in fg_decorations_by_layer.keys() {
4376                self.render_text_decorations_for_layer(target, &fg_decorations_by_layer, z);
4377            }
4378        } else {
4379            // Simple path (no glass)
4380            // Pre-generate text decorations grouped by layer for interleaved rendering
4381            let decorations_by_layer = generate_text_decoration_primitives_by_layer(&texts);
4382
4383            let max_z = batch.max_z_layer();
4384            let max_text_z = glyphs_by_layer.keys().cloned().max().unwrap_or(0);
4385            let max_decoration_z = decorations_by_layer.keys().cloned().max().unwrap_or(0);
4386            let max_layer = max_z.max(max_text_z).max(max_decoration_z);
4387            let has_layer_effects = batch.has_layer_effects();
4388
4389            if max_layer > 0 && !has_layer_effects {
4390                // Interleaved z-layer rendering for proper Stack z-ordering
4391                // Group images by z_index for interleaved rendering
4392                let mut images_by_layer: std::collections::BTreeMap<u32, Vec<&ImageElement>> =
4393                    std::collections::BTreeMap::new();
4394                for img in &images {
4395                    images_by_layer.entry(img.z_index).or_default().push(img);
4396                }
4397                let max_image_z = images_by_layer.keys().cloned().max().unwrap_or(0);
4398                let max_layer = max_layer.max(max_image_z);
4399
4400                // First pass: render z_layer=0 primitives with clear
4401                let z0_primitives = batch.primitives_for_layer(0);
4402                // Create a temporary batch for z=0 (include paths - they don't have z-layer support)
4403                let mut z0_batch = PrimitiveBatch::new();
4404                z0_batch.primitives = z0_primitives;
4405                z0_batch.paths = batch.paths.clone();
4406                self.renderer
4407                    .render_with_clear(target, &z0_batch, [0.0, 0.0, 0.0, 1.0]);
4408
4409                // Render dynamic images (video frames)
4410                if !batch.dynamic_images.is_empty() {
4411                    self.renderer
4412                        .render_dynamic_images(target, &batch.dynamic_images);
4413                }
4414
4415                // Render paths with MSAA for smooth edges on curved shapes like notch
4416                if use_msaa_overlay && z0_batch.has_paths() {
4417                    self.renderer
4418                        .render_paths_overlay_msaa(target, &z0_batch, self.sample_count);
4419                }
4420
4421                // Render z=0 images
4422                if let Some(z0_images) = images_by_layer.get(&0) {
4423                    self.render_images_ref(target, z0_images);
4424                }
4425
4426                // Render z=0 text (must render before z=1 primitives for proper z-ordering)
4427                if let Some(glyphs) = glyphs_by_layer.get(&0) {
4428                    if !glyphs.is_empty() {
4429                        self.render_text(target, glyphs);
4430                    }
4431                }
4432                self.render_text_decorations_for_layer(target, &decorations_by_layer, 0);
4433
4434                // Render subsequent layers interleaved (primitives, images, text per layer)
4435                for z in 1..=max_layer {
4436                    // Render primitives for this layer
4437                    let layer_primitives = batch.primitives_for_layer(z);
4438                    if !layer_primitives.is_empty() {
4439                        self.renderer
4440                            .render_primitives_overlay(target, &layer_primitives);
4441                    }
4442
4443                    // Render images for this layer
4444                    if let Some(layer_images) = images_by_layer.get(&z) {
4445                        self.render_images_ref(target, layer_images);
4446                    }
4447
4448                    // Render text for this layer (interleaved with primitives for proper z-order)
4449                    if let Some(glyphs) = glyphs_by_layer.get(&z) {
4450                        if !glyphs.is_empty() {
4451                            self.render_text(target, glyphs);
4452                        }
4453                    }
4454                    self.render_text_decorations_for_layer(target, &decorations_by_layer, z);
4455                }
4456
4457                // Render SVGs as rasterized images for high-quality anti-aliasing
4458                if !svgs.is_empty() {
4459                    self.render_rasterized_svgs(target, &svgs, scale_factor);
4460                }
4461
4462                // Render foreground primitives (e.g. borders on top)
4463                if !batch.foreground_primitives.is_empty() {
4464                    self.renderer
4465                        .render_primitives_overlay(target, &batch.foreground_primitives);
4466                }
4467
4468                // Render foreground text (inside foreground-layer elements, after foreground primitives)
4469                if !fg_glyphs.is_empty() {
4470                    self.render_text(target, &fg_glyphs);
4471                }
4472                for &z in fg_decorations_by_layer.keys() {
4473                    self.render_text_decorations_for_layer(target, &fg_decorations_by_layer, z);
4474                }
4475            } else {
4476                // Fast path: render full batch (handles layer effects like backdrop-filter)
4477                self.renderer
4478                    .render_with_clear(target, &batch, [0.0, 0.0, 0.0, 1.0]);
4479
4480                // Render dynamic images (video frames)
4481                if !batch.dynamic_images.is_empty() {
4482                    self.renderer
4483                        .render_dynamic_images(target, &batch.dynamic_images);
4484                }
4485
4486                // Render paths with MSAA for smooth edges on curved shapes like notch
4487                if use_msaa_overlay && batch.has_paths() {
4488                    self.renderer
4489                        .render_paths_overlay_msaa(target, &batch, self.sample_count);
4490                }
4491
4492                self.render_images(target, &images, width as f32, height as f32, scale_factor);
4493
4494                // Render foreground primitives (e.g. borders on top)
4495                if !batch.foreground_primitives.is_empty() {
4496                    self.renderer
4497                        .render_primitives_overlay(target, &batch.foreground_primitives);
4498                }
4499
4500                // Render SVGs as rasterized images for high-quality anti-aliasing
4501                if !svgs.is_empty() {
4502                    self.render_rasterized_svgs(target, &svgs, scale_factor);
4503                }
4504
4505                // Interleaved z-layer rendering for proper text z-ordering
4506                // Render z=0 text before any z>0 primitive overlays
4507                if let Some(glyphs) = glyphs_by_layer.get(&0) {
4508                    if !glyphs.is_empty() {
4509                        self.render_text(target, glyphs);
4510                    }
4511                }
4512                self.render_text_decorations_for_layer(target, &decorations_by_layer, 0);
4513
4514                if max_layer > 0 {
4515                    let effect_indices = batch.effect_layer_indices();
4516                    for z in 1..=max_layer {
4517                        // Render primitives for this z-layer
4518                        let layer_primitives = if effect_indices.is_empty() {
4519                            batch.primitives_for_layer(z)
4520                        } else {
4521                            batch.primitives_for_layer_excluding_effects(z, &effect_indices)
4522                        };
4523                        if !layer_primitives.is_empty() {
4524                            self.renderer
4525                                .render_primitives_overlay(target, &layer_primitives);
4526                        }
4527
4528                        // Render text for this z-layer (interleaved for proper z-order)
4529                        if let Some(glyphs) = glyphs_by_layer.get(&z) {
4530                            if !glyphs.is_empty() {
4531                                self.render_text(target, glyphs);
4532                            }
4533                        }
4534                        self.render_text_decorations_for_layer(target, &decorations_by_layer, z);
4535                    }
4536                }
4537
4538                // Render foreground text (inside foreground-layer elements, after all z-layers)
4539                if !fg_glyphs.is_empty() {
4540                    self.render_text(target, &fg_glyphs);
4541                }
4542                for &z in fg_decorations_by_layer.keys() {
4543                    self.render_text_decorations_for_layer(target, &fg_decorations_by_layer, z);
4544                }
4545            }
4546        }
4547
4548        // Render 3D-layer text/SVGs/images: for each 3D layer group, render to an
4549        // offscreen texture and blit with the same perspective transform as the parent.
4550        for layer_id in &layer_3d_ids {
4551            if let Some((info, layer_texts)) = layer_3d_texts.get(layer_id) {
4552                let layer_svgs_vec = layer_3d_svgs.get(layer_id);
4553                let layer_images_vec = layer_3d_images.get(layer_id);
4554                self.render_3d_layer_elements(
4555                    target,
4556                    info,
4557                    layer_texts,
4558                    layer_svgs_vec.map(|v| v.as_slice()).unwrap_or(&[]),
4559                    layer_images_vec.map(|v| v.as_slice()).unwrap_or(&[]),
4560                    scale_factor,
4561                );
4562            }
4563        }
4564
4565        // Render @flow shader elements on top of their SDF base
4566        self.has_active_flows = !flow_elements.is_empty();
4567        if !flow_elements.is_empty() {
4568            let stylesheet = tree.stylesheet();
4569
4570            // Use monotonic time for smooth animation
4571            static START_TIME: std::sync::OnceLock<web_time::Instant> = std::sync::OnceLock::new();
4572            let start = START_TIME.get_or_init(web_time::Instant::now);
4573            let elapsed_secs = start.elapsed().as_secs_f32();
4574
4575            for flow_el in &flow_elements {
4576                // Resolve FlowGraph: direct graph first, then stylesheet lookup
4577                let graph = flow_el
4578                    .flow_graph
4579                    .as_deref()
4580                    .or_else(|| stylesheet.and_then(|s| s.get_flow(&flow_el.flow_name)));
4581
4582                if let Some(graph) = graph {
4583                    // Compile on first use (no-op if already cached)
4584                    if let Err(e) = self.renderer.flow_pipeline_cache().compile(graph) {
4585                        tracing::warn!("@flow '{}' compile error: {}", flow_el.flow_name, e);
4586                        continue;
4587                    }
4588
4589                    let uniforms = blinc_gpu::FlowUniformData {
4590                        viewport_size: [width as f32, height as f32],
4591                        time: elapsed_secs,
4592                        frame_index: 0.0, // TODO: track frame counter
4593                        element_bounds: [flow_el.x, flow_el.y, flow_el.width, flow_el.height],
4594                        pointer: [
4595                            (self.cursor_pos[0] - flow_el.x) / flow_el.width.max(1.0),
4596                            (self.cursor_pos[1] - flow_el.y) / flow_el.height.max(1.0),
4597                        ],
4598                        corner_radius: flow_el.corner_radius,
4599                        _padding: 0.0,
4600                    };
4601
4602                    let viewport = [flow_el.x, flow_el.y, flow_el.width, flow_el.height];
4603                    if !self.renderer.render_flow(
4604                        target,
4605                        &flow_el.flow_name,
4606                        &uniforms,
4607                        Some(viewport),
4608                    ) {
4609                        tracing::warn!("@flow '{}' render failed", flow_el.flow_name);
4610                    }
4611                }
4612            }
4613        }
4614
4615        // Poll the device to free completed command buffers
4616        self.renderer.poll();
4617
4618        // Dispatch 3D mesh draws captured during `tree.render_with_motion`.
4619        // Each `PendingMesh` carries a snapshot of the camera and lights
4620        // active when `canvas(|ctx, bounds| ctx.draw_mesh_data(...))` fired,
4621        // so the mesh pipeline renders at the correct pose even if the
4622        // closure's camera was transient. View-projection is computed
4623        // from the captured camera + the actual frame viewport so aspect
4624        // stays correct under window resizes.
4625        //
4626        // MVP scope: meshes render to the full frame target (no scissor
4627        // to the canvas bounds yet), composite on top of the 2D UI, and
4628        // sit under `render_overlays` so overlay panels still clip
4629        // cleanly over them. Per-canvas viewport clipping is a
4630        // follow-up once the first end-to-end demo proves the path.
4631        if !pending_meshes.is_empty() {
4632            dispatch_pending_meshes(&mut self.renderer, target, width, height, &pending_meshes);
4633        }
4634
4635        // Render overlays from RenderState
4636        self.render_overlays(render_state, width, height, target);
4637
4638        // Render debug visualization if enabled (BLINC_DEBUG=text|layout|all)
4639        let debug = DebugMode::from_env();
4640        if debug.text {
4641            self.render_text_debug(target, &texts);
4642        }
4643        if debug.layout {
4644            let scale = tree.scale_factor();
4645            self.render_layout_debug(target, tree, scale);
4646        }
4647        if debug.motion {
4648            self.render_motion_debug(target, tree, width, height);
4649        }
4650
4651        // Return scratch buffers for reuse on next frame
4652        self.return_scratch_elements(texts, svgs, images);
4653
4654        // Periodic cache stats (every ~5s at 60fps)
4655        self.log_cache_stats();
4656
4657        Ok(())
4658    }
4659
4660    /// Render 3D-layer text/SVGs/images to an offscreen texture and blit with perspective.
4661    ///
4662    /// Elements inside a parent with `perspective` + `rotate-x`/`rotate-y` need to be
4663    /// rendered to a temporary offscreen texture and then blitted with the same perspective
4664    /// transform so they visually tilt with their parent's 3D transform.
4665    fn render_3d_layer_elements(
4666        &mut self,
4667        target: &wgpu::TextureView,
4668        info: &Transform3DLayerInfo,
4669        texts: &[TextElement],
4670        svgs: &[SvgElement],
4671        images: &[ImageElement],
4672        scale_factor: f32,
4673    ) {
4674        let [lx, ly, lw, lh] = info.layer_bounds;
4675        if lw <= 0.0 || lh <= 0.0 {
4676            return;
4677        }
4678
4679        let tex_w = (lw.ceil() as u32).max(1);
4680        let tex_h = (lh.ceil() as u32).max(1);
4681
4682        // Acquire offscreen texture
4683        let layer_tex = self.renderer.acquire_layer_texture((tex_w, tex_h), false);
4684        self.renderer
4685            .clear_target(&layer_tex.view, wgpu::Color::TRANSPARENT);
4686
4687        // Set viewport to offscreen texture size
4688        self.renderer.set_viewport_override((tex_w, tex_h));
4689
4690        // Render offset text glyphs
4691        if !texts.is_empty() {
4692            let mut layer_glyphs: Vec<GpuGlyph> = Vec::new();
4693            for text in texts {
4694                let alignment = match text.align {
4695                    TextAlign::Left => TextAlignment::Left,
4696                    TextAlign::Center => TextAlignment::Center,
4697                    TextAlign::Right => TextAlignment::Right,
4698                };
4699
4700                let color = if text.motion_opacity < 1.0 {
4701                    [
4702                        text.color[0],
4703                        text.color[1],
4704                        text.color[2],
4705                        text.color[3] * text.motion_opacity,
4706                    ]
4707                } else {
4708                    text.color
4709                };
4710
4711                let effective_width = if let Some(clip) = text.clip_bounds {
4712                    clip[2].min(text.width)
4713                } else {
4714                    text.width
4715                };
4716                let needs_wrap = text.wrap && effective_width < text.measured_width - 2.0;
4717                let wrap_width = Some(text.width);
4718                let font_name = text.font_family.name.as_deref();
4719                let generic = to_gpu_generic_font(text.font_family.generic);
4720                let font_weight = text.weight.weight();
4721
4722                let (anchor, y_pos, use_layout_height) = match text.v_align {
4723                    TextVerticalAlign::Center => {
4724                        (TextAnchor::Center, text.y + text.height / 2.0, false)
4725                    }
4726                    TextVerticalAlign::Top => (TextAnchor::Top, text.y, true),
4727                    TextVerticalAlign::Baseline => {
4728                        let baseline_y = text.y + text.ascender;
4729                        (TextAnchor::Baseline, baseline_y, false)
4730                    }
4731                };
4732                let layout_height = if use_layout_height {
4733                    Some(text.height)
4734                } else {
4735                    None
4736                };
4737
4738                if let Ok(mut glyphs) = self.text_ctx.prepare_text_with_style(
4739                    &text.content,
4740                    text.x - lx,
4741                    y_pos - ly,
4742                    text.font_size,
4743                    color,
4744                    anchor,
4745                    alignment,
4746                    wrap_width,
4747                    needs_wrap,
4748                    font_name,
4749                    generic,
4750                    font_weight,
4751                    text.italic,
4752                    layout_height,
4753                    text.letter_spacing,
4754                ) {
4755                    // Offset clip bounds to layer-local coords
4756                    if let Some(clip) = text.clip_bounds {
4757                        for glyph in &mut glyphs {
4758                            glyph.clip_bounds = [clip[0] - lx, clip[1] - ly, clip[2], clip[3]];
4759                        }
4760                    }
4761                    layer_glyphs.extend(glyphs);
4762                }
4763            }
4764
4765            if !layer_glyphs.is_empty() {
4766                self.render_text(&layer_tex.view, &layer_glyphs);
4767            }
4768        }
4769
4770        // Render offset images (mutate in place — we own these from partition)
4771        if !images.is_empty() {
4772            let mut offset_images = images.to_vec();
4773            for img in &mut offset_images {
4774                img.x -= lx;
4775                img.y -= ly;
4776                if let Some(ref mut clip) = img.clip_bounds {
4777                    clip[0] -= lx;
4778                    clip[1] -= ly;
4779                }
4780                if let Some(ref mut scroll) = img.scroll_clip {
4781                    scroll[0] -= lx;
4782                    scroll[1] -= ly;
4783                }
4784            }
4785            self.render_images(&layer_tex.view, &offset_images, lw, lh, scale_factor);
4786        }
4787
4788        // Render offset SVGs (mutate in place — we own these from partition)
4789        if !svgs.is_empty() {
4790            let mut offset_svgs = svgs.to_vec();
4791            for svg in &mut offset_svgs {
4792                svg.x -= lx;
4793                svg.y -= ly;
4794                if let Some(ref mut clip) = svg.clip_bounds {
4795                    clip[0] -= lx;
4796                    clip[1] -= ly;
4797                }
4798            }
4799            self.render_rasterized_svgs(&layer_tex.view, &offset_svgs, scale_factor);
4800        }
4801
4802        // Restore viewport
4803        self.renderer.restore_viewport();
4804
4805        // Blit with perspective transform
4806        self.renderer.blit_tight_texture_to_target(
4807            &layer_tex.view,
4808            (tex_w, tex_h),
4809            target,
4810            (lx, ly),
4811            (lw, lh),
4812            info.opacity,
4813            blinc_core::BlendMode::Normal,
4814            None,
4815            Some(info.transform_3d),
4816        );
4817
4818        self.renderer.release_layer_texture(layer_tex);
4819    }
4820
4821    /// Render a tree on top of existing content (no clear)
4822    ///
4823    /// This is used for overlay trees (modals, toasts, dialogs) that render
4824    /// on top of the main UI without clearing it.
4825    pub fn render_overlay_tree_with_motion(
4826        &mut self,
4827        tree: &RenderTree,
4828        render_state: &blinc_layout::RenderState,
4829        width: u32,
4830        height: u32,
4831        target: &wgpu::TextureView,
4832    ) -> Result<()> {
4833        // Get scale factor for HiDPI rendering
4834        let scale_factor = tree.scale_factor();
4835
4836        // Create a single paint context for all layers with text rendering support
4837        let mut ctx =
4838            GpuPaintContext::with_text_context(width as f32, height as f32, &mut self.text_ctx);
4839
4840        // Render with motion animations applied (all layers to same context)
4841        tree.render_with_motion(&mut ctx, render_state);
4842
4843        // Take the batch (mutable so CSS-transformed text primitives can be added)
4844        let mut batch = ctx.take_batch();
4845
4846        // Collect text, SVG, image, and flow elements WITH motion state
4847        let (texts, svgs, images, _flows) =
4848            self.collect_render_elements_with_state(tree, Some(render_state));
4849
4850        // Pre-load all images into cache before rendering
4851        self.preload_images(&images, width as f32, height as f32);
4852
4853        // Prepare text glyphs with z_layer information
4854        let mut glyphs_by_layer: std::collections::BTreeMap<u32, Vec<GpuGlyph>> =
4855            std::collections::BTreeMap::new();
4856        let mut css_transformed_text_prims: Vec<GpuPrimitive> = Vec::new();
4857        for text in &texts {
4858            let alignment = match text.align {
4859                TextAlign::Left => TextAlignment::Left,
4860                TextAlign::Center => TextAlignment::Center,
4861                TextAlign::Right => TextAlignment::Right,
4862            };
4863
4864            // Apply motion opacity to text color
4865            let color = if text.motion_opacity < 1.0 {
4866                [
4867                    text.color[0],
4868                    text.color[1],
4869                    text.color[2],
4870                    text.color[3] * text.motion_opacity,
4871                ]
4872            } else {
4873                text.color
4874            };
4875
4876            // Determine wrap width
4877            let effective_width = if let Some(clip) = text.clip_bounds {
4878                clip[2].min(text.width)
4879            } else {
4880                text.width
4881            };
4882
4883            let needs_wrap = text.wrap && effective_width < text.measured_width - 2.0;
4884            let wrap_width = Some(text.width);
4885            let font_name = text.font_family.name.as_deref();
4886            let generic = to_gpu_generic_font(text.font_family.generic);
4887            let font_weight = text.weight.weight();
4888
4889            let (anchor, y_pos, use_layout_height) = match text.v_align {
4890                TextVerticalAlign::Center => {
4891                    (TextAnchor::Center, text.y + text.height / 2.0, false)
4892                }
4893                TextVerticalAlign::Top => (TextAnchor::Top, text.y, true),
4894                TextVerticalAlign::Baseline => {
4895                    let baseline_y = text.y + text.ascender;
4896                    (TextAnchor::Baseline, baseline_y, false)
4897                }
4898            };
4899            let layout_height = if use_layout_height {
4900                Some(text.height)
4901            } else {
4902                None
4903            };
4904
4905            if let Ok(glyphs) = self.text_ctx.prepare_text_with_style(
4906                &text.content,
4907                text.x,
4908                y_pos,
4909                text.font_size,
4910                color,
4911                anchor,
4912                alignment,
4913                wrap_width,
4914                needs_wrap,
4915                font_name,
4916                generic,
4917                font_weight,
4918                text.italic,
4919                layout_height,
4920                text.letter_spacing,
4921            ) {
4922                let mut glyphs = glyphs;
4923                if let Some(clip) = text.clip_bounds {
4924                    for glyph in &mut glyphs {
4925                        glyph.clip_bounds = clip;
4926                    }
4927                }
4928
4929                if let Some(affine) = text.css_affine {
4930                    // CSS-transformed text: convert to SDF primitives with local_affine
4931                    // Pushed into fg_batch.primitives to render in the main SDF pass
4932                    let [a, b, c, d, tx, ty] = affine;
4933                    let tx_scaled = tx * scale_factor;
4934                    let ty_scaled = ty * scale_factor;
4935                    for glyph in &glyphs {
4936                        let gc_x = glyph.bounds[0] + glyph.bounds[2] / 2.0;
4937                        let gc_y = glyph.bounds[1] + glyph.bounds[3] / 2.0;
4938                        let new_gc_x = a * gc_x + c * gc_y + tx_scaled;
4939                        let new_gc_y = b * gc_x + d * gc_y + ty_scaled;
4940                        let mut prim = GpuPrimitive::from_glyph(glyph);
4941                        prim.bounds = [
4942                            new_gc_x - glyph.bounds[2] / 2.0,
4943                            new_gc_y - glyph.bounds[3] / 2.0,
4944                            glyph.bounds[2],
4945                            glyph.bounds[3],
4946                        ];
4947                        prim.local_affine = [a, b, c, d];
4948                        prim.set_z_layer(text.z_index);
4949                        css_transformed_text_prims.push(prim);
4950                    }
4951                } else {
4952                    glyphs_by_layer
4953                        .entry(text.z_index)
4954                        .or_default()
4955                        .extend(glyphs);
4956                }
4957            }
4958        }
4959
4960        // SVGs are rendered as rasterized images (not tessellated paths) for better anti-aliasing
4961        // They will be rendered later via render_rasterized_svgs
4962
4963        self.renderer.resize(width, height);
4964
4965        // If we have CSS-transformed text, push text prims into the main batch
4966        // and bind the real glyph atlas to the SDF pipeline.
4967        if !css_transformed_text_prims.is_empty() {
4968            if let (Some(atlas), Some(color_atlas)) =
4969                (self.text_ctx.atlas_view(), self.text_ctx.color_atlas_view())
4970            {
4971                batch.primitives.append(&mut css_transformed_text_prims);
4972                self.renderer.set_glyph_atlas(atlas, color_atlas);
4973            }
4974        }
4975
4976        // For overlay rendering, we DON'T have glass effects (overlays are simple)
4977        // Render primitives without clearing (LoadOp::Load)
4978        let max_z = batch.max_z_layer();
4979        let max_text_z = glyphs_by_layer.keys().cloned().max().unwrap_or(0);
4980        let max_layer = max_z.max(max_text_z);
4981
4982        tracing::trace!(
4983            "render_overlay_tree: {} primitives, {} text layers, max_layer={}",
4984            batch.primitives.len(),
4985            glyphs_by_layer.len(),
4986            max_layer
4987        );
4988
4989        // Render all layers using overlay mode (no clear)
4990        for z in 0..=max_layer {
4991            let layer_primitives = batch.primitives_for_layer(z);
4992            if !layer_primitives.is_empty() {
4993                tracing::trace!(
4994                    "render_overlay_tree: rendering {} primitives at z={}",
4995                    layer_primitives.len(),
4996                    z
4997                );
4998                self.renderer
4999                    .render_primitives_overlay(target, &layer_primitives);
5000            }
5001
5002            if let Some(glyphs) = glyphs_by_layer.get(&z) {
5003                if !glyphs.is_empty() {
5004                    tracing::trace!(
5005                        "render_overlay_tree: rendering {} glyphs at z={}",
5006                        glyphs.len(),
5007                        z
5008                    );
5009                    self.render_text(target, glyphs);
5010                }
5011            }
5012        }
5013
5014        // Images render on top
5015        self.render_images(target, &images, width as f32, height as f32, scale_factor);
5016
5017        // Render foreground primitives (e.g. borders on top)
5018        if !batch.foreground_primitives.is_empty() {
5019            self.renderer
5020                .render_primitives_overlay(target, &batch.foreground_primitives);
5021        }
5022
5023        // Poll the device to free completed command buffers
5024        self.renderer.poll();
5025
5026        // Render layout debug for overlay tree if enabled
5027        let debug = DebugMode::from_env();
5028        if debug.layout {
5029            let scale = tree.scale_factor();
5030            self.render_layout_debug(target, tree, scale);
5031        }
5032        if debug.motion {
5033            self.render_motion_debug(target, tree, width, height);
5034        }
5035
5036        // Return scratch buffers for reuse on next frame
5037        self.return_scratch_elements(texts, svgs, images);
5038
5039        Ok(())
5040    }
5041
5042    /// Render overlays from RenderState (cursors, selections, focus rings)
5043    fn render_overlays(
5044        &mut self,
5045        render_state: &blinc_layout::RenderState,
5046        width: u32,
5047        height: u32,
5048        target: &wgpu::TextureView,
5049    ) {
5050        let overlays = render_state.overlays();
5051        if overlays.is_empty() {
5052            return;
5053        }
5054
5055        // Create a paint context for overlays
5056        let mut overlay_ctx = GpuPaintContext::new(width as f32, height as f32);
5057
5058        for overlay in overlays {
5059            match overlay {
5060                Overlay::Cursor {
5061                    position,
5062                    size,
5063                    color,
5064                    opacity,
5065                } => {
5066                    if *opacity > 0.0 {
5067                        // Apply opacity to cursor color
5068                        let cursor_color =
5069                            Color::rgba(color.r, color.g, color.b, color.a * opacity);
5070                        overlay_ctx.execute_command(&DrawCommand::FillRect {
5071                            rect: Rect::new(position.0, position.1, size.0, size.1),
5072                            corner_radius: CornerRadius::default(),
5073                            brush: Brush::Solid(cursor_color),
5074                        });
5075                    }
5076                }
5077                Overlay::Selection { rects: _, color: _ } => {
5078                    // TODO: Re-enable for real-time text selection
5079                    // Disabled for now to avoid blue mask issue after modal close
5080                }
5081                Overlay::FocusRing {
5082                    position,
5083                    size,
5084                    radius,
5085                    color,
5086                    thickness,
5087                } => {
5088                    overlay_ctx.execute_command(&DrawCommand::StrokeRect {
5089                        rect: Rect::new(position.0, position.1, size.0, size.1),
5090                        corner_radius: CornerRadius::uniform(*radius),
5091                        stroke: Stroke::new(*thickness),
5092                        brush: Brush::Solid(*color),
5093                    });
5094                }
5095            }
5096        }
5097
5098        // Render overlays as an overlay pass (on top of existing content)
5099        let overlay_batch = overlay_ctx.take_batch();
5100        if !overlay_batch.is_empty() {
5101            self.renderer.render_overlay(target, &overlay_batch);
5102        }
5103    }
5104}
5105
5106/// Convert layout's GenericFont to GPU's GenericFont
5107fn to_gpu_generic_font(generic: GenericFont) -> GpuGenericFont {
5108    match generic {
5109        GenericFont::System => GpuGenericFont::System,
5110        GenericFont::Monospace => GpuGenericFont::Monospace,
5111        GenericFont::Serif => GpuGenericFont::Serif,
5112        GenericFont::SansSerif => GpuGenericFont::SansSerif,
5113    }
5114}
5115
5116/// Debug mode flags for visual debugging
5117///
5118/// Set environment variable `BLINC_DEBUG` to enable debug visualization:
5119/// - `text`: Show text bounding boxes and baselines
5120/// - `layout`: Show all element bounding boxes (useful for debugging hit-testing)
5121/// - `motion`: Show active animation stats overlay
5122/// - `all` or `1` or `true`: Show all debug visualizations
5123#[derive(Clone, Copy)]
5124pub struct DebugMode {
5125    /// Show text bounding boxes and baseline indicators
5126    pub text: bool,
5127    /// Show all element bounding boxes
5128    pub layout: bool,
5129    /// Show motion/animation debug info
5130    pub motion: bool,
5131}
5132
5133impl DebugMode {
5134    /// Check environment variable and return debug mode configuration
5135    pub fn from_env() -> Self {
5136        let debug_value = std::env::var("BLINC_DEBUG")
5137            .map(|v| v.to_lowercase())
5138            .unwrap_or_default();
5139
5140        let all = debug_value == "all" || debug_value == "1" || debug_value == "true";
5141        let text = all || debug_value == "text";
5142        let layout = all || debug_value == "layout";
5143        let motion = all || debug_value == "motion";
5144
5145        Self {
5146            text,
5147            layout,
5148            motion,
5149        }
5150    }
5151
5152    /// Check if any debug mode is enabled
5153    pub fn any_enabled(&self) -> bool {
5154        self.text || self.layout || self.motion
5155    }
5156}
5157
5158/// Generate text decoration primitives (strikethrough and underline) grouped by z-layer
5159///
5160/// Creates decoration lines for text elements that have:
5161/// - strikethrough: horizontal line through the middle of the text
5162/// - underline: horizontal line below the text baseline
5163///
5164/// Returns a HashMap of z_index -> primitives for interleaved rendering with text
5165fn generate_text_decoration_primitives_by_layer(
5166    texts: &[TextElement],
5167) -> std::collections::HashMap<u32, Vec<GpuPrimitive>> {
5168    let mut primitives_by_layer: std::collections::HashMap<u32, Vec<GpuPrimitive>> =
5169        std::collections::HashMap::new();
5170
5171    for text in texts {
5172        if !text.strikethrough && !text.underline {
5173            continue;
5174        }
5175
5176        // Calculate text width for decorations
5177        let decoration_width = if text.wrap && text.measured_width > text.width {
5178            text.width
5179        } else {
5180            text.measured_width.min(text.width)
5181        };
5182
5183        // Skip if there's no meaningful width
5184        if decoration_width <= 0.0 {
5185            continue;
5186        }
5187
5188        // Line thickness: use CSS text-decoration-thickness if set, else scale with font size
5189        let line_thickness = text
5190            .decoration_thickness
5191            .unwrap_or_else(|| (text.font_size / 14.0).clamp(1.0, 3.0));
5192
5193        // Decoration color: use CSS text-decoration-color if set, else use text color
5194        let dec_color = text.decoration_color.unwrap_or(text.color);
5195
5196        let layer_primitives = primitives_by_layer.entry(text.z_index).or_default();
5197
5198        // Calculate the actual baseline Y position based on vertical alignment
5199        // This must match the text rendering logic to position decorations correctly
5200        //
5201        // glyph_extent = ascender - descender (where descender is negative)
5202        // Typical descender is about -20% of ascender, so glyph_extent ≈ ascender * 1.2
5203        let descender_approx = -text.ascender * 0.2;
5204        let glyph_extent = text.ascender - descender_approx;
5205
5206        let baseline_y = match text.v_align {
5207            TextVerticalAlign::Center => {
5208                // GPU: y_pos = text.y + text.height / 2.0, then y_offset = y_pos - glyph_extent / 2.0
5209                // Glyph top is at: text.y + text.height/2 - glyph_extent/2
5210                // Baseline is at: glyph_top + ascender
5211                let glyph_top = text.y + text.height / 2.0 - glyph_extent / 2.0;
5212                glyph_top + text.ascender
5213            }
5214            TextVerticalAlign::Top => {
5215                // GPU: y_pos = text.y, y_offset = y + (layout_height - glyph_extent) / 2.0
5216                // Glyph top is at: text.y + (text.height - glyph_extent) / 2.0
5217                // Baseline is at: glyph_top + ascender
5218                let glyph_top = text.y + (text.height - glyph_extent) / 2.0;
5219                glyph_top + text.ascender
5220            }
5221            TextVerticalAlign::Baseline => {
5222                // GPU: y_pos = text.y + ascender, y_offset = y_pos - ascender = text.y
5223                // Glyph top is at: text.y
5224                // Baseline is at: text.y + ascender
5225                text.y + text.ascender
5226            }
5227        };
5228
5229        // Strikethrough: draw line through the center of lowercase letters (x-height center)
5230        if text.strikethrough {
5231            // x-height is typically ~50% of ascender, center of x-height is ~25% above baseline
5232            let strikethrough_y = baseline_y - text.ascender * 0.35;
5233            let mut strike_rect = GpuPrimitive::rect(
5234                text.x,
5235                strikethrough_y - line_thickness / 2.0,
5236                decoration_width,
5237                line_thickness,
5238            )
5239            .with_color(dec_color[0], dec_color[1], dec_color[2], dec_color[3]);
5240
5241            // Apply clip bounds from text element if present
5242            if let Some(clip) = text.clip_bounds {
5243                strike_rect = strike_rect.with_clip_rect(clip[0], clip[1], clip[2], clip[3]);
5244            }
5245            layer_primitives.push(strike_rect);
5246        }
5247
5248        // Underline: draw line just below the baseline (at text bottom)
5249        if text.underline {
5250            // Underline position: just below baseline, snapping to text bottom
5251            let underline_y = baseline_y + text.ascender * 0.05;
5252            let mut underline_rect = GpuPrimitive::rect(
5253                text.x,
5254                underline_y - line_thickness / 2.0,
5255                decoration_width,
5256                line_thickness,
5257            )
5258            .with_color(dec_color[0], dec_color[1], dec_color[2], dec_color[3]);
5259
5260            // Apply clip bounds from text element if present
5261            if let Some(clip) = text.clip_bounds {
5262                underline_rect = underline_rect.with_clip_rect(clip[0], clip[1], clip[2], clip[3]);
5263            }
5264            layer_primitives.push(underline_rect);
5265        }
5266    }
5267
5268    primitives_by_layer
5269}
5270
5271/// Generate debug primitives for text elements
5272///
5273/// Creates visual overlays showing:
5274/// - Bounding box outline (cyan)
5275/// - Baseline position (magenta line)
5276/// - Ascender line (green, at top of bounding box)
5277/// - Descender line (yellow, at bottom of bounding box)
5278fn generate_text_debug_primitives(texts: &[TextElement]) -> Vec<GpuPrimitive> {
5279    let mut primitives = Vec::new();
5280
5281    for text in texts {
5282        // Determine the actual text width for debug visualization:
5283        // - For non-wrapped text: use measured_width (actual rendered text width)
5284        // - For wrapped text: use layout width (container constrains the text)
5285        let debug_width = if text.wrap && text.measured_width > text.width {
5286            // Text is wrapping - use container width
5287            text.width
5288        } else {
5289            // Single line - use actual measured width (clamped to layout width)
5290            text.measured_width.min(text.width)
5291        };
5292
5293        // Bounding box outline (cyan, semi-transparent)
5294        let bbox = GpuPrimitive::rect(text.x, text.y, debug_width, text.height)
5295            .with_color(0.0, 0.0, 0.0, 0.0) // Transparent fill
5296            .with_border(1.0, 0.0, 1.0, 1.0, 0.7); // Cyan border
5297        primitives.push(bbox);
5298
5299        // Baseline indicator (magenta horizontal line)
5300        // The baseline is at y + ascender
5301        let baseline_y = text.y + text.ascender;
5302        let baseline = GpuPrimitive::rect(text.x, baseline_y - 0.5, debug_width, 1.0)
5303            .with_color(1.0, 0.0, 1.0, 0.6); // Magenta
5304        primitives.push(baseline);
5305
5306        // Ascender line indicator (green, at top of text)
5307        // For v_baseline texts, this shows where the ascender sits
5308        let ascender_line = GpuPrimitive::rect(text.x, text.y - 0.5, debug_width, 1.0)
5309            .with_color(0.0, 1.0, 0.0, 0.4); // Green, more transparent
5310        primitives.push(ascender_line);
5311
5312        // Descender line (yellow, at bottom of bounding box)
5313        let descender_y = text.y + text.height;
5314        let descender_line = GpuPrimitive::rect(text.x, descender_y - 0.5, debug_width, 1.0)
5315            .with_color(1.0, 1.0, 0.0, 0.4); // Yellow
5316        primitives.push(descender_line);
5317    }
5318
5319    primitives
5320}
5321
5322/// Collect all element bounds from the render tree for debug visualization
5323fn collect_debug_bounds(tree: &RenderTree, scale: f32) -> Vec<DebugBoundsElement> {
5324    let mut bounds = Vec::new();
5325
5326    if let Some(root) = tree.root() {
5327        collect_debug_bounds_recursive(tree, root, (0.0, 0.0), 0, scale, &mut bounds);
5328    }
5329
5330    bounds
5331}
5332
5333/// Recursively collect bounds from all nodes
5334fn collect_debug_bounds_recursive(
5335    tree: &RenderTree,
5336    node: LayoutNodeId,
5337    parent_offset: (f32, f32),
5338    depth: u32,
5339    scale: f32,
5340    bounds: &mut Vec<DebugBoundsElement>,
5341) {
5342    use blinc_layout::renderer::ElementType;
5343
5344    let Some(node_bounds) = tree.layout().get_bounds(node, parent_offset) else {
5345        return;
5346    };
5347
5348    // Determine element type name
5349    let element_type = tree
5350        .get_render_node(node)
5351        .map(|n| match &n.element_type {
5352            ElementType::Div => "Div".to_string(),
5353            ElementType::Text(_) => "Text".to_string(),
5354            ElementType::StyledText(_) => "StyledText".to_string(),
5355            ElementType::Image(_) => "Image".to_string(),
5356            ElementType::Svg(_) => "Svg".to_string(),
5357            ElementType::Canvas(_) => "Canvas".to_string(),
5358        })
5359        .unwrap_or_else(|| "Unknown".to_string());
5360
5361    // Add this element's bounds (with DPI scaling)
5362    bounds.push(DebugBoundsElement {
5363        x: node_bounds.x * scale,
5364        y: node_bounds.y * scale,
5365        width: node_bounds.width * scale,
5366        height: node_bounds.height * scale,
5367        element_type,
5368        depth,
5369    });
5370
5371    // Get scroll offset for this node (scroll containers offset their children)
5372    let scroll_offset = tree.get_scroll_offset(node);
5373
5374    // Calculate new offset for children (including scroll offset)
5375    let new_offset = (
5376        node_bounds.x + scroll_offset.0,
5377        node_bounds.y + scroll_offset.1,
5378    );
5379
5380    // Recurse into children
5381    for child in tree.layout().children(node) {
5382        collect_debug_bounds_recursive(tree, child, new_offset, depth + 1, scale, bounds);
5383    }
5384}
5385
5386/// Generate debug primitives for layout element bounds
5387///
5388/// Creates visual overlays showing:
5389/// - Colored outlines for each element's bounding box
5390/// - Colors cycle based on tree depth (red, green, blue, yellow, cyan, magenta)
5391fn generate_layout_debug_primitives(bounds: &[DebugBoundsElement]) -> Vec<GpuPrimitive> {
5392    let mut primitives = Vec::new();
5393
5394    // Color palette for different depths (cycling)
5395    let colors: [(f32, f32, f32); 6] = [
5396        (1.0, 0.3, 0.3), // Red
5397        (0.3, 1.0, 0.3), // Green
5398        (0.3, 0.3, 1.0), // Blue
5399        (1.0, 1.0, 0.3), // Yellow
5400        (0.3, 1.0, 1.0), // Cyan
5401        (1.0, 0.3, 1.0), // Magenta
5402    ];
5403
5404    for elem in bounds {
5405        // Skip very small elements (likely invisible)
5406        if elem.width < 1.0 || elem.height < 1.0 {
5407            continue;
5408        }
5409
5410        let (r, g, b) = colors[(elem.depth as usize) % colors.len()];
5411        let alpha = 0.5; // Semi-transparent outline
5412
5413        // Draw outline only (transparent fill with colored border)
5414        let rect = GpuPrimitive::rect(elem.x, elem.y, elem.width, elem.height)
5415            .with_color(0.0, 0.0, 0.0, 0.0) // Transparent fill
5416            .with_border(1.0, r, g, b, alpha); // Colored border
5417
5418        primitives.push(rect);
5419    }
5420
5421    primitives
5422}
5423
5424/// Scale and translate a path for SVG rendering with tint
5425fn scale_and_translate_path(
5426    path: &blinc_core::Path,
5427    x: f32,
5428    y: f32,
5429    scale: f32,
5430) -> blinc_core::Path {
5431    use blinc_core::{PathCommand, Point, Vec2};
5432
5433    if scale == 1.0 && x == 0.0 && y == 0.0 {
5434        return path.clone();
5435    }
5436
5437    let transform_point = |p: Point| -> Point { Point::new(p.x * scale + x, p.y * scale + y) };
5438
5439    let new_commands: Vec<PathCommand> = path
5440        .commands()
5441        .iter()
5442        .map(|cmd| match cmd {
5443            PathCommand::MoveTo(p) => PathCommand::MoveTo(transform_point(*p)),
5444            PathCommand::LineTo(p) => PathCommand::LineTo(transform_point(*p)),
5445            PathCommand::QuadTo { control, end } => PathCommand::QuadTo {
5446                control: transform_point(*control),
5447                end: transform_point(*end),
5448            },
5449            PathCommand::CubicTo {
5450                control1,
5451                control2,
5452                end,
5453            } => PathCommand::CubicTo {
5454                control1: transform_point(*control1),
5455                control2: transform_point(*control2),
5456                end: transform_point(*end),
5457            },
5458            PathCommand::ArcTo {
5459                radii,
5460                rotation,
5461                large_arc,
5462                sweep,
5463                end,
5464            } => PathCommand::ArcTo {
5465                radii: Vec2::new(radii.x * scale, radii.y * scale),
5466                rotation: *rotation,
5467                large_arc: *large_arc,
5468                sweep: *sweep,
5469                end: transform_point(*end),
5470            },
5471            PathCommand::Close => PathCommand::Close,
5472        })
5473        .collect();
5474
5475    blinc_core::Path::from_commands(new_commands)
5476}
5477
5478// ─────────────────────────────────────────────────────────────────────────────
5479// 3D mesh dispatch
5480// ─────────────────────────────────────────────────────────────────────────────
5481
5482/// Dispatch every `PendingMesh` captured by `GpuPaintContext` to
5483/// `GpuRenderer::render_mesh_data` against the frame target.
5484///
5485/// Computes a view-projection matrix from each pending mesh's captured
5486/// `Camera` against the current viewport size (so aspect stays correct
5487/// under window resizes) and extracts the first `Light::Directional`
5488/// for the mesh pipeline's sun light. Other light types are ignored
5489/// for now — the mesh pipeline only takes a single directional input,
5490/// and widening that is follow-up work tracked alongside per-canvas
5491/// viewport clipping.
5492///
5493/// If a mesh's camera is `Camera::default()` the pose is identity /
5494/// zero-eye which produces an invisible frame; demos should always
5495/// `ctx.set_camera(&cam)` before calling `ctx.draw_mesh_data`. A
5496/// `tracing::warn!` surfaces the silent-empty case to avoid
5497/// head-scratching during demo authoring.
5498fn dispatch_pending_meshes(
5499    renderer: &mut GpuRenderer,
5500    target: &wgpu::TextureView,
5501    width: u32,
5502    height: u32,
5503    meshes: &[PendingMesh],
5504) {
5505    if meshes.is_empty() {
5506        return;
5507    }
5508    let aspect = if height > 0 {
5509        width as f32 / height as f32
5510    } else {
5511        1.0
5512    };
5513
5514    for pending in meshes {
5515        // Upload the environment cubemap if the pending mesh carries one.
5516        // The renderer's texture is overwritten each time, so only the
5517        // last-set environment matters — but in practice every PendingMesh
5518        // from the same SceneKit3D shares the same Arc.
5519        if let Some(ref env) = pending.env_cubemap {
5520            renderer.upload_environment_cubemap(env);
5521        }
5522
5523        // Use the canvas viewport aspect when available so the
5524        // perspective projection matches the clipped region, not the
5525        // full frame. Falls back to the frame aspect for full-viewport
5526        // mesh draws (no canvas wrapper).
5527        let vp_aspect = pending
5528            .viewport
5529            .map(|[_, _, w, h]| if h > 0.0 { w / h } else { 1.0 })
5530            .unwrap_or(aspect);
5531        let view_proj = camera_view_proj(&pending.camera, vp_aspect);
5532        let inv_view_proj = mat4_inverse_flat(&view_proj);
5533        let camera_pos = [
5534            pending.camera.position.x,
5535            pending.camera.position.y,
5536            pending.camera.position.z,
5537        ];
5538        let (light_dir, light_intensity) = first_directional_light(&pending.lights);
5539        let model = mat4_to_array(&pending.transform);
5540
5541        renderer.render_mesh_data(
5542            target,
5543            &pending.mesh,
5544            &model,
5545            &view_proj,
5546            camera_pos,
5547            light_dir,
5548            light_intensity,
5549            None,
5550            pending.viewport,
5551        );
5552    }
5553}
5554
5555/// Build a view × projection matrix for the captured `Camera`.
5556///
5557/// Right-handed coordinate system, +Y up. Matches the convention the
5558/// mesh shader expects (see `crates/blinc_gpu/src/shaders/mesh.wgsl`).
5559///
5560/// For `CameraProjection::Perspective`, the stored `aspect` field on
5561/// the projection is overridden by the frame's actual aspect so the
5562/// scene doesn't stretch on resize — the stored value is just a
5563/// fallback default from `Camera::perspective`.
5564fn camera_view_proj(camera: &blinc_core::Camera, frame_aspect: f32) -> [f32; 16] {
5565    let view = mat4_look_at(camera.position, camera.target, camera.up);
5566    let proj = match camera.projection {
5567        blinc_core::CameraProjection::Perspective {
5568            fov_y, near, far, ..
5569        } => mat4_perspective_rh(fov_y, frame_aspect, near, far),
5570        blinc_core::CameraProjection::Orthographic {
5571            left,
5572            right,
5573            bottom,
5574            top,
5575            near,
5576            far,
5577        } => mat4_orthographic_rh(left, right, bottom, top, near, far),
5578    };
5579    mat4_mul_flat(&proj, &view)
5580}
5581
5582/// Extract the first `Light::Directional` from the snapshot, returning
5583/// a normalized direction vector and scalar intensity. Falls back to a
5584/// soft top-down fill if none is present so the demo never renders
5585/// pitch-black.
5586fn first_directional_light(lights: &[blinc_core::Light]) -> ([f32; 3], f32) {
5587    for light in lights {
5588        if let blinc_core::Light::Directional {
5589            direction,
5590            intensity,
5591            ..
5592        } = light
5593        {
5594            let d = direction.normalize();
5595            return ([d.x, d.y, d.z], *intensity);
5596        }
5597    }
5598    ([0.0, -1.0, 0.3], 0.8)
5599}
5600
5601/// Flatten a column-major `Mat4` to the `[f32; 16]` layout
5602/// `GpuRenderer::render_mesh_data` expects.
5603fn mat4_to_array(m: &blinc_core::Mat4) -> [f32; 16] {
5604    let mut out = [0.0f32; 16];
5605    for col in 0..4 {
5606        for row in 0..4 {
5607            out[col * 4 + row] = m.cols[col][row];
5608        }
5609    }
5610    out
5611}
5612
5613/// Multiply two flat column-major 4×4 matrices (`a * b`), returning a
5614/// `[f32; 16]` in the same layout. Used to compose `proj * view` after
5615/// both are computed in `Mat4`/array form.
5616fn mat4_mul_flat(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] {
5617    let mut out = [0.0f32; 16];
5618    for col in 0..4 {
5619        for row in 0..4 {
5620            let mut s = 0.0;
5621            for k in 0..4 {
5622                s += a[k * 4 + row] * b[col * 4 + k];
5623            }
5624            out[col * 4 + row] = s;
5625        }
5626    }
5627    out
5628}
5629
5630/// Right-handed look-at view matrix. Produces `[f32; 16]` directly
5631/// (column-major) for the downstream multiply.
5632fn mat4_look_at(
5633    eye: blinc_core::Vec3,
5634    target: blinc_core::Vec3,
5635    up: blinc_core::Vec3,
5636) -> [f32; 16] {
5637    let f = blinc_core::Vec3::new(target.x - eye.x, target.y - eye.y, target.z - eye.z).normalize();
5638    let r = f.cross(up).normalize();
5639    let u = r.cross(f);
5640    let tx = -(r.x * eye.x + r.y * eye.y + r.z * eye.z);
5641    let ty = -(u.x * eye.x + u.y * eye.y + u.z * eye.z);
5642    let tz = f.x * eye.x + f.y * eye.y + f.z * eye.z;
5643    // Column-major: col0 = [r.x, u.x, -f.x, 0], col1 = [r.y, u.y, -f.y, 0], ...
5644    [
5645        r.x, u.x, -f.x, 0.0, r.y, u.y, -f.y, 0.0, r.z, u.z, -f.z, 0.0, tx, ty, tz, 1.0,
5646    ]
5647}
5648
5649/// Right-handed perspective projection. Maps view-space Z in `[-far, -near]`
5650/// to clip-space depth `[0, 1]` (wgpu convention). `fov_y` is radians.
5651fn mat4_perspective_rh(fov_y: f32, aspect: f32, near: f32, far: f32) -> [f32; 16] {
5652    let f = 1.0 / (fov_y * 0.5).tan();
5653    let nf = 1.0 / (near - far);
5654    [
5655        f / aspect,
5656        0.0,
5657        0.0,
5658        0.0,
5659        0.0,
5660        f,
5661        0.0,
5662        0.0,
5663        0.0,
5664        0.0,
5665        far * nf,
5666        -1.0,
5667        0.0,
5668        0.0,
5669        far * near * nf,
5670        0.0,
5671    ]
5672}
5673
5674/// Right-handed orthographic projection. Uses the same clip-space
5675/// depth range `[0, 1]` as the perspective variant so the mesh shader
5676/// can stay agnostic of the projection choice.
5677fn mat4_orthographic_rh(
5678    left: f32,
5679    right: f32,
5680    bottom: f32,
5681    top: f32,
5682    near: f32,
5683    far: f32,
5684) -> [f32; 16] {
5685    let rl = 1.0 / (right - left);
5686    let tb = 1.0 / (top - bottom);
5687    let fnn = 1.0 / (far - near);
5688    [
5689        2.0 * rl,
5690        0.0,
5691        0.0,
5692        0.0,
5693        0.0,
5694        2.0 * tb,
5695        0.0,
5696        0.0,
5697        0.0,
5698        0.0,
5699        -fnn,
5700        0.0,
5701        -(right + left) * rl,
5702        -(top + bottom) * tb,
5703        -near * fnn,
5704        1.0,
5705    ]
5706}
5707
5708/// Inverse of a column-major 4×4 matrix (GLU-style cofactor expansion).
5709fn mat4_inverse_flat(m: &[f32; 16]) -> [f32; 16] {
5710    let mut inv = [0.0f32; 16];
5711    inv[0] = m[5] * m[10] * m[15] - m[5] * m[11] * m[14] - m[9] * m[6] * m[15]
5712        + m[9] * m[7] * m[14]
5713        + m[13] * m[6] * m[11]
5714        - m[13] * m[7] * m[10];
5715    inv[4] = -m[4] * m[10] * m[15] + m[4] * m[11] * m[14] + m[8] * m[6] * m[15]
5716        - m[8] * m[7] * m[14]
5717        - m[12] * m[6] * m[11]
5718        + m[12] * m[7] * m[10];
5719    inv[8] = m[4] * m[9] * m[15] - m[4] * m[11] * m[13] - m[8] * m[5] * m[15]
5720        + m[8] * m[7] * m[13]
5721        + m[12] * m[5] * m[11]
5722        - m[12] * m[7] * m[9];
5723    inv[12] = -m[4] * m[9] * m[14] + m[4] * m[10] * m[13] + m[8] * m[5] * m[14]
5724        - m[8] * m[6] * m[13]
5725        - m[12] * m[5] * m[10]
5726        + m[12] * m[6] * m[9];
5727    inv[1] = -m[1] * m[10] * m[15] + m[1] * m[11] * m[14] + m[9] * m[2] * m[15]
5728        - m[9] * m[3] * m[14]
5729        - m[13] * m[2] * m[11]
5730        + m[13] * m[3] * m[10];
5731    inv[5] = m[0] * m[10] * m[15] - m[0] * m[11] * m[14] - m[8] * m[2] * m[15]
5732        + m[8] * m[3] * m[14]
5733        + m[12] * m[2] * m[11]
5734        - m[12] * m[3] * m[10];
5735    inv[9] = -m[0] * m[9] * m[15] + m[0] * m[11] * m[13] + m[8] * m[1] * m[15]
5736        - m[8] * m[3] * m[13]
5737        - m[12] * m[1] * m[11]
5738        + m[12] * m[3] * m[9];
5739    inv[13] = m[0] * m[9] * m[14] - m[0] * m[10] * m[13] - m[8] * m[1] * m[14]
5740        + m[8] * m[2] * m[13]
5741        + m[12] * m[1] * m[10]
5742        - m[12] * m[2] * m[9];
5743    inv[2] = m[1] * m[6] * m[15] - m[1] * m[7] * m[14] - m[5] * m[2] * m[15]
5744        + m[5] * m[3] * m[14]
5745        + m[13] * m[2] * m[7]
5746        - m[13] * m[3] * m[6];
5747    inv[6] = -m[0] * m[6] * m[15] + m[0] * m[7] * m[14] + m[4] * m[2] * m[15]
5748        - m[4] * m[3] * m[14]
5749        - m[12] * m[2] * m[7]
5750        + m[12] * m[3] * m[6];
5751    inv[10] = m[0] * m[5] * m[15] - m[0] * m[7] * m[13] - m[4] * m[1] * m[15]
5752        + m[4] * m[3] * m[13]
5753        + m[12] * m[1] * m[7]
5754        - m[12] * m[3] * m[5];
5755    inv[14] = -m[0] * m[5] * m[14] + m[0] * m[6] * m[13] + m[4] * m[1] * m[14]
5756        - m[4] * m[2] * m[13]
5757        - m[12] * m[1] * m[6]
5758        + m[12] * m[2] * m[5];
5759    inv[3] = -m[1] * m[6] * m[11] + m[1] * m[7] * m[10] + m[5] * m[2] * m[11]
5760        - m[5] * m[3] * m[10]
5761        - m[9] * m[2] * m[7]
5762        + m[9] * m[3] * m[6];
5763    inv[7] = m[0] * m[6] * m[11] - m[0] * m[7] * m[10] - m[4] * m[2] * m[11]
5764        + m[4] * m[3] * m[10]
5765        + m[8] * m[2] * m[7]
5766        - m[8] * m[3] * m[6];
5767    inv[11] = -m[0] * m[5] * m[11] + m[0] * m[7] * m[9] + m[4] * m[1] * m[11]
5768        - m[4] * m[3] * m[9]
5769        - m[8] * m[1] * m[7]
5770        + m[8] * m[3] * m[5];
5771    inv[15] = m[0] * m[5] * m[10] - m[0] * m[6] * m[9] - m[4] * m[1] * m[10]
5772        + m[4] * m[2] * m[9]
5773        + m[8] * m[1] * m[6]
5774        - m[8] * m[2] * m[5];
5775    let det = m[0] * inv[0] + m[1] * inv[4] + m[2] * inv[8] + m[3] * inv[12];
5776    if det.abs() < 1e-12 {
5777        return [
5778            1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
5779        ];
5780    }
5781    let id = 1.0 / det;
5782    for v in &mut inv {
5783        *v *= id;
5784    }
5785    inv
5786}