Skip to main content

cvkg_render_gpu/
api.rs

1//! Bridging the internal renderer to `cvkg-core` traits.
2use crate::renderer::SurtrRenderer;
3use crate::types::*;
4use crate::vertex::*;
5use cvkg_core::LAYOUT_DIRTY;
6use cvkg_core::{ColorTheme, Mesh, Rect, Renderer};
7use lyon::math::point;
8use lyon::tessellation::{BuffersBuilder, StrokeOptions, StrokeTessellator, VertexBuffers};
9use std::sync::atomic::Ordering;
10
11impl cvkg_core::ElapsedTime for SurtrRenderer {
12    fn delta_time(&self) -> f32 {
13        self.current_scene.delta_time
14    }
15
16    fn elapsed_time(&self) -> f32 {
17        self.start_time.elapsed().as_secs_f32()
18    }
19}
20
21impl cvkg_core::Renderer for SurtrRenderer {
22    fn is_over_budget(&self) -> bool {
23        self.frame_budget.allow_degradation
24            && self.last_frame_start.elapsed().as_secs_f32() * 1000.0 > self.frame_budget.target_ms
25    }
26
27    fn prewarm_vram(&mut self, assets: Vec<(String, Vec<u8>)>) {
28        log::info!(
29            "[Surtr] Pre-warming Mega-Heim with {} assets...",
30            assets.len()
31        );
32        for (name, data) in assets {
33            self.load_image_to_heim(&name, &data);
34        }
35    }
36
37    fn fill_rect(&mut self, rect: Rect, color: [f32; 4]) {
38        self.fill_rect_with_mode(rect, self.apply_opacity(color), 0, None);
39    }
40
41    fn fill_rounded_rect(&mut self, rect: Rect, radius: f32, color: [f32; 4]) {
42        self.fill_rect_with_full_params(
43            rect,
44            self.apply_opacity(color),
45            3,
46            None,
47            radius,
48            Rect {
49                x: 0.0,
50                y: 0.0,
51                width: 1.0,
52                height: 1.0,
53            },
54        );
55    }
56
57    /// Fill a rounded rect with glass material for frosted backdrop effect.
58    /// This is the proper way to render glass cards that need macOS Tahoe-style blur.
59    /// The blur_radius controls the intensity of the backdrop blur.
60    /// For Tahoe parity, this registers the rect as a portal region for
61    /// per-element isolated backdrop blur when z_index != 0.
62    fn fill_glass_rect(&mut self, rect: Rect, radius: f32, blur_radius: f32) {
63        // Store blur radius for use during glass pass - the renderer will apply
64        // this to the Kawase blur uniform during the backdrop blur phase
65        let blur_strength = (blur_radius / 100.0).clamp(0.0, 4.0);
66
67        // Register for portal-aware per-element backdrop blur (Tahoe feature)
68        // When current_z != 0, this element is in a portal layer
69        if self.current_z != 0.0 {
70            self.portal_regions.push_back(rect);
71        }
72
73        // Non-trivial algorithm: Temporary Material Override Binding
74        // WHY: The underlying fill_rect_with_full_params method query-routes geometry attributes
75        // from self.current_draw_material. In immediate-mode rendering, we must bind the Glass material
76        // temporarily so that the instance generator receives the requested blur_radius and IOR override.
77        // CONTRACT: Restores self.current_draw_material to its original value after the draw call completes.
78        let prev_material = self.current_draw_material;
79        self.current_draw_material = cvkg_core::DrawMaterial::Glass {
80            blur_radius,
81            ior_override: 0.0,
82        };
83
84        self.fill_rect_with_full_params(
85            rect,
86            [1.0, 1.0, 1.0, 0.4], // Glass tint: white at 40% opacity
87            7,                    // Mode 7 = Glass material
88            None,
89            radius,
90            Rect {
91                x: 0.0,
92                y: 0.0,
93                width: 1.0,
94                height: 1.0,
95            },
96        );
97
98        self.current_draw_material = prev_material;
99    }
100
101    fn fill_ellipse(&mut self, rect: Rect, color: [f32; 4]) {
102        self.fill_rect_with_full_params(
103            rect,
104            self.apply_opacity(color),
105            4,
106            None,
107            0.0,
108            Rect {
109                x: 0.0,
110                y: 0.0,
111                width: 1.0,
112                height: 1.0,
113            },
114        );
115    }
116
117    fn draw_3d_cube(&mut self, rect: Rect, color: [f32; 4], rotation: [f32; 3]) {
118        self.fill_rect_with_full_params_and_slice(
119            rect,
120            self.apply_opacity(color),
121            21,
122            None,
123            0.0,
124            Rect {
125                x: 0.0,
126                y: 0.0,
127                width: 1.0,
128                height: 1.0,
129            },
130            [rotation[0], rotation[1], rotation[2], 0.0],
131            [0.0, 0.0],
132        );
133    }
134
135    fn bifrost(&mut self, rect: Rect, blur: f32, _saturation: f32, opacity: f32) {
136        // Calculate screen-space UVs for high-fidelity global refraction
137        let logical_w = self.current_width() as f32 / self.current_scale_factor();
138        let logical_h = self.current_height() as f32 / self.current_scale_factor();
139        let screen_uv = Rect {
140            x: rect.x / logical_w,
141            y: rect.y / logical_h,
142            width: rect.width / logical_w,
143            height: rect.height / logical_h,
144        };
145        // Use mode 7 for high-fidelity background blur sampling
146        // Use the blur parameter as corner radius for the glass panel
147        self.fill_rect_with_full_params(rect, [1.0, 1.0, 1.0, opacity], 7, None, blur, screen_uv);
148    }
149
150    fn gungnir(&mut self, rect: Rect, color: [f32; 4], radius: f32, intensity: f32) {
151        // Create neon glow effect using additive blending
152        // This renders a glowing aura around the element
153        let center_x = rect.x + rect.width * 0.5;
154        let center_y = rect.y + rect.height * 0.5;
155        let max_dim = rect.width.max(rect.height) * 0.5 + radius;
156
157        // Draw expanding glow layers
158        for i in 0..8 {
159            let alpha = intensity / (i as f32 + 1.0) * 0.3;
160            let glow_color = [color[0], color[1], color[2], alpha];
161            self.fill_rect_with_mode(
162                Rect {
163                    x: center_x - max_dim - i as f32 * 2.0,
164                    y: center_y - max_dim - i as f32 * 2.0,
165                    width: max_dim * 2.0 + i as f32 * 4.0,
166                    height: max_dim * 2.0 + i as f32 * 4.0,
167                },
168                glow_color,
169                8, // Mode for additive blending
170                None,
171            );
172        }
173    }
174
175    /// Renders a dynamic glowing hover boundary field around a hit target.
176    ///
177    /// # Contract
178    /// Expands the bounding box of the visual target by `radius` to establish
179    /// a continuous proximity glow. Uses blending mode 18 (GPU drop shadow/glow)
180    /// to rasterize the glow with specialized radius-to-margin uv coordinate mappings.
181    fn mani_glow(&mut self, rect: Rect, color: [f32; 4], radius: f32) {
182        let margin = radius;
183        let glow_rect = Rect {
184            x: rect.x - margin,
185            y: rect.y - margin,
186            width: rect.width + 2.0 * margin,
187            height: rect.height + 2.0 * margin,
188        };
189        let uv_rect = Rect {
190            x: margin,
191            y: radius,
192            width: 0.0,
193            height: 0.0,
194        };
195        self.fill_rect_with_full_params(
196            glow_rect,
197            self.apply_opacity(color),
198            18,
199            None,
200            8.0,
201            uv_rect,
202        );
203    }
204
205    fn stroke_rect(&mut self, rect: Rect, color: [f32; 4], stroke_width: f32) {
206        let c = self.apply_opacity(color);
207        let hw = stroke_width;
208        // Top, bottom, left, right edge bars
209        self.fill_rect_with_mode(
210            Rect {
211                x: rect.x,
212                y: rect.y,
213                width: rect.width,
214                height: hw,
215            },
216            c,
217            1,
218            None,
219        );
220        self.fill_rect_with_mode(
221            Rect {
222                x: rect.x,
223                y: rect.y + rect.height - hw,
224                width: rect.width,
225                height: hw,
226            },
227            c,
228            1,
229            None,
230        );
231        self.fill_rect_with_mode(
232            Rect {
233                x: rect.x,
234                y: rect.y,
235                width: hw,
236                height: rect.height,
237            },
238            c,
239            1,
240            None,
241        );
242        self.fill_rect_with_mode(
243            Rect {
244                x: rect.x + rect.width - hw,
245                y: rect.y,
246                width: hw,
247                height: rect.height,
248            },
249            c,
250            1,
251            None,
252        );
253    }
254
255    fn stroke_rounded_rect(&mut self, rect: Rect, radius: f32, color: [f32; 4], stroke_width: f32) {
256        self.fill_rect_with_full_params(
257            rect,
258            self.apply_opacity(color),
259            17,
260            None,
261            radius,
262            Rect {
263                x: stroke_width,
264                y: 0.0,
265                width: 0.0,
266                height: 0.0,
267            },
268        );
269    }
270
271    fn stroke_ellipse(&mut self, rect: Rect, color: [f32; 4], stroke_width: f32) {
272        // Tessellate an ellipse stroke using Lyon's StrokeTessellator.
273        let cx = rect.x + rect.width / 2.0;
274        let cy = rect.y + rect.height / 2.0;
275        let rx = rect.width / 2.0;
276        let ry = rect.height / 2.0;
277
278        // Build an ellipse path using Lyon
279        let mut builder = lyon::path::Path::builder();
280        if rx > 0.0 && ry > 0.0 {
281            // Approximate ellipse with 64 segments
282            let segments = 64;
283            for i in 0..segments {
284                let angle = 2.0 * std::f32::consts::PI * (i as f32) / (segments as f32);
285                let x = cx + rx * angle.cos();
286                let y = cy + ry * angle.sin();
287                if i == 0 {
288                    builder.begin(lyon::math::point(x, y));
289                } else {
290                    builder.line_to(lyon::math::point(x, y));
291                }
292            }
293            builder.close();
294        }
295        let path = builder.build();
296        self.stroke_path(&path, color, stroke_width);
297    }
298
299    fn draw_linear_gradient(
300        &mut self,
301        rect: Rect,
302        start_color: [f32; 4],
303        end_color: [f32; 4],
304        angle: f32,
305    ) {
306        self.fill_rect_with_full_params_and_slice(
307            rect,
308            self.apply_opacity(start_color),
309            15,
310            None,
311            0.0,
312            Rect {
313                x: angle,
314                y: 0.0,
315                width: 1.0,
316                height: 1.0,
317            },
318            end_color,
319            [0.0, 0.0],
320        );
321    }
322
323    fn draw_radial_gradient(&mut self, rect: Rect, inner_color: [f32; 4], outer_color: [f32; 4]) {
324        self.fill_rect_with_full_params_and_slice(
325            rect,
326            self.apply_opacity(inner_color),
327            16,
328            None,
329            0.0,
330            Rect {
331                x: 0.0,
332                y: 0.0,
333                width: 1.0,
334                height: 1.0,
335            },
336            outer_color,
337            [0.0, 0.0],
338        );
339    }
340
341    fn draw_drop_shadow(
342        &mut self,
343        rect: Rect,
344        radius: f32,
345        color: [f32; 4],
346        blur: f32,
347        spread: f32,
348    ) {
349        let margin = blur + spread;
350        let inflated = Rect {
351            x: rect.x - margin,
352            y: rect.y - margin,
353            width: rect.width + margin * 2.0,
354            height: rect.height + margin * 2.0,
355        };
356        // uv.x = total margin (for SDF offset), uv.y = blur width (for falloff)
357        self.fill_rect_with_full_params(
358            inflated,
359            self.apply_opacity(color),
360            18,
361            None,
362            radius,
363            Rect {
364                x: margin,
365                y: blur,
366                width: 0.0,
367                height: 0.0,
368            },
369        );
370    }
371
372    fn stroke_dashed_rounded_rect(
373        &mut self,
374        rect: Rect,
375        radius: f32,
376        color: [f32; 4],
377        width: f32,
378        dash: f32,
379        gap: f32,
380    ) {
381        self.fill_rect_with_full_params(
382            rect,
383            self.apply_opacity(color),
384            19,
385            None,
386            radius,
387            Rect {
388                x: width,
389                y: dash,
390                width: gap,
391                height: 0.0,
392            },
393        );
394    }
395
396    fn draw_9slice(
397        &mut self,
398        image_name: &str,
399        rect: Rect,
400        left: f32,
401        top: f32,
402        right: f32,
403        bottom: f32,
404    ) {
405        let c = self.apply_opacity([1.0, 1.0, 1.0, 1.0]);
406        let tid = self.get_texture_id(image_name);
407        self.fill_rect_with_full_params(
408            rect,
409            c,
410            20,
411            tid,
412            bottom,
413            Rect {
414                x: left,
415                y: top,
416                width: right,
417                height: 0.0,
418            },
419        );
420    }
421
422    fn draw_line(
423        &mut self,
424        x1: f32,
425        y1: f32,
426        x2: f32,
427        y2: f32,
428        color: [f32; 4],
429        stroke_width: f32,
430    ) {
431        let dx = x2 - x1;
432        let dy = y2 - y1;
433        let len = (dx * dx + dy * dy).sqrt();
434        if len < 0.001 {
435            return;
436        }
437
438        // Create a proper line path using Lyon for correct tessellation
439        // The stroke_path function will apply the current transform, which handles rotation
440        let mut builder = lyon::path::Path::builder();
441        builder.begin(point(x1, y1));
442        builder.line_to(point(x2, y2));
443        builder.close();
444        let path = builder.build();
445
446        self.stroke_path(&path, color, stroke_width);
447    }
448
449    fn draw_image(&mut self, image_name: &str, rect: Rect) {
450        // Guard: skip if image not loaded — avoids rendering garbage from uninitialized atlas regions
451        if !self.image_uv_registry.contains(image_name) {
452            log::warn!("[Surtr] draw_image: '{}' not loaded, skipping", image_name);
453            return;
454        }
455        let tid = self
456            .get_texture_id(image_name)
457            .or_else(|| self.get_texture_id("__mega_heim"));
458        let uv_rect = self
459            .image_uv_registry
460            .get(image_name)
461            .copied()
462            .unwrap_or(Rect {
463                x: 0.0,
464                y: 0.0,
465                width: 1.0,
466                height: 1.0,
467            });
468        self.fill_rect_with_full_params(rect, [1.0, 1.0, 1.0, 1.0], 2, tid, 0.0, uv_rect);
469    }
470
471    fn draw_text(&mut self, text: &str, x: f32, y: f32, size: f32, color: [f32; 4]) {
472        // High-DPI: Shape and rasterize at the physical scale factor for maximum sharpness.
473        let scaled_size = size * self.current_scale_factor();
474        let shaped = self.shape_text_with_stack(text, scaled_size);
475        let c = self.apply_opacity(color);
476
477        for glyph in shaped.glyphs {
478            let cache_key = glyph.cache_key;
479
480            let (uv_rect, w, h, x_off, y_off) = if let Some(info) = self.text_cache.get(&cache_key)
481            {
482                *info
483            } else {
484                if let Some(image) = self.text_engine.rasterize(cache_key) {
485                    let glyph_id = image.glyph_id;
486                    let data_len = image.data.len();
487                    let gw = image.width;
488                    let gh = image.height;
489                    let x_offset = image.x_offset;
490                    let y_offset = image.y_offset;
491                    let (rgba_data, gw, gh) = glyph_image_to_rgba(image);
492                    if gw == 0 || gh == 0 {
493                        continue;
494                    }
495                    if rgba_data.is_empty() {
496                        log::warn!(
497                            "Glyph rasterizer returned unsupported pixel format for glyph {} ({} bytes, {}x{}), skipping",
498                            glyph_id,
499                            data_len,
500                            gw,
501                            gh
502                        );
503                        continue;
504                    }
505
506                    let pack_res = self.heim_packer.pack(gw, gh);
507                    let (nx, ny) = if let Some(pos) = pack_res {
508                        pos
509                    } else {
510                        // RECLAIM & RETRY: Heim is full, quench the forge and try again.
511                        self.reclaim_vram();
512                        match self.heim_packer.pack(gw, gh) {
513                            Some(pos) => pos,
514                            None => {
515                                log::error!(
516                                    "Glyph heim critically full after reclaim: cannot pack {}x{} glyph for '{}', skipping",
517                                    gw,
518                                    gh,
519                                    text
520                                );
521                                continue; // Skip this glyph rather than corrupting atlas origin
522                            }
523                        }
524                    };
525
526                    log::info!("Rasterized glyph {}, gw: {}, gh: {}, data len: {}, first 20 bytes: {:?}", glyph_id, gw, gh, rgba_data.len(), &rgba_data[0..std::cmp::min(rgba_data.len(), 20)]);
527
528                    self.queue.write_texture(
529                        wgpu::TexelCopyTextureInfo {
530                            texture: &self.mega_heim_tex,
531                            mip_level: 0,
532                            origin: wgpu::Origin3d { x: nx, y: ny, z: 0 },
533                            aspect: wgpu::TextureAspect::All,
534                        },
535                        &rgba_data,
536                        wgpu::TexelCopyBufferLayout {
537                            offset: 0,
538                            bytes_per_row: Some(gw * 4),
539                            rows_per_image: Some(gh),
540                        },
541                        wgpu::Extent3d {
542                            width: gw,
543                            height: gh,
544                            depth_or_array_layers: 1,
545                        },
546                    );
547
548                    let tex_w = self.mega_heim_tex.width() as f32;
549                    let tex_h = self.mega_heim_tex.height() as f32;
550                    let info = (
551                        Rect {
552                            x: nx as f32 / tex_w,
553                            y: ny as f32 / tex_h,
554                            width: gw as f32 / tex_w,
555                            height: gh as f32 / tex_h,
556                        },
557                        gw as f32,
558                        gh as f32,
559                        x_offset,
560                        y_offset,
561                    );
562                    self.text_cache.put(cache_key, info);
563                    info
564                } else {
565                    (Rect::zero(), 0.0, 0.0, 0.0, 0.0)
566                }
567            };
568
569            if w > 0.0 {
570                // Position glyph relative to baseline.
571                // glyph.x/y are in physical pixels, baseline-relative.
572                // shaped.ascent gives the baseline offset from the text origin (y).
573                let baseline_y = y + shaped.ascent / self.current_scale_factor();
574                let glyph_rect = Rect {
575                    x: x + (glyph.x + x_off) / self.current_scale_factor(),
576                    y: baseline_y + (glyph.y - y_off) / self.current_scale_factor(),
577                    width: w / self.current_scale_factor(),
578                    height: h / self.current_scale_factor(),
579                };
580                let tid = self.get_texture_id("__mega_heim");
581                self.fill_rect_with_full_params(glyph_rect, c, 6, tid, 0.0, uv_rect);
582            }
583        }
584    }
585
586    /// measure_text — Calculates the dimensions of a text string without rendering.
587    fn measure_text(&mut self, text: &str, size: f32) -> (f32, f32) {
588        let sf = self.current_scale_factor();
589        let shaped = self.shape_text_with_stack(text, size * sf);
590        // Convert physical pixels back to logical units
591        (shaped.width / sf, shaped.height / sf)
592    }
593
594    fn shape_rich_text(
595        &mut self,
596        spans: &[cvkg_runic_text::TextSpan],
597        max_width: Option<f32>,
598        align: cvkg_runic_text::TextAlign,
599        overflow: cvkg_runic_text::TextOverflow,
600    ) -> Option<cvkg_runic_text::ShapedText> {
601        let sf = self.current_scale_factor();
602        let mut scaled_spans = spans.to_vec();
603        for span in &mut scaled_spans {
604            span.style.font_size *= sf;
605            if span.style.fallback_families.is_empty() {
606                span.style.fallback_families = vec![
607                    "SF Pro".to_string(),
608                    "Inter".to_string(),
609                    "Helvetica Neue".to_string(),
610                    "Helvetica".to_string(),
611                    "Arial".to_string(),
612                    "sans-serif".to_string(),
613                ];
614            }
615        }
616        let scaled_max_width = max_width.map(|w| w * sf);
617        self.text_engine
618            .shape_layout(&scaled_spans, scaled_max_width, align, overflow)
619            .ok()
620    }
621
622    fn draw_shaped_text(&mut self, shaped: &cvkg_runic_text::ShapedText, x: f32, y: f32) {
623        for glyph in &shaped.glyphs {
624            let byte_idx = shaped
625                .grapheme_boundaries
626                .get(glyph.cluster as usize)
627                .copied()
628                .unwrap_or(0);
629            let mut span_color = [1.0, 1.0, 1.0, 1.0];
630            for span in &shaped.spans {
631                if byte_idx >= span.byte_offset && byte_idx < span.byte_offset + span.text.len() {
632                    span_color = [
633                        span.style.color[0] as f32 / 255.0,
634                        span.style.color[1] as f32 / 255.0,
635                        span.style.color[2] as f32 / 255.0,
636                        span.style.color[3] as f32 / 255.0,
637                    ];
638                    break;
639                }
640            }
641            let c = self.apply_opacity(span_color);
642
643            let cache_key = glyph.cache_key;
644            let (uv_rect, w, h, x_off, y_off) = if let Some(info) = self.text_cache.get(&cache_key)
645            {
646                *info
647            } else {
648                if let Some(image) = self.text_engine.rasterize(cache_key) {
649                    let glyph_id = image.glyph_id;
650                    let data_len = image.data.len();
651                    let gw = image.width;
652                    let gh = image.height;
653                    let x_offset = image.x_offset;
654                    let y_offset = image.y_offset;
655                    let (rgba_data, gw, gh) = glyph_image_to_rgba(image);
656                    if gw == 0 || gh == 0 {
657                        continue;
658                    }
659                    if rgba_data.is_empty() {
660                        log::warn!(
661                            "Glyph rasterizer returned unsupported pixel format for glyph {} ({} bytes, {}x{}), skipping",
662                            glyph_id,
663                            data_len,
664                            gw,
665                            gh
666                        );
667                        continue;
668                    }
669
670                    let pack_res = self.heim_packer.pack(gw, gh);
671                    let (nx, ny) = if let Some(pos) = pack_res {
672                        pos
673                    } else {
674                        self.reclaim_vram();
675                        match self.heim_packer.pack(gw, gh) {
676                            Some(pos) => pos,
677                            None => {
678                                log::error!(
679                                    "Glyph heim critically full after reclaim: cannot pack {}x{} glyph, skipping",
680                                    gw,
681                                    gh
682                                );
683                                continue; // Skip this glyph rather than corrupting atlas origin
684                            }
685                        }
686                    };
687                    // DEBUG: print first few bytes to see if they are white
688                    let sample = &rgba_data[0..std::cmp::min(rgba_data.len(), 20)];
689                    log::info!("Rasterized glyph {}, gw: {}, gh: {}, data len: {}, first 20 bytes: {:?}", glyph_id, gw, gh, rgba_data.len(), sample);
690                    
691                    self.queue.write_texture(
692                        wgpu::TexelCopyTextureInfo {
693                            texture: &self.mega_heim_tex,
694                            mip_level: 0,
695                            origin: wgpu::Origin3d { x: nx, y: ny, z: 0 },
696                            aspect: wgpu::TextureAspect::All,
697                        },
698                        &rgba_data,
699                        wgpu::TexelCopyBufferLayout {
700                            offset: 0,
701                            bytes_per_row: Some(gw * 4),
702                            rows_per_image: Some(gh),
703                        },
704                        wgpu::Extent3d {
705                            width: gw,
706                            height: gh,
707                            depth_or_array_layers: 1,
708                        },
709                    );
710
711                    let tex_w = self.mega_heim_tex.width() as f32;
712                    let tex_h = self.mega_heim_tex.height() as f32;
713                    let info = (
714                        Rect {
715                            x: nx as f32 / tex_w,
716                            y: ny as f32 / tex_h,
717                            width: gw as f32 / tex_w,
718                            height: gh as f32 / tex_h,
719                        },
720                        gw as f32,
721                        gh as f32,
722                        x_offset,
723                        y_offset,
724                    );
725                    self.text_cache.put(cache_key, info);
726                    info
727                } else {
728                    (Rect::zero(), 0.0, 0.0, 0.0, 0.0)
729                }
730            };
731
732            if w > 0.0 {
733                let sf = self.current_scale_factor();
734                // Position glyph relative to baseline.
735                // glyph.x/y are in physical pixels, baseline-relative.
736                // shaped.ascent gives the baseline offset from the text origin (y).
737                let baseline_y = y + shaped.ascent / sf;
738                let glyph_rect = Rect {
739                    x: x + (glyph.x + x_off) / sf,
740                    y: baseline_y + (glyph.y - y_off) / sf,
741                    width: w / sf,
742                    height: h / sf,
743                };
744                let tid = self.get_texture_id("__mega_heim");
745                let slice = self
746                    .slice_stack
747                    .last()
748                    .copied()
749                    .map(|(a, o)| [a, o, 1.0, 1.0])
750                    .unwrap_or([0.0, 0.0, 0.0, 1.0]);
751                self.fill_rect_with_full_params_and_slice(
752                    glyph_rect,
753                    c,
754                    6,
755                    tid,
756                    0.0,
757                    uv_rect,
758                    slice,
759                    [glyph.glyph_index as f32, glyph.time_offset],
760                );
761            }
762        }
763    }
764
765    fn draw_texture(&mut self, texture_id: u32, rect: Rect) {
766        self.fill_rect_with_full_params_and_slice(
767            rect,
768            [1.0, 1.0, 1.0, 1.0],
769            2,
770            Some(texture_id),
771            0.0,
772            Rect {
773                x: 0.0,
774                y: 0.0,
775                width: 1.0,
776                height: 1.0,
777            },
778            [0.0, 0.0, 0.0, 1.0],
779            [0.0, 0.0],
780        );
781    }
782
783    /// load_image — Proactively pushes a raw asset into the Mega-Heim.
784    /// load_image — Proactively pushes a raw asset into the Texture Array.
785    fn load_image(&mut self, name: &str, data: &[u8]) {
786        if self.image_uv_registry.contains(name) {
787            return;
788        }
789        let img_result = image::load_from_memory(data);
790        let img = match img_result {
791            Ok(img) => img.to_rgba8(),
792            Err(e) => {
793                log::error!("Failed to load image {}: {}", name, e);
794                image::RgbaImage::from_pixel(1, 1, image::Rgba([255, 255, 255, 255]))
795            }
796        };
797        let (width, height) = img.dimensions();
798
799        let size = wgpu::Extent3d {
800            width,
801            height,
802            depth_or_array_layers: 1,
803        };
804        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
805            label: Some(&format!("Texture Array Layer: {}", name)),
806            size,
807            mip_level_count: 1,
808            sample_count: 1,
809            dimension: wgpu::TextureDimension::D2,
810            format: wgpu::TextureFormat::Rgba8UnormSrgb,
811            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
812            view_formats: &[],
813        });
814
815        self.queue.write_texture(
816            wgpu::TexelCopyTextureInfo {
817                texture: &texture,
818                mip_level: 0,
819                origin: wgpu::Origin3d::ZERO,
820                aspect: wgpu::TextureAspect::All,
821            },
822            &img,
823            wgpu::TexelCopyBufferLayout {
824                offset: 0,
825                bytes_per_row: Some(4 * width),
826                rows_per_image: Some(height),
827            },
828            size,
829        );
830
831        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
832
833        // Slot allocation (Skip index 0 which is the dummy/atlas)
834        let index = if self.texture_registry.len() < 255 {
835            (self.texture_registry.len() + 1) as u32
836        } else {
837            // Evict the least recently used texture
838            if let Some((old_name, old_index)) = self.texture_registry.pop_lru() {
839                self.image_uv_registry.pop(&old_name);
840                old_index
841            } else {
842                1 // Fallback
843            }
844        };
845
846        self.texture_views[index as usize] = view;
847        self.image_uv_registry.put(
848            name.to_string(),
849            Rect {
850                x: 0.0,
851                y: 0.0,
852                width: 1.0,
853                height: 1.0,
854            },
855        );
856        self.texture_registry.put(name.to_string(), index);
857        self.rebuild_texture_array_bind_group();
858    }
859
860    fn push_clip_rect(&mut self, rect: Rect) {
861        self.clip_stack.push(rect);
862    }
863
864    fn pop_clip_rect(&mut self) {
865        self.clip_stack.pop();
866    }
867
868    fn current_clip_rect(&self) -> Rect {
869        self.clip_stack.last().copied().unwrap_or(Rect::new(
870            0.0,
871            0.0,
872            self.current_width() as f32,
873            self.current_height() as f32,
874        ))
875    }
876
877    fn memoize(&mut self, id: u64, data_hash: u64, render_fn: &dyn Fn(&mut dyn Renderer)) {
878        // Check if we've already rendered this content with the same hash this frame
879        // The cache stores the last-seen hash for each ID
880        let should_skip = self.memo_cache.get(&id) == Some(&data_hash);
881
882        if !should_skip {
883            // Update cache with current hash
884            self.memo_cache.insert(id, data_hash);
885            render_fn(self);
886        }
887        // If should_skip is true, we skip rendering as the content hasn't changed
888    }
889
890    fn push_opacity(&mut self, opacity: f32) {
891        let current = self.opacity_stack.last().copied().unwrap_or(1.0);
892        self.opacity_stack.push(current * opacity);
893    }
894
895    fn pop_opacity(&mut self) {
896        self.opacity_stack.pop();
897    }
898
899    fn push_shadow(&mut self, radius: f32, color: [f32; 4], offset: [f32; 2]) {
900        self.shadow_stack.push(ShadowState {
901            radius,
902            color,
903            _offset: offset,
904        });
905    }
906
907    fn pop_shadow(&mut self) {
908        self.shadow_stack.pop();
909    }
910
911    fn push_transform(&mut self, translation: [f32; 2], scale: [f32; 2], rotation: f32) {
912        let c = rotation.cos();
913        let sn = rotation.sin();
914        let affine = glam::Mat3::from_cols(
915            glam::Vec3::new(c * scale[0], sn * scale[0], 0.0),
916            glam::Vec3::new(-sn * scale[1], c * scale[1], 0.0),
917            glam::Vec3::new(translation[0], translation[1], 1.0),
918        );
919
920        let parent = self
921            .transform_stack
922            .last()
923            .copied()
924            .unwrap_or(glam::Mat3::IDENTITY);
925        self.transform_stack.push(parent * affine);
926    }
927
928    fn push_affine(&mut self, transform: [f32; 6]) {
929        let affine = glam::Mat3::from_cols(
930            glam::Vec3::new(transform[0], transform[1], 0.0),
931            glam::Vec3::new(transform[2], transform[3], 0.0),
932            glam::Vec3::new(transform[4], transform[5], 1.0),
933        );
934        let parent = self
935            .transform_stack
936            .last()
937            .copied()
938            .unwrap_or(glam::Mat3::IDENTITY);
939        self.transform_stack.push(parent * affine);
940    }
941
942    fn pop_transform(&mut self) {
943        self.transform_stack.pop();
944    }
945
946    fn set_theme(&mut self, theme: ColorTheme) {
947        self.current_theme = theme;
948        self.queue
949            .write_buffer(&self.theme_buffer, 0, bytemuck::bytes_of(&theme));
950    }
951
952    fn set_rage(&mut self, rage: f32) {
953        self.current_scene.berzerker_rage = rage;
954        // scene_buffer is updated every frame in begin_frame, so no need to write here
955    }
956
957    fn trigger_shatter_event(&mut self, origin: [f32; 2], force: f32) {
958        self.current_scene.shatter_origin = origin;
959        self.current_scene.shatter_time = self.current_scene.time;
960        self.current_scene.shatter_force = force;
961    }
962
963    fn set_scene_preset(&mut self, preset: u32) {
964        self.current_scene.scene_type = preset;
965    }
966
967    /// push_mjolnir_slice — Pushes a geometric clipping plane onto the stack.
968    /// All subsequent draw calls will be sliced by this plane until it is popped.
969    fn push_mjolnir_slice(&mut self, angle: f32, offset: f32) {
970        self.slice_stack.push((angle, offset));
971    }
972
973    /// pop_mjolnir_slice — Removes the top-most geometric clipping plane from the stack.
974    fn pop_mjolnir_slice(&mut self) {
975        self.slice_stack.pop();
976    }
977
978    fn mjolnir_shatter(&mut self, rect: Rect, pieces: u32, force: f32, color: [f32; 4]) {
979        self.shatter_internal(rect, pieces, force, color, 8);
980    }
981
982    fn mjolnir_fluid_shatter(&mut self, rect: Rect, pieces: u32, force: f32, color: [f32; 4]) {
983        self.shatter_internal(rect, pieces, force, color, 11);
984    }
985
986    fn draw_mjolnir_bolt(&mut self, from: [f32; 2], to: [f32; 2], color: [f32; 4]) {
987        self.recursive_bolt(from, to, 4, color);
988    }
989
990    fn dispatch_particles(
991        &mut self,
992        origin: [f32; 2],
993        count: u32,
994        effect_type: &str,
995        _color: [f32; 4],
996    ) {
997        log::info!(
998            "[Surtr] Dispatching {} {} particles at {:?}",
999            count,
1000            effect_type,
1001            origin
1002        );
1003        // Stub: A full implementation would push to a compute pass command queue
1004    }
1005
1006    fn draw_hologram(&mut self, rect: Rect, hologram_id: &str, time: f32) {
1007        log::info!(
1008            "[Surtr] Drawing hologram {} at {:?} (t={})",
1009            hologram_id,
1010            rect,
1011            time
1012        );
1013        // Stub: In the future, this will push a DrawCall into the volumetric pass queue.
1014        // For now, render a glowing wireframe box
1015        self.stroke_rect(rect, [0.0, 1.0, 1.0, 0.5], 2.0);
1016    }
1017
1018    fn upload_data_texture(&mut self, id: &str, data: &[f32], width: u32, height: u32) {
1019        let size = wgpu::Extent3d {
1020            width,
1021            height,
1022            depth_or_array_layers: 1,
1023        };
1024        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
1025            label: Some(id),
1026            size,
1027            mip_level_count: 1,
1028            sample_count: 1,
1029            dimension: wgpu::TextureDimension::D2,
1030            format: wgpu::TextureFormat::R32Float,
1031            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1032            view_formats: &[],
1033        });
1034        self.queue.write_texture(
1035            wgpu::TexelCopyTextureInfo {
1036                texture: &texture,
1037                mip_level: 0,
1038                origin: wgpu::Origin3d::ZERO,
1039                aspect: wgpu::TextureAspect::All,
1040            },
1041            bytemuck::cast_slice(data),
1042            wgpu::TexelCopyBufferLayout {
1043                offset: 0,
1044                bytes_per_row: Some(4 * width),
1045                rows_per_image: Some(height),
1046            },
1047            size,
1048        );
1049        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
1050        let sampler = self.device.create_sampler(&wgpu::SamplerDescriptor {
1051            address_mode_u: wgpu::AddressMode::ClampToEdge,
1052            address_mode_v: wgpu::AddressMode::ClampToEdge,
1053            mag_filter: wgpu::FilterMode::Linear,
1054            ..Default::default()
1055        });
1056        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
1057            layout: &self.texture_bind_group_layout,
1058            entries: &[
1059                wgpu::BindGroupEntry {
1060                    binding: 0,
1061                    resource: wgpu::BindingResource::TextureViewArray(&vec![&view; 256]),
1062                },
1063                wgpu::BindGroupEntry {
1064                    binding: 1,
1065                    resource: wgpu::BindingResource::Sampler(&sampler),
1066                },
1067            ],
1068            label: Some(id),
1069        });
1070        self.texture_bind_groups.push(bind_group);
1071        let tid = (self.texture_bind_groups.len() - 1) as u32;
1072        self.texture_registry.put(id.to_string(), tid);
1073    }
1074
1075    fn draw_heatmap(&mut self, texture_id: &str, rect: Rect, _palette: &str) {
1076        let tid = self.get_texture_id(texture_id);
1077        self.fill_rect_with_mode(rect, [1.0, 1.0, 1.0, 1.0], 12, tid);
1078    }
1079
1080    fn draw_mesh(&mut self, mesh: &Mesh, color: [f32; 4], transform: glam::Mat4) {
1081        let base_idx = self.vertices.len() as u32;
1082        let screen = [self.current_width() as f32, self.current_height() as f32];
1083
1084        for i in 0..mesh.vertices.len() {
1085            let pos = transform.transform_point3(glam::Vec3::from(mesh.vertices[i]));
1086            let norm = transform.transform_vector3(glam::Vec3::from(mesh.normals[i]));
1087
1088            self.vertices.push(Vertex {
1089                position: pos.to_array(),
1090                normal: norm.to_array(),
1091                uv: [0.0, 0.0],
1092                color,
1093                material_id: 13, // Material 13: 3D Surface
1094                radius: 0.0,
1095                slice: [0.0, 0.0, 0.0, 1.0],
1096                logical: [0.0, 0.0],
1097                size: [0.0, 0.0],
1098                clip: [-f32::INFINITY, -f32::INFINITY, f32::INFINITY, f32::INFINITY],
1099                tex_index: 0,
1100            });
1101        }
1102
1103        for idx in &mesh.indices {
1104            self.indices.push(base_idx + idx);
1105        }
1106
1107        let (translation, scale_transform, rotation, _, _) = self.current_transform();
1108
1109        if self.draw_calls.is_empty() || self.current_texture_id.is_some() {
1110            self.current_texture_id = None;
1111
1112            self.instance_data.push(InstanceData {
1113                translation,
1114                scale: scale_transform,
1115                rotation,
1116                blur_radius: 0.0,
1117                ior_override: 0.0,
1118            });
1119            self.draw_calls.push(DrawCall {
1120                target_id: None,
1121                texture_id: None,
1122                scissor_rect: self.clip_stack.last().copied(),
1123                index_start: (self.indices.len() as u32) - (mesh.indices.len() as u32),
1124                index_count: mesh.indices.len() as u32,
1125                material: cvkg_core::DrawMaterial::Opaque,
1126                instance_start: (self.instance_data.len() - 1) as u32,
1127            });
1128        } else {
1129            self.draw_calls.last_mut().unwrap().index_count += mesh.indices.len() as u32;
1130        }
1131    }
1132
1133    fn draw_mesh_3d(
1134        &mut self,
1135        mesh: &Mesh,
1136        material: &cvkg_core::Material3D,
1137        transform: &cvkg_core::Transform3D,
1138    ) {
1139        let base_idx = self.vertices.len() as u32;
1140        let screen = [self.current_width() as f32, self.current_height() as f32];
1141        let model_matrix = transform.to_matrix();
1142
1143        for i in 0..mesh.vertices.len() {
1144            let pos = model_matrix.transform_point3(glam::Vec3::from(mesh.vertices[i]));
1145            let norm = model_matrix.transform_vector3(glam::Vec3::from(mesh.normals[i]));
1146
1147            self.vertices.push(Vertex {
1148                position: [pos.x, pos.y, pos.z],
1149                normal: [norm.x, norm.y, norm.z],
1150                uv: [0.0, 0.0],
1151                color: material.base_color,
1152                material_id: 13, // Material 13: 3D Surface
1153                radius: 0.0,
1154                slice: [material.metallic, material.roughness, material.opacity, 1.0],
1155                logical: [0.0, 0.0],
1156                size: [0.0, 0.0],
1157                clip: [-f32::INFINITY, -f32::INFINITY, f32::INFINITY, f32::INFINITY],
1158                tex_index: 0,
1159            });
1160        }
1161
1162        for idx in &mesh.indices {
1163            self.indices.push(base_idx + idx);
1164        }
1165
1166        self.instance_data.push(InstanceData {
1167            translation: [0.0, 0.0],
1168            scale: [1.0, 1.0],
1169            rotation: 0.0,
1170            blur_radius: 0.0,
1171            ior_override: 0.0,
1172        });
1173
1174        self.draw_calls.push(DrawCall {
1175            target_id: None,
1176            texture_id: None,
1177            scissor_rect: self.clip_stack.last().copied(),
1178            index_start: (self.indices.len() as u32) - (mesh.indices.len() as u32),
1179            index_count: mesh.indices.len() as u32,
1180            material: cvkg_core::DrawMaterial::Opaque,
1181            instance_start: (self.instance_data.len() - 1) as u32,
1182        });
1183    }
1184
1185    fn set_camera_3d(&mut self, camera: &cvkg_core::Camera3D) {
1186        self.current_scene.proj = camera.projection_matrix();
1187        self.current_scene.view = camera.view_matrix();
1188    }
1189
1190    fn push_transform_3d(&mut self, transform: &cvkg_core::Transform3D) {
1191        // Push a 2D-compatible transform for the existing pipeline
1192        // Use proper matrix decomposition to extract scale correctly (handles rotated matrices)
1193        let (translation, rotation_quat, scale_glam) =
1194            transform.to_matrix().to_scale_rotation_translation();
1195        let translation = [translation.x, translation.y];
1196        let scale = [scale_glam.x, scale_glam.y];
1197        let rotation = if rotation_quat.length_squared() > 0.0 {
1198            let (axis, angle) = rotation_quat.to_axis_angle();
1199            angle * axis.z.signum() // Radians (preserving Z-axis direction)
1200        } else {
1201            0.0
1202        };
1203        self.push_transform(translation, scale, rotation);
1204    }
1205
1206    fn pop_transform_3d(&mut self) {
1207        // Only pop the single transform that was pushed - no double pop
1208        self.pop_transform();
1209    }
1210
1211    fn render_scene_node_3d(
1212        &mut self,
1213        position: [f32; 3],
1214        rotation: [f32; 4],
1215        scale: [f32; 3],
1216        color: [f32; 4],
1217        meshes: &[Mesh],
1218    ) {
1219        let transform = cvkg_core::Transform3D {
1220            position: glam::Vec3::from(position),
1221            rotation: glam::Quat::from_xyzw(rotation[0], rotation[1], rotation[2], rotation[3]),
1222            scale: glam::Vec3::from(scale),
1223        };
1224        // Use provided mesh or generate a default unit cube
1225        if meshes.is_empty() {
1226            // Generate a unit cube mesh on the stack
1227            let h = 0.5f32;
1228            let cube = Mesh {
1229                vertices: vec![
1230                    [-h, -h, -h],
1231                    [h, -h, -h],
1232                    [h, h, -h],
1233                    [-h, h, -h],
1234                    [-h, -h, h],
1235                    [h, -h, h],
1236                    [h, h, h],
1237                    [-h, h, h],
1238                ],
1239                normals: vec![
1240                    [0.0, 0.0, -1.0],
1241                    [0.0, 0.0, -1.0],
1242                    [0.0, 0.0, -1.0],
1243                    [0.0, 0.0, -1.0],
1244                    [0.0, 0.0, 1.0],
1245                    [0.0, 0.0, 1.0],
1246                    [0.0, 0.0, 1.0],
1247                    [0.0, 0.0, 1.0],
1248                    [0.0, -1.0, 0.0],
1249                    [0.0, -1.0, 0.0],
1250                    [0.0, -1.0, 0.0],
1251                    [0.0, -1.0, 0.0],
1252                    [1.0, 0.0, 0.0],
1253                    [1.0, 0.0, 0.0],
1254                    [1.0, 0.0, 0.0],
1255                    [1.0, 0.0, 0.0],
1256                    [0.0, 1.0, 0.0],
1257                    [0.0, 1.0, 0.0],
1258                    [0.0, 1.0, 0.0],
1259                    [0.0, 1.0, 0.0],
1260                    [-1.0, 0.0, 0.0],
1261                    [-1.0, 0.0, 0.0],
1262                    [-1.0, 0.0, 0.0],
1263                    [-1.0, 0.0, 0.0],
1264                ],
1265                indices: vec![
1266                    0, 1, 2, 0, 2, 3, // front
1267                    5, 4, 7, 5, 7, 6, // back
1268                    4, 0, 3, 4, 3, 7, // left
1269                    1, 5, 6, 1, 6, 2, // right
1270                    3, 2, 6, 3, 6, 7, // top
1271                    4, 5, 1, 4, 1, 0, // bottom
1272                ],
1273            };
1274            let material = cvkg_core::Material3D::unlit(color);
1275            self.draw_mesh_3d(&cube, &material, &transform);
1276        } else {
1277            let material = cvkg_core::Material3D::unlit(color);
1278            self.draw_mesh_3d(&meshes[0], &material, &transform);
1279        }
1280    }
1281
1282    fn register_shared_element(&mut self, id: &str, rect: Rect) {
1283        self.shared_elements.put(id.to_string(), rect);
1284    }
1285
1286    fn set_z_index(&mut self, z: f32) {
1287        self.current_z = z;
1288    }
1289
1290    fn set_material(&mut self, material: cvkg_core::DrawMaterial) {
1291        self.current_draw_material = material;
1292    }
1293
1294    fn current_material(&self) -> cvkg_core::DrawMaterial {
1295        self.current_draw_material
1296    }
1297
1298    fn get_z_index(&self) -> f32 {
1299        self.current_z
1300    }
1301
1302    fn request_redraw(&mut self) {
1303        self.redraw_requested = true;
1304    }
1305
1306    // -- Portal / PhaseGate rendering -----------------------------------------
1307
1308    /// Begin rendering into the portal root layer instead of the inline tree.
1309    /// All draw calls between `enter_portal` and `exit_portal` are collected
1310    /// into a separate buffer that is composited AFTER the main tree.
1311    ///
1312    /// WHY separate buffer: The main tree may have clipping, transforms, or
1313    /// opacity that should NOT affect overlays. The portal layer renders on top
1314    /// of everything, ignoring the local coordinate system.
1315    ///
1316    /// `z_index` controls the layer ordering for portal content.
1317    fn enter_portal(&mut self, z_index: i32) {
1318        // Portal rendering enables per-element backdrop blur for Tahoe glass
1319        // When z_index is 0, we're rendering normal glass cards
1320        // When z_index > 0, we're in a portal layer that will get special treatment
1321        self.current_z = z_index as f32;
1322    }
1323
1324    /// Exit the portal layer and return to inline rendering.
1325    /// The portal content collected since `enter_portal` is now sealed --
1326    /// no more draw calls will be appended to it.
1327    fn exit_portal(&mut self) {
1328        self.current_z = 0.0;
1329    }
1330
1331    fn push_vnode(&mut self, rect: Rect, name: &'static str) {
1332        self.vnode_stack.push((rect, name));
1333    }
1334
1335    fn pop_vnode(&mut self) {
1336        self.vnode_stack.pop();
1337    }
1338
1339    fn register_handler(
1340        &mut self,
1341        event_type: &str,
1342        handler: std::sync::Arc<dyn Fn(cvkg_core::Event) + Send + Sync>,
1343    ) {
1344        self.event_handlers
1345            .entry(event_type.to_string())
1346            .or_insert_with(Vec::new)
1347            .push(handler);
1348    }
1349
1350    fn load_svg(&mut self, name: &str, svg_data: &[u8]) {
1351        SurtrRenderer::load_svg(self, name, svg_data);
1352    }
1353
1354    fn draw_svg(&mut self, name: &str, rect: Rect) {
1355        SurtrRenderer::draw_svg(self, name, rect, None, 1);
1356    }
1357
1358    fn serialize_svg(&mut self, name: &str) -> Result<String, String> {
1359        let tree = self
1360            .svg_trees
1361            .get(name)
1362            .ok_or_else(|| format!("SVG '{}' not found", name))?;
1363        let config = cvkg_svg_serialize::SerializerConfig::default();
1364        let mut serializer = cvkg_svg_serialize::SvgSerializer::with_config(config);
1365        serializer
1366            .serialize(tree)
1367            .map_err(|e| format!("SVG serialization failed: {}", e))
1368    }
1369
1370    fn apply_svg_filter(
1371        &mut self,
1372        name: &str,
1373        filter_id: &str,
1374        _region: Rect,
1375    ) -> Result<String, String> {
1376        let tree = self
1377            .svg_trees
1378            .get(name)
1379            .ok_or_else(|| format!("SVG '{}' not found", name))?;
1380        let _filter = Self::find_filter(tree, filter_id)
1381            .ok_or_else(|| format!("Filter '{}' not found in SVG '{}'", filter_id, name))?;
1382        let config = cvkg_svg_serialize::SerializerConfig::default();
1383        let mut serializer = cvkg_svg_serialize::SvgSerializer::with_config(config);
1384        serializer
1385            .serialize(tree)
1386            .map_err(|e| format!("SVG filter serialization failed: {}", e))
1387    }
1388}
1389
1390// ── Inherent methods on SurtrRenderer (not part of the Renderer trait) ──
1391
1392impl SurtrRenderer {
1393    /// Clear all registered event handlers. Call at the start of each frame
1394    /// before re-rendering the component tree.
1395    pub fn clear_event_handlers(&mut self) {
1396        self.event_handlers.clear();
1397    }
1398
1399    /// Get all registered event handlers for a specific event type.
1400    pub fn get_handlers(
1401        &self,
1402        event_type: &str,
1403    ) -> Option<&Vec<std::sync::Arc<dyn Fn(cvkg_core::Event) + Send + Sync>>> {
1404        self.event_handlers.get(event_type)
1405    }
1406
1407    /// Compute per-vertex transform values from the current matrix.
1408    /// Extracts translation, scale, rotation, and skew from the affine matrix
1409    /// so the existing vertex shader fields still work correctly.
1410    pub(crate) fn current_transform(&self) -> ([f32; 2], [f32; 2], f32, f32, f32) {
1411        // Returns (translation, scale, rotation,
1412        // skew_x, skew_y)
1413        let m = self
1414            .transform_stack
1415            .last()
1416            .copied()
1417            .unwrap_or(glam::Mat3::IDENTITY);
1418        let t = [m.z_axis.x, m.z_axis.y];
1419        // Extract scale and rotation from the 2x2 submatrix
1420        let a = m.x_axis.x;
1421        let b = m.x_axis.y;
1422        let c = m.y_axis.x;
1423        let d = m.y_axis.y;
1424        let sx = (a * a + b * b).sqrt();
1425        let sy = (c * c + d * d).sqrt();
1426        let rotation = b.atan2(a);
1427        // Skew: the angle between the basis vectors minus 90 degrees
1428        let skew_x = (a * c + b * d) / (sx * sy); // sin(skew)
1429        (t, [sx, sy], rotation, skew_x, 0.0)
1430    }
1431
1432    pub fn stroke_path(&mut self, path: &lyon::path::Path, color: [f32; 4], stroke_width: f32) {
1433        let c = self.apply_opacity(color);
1434        let mut tessellator = StrokeTessellator::new();
1435        let mut buffers: VertexBuffers<Vertex, u32> = VertexBuffers::new();
1436        let base_vertex_idx = self.vertices.len() as u32;
1437        let base_index_idx = self.indices.len() as u32;
1438
1439        let (translation, scale, rotation, _, _) = self.current_transform();
1440        let clip_rect = self.clip_stack.last().copied().unwrap_or(cvkg_core::Rect {
1441            x: -10000.0,
1442            y: -10000.0,
1443            width: 20000.0,
1444            height: 20000.0,
1445        });
1446        let clip = [clip_rect.x, clip_rect.y, clip_rect.width, clip_rect.height];
1447
1448        let result = tessellator.tessellate_path(
1449            path,
1450            &StrokeOptions::default().with_line_width(stroke_width),
1451            &mut BuffersBuilder::new(
1452                &mut buffers,
1453                CustomStrokeVertexConstructor { color: c, clip, path_length: 1.0 },
1454            ),
1455        );
1456        if let Err(e) = result {
1457            log::warn!("Failed to tessellate stroke path: {:?}", e);
1458            return;
1459        }
1460
1461        self.vertices.extend(buffers.vertices);
1462        for idx in &buffers.indices {
1463            self.indices.push(base_vertex_idx + *idx);
1464        }
1465
1466        let material = self.current_material();
1467        let tid = self.get_texture_id("__mega_heim");
1468
1469        let last_call = self.draw_calls.last();
1470        let needs_new_call = self.draw_calls.is_empty()
1471            || self.current_texture_id != tid
1472            || last_call.unwrap().scissor_rect != self.clip_stack.last().copied()
1473            || last_call.unwrap().material != material;
1474
1475        if needs_new_call {
1476            self.current_texture_id = tid;
1477
1478            self.instance_data.push(InstanceData {
1479                translation,
1480                scale,
1481                rotation,
1482                blur_radius: 0.0,
1483                ior_override: 0.0,
1484            });
1485            self.draw_calls.push(DrawCall {
1486                target_id: None,
1487                texture_id: tid,
1488                scissor_rect: self.clip_stack.last().copied(),
1489                index_start: base_index_idx,
1490                index_count: buffers.indices.len() as u32,
1491                material,
1492                instance_start: (self.instance_data.len() - 1) as u32,
1493            });
1494        } else if let Some(call) = self.draw_calls.last_mut() {
1495            call.index_count += buffers.indices.len() as u32;
1496        }
1497    }
1498}
1499
1500impl cvkg_core::FrameRenderer<wgpu::CommandEncoder> for SurtrRenderer {
1501    fn begin_frame(&mut self) -> wgpu::CommandEncoder {
1502        cvkg_core::begin_render_phase();
1503        let id = self
1504            .current_window
1505            .expect("No target window set for frame. Call set_target_window first.");
1506        self.begin_frame(id)
1507    }
1508
1509    fn render_frame(&mut self) {
1510        // Visual Lint: If layout was dirtied during the render phase (layout thrashing),
1511        // draw a 10px red border as a warning flash.
1512        if LAYOUT_DIRTY.swap(false, Ordering::AcqRel) {
1513            if let Some(window_id) = self.current_window {
1514                if let Some(surface_ctx) = self.surfaces.get(&window_id) {
1515                    let w = surface_ctx.config.width as f32;
1516                    let h = surface_ctx.config.height as f32;
1517                    let border_rect = cvkg_core::Rect {
1518                        x: 0.0,
1519                        y: 0.0,
1520                        width: w,
1521                        height: h,
1522                    };
1523                    // Draw a thick red border to signal layout-thrashing
1524                    self.stroke_rect(border_rect, [1.0, 0.0, 0.0, 1.0], 10.0);
1525                }
1526            }
1527        }
1528
1529        // Dynamic Buffer Growth (Up to 4x capacity)
1530        let req_v_size = (self.vertices.len() * std::mem::size_of::<Vertex>()) as u64;
1531        let mut cur_v_size = self.vertex_buffer.size();
1532        let max_v_size = (MAX_VERTICES * std::mem::size_of::<Vertex>()) as u64 * 4;
1533
1534        if req_v_size > cur_v_size {
1535            while cur_v_size < req_v_size && cur_v_size < max_v_size {
1536                cur_v_size *= 2;
1537            }
1538            if req_v_size > max_v_size {
1539                log::error!("Exceeded dynamic vertex buffer max capacity! Capping geometry.");
1540                self.vertices
1541                    .truncate((max_v_size / std::mem::size_of::<Vertex>() as u64) as usize);
1542                cur_v_size = max_v_size;
1543            }
1544            log::info!("Growing vertex buffer to {} bytes", cur_v_size);
1545            self.vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
1546                label: Some("Vertex Buffer (Grown)"),
1547                size: cur_v_size,
1548                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1549                mapped_at_creation: false,
1550            });
1551        }
1552
1553        let req_i_size = (self.indices.len() * std::mem::size_of::<u32>()) as u64;
1554        let mut cur_i_size = self.index_buffer.size();
1555        let max_i_size = (MAX_INDICES * std::mem::size_of::<u32>()) as u64 * 4;
1556
1557        if req_i_size > cur_i_size {
1558            while cur_i_size < req_i_size && cur_i_size < max_i_size {
1559                cur_i_size *= 2;
1560            }
1561            if req_i_size > max_i_size {
1562                log::error!("Exceeded dynamic index buffer max capacity! Capping geometry.");
1563                self.indices
1564                    .truncate((max_i_size / std::mem::size_of::<u32>() as u64) as usize);
1565                cur_i_size = max_i_size;
1566            }
1567            log::info!("Growing index buffer to {} bytes", cur_i_size);
1568            self.index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
1569                label: Some("Index Buffer (Grown)"),
1570                size: cur_i_size,
1571                usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
1572                mapped_at_creation: false,
1573            });
1574        }
1575
1576        // Forge Submission: Sync all geometry to GPU using StagingBelt with a dedicated encoder
1577        let mut staging_encoder =
1578            self.device
1579                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1580                    label: Some("Surtr Staging Encoder"),
1581                });
1582
1583        let mut has_writes = false;
1584
1585        if !self.vertices.is_empty() {
1586            let v_bytes = bytemuck::cast_slice(&self.vertices);
1587            self.staging_belt
1588                .write_buffer(
1589                    &mut staging_encoder,
1590                    &self.vertex_buffer,
1591                    0,
1592                    wgpu::BufferSize::new(v_bytes.len() as u64).unwrap(),
1593                )
1594                .copy_from_slice(v_bytes);
1595            has_writes = true;
1596        }
1597
1598        if !self.indices.is_empty() {
1599            let i_bytes = bytemuck::cast_slice(&self.indices);
1600            self.staging_belt
1601                .write_buffer(
1602                    &mut staging_encoder,
1603                    &self.index_buffer,
1604                    0,
1605                    wgpu::BufferSize::new(i_bytes.len() as u64).unwrap(),
1606                )
1607                .copy_from_slice(i_bytes);
1608            has_writes = true;
1609        }
1610
1611        if !self.instance_data.is_empty() {
1612            let inst_bytes = bytemuck::cast_slice(&self.instance_data);
1613            self.staging_belt
1614                .write_buffer(
1615                    &mut staging_encoder,
1616                    &self.instance_buffer,
1617                    0,
1618                    wgpu::BufferSize::new(inst_bytes.len() as u64).unwrap(),
1619                )
1620                .copy_from_slice(inst_bytes);
1621            has_writes = true;
1622        }
1623
1624        if has_writes {
1625            self.staging_belt.finish();
1626            self.staging_command_buffers.push(staging_encoder.finish());
1627        }
1628
1629        // Update Time & Uniforms (Direct write is fine for small uniforms)
1630        self.current_scene.time = self.start_time.elapsed().as_secs_f32();
1631        self.queue.write_buffer(
1632            &self.scene_buffer,
1633            0,
1634            bytemuck::bytes_of(&self.current_scene),
1635        );
1636        self.queue.write_buffer(
1637            &self.theme_buffer,
1638            0,
1639            bytemuck::bytes_of(&self.current_theme),
1640        );
1641
1642        // Populate telemetry for this frame
1643        self.telemetry.draw_calls = self.draw_calls.len() as u32;
1644        self.telemetry.vertices = self.vertices.len() as u32;
1645    }
1646
1647    fn end_frame(&mut self, encoder: wgpu::CommandEncoder) {
1648        // Delegate to the inherent end_frame which runs the render graph
1649        SurtrRenderer::end_frame(self, encoder);
1650        cvkg_core::end_render_phase();
1651    }
1652}
1653
1654fn glyph_image_to_rgba(image: cvkg_runic_text::GlyphImage) -> (Vec<u8>, u32, u32) {
1655    let width = image.width;
1656    let height = image.height;
1657    let pixels = width.saturating_mul(height) as usize;
1658
1659    if pixels == 0 || image.data.is_empty() {
1660        return (Vec::new(), width, height);
1661    }
1662
1663    let (bytes_per_pixel, remainder) = (image.data.len() / pixels, image.data.len() % pixels);
1664    if remainder != 0 {
1665        log::warn!(
1666            "Glyph rasterizer returned {} bytes for {}x{} glyph; expected whole pixels ({} bytes per pixel)",
1667            image.data.len(),
1668            width,
1669            height,
1670            bytes_per_pixel
1671        );
1672        return (Vec::new(), width, height);
1673    }
1674
1675    let rgba_data = match bytes_per_pixel {
1676        1 => {
1677            let mut data = Vec::with_capacity(pixels * 4);
1678            for alpha in &image.data {
1679                data.push(255);
1680                data.push(255);
1681                data.push(255);
1682                data.push(*alpha);
1683            }
1684            data
1685        }
1686        3 => {
1687            let mut data = Vec::with_capacity(pixels * 4);
1688            for rgb in image.data.chunks_exact(3) {
1689                let alpha = rgb.iter().copied().max().unwrap_or(0);
1690                data.push(255);
1691                data.push(255);
1692                data.push(255);
1693                data.push(alpha);
1694            }
1695            data
1696        }
1697        4 => {
1698            let mut data = image.data;
1699            for chunk in data.chunks_exact_mut(4) {
1700                // If it's a SubpixelMask, swash sets A=0. We need to reconstruct an alpha
1701                // so the shader doesn't discard it.
1702                if chunk[3] == 0 && (chunk[0] > 0 || chunk[1] > 0 || chunk[2] > 0) {
1703                    chunk[3] = chunk[0].max(chunk[1]).max(chunk[2]);
1704                }
1705            }
1706            data
1707        }
1708        _ => {
1709            log::warn!(
1710                "Glyph rasterizer returned unsupported {} bytes per pixel for {}x{} glyph ({} bytes total)",
1711                bytes_per_pixel,
1712                width,
1713                height,
1714                image.data.len()
1715            );
1716            Vec::new()
1717        }
1718    };
1719
1720    (rgba_data, width, height)
1721}
1722
1723#[cfg(test)]
1724mod tests {
1725    use super::glyph_image_to_rgba;
1726
1727    #[test]
1728    fn glyph_image_to_rgba_keeps_rgba_color_data() {
1729        let image = cvkg_runic_text::GlyphImage {
1730            glyph_id: 1,
1731            width: 2,
1732            height: 1,
1733            data: vec![1, 2, 3, 4, 5, 6, 7, 8],
1734            x_offset: 0.0,
1735            y_offset: 0.0,
1736            cache_key: 42,
1737        };
1738
1739        assert_eq!(
1740            glyph_image_to_rgba(image),
1741            (vec![1, 2, 3, 4, 5, 6, 7, 8], 2, 1)
1742        );
1743    }
1744
1745    #[test]
1746    fn glyph_image_to_rgba_expands_grayscale_alpha() {
1747        let image = cvkg_runic_text::GlyphImage {
1748            glyph_id: 1,
1749            width: 3,
1750            height: 1,
1751            data: vec![0, 128, 255],
1752            x_offset: 0.0,
1753            y_offset: 0.0,
1754            cache_key: 42,
1755        };
1756
1757        assert_eq!(
1758            glyph_image_to_rgba(image),
1759            (
1760                vec![255, 255, 255, 0, 255, 255, 255, 128, 255, 255, 255, 255],
1761                3,
1762                1
1763            )
1764        );
1765    }
1766
1767    #[test]
1768    fn glyph_image_to_rgba_collapses_subpixel_rgb_to_alpha() {
1769        let image = cvkg_runic_text::GlyphImage {
1770            glyph_id: 1,
1771            width: 2,
1772            height: 1,
1773            data: vec![0, 128, 255, 255, 0, 64],
1774            x_offset: 0.0,
1775            y_offset: 0.0,
1776            cache_key: 42,
1777        };
1778
1779        assert_eq!(
1780            glyph_image_to_rgba(image),
1781            (vec![255, 255, 255, 255, 255, 255, 255, 255], 2, 1)
1782        );
1783    }
1784}