Skip to main content

cvkg_render_gpu/
api.rs

1//! Bridging the internal renderer to `cvkg-core` traits.
2use crate::renderer::SurtrRenderer;
3use crate::renderer::material_id;
4use crate::types::*;
5use crate::vertex::*;
6use cvkg_core::LAYOUT_DIRTY;
7use cvkg_core::{ColorTheme, Mesh, Rect, RenderStateSnapshot, Renderer};
8use lyon::math::point;
9use lyon::tessellation::{BuffersBuilder, StrokeOptions, StrokeTessellator, VertexBuffers};
10use std::hash::{Hash, Hasher};
11use std::sync::atomic::Ordering;
12
13impl cvkg_core::ElapsedTime for SurtrRenderer {
14    fn delta_time(&self) -> f32 {
15        self.current_scene.delta_time
16    }
17
18    fn elapsed_time(&self) -> f32 {
19        self.start_time.elapsed().as_secs_f32()
20    }
21}
22
23impl cvkg_core::Renderer for SurtrRenderer {
24    fn is_over_budget(&self) -> bool {
25        self.frame_budget.allow_degradation
26            && self.last_frame_start.elapsed().as_secs_f32() * 1000.0 > self.frame_budget.target_ms
27    }
28
29    fn text_scale_factor(&self) -> f32 {
30        self.current_scale_factor()
31    }
32
33    fn prewarm_vram(&mut self, assets: Vec<(String, Vec<u8>)>) {
34        log::info!(
35            "[Surtr] Pre-warming Mega-Heim with {} assets...",
36            assets.len()
37        );
38        for (name, data) in assets {
39            self.load_image_to_heim(&name, &data);
40        }
41    }
42
43    fn fill_rect(&mut self, rect: Rect, color: [f32; 4]) {
44        self.fill_rect_with_mode(rect, self.apply_opacity(color), 0, None);
45    }
46
47    fn fill_rounded_rect(&mut self, rect: Rect, radius: f32, color: [f32; 4]) {
48        self.fill_rect_with_full_params(
49            rect,
50            self.apply_opacity(color),
51            3,
52            None,
53            radius,
54            Rect {
55                x: 0.0,
56                y: 0.0,
57                width: 1.0,
58                height: 1.0,
59            },
60        );
61    }
62
63    /// Fill a rounded rect with glass material for frosted backdrop effect.
64    /// This is the proper way to render glass cards that need macOS Tahoe-style blur.
65    /// The blur_radius controls the intensity of the backdrop blur.
66    /// The glass_intensity controls overall glass effect strength (0.0 = solid, 1.0 = full glass).
67    /// For Tahoe parity, this registers the rect as a portal region for
68    /// per-element isolated backdrop blur when z_index != 0.
69    fn fill_glass_rect(&mut self, rect: Rect, radius: f32, blur_radius: f32) {
70        self.fill_glass_rect_with_intensity(rect, radius, blur_radius, 1.0);
71    }
72
73    /// Fill a rounded rect with glass material with explicit intensity control.
74    /// `glass_intensity` ranges from 0.0 (solid, no glass effect) to 1.0 (full glass).
75    /// This allows per-component control over glass strength.
76    fn fill_glass_rect_with_intensity(&mut self, rect: Rect, radius: f32, blur_radius: f32, glass_intensity: f32) {
77        // Default tint: neutral white with moderate alpha, matching pre-tint behavior
78        self.fill_glass_rect_with_tint(rect, radius, blur_radius, [1.0, 1.0, 1.0, 0.4], glass_intensity);
79    }
80
81    /// Fill a rounded rect with glass material with explicit tint color and intensity.
82    /// `tint_color` is the glass base color (RGBA). `glass_intensity` controls effect strength.
83    fn fill_glass_rect_with_tint(&mut self, rect: Rect, radius: f32, blur_radius: f32, tint_color: [f32; 4], glass_intensity: f32) {
84        let gi = glass_intensity.clamp(0.0, 1.0);
85        // Per-instance blur_radius drives the shader's blur_mip level.
86        // Scale: 0-100 input maps to 0-4 mip levels for the Kawase blur chain.
87        let blur_strength = (blur_radius / 25.0).clamp(0.0, 4.0) * gi;
88
89        // Register for portal-aware per-element backdrop blur (Tahoe feature)
90        if self.current_z != 0.0 {
91            self.portal_regions.push_back(rect);
92        }
93
94        // Temporary Material Override Binding
95        let prev_material = self.current_draw_material;
96        self.current_draw_material = cvkg_core::DrawMaterial::Glass {
97            blur_radius: blur_strength,
98            ior_override: 0.0,
99            glass_intensity: gi,
100        };
101
102        // Tint color alpha is modulated by intensity so intensity=0 gives a near-invisible fill
103        let fill_color = [
104            tint_color[0],
105            tint_color[1],
106            tint_color[2],
107            tint_color[3] * gi,
108        ];
109
110        self.fill_rect_with_full_params(
111            rect,
112            fill_color,
113            7, // Mode 7 = Glass material
114            None,
115            radius,
116            Rect {
117                x: 0.0,
118                y: 0.0,
119                width: 1.0,
120                height: 1.0,
121            },
122        );
123
124        self.current_draw_material = prev_material;
125    }
126
127    fn fill_glass_rect_with_pressure(&mut self, rect: Rect, radius: f32, blur_radius: f32, pressure: f32) {
128        // Pressure scales both blur and tint: full pressure = full glass effect
129        let p = pressure.clamp(0.0, 1.0);
130        self.fill_glass_rect_with_intensity(rect, radius, blur_radius * p, p);
131    }
132
133    /// Set the default background color for the canvas.
134    /// This color is used when the app does not draw its own background.
135    /// Default: `[0.02, 0.02, 0.05, 1.0]` (Deep Void).
136    fn set_default_background_color(&mut self, color: [f32; 4]) {
137        self.default_background_color = color;
138    }
139
140    /// Fill a squircle (superellipse) for Apple-style icon silhouettes.
141    /// `n` controls the squareness: 2.0 = rounded rect, 4.0 = classic squircle, higher = more square.
142    fn fill_squircle(&mut self, rect: Rect, n: f32, color: [f32; 4]) {
143        let prev_material = self.current_draw_material;
144        self.current_draw_material = cvkg_core::DrawMaterial::Opaque;
145        self.fill_rect_with_full_params(
146            rect,
147            self.apply_opacity(color),
148            0,
149            None,
150            rect.width.min(rect.height) * 0.22 * (n / 4.0),
151            Rect { x: 0.0, y: 0.0, width: 1.0, height: 1.0 },
152        );
153        self.current_draw_material = prev_material;
154    }
155
156    /// Stroke a squircle (superellipse) outline.
157    fn stroke_squircle(&mut self, rect: Rect, n: f32, color: [f32; 4], stroke_width: f32) {
158        let prev_material = self.current_draw_material;
159        self.current_draw_material = cvkg_core::DrawMaterial::Opaque;
160        self.fill_rect_with_full_params(
161            rect,
162            self.apply_opacity(color),
163            material_id::SQUIRCLE_STROKE,
164            None,
165            rect.width.min(rect.height) * 0.22 * (n / 4.0),
166            Rect { x: stroke_width, y: 0.0, width: 0.0, height: 0.0 },
167        );
168        self.current_draw_material = prev_material;
169    }
170
171    /// Draw a focus ring around a rect (for keyboard navigation accessibility).
172    /// `offset` is the gap between the rect and the ring, `width` is the ring thickness.
173    fn draw_focus_ring(&mut self, rect: Rect, radius: f32, offset: f32, width: f32, color: [f32; 4]) {
174        let ring_rect = Rect {
175            x: rect.x - offset,
176            y: rect.y - offset,
177            width: rect.width + 2.0 * offset,
178            height: rect.height + 2.0 * offset,
179        };
180        self.stroke_squircle(ring_rect, 4.0, color, width);
181    }
182
183    fn fill_ellipse(&mut self, rect: Rect, color: [f32; 4]) {
184        self.fill_rect_with_full_params(
185            rect,
186            self.apply_opacity(color),
187            4,
188            None,
189            0.0,
190            Rect {
191                x: 0.0,
192                y: 0.0,
193                width: 1.0,
194                height: 1.0,
195            },
196        );
197    }
198
199    fn draw_3d_cube(&mut self, rect: Rect, color: [f32; 4], rotation: [f32; 3]) {
200        self.fill_rect_with_full_params_and_slice(
201            rect,
202            self.apply_opacity(color),
203            material_id::MESH_3D,
204            None,
205            0.0,
206            Rect {
207                x: 0.0,
208                y: 0.0,
209                width: 1.0,
210                height: 1.0,
211            },
212            [rotation[0], rotation[1], rotation[2], 0.0],
213            [0.0, 0.0],
214        );
215    }
216
217    fn bifrost(&mut self, rect: Rect, blur: f32, _saturation: f32, opacity: f32) {
218        // Calculate screen-space UVs for high-fidelity global refraction
219        let logical_w = self.current_width() as f32 / self.current_scale_factor();
220        let logical_h = self.current_height() as f32 / self.current_scale_factor();
221        let screen_uv = Rect {
222            x: rect.x / logical_w,
223            y: rect.y / logical_h,
224            width: rect.width / logical_w,
225            height: rect.height / logical_h,
226        };
227        // Use mode 7 for high-fidelity background blur sampling
228        // Use the blur parameter as corner radius for the glass panel
229        self.fill_rect_with_full_params(rect, [1.0, 1.0, 1.0, opacity], 7, None, blur, screen_uv);
230    }
231
232    fn gungnir(&mut self, rect: Rect, color: [f32; 4], radius: f32, intensity: f32) {
233        // Single draw call via SDF glow material instead of 4 additive rects
234        let margin = radius;
235        let glow_rect = Rect {
236            x: rect.x - margin,
237            y: rect.y - margin,
238            width: rect.width + 2.0 * margin,
239            height: rect.height + 2.0 * margin,
240        };
241        let glow_color = [color[0], color[1], color[2], intensity * 0.3];
242        self.fill_rect_with_full_params(
243            glow_rect,
244            self.apply_opacity(glow_color),
245            material_id::DROP_SHADOW,
246            None,
247            8.0,
248            Rect {
249                x: margin,
250                y: radius,
251                width: 0.0,
252                height: 0.0,
253            },
254        );
255    }
256
257    /// Soft glow variant -- half the intensity of gungnir().
258    /// Use for hover highlights, non-critical indicators.
259    fn gungnir_soft(&mut self, rect: Rect, color: [f32; 4], radius: f32, intensity: f32) {
260        self.gungnir(rect, color, radius, intensity * 0.5);
261    }
262
263    /// Renders a dynamic glowing hover boundary field around a hit target.
264    ///
265    /// # Contract
266    /// Expands the bounding box of the visual target by `radius` to establish
267    /// a continuous proximity glow. Uses the drop shadow/glow SDF material
268    /// to rasterize the glow with specialized radius-to-margin uv coordinate mappings.
269    fn mani_glow(&mut self, rect: Rect, color: [f32; 4], radius: f32) {
270        let margin = radius;
271        let glow_rect = Rect {
272            x: rect.x - margin,
273            y: rect.y - margin,
274            width: rect.width + 2.0 * margin,
275            height: rect.height + 2.0 * margin,
276        };
277        let uv_rect = Rect {
278            x: margin,
279            y: radius,
280            width: 0.0,
281            height: 0.0,
282        };
283        self.fill_rect_with_full_params(
284            glow_rect,
285            self.apply_opacity(color),
286            material_id::DROP_SHADOW,
287            None,
288            8.0,
289            uv_rect,
290        );
291    }
292
293    fn stroke_rect(&mut self, rect: Rect, color: [f32; 4], stroke_width: f32) {
294        let c = self.apply_opacity(color);
295        // Single draw call via SDF stroke material instead of 4 edge bars
296        self.fill_rect_with_full_params(
297            rect,
298            c,
299            material_id::SQUIRCLE_STROKE,
300            None,
301            0.0, // radius = 0 for sharp rect corners
302            Rect {
303                x: stroke_width,
304                y: 0.0,
305                width: 0.0,
306                height: 0.0,
307            },
308        );
309    }
310
311    fn stroke_rounded_rect(&mut self, rect: Rect, radius: f32, color: [f32; 4], stroke_width: f32) {
312        self.fill_rect_with_full_params(
313            rect,
314            self.apply_opacity(color),
315            material_id::SQUIRCLE_STROKE,
316            None,
317            radius,
318            Rect {
319                x: stroke_width,
320                y: 0.0,
321                width: 0.0,
322                height: 0.0,
323            },
324        );
325    }
326
327    fn stroke_ellipse(&mut self, rect: Rect, color: [f32; 4], stroke_width: f32) {
328        // Tessellate an ellipse stroke using Lyon's StrokeTessellator.
329        let cx = rect.x + rect.width / 2.0;
330        let cy = rect.y + rect.height / 2.0;
331        let rx = rect.width / 2.0;
332        let ry = rect.height / 2.0;
333
334        // Build an ellipse path using Lyon
335        let mut builder = lyon::path::Path::builder();
336        if rx > 0.0 && ry > 0.0 {
337            // Approximate ellipse with 64 segments
338            let segments = 64;
339            for i in 0..segments {
340                let angle = 2.0 * std::f32::consts::PI * (i as f32) / (segments as f32);
341                let x = cx + rx * angle.cos();
342                let y = cy + ry * angle.sin();
343                if i == 0 {
344                    builder.begin(lyon::math::point(x, y));
345                } else {
346                    builder.line_to(lyon::math::point(x, y));
347                }
348            }
349            builder.close();
350        }
351        let path = builder.build();
352        self.stroke_path(&path, color, stroke_width);
353    }
354
355    fn draw_linear_gradient(
356        &mut self,
357        rect: Rect,
358        start_color: [f32; 4],
359        end_color: [f32; 4],
360        angle: f32,
361    ) {
362        self.fill_rect_with_full_params_and_slice(
363            rect,
364            self.apply_opacity(start_color),
365            15,
366            None,
367            0.0,
368            Rect {
369                x: angle,
370                y: 0.0,
371                width: 1.0,
372                height: 1.0,
373            },
374            end_color,
375            [0.0, 0.0],
376        );
377    }
378
379    fn draw_radial_gradient(&mut self, rect: Rect, inner_color: [f32; 4], outer_color: [f32; 4]) {
380        self.fill_rect_with_full_params_and_slice(
381            rect,
382            self.apply_opacity(inner_color),
383            material_id::RADIAL_GRADIENT,
384            None,
385            0.0,
386            Rect {
387                x: 0.0,
388                y: 0.0,
389                width: 1.0,
390                height: 1.0,
391            },
392            outer_color,
393            [0.0, 0.0],
394        );
395    }
396
397    fn draw_drop_shadow(
398        &mut self,
399        rect: Rect,
400        radius: f32,
401        color: [f32; 4],
402        blur: f32,
403        spread: f32,
404    ) {
405        let margin = blur + spread;
406        let inflated = Rect {
407            x: rect.x - margin,
408            y: rect.y - margin,
409            width: rect.width + margin * 2.0,
410            height: rect.height + margin * 2.0,
411        };
412        // uv.x = total margin (for SDF offset), uv.y = blur width (for falloff)
413        self.fill_rect_with_full_params(
414            inflated,
415            self.apply_opacity(color),
416            material_id::DROP_SHADOW,
417            None,
418            radius,
419            Rect {
420                x: margin,
421                y: blur,
422                width: 0.0,
423                height: 0.0,
424            },
425        );
426    }
427
428    fn stroke_dashed_rounded_rect(
429        &mut self,
430        rect: Rect,
431        radius: f32,
432        color: [f32; 4],
433        width: f32,
434        dash: f32,
435        gap: f32,
436    ) {
437        self.fill_rect_with_full_params(
438            rect,
439            self.apply_opacity(color),
440            material_id::DASHED_STROKE,
441            None,
442            radius,
443            Rect {
444                x: width,
445                y: dash,
446                width: gap,
447                height: 0.0,
448            },
449        );
450    }
451
452    fn draw_9slice(
453        &mut self,
454        image_name: &str,
455        rect: Rect,
456        left: f32,
457        top: f32,
458        right: f32,
459        bottom: f32,
460    ) {
461        let c = self.apply_opacity([1.0, 1.0, 1.0, 1.0]);
462        let tid = self.get_texture_id(image_name);
463        self.fill_rect_with_full_params(
464            rect,
465            c,
466            20,
467            tid,
468            bottom,
469            Rect {
470                x: left,
471                y: top,
472                width: right,
473                height: 0.0,
474            },
475        );
476    }
477
478    fn draw_line(
479        &mut self,
480        x1: f32,
481        y1: f32,
482        x2: f32,
483        y2: f32,
484        color: [f32; 4],
485        stroke_width: f32,
486    ) {
487        let dx = x2 - x1;
488        let dy = y2 - y1;
489        let len_sq = dx * dx + dy * dy;
490        if len_sq < 0.000001 {
491            return;
492        }
493        let len = len_sq.sqrt();
494        let half_w = stroke_width * 0.5;
495        // Perpendicular unit vector
496        let nx = -dy / len * half_w;
497        let ny = dx / len * half_w;
498        // Build 4 corner points of the line quad
499        let points = [
500            [x1 + nx, y1 + ny],
501            [x2 + nx, y2 + ny],
502            [x2 - nx, y2 - ny],
503            [x1 - nx, y1 - ny],
504        ];
505        self.push_oriented_quad(points, color, 1, Rect { x: 0.0, y: 0.0, width: 1.0, height: 1.0 });
506    }
507
508    fn draw_image(&mut self, image_name: &str, rect: Rect) {
509        // Guard: skip if image not loaded -- avoids rendering garbage from uninitialized atlas regions
510        if !self.image_uv_registry.contains(image_name) {
511            log::warn!("[Surtr] draw_image: '{}' not loaded, skipping", image_name);
512            return;
513        }
514        let tid = self
515            .get_texture_id(image_name)
516            .or_else(|| self.get_texture_id("__mega_heim"));
517        let uv_rect = self
518            .image_uv_registry
519            .get(image_name)
520            .copied()
521            .unwrap_or(Rect {
522                x: 0.0,
523                y: 0.0,
524                width: 1.0,
525                height: 1.0,
526            });
527        self.fill_rect_with_full_params(rect, [1.0, 1.0, 1.0, 1.0], 2, tid, 0.0, uv_rect);
528    }
529
530
531
532    fn shape_rich_text(
533        &mut self,
534        spans: &[cvkg_runic_text::TextSpan],
535        max_width: Option<f32>,
536        align: cvkg_runic_text::TextAlign,
537        overflow: cvkg_runic_text::TextOverflow,
538    ) -> Option<cvkg_runic_text::ShapedText> {
539        let sf = self.current_scale_factor();
540        let mut scaled_spans = spans.to_vec();
541        for span in &mut scaled_spans {
542            span.style.font_size *= sf;
543            if span.style.fallback_families.is_empty() {
544                span.style.fallback_families = vec![
545                    "SF Pro".to_string(),
546                    "Inter".to_string(),
547                    "Helvetica Neue".to_string(),
548                    "Helvetica".to_string(),
549                    "Arial".to_string(),
550                    "sans-serif".to_string(),
551                ];
552            }
553        }
554        let scaled_max_width = max_width.map(|w| w * sf);
555        self.text.engine
556            .shape_layout(&scaled_spans, scaled_max_width, align, overflow)
557            .ok()
558    }
559
560    fn draw_shaped_text(&mut self, shaped: &cvkg_runic_text::ShapedText, x: f32, y: f32) {
561        for glyph in &shaped.glyphs {
562            let byte_idx = shaped
563                .grapheme_boundaries
564                .get(glyph.cluster as usize)
565                .copied()
566                .unwrap_or(0);
567            let mut span_color = [1.0, 1.0, 1.0, 1.0];
568            for span in &shaped.spans {
569                if byte_idx >= span.byte_offset && byte_idx < span.byte_offset + span.text.len() {
570                    span_color = [
571                        span.style.color[0] as f32 / 255.0,
572                        span.style.color[1] as f32 / 255.0,
573                        span.style.color[2] as f32 / 255.0,
574                        span.style.color[3] as f32 / 255.0,
575                    ];
576                    break;
577                }
578            }
579            let c = self.apply_opacity(span_color);
580
581            let cache_key = glyph.cache_key;
582            let (uv_rect, w, h, x_off, y_off) = if let Some(info) = self.text.glyph_cache.get(&cache_key)
583            {
584                *info
585            } else {
586                if let Some(image) = self.text.engine.rasterize(cache_key) {
587                    let glyph_id = image.glyph_id;
588                    let data_len = image.data.len();
589                    let gw = image.width;
590                    let gh = image.height;
591                    let x_offset = image.x_offset;
592                    let y_offset = image.y_offset;
593                    let (rgba_data, gw, gh) = glyph_image_to_rgba(image);
594                    if gw == 0 || gh == 0 {
595                        let info = (Rect::zero(), 0.0, 0.0, 0.0, 0.0);
596                        self.text.glyph_cache.put(cache_key, info);
597                        continue;
598                    }
599                    if rgba_data.is_empty() {
600                        log::warn!(
601                            "Glyph rasterizer returned unsupported pixel format for glyph {} ({} bytes, {}x{}), skipping",
602                            glyph_id,
603                            data_len,
604                            gw,
605                            gh
606                        );
607                        continue;
608                    }
609
610                    let pack_res = self.heim_packer.pack(gw, gh);
611                    let (nx, ny) = if let Some(pos) = pack_res {
612                        pos
613                    } else {
614                        self.reclaim_vram();
615                        match self.heim_packer.pack(gw, gh) {
616                            Some(pos) => pos,
617                            None => {
618                                log::error!(
619                                    "Glyph heim critically full after reclaim: cannot pack {}x{} glyph, skipping",
620                                    gw,
621                                    gh
622                                );
623                                continue; // Skip this glyph rather than corrupting atlas origin
624                            }
625                        }
626                    };
627                    
628                    self.queue.write_texture(
629                        wgpu::TexelCopyTextureInfo {
630                            texture: &self.mega_heim_tex,
631                            mip_level: 0,
632                            origin: wgpu::Origin3d { x: nx, y: ny, z: 0 },
633                            aspect: wgpu::TextureAspect::All,
634                        },
635                        &rgba_data,
636                        wgpu::TexelCopyBufferLayout {
637                            offset: 0,
638                            bytes_per_row: Some(gw * 4),
639                            rows_per_image: Some(gh),
640                        },
641                        wgpu::Extent3d {
642                            width: gw,
643                            height: gh,
644                            depth_or_array_layers: 1,
645                        },
646                    );
647
648                    let tex_w = self.mega_heim_tex.width() as f32;
649                    let tex_h = self.mega_heim_tex.height() as f32;
650                    let info = (
651                        Rect {
652                            x: nx as f32 / tex_w,
653                            y: ny as f32 / tex_h,
654                            width: gw as f32 / tex_w,
655                            height: gh as f32 / tex_h,
656                        },
657                        gw as f32,
658                        gh as f32,
659                        x_offset,
660                        y_offset,
661                    );
662                    self.text.glyph_cache.put(cache_key, info);
663                    info
664                } else {
665                    (Rect::zero(), 0.0, 0.0, 0.0, 0.0)
666                }
667            };
668
669            if w > 0.0 {
670                let sf = self.current_scale_factor();
671                // Position glyph relative to baseline.
672                // glyph.x/y are in physical pixels, baseline-relative.
673                // shaped.ascent gives the baseline offset from the text origin (y).
674                let baseline_y = y + shaped.ascent / sf;
675                let glyph_rect = Rect {
676                    x: x + (glyph.x + x_off) / sf,
677                    y: baseline_y + (glyph.y - y_off) / sf,
678                    width: w / sf,
679                    height: h / sf,
680                };
681                let tid = self.get_texture_id("__mega_heim");
682                let slice = self
683                    .slice_stack
684                    .last()
685                    .copied()
686                    .map(|(a, o)| [a, o, 1.0, 1.0])
687                    .unwrap_or([0.0, 0.0, 0.0, 1.0]);
688                self.fill_rect_with_full_params_and_slice(
689                    glyph_rect,
690                    c,
691                    6,
692                    tid,
693                    0.0,
694                    uv_rect,
695                    slice,
696                    [glyph.glyph_index as f32, glyph.time_offset],
697                );
698            }
699        }
700    }
701
702    fn draw_texture(&mut self, texture_id: u32, rect: Rect) {
703        self.fill_rect_with_full_params_and_slice(
704            rect,
705            [1.0, 1.0, 1.0, 1.0],
706            2,
707            Some(texture_id),
708            0.0,
709            Rect {
710                x: 0.0,
711                y: 0.0,
712                width: 1.0,
713                height: 1.0,
714            },
715            [0.0, 0.0, 0.0, 1.0],
716            [0.0, 0.0],
717        );
718    }
719
720    /// load_image -- Proactively pushes a raw asset into the Mega-Heim.
721    /// load_image -- Proactively pushes a raw asset into the Texture Array.
722    fn load_image(&mut self, name: &str, data: &[u8]) {
723        if self.image_uv_registry.contains(name) {
724            return;
725        }
726        let img_result = image::load_from_memory(data);
727        let img = match img_result {
728            Ok(img) => img.to_rgba8(),
729            Err(e) => {
730                log::error!("Failed to load image {}: {}", name, e);
731                image::RgbaImage::from_pixel(1, 1, image::Rgba([255, 255, 255, 255]))
732            }
733        };
734        let (width, height) = img.dimensions();
735
736        let size = wgpu::Extent3d {
737            width,
738            height,
739            depth_or_array_layers: 1,
740        };
741        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
742            label: Some(&format!("Texture Array Layer: {}", name)),
743            size,
744            mip_level_count: 1,
745            sample_count: 1,
746            dimension: wgpu::TextureDimension::D2,
747            format: wgpu::TextureFormat::Rgba8UnormSrgb,
748            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
749            view_formats: &[],
750        });
751
752        self.queue.write_texture(
753            wgpu::TexelCopyTextureInfo {
754                texture: &texture,
755                mip_level: 0,
756                origin: wgpu::Origin3d::ZERO,
757                aspect: wgpu::TextureAspect::All,
758            },
759            &img,
760            wgpu::TexelCopyBufferLayout {
761                offset: 0,
762                bytes_per_row: Some(4 * width),
763                rows_per_image: Some(height),
764            },
765            size,
766        );
767
768        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
769
770        // Slot allocation (Skip index 0 which is the dummy/atlas)
771        // texture_views is a fixed 32-element Vec; indices 1..=31 are usable.
772        let index = if self.texture_registry.len() < 31 {
773            (self.texture_registry.len() + 1) as u32
774        } else {
775            // Evict the least recently used texture and reuse its slot.
776            // The bind group cache is invalidated below by rebuilding.
777            if let Some((old_name, old_index)) = self.texture_registry.pop_lru() {
778                self.image_uv_registry.pop(&old_name);
779                old_index
780            } else {
781                log::warn!("[GPU] texture registry full and no LRU entry to evict");
782                return;
783            }
784        };
785
786        // Bounds guard: index must be in 1..32 (index 0 is the atlas).
787        if index == 0 || index as usize >= self.texture_views.len() {
788            log::error!("[GPU] load_image: invalid texture index {} (registry has {} entries)", index, self.texture_registry.len());
789            return;
790        }
791
792        self.texture_views[index as usize] = view;
793        self.image_uv_registry.put(
794            name.to_string(),
795            Rect {
796                x: 0.0,
797                y: 0.0,
798                width: 1.0,
799                height: 1.0,
800            },
801        );
802        self.texture_registry.put(name.to_string(), index);
803        self.rebuild_texture_array_bind_group();
804    }
805
806    fn push_clip_rect(&mut self, rect: Rect) {
807        self.clip_stack.push(rect);
808    }
809
810    fn pop_clip_rect(&mut self) {
811        self.clip_stack.pop();
812    }
813
814    fn current_clip_rect(&self) -> Rect {
815        self.clip_stack.last().copied().unwrap_or(Rect::new(
816            0.0,
817            0.0,
818            self.current_width() as f32,
819            self.current_height() as f32,
820        ))
821    }
822
823    fn memoize(&mut self, id: u64, data_hash: u64, render_fn: &dyn Fn(&mut dyn Renderer)) {
824        // P0-4 fix: actually cache and replay GPU draw commands.
825        //
826        // The previous implementation only cached `(data_hash, frame_generation)`
827        // and emitted ZERO draw calls on the skip path. Any content using
828        // `memoize` rendered once and then vanished on every subsequent frame.
829        //
830        // The fix: on first call (or when hash changes), record the vertex/
831        // index/instance buffers and DrawCall list produced by `render_fn`,
832        // with offsets remapped relative to the captured slice. On replay,
833        // append the cached buffers to the current buffer state and shift
834        // the cached DrawCall offsets by the current buffer length so the
835        // replayed commands reference the freshly-appended data.
836        use crate::types::{DrawCall, MemoEntry};
837
838        let should_skip = self
839            .memo_cache
840            .get(&id)
841            .map_or(false, |entry| entry.hash == data_hash);
842
843        if should_skip {
844            // Replay path: append cached buffers and remap cached DrawCall offsets.
845            if let Some(entry) = self.memo_cache.get(&id) {
846                let i_offset = self.indices.len() as u32;
847                let inst_offset = self.instance_data.len() as u32;
848
849                self.vertices.extend_from_slice(&entry.vertices);
850                self.indices.extend_from_slice(&entry.indices);
851                self.instance_data
852                    .extend_from_slice(&entry.instance_data);
853
854                for dc in &entry.draw_calls {
855                    let mut replayed = dc.clone();
856                    // Offsets stored relative to the captured slice start;
857                    // shift them by the current buffer lengths so they
858                    // reference the freshly-appended data.
859                    replayed.index_start += i_offset;
860                    replayed.instance_start += inst_offset;
861                    self.draw_calls.push(replayed);
862                }
863            }
864        } else {
865            // Capture path: snapshot lengths, render, then record deltas.
866            let v_start = self.vertices.len();
867            let i_start = self.indices.len();
868            let inst_start = self.instance_data.len();
869            let dc_start = self.draw_calls.len();
870
871            render_fn(self);
872
873            // Remap DrawCall offsets to be relative to the captured slice.
874            let draw_calls: Vec<DrawCall> = self.draw_calls[dc_start..]
875                .iter()
876                .map(|dc| {
877                    let mut remapped = dc.clone();
878                    // saturating_sub guards against underflow if a draw call
879                    // somehow already had an offset below the slice start
880                    // (should not happen, but defensive).
881                    remapped.index_start = remapped
882                        .index_start
883                        .saturating_sub(i_start as u32);
884                    remapped.instance_start = remapped
885                        .instance_start
886                        .saturating_sub(inst_start as u32);
887                    remapped
888                })
889                .collect();
890
891            let entry = MemoEntry {
892                hash: data_hash,
893                frame_gen: self.frame_generation,
894                vertices: self.vertices[v_start..].to_vec(),
895                indices: self.indices[i_start..].to_vec(),
896                instance_data: self.instance_data[inst_start..].to_vec(),
897                draw_calls,
898            };
899
900            self.memo_cache.insert(id, entry);
901        }
902    }
903
904    fn snapshot_render_state(&self) -> RenderStateSnapshot {
905        RenderStateSnapshot {
906            clip_depth: self.clip_stack.len() as u32,
907            opacity_depth: self.opacity_stack.len() as u32,
908            slice_depth: self.slice_stack.len() as u32,
909            shadow_depth: self.shadow_stack.len() as u32,
910            transform_depth: self.transform_stack.len() as u32,
911            vnode_depth: self.vnode_stack.len() as u32,
912        }
913    }
914
915    fn restore_render_state(&mut self, snap: RenderStateSnapshot) {
916        // Idempotent: pop only items pushed beyond the snapshot point.
917        while self.clip_stack.len() as u32 > snap.clip_depth {
918            self.clip_stack.pop();
919        }
920        while self.opacity_stack.len() as u32 > snap.opacity_depth {
921            self.opacity_stack.pop();
922        }
923        while self.slice_stack.len() as u32 > snap.slice_depth {
924            self.slice_stack.pop();
925        }
926        while self.shadow_stack.len() as u32 > snap.shadow_depth {
927            self.shadow_stack.pop();
928        }
929        while self.transform_stack.len() as u32 > snap.transform_depth {
930            self.transform_stack.pop();
931        }
932        while self.vnode_stack.len() as u32 > snap.vnode_depth {
933            self.vnode_stack.pop();
934        }
935    }
936
937    fn push_opacity(&mut self, opacity: f32) {
938        let current = self.opacity_stack.last().copied().unwrap_or(1.0);
939        self.opacity_stack.push(current * opacity);
940    }
941
942    fn pop_opacity(&mut self) {
943        self.opacity_stack.pop();
944    }
945
946    fn push_shadow(&mut self, radius: f32, color: [f32; 4], offset: [f32; 2]) {
947        self.shadow_stack.push(ShadowState {
948            radius,
949            color,
950            _offset: offset,
951        });
952    }
953
954    fn pop_shadow(&mut self) {
955        self.shadow_stack.pop();
956    }
957
958    fn push_transform(&mut self, translation: [f32; 2], scale: [f32; 2], rotation: f32) {
959        let c = rotation.cos();
960        let sn = rotation.sin();
961        let affine = glam::Mat3::from_cols(
962            glam::Vec3::new(c * scale[0], sn * scale[0], 0.0),
963            glam::Vec3::new(-sn * scale[1], c * scale[1], 0.0),
964            glam::Vec3::new(translation[0], translation[1], 1.0),
965        );
966
967        let parent = self
968            .transform_stack
969            .last()
970            .copied()
971            .unwrap_or(glam::Mat3::IDENTITY);
972        self.transform_stack.push(parent * affine);
973    }
974
975    fn push_affine(&mut self, transform: [f32; 6]) {
976        let affine = glam::Mat3::from_cols(
977            glam::Vec3::new(transform[0], transform[1], 0.0),
978            glam::Vec3::new(transform[2], transform[3], 0.0),
979            glam::Vec3::new(transform[4], transform[5], 1.0),
980        );
981        let parent = self
982            .transform_stack
983            .last()
984            .copied()
985            .unwrap_or(glam::Mat3::IDENTITY);
986        self.transform_stack.push(parent * affine);
987    }
988
989    fn pop_transform(&mut self) {
990        self.transform_stack.pop();
991    }
992
993    fn set_theme(&mut self, theme: ColorTheme) {
994        self.current_theme = theme;
995        self.queue
996            .write_buffer(&self.theme_buffer, 0, bytemuck::bytes_of(&theme));
997    }
998
999    fn set_rage(&mut self, rage: f32) {
1000        self.current_scene.berzerker_rage = rage;
1001        // scene_buffer is updated every frame in begin_frame, so no need to write here
1002    }
1003
1004    fn set_fireball_pos(&mut self, pos: [f32; 2]) {
1005        self.current_scene.fireball_pos = pos;
1006    }
1007
1008    fn trigger_shatter_event(&mut self, origin: [f32; 2], force: f32) {
1009        self.current_scene.shatter_origin = origin;
1010        self.current_scene.shatter_time = self.current_scene.time;
1011        self.current_scene.shatter_force = force;
1012    }
1013
1014    fn set_scene_preset(&mut self, preset: u32) {
1015        self.current_scene.scene_type = preset;
1016    }
1017
1018    /// push_mjolnir_slice -- Pushes a geometric clipping plane onto the stack.
1019    /// All subsequent draw calls will be sliced by this plane until it is popped.
1020    fn push_mjolnir_slice(&mut self, angle: f32, offset: f32) {
1021        self.slice_stack.push((angle, offset));
1022    }
1023
1024    /// pop_mjolnir_slice -- Removes the top-most geometric clipping plane from the stack.
1025    fn pop_mjolnir_slice(&mut self) {
1026        self.slice_stack.pop();
1027    }
1028
1029    fn mjolnir_shatter(&mut self, rect: Rect, pieces: u32, force: f32, color: [f32; 4]) {
1030        self.shatter_internal(rect, pieces, force, color, 8);
1031    }
1032
1033    fn mjolnir_fluid_shatter(&mut self, rect: Rect, pieces: u32, force: f32, color: [f32; 4]) {
1034        self.shatter_internal(rect, pieces, force, color, 11);
1035    }
1036
1037    fn draw_mjolnir_bolt(&mut self, from: [f32; 2], to: [f32; 2], color: [f32; 4]) {
1038        self.recursive_bolt(from, to, 4, color);
1039    }
1040
1041    fn dispatch_particles(
1042        &mut self,
1043        origin: [f32; 2],
1044        count: u32,
1045        effect_type: &str,
1046        color: [f32; 4],
1047    ) {
1048        use crate::types::{GpuParticle, MAX_PARTICLES};
1049
1050        let dt = self.current_scene.delta_time;
1051        let now = std::time::Instant::now();
1052
1053        // Determine spawn parameters based on effect type
1054        let (speed_range, life_range, spread_angle) = match effect_type {
1055            "firework" => (100.0..300.0, 1.0..2.5, std::f32::consts::TAU),
1056            "spark" => (50.0..150.0, 0.5..1.5, std::f32::consts::PI),
1057            "rain" => (20.0..80.0, 1.0..3.0, std::f32::consts::FRAC_PI_4),
1058            "data_stream" => (80.0..200.0, 0.8..2.0, std::f32::consts::FRAC_PI_6),
1059            "bubble" => (10.0..40.0, 2.0..4.0, std::f32::consts::TAU),
1060            _ => (30.0..120.0, 1.0..2.0, std::f32::consts::TAU),
1061        };
1062
1063        let count = count.min((MAX_PARTICLES - self.particles.count as usize) as u32);
1064        if count == 0 {
1065            return;
1066        }
1067
1068        let mut rng_state = (now.elapsed().as_nanos() as u64).wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
1069        let mut rand_f32 = |range: std::ops::Range<f32>| -> f32 {
1070            rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
1071            let t = (rng_state >> 33) as f32 / (1u64 << 31) as f32;
1072            range.start + t * (range.end - range.start)
1073        };
1074
1075        for _ in 0..count {
1076            let angle = rand_f32(0.0..spread_angle);
1077            let speed = rand_f32(speed_range.clone());
1078            let life = rand_f32(life_range.clone());
1079            let vx = angle.cos() * speed;
1080            let vy = angle.sin() * speed;
1081
1082            let particle = GpuParticle {
1083                pos_vel: [origin[0], origin[1], vx, vy],
1084                color_life: [color[0], color[1], color[2], life],
1085            };
1086            self.particles.staging.push(particle);
1087        }
1088
1089        log::debug!(
1090            "[Surtr] dispatch_particles: {} {} particles at {:?} (staged, {} total pending)",
1091            count,
1092            effect_type,
1093            origin,
1094            self.particles.staging.len()
1095        );
1096    }
1097
1098    fn draw_hologram(&mut self, rect: Rect, hologram_id: &str, time: f32) {
1099        use std::hash::{Hash, Hasher};
1100        let mut hasher = std::collections::hash_map::DefaultHasher::new();
1101        hologram_id.hash(&mut hasher);
1102        let id_hash = hasher.finish() as u32;
1103
1104        log::debug!(
1105            "[Surtr] draw_hologram: {} at {:?} t={} (hologram pipeline)",
1106            hologram_id,
1107            rect,
1108            time
1109        );
1110
1111        self.hologram_instances.push(crate::renderer::HologramInstance {
1112            rect,
1113            id_hash,
1114            time,
1115        });
1116        self.volumetric_enabled = true;
1117    }
1118
1119    fn upload_data_texture(&mut self, id: &str, data: &[f32], width: u32, height: u32) {
1120        let size = wgpu::Extent3d {
1121            width,
1122            height,
1123            depth_or_array_layers: 1,
1124        };
1125        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
1126            label: Some(id),
1127            size,
1128            mip_level_count: 1,
1129            sample_count: 1,
1130            dimension: wgpu::TextureDimension::D2,
1131            format: wgpu::TextureFormat::R32Float,
1132            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1133            view_formats: &[],
1134        });
1135        self.queue.write_texture(
1136            wgpu::TexelCopyTextureInfo {
1137                texture: &texture,
1138                mip_level: 0,
1139                origin: wgpu::Origin3d::ZERO,
1140                aspect: wgpu::TextureAspect::All,
1141            },
1142            bytemuck::cast_slice(data),
1143            wgpu::TexelCopyBufferLayout {
1144                offset: 0,
1145                bytes_per_row: Some(4 * width),
1146                rows_per_image: Some(height),
1147            },
1148            size,
1149        );
1150        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
1151        // Reuse the renderer's pre-created linear sampler (ClampToEdge + Linear)
1152        // instead of allocating a new sampler on every upload.
1153        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
1154            layout: &self.texture_bind_group_layout,
1155            entries: &[
1156                wgpu::BindGroupEntry {
1157                    binding: 0,
1158                    // The layout requires 32 entries; only index 0 is the actual texture.
1159                    resource: wgpu::BindingResource::TextureViewArray(&vec![&view; 32]),
1160                },
1161                wgpu::BindGroupEntry {
1162                    binding: 1,
1163                    resource: wgpu::BindingResource::Sampler(&self.linear_sampler),
1164                },
1165            ],
1166            label: Some(id),
1167        });
1168        self.texture_bind_groups.push(bind_group);
1169        let tid = (self.texture_bind_groups.len() - 1) as u32;
1170        self.texture_registry.put(id.to_string(), tid);
1171    }
1172
1173    fn draw_heatmap(&mut self, texture_id: &str, rect: Rect, _palette: &str) {
1174        let tid = self.get_texture_id(texture_id);
1175        self.fill_rect_with_mode(rect, [1.0, 1.0, 1.0, 1.0], 12, tid);
1176    }
1177
1178    fn draw_mesh(&mut self, mesh: &Mesh, color: [f32; 4], transform: glam::Mat4) {
1179        let base_idx = self.vertices.len() as u32;
1180
1181        for i in 0..mesh.vertices.len() {
1182            let pos = transform.transform_point3(glam::Vec3::from(mesh.vertices[i]));
1183            let norm = transform.transform_vector3(glam::Vec3::from(mesh.normals[i]));
1184
1185            self.vertices.push(Vertex {
1186                position: pos.to_array(),
1187                normal: norm.to_array(),
1188                uv: [0.0, 0.0],
1189                color,
1190                material_id: 13, // Material 13: 3D Surface
1191                radius: 0.0,
1192                slice: [0.0, 0.0, 0.0, 1.0],
1193                logical: [0.0, 0.0],
1194                size: [0.0, 0.0],
1195                clip: [-f32::INFINITY, -f32::INFINITY, f32::INFINITY, f32::INFINITY],
1196                tex_index: 0,
1197            });
1198        }
1199
1200        for idx in &mesh.indices {
1201            self.indices.push(base_idx + idx);
1202        }
1203
1204        let (translation, scale_transform, rotation, _, _) = self.current_transform();
1205
1206        if self.draw_calls.is_empty() || self.current_texture_id.is_some() {
1207            self.current_texture_id = None;
1208
1209            self.instance_data.push(InstanceData {
1210                translation,
1211                scale: scale_transform,
1212                rotation,
1213                blur_radius: 0.0,
1214                ior_override: 0.0,
1215                glass_intensity: 1.0,
1216            });
1217            self.draw_calls.push(DrawCall {
1218                target_id: None,
1219                texture_id: None,
1220                scissor_rect: self.clip_stack.last().copied(),
1221                index_start: (self.indices.len() as u32) - (mesh.indices.len() as u32),
1222                index_count: mesh.indices.len() as u32,
1223                instance_count: 1,
1224                material: cvkg_core::DrawMaterial::Opaque,
1225                instance_start: (self.instance_data.len() - 1) as u32,
1226                draw_order: 0,
1227            });
1228        } else {
1229            self.draw_calls.last_mut().unwrap().index_count += mesh.indices.len() as u32;
1230        }
1231    }
1232
1233    fn draw_mesh_3d(
1234        &mut self,
1235        mesh: &Mesh,
1236        material: &cvkg_core::Material3D,
1237        transform: &cvkg_core::Transform3D,
1238    ) {
1239        let base_idx = self.vertices.len() as u32;
1240        let model_matrix = transform.to_matrix();
1241
1242        for i in 0..mesh.vertices.len() {
1243            let pos = model_matrix.transform_point3(glam::Vec3::from(mesh.vertices[i]));
1244            let norm = model_matrix.transform_vector3(glam::Vec3::from(mesh.normals[i]));
1245
1246            self.vertices.push(Vertex {
1247                position: [pos.x, pos.y, pos.z],
1248                normal: [norm.x, norm.y, norm.z],
1249                uv: [0.0, 0.0],
1250                color: material.base_color,
1251                material_id: 13, // Material 13: 3D Surface
1252                radius: 0.0,
1253                slice: [material.metallic, material.roughness, material.opacity, 1.0],
1254                logical: [0.0, 0.0],
1255                size: [0.0, 0.0],
1256                clip: [-f32::INFINITY, -f32::INFINITY, f32::INFINITY, f32::INFINITY],
1257                tex_index: 0,
1258            });
1259        }
1260
1261        for idx in &mesh.indices {
1262            self.indices.push(base_idx + idx);
1263        }
1264
1265        self.instance_data.push(InstanceData {
1266            translation: [0.0, 0.0],
1267            scale: [1.0, 1.0],
1268            rotation: 0.0,
1269            blur_radius: 0.0,
1270            ior_override: 0.0,
1271            glass_intensity: 1.0,
1272        });
1273
1274        self.draw_calls.push(DrawCall {
1275            target_id: None,
1276            texture_id: None,
1277            scissor_rect: self.clip_stack.last().copied(),
1278            index_start: (self.indices.len() as u32) - (mesh.indices.len() as u32),
1279            index_count: mesh.indices.len() as u32,
1280            instance_count: 1,
1281            material: cvkg_core::DrawMaterial::Opaque,
1282            instance_start: (self.instance_data.len() - 1) as u32,
1283            draw_order: 0,
1284        });
1285    }
1286
1287    fn set_camera_3d(&mut self, camera: &cvkg_core::Camera3D) {
1288        self.current_scene.proj = camera.projection_matrix();
1289        self.current_scene.view = camera.view_matrix();
1290    }
1291
1292    fn push_transform_3d(&mut self, transform: &cvkg_core::Transform3D) {
1293        // Push a 2D-compatible transform for the existing pipeline
1294        // Use proper matrix decomposition to extract scale correctly (handles rotated matrices)
1295        let (translation, rotation_quat, scale_glam) =
1296            transform.to_matrix().to_scale_rotation_translation();
1297        let translation = [translation.x, translation.y];
1298        let scale = [scale_glam.x, scale_glam.y];
1299        let rotation = if rotation_quat.length_squared() > 0.0 {
1300            let (axis, angle) = rotation_quat.to_axis_angle();
1301            angle * axis.z.signum() // Radians (preserving Z-axis direction)
1302        } else {
1303            0.0
1304        };
1305        self.push_transform(translation, scale, rotation);
1306    }
1307
1308    fn pop_transform_3d(&mut self) {
1309        // Only pop the single transform that was pushed - no double pop
1310        self.pop_transform();
1311    }
1312
1313    /// Render a 3D scene graph node using the GPU backend.
1314    ///
1315    /// # Contract
1316    /// PBR lighting and opacity are computed using base color, metallic (0.0), and roughness (0.5)
1317    /// to support standard matte opaque 3D meshes.
1318    fn render_scene_node_3d(
1319        &mut self,
1320        position: [f32; 3],
1321        rotation: [f32; 4],
1322        scale: [f32; 3],
1323        color: [f32; 4],
1324        meshes: &[Mesh],
1325    ) {
1326        let transform = cvkg_core::Transform3D {
1327            position: glam::Vec3::from(position),
1328            rotation: glam::Quat::from_xyzw(rotation[0], rotation[1], rotation[2], rotation[3]),
1329            scale: glam::Vec3::from(scale),
1330        };
1331        // Use provided mesh or generate a default unit cube
1332        if meshes.is_empty() {
1333            // Generate a unit cube mesh on the stack
1334            let h = 0.5f32;
1335            let cube = Mesh {
1336                vertices: vec![
1337                    [-h, -h, -h],
1338                    [h, -h, -h],
1339                    [h, h, -h],
1340                    [-h, h, -h],
1341                    [-h, -h, h],
1342                    [h, -h, h],
1343                    [h, h, h],
1344                    [-h, h, h],
1345                ],
1346                normals: vec![
1347                    [0.0, 0.0, -1.0],
1348                    [0.0, 0.0, -1.0],
1349                    [0.0, 0.0, -1.0],
1350                    [0.0, 0.0, -1.0],
1351                    [0.0, 0.0, 1.0],
1352                    [0.0, 0.0, 1.0],
1353                    [0.0, 0.0, 1.0],
1354                    [0.0, 0.0, 1.0],
1355                    [0.0, -1.0, 0.0],
1356                    [0.0, -1.0, 0.0],
1357                    [0.0, -1.0, 0.0],
1358                    [0.0, -1.0, 0.0],
1359                    [1.0, 0.0, 0.0],
1360                    [1.0, 0.0, 0.0],
1361                    [1.0, 0.0, 0.0],
1362                    [1.0, 0.0, 0.0],
1363                    [0.0, 1.0, 0.0],
1364                    [0.0, 1.0, 0.0],
1365                    [0.0, 1.0, 0.0],
1366                    [0.0, 1.0, 0.0],
1367                    [-1.0, 0.0, 0.0],
1368                    [-1.0, 0.0, 0.0],
1369                    [-1.0, 0.0, 0.0],
1370                    [-1.0, 0.0, 0.0],
1371                ],
1372                indices: vec![
1373                    0, 1, 2, 0, 2, 3, // front
1374                    5, 4, 7, 5, 7, 6, // back
1375                    4, 0, 3, 4, 3, 7, // left
1376                    1, 5, 6, 1, 6, 2, // right
1377                    3, 2, 6, 3, 6, 7, // top
1378                    4, 5, 1, 4, 1, 0, // bottom
1379                ],
1380            };
1381            let material = cvkg_core::Material3D {
1382                base_color: color,
1383                metallic: 0.0,
1384                roughness: 0.5,
1385                emissive: [0.0, 0.0, 0.0],
1386                opacity: color[3],
1387            };
1388            self.draw_mesh_3d(&cube, &material, &transform);
1389        } else {
1390            let material = cvkg_core::Material3D {
1391                base_color: color,
1392                metallic: 0.0,
1393                roughness: 0.5,
1394                emissive: [0.0, 0.0, 0.0],
1395                opacity: color[3],
1396            };
1397            self.draw_mesh_3d(&meshes[0], &material, &transform);
1398        }
1399    }
1400
1401    fn register_shared_element(&mut self, id: &str, rect: Rect) {
1402        self.shared_elements.put(id.to_string(), rect);
1403    }
1404
1405    fn set_z_index(&mut self, z: f32) {
1406        self.current_z = z;
1407    }
1408
1409    fn set_material(&mut self, material: cvkg_core::DrawMaterial) {
1410        self.current_draw_material = material;
1411    }
1412
1413    fn current_material(&self) -> cvkg_core::DrawMaterial {
1414        self.current_draw_material
1415    }
1416
1417    fn get_z_index(&self) -> f32 {
1418        self.current_z
1419    }
1420
1421    fn request_redraw(&mut self) {
1422        self.redraw_requested = true;
1423    }
1424
1425    // -- Portal / PhaseGate rendering -----------------------------------------
1426
1427    /// Begin rendering into the portal root layer instead of the inline tree.
1428    /// All draw calls between `enter_portal` and `exit_portal` are collected
1429    /// into a separate buffer that is composited AFTER the main tree.
1430    ///
1431    /// WHY separate buffer: The main tree may have clipping, transforms, or
1432    /// opacity that should NOT affect overlays. The portal layer renders on top
1433    /// of everything, ignoring the local coordinate system.
1434    ///
1435    /// `z_index` controls the layer ordering for portal content.
1436    fn enter_portal(&mut self, z_index: i32) {
1437        // Portal rendering enables per-element backdrop blur for Tahoe glass
1438        // When z_index is 0, we're rendering normal glass cards
1439        // When z_index > 0, we're in a portal layer that will get special treatment
1440        self.current_z = z_index as f32;
1441    }
1442
1443    /// Exit the portal layer and return to inline rendering.
1444    /// The portal content collected since `enter_portal` is now sealed --
1445    /// no more draw calls will be appended to it.
1446    fn exit_portal(&mut self) {
1447        self.current_z = 0.0;
1448    }
1449
1450    fn push_vnode(&mut self, rect: Rect, name: &'static str) {
1451        self.vnode_stack.push((rect, name));
1452    }
1453
1454    fn pop_vnode(&mut self) {
1455        self.vnode_stack.pop();
1456    }
1457
1458    fn register_handler(
1459        &mut self,
1460        event_type: &str,
1461        handler: std::sync::Arc<dyn Fn(cvkg_core::Event) + Send + Sync>,
1462    ) {
1463        self.event_handlers
1464            .entry(event_type.to_string())
1465            .or_insert_with(Vec::new)
1466            .push(handler);
1467    }
1468
1469    fn load_svg(&mut self, name: &str, svg_data: &[u8]) {
1470        SurtrRenderer::load_svg(self, name, svg_data);
1471    }
1472
1473    fn draw_svg(&mut self, name: &str, rect: Rect) {
1474        SurtrRenderer::draw_svg(self, name, rect, None, 0);
1475    }
1476    fn draw_svg_with_offset(&mut self, name: &str, rect: Rect, animation_time_offset: f32) {
1477        SurtrRenderer::draw_svg_with_offset(self, name, rect, None, 0, animation_time_offset);
1478    }
1479
1480    /// Draw SVG content with explicit draw_order for z-sorting within the same pass.
1481    /// Use draw_order=200 for SVG content that should render above UI chrome (draw_order=0).
1482    fn draw_svg_with_order(&mut self, name: &str, rect: Rect, draw_order: i32) {
1483        SurtrRenderer::draw_svg_with_order(self, name, rect, None, 0, 0.0, draw_order);
1484    }
1485
1486    fn serialize_svg(&mut self, name: &str) -> Result<String, String> {
1487        let tree = self
1488            .svg
1489            .tree_cache
1490            .get(name)
1491            .ok_or_else(|| format!("SVG '{}' not found", name))?;
1492        let config = cvkg_svg_serialize::SerializerConfig::default();
1493        let mut serializer = cvkg_svg_serialize::SvgSerializer::with_config(config);
1494        serializer
1495            .serialize(tree)
1496            .map_err(|e| format!("SVG serialization failed: {}", e))
1497    }
1498
1499    fn apply_svg_filter(
1500        &mut self,
1501        name: &str,
1502        filter_id: &str,
1503        _region: Rect,
1504    ) -> Result<String, String> {
1505        let tree = self
1506            .svg
1507            .tree_cache
1508            .get(name)
1509            .ok_or_else(|| format!("SVG '{}' not found", name))?;
1510        let _filter = Self::find_filter(tree, filter_id)
1511            .ok_or_else(|| format!("Filter '{}' not found in SVG '{}'", filter_id, name))?;
1512        let config = cvkg_svg_serialize::SerializerConfig::default();
1513        let mut serializer = cvkg_svg_serialize::SvgSerializer::with_config(config);
1514        serializer
1515            .serialize(tree)
1516            .map_err(|e| format!("SVG filter serialization failed: {}", e))
1517    }
1518
1519    /// Phase 2.1: text shaping cache lookup.
1520    /// Uses text.shaped_cache which stores Arc<ShapedText>.
1521    fn measure_text(&mut self, text: &str, size: f32) -> (f32, f32) {
1522        let cache_key = (text.to_string(), (size * 100.0) as u32);
1523        if let Some(shaped) = self.text.shaped_cache.get(&cache_key) {
1524            return (shaped.width, shaped.height);
1525        }
1526        // Fall back to shape_rich_text (handles color/style variations).
1527        let style = cvkg_runic_text::TextStyle::new("Inter", size);
1528        let spans = [cvkg_runic_text::TextSpan::new(text, style)];
1529        if let Some(shaped) = self.shape_rich_text(
1530            &spans,
1531            None,
1532            cvkg_runic_text::TextAlign::Start,
1533            cvkg_runic_text::TextOverflow::Visible,
1534        ) {
1535            let shaped = std::sync::Arc::new(shaped);
1536            let result = (shaped.width, shaped.height);
1537            self.text.shaped_cache.put(cache_key, shaped);
1538            result
1539        } else {
1540            (0.0, 0.0)
1541        }
1542    }
1543
1544    /// Phase 2.2: Override draw_text to use the shaped text cache.
1545    /// The default trait implementation calls shape_rich_text every frame.
1546    /// This override checks the shaped_cache first, avoiding redundant HarfBuzz shaping.
1547    /// Uses Arc<ShapedText> to avoid cloning glyph data on cache hit.
1548    fn draw_text(&mut self, text: &str, x: f32, y: f32, size: f32, color: [f32; 4]) {
1549        let cache_key = (text.to_string(), (size * 100.0) as u32);
1550        let r = (color[0] * 255.0).clamp(0.0, 255.0) as u8;
1551        let g = (color[1] * 255.0).clamp(0.0, 255.0) as u8;
1552        let b = (color[2] * 255.0).clamp(0.0, 255.0) as u8;
1553        let a = (color[3] * 255.0).clamp(0.0, 255.0) as u8;
1554        // Get Arc clone (atomic increment, no heap allocation) to avoid borrow conflict.
1555        let cached = self.text.shaped_cache.get(&cache_key).cloned();
1556        if let Some(shaped) = cached {
1557            // Check if the cached color matches -- if so, draw directly without modification.
1558            let color_matches = shaped.spans.first()
1559                .map(|s| s.style.color == [r, g, b, a])
1560                .unwrap_or(false);
1561            if color_matches {
1562                self.draw_shaped_text(&shaped, x, y);
1563                return;
1564            }
1565            // Color differs -- we need to clone just the spans to update color.
1566            // The glyphs are shared via Arc, so this is much cheaper than cloning everything.
1567            let mut shaped = (*shaped).clone();
1568            for span in &mut shaped.spans {
1569                span.style.color = [r, g, b, a];
1570            }
1571            self.draw_shaped_text(&shaped, x, y);
1572            return;
1573        }
1574        // Not cached -- shape now and store.
1575        let mut style = cvkg_runic_text::TextStyle::new("Inter", size);
1576        style.color = [r, g, b, a];
1577        let spans = [cvkg_runic_text::TextSpan::new(text, style)];
1578        if let Some(shaped) = self.shape_rich_text(
1579            &spans,
1580            None,
1581            cvkg_runic_text::TextAlign::Start,
1582            cvkg_runic_text::TextOverflow::Visible,
1583        ) {
1584            let shaped = std::sync::Arc::new(shaped);
1585            self.draw_shaped_text(&shaped, x, y);
1586            self.text.shaped_cache.put(cache_key, shaped);
1587        }
1588    }
1589}
1590
1591// ── Inherent methods on SurtrRenderer (not part of the Renderer trait) ──
1592
1593impl SurtrRenderer {
1594    /// Clear all registered event handlers. Call at the start of each frame
1595    /// before re-rendering the component tree.
1596    pub fn clear_event_handlers(&mut self) {
1597        self.event_handlers.clear();
1598    }
1599
1600    /// Phase 2.1: clear the text shaping cache at the start of each frame.
1601    pub fn clear_text_cache(&mut self) {
1602        self.text.shaped_cache.clear();
1603    }
1604
1605    /// Get all registered event handlers for a specific event type.
1606    pub fn get_handlers(
1607        &self,
1608        event_type: &str,
1609    ) -> Option<&Vec<std::sync::Arc<dyn Fn(cvkg_core::Event) + Send + Sync>>> {
1610        self.event_handlers.get(event_type)
1611    }
1612
1613    /// Compute per-vertex transform values from the current matrix.
1614    /// Extracts translation, scale, rotation, and skew from the affine matrix
1615    /// so the existing vertex shader fields still work correctly.
1616    pub(crate) fn current_transform(&self) -> ([f32; 2], [f32; 2], f32, f32, f32) {
1617        // Returns (translation, scale, rotation,
1618        // skew_x, skew_y)
1619        let m = self
1620            .transform_stack
1621            .last()
1622            .copied()
1623            .unwrap_or(glam::Mat3::IDENTITY);
1624        let t = [m.z_axis.x, m.z_axis.y];
1625        // Extract scale and rotation from the 2x2 submatrix
1626        let a = m.x_axis.x;
1627        let b = m.x_axis.y;
1628        let c = m.y_axis.x;
1629        let d = m.y_axis.y;
1630        let sx = (a * a + b * b).sqrt();
1631        let sy = (c * c + d * d).sqrt();
1632        let rotation = b.atan2(a);
1633        // Skew: the angle between the basis vectors minus 90 degrees
1634        let skew_x = (a * c + b * d) / (sx * sy); // sin(skew)
1635        (t, [sx, sy], rotation, skew_x, 0.0)
1636    }
1637
1638    pub fn stroke_path(&mut self, path: &lyon::path::Path, color: [f32; 4], stroke_width: f32) {
1639        let c = self.apply_opacity(color);
1640        let base_vertex_idx = self.vertices.len() as u32;
1641        let base_index_idx = self.indices.len() as u32;
1642        // Compute a stable hash for this path + stroke width for cache lookup.
1643        // We use the path's element count and bounding box as a lightweight identity.
1644        // Note: This is a heuristic — paths with identical element counts and bounds
1645        // will share a cache entry, which is acceptable for our use case (static paths).
1646        let path_hash = {
1647            let mut h = std::collections::hash_map::DefaultHasher::new();
1648            // Hash the number of elements in the path
1649            let num_elements = path.iter().count();
1650            std::hash::Hash::hash(&num_elements, &mut h);
1651            // Hash the stroke width for exact matching
1652            std::hash::Hash::hash(&stroke_width.to_bits(), &mut h);
1653            h.finish()
1654        };
1655
1656        // Check cache — if we have tessellated geometry for this path+width, reuse it.
1657        let (vert_count, idx_count) = match self.path_geometry_cache.get(&path_hash) {
1658            Some((cached_verts, cached_indices)) => {
1659                // Cache hit — copy tessellated geometry directly.
1660                self.vertices.extend_from_slice(cached_verts);
1661                for idx in cached_indices {
1662                    self.indices.push(base_vertex_idx + *idx);
1663                }
1664                (cached_verts.len(), cached_indices.len())
1665            }
1666            None => {
1667                // Cache miss — tessellate and store in cache.
1668                let mut tessellator = StrokeTessellator::new();
1669                let mut buffers: VertexBuffers<Vertex, u32> = VertexBuffers::new();
1670                let result = tessellator.tessellate_path(
1671                    path,
1672                    &StrokeOptions::default().with_line_width(stroke_width),
1673                    &mut BuffersBuilder::new(
1674                        &mut buffers,
1675                        CustomStrokeVertexConstructor {
1676                            color: c,
1677                            clip: [0.0, 0.0, 0.0, 0.0],
1678                            path_length: 1.0,
1679                        },
1680                    ),
1681                );
1682                if let Err(e) = result {
1683                    log::warn!("Failed to tessellate stroke path: {:?}", e);
1684                    return;
1685                }
1686                let vert_count = buffers.vertices.len();
1687                let idx_count = buffers.indices.len();
1688                // Store in cache before extending (cache owns a copy).
1689                let cached_verts = buffers.vertices.clone();
1690                let cached_indices = buffers.indices.clone();
1691                self.path_geometry_cache
1692                    .put(path_hash, (cached_verts, cached_indices));
1693                // Now extend the live buffers.
1694                self.vertices.extend(buffers.vertices);
1695                for idx in &buffers.indices {
1696                    self.indices.push(base_vertex_idx + *idx);
1697                }
1698                (vert_count, idx_count)
1699            }
1700        };
1701
1702        let material = self.current_material();
1703        let tid = self.get_texture_id("__mega_heim");
1704
1705        if self.draw_calls.last().is_none()
1706            || self.current_texture_id != tid
1707            || self.draw_calls.last().unwrap().scissor_rect != self.clip_stack.last().copied()
1708            || self.draw_calls.last().unwrap().material != material
1709        {
1710            self.current_texture_id = tid;
1711            let (translation, scale, rotation, _, _) = self.current_transform();
1712            self.instance_data.push(InstanceData {
1713                translation,
1714                scale,
1715                rotation,
1716                blur_radius: 0.0,
1717                ior_override: 0.0,
1718                glass_intensity: 1.0,
1719            });
1720            self.draw_calls.push(DrawCall {
1721                target_id: None,
1722                texture_id: tid,
1723                scissor_rect: self.clip_stack.last().copied(),
1724                index_start: base_index_idx,
1725                index_count: idx_count as u32,
1726                instance_count: 1,
1727                material,
1728                instance_start: (self.instance_data.len() - 1) as u32,
1729                draw_order: 0,
1730            });
1731        } else {
1732            // Merge into the current draw call.
1733            if let Some(last) = self.draw_calls.last_mut() {
1734                last.index_count += idx_count as u32;
1735            }
1736        }
1737    }
1738}
1739
1740impl cvkg_core::FrameRenderer<wgpu::CommandEncoder> for SurtrRenderer {
1741    fn begin_frame(&mut self) -> wgpu::CommandEncoder {
1742        cvkg_core::begin_render_phase();
1743        self.frame_rendered = false;
1744        self.app_drew_background = false;
1745        let id = self
1746            .current_window
1747            .expect("No target window set for frame. Call set_target_window first.");
1748        self.begin_frame(id)
1749    }
1750
1751    fn render_frame(&mut self) {
1752        // Visual Lint: If layout was dirtied during the render phase (layout thrashing),
1753        // draw a 10px red border as a warning flash.
1754        if LAYOUT_DIRTY.swap(false, Ordering::AcqRel) {
1755            if let Some(window_id) = self.current_window {
1756                if let Some(surface_ctx) = self.surfaces.get(&window_id) {
1757                    let w = surface_ctx.config.width as f32;
1758                    let h = surface_ctx.config.height as f32;
1759                    let border_rect = cvkg_core::Rect {
1760                        x: 0.0,
1761                        y: 0.0,
1762                        width: w,
1763                        height: h,
1764                    };
1765                    // Draw a thick red border to signal layout-thrashing
1766                    self.stroke_rect(border_rect, [1.0, 0.0, 0.0, 1.0], 10.0);
1767                }
1768            }
1769        }
1770
1771        // Dynamic Buffer Growth (Up to 4x capacity)
1772        // P1-1: growth logic moved into GeometryBuffers methods.
1773        // The old code reallocated the buffer here, which is
1774        // expensive on mobile. The new methods return false if
1775        // no growth is needed, avoiding the create_buffer call.
1776        let max_v_capacity = MAX_VERTICES * 4;
1777        let grown = self.geometry_buffers.grow_vertex_buffer(
1778            &self.device,
1779            self.vertices.len(),
1780            max_v_capacity,
1781        );
1782        if grown {
1783            log::info!("Grew vertex buffer to fit {} vertices", self.vertices.len());
1784        }
1785        if self.vertices.len() > max_v_capacity {
1786            log::error!("Exceeded dynamic vertex buffer max capacity! Capping geometry.");
1787            self.vertices.truncate(max_v_capacity);
1788        }
1789
1790        let max_i_capacity = MAX_INDICES * 4;
1791        let grown = self.geometry_buffers.grow_index_buffer(
1792            &self.device,
1793            self.indices.len(),
1794            max_i_capacity,
1795        );
1796        if grown {
1797            log::info!("Grew index buffer to fit {} indices", self.indices.len());
1798        }
1799        if self.indices.len() > max_i_capacity {
1800            log::error!("Exceeded dynamic index buffer max capacity! Capping geometry.");
1801            self.indices.truncate(max_i_capacity);
1802        }
1803
1804        // Forge Submission: Sync all geometry to GPU using StagingBelt with a dedicated encoder
1805        let mut staging_encoder =
1806            self.device
1807                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1808                    label: Some("Surtr Staging Encoder"),
1809                });
1810
1811        let mut has_writes = false;
1812
1813        if !self.vertices.is_empty() {
1814            let v_bytes = bytemuck::cast_slice(&self.vertices);
1815            self.staging_belt
1816                .write_buffer(
1817                    &mut staging_encoder,
1818                    &self.geometry_buffers.vertex_buffer,
1819                    0,
1820                    wgpu::BufferSize::new(v_bytes.len() as u64).unwrap(),
1821                )
1822                .copy_from_slice(v_bytes);
1823            has_writes = true;
1824        }
1825
1826        if !self.indices.is_empty() {
1827            let i_bytes = bytemuck::cast_slice(&self.indices);
1828            self.staging_belt
1829                .write_buffer(
1830                    &mut staging_encoder,
1831                    &self.geometry_buffers.index_buffer,
1832                    0,
1833                    wgpu::BufferSize::new(i_bytes.len() as u64).unwrap(),
1834                )
1835                .copy_from_slice(i_bytes);
1836            has_writes = true;
1837        }
1838
1839        if !self.instance_data.is_empty() {
1840            let inst_bytes = bytemuck::cast_slice(&self.instance_data);
1841            self.staging_belt
1842                .write_buffer(
1843                    &mut staging_encoder,
1844                    &self.geometry_buffers.instance_buffer,
1845                    0,
1846                    wgpu::BufferSize::new(inst_bytes.len() as u64).unwrap(),
1847                )
1848                .copy_from_slice(inst_bytes);
1849            has_writes = true;
1850        }
1851
1852        if has_writes {
1853            self.staging_belt.finish();
1854            self.staging_command_buffers.push(staging_encoder.finish());
1855        }
1856
1857        // Update Time & Uniforms (Direct write is fine for small uniforms)
1858        self.current_scene.time = self.start_time.elapsed().as_secs_f32();
1859        self.queue.write_buffer(
1860            &self.scene_buffer,
1861            0,
1862            bytemuck::bytes_of(&self.current_scene),
1863        );
1864        self.queue.write_buffer(
1865            &self.theme_buffer,
1866            0,
1867            bytemuck::bytes_of(&self.current_theme),
1868        );
1869
1870        // Populate telemetry for this frame
1871        self.telemetry.draw_calls = self.draw_calls.len() as u32;
1872        self.telemetry.vertices = self.vertices.len() as u32;
1873        self.frame_rendered = true;
1874
1875        // Performance diagnostic: log draw call and command buffer count
1876        log::debug!(
1877            "[Perf] draw_calls={} vertices={} instances={} staging_cmds={}",
1878            self.draw_calls.len(),
1879            self.vertices.len(),
1880            self.instance_data.len(),
1881            self.staging_command_buffers.len()
1882        );
1883    }
1884
1885    fn end_frame(&mut self, encoder: wgpu::CommandEncoder) {
1886        // Delegate to the inherent end_frame which runs the render graph
1887        SurtrRenderer::end_frame(self, encoder);
1888        cvkg_core::end_render_phase();
1889    }
1890}
1891
1892fn glyph_image_to_rgba(image: cvkg_runic_text::GlyphImage) -> (Vec<u8>, u32, u32) {
1893    let width = image.width;
1894    let height = image.height;
1895    let pixels = width.saturating_mul(height) as usize;
1896
1897    if pixels == 0 || image.data.is_empty() {
1898        return (Vec::new(), width, height);
1899    }
1900
1901    let (bytes_per_pixel, remainder) = (image.data.len() / pixels, image.data.len() % pixels);
1902    if remainder != 0 {
1903        log::warn!(
1904            "Glyph rasterizer returned {} bytes for {}x{} glyph; expected whole pixels ({} bytes per pixel)",
1905            image.data.len(),
1906            width,
1907            height,
1908            bytes_per_pixel
1909        );
1910        return (Vec::new(), width, height);
1911    }
1912
1913    let rgba_data = match bytes_per_pixel {
1914        1 => {
1915            let mut data = Vec::with_capacity(pixels * 4);
1916            for alpha in &image.data {
1917                data.push(255);
1918                data.push(255);
1919                data.push(255);
1920                data.push(*alpha);
1921            }
1922            data
1923        }
1924        3 => {
1925            let mut data = Vec::with_capacity(pixels * 4);
1926            for rgb in image.data.chunks_exact(3) {
1927                let alpha = rgb.iter().copied().max().unwrap_or(0);
1928                data.push(255);
1929                data.push(255);
1930                data.push(255);
1931                data.push(alpha);
1932            }
1933            data
1934        }
1935        4 => {
1936            let mut data = image.data;
1937            for chunk in data.chunks_exact_mut(4) {
1938                // If it's a SubpixelMask, swash sets A=0. We need to reconstruct an alpha
1939                // so the shader doesn't discard it.
1940                if chunk[3] == 0 && (chunk[0] > 0 || chunk[1] > 0 || chunk[2] > 0) {
1941                    chunk[3] = chunk[0].max(chunk[1]).max(chunk[2]);
1942                }
1943            }
1944            data
1945        }
1946        _ => {
1947            log::warn!(
1948                "Glyph rasterizer returned unsupported {} bytes per pixel for {}x{} glyph ({} bytes total)",
1949                bytes_per_pixel,
1950                width,
1951                height,
1952                image.data.len()
1953            );
1954            Vec::new()
1955        }
1956    };
1957
1958    (rgba_data, width, height)
1959}
1960
1961#[cfg(test)]
1962mod tests {
1963    use super::glyph_image_to_rgba;
1964
1965    #[test]
1966    fn glyph_image_to_rgba_keeps_rgba_color_data() {
1967        let image = cvkg_runic_text::GlyphImage {
1968            glyph_id: 1,
1969            width: 2,
1970            height: 1,
1971            data: vec![1, 2, 3, 4, 5, 6, 7, 8],
1972            x_offset: 0.0,
1973            y_offset: 0.0,
1974            cache_key: 42,
1975        };
1976
1977        assert_eq!(
1978            glyph_image_to_rgba(image),
1979            (vec![1, 2, 3, 4, 5, 6, 7, 8], 2, 1)
1980        );
1981    }
1982
1983    #[test]
1984    fn glyph_image_to_rgba_expands_grayscale_alpha() {
1985        let image = cvkg_runic_text::GlyphImage {
1986            glyph_id: 1,
1987            width: 3,
1988            height: 1,
1989            data: vec![0, 128, 255],
1990            x_offset: 0.0,
1991            y_offset: 0.0,
1992            cache_key: 42,
1993        };
1994
1995        assert_eq!(
1996            glyph_image_to_rgba(image),
1997            (
1998                vec![255, 255, 255, 0, 255, 255, 255, 128, 255, 255, 255, 255],
1999                3,
2000                1
2001            )
2002        );
2003    }
2004
2005    #[test]
2006    fn glyph_image_to_rgba_collapses_subpixel_rgb_to_alpha() {
2007        let image = cvkg_runic_text::GlyphImage {
2008            glyph_id: 1,
2009            width: 2,
2010            height: 1,
2011            data: vec![0, 128, 255, 255, 0, 64],
2012            x_offset: 0.0,
2013            y_offset: 0.0,
2014            cache_key: 42,
2015        };
2016
2017        assert_eq!(
2018            glyph_image_to_rgba(image),
2019            (vec![255, 255, 255, 255, 255, 255, 255, 255], 2, 1)
2020        );
2021    }
2022}