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