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        (shaped.width / sf, shaped.height / sf)
591    }
592
593    fn shape_rich_text(
594        &mut self,
595        spans: &[cvkg_runic_text::TextSpan],
596        max_width: Option<f32>,
597        align: cvkg_runic_text::TextAlign,
598        overflow: cvkg_runic_text::TextOverflow,
599    ) -> Option<cvkg_runic_text::ShapedText> {
600        let sf = self.current_scale_factor();
601        let mut scaled_spans = spans.to_vec();
602        for span in &mut scaled_spans {
603            span.style.font_size *= sf;
604            if span.style.fallback_families.is_empty() {
605                span.style.fallback_families = vec![
606                    "SF Pro".to_string(),
607                    "Inter".to_string(),
608                    "Helvetica Neue".to_string(),
609                    "Helvetica".to_string(),
610                    "Arial".to_string(),
611                    "sans-serif".to_string(),
612                ];
613            }
614        }
615        let scaled_max_width = max_width.map(|w| w * sf);
616        self.text_engine
617            .shape_layout(&scaled_spans, scaled_max_width, align, overflow)
618            .ok()
619    }
620
621    fn draw_shaped_text(&mut self, shaped: &cvkg_runic_text::ShapedText, x: f32, y: f32) {
622        for glyph in &shaped.glyphs {
623            let byte_idx = shaped
624                .grapheme_boundaries
625                .get(glyph.cluster as usize)
626                .copied()
627                .unwrap_or(0);
628            let mut span_color = [1.0, 1.0, 1.0, 1.0];
629            for span in &shaped.spans {
630                if byte_idx >= span.byte_offset && byte_idx < span.byte_offset + span.text.len() {
631                    span_color = [
632                        span.style.color[0] as f32 / 255.0,
633                        span.style.color[1] as f32 / 255.0,
634                        span.style.color[2] as f32 / 255.0,
635                        span.style.color[3] as f32 / 255.0,
636                    ];
637                    break;
638                }
639            }
640            let c = self.apply_opacity(span_color);
641
642            let cache_key = glyph.cache_key;
643            let (uv_rect, w, h, x_off, y_off) = if let Some(info) = self.text_cache.get(&cache_key)
644            {
645                *info
646            } else {
647                if let Some(image) = self.text_engine.rasterize(cache_key) {
648                    let glyph_id = image.glyph_id;
649                    let data_len = image.data.len();
650                    let gw = image.width;
651                    let gh = image.height;
652                    let x_offset = image.x_offset;
653                    let y_offset = image.y_offset;
654                    let (rgba_data, gw, gh) = glyph_image_to_rgba(image);
655                    if gw == 0 || gh == 0 {
656                        continue;
657                    }
658                    if rgba_data.is_empty() {
659                        log::warn!(
660                            "Glyph rasterizer returned unsupported pixel format for glyph {} ({} bytes, {}x{}), skipping",
661                            glyph_id,
662                            data_len,
663                            gw,
664                            gh
665                        );
666                        continue;
667                    }
668
669                    let pack_res = self.heim_packer.pack(gw, gh);
670                    let (nx, ny) = if let Some(pos) = pack_res {
671                        pos
672                    } else {
673                        self.reclaim_vram();
674                        match self.heim_packer.pack(gw, gh) {
675                            Some(pos) => pos,
676                            None => {
677                                log::error!(
678                                    "Glyph heim critically full after reclaim: cannot pack {}x{} glyph, skipping",
679                                    gw,
680                                    gh
681                                );
682                                continue; // Skip this glyph rather than corrupting atlas origin
683                            }
684                        }
685                    };
686                    // DEBUG: print first few bytes to see if they are white
687                    let sample = &rgba_data[0..std::cmp::min(rgba_data.len(), 20)];
688                    log::info!("Rasterized glyph {}, gw: {}, gh: {}, data len: {}, first 20 bytes: {:?}", glyph_id, gw, gh, rgba_data.len(), sample);
689                    
690                    self.queue.write_texture(
691                        wgpu::TexelCopyTextureInfo {
692                            texture: &self.mega_heim_tex,
693                            mip_level: 0,
694                            origin: wgpu::Origin3d { x: nx, y: ny, z: 0 },
695                            aspect: wgpu::TextureAspect::All,
696                        },
697                        &rgba_data,
698                        wgpu::TexelCopyBufferLayout {
699                            offset: 0,
700                            bytes_per_row: Some(gw * 4),
701                            rows_per_image: Some(gh),
702                        },
703                        wgpu::Extent3d {
704                            width: gw,
705                            height: gh,
706                            depth_or_array_layers: 1,
707                        },
708                    );
709
710                    let tex_w = self.mega_heim_tex.width() as f32;
711                    let tex_h = self.mega_heim_tex.height() as f32;
712                    let info = (
713                        Rect {
714                            x: nx as f32 / tex_w,
715                            y: ny as f32 / tex_h,
716                            width: gw as f32 / tex_w,
717                            height: gh as f32 / tex_h,
718                        },
719                        gw as f32,
720                        gh as f32,
721                        x_offset,
722                        y_offset,
723                    );
724                    self.text_cache.put(cache_key, info);
725                    info
726                } else {
727                    (Rect::zero(), 0.0, 0.0, 0.0, 0.0)
728                }
729            };
730
731            if w > 0.0 {
732                let sf = self.current_scale_factor();
733                // Position glyph relative to baseline.
734                // glyph.x/y are in physical pixels, baseline-relative.
735                // shaped.ascent gives the baseline offset from the text origin (y).
736                let baseline_y = y + shaped.ascent / sf;
737                let glyph_rect = Rect {
738                    x: x + (glyph.x + x_off) / sf,
739                    y: baseline_y + (glyph.y - y_off) / sf,
740                    width: w / sf,
741                    height: h / sf,
742                };
743                let tid = self.get_texture_id("__mega_heim");
744                let slice = self
745                    .slice_stack
746                    .last()
747                    .copied()
748                    .map(|(a, o)| [a, o, 1.0, 1.0])
749                    .unwrap_or([0.0, 0.0, 0.0, 1.0]);
750                self.fill_rect_with_full_params_and_slice(
751                    glyph_rect,
752                    c,
753                    6,
754                    tid,
755                    0.0,
756                    uv_rect,
757                    slice,
758                    [glyph.glyph_index as f32, glyph.time_offset],
759                );
760            }
761        }
762    }
763
764    fn draw_texture(&mut self, texture_id: u32, rect: Rect) {
765        self.fill_rect_with_full_params_and_slice(
766            rect,
767            [1.0, 1.0, 1.0, 1.0],
768            2,
769            Some(texture_id),
770            0.0,
771            Rect {
772                x: 0.0,
773                y: 0.0,
774                width: 1.0,
775                height: 1.0,
776            },
777            [0.0, 0.0, 0.0, 1.0],
778            [0.0, 0.0],
779        );
780    }
781
782    /// load_image — Proactively pushes a raw asset into the Mega-Heim.
783    /// load_image — Proactively pushes a raw asset into the Texture Array.
784    fn load_image(&mut self, name: &str, data: &[u8]) {
785        if self.image_uv_registry.contains(name) {
786            return;
787        }
788        let img_result = image::load_from_memory(data);
789        let img = match img_result {
790            Ok(img) => img.to_rgba8(),
791            Err(e) => {
792                log::error!("Failed to load image {}: {}", name, e);
793                image::RgbaImage::from_pixel(1, 1, image::Rgba([255, 255, 255, 255]))
794            }
795        };
796        let (width, height) = img.dimensions();
797
798        let size = wgpu::Extent3d {
799            width,
800            height,
801            depth_or_array_layers: 1,
802        };
803        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
804            label: Some(&format!("Texture Array Layer: {}", name)),
805            size,
806            mip_level_count: 1,
807            sample_count: 1,
808            dimension: wgpu::TextureDimension::D2,
809            format: wgpu::TextureFormat::Rgba8UnormSrgb,
810            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
811            view_formats: &[],
812        });
813
814        self.queue.write_texture(
815            wgpu::TexelCopyTextureInfo {
816                texture: &texture,
817                mip_level: 0,
818                origin: wgpu::Origin3d::ZERO,
819                aspect: wgpu::TextureAspect::All,
820            },
821            &img,
822            wgpu::TexelCopyBufferLayout {
823                offset: 0,
824                bytes_per_row: Some(4 * width),
825                rows_per_image: Some(height),
826            },
827            size,
828        );
829
830        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
831
832        // Slot allocation (Skip index 0 which is the dummy/atlas)
833        let index = if self.texture_registry.len() < 255 {
834            (self.texture_registry.len() + 1) as u32
835        } else {
836            // Evict the least recently used texture
837            if let Some((old_name, old_index)) = self.texture_registry.pop_lru() {
838                self.image_uv_registry.pop(&old_name);
839                old_index
840            } else {
841                1 // Fallback
842            }
843        };
844
845        self.texture_views[index as usize] = view;
846        self.image_uv_registry.put(
847            name.to_string(),
848            Rect {
849                x: 0.0,
850                y: 0.0,
851                width: 1.0,
852                height: 1.0,
853            },
854        );
855        self.texture_registry.put(name.to_string(), index);
856        self.rebuild_texture_array_bind_group();
857    }
858
859    fn push_clip_rect(&mut self, rect: Rect) {
860        self.clip_stack.push(rect);
861    }
862
863    fn pop_clip_rect(&mut self) {
864        self.clip_stack.pop();
865    }
866
867    fn current_clip_rect(&self) -> Rect {
868        self.clip_stack.last().copied().unwrap_or(Rect::new(
869            0.0,
870            0.0,
871            self.current_width() as f32,
872            self.current_height() as f32,
873        ))
874    }
875
876    fn memoize(&mut self, id: u64, data_hash: u64, render_fn: &dyn Fn(&mut dyn Renderer)) {
877        // Check if we've already rendered this content with the same hash this frame
878        // The cache stores the last-seen hash for each ID
879        let should_skip = self.memo_cache.get(&id) == Some(&data_hash);
880
881        if !should_skip {
882            // Update cache with current hash
883            self.memo_cache.insert(id, data_hash);
884            render_fn(self);
885        }
886        // If should_skip is true, we skip rendering as the content hasn't changed
887    }
888
889    fn push_opacity(&mut self, opacity: f32) {
890        let current = self.opacity_stack.last().copied().unwrap_or(1.0);
891        self.opacity_stack.push(current * opacity);
892    }
893
894    fn pop_opacity(&mut self) {
895        self.opacity_stack.pop();
896    }
897
898    fn push_shadow(&mut self, radius: f32, color: [f32; 4], offset: [f32; 2]) {
899        self.shadow_stack.push(ShadowState {
900            radius,
901            color,
902            _offset: offset,
903        });
904    }
905
906    fn pop_shadow(&mut self) {
907        self.shadow_stack.pop();
908    }
909
910    fn push_transform(&mut self, translation: [f32; 2], scale: [f32; 2], rotation: f32) {
911        let c = rotation.cos();
912        let sn = rotation.sin();
913        let affine = glam::Mat3::from_cols(
914            glam::Vec3::new(c * scale[0], sn * scale[0], 0.0),
915            glam::Vec3::new(-sn * scale[1], c * scale[1], 0.0),
916            glam::Vec3::new(translation[0], translation[1], 1.0),
917        );
918
919        let parent = self
920            .transform_stack
921            .last()
922            .copied()
923            .unwrap_or(glam::Mat3::IDENTITY);
924        self.transform_stack.push(parent * affine);
925    }
926
927    fn push_affine(&mut self, transform: [f32; 6]) {
928        let affine = glam::Mat3::from_cols(
929            glam::Vec3::new(transform[0], transform[1], 0.0),
930            glam::Vec3::new(transform[2], transform[3], 0.0),
931            glam::Vec3::new(transform[4], transform[5], 1.0),
932        );
933        let parent = self
934            .transform_stack
935            .last()
936            .copied()
937            .unwrap_or(glam::Mat3::IDENTITY);
938        self.transform_stack.push(parent * affine);
939    }
940
941    fn pop_transform(&mut self) {
942        self.transform_stack.pop();
943    }
944
945    fn set_theme(&mut self, theme: ColorTheme) {
946        self.current_theme = theme;
947        self.queue
948            .write_buffer(&self.theme_buffer, 0, bytemuck::bytes_of(&theme));
949    }
950
951    fn set_rage(&mut self, rage: f32) {
952        self.current_scene.berzerker_rage = rage;
953        // scene_buffer is updated every frame in begin_frame, so no need to write here
954    }
955
956    fn trigger_shatter_event(&mut self, origin: [f32; 2], force: f32) {
957        self.current_scene.shatter_origin = origin;
958        self.current_scene.shatter_time = self.current_scene.time;
959        self.current_scene.shatter_force = force;
960    }
961
962    fn set_scene_preset(&mut self, preset: u32) {
963        self.current_scene.scene_type = preset;
964    }
965
966    /// push_mjolnir_slice — Pushes a geometric clipping plane onto the stack.
967    /// All subsequent draw calls will be sliced by this plane until it is popped.
968    fn push_mjolnir_slice(&mut self, angle: f32, offset: f32) {
969        self.slice_stack.push((angle, offset));
970    }
971
972    /// pop_mjolnir_slice — Removes the top-most geometric clipping plane from the stack.
973    fn pop_mjolnir_slice(&mut self) {
974        self.slice_stack.pop();
975    }
976
977    fn mjolnir_shatter(&mut self, rect: Rect, pieces: u32, force: f32, color: [f32; 4]) {
978        self.shatter_internal(rect, pieces, force, color, 8);
979    }
980
981    fn mjolnir_fluid_shatter(&mut self, rect: Rect, pieces: u32, force: f32, color: [f32; 4]) {
982        self.shatter_internal(rect, pieces, force, color, 11);
983    }
984
985    fn draw_mjolnir_bolt(&mut self, from: [f32; 2], to: [f32; 2], color: [f32; 4]) {
986        self.recursive_bolt(from, to, 4, color);
987    }
988
989    fn dispatch_particles(
990        &mut self,
991        origin: [f32; 2],
992        count: u32,
993        effect_type: &str,
994        _color: [f32; 4],
995    ) {
996        log::info!(
997            "[Surtr] Dispatching {} {} particles at {:?}",
998            count,
999            effect_type,
1000            origin
1001        );
1002        // Stub: A full implementation would push to a compute pass command queue
1003    }
1004
1005    fn draw_hologram(&mut self, rect: Rect, hologram_id: &str, time: f32) {
1006        log::info!(
1007            "[Surtr] Drawing hologram {} at {:?} (t={})",
1008            hologram_id,
1009            rect,
1010            time
1011        );
1012        // Stub: In the future, this will push a DrawCall into the volumetric pass queue.
1013        // For now, render a glowing wireframe box
1014        self.stroke_rect(rect, [0.0, 1.0, 1.0, 0.5], 2.0);
1015    }
1016
1017    fn upload_data_texture(&mut self, id: &str, data: &[f32], width: u32, height: u32) {
1018        let size = wgpu::Extent3d {
1019            width,
1020            height,
1021            depth_or_array_layers: 1,
1022        };
1023        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
1024            label: Some(id),
1025            size,
1026            mip_level_count: 1,
1027            sample_count: 1,
1028            dimension: wgpu::TextureDimension::D2,
1029            format: wgpu::TextureFormat::R32Float,
1030            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1031            view_formats: &[],
1032        });
1033        self.queue.write_texture(
1034            wgpu::TexelCopyTextureInfo {
1035                texture: &texture,
1036                mip_level: 0,
1037                origin: wgpu::Origin3d::ZERO,
1038                aspect: wgpu::TextureAspect::All,
1039            },
1040            bytemuck::cast_slice(data),
1041            wgpu::TexelCopyBufferLayout {
1042                offset: 0,
1043                bytes_per_row: Some(4 * width),
1044                rows_per_image: Some(height),
1045            },
1046            size,
1047        );
1048        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
1049        let sampler = self.device.create_sampler(&wgpu::SamplerDescriptor {
1050            address_mode_u: wgpu::AddressMode::ClampToEdge,
1051            address_mode_v: wgpu::AddressMode::ClampToEdge,
1052            mag_filter: wgpu::FilterMode::Linear,
1053            ..Default::default()
1054        });
1055        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
1056            layout: &self.texture_bind_group_layout,
1057            entries: &[
1058                wgpu::BindGroupEntry {
1059                    binding: 0,
1060                    resource: wgpu::BindingResource::TextureViewArray(&vec![&view; 256]),
1061                },
1062                wgpu::BindGroupEntry {
1063                    binding: 1,
1064                    resource: wgpu::BindingResource::Sampler(&sampler),
1065                },
1066            ],
1067            label: Some(id),
1068        });
1069        self.texture_bind_groups.push(bind_group);
1070        let tid = (self.texture_bind_groups.len() - 1) as u32;
1071        self.texture_registry.put(id.to_string(), tid);
1072    }
1073
1074    fn draw_heatmap(&mut self, texture_id: &str, rect: Rect, _palette: &str) {
1075        let tid = self.get_texture_id(texture_id);
1076        self.fill_rect_with_mode(rect, [1.0, 1.0, 1.0, 1.0], 12, tid);
1077    }
1078
1079    fn draw_mesh(&mut self, mesh: &Mesh, color: [f32; 4], transform: glam::Mat4) {
1080        let base_idx = self.vertices.len() as u32;
1081        let screen = [self.current_width() as f32, self.current_height() as f32];
1082
1083        for i in 0..mesh.vertices.len() {
1084            let pos = transform.transform_point3(glam::Vec3::from(mesh.vertices[i]));
1085            let norm = transform.transform_vector3(glam::Vec3::from(mesh.normals[i]));
1086
1087            self.vertices.push(Vertex {
1088                position: pos.to_array(),
1089                normal: norm.to_array(),
1090                uv: [0.0, 0.0],
1091                color,
1092                material_id: 13, // Material 13: 3D Surface
1093                radius: 0.0,
1094                slice: [0.0, 0.0, 0.0, 1.0],
1095                logical: [0.0, 0.0],
1096                size: [0.0, 0.0],
1097                clip: [-f32::INFINITY, -f32::INFINITY, f32::INFINITY, f32::INFINITY],
1098                tex_index: 0,
1099            });
1100        }
1101
1102        for idx in &mesh.indices {
1103            self.indices.push(base_idx + idx);
1104        }
1105
1106        let (translation, scale_transform, rotation, _, _) = self.current_transform();
1107
1108        if self.draw_calls.is_empty() || self.current_texture_id.is_some() {
1109            self.current_texture_id = None;
1110
1111            self.instance_data.push(InstanceData {
1112                translation,
1113                scale: scale_transform,
1114                rotation,
1115                blur_radius: 0.0,
1116                ior_override: 0.0,
1117            });
1118            self.draw_calls.push(DrawCall {
1119                target_id: None,
1120                texture_id: None,
1121                scissor_rect: self.clip_stack.last().copied(),
1122                index_start: (self.indices.len() as u32) - (mesh.indices.len() as u32),
1123                index_count: mesh.indices.len() as u32,
1124                material: cvkg_core::DrawMaterial::Opaque,
1125                instance_start: (self.instance_data.len() - 1) as u32,
1126            });
1127        } else {
1128            self.draw_calls.last_mut().unwrap().index_count += mesh.indices.len() as u32;
1129        }
1130    }
1131
1132    fn draw_mesh_3d(
1133        &mut self,
1134        mesh: &Mesh,
1135        material: &cvkg_core::Material3D,
1136        transform: &cvkg_core::Transform3D,
1137    ) {
1138        let base_idx = self.vertices.len() as u32;
1139        let screen = [self.current_width() as f32, self.current_height() as f32];
1140        let model_matrix = transform.to_matrix();
1141
1142        for i in 0..mesh.vertices.len() {
1143            let pos = model_matrix.transform_point3(glam::Vec3::from(mesh.vertices[i]));
1144            let norm = model_matrix.transform_vector3(glam::Vec3::from(mesh.normals[i]));
1145
1146            self.vertices.push(Vertex {
1147                position: [pos.x, pos.y, pos.z],
1148                normal: [norm.x, norm.y, norm.z],
1149                uv: [0.0, 0.0],
1150                color: material.base_color,
1151                material_id: 13, // Material 13: 3D Surface
1152                radius: 0.0,
1153                slice: [material.metallic, material.roughness, material.opacity, 1.0],
1154                logical: [0.0, 0.0],
1155                size: [0.0, 0.0],
1156                clip: [-f32::INFINITY, -f32::INFINITY, f32::INFINITY, f32::INFINITY],
1157                tex_index: 0,
1158            });
1159        }
1160
1161        for idx in &mesh.indices {
1162            self.indices.push(base_idx + idx);
1163        }
1164
1165        self.instance_data.push(InstanceData {
1166            translation: [0.0, 0.0],
1167            scale: [1.0, 1.0],
1168            rotation: 0.0,
1169            blur_radius: 0.0,
1170            ior_override: 0.0,
1171        });
1172
1173        self.draw_calls.push(DrawCall {
1174            target_id: None,
1175            texture_id: None,
1176            scissor_rect: self.clip_stack.last().copied(),
1177            index_start: (self.indices.len() as u32) - (mesh.indices.len() as u32),
1178            index_count: mesh.indices.len() as u32,
1179            material: cvkg_core::DrawMaterial::Opaque,
1180            instance_start: (self.instance_data.len() - 1) as u32,
1181        });
1182    }
1183
1184    fn set_camera_3d(&mut self, camera: &cvkg_core::Camera3D) {
1185        self.current_scene.proj = camera.projection_matrix();
1186        self.current_scene.view = camera.view_matrix();
1187    }
1188
1189    fn push_transform_3d(&mut self, transform: &cvkg_core::Transform3D) {
1190        // Push a 2D-compatible transform for the existing pipeline
1191        // Use proper matrix decomposition to extract scale correctly (handles rotated matrices)
1192        let (translation, rotation_quat, scale_glam) =
1193            transform.to_matrix().to_scale_rotation_translation();
1194        let translation = [translation.x, translation.y];
1195        let scale = [scale_glam.x, scale_glam.y];
1196        let rotation = if rotation_quat.length_squared() > 0.0 {
1197            let (axis, angle) = rotation_quat.to_axis_angle();
1198            angle * axis.z.signum() // Radians (preserving Z-axis direction)
1199        } else {
1200            0.0
1201        };
1202        self.push_transform(translation, scale, rotation);
1203    }
1204
1205    fn pop_transform_3d(&mut self) {
1206        // Only pop the single transform that was pushed - no double pop
1207        self.pop_transform();
1208    }
1209
1210    fn render_scene_node_3d(
1211        &mut self,
1212        position: [f32; 3],
1213        rotation: [f32; 4],
1214        scale: [f32; 3],
1215        color: [f32; 4],
1216        meshes: &[Mesh],
1217    ) {
1218        let transform = cvkg_core::Transform3D {
1219            position: glam::Vec3::from(position),
1220            rotation: glam::Quat::from_xyzw(rotation[0], rotation[1], rotation[2], rotation[3]),
1221            scale: glam::Vec3::from(scale),
1222        };
1223        // Use provided mesh or generate a default unit cube
1224        if meshes.is_empty() {
1225            // Generate a unit cube mesh on the stack
1226            let h = 0.5f32;
1227            let cube = Mesh {
1228                vertices: vec![
1229                    [-h, -h, -h],
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                ],
1238                normals: vec![
1239                    [0.0, 0.0, -1.0],
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, -1.0, 0.0],
1248                    [0.0, -1.0, 0.0],
1249                    [0.0, -1.0, 0.0],
1250                    [0.0, -1.0, 0.0],
1251                    [1.0, 0.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                    [0.0, 1.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                    [-1.0, 0.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                ],
1264                indices: vec![
1265                    0, 1, 2, 0, 2, 3, // front
1266                    5, 4, 7, 5, 7, 6, // back
1267                    4, 0, 3, 4, 3, 7, // left
1268                    1, 5, 6, 1, 6, 2, // right
1269                    3, 2, 6, 3, 6, 7, // top
1270                    4, 5, 1, 4, 1, 0, // bottom
1271                ],
1272            };
1273            let material = cvkg_core::Material3D::unlit(color);
1274            self.draw_mesh_3d(&cube, &material, &transform);
1275        } else {
1276            let material = cvkg_core::Material3D::unlit(color);
1277            self.draw_mesh_3d(&meshes[0], &material, &transform);
1278        }
1279    }
1280
1281    fn register_shared_element(&mut self, id: &str, rect: Rect) {
1282        self.shared_elements.put(id.to_string(), rect);
1283    }
1284
1285    fn set_z_index(&mut self, z: f32) {
1286        self.current_z = z;
1287    }
1288
1289    fn set_material(&mut self, material: cvkg_core::DrawMaterial) {
1290        self.current_draw_material = material;
1291    }
1292
1293    fn current_material(&self) -> cvkg_core::DrawMaterial {
1294        self.current_draw_material
1295    }
1296
1297    fn get_z_index(&self) -> f32 {
1298        self.current_z
1299    }
1300
1301    fn request_redraw(&mut self) {
1302        self.redraw_requested = true;
1303    }
1304
1305    // -- Portal / PhaseGate rendering -----------------------------------------
1306
1307    /// Begin rendering into the portal root layer instead of the inline tree.
1308    /// All draw calls between `enter_portal` and `exit_portal` are collected
1309    /// into a separate buffer that is composited AFTER the main tree.
1310    ///
1311    /// WHY separate buffer: The main tree may have clipping, transforms, or
1312    /// opacity that should NOT affect overlays. The portal layer renders on top
1313    /// of everything, ignoring the local coordinate system.
1314    ///
1315    /// `z_index` controls the layer ordering for portal content.
1316    fn enter_portal(&mut self, z_index: i32) {
1317        // Portal rendering enables per-element backdrop blur for Tahoe glass
1318        // When z_index is 0, we're rendering normal glass cards
1319        // When z_index > 0, we're in a portal layer that will get special treatment
1320        self.current_z = z_index as f32;
1321    }
1322
1323    /// Exit the portal layer and return to inline rendering.
1324    /// The portal content collected since `enter_portal` is now sealed --
1325    /// no more draw calls will be appended to it.
1326    fn exit_portal(&mut self) {
1327        self.current_z = 0.0;
1328    }
1329
1330    fn push_vnode(&mut self, rect: Rect, name: &'static str) {
1331        self.vnode_stack.push((rect, name));
1332    }
1333
1334    fn pop_vnode(&mut self) {
1335        self.vnode_stack.pop();
1336    }
1337
1338    fn register_handler(
1339        &mut self,
1340        event_type: &str,
1341        handler: std::sync::Arc<dyn Fn(cvkg_core::Event) + Send + Sync>,
1342    ) {
1343        self.event_handlers
1344            .entry(event_type.to_string())
1345            .or_insert_with(Vec::new)
1346            .push(handler);
1347    }
1348
1349    fn load_svg(&mut self, name: &str, svg_data: &[u8]) {
1350        SurtrRenderer::load_svg(self, name, svg_data);
1351    }
1352
1353    fn draw_svg(&mut self, name: &str, rect: Rect) {
1354        SurtrRenderer::draw_svg(self, name, rect, None, 0);
1355    }
1356    fn draw_svg_with_offset(&mut self, name: &str, rect: Rect, animation_time_offset: f32) {
1357        SurtrRenderer::draw_svg_with_offset(self, name, rect, None, 0, animation_time_offset);
1358    }
1359
1360    fn serialize_svg(&mut self, name: &str) -> Result<String, String> {
1361        let tree = self
1362            .svg_trees
1363            .get(name)
1364            .ok_or_else(|| format!("SVG '{}' not found", name))?;
1365        let config = cvkg_svg_serialize::SerializerConfig::default();
1366        let mut serializer = cvkg_svg_serialize::SvgSerializer::with_config(config);
1367        serializer
1368            .serialize(tree)
1369            .map_err(|e| format!("SVG serialization failed: {}", e))
1370    }
1371
1372    fn apply_svg_filter(
1373        &mut self,
1374        name: &str,
1375        filter_id: &str,
1376        _region: Rect,
1377    ) -> Result<String, String> {
1378        let tree = self
1379            .svg_trees
1380            .get(name)
1381            .ok_or_else(|| format!("SVG '{}' not found", name))?;
1382        let _filter = Self::find_filter(tree, filter_id)
1383            .ok_or_else(|| format!("Filter '{}' not found in SVG '{}'", filter_id, name))?;
1384        let config = cvkg_svg_serialize::SerializerConfig::default();
1385        let mut serializer = cvkg_svg_serialize::SvgSerializer::with_config(config);
1386        serializer
1387            .serialize(tree)
1388            .map_err(|e| format!("SVG filter serialization failed: {}", e))
1389    }
1390}
1391
1392// ── Inherent methods on SurtrRenderer (not part of the Renderer trait) ──
1393
1394impl SurtrRenderer {
1395    /// Clear all registered event handlers. Call at the start of each frame
1396    /// before re-rendering the component tree.
1397    pub fn clear_event_handlers(&mut self) {
1398        self.event_handlers.clear();
1399    }
1400
1401    /// Get all registered event handlers for a specific event type.
1402    pub fn get_handlers(
1403        &self,
1404        event_type: &str,
1405    ) -> Option<&Vec<std::sync::Arc<dyn Fn(cvkg_core::Event) + Send + Sync>>> {
1406        self.event_handlers.get(event_type)
1407    }
1408
1409    /// Compute per-vertex transform values from the current matrix.
1410    /// Extracts translation, scale, rotation, and skew from the affine matrix
1411    /// so the existing vertex shader fields still work correctly.
1412    pub(crate) fn current_transform(&self) -> ([f32; 2], [f32; 2], f32, f32, f32) {
1413        // Returns (translation, scale, rotation,
1414        // skew_x, skew_y)
1415        let m = self
1416            .transform_stack
1417            .last()
1418            .copied()
1419            .unwrap_or(glam::Mat3::IDENTITY);
1420        let t = [m.z_axis.x, m.z_axis.y];
1421        // Extract scale and rotation from the 2x2 submatrix
1422        let a = m.x_axis.x;
1423        let b = m.x_axis.y;
1424        let c = m.y_axis.x;
1425        let d = m.y_axis.y;
1426        let sx = (a * a + b * b).sqrt();
1427        let sy = (c * c + d * d).sqrt();
1428        let rotation = b.atan2(a);
1429        // Skew: the angle between the basis vectors minus 90 degrees
1430        let skew_x = (a * c + b * d) / (sx * sy); // sin(skew)
1431        (t, [sx, sy], rotation, skew_x, 0.0)
1432    }
1433
1434    pub fn stroke_path(&mut self, path: &lyon::path::Path, color: [f32; 4], stroke_width: f32) {
1435        let c = self.apply_opacity(color);
1436        let mut tessellator = StrokeTessellator::new();
1437        let mut buffers: VertexBuffers<Vertex, u32> = VertexBuffers::new();
1438        let base_vertex_idx = self.vertices.len() as u32;
1439        let base_index_idx = self.indices.len() as u32;
1440
1441        let (translation, scale, rotation, _, _) = self.current_transform();
1442        let clip_rect = self.clip_stack.last().copied().unwrap_or(cvkg_core::Rect {
1443            x: -10000.0,
1444            y: -10000.0,
1445            width: 20000.0,
1446            height: 20000.0,
1447        });
1448        let clip = [clip_rect.x, clip_rect.y, clip_rect.width, clip_rect.height];
1449
1450        let result = tessellator.tessellate_path(
1451            path,
1452            &StrokeOptions::default().with_line_width(stroke_width),
1453            &mut BuffersBuilder::new(
1454                &mut buffers,
1455                CustomStrokeVertexConstructor { color: c, clip, path_length: 1.0 },
1456            ),
1457        );
1458        if let Err(e) = result {
1459            log::warn!("Failed to tessellate stroke path: {:?}", e);
1460            return;
1461        }
1462
1463        self.vertices.extend(buffers.vertices);
1464        for idx in &buffers.indices {
1465            self.indices.push(base_vertex_idx + *idx);
1466        }
1467
1468        let material = self.current_material();
1469        let tid = self.get_texture_id("__mega_heim");
1470
1471        let last_call = self.draw_calls.last();
1472        let needs_new_call = self.draw_calls.is_empty()
1473            || self.current_texture_id != tid
1474            || last_call.unwrap().scissor_rect != self.clip_stack.last().copied()
1475            || last_call.unwrap().material != material;
1476
1477        if needs_new_call {
1478            self.current_texture_id = tid;
1479
1480            self.instance_data.push(InstanceData {
1481                translation,
1482                scale,
1483                rotation,
1484                blur_radius: 0.0,
1485                ior_override: 0.0,
1486            });
1487            self.draw_calls.push(DrawCall {
1488                target_id: None,
1489                texture_id: tid,
1490                scissor_rect: self.clip_stack.last().copied(),
1491                index_start: base_index_idx,
1492                index_count: buffers.indices.len() as u32,
1493                material,
1494                instance_start: (self.instance_data.len() - 1) as u32,
1495            });
1496        } else if let Some(call) = self.draw_calls.last_mut() {
1497            call.index_count += buffers.indices.len() as u32;
1498        }
1499    }
1500}
1501
1502impl cvkg_core::FrameRenderer<wgpu::CommandEncoder> for SurtrRenderer {
1503    fn begin_frame(&mut self) -> wgpu::CommandEncoder {
1504        cvkg_core::begin_render_phase();
1505        let id = self
1506            .current_window
1507            .expect("No target window set for frame. Call set_target_window first.");
1508        self.begin_frame(id)
1509    }
1510
1511    fn render_frame(&mut self) {
1512        // Visual Lint: If layout was dirtied during the render phase (layout thrashing),
1513        // draw a 10px red border as a warning flash.
1514        if LAYOUT_DIRTY.swap(false, Ordering::AcqRel) {
1515            if let Some(window_id) = self.current_window {
1516                if let Some(surface_ctx) = self.surfaces.get(&window_id) {
1517                    let w = surface_ctx.config.width as f32;
1518                    let h = surface_ctx.config.height as f32;
1519                    let border_rect = cvkg_core::Rect {
1520                        x: 0.0,
1521                        y: 0.0,
1522                        width: w,
1523                        height: h,
1524                    };
1525                    // Draw a thick red border to signal layout-thrashing
1526                    self.stroke_rect(border_rect, [1.0, 0.0, 0.0, 1.0], 10.0);
1527                }
1528            }
1529        }
1530
1531        // Dynamic Buffer Growth (Up to 4x capacity)
1532        let req_v_size = (self.vertices.len() * std::mem::size_of::<Vertex>()) as u64;
1533        let mut cur_v_size = self.vertex_buffer.size();
1534        let max_v_size = (MAX_VERTICES * std::mem::size_of::<Vertex>()) as u64 * 4;
1535
1536        if req_v_size > cur_v_size {
1537            while cur_v_size < req_v_size && cur_v_size < max_v_size {
1538                cur_v_size *= 2;
1539            }
1540            if req_v_size > max_v_size {
1541                log::error!("Exceeded dynamic vertex buffer max capacity! Capping geometry.");
1542                self.vertices
1543                    .truncate((max_v_size / std::mem::size_of::<Vertex>() as u64) as usize);
1544                cur_v_size = max_v_size;
1545            }
1546            log::info!("Growing vertex buffer to {} bytes", cur_v_size);
1547            self.vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
1548                label: Some("Vertex Buffer (Grown)"),
1549                size: cur_v_size,
1550                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1551                mapped_at_creation: false,
1552            });
1553        }
1554
1555        let req_i_size = (self.indices.len() * std::mem::size_of::<u32>()) as u64;
1556        let mut cur_i_size = self.index_buffer.size();
1557        let max_i_size = (MAX_INDICES * std::mem::size_of::<u32>()) as u64 * 4;
1558
1559        if req_i_size > cur_i_size {
1560            while cur_i_size < req_i_size && cur_i_size < max_i_size {
1561                cur_i_size *= 2;
1562            }
1563            if req_i_size > max_i_size {
1564                log::error!("Exceeded dynamic index buffer max capacity! Capping geometry.");
1565                self.indices
1566                    .truncate((max_i_size / std::mem::size_of::<u32>() as u64) as usize);
1567                cur_i_size = max_i_size;
1568            }
1569            log::info!("Growing index buffer to {} bytes", cur_i_size);
1570            self.index_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
1571                label: Some("Index Buffer (Grown)"),
1572                size: cur_i_size,
1573                usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
1574                mapped_at_creation: false,
1575            });
1576        }
1577
1578        // Forge Submission: Sync all geometry to GPU using StagingBelt with a dedicated encoder
1579        let mut staging_encoder =
1580            self.device
1581                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1582                    label: Some("Surtr Staging Encoder"),
1583                });
1584
1585        let mut has_writes = false;
1586
1587        if !self.vertices.is_empty() {
1588            let v_bytes = bytemuck::cast_slice(&self.vertices);
1589            self.staging_belt
1590                .write_buffer(
1591                    &mut staging_encoder,
1592                    &self.vertex_buffer,
1593                    0,
1594                    wgpu::BufferSize::new(v_bytes.len() as u64).unwrap(),
1595                )
1596                .copy_from_slice(v_bytes);
1597            has_writes = true;
1598        }
1599
1600        if !self.indices.is_empty() {
1601            let i_bytes = bytemuck::cast_slice(&self.indices);
1602            self.staging_belt
1603                .write_buffer(
1604                    &mut staging_encoder,
1605                    &self.index_buffer,
1606                    0,
1607                    wgpu::BufferSize::new(i_bytes.len() as u64).unwrap(),
1608                )
1609                .copy_from_slice(i_bytes);
1610            has_writes = true;
1611        }
1612
1613        if !self.instance_data.is_empty() {
1614            let inst_bytes = bytemuck::cast_slice(&self.instance_data);
1615            self.staging_belt
1616                .write_buffer(
1617                    &mut staging_encoder,
1618                    &self.instance_buffer,
1619                    0,
1620                    wgpu::BufferSize::new(inst_bytes.len() as u64).unwrap(),
1621                )
1622                .copy_from_slice(inst_bytes);
1623            has_writes = true;
1624        }
1625
1626        if has_writes {
1627            self.staging_belt.finish();
1628            self.staging_command_buffers.push(staging_encoder.finish());
1629        }
1630
1631        // Update Time & Uniforms (Direct write is fine for small uniforms)
1632        self.current_scene.time = self.start_time.elapsed().as_secs_f32();
1633        self.queue.write_buffer(
1634            &self.scene_buffer,
1635            0,
1636            bytemuck::bytes_of(&self.current_scene),
1637        );
1638        self.queue.write_buffer(
1639            &self.theme_buffer,
1640            0,
1641            bytemuck::bytes_of(&self.current_theme),
1642        );
1643
1644        // Populate telemetry for this frame
1645        self.telemetry.draw_calls = self.draw_calls.len() as u32;
1646        self.telemetry.vertices = self.vertices.len() as u32;
1647    }
1648
1649    fn end_frame(&mut self, encoder: wgpu::CommandEncoder) {
1650        // Delegate to the inherent end_frame which runs the render graph
1651        SurtrRenderer::end_frame(self, encoder);
1652        cvkg_core::end_render_phase();
1653    }
1654}
1655
1656fn glyph_image_to_rgba(image: cvkg_runic_text::GlyphImage) -> (Vec<u8>, u32, u32) {
1657    let width = image.width;
1658    let height = image.height;
1659    let pixels = width.saturating_mul(height) as usize;
1660
1661    if pixels == 0 || image.data.is_empty() {
1662        return (Vec::new(), width, height);
1663    }
1664
1665    let (bytes_per_pixel, remainder) = (image.data.len() / pixels, image.data.len() % pixels);
1666    if remainder != 0 {
1667        log::warn!(
1668            "Glyph rasterizer returned {} bytes for {}x{} glyph; expected whole pixels ({} bytes per pixel)",
1669            image.data.len(),
1670            width,
1671            height,
1672            bytes_per_pixel
1673        );
1674        return (Vec::new(), width, height);
1675    }
1676
1677    let rgba_data = match bytes_per_pixel {
1678        1 => {
1679            let mut data = Vec::with_capacity(pixels * 4);
1680            for alpha in &image.data {
1681                data.push(255);
1682                data.push(255);
1683                data.push(255);
1684                data.push(*alpha);
1685            }
1686            data
1687        }
1688        3 => {
1689            let mut data = Vec::with_capacity(pixels * 4);
1690            for rgb in image.data.chunks_exact(3) {
1691                let alpha = rgb.iter().copied().max().unwrap_or(0);
1692                data.push(255);
1693                data.push(255);
1694                data.push(255);
1695                data.push(alpha);
1696            }
1697            data
1698        }
1699        4 => {
1700            let mut data = image.data;
1701            for chunk in data.chunks_exact_mut(4) {
1702                // If it's a SubpixelMask, swash sets A=0. We need to reconstruct an alpha
1703                // so the shader doesn't discard it.
1704                if chunk[3] == 0 && (chunk[0] > 0 || chunk[1] > 0 || chunk[2] > 0) {
1705                    chunk[3] = chunk[0].max(chunk[1]).max(chunk[2]);
1706                }
1707            }
1708            data
1709        }
1710        _ => {
1711            log::warn!(
1712                "Glyph rasterizer returned unsupported {} bytes per pixel for {}x{} glyph ({} bytes total)",
1713                bytes_per_pixel,
1714                width,
1715                height,
1716                image.data.len()
1717            );
1718            Vec::new()
1719        }
1720    };
1721
1722    (rgba_data, width, height)
1723}
1724
1725#[cfg(test)]
1726mod tests {
1727    use super::glyph_image_to_rgba;
1728
1729    #[test]
1730    fn glyph_image_to_rgba_keeps_rgba_color_data() {
1731        let image = cvkg_runic_text::GlyphImage {
1732            glyph_id: 1,
1733            width: 2,
1734            height: 1,
1735            data: vec![1, 2, 3, 4, 5, 6, 7, 8],
1736            x_offset: 0.0,
1737            y_offset: 0.0,
1738            cache_key: 42,
1739        };
1740
1741        assert_eq!(
1742            glyph_image_to_rgba(image),
1743            (vec![1, 2, 3, 4, 5, 6, 7, 8], 2, 1)
1744        );
1745    }
1746
1747    #[test]
1748    fn glyph_image_to_rgba_expands_grayscale_alpha() {
1749        let image = cvkg_runic_text::GlyphImage {
1750            glyph_id: 1,
1751            width: 3,
1752            height: 1,
1753            data: vec![0, 128, 255],
1754            x_offset: 0.0,
1755            y_offset: 0.0,
1756            cache_key: 42,
1757        };
1758
1759        assert_eq!(
1760            glyph_image_to_rgba(image),
1761            (
1762                vec![255, 255, 255, 0, 255, 255, 255, 128, 255, 255, 255, 255],
1763                3,
1764                1
1765            )
1766        );
1767    }
1768
1769    #[test]
1770    fn glyph_image_to_rgba_collapses_subpixel_rgb_to_alpha() {
1771        let image = cvkg_runic_text::GlyphImage {
1772            glyph_id: 1,
1773            width: 2,
1774            height: 1,
1775            data: vec![0, 128, 255, 255, 0, 64],
1776            x_offset: 0.0,
1777            y_offset: 0.0,
1778            cache_key: 42,
1779        };
1780
1781        assert_eq!(
1782            glyph_image_to_rgba(image),
1783            (vec![255, 255, 255, 255, 255, 255, 255, 255], 2, 1)
1784        );
1785    }
1786}