Skip to main content

cvkg_render_gpu/api/
mod.rs

1//! Bridging the internal renderer to `cvkg-core` traits.
2use crate::renderer::GpuRenderer;
3
4pub mod frame;
5pub mod shapes;
6pub mod text;
7
8use crate::renderer::material_id;
9use crate::types::*;
10use crate::vertex::*;
11use cvkg_core::LAYOUT_DIRTY;
12use cvkg_core::{ColorTheme, Mesh, Rect, RenderStateSnapshot, Renderer};
13use lyon::math::point;
14use lyon::tessellation::{BuffersBuilder, StrokeOptions, StrokeTessellator, VertexBuffers};
15use std::hash::{Hash, Hasher};
16use std::sync::atomic::Ordering;
17
18impl cvkg_core::ElapsedTime for GpuRenderer {
19    fn delta_time(&self) -> f32 {
20        self.current_scene.delta_time
21    }
22
23    fn elapsed_time(&self) -> f32 {
24        self.start_time.elapsed().as_secs_f32()
25    }
26}
27
28impl cvkg_core::RendererErrorHandler for GpuRenderer {
29    fn on_render_error(&mut self, error: &cvkg_core::CvkgError) {
30        log::error!("[GpuRenderer] {error}");
31        self.render_error_count += 1;
32    }
33
34    fn on_fatal_error(&mut self, error: &cvkg_core::CvkgError) {
35        log::error!("[GpuRenderer FATAL] {error}");
36        self.has_fatal_error = true;
37    }
38
39    fn has_error(&self) -> bool {
40        self.has_fatal_error
41    }
42}
43
44impl cvkg_core::Renderer for GpuRenderer {
45    fn is_over_budget(&self) -> bool {
46        self.frame_budget.allow_degradation
47            && self.last_frame_start.elapsed().as_secs_f32() * 1000.0 > self.frame_budget.target_ms
48    }
49
50    fn text_scale_factor(&self) -> f32 {
51        self.current_scale_factor()
52    }
53
54    fn prewarm_vram(&mut self, assets: Vec<(String, Vec<u8>)>) {
55        log::info!(
56            "[Surtr] Pre-warming Mega-Heim with {} assets...",
57            assets.len()
58        );
59        for (name, data) in assets {
60            self.load_image_to_heim(&name, &data);
61        }
62    }
63
64    fn fill_rect(&mut self, rect: Rect, color: [f32; 4]) {
65        self.fill_rect_with_mode(rect, self.apply_opacity(color), 0, None);
66    }
67
68    fn fill_rounded_rect(&mut self, rect: Rect, radius: f32, color: [f32; 4]) {
69        self.fill_rect_with_full_params(
70            rect,
71            self.apply_opacity(color),
72            3,
73            None,
74            radius,
75            Rect {
76                x: 0.0,
77                y: 0.0,
78                width: 1.0,
79                height: 1.0,
80            },
81        );
82    }
83
84    /// Fill a rounded rect with glass material for frosted backdrop effect.
85    /// This is the proper way to render glass cards that need macOS Tahoe-style blur.
86    /// The blur_radius controls the intensity of the backdrop blur.
87    /// The glass_intensity controls overall glass effect strength (0.0 = solid, 1.0 = full glass).
88    /// For Tahoe parity, this registers the rect as a portal region for
89    /// per-element isolated backdrop blur when z_index != 0.
90    fn fill_glass_rect(&mut self, rect: Rect, radius: f32, blur_radius: f32) {
91        self.fill_glass_rect_with_intensity(rect, radius, blur_radius, 1.0);
92    }
93
94    /// Fill a rounded rect with glass material with explicit intensity control.
95    /// `glass_intensity` ranges from 0.0 (solid, no glass effect) to 1.0 (full glass).
96    /// This allows per-component control over glass strength.
97    fn fill_glass_rect_with_intensity(
98        &mut self,
99        rect: Rect,
100        radius: f32,
101        blur_radius: f32,
102        glass_intensity: f32,
103    ) {
104        // Default tint: neutral white with moderate alpha, matching pre-tint behavior
105        self.fill_glass_rect_with_tint(
106            rect,
107            radius,
108            blur_radius,
109            [1.0, 1.0, 1.0, 0.4],
110            glass_intensity,
111        );
112    }
113
114    /// Fill a rounded rect with glass material with explicit tint color and intensity.
115    /// `tint_color` is the glass base color (RGBA). `glass_intensity` controls effect strength.
116    fn fill_glass_rect_with_tint(
117        &mut self,
118        rect: Rect,
119        radius: f32,
120        blur_radius: f32,
121        tint_color: [f32; 4],
122        glass_intensity: f32,
123    ) {
124        let gi = glass_intensity.clamp(0.0, 1.0);
125        // Per-instance blur_radius drives the shader's blur_mip level.
126        // Scale: 0-100 input maps to 0-4 mip levels for the Kawase blur chain.
127        let blur_strength = (blur_radius / 25.0).clamp(0.0, 4.0) * gi;
128
129        // Register for portal-aware per-element backdrop blur (Tahoe feature)
130        if self.current_z != 0.0 {
131            self.portal_regions.push_back(rect);
132        }
133
134        // Temporary Material Override Binding
135        let prev_material = self.current_draw_material;
136        self.current_draw_material = cvkg_core::DrawMaterial::Glass {
137            blur_radius: blur_strength,
138            ior_override: 0.0,
139            glass_intensity: gi,
140        };
141
142        // Tint color alpha is modulated by intensity so intensity=0 gives a near-invisible fill
143        let fill_color = [
144            tint_color[0],
145            tint_color[1],
146            tint_color[2],
147            tint_color[3] * gi,
148        ];
149
150        self.fill_rect_with_full_params(
151            rect,
152            fill_color,
153            7, // Mode 7 = Glass material
154            None,
155            radius,
156            Rect {
157                x: 0.0,
158                y: 0.0,
159                width: 1.0,
160                height: 1.0,
161            },
162        );
163
164        self.current_draw_material = prev_material;
165    }
166
167    fn fill_glass_rect_with_pressure(
168        &mut self,
169        rect: Rect,
170        radius: f32,
171        blur_radius: f32,
172        pressure: f32,
173    ) {
174        // Pressure scales both blur and tint: full pressure = full glass effect
175        let p = pressure.clamp(0.0, 1.0);
176        self.fill_glass_rect_with_intensity(rect, radius, blur_radius * p, p);
177    }
178
179    /// Set the default background color for the canvas.
180    /// This color is used when the app does not draw its own background.
181    /// Default: `[0.02, 0.02, 0.05, 1.0]` (Deep Void).
182    fn set_default_background_color(&mut self, color: [f32; 4]) {
183        self.default_background_color = color;
184    }
185
186    /// Fill a squircle (superellipse) for Apple-style icon silhouettes.
187    /// `n` controls the squareness: 2.0 = rounded rect, 4.0 = classic squircle, higher = more square.
188    fn fill_squircle(&mut self, rect: Rect, n: f32, color: [f32; 4]) {
189        let prev_material = self.current_draw_material;
190        self.current_draw_material = cvkg_core::DrawMaterial::Opaque;
191        self.fill_rect_with_full_params(
192            rect,
193            self.apply_opacity(color),
194            0,
195            None,
196            rect.width.min(rect.height) * 0.22 * (n / 4.0),
197            Rect {
198                x: 0.0,
199                y: 0.0,
200                width: 1.0,
201                height: 1.0,
202            },
203        );
204        self.current_draw_material = prev_material;
205    }
206
207    /// Stroke a squircle (superellipse) outline.
208    fn stroke_squircle(&mut self, rect: Rect, n: f32, color: [f32; 4], stroke_width: f32) {
209        let prev_material = self.current_draw_material;
210        self.current_draw_material = cvkg_core::DrawMaterial::Opaque;
211        self.fill_rect_with_full_params(
212            rect,
213            self.apply_opacity(color),
214            material_id::SQUIRCLE_STROKE,
215            None,
216            rect.width.min(rect.height) * 0.22 * (n / 4.0),
217            Rect {
218                x: stroke_width,
219                y: 0.0,
220                width: 0.0,
221                height: 0.0,
222            },
223        );
224        self.current_draw_material = prev_material;
225    }
226
227    /// Draw a focus ring around a rect (for keyboard navigation accessibility).
228    /// `offset` is the gap between the rect and the ring, `width` is the ring thickness.
229    fn draw_focus_ring(
230        &mut self,
231        rect: Rect,
232        radius: f32,
233        offset: f32,
234        width: f32,
235        color: [f32; 4],
236    ) {
237        let ring_rect = Rect {
238            x: rect.x - offset,
239            y: rect.y - offset,
240            width: rect.width + 2.0 * offset,
241            height: rect.height + 2.0 * offset,
242        };
243        self.stroke_squircle(ring_rect, 4.0, color, width);
244    }
245
246    fn fill_ellipse(&mut self, rect: Rect, color: [f32; 4]) {
247        self.fill_rect_with_full_params(
248            rect,
249            self.apply_opacity(color),
250            4,
251            None,
252            0.0,
253            Rect {
254                x: 0.0,
255                y: 0.0,
256                width: 1.0,
257                height: 1.0,
258            },
259        );
260    }
261
262    fn draw_3d_cube(&mut self, rect: Rect, color: [f32; 4], rotation: [f32; 3]) {
263        self.fill_rect_with_full_params_and_slice(
264            rect,
265            self.apply_opacity(color),
266            material_id::MESH_3D,
267            None,
268            0.0,
269            Rect {
270                x: 0.0,
271                y: 0.0,
272                width: 1.0,
273                height: 1.0,
274            },
275            [rotation[0], rotation[1], rotation[2], 0.0],
276            [0.0, 0.0],
277        );
278    }
279
280    fn bifrost(&mut self, rect: Rect, blur: f32, _saturation: f32, opacity: f32) {
281        // Calculate screen-space UVs for high-fidelity global refraction
282        let logical_w = self.current_width() as f32 / self.current_scale_factor();
283        let logical_h = self.current_height() as f32 / self.current_scale_factor();
284        let screen_uv = Rect {
285            x: rect.x / logical_w,
286            y: rect.y / logical_h,
287            width: rect.width / logical_w,
288            height: rect.height / logical_h,
289        };
290        // Use mode 7 for high-fidelity background blur sampling
291        // Use the blur parameter as corner radius for the glass panel
292        self.fill_rect_with_full_params(rect, [1.0, 1.0, 1.0, opacity], 7, None, blur, screen_uv);
293    }
294
295    fn gungnir(&mut self, rect: Rect, color: [f32; 4], radius: f32, intensity: f32) {
296        // Single draw call via SDF glow material instead of 4 additive rects
297        let margin = radius;
298        let glow_rect = Rect {
299            x: rect.x - margin,
300            y: rect.y - margin,
301            width: rect.width + 2.0 * margin,
302            height: rect.height + 2.0 * margin,
303        };
304        let glow_color = [color[0], color[1], color[2], intensity * 0.3];
305        self.fill_rect_with_full_params(
306            glow_rect,
307            self.apply_opacity(glow_color),
308            material_id::DROP_SHADOW,
309            None,
310            8.0,
311            Rect {
312                x: margin,
313                y: radius,
314                width: 0.0,
315                height: 0.0,
316            },
317        );
318    }
319
320    /// Soft glow variant -- half the intensity of gungnir().
321    /// Use for hover highlights, non-critical indicators.
322    fn gungnir_soft(&mut self, rect: Rect, color: [f32; 4], radius: f32, intensity: f32) {
323        self.gungnir(rect, color, radius, intensity * 0.5);
324    }
325
326    /// Renders a dynamic glowing hover boundary field around a hit target.
327    ///
328    /// # Contract
329    /// Expands the bounding box of the visual target by `radius` to establish
330    /// a continuous proximity glow. Uses the drop shadow/glow SDF material
331    /// to rasterize the glow with specialized radius-to-margin uv coordinate mappings.
332    fn mani_glow(&mut self, rect: Rect, color: [f32; 4], radius: f32) {
333        let margin = radius;
334        let glow_rect = Rect {
335            x: rect.x - margin,
336            y: rect.y - margin,
337            width: rect.width + 2.0 * margin,
338            height: rect.height + 2.0 * margin,
339        };
340        let uv_rect = Rect {
341            x: margin,
342            y: radius,
343            width: 0.0,
344            height: 0.0,
345        };
346        self.fill_rect_with_full_params(
347            glow_rect,
348            self.apply_opacity(color),
349            material_id::DROP_SHADOW,
350            None,
351            8.0,
352            uv_rect,
353        );
354    }
355
356    fn stroke_rect(&mut self, rect: Rect, color: [f32; 4], stroke_width: f32) {
357        let c = self.apply_opacity(color);
358        // Single draw call via SDF stroke material instead of 4 edge bars
359        self.fill_rect_with_full_params(
360            rect,
361            c,
362            material_id::SQUIRCLE_STROKE,
363            None,
364            0.0, // radius = 0 for sharp rect corners
365            Rect {
366                x: stroke_width,
367                y: 0.0,
368                width: 0.0,
369                height: 0.0,
370            },
371        );
372    }
373
374    fn stroke_rounded_rect(&mut self, rect: Rect, radius: f32, color: [f32; 4], stroke_width: f32) {
375        self.fill_rect_with_full_params(
376            rect,
377            self.apply_opacity(color),
378            material_id::SQUIRCLE_STROKE,
379            None,
380            radius,
381            Rect {
382                x: stroke_width,
383                y: 0.0,
384                width: 0.0,
385                height: 0.0,
386            },
387        );
388    }
389
390    fn stroke_ellipse(&mut self, rect: Rect, color: [f32; 4], stroke_width: f32) {
391        // Tessellate an ellipse stroke using Lyon's StrokeTessellator.
392        let cx = rect.x + rect.width / 2.0;
393        let cy = rect.y + rect.height / 2.0;
394        let rx = rect.width / 2.0;
395        let ry = rect.height / 2.0;
396
397        // Build an ellipse path using Lyon
398        let mut builder = lyon::path::Path::builder();
399        if rx > 0.0 && ry > 0.0 {
400            // Approximate ellipse with 64 segments
401            let segments = 64;
402            for i in 0..segments {
403                let angle = 2.0 * std::f32::consts::PI * (i as f32) / (segments as f32);
404                let x = cx + rx * angle.cos();
405                let y = cy + ry * angle.sin();
406                if i == 0 {
407                    builder.begin(lyon::math::point(x, y));
408                } else {
409                    builder.line_to(lyon::math::point(x, y));
410                }
411            }
412            builder.close();
413        }
414        let path = builder.build();
415        self.stroke_path(&path, color, stroke_width);
416    }
417
418    fn draw_linear_gradient(
419        &mut self,
420        rect: Rect,
421        start_color: [f32; 4],
422        end_color: [f32; 4],
423        angle: f32,
424    ) {
425        self.fill_rect_with_full_params_and_slice(
426            rect,
427            self.apply_opacity(start_color),
428            15,
429            None,
430            0.0,
431            Rect {
432                x: angle,
433                y: 0.0,
434                width: 1.0,
435                height: 1.0,
436            },
437            end_color,
438            [0.0, 0.0],
439        );
440    }
441
442    fn draw_radial_gradient(&mut self, rect: Rect, inner_color: [f32; 4], outer_color: [f32; 4]) {
443        self.fill_rect_with_full_params_and_slice(
444            rect,
445            self.apply_opacity(inner_color),
446            material_id::RADIAL_GRADIENT,
447            None,
448            0.0,
449            Rect {
450                x: 0.0,
451                y: 0.0,
452                width: 1.0,
453                height: 1.0,
454            },
455            outer_color,
456            [0.0, 0.0],
457        );
458    }
459
460    fn draw_linear_gradient_multi(&mut self, rect: Rect, stops: &[[f32; 4]], angle: f32) {
461        self.draw_gradient_multi(rect, stops, angle, false);
462    }
463
464    fn draw_radial_gradient_multi(&mut self, rect: Rect, stops: &[[f32; 4]]) {
465        self.draw_gradient_multi(rect, stops, 0.0, true);
466    }
467
468    fn draw_drop_shadow(
469        &mut self,
470        rect: Rect,
471        radius: f32,
472        color: [f32; 4],
473        blur: f32,
474        spread: f32,
475    ) {
476        let margin = blur + spread;
477        let inflated = Rect {
478            x: rect.x - margin,
479            y: rect.y - margin,
480            width: rect.width + margin * 2.0,
481            height: rect.height + margin * 2.0,
482        };
483        // uv.x = total margin (for SDF offset), uv.y = blur width (for falloff)
484        self.fill_rect_with_full_params_and_slice(
485            inflated,
486            self.apply_opacity(color),
487            material_id::DROP_SHADOW,
488            None,
489            radius,
490            Rect {
491                x: margin,
492                y: blur,
493                width: 0.0,
494                height: 0.0,
495            },
496            [0.0, 0.0, 0.0, 1.0],
497            [0.0, 0.0],
498        );
499    }
500
501    fn stroke_dashed_rounded_rect(
502        &mut self,
503        rect: Rect,
504        radius: f32,
505        color: [f32; 4],
506        width: f32,
507        dash: f32,
508        gap: f32,
509    ) {
510        self.fill_rect_with_full_params(
511            rect,
512            self.apply_opacity(color),
513            material_id::DASHED_STROKE,
514            None,
515            radius,
516            Rect {
517                x: width,
518                y: dash,
519                width: gap,
520                height: 0.0,
521            },
522        );
523    }
524
525    fn draw_9slice(
526        &mut self,
527        image_name: &str,
528        rect: Rect,
529        left: f32,
530        top: f32,
531        right: f32,
532        bottom: f32,
533    ) {
534        let c = self.apply_opacity([1.0, 1.0, 1.0, 1.0]);
535        let tid = self.get_texture_id(image_name);
536        self.fill_rect_with_full_params(
537            rect,
538            c,
539            20,
540            tid,
541            bottom,
542            Rect {
543                x: left,
544                y: top,
545                width: right,
546                height: 0.0,
547            },
548        );
549    }
550
551    fn draw_line(
552        &mut self,
553        x1: f32,
554        y1: f32,
555        x2: f32,
556        y2: f32,
557        color: [f32; 4],
558        stroke_width: f32,
559    ) {
560        let dx = x2 - x1;
561        let dy = y2 - y1;
562        let len_sq = dx * dx + dy * dy;
563        if len_sq < 0.000001 {
564            return;
565        }
566        let len = len_sq.sqrt();
567        let half_w = stroke_width * 0.5;
568        // Perpendicular unit vector
569        let nx = -dy / len * half_w;
570        let ny = dx / len * half_w;
571        // Build 4 corner points of the line quad
572        let points = [
573            [x1 + nx, y1 + ny],
574            [x2 + nx, y2 + ny],
575            [x2 - nx, y2 - ny],
576            [x1 - nx, y1 - ny],
577        ];
578        self.push_oriented_quad(
579            points,
580            color,
581            1,
582            Rect {
583                x: 0.0,
584                y: 0.0,
585                width: 1.0,
586                height: 1.0,
587            },
588        );
589    }
590
591    fn draw_image(&mut self, image_name: &str, rect: Rect) {
592        // Guard: skip if image not loaded -- avoids rendering garbage from uninitialized atlas regions
593        if !self.image_uv_registry.contains(image_name) {
594            log::warn!("[Surtr] draw_image: '{}' not loaded, skipping", image_name);
595            return;
596        }
597        let tid = self
598            .get_texture_id(image_name)
599            .or_else(|| self.get_texture_id("__mega_heim"));
600        let uv_rect = self
601            .image_uv_registry
602            .get(image_name)
603            .copied()
604            .unwrap_or(Rect {
605                x: 0.0,
606                y: 0.0,
607                width: 1.0,
608                height: 1.0,
609            });
610        self.fill_rect_with_full_params(rect, [1.0, 1.0, 1.0, 1.0], 2, tid, 0.0, uv_rect);
611    }
612
613    fn shape_rich_text(
614        &mut self,
615        spans: &[cvkg_runic_text::TextSpan],
616        max_width: Option<f32>,
617        align: cvkg_runic_text::TextAlign,
618        overflow: cvkg_runic_text::TextOverflow,
619    ) -> Option<cvkg_runic_text::ShapedText> {
620        self.shape_rich_text_impl(spans, max_width, align, overflow)
621    }
622
623    fn draw_shaped_text(&mut self, shaped: &cvkg_runic_text::ShapedText, x: f32, y: f32) {
624        self.draw_shaped_text_impl(shaped, x, y);
625    }
626
627    fn draw_texture(&mut self, texture_id: u32, rect: Rect) {
628        self.fill_rect_with_full_params_and_slice(
629            rect,
630            [1.0, 1.0, 1.0, 1.0],
631            2,
632            Some(texture_id),
633            0.0,
634            Rect {
635                x: 0.0,
636                y: 0.0,
637                width: 1.0,
638                height: 1.0,
639            },
640            [0.0, 0.0, 0.0, 1.0],
641            [0.0, 0.0],
642        );
643    }
644
645    /// load_image -- Proactively pushes a raw asset into the Mega-Heim.
646    /// load_image -- Proactively pushes a raw asset into the Texture Array.
647    fn load_image(&mut self, name: &str, data: &[u8]) {
648        if self.image_uv_registry.contains(name) {
649            return;
650        }
651        let img_result = image::load_from_memory(data);
652        let img = match img_result {
653            Ok(img) => img.to_rgba8(),
654            Err(e) => {
655                log::error!("Failed to load image {}: {}", name, e);
656                image::RgbaImage::from_pixel(1, 1, image::Rgba([255, 255, 255, 255]))
657            }
658        };
659        let (width, height) = img.dimensions();
660
661        let size = wgpu::Extent3d {
662            width,
663            height,
664            depth_or_array_layers: 1,
665        };
666        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
667            label: Some(&format!("Texture Array Layer: {}", name)),
668            size,
669            mip_level_count: 1,
670            sample_count: 1,
671            dimension: wgpu::TextureDimension::D2,
672            format: wgpu::TextureFormat::Rgba8UnormSrgb,
673            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
674            view_formats: &[],
675        });
676
677        self.queue.write_texture(
678            wgpu::TexelCopyTextureInfo {
679                texture: &texture,
680                mip_level: 0,
681                origin: wgpu::Origin3d::ZERO,
682                aspect: wgpu::TextureAspect::All,
683            },
684            &img,
685            wgpu::TexelCopyBufferLayout {
686                offset: 0,
687                bytes_per_row: Some(4 * width),
688                rows_per_image: Some(height),
689            },
690            size,
691        );
692
693        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
694
695        // Slot allocation (Skip index 0 which is the dummy/atlas)
696        // texture_views is a fixed 32-element Vec; indices 1..=31 are usable.
697        let index = if self.texture_registry.len() < 31 {
698            (self.texture_registry.len() + 1) as u32
699        } else {
700            // Evict the least recently used texture and reuse its slot.
701            // The bind group cache is invalidated below by rebuilding.
702            if let Some((old_name, old_index)) = self.texture_registry.pop_lru() {
703                self.image_uv_registry.pop(&old_name);
704                old_index
705            } else {
706                log::warn!("[GPU] texture registry full and no LRU entry to evict");
707                return;
708            }
709        };
710
711        // Bounds guard: index must be in 1..32 (index 0 is the atlas).
712        if index == 0 || index as usize >= self.texture_views.len() {
713            log::error!(
714                "[GPU] load_image: invalid texture index {} (registry has {} entries)",
715                index,
716                self.texture_registry.len()
717            );
718            return;
719        }
720
721        self.texture_views[index as usize] = view;
722        self.image_uv_registry.put(
723            name.to_string(),
724            Rect {
725                x: 0.0,
726                y: 0.0,
727                width: 1.0,
728                height: 1.0,
729            },
730        );
731        self.texture_registry.put(name.to_string(), index);
732        self.rebuild_texture_array_bind_group();
733    }
734
735    fn push_clip_rect(&mut self, rect: Rect) {
736        self.clip_stack.push(rect);
737    }
738
739    fn pop_clip_rect(&mut self) {
740        self.clip_stack.pop();
741    }
742
743    fn current_clip_rect(&self) -> Rect {
744        self.clip_stack.last().copied().unwrap_or(Rect::new(
745            0.0,
746            0.0,
747            self.current_width() as f32,
748            self.current_height() as f32,
749        ))
750    }
751
752    fn memoize(&mut self, id: u64, data_hash: u64, render_fn: &dyn Fn(&mut dyn Renderer)) {
753        // P0-4 fix: actually cache and replay GPU draw commands.
754        //
755        // The previous implementation only cached `(data_hash, frame_generation)`
756        // and emitted ZERO draw calls on the skip path. Any content using
757        // `memoize` rendered once and then vanished on every subsequent frame.
758        //
759        // The fix: on first call (or when hash changes), record the vertex/
760        // index/instance buffers and DrawCall list produced by `render_fn`,
761        // with offsets remapped relative to the captured slice. On replay,
762        // append the cached buffers to the current buffer state and shift
763        // the cached DrawCall offsets by the current buffer length so the
764        // replayed commands reference the freshly-appended data.
765        use crate::types::{DrawCall, MemoEntry};
766
767        let should_skip = self
768            .memo_cache
769            .get(&id)
770            .is_some_and(|entry| entry.hash == data_hash);
771
772        if should_skip {
773            // Replay path: append cached buffers and remap cached DrawCall offsets.
774            if let Some(entry) = self.memo_cache.get(&id) {
775                let i_offset = self.indices.len() as u32;
776                let inst_offset = self.instance_data.len() as u32;
777
778                self.vertices.extend_from_slice(&entry.vertices);
779                self.indices.extend_from_slice(&entry.indices);
780                self.instance_data.extend_from_slice(&entry.instance_data);
781
782                for dc in &entry.draw_calls {
783                    let mut replayed = dc.clone();
784                    // Offsets stored relative to the captured slice start;
785                    // shift them by the current buffer lengths so they
786                    // reference the freshly-appended data.
787                    replayed.index_start += i_offset;
788                    replayed.instance_start += inst_offset;
789                    self.draw_calls.push(replayed);
790                }
791            }
792        } else {
793            // Capture path: snapshot lengths, render, then record deltas.
794            let v_start = self.vertices.len();
795            let i_start = self.indices.len();
796            let inst_start = self.instance_data.len();
797            let dc_start = self.draw_calls.len();
798
799            render_fn(self);
800
801            // Remap DrawCall offsets to be relative to the captured slice.
802            let draw_calls: Vec<DrawCall> = self.draw_calls[dc_start..]
803                .iter()
804                .map(|dc| {
805                    let mut remapped = dc.clone();
806                    // saturating_sub guards against underflow if a draw call
807                    // somehow already had an offset below the slice start
808                    // (should not happen, but defensive).
809                    remapped.index_start = remapped.index_start.saturating_sub(i_start as u32);
810                    remapped.instance_start =
811                        remapped.instance_start.saturating_sub(inst_start as u32);
812                    remapped
813                })
814                .collect();
815
816            let entry = MemoEntry {
817                hash: data_hash,
818                frame_gen: self.frame_generation,
819                vertices: self.vertices[v_start..].to_vec(),
820                indices: self.indices[i_start..].to_vec(),
821                instance_data: self.instance_data[inst_start..].to_vec(),
822                draw_calls,
823            };
824
825            self.memo_cache.insert(id, entry);
826        }
827    }
828
829    fn snapshot_render_state(&self) -> RenderStateSnapshot {
830        RenderStateSnapshot {
831            clip_depth: self.clip_stack.len() as u32,
832            opacity_depth: self.opacity_stack.len() as u32,
833            slice_depth: self.slice_stack.len() as u32,
834            shadow_depth: self.shadow_stack.len() as u32,
835            transform_depth: self.transform_stack.len() as u32,
836            vnode_depth: self.vnode_stack.len() as u32,
837        }
838    }
839
840    fn restore_render_state(&mut self, snap: RenderStateSnapshot) {
841        // Idempotent: pop only items pushed beyond the snapshot point.
842        while self.clip_stack.len() as u32 > snap.clip_depth {
843            self.clip_stack.pop();
844        }
845        while self.opacity_stack.len() as u32 > snap.opacity_depth {
846            self.opacity_stack.pop();
847        }
848        while self.slice_stack.len() as u32 > snap.slice_depth {
849            self.slice_stack.pop();
850        }
851        while self.shadow_stack.len() as u32 > snap.shadow_depth {
852            self.shadow_stack.pop();
853        }
854        while self.transform_stack.len() as u32 > snap.transform_depth {
855            self.transform_stack.pop();
856        }
857        while self.vnode_stack.len() as u32 > snap.vnode_depth {
858            self.vnode_stack.pop();
859        }
860    }
861
862    fn push_opacity(&mut self, opacity: f32) {
863        let current = self.opacity_stack.last().copied().unwrap_or(1.0);
864        self.opacity_stack.push(current * opacity);
865    }
866
867    fn pop_opacity(&mut self) {
868        self.opacity_stack.pop();
869    }
870
871    fn push_shadow(&mut self, radius: f32, color: [f32; 4], offset: [f32; 2]) {
872        self.shadow_stack.push(ShadowState {
873            radius,
874            color,
875            _offset: offset,
876        });
877    }
878
879    fn pop_shadow(&mut self) {
880        self.shadow_stack.pop();
881    }
882
883    fn push_transform(&mut self, translation: [f32; 2], scale: [f32; 2], rotation: f32) {
884        let c = rotation.cos();
885        let sn = rotation.sin();
886        let affine = glam::Mat3::from_cols(
887            glam::Vec3::new(c * scale[0], sn * scale[0], 0.0),
888            glam::Vec3::new(-sn * scale[1], c * scale[1], 0.0),
889            glam::Vec3::new(translation[0], translation[1], 1.0),
890        );
891
892        let parent = self
893            .transform_stack
894            .last()
895            .copied()
896            .unwrap_or(glam::Mat3::IDENTITY);
897        self.transform_stack.push(parent * affine);
898    }
899
900    fn push_affine(&mut self, transform: [f32; 6]) {
901        let affine = glam::Mat3::from_cols(
902            glam::Vec3::new(transform[0], transform[1], 0.0),
903            glam::Vec3::new(transform[2], transform[3], 0.0),
904            glam::Vec3::new(transform[4], transform[5], 1.0),
905        );
906        let parent = self
907            .transform_stack
908            .last()
909            .copied()
910            .unwrap_or(glam::Mat3::IDENTITY);
911        self.transform_stack.push(parent * affine);
912    }
913
914    fn pop_transform(&mut self) {
915        self.transform_stack.pop();
916    }
917
918    fn set_theme(&mut self, theme: ColorTheme) {
919        self.current_theme = theme;
920        self.queue
921            .write_buffer(&self.theme_buffer, 0, bytemuck::bytes_of(&theme));
922    }
923
924    fn set_rage(&mut self, rage: f32) {
925        self.current_scene.berzerker_rage = rage;
926        // scene_buffer is updated every frame in begin_frame, so no need to write here
927    }
928
929    fn set_fireball_pos(&mut self, pos: [f32; 2]) {
930        self.current_scene.fireball_pos = pos;
931    }
932
933    fn trigger_shatter_event(&mut self, origin: [f32; 2], force: f32) {
934        self.current_scene.shatter_origin = origin;
935        self.current_scene.shatter_time = self.current_scene.time;
936        self.current_scene.shatter_force = force;
937    }
938
939    fn set_scene_preset(&mut self, preset: u32) {
940        self.current_scene.scene_type = preset;
941    }
942
943    /// push_mjolnir_slice -- Pushes a geometric clipping plane onto the stack.
944    /// All subsequent draw calls will be sliced by this plane until it is popped.
945    fn push_mjolnir_slice(&mut self, angle: f32, offset: f32) {
946        self.slice_stack.push((angle, offset));
947    }
948
949    /// pop_mjolnir_slice -- Removes the top-most geometric clipping plane from the stack.
950    fn pop_mjolnir_slice(&mut self) {
951        self.slice_stack.pop();
952    }
953
954    fn mjolnir_shatter(&mut self, rect: Rect, pieces: u32, force: f32, color: [f32; 4]) {
955        self.shatter_internal(rect, pieces, force, color, 8);
956    }
957
958    fn mjolnir_fluid_shatter(&mut self, rect: Rect, pieces: u32, force: f32, color: [f32; 4]) {
959        self.shatter_internal(rect, pieces, force, color, 11);
960    }
961
962    fn draw_mjolnir_bolt(&mut self, from: [f32; 2], to: [f32; 2], color: [f32; 4]) {
963        self.recursive_bolt(from, to, 4, color);
964    }
965
966    fn dispatch_particles(
967        &mut self,
968        origin: [f32; 2],
969        count: u32,
970        effect_type: &str,
971        color: [f32; 4],
972    ) {
973        use crate::types::{GpuParticle, MAX_PARTICLES};
974
975        let dt = self.current_scene.delta_time;
976        let now = std::time::Instant::now();
977
978        // Determine spawn parameters based on effect type
979        let (speed_range, life_range, spread_angle) = match effect_type {
980            "firework" => (100.0..300.0, 1.0..2.5, std::f32::consts::TAU),
981            "spark" => (50.0..150.0, 0.5..1.5, std::f32::consts::PI),
982            "rain" => (20.0..80.0, 1.0..3.0, std::f32::consts::FRAC_PI_4),
983            "data_stream" => (80.0..200.0, 0.8..2.0, std::f32::consts::FRAC_PI_6),
984            "bubble" => (10.0..40.0, 2.0..4.0, std::f32::consts::TAU),
985            _ => (30.0..120.0, 1.0..2.0, std::f32::consts::TAU),
986        };
987
988        let count = count.min((MAX_PARTICLES - self.particles.count as usize) as u32);
989        if count == 0 {
990            return;
991        }
992
993        let mut rng_state = (now.elapsed().as_nanos() as u64)
994            .wrapping_mul(6364136223846793005)
995            .wrapping_add(1442695040888963407);
996        let mut rand_f32 = |range: std::ops::Range<f32>| -> f32 {
997            rng_state = rng_state
998                .wrapping_mul(6364136223846793005)
999                .wrapping_add(1442695040888963407);
1000            let t = (rng_state >> 33) as f32 / (1u64 << 31) as f32;
1001            range.start + t * (range.end - range.start)
1002        };
1003
1004        for _ in 0..count {
1005            let angle = rand_f32(0.0..spread_angle);
1006            let speed = rand_f32(speed_range.clone());
1007            let life = rand_f32(life_range.clone());
1008            let vx = angle.cos() * speed;
1009            let vy = angle.sin() * speed;
1010
1011            let particle = GpuParticle {
1012                pos_vel: [origin[0], origin[1], vx, vy],
1013                color_life: [color[0], color[1], color[2], life],
1014            };
1015            self.particles.staging.push(particle);
1016        }
1017
1018        log::debug!(
1019            "[Surtr] dispatch_particles: {} {} particles at {:?} (staged, {} total pending)",
1020            count,
1021            effect_type,
1022            origin,
1023            self.particles.staging.len()
1024        );
1025    }
1026
1027    fn draw_hologram(&mut self, rect: Rect, hologram_id: &str, time: f32) {
1028        use std::hash::{Hash, Hasher};
1029        let mut hasher = std::collections::hash_map::DefaultHasher::new();
1030        hologram_id.hash(&mut hasher);
1031        let id_hash = hasher.finish() as u32;
1032
1033        log::debug!(
1034            "[Surtr] draw_hologram: {} at {:?} t={} (hologram pipeline)",
1035            hologram_id,
1036            rect,
1037            time
1038        );
1039
1040        self.hologram_instances
1041            .push(crate::renderer::HologramInstance {
1042                rect,
1043                id_hash,
1044                time,
1045            });
1046        self.volumetric_enabled = true;
1047    }
1048
1049    fn upload_data_texture(&mut self, id: &str, data: &[f32], width: u32, height: u32) {
1050        let size = wgpu::Extent3d {
1051            width,
1052            height,
1053            depth_or_array_layers: 1,
1054        };
1055        let texture = self.device.create_texture(&wgpu::TextureDescriptor {
1056            label: Some(id),
1057            size,
1058            mip_level_count: 1,
1059            sample_count: 1,
1060            dimension: wgpu::TextureDimension::D2,
1061            format: wgpu::TextureFormat::R32Float,
1062            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
1063            view_formats: &[],
1064        });
1065        self.queue.write_texture(
1066            wgpu::TexelCopyTextureInfo {
1067                texture: &texture,
1068                mip_level: 0,
1069                origin: wgpu::Origin3d::ZERO,
1070                aspect: wgpu::TextureAspect::All,
1071            },
1072            bytemuck::cast_slice(data),
1073            wgpu::TexelCopyBufferLayout {
1074                offset: 0,
1075                bytes_per_row: Some(4 * width),
1076                rows_per_image: Some(height),
1077            },
1078            size,
1079        );
1080        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
1081        // Reuse the renderer's pre-created linear sampler (ClampToEdge + Linear)
1082        // instead of allocating a new sampler on every upload.
1083        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
1084            layout: &self.texture_bind_group_layout,
1085            entries: &[
1086                wgpu::BindGroupEntry {
1087                    binding: 0,
1088                    // The layout requires 32 entries; only index 0 is the actual texture.
1089                    resource: wgpu::BindingResource::TextureViewArray(&vec![&view; 32]),
1090                },
1091                wgpu::BindGroupEntry {
1092                    binding: 1,
1093                    resource: wgpu::BindingResource::Sampler(&self.linear_sampler),
1094                },
1095            ],
1096            label: Some(id),
1097        });
1098        self.texture_bind_groups.push(bind_group);
1099        let tid = (self.texture_bind_groups.len() - 1) as u32;
1100        self.texture_registry.put(id.to_string(), tid);
1101    }
1102
1103    fn draw_heatmap(&mut self, texture_id: &str, rect: Rect, _palette: &str) {
1104        let tid = self.get_texture_id(texture_id);
1105        self.fill_rect_with_mode(rect, [1.0, 1.0, 1.0, 1.0], 12, tid);
1106    }
1107
1108    fn draw_mesh(&mut self, mesh: &Mesh, color: [f32; 4], transform: glam::Mat4) {
1109        let base_idx = self.vertices.len() as u32;
1110
1111        for i in 0..mesh.vertices.len() {
1112            let pos = transform.transform_point3(glam::Vec3::from(mesh.vertices[i]));
1113            let norm = transform.transform_vector3(glam::Vec3::from(mesh.normals[i]));
1114
1115            self.vertices.push(Vertex {
1116                position: pos.to_array(),
1117                normal: norm.to_array(),
1118                uv: [0.0, 0.0],
1119                color,
1120                material_id: 13, // Material 13: 3D Surface
1121                radius: 0.0,
1122                slice: [0.0, 0.0, 0.0, 1.0],
1123                logical: [0.0, 0.0],
1124                size: [0.0, 0.0],
1125                clip: [-f32::INFINITY, -f32::INFINITY, f32::INFINITY, f32::INFINITY],
1126                tex_index: 0,
1127            });
1128        }
1129
1130        for idx in &mesh.indices {
1131            self.indices.push(base_idx + idx);
1132        }
1133
1134        let (translation, scale_transform, rotation, _, _) = self.current_transform();
1135
1136        if self.draw_calls.is_empty() || self.current_texture_id.is_some() {
1137            self.current_texture_id = None;
1138
1139            self.instance_data.push(InstanceData {
1140                translation,
1141                scale: scale_transform,
1142                rotation,
1143                blur_radius: 0.0,
1144                ior_override: 0.0,
1145                glass_intensity: 1.0,
1146            });
1147            self.draw_calls.push(DrawCall {
1148                target_id: None,
1149                texture_id: None,
1150                scissor_rect: self.clip_stack.last().copied(),
1151                index_start: (self.indices.len() as u32) - (mesh.indices.len() as u32),
1152                index_count: mesh.indices.len() as u32,
1153                instance_count: 1,
1154                material: cvkg_core::DrawMaterial::Opaque,
1155                instance_start: (self.instance_data.len() - 1) as u32,
1156                draw_order: 0,
1157            });
1158        } else {
1159            self.draw_calls.last_mut().unwrap().index_count += mesh.indices.len() as u32;
1160        }
1161    }
1162
1163    fn draw_mesh_3d(
1164        &mut self,
1165        mesh: &Mesh,
1166        material: &cvkg_core::Material3D,
1167        transform: &cvkg_core::Transform3D,
1168    ) {
1169        let base_idx = self.vertices.len() as u32;
1170        let model_matrix = transform.to_matrix();
1171
1172        for i in 0..mesh.vertices.len() {
1173            let pos = model_matrix.transform_point3(glam::Vec3::from(mesh.vertices[i]));
1174            let norm = model_matrix.transform_vector3(glam::Vec3::from(mesh.normals[i]));
1175
1176            self.vertices.push(Vertex {
1177                position: [pos.x, pos.y, pos.z],
1178                normal: [norm.x, norm.y, norm.z],
1179                uv: [0.0, 0.0],
1180                color: material.base_color,
1181                material_id: 13, // Material 13: 3D Surface
1182                radius: 0.0,
1183                slice: [material.metallic, material.roughness, material.opacity, 1.0],
1184                logical: [0.0, 0.0],
1185                size: [0.0, 0.0],
1186                clip: [-f32::INFINITY, -f32::INFINITY, f32::INFINITY, f32::INFINITY],
1187                tex_index: 0,
1188            });
1189        }
1190
1191        for idx in &mesh.indices {
1192            self.indices.push(base_idx + idx);
1193        }
1194
1195        self.instance_data.push(InstanceData {
1196            translation: [0.0, 0.0],
1197            scale: [1.0, 1.0],
1198            rotation: 0.0,
1199            blur_radius: 0.0,
1200            ior_override: 0.0,
1201            glass_intensity: 1.0,
1202        });
1203
1204        self.draw_calls.push(DrawCall {
1205            target_id: None,
1206            texture_id: None,
1207            scissor_rect: self.clip_stack.last().copied(),
1208            index_start: (self.indices.len() as u32) - (mesh.indices.len() as u32),
1209            index_count: mesh.indices.len() as u32,
1210            instance_count: 1,
1211            material: cvkg_core::DrawMaterial::Opaque,
1212            instance_start: (self.instance_data.len() - 1) as u32,
1213            draw_order: 0,
1214        });
1215    }
1216
1217    fn set_camera_3d(&mut self, camera: &cvkg_core::Camera3D) {
1218        self.current_scene.proj = camera.projection_matrix();
1219        self.current_scene.view = camera.view_matrix();
1220    }
1221
1222    fn push_transform_3d(&mut self, transform: &cvkg_core::Transform3D) {
1223        // Push a 2D-compatible transform for the existing pipeline
1224        // Use proper matrix decomposition to extract scale correctly (handles rotated matrices)
1225        let (translation, rotation_quat, scale_glam) =
1226            transform.to_matrix().to_scale_rotation_translation();
1227        let translation = [translation.x, translation.y];
1228        let scale = [scale_glam.x, scale_glam.y];
1229        let rotation = if rotation_quat.length_squared() > 0.0 {
1230            let (axis, angle) = rotation_quat.to_axis_angle();
1231            angle * axis.z.signum() // Radians (preserving Z-axis direction)
1232        } else {
1233            0.0
1234        };
1235        self.push_transform(translation, scale, rotation);
1236    }
1237
1238    fn pop_transform_3d(&mut self) {
1239        // Only pop the single transform that was pushed - no double pop
1240        self.pop_transform();
1241    }
1242
1243    /// Render a 3D scene graph node using the GPU backend.
1244    ///
1245    /// # Contract
1246    /// PBR lighting and opacity are computed using base color, metallic (0.0), and roughness (0.5)
1247    /// to support standard matte opaque 3D meshes.
1248    fn render_scene_node_3d(
1249        &mut self,
1250        position: [f32; 3],
1251        rotation: [f32; 4],
1252        scale: [f32; 3],
1253        color: [f32; 4],
1254        meshes: &[Mesh],
1255    ) {
1256        let transform = cvkg_core::Transform3D {
1257            position: glam::Vec3::from(position),
1258            rotation: glam::Quat::from_xyzw(rotation[0], rotation[1], rotation[2], rotation[3]),
1259            scale: glam::Vec3::from(scale),
1260        };
1261        // Use provided mesh or generate a default unit cube
1262        if meshes.is_empty() {
1263            // Generate a unit cube mesh on the stack
1264            let h = 0.5f32;
1265            let cube = Mesh {
1266                vertices: vec![
1267                    [-h, -h, -h],
1268                    [h, -h, -h],
1269                    [h, h, -h],
1270                    [-h, h, -h],
1271                    [-h, -h, h],
1272                    [h, -h, h],
1273                    [h, h, h],
1274                    [-h, h, h],
1275                ],
1276                normals: vec![
1277                    [0.0, 0.0, -1.0],
1278                    [0.0, 0.0, -1.0],
1279                    [0.0, 0.0, -1.0],
1280                    [0.0, 0.0, -1.0],
1281                    [0.0, 0.0, 1.0],
1282                    [0.0, 0.0, 1.0],
1283                    [0.0, 0.0, 1.0],
1284                    [0.0, 0.0, 1.0],
1285                    [0.0, -1.0, 0.0],
1286                    [0.0, -1.0, 0.0],
1287                    [0.0, -1.0, 0.0],
1288                    [0.0, -1.0, 0.0],
1289                    [1.0, 0.0, 0.0],
1290                    [1.0, 0.0, 0.0],
1291                    [1.0, 0.0, 0.0],
1292                    [1.0, 0.0, 0.0],
1293                    [0.0, 1.0, 0.0],
1294                    [0.0, 1.0, 0.0],
1295                    [0.0, 1.0, 0.0],
1296                    [0.0, 1.0, 0.0],
1297                    [-1.0, 0.0, 0.0],
1298                    [-1.0, 0.0, 0.0],
1299                    [-1.0, 0.0, 0.0],
1300                    [-1.0, 0.0, 0.0],
1301                ],
1302                indices: vec![
1303                    0, 1, 2, 0, 2, 3, // front
1304                    5, 4, 7, 5, 7, 6, // back
1305                    4, 0, 3, 4, 3, 7, // left
1306                    1, 5, 6, 1, 6, 2, // right
1307                    3, 2, 6, 3, 6, 7, // top
1308                    4, 5, 1, 4, 1, 0, // bottom
1309                ],
1310            };
1311            let material = cvkg_core::Material3D {
1312                base_color: color,
1313                metallic: 0.0,
1314                roughness: 0.5,
1315                emissive: [0.0, 0.0, 0.0],
1316                opacity: color[3],
1317            };
1318            self.draw_mesh_3d(&cube, &material, &transform);
1319        } else {
1320            let material = cvkg_core::Material3D {
1321                base_color: color,
1322                metallic: 0.0,
1323                roughness: 0.5,
1324                emissive: [0.0, 0.0, 0.0],
1325                opacity: color[3],
1326            };
1327            self.draw_mesh_3d(&meshes[0], &material, &transform);
1328        }
1329    }
1330
1331    fn register_shared_element(&mut self, id: &str, rect: Rect) {
1332        self.shared_elements.put(id.to_string(), rect);
1333    }
1334
1335    fn set_z_index(&mut self, z: f32) {
1336        self.current_z = z;
1337    }
1338
1339    fn set_material(&mut self, material: cvkg_core::DrawMaterial) {
1340        self.current_draw_material = material;
1341    }
1342
1343    fn current_material(&self) -> cvkg_core::DrawMaterial {
1344        self.current_draw_material
1345    }
1346
1347    fn get_z_index(&self) -> f32 {
1348        self.current_z
1349    }
1350
1351    fn request_redraw(&mut self) {
1352        self.redraw_requested = true;
1353    }
1354
1355    // -- Portal / PhaseGate rendering -----------------------------------------
1356
1357    /// Begin rendering into the portal root layer instead of the inline tree.
1358    /// All draw calls between `enter_portal` and `exit_portal` are collected
1359    /// into a separate buffer that is composited AFTER the main tree.
1360    ///
1361    /// WHY separate buffer: The main tree may have clipping, transforms, or
1362    /// opacity that should NOT affect overlays. The portal layer renders on top
1363    /// of everything, ignoring the local coordinate system.
1364    ///
1365    /// `z_index` controls the layer ordering for portal content.
1366    fn enter_portal(&mut self, z_index: i32) {
1367        // Portal rendering enables per-element backdrop blur for Tahoe glass
1368        // When z_index is 0, we're rendering normal glass cards
1369        // When z_index > 0, we're in a portal layer that will get special treatment
1370        self.current_z = z_index as f32;
1371    }
1372
1373    /// Exit the portal layer and return to inline rendering.
1374    /// The portal content collected since `enter_portal` is now sealed --
1375    /// no more draw calls will be appended to it.
1376    fn exit_portal(&mut self) {
1377        self.current_z = 0.0;
1378    }
1379
1380    fn push_vnode(&mut self, rect: Rect, name: &'static str) {
1381        self.vnode_stack.push((rect, name));
1382    }
1383
1384    fn pop_vnode(&mut self) {
1385        self.vnode_stack.pop();
1386    }
1387
1388    fn register_handler(
1389        &mut self,
1390        event_type: &str,
1391        handler: std::sync::Arc<dyn Fn(cvkg_core::Event) + Send + Sync>,
1392    ) {
1393        self.event_handlers
1394            .entry(event_type.to_string())
1395            .or_insert_with(Vec::new)
1396            .push(handler);
1397    }
1398
1399    fn load_svg(&mut self, name: &str, svg_data: &[u8]) {
1400        GpuRenderer::load_svg(self, name, svg_data);
1401    }
1402
1403    fn draw_svg(&mut self, name: &str, rect: Rect) {
1404        GpuRenderer::draw_svg(self, name, rect, None, 0);
1405    }
1406    fn draw_svg_with_offset(&mut self, name: &str, rect: Rect, animation_time_offset: f32) {
1407        GpuRenderer::draw_svg_with_offset(self, name, rect, None, 0, animation_time_offset);
1408    }
1409
1410    /// Draw SVG content with explicit draw_order for z-sorting within the same pass.
1411    /// Use draw_order=200 for SVG content that should render above UI chrome (draw_order=0).
1412    fn draw_svg_with_order(&mut self, name: &str, rect: Rect, draw_order: i32) {
1413        GpuRenderer::draw_svg_with_order(self, name, rect, None, 0, 0.0, draw_order);
1414    }
1415
1416    fn serialize_svg(&mut self, name: &str) -> Result<String, String> {
1417        let tree = self
1418            .svg
1419            .tree_cache
1420            .get(name)
1421            .ok_or_else(|| format!("SVG '{}' not found", name))?;
1422        let config = cvkg_svg_serialize::SerializerConfig::default();
1423        let mut serializer = cvkg_svg_serialize::SvgSerializer::with_config(config);
1424        serializer
1425            .serialize(tree)
1426            .map_err(|e| format!("SVG serialization failed: {}", e))
1427    }
1428
1429    fn apply_svg_filter(
1430        &mut self,
1431        name: &str,
1432        filter_id: &str,
1433        _region: Rect,
1434    ) -> Result<String, String> {
1435        let tree = self
1436            .svg
1437            .tree_cache
1438            .get(name)
1439            .ok_or_else(|| format!("SVG '{}' not found", name))?;
1440        let _filter = Self::find_filter(tree, filter_id)
1441            .ok_or_else(|| format!("Filter '{}' not found in SVG '{}'", filter_id, name))?;
1442        let config = cvkg_svg_serialize::SerializerConfig::default();
1443        let mut serializer = cvkg_svg_serialize::SvgSerializer::with_config(config);
1444        serializer
1445            .serialize(tree)
1446            .map_err(|e| format!("SVG filter serialization failed: {}", e))
1447    }
1448
1449    fn measure_text(&mut self, text: &str, size: f32) -> (f32, f32) {
1450        self.measure_text_impl(text, size)
1451    }
1452
1453    fn draw_text(&mut self, text: &str, x: f32, y: f32, size: f32, color: [f32; 4]) {
1454        self.draw_text_impl(text, x, y, size, color);
1455    }
1456}
1457
1458// ── Inherent methods on GpuRenderer (not part of the Renderer trait) ──
1459
1460impl GpuRenderer {
1461    /// Clear all registered event handlers. Call at the start of each frame
1462    /// before re-rendering the component tree.
1463    pub fn clear_event_handlers(&mut self) {
1464        self.event_handlers.clear();
1465    }
1466
1467    /// Phase 2.1: clear the text shaping cache at the start of each frame.
1468    pub fn clear_text_cache(&mut self) {
1469        self.clear_text_cache_impl();
1470    }
1471
1472    /// Get all registered event handlers for a specific event type.
1473    pub fn get_handlers(
1474        &self,
1475        event_type: &str,
1476    ) -> Option<&Vec<std::sync::Arc<dyn Fn(cvkg_core::Event) + Send + Sync>>> {
1477        self.event_handlers.get(event_type)
1478    }
1479
1480    /// Compute per-vertex transform values from the current matrix.
1481    /// Extracts translation, scale, rotation, and skew from the affine matrix
1482    /// so the existing vertex shader fields still work correctly.
1483    pub(crate) fn current_transform(&self) -> ([f32; 2], [f32; 2], f32, f32, f32) {
1484        // Returns (translation, scale, rotation,
1485        // skew_x, skew_y)
1486        let m = self
1487            .transform_stack
1488            .last()
1489            .copied()
1490            .unwrap_or(glam::Mat3::IDENTITY);
1491        let t = [m.z_axis.x, m.z_axis.y];
1492        // Extract scale and rotation from the 2x2 submatrix
1493        let a = m.x_axis.x;
1494        let b = m.x_axis.y;
1495        let c = m.y_axis.x;
1496        let d = m.y_axis.y;
1497        let sx = (a * a + b * b).sqrt();
1498        let sy = (c * c + d * d).sqrt();
1499        let rotation = b.atan2(a);
1500        // Skew: the angle between the basis vectors minus 90 degrees
1501        let skew_x = (a * c + b * d) / (sx * sy); // sin(skew)
1502        (t, [sx, sy], rotation, skew_x, 0.0)
1503    }
1504
1505    pub fn stroke_path(&mut self, path: &lyon::path::Path, color: [f32; 4], stroke_width: f32) {
1506        self.stroke_path_impl(path, color, stroke_width);
1507    }
1508}