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