Skip to main content

runmat_plot/export/
native_surface.rs

1use std::{any::Any, panic, sync::Arc};
2
3use crate::core::{Camera, PlotRenderConfig, PlotRenderer, RenderResult};
4use crate::gpu::util::map_read_async;
5use crate::plots::Figure;
6#[cfg(feature = "egui-overlay")]
7use crate::styling::ModernDarkTheme;
8use crate::styling::PlotThemeConfig;
9#[cfg(feature = "egui-overlay")]
10use runmat_time::Instant;
11
12#[cfg(feature = "egui-overlay")]
13use crate::overlay::plot_overlay::{OverlayConfig, OverlayMetrics, PlotOverlay};
14#[cfg(feature = "egui-overlay")]
15use egui_wgpu::ScreenDescriptor;
16
17pub const HEADLESS_GPU_ADAPTER_UNAVAILABLE: &str = "Failed to find suitable GPU adapter";
18pub const HEADLESS_GPU_DEVICE_CREATION_FAILED_PREFIX: &str = "Failed to create device:";
19pub const HEADLESS_GPU_CONTEXT_PANICKED_PREFIX: &str = "Headless GPU context creation panicked:";
20
21/// Renderer adapter for external/native surface targets owned by a host runtime.
22pub struct NativeSurfaceRenderContext {
23    renderer: PlotRenderer,
24    config: PlotRenderConfig,
25    pixels_per_point: f32,
26    background_policy: BackgroundPolicy,
27    textmark: Option<String>,
28    #[cfg(feature = "egui-overlay")]
29    overlay: Option<NativeOverlayState>,
30}
31
32#[derive(Debug, Clone, Copy)]
33enum BackgroundPolicy {
34    ThemeDriven,
35    Explicit(glam::Vec4),
36}
37
38#[cfg(feature = "egui-overlay")]
39struct NativeOverlayState {
40    egui_ctx: egui::Context,
41    egui_renderer: egui_wgpu::Renderer,
42    plot_overlay: PlotOverlay,
43}
44
45impl NativeSurfaceRenderContext {
46    /// Create a context that can render into external texture views.
47    pub async fn new(
48        device: Arc<wgpu::Device>,
49        queue: Arc<wgpu::Queue>,
50        width: u32,
51        height: u32,
52        format: wgpu::TextureFormat,
53    ) -> Result<Self, String> {
54        let surface_config = wgpu::SurfaceConfiguration {
55            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
56            format,
57            width: width.max(1),
58            height: height.max(1),
59            present_mode: wgpu::PresentMode::Fifo,
60            alpha_mode: wgpu::CompositeAlphaMode::Opaque,
61            view_formats: vec![],
62            desired_maximum_frame_latency: 1,
63        };
64        let renderer = PlotRenderer::new(device, queue, surface_config)
65            .await
66            .map_err(|err| format!("native surface renderer init failed: {err}"))?;
67        let config = PlotRenderConfig {
68            width: width.max(1),
69            height: height.max(1),
70            ..PlotRenderConfig::default()
71        };
72        #[cfg(feature = "egui-overlay")]
73        let overlay = {
74            let egui_ctx = egui::Context::default();
75            ModernDarkTheme::default().apply_to_egui(&egui_ctx);
76            let egui_renderer = crate::wgpu_compat::egui_renderer_new(
77                &renderer.wgpu_renderer.device,
78                format,
79                None,
80                1,
81            );
82            Some(NativeOverlayState {
83                egui_ctx,
84                egui_renderer,
85                plot_overlay: PlotOverlay::new(),
86            })
87        };
88
89        Ok(Self {
90            renderer,
91            config,
92            pixels_per_point: 1.0,
93            background_policy: BackgroundPolicy::ThemeDriven,
94            textmark: None,
95            #[cfg(feature = "egui-overlay")]
96            overlay,
97        })
98    }
99
100    /// Resize renderer viewport state.
101    pub fn resize(&mut self, width: u32, height: u32) {
102        let next_width = width.max(1);
103        let next_height = height.max(1);
104        self.config.width = next_width;
105        self.config.height = next_height;
106        self.renderer.wgpu_renderer.surface_config.width = next_width;
107        self.renderer.wgpu_renderer.surface_config.height = next_height;
108        self.renderer.on_surface_config_updated();
109    }
110
111    pub fn set_pixels_per_point(&mut self, pixels_per_point: f32) {
112        self.pixels_per_point = pixels_per_point.clamp(0.5, 4.0);
113    }
114
115    pub fn set_theme_config(&mut self, theme: PlotThemeConfig) {
116        self.renderer.theme = theme.clone();
117        self.config.theme = theme;
118        self.apply_background_policy();
119    }
120
121    pub fn set_textmark(&mut self, textmark: Option<&str>) {
122        self.textmark = textmark
123            .map(str::trim)
124            .filter(|s| !s.is_empty())
125            .map(ToOwned::to_owned);
126    }
127
128    /// Render a figure directly into an externally-owned texture view.
129    pub fn render_to_view(
130        &mut self,
131        figure: &Figure,
132        view: &wgpu::TextureView,
133        camera: Option<&Camera>,
134        axes_cameras: Option<&[Camera]>,
135    ) -> Result<RenderResult, String> {
136        self.render_to_view_with_camera_state(figure, view, camera, axes_cameras, None)
137    }
138
139    pub fn render_to_view_with_camera_state(
140        &mut self,
141        figure: &Figure,
142        view: &wgpu::TextureView,
143        camera: Option<&Camera>,
144        axes_cameras: Option<&[Camera]>,
145        axes_camera_user_controlled: Option<&[bool]>,
146    ) -> Result<RenderResult, String> {
147        self.prepare_scene(figure, camera, axes_cameras, axes_camera_user_controlled);
148
149        let mut encoder = self.renderer.wgpu_renderer.device.create_command_encoder(
150            &wgpu::CommandEncoderDescriptor {
151                label: Some("Native Surface Render Encoder"),
152            },
153        );
154
155        let render_result = self.render_scene_with_overlay(&mut encoder, view)?;
156
157        self.renderer
158            .wgpu_renderer
159            .queue
160            .submit(std::iter::once(encoder.finish()));
161
162        Ok(render_result)
163    }
164
165    /// Render a figure into an offscreen texture and read back RGBA8 bytes.
166    pub async fn render_to_rgba(
167        &mut self,
168        figure: &Figure,
169        camera: Option<&Camera>,
170        axes_cameras: Option<&[Camera]>,
171    ) -> Result<Vec<u8>, String> {
172        self.render_to_rgba_with_camera_state(figure, camera, axes_cameras, None)
173            .await
174    }
175
176    pub async fn render_to_rgba_with_camera_state(
177        &mut self,
178        figure: &Figure,
179        camera: Option<&Camera>,
180        axes_cameras: Option<&[Camera]>,
181        axes_camera_user_controlled: Option<&[bool]>,
182    ) -> Result<Vec<u8>, String> {
183        log::debug!(
184            "runmat-plot: native_surface.render_to_rgba.start width={} height={} axes={} overrides={}",
185            self.config.width.max(1),
186            self.config.height.max(1),
187            figure.axes_metadata.len(),
188            axes_cameras.map(|items| items.len()).unwrap_or(0)
189        );
190        self.prepare_scene(figure, camera, axes_cameras, axes_camera_user_controlled);
191
192        let width = self.config.width.max(1);
193        let height = self.config.height.max(1);
194        let format = self.renderer.wgpu_renderer.surface_config.format;
195        let device = self.renderer.wgpu_renderer.device.clone();
196        let queue = self.renderer.wgpu_renderer.queue.clone();
197
198        let color_texture = device.create_texture(&wgpu::TextureDescriptor {
199            label: Some("native_surface_offscreen_color"),
200            size: wgpu::Extent3d {
201                width,
202                height,
203                depth_or_array_layers: 1,
204            },
205            mip_level_count: 1,
206            sample_count: 1,
207            dimension: wgpu::TextureDimension::D2,
208            format,
209            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
210            view_formats: &[],
211        });
212        let color_view = color_texture.create_view(&wgpu::TextureViewDescriptor::default());
213
214        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
215            label: Some("Native Surface RGBA Render Encoder"),
216        });
217        log::debug!(
218            "runmat-plot: native_surface.render_to_rgba.render_scene width={} height={}",
219            width,
220            height
221        );
222        self.render_scene_with_overlay(&mut encoder, &color_view)?;
223        log::debug!("runmat-plot: native_surface.render_to_rgba.render_scene_ok");
224        queue.submit(std::iter::once(encoder.finish()));
225
226        let bytes_per_pixel = 4u32;
227        let padded_bytes_per_row = (width * bytes_per_pixel).div_ceil(256) * 256;
228        let output_buffer_size = (padded_bytes_per_row * height) as wgpu::BufferAddress;
229        let output_buffer = device.create_buffer(&wgpu::BufferDescriptor {
230            label: Some("native_surface_offscreen_readback"),
231            size: output_buffer_size,
232            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
233            mapped_at_creation: false,
234        });
235
236        let mut copy_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
237            label: Some("Native Surface RGBA Copy Encoder"),
238        });
239        copy_encoder.copy_texture_to_buffer(
240            crate::wgpu_compat::TexelCopyTextureInfo {
241                texture: &color_texture,
242                mip_level: 0,
243                origin: wgpu::Origin3d::ZERO,
244                aspect: wgpu::TextureAspect::All,
245            },
246            crate::wgpu_compat::TexelCopyBufferInfo {
247                buffer: &output_buffer,
248                layout: crate::wgpu_compat::TexelCopyBufferLayout {
249                    offset: 0,
250                    bytes_per_row: Some(padded_bytes_per_row),
251                    rows_per_image: Some(height),
252                },
253            },
254            wgpu::Extent3d {
255                width,
256                height,
257                depth_or_array_layers: 1,
258            },
259        );
260        queue.submit(std::iter::once(copy_encoder.finish()));
261        log::debug!(
262            "runmat-plot: native_surface.render_to_rgba.copy_submitted padded_bytes_per_row={} height={}",
263            padded_bytes_per_row,
264            height
265        );
266
267        let slice = output_buffer.slice(..);
268        map_read_async(device.as_ref(), &slice).await?;
269        log::debug!("runmat-plot: native_surface.render_to_rgba.readback_ready");
270        let data = slice.get_mapped_range();
271        let mut pixels = vec![0u8; (width * height * 4) as usize];
272        for row in 0..height as usize {
273            let src_start = row * padded_bytes_per_row as usize;
274            let dst_start = row * width as usize * 4;
275            pixels[dst_start..dst_start + width as usize * 4]
276                .copy_from_slice(&data[src_start..src_start + width as usize * 4]);
277        }
278        drop(data);
279        output_buffer.unmap();
280        log::debug!(
281            "runmat-plot: native_surface.render_to_rgba.ok bytes={}",
282            pixels.len()
283        );
284        Ok(pixels)
285    }
286
287    fn prepare_scene(
288        &mut self,
289        figure: &Figure,
290        camera: Option<&Camera>,
291        axes_cameras: Option<&[Camera]>,
292        axes_camera_user_controlled: Option<&[bool]>,
293    ) {
294        // Keep runtime config aligned with figure metadata, but treat the default figure
295        // white background as "unspecified" and prefer active theme background for app parity.
296        let bg = figure.background_color;
297        self.background_policy = if is_default_figure_bg(bg) {
298            BackgroundPolicy::ThemeDriven
299        } else {
300            BackgroundPolicy::Explicit(bg)
301        };
302        self.apply_background_policy();
303        self.config.show_grid = figure.grid_enabled
304            || figure.minor_grid_enabled
305            || figure
306                .axes_metadata
307                .iter()
308                .any(|meta| meta.grid_enabled || meta.minor_grid_enabled);
309        self.config.show_title = figure.has_any_titles();
310
311        self.renderer.set_figure(figure.clone());
312        if let Some(camera) = camera {
313            *self.renderer.camera_mut() = camera.clone();
314        }
315        if let Some(overrides) = axes_cameras {
316            for (index, override_camera) in overrides.iter().enumerate() {
317                if let Some(target) = self.renderer.axes_camera_mut(index) {
318                    *target = override_camera.clone();
319                }
320            }
321        }
322        if let Some(flags) = axes_camera_user_controlled {
323            self.renderer.set_axes_camera_interaction_flags(flags);
324        }
325    }
326
327    fn render_scene_with_overlay(
328        &mut self,
329        encoder: &mut wgpu::CommandEncoder,
330        target_view: &wgpu::TextureView,
331    ) -> Result<RenderResult, String> {
332        #[cfg(feature = "egui-overlay")]
333        {
334            let Some(overlay) = self.overlay.as_mut() else {
335                log::debug!(
336                    "runmat-plot: native_surface.render_scene_with_overlay.branch_no_overlay"
337                );
338                return self
339                    .renderer
340                    .render_scene_to_target(encoder, target_view, &self.config)
341                    .map_err(|err| format!("native surface render failed: {err}"));
342            };
343
344            let start_time = Instant::now();
345            log::debug!(
346                "runmat-plot: native_surface.render_scene_with_overlay.start width={} height={} ppp={}",
347                self.config.width,
348                self.config.height,
349                self.pixels_per_point
350            );
351            let mut plot_area_points: Option<egui::Rect> = None;
352            let scene_stats = self.renderer.scene.statistics();
353            let _ = self.renderer.calculate_data_bounds();
354            let ppp = self.pixels_per_point.max(0.5);
355            let screen_rect = egui::Rect::from_min_size(
356                egui::Pos2::new(0.0, 0.0),
357                egui::Vec2::new(
358                    (self.config.width.max(1) as f32) / ppp,
359                    (self.config.height.max(1) as f32) / ppp,
360                ),
361            );
362            let full_output = overlay.egui_ctx.run(
363                egui::RawInput {
364                    screen_rect: Some(screen_rect),
365                    viewports: std::iter::once((
366                        egui::ViewportId::ROOT,
367                        egui::ViewportInfo {
368                            native_pixels_per_point: Some(ppp),
369                            inner_rect: Some(screen_rect),
370                            outer_rect: Some(screen_rect),
371                            focused: Some(true),
372                            ..Default::default()
373                        },
374                    ))
375                    .collect(),
376                    ..Default::default()
377                },
378                |ctx| {
379                    overlay
380                        .plot_overlay
381                        .set_theme_config(self.renderer.theme.clone());
382                    overlay.plot_overlay.apply_theme(ctx);
383                    let overlay_config = OverlayConfig {
384                        // Let plot renderer own grid drawing semantics.
385                        show_grid: false,
386                        // Toolbar actions are surfaced by host UI, not native overlay.
387                        show_toolbar: false,
388                        font_scale: 1.25,
389                        show_axes: true,
390                        show_title: true,
391                        title: self
392                            .renderer
393                            .overlay_title()
394                            .cloned()
395                            .or(Some("Plot".to_string())),
396                        x_label: self
397                            .renderer
398                            .overlay_x_label()
399                            .cloned()
400                            .or(Some("X".to_string())),
401                        y_label: self
402                            .renderer
403                            .overlay_y_label()
404                            .cloned()
405                            .or(Some("Y".to_string())),
406                        show_sidebar: false,
407                        ..Default::default()
408                    };
409                    let overlay_metrics = OverlayMetrics {
410                        vertex_count: scene_stats.total_vertices,
411                        triangle_count: scene_stats.total_triangles,
412                        render_time_ms: 0.0,
413                        fps: 60.0,
414                    };
415                    let frame_info = overlay.plot_overlay.render(
416                        ctx,
417                        &self.renderer,
418                        &overlay_config,
419                        overlay_metrics,
420                    );
421                    if let Some(textmark) = self.textmark.as_deref() {
422                        let screen = ctx.screen_rect();
423                        let anchor = egui::pos2(screen.max.x - 8.0, screen.max.y - 6.0);
424                        let font =
425                            egui::FontId::proportional(11.0 * overlay_config.font_scale.max(0.8));
426                        let layer = egui::LayerId::new(
427                            egui::Order::Foreground,
428                            egui::Id::new("runmat_export_textmark"),
429                        );
430                        let painter = ctx.layer_painter(layer);
431                        painter.text(
432                            anchor + egui::vec2(1.0, 1.0),
433                            egui::Align2::RIGHT_BOTTOM,
434                            textmark,
435                            font.clone(),
436                            egui::Color32::from_rgba_premultiplied(0, 0, 0, 72),
437                        );
438                        painter.text(
439                            anchor,
440                            egui::Align2::RIGHT_BOTTOM,
441                            textmark,
442                            font,
443                            egui::Color32::from_rgba_premultiplied(226, 234, 245, 96),
444                        );
445                    }
446                    plot_area_points = frame_info.plot_area;
447                },
448            );
449
450            let paint_jobs = overlay
451                .egui_ctx
452                .tessellate(full_output.shapes, full_output.pixels_per_point);
453            for (id, image_delta) in &full_output.textures_delta.set {
454                overlay.egui_renderer.update_texture(
455                    &self.renderer.wgpu_renderer.device,
456                    &self.renderer.wgpu_renderer.queue,
457                    *id,
458                    image_delta,
459                );
460            }
461
462            let screen_descriptor = ScreenDescriptor {
463                size_in_pixels: [self.config.width.max(1), self.config.height.max(1)],
464                pixels_per_point: full_output.pixels_per_point,
465            };
466            overlay.egui_renderer.update_buffers(
467                &self.renderer.wgpu_renderer.device,
468                &self.renderer.wgpu_renderer.queue,
469                encoder,
470                &paint_jobs,
471                &screen_descriptor,
472            );
473
474            let (vx, vy, vw, vh) = if let Some(rect) = plot_area_points {
475                let vx = (rect.min.x * ppp).round().max(0.0) as u32;
476                let vy = (rect.min.y * ppp).round().max(0.0) as u32;
477                let vw = (rect.width() * ppp).round().max(1.0) as u32;
478                let vh = (rect.height() * ppp).round().max(1.0) as u32;
479                (vx, vy, vw, vh)
480            } else {
481                (0, 0, self.config.width.max(1), self.config.height.max(1))
482            };
483            let max_w = self.config.width.max(1);
484            let max_h = self.config.height.max(1);
485            let vx = vx.min(max_w.saturating_sub(1));
486            let vy = vy.min(max_h.saturating_sub(1));
487            let vw = vw.max(1).min(max_w.saturating_sub(vx).max(1));
488            let vh = vh.max(1).min(max_h.saturating_sub(vy).max(1));
489
490            if vw > 0 && vh > 0 {
491                self.renderer
492                    .camera_mut()
493                    .update_aspect_ratio((vw as f32) / (vh as f32));
494            }
495
496            let (rows, cols) = self.renderer.figure_axes_grid();
497            log::debug!(
498                "runmat-plot: native_surface.render_scene_with_overlay.axes_grid rows={} cols={} plot_area_present={}",
499                rows,
500                cols,
501                plot_area_points.is_some()
502            );
503            if rows * cols > 1 {
504                log::debug!(
505                    "runmat-plot: native_surface.render_scene_with_overlay.branch_subplot_axes"
506                );
507                let rect_points = plot_area_points.unwrap_or_else(|| {
508                    egui::Rect::from_min_size(
509                        egui::Pos2::new(0.0, 0.0),
510                        egui::Vec2::new(
511                            (self.config.width.max(1) as f32) / ppp,
512                            (self.config.height.max(1) as f32) / ppp,
513                        ),
514                    )
515                });
516                let existing_rect_count = overlay.plot_overlay.axes_plot_rects().len();
517                log::debug!(
518                    "runmat-plot: native_surface.render_scene_with_overlay.subplot_rect_source rows={} cols={} existing_rects={} expected_rects={}",
519                    rows,
520                    cols,
521                    existing_rect_count,
522                    rows * cols
523                );
524                let rects = if overlay.plot_overlay.axes_plot_rects().len() == rows * cols {
525                    log::debug!(
526                        "runmat-plot: native_surface.render_scene_with_overlay.subplot_rect_source_existing rects={}",
527                        existing_rect_count
528                    );
529                    overlay.plot_overlay.axes_plot_rects().to_vec()
530                } else {
531                    log::debug!(
532                        "runmat-plot: native_surface.render_scene_with_overlay.subplot_rect_source_compute rect_points=({}, {})..({}, {})",
533                        rect_points.min.x,
534                        rect_points.min.y,
535                        rect_points.max.x,
536                        rect_points.max.y
537                    );
538                    overlay.plot_overlay.compute_subplot_plot_rects_snapped(
539                        rect_points,
540                        &self.renderer,
541                        1.0,
542                        ppp,
543                    )
544                };
545                log::debug!(
546                    "runmat-plot: native_surface.render_scene_with_overlay.subplot_rects_ready rects={}",
547                    rects.len()
548                );
549                let sw = self.config.width as f32;
550                let sh = self.config.height as f32;
551                let mut viewports: Vec<(u32, u32, u32, u32)> = Vec::with_capacity(rects.len());
552                for (rect_index, r) in rects.into_iter().enumerate() {
553                    log::debug!(
554                        "runmat-plot: native_surface.render_scene_with_overlay.subplot_rect viewport_index={} rect=({}, {})..({}, {})",
555                        rect_index,
556                        r.min.x,
557                        r.min.y,
558                        r.max.x,
559                        r.max.y
560                    );
561                    let mut rx = (r.min.x * ppp).round().max(0.0);
562                    let mut ry = (r.min.y * ppp).round().max(0.0);
563                    let mut rw = (r.width() * ppp).round().max(1.0);
564                    let mut rh = (r.height() * ppp).round().max(1.0);
565                    if rx >= sw {
566                        rx = (sw - 1.0).max(0.0);
567                    }
568                    if ry >= sh {
569                        ry = (sh - 1.0).max(0.0);
570                    }
571                    if rx + rw > sw {
572                        rw = (sw - rx).max(1.0);
573                    }
574                    if ry + rh > sh {
575                        rh = (sh - ry).max(1.0);
576                    }
577                    viewports.push((rx as u32, ry as u32, rw as u32, rh as u32));
578                    log::debug!(
579                        "runmat-plot: native_surface.render_scene_with_overlay.subplot_viewport viewport_index={} viewport=({}, {}, {}, {})",
580                        rect_index,
581                        rx as u32,
582                        ry as u32,
583                        rw as u32,
584                        rh as u32
585                    );
586                }
587                log::debug!(
588                    "runmat-plot: native_surface.render_scene_with_overlay.subplot_viewports_ready count={}",
589                    viewports.len()
590                );
591                let axes_plot_sizes_px: Vec<(u32, u32)> = viewports
592                    .iter()
593                    .map(|&(_, _, w, h)| (w.max(1), h.max(1)))
594                    .collect();
595                self.renderer
596                    .ensure_scene_viewport_dependent_geometry_for_axes(&axes_plot_sizes_px);
597                self.renderer
598                    .render_axes_to_viewports(
599                        encoder,
600                        target_view,
601                        &viewports,
602                        self.config.msaa_samples.max(1),
603                        &self.config,
604                    )
605                    .map_err(|err| format!("native surface subplot render failed: {err}"))?;
606                log::debug!(
607                    "runmat-plot: native_surface.render_scene_with_overlay.subplot_render_ok viewports={}",
608                    viewports.len()
609                );
610            } else {
611                log::debug!(
612                    "runmat-plot: native_surface.render_scene_with_overlay.branch_single_axes"
613                );
614                {
615                    let clear_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
616                        label: Some("runmat-native-single-axes-clear"),
617                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
618                            view: target_view,
619                            resolve_target: None,
620                            ops: wgpu::Operations {
621                                load: wgpu::LoadOp::Clear(wgpu::Color {
622                                    r: self.config.background_color.x as f64,
623                                    g: self.config.background_color.y as f64,
624                                    b: self.config.background_color.z as f64,
625                                    a: self.config.background_color.w as f64,
626                                }),
627                                store: wgpu::StoreOp::Store,
628                            },
629                        })],
630                        depth_stencil_attachment: None,
631                        timestamp_writes: None,
632                        occlusion_query_set: None,
633                    });
634                    drop(clear_pass);
635                }
636
637                let mut cfg = self.config.clone();
638                cfg.width = vw.max(1);
639                cfg.height = vh.max(1);
640                let cam = self
641                    .renderer
642                    .axes_camera(0)
643                    .cloned()
644                    .unwrap_or_else(|| self.renderer.camera().clone());
645                let axes_plot_sizes_px = vec![(vw.max(1), vh.max(1))];
646                self.renderer
647                    .ensure_scene_viewport_dependent_geometry_for_axes(&axes_plot_sizes_px);
648                self.renderer
649                    .render_camera_to_viewport(
650                        encoder,
651                        target_view,
652                        (vx, vy, vw, vh),
653                        &cfg,
654                        &cam,
655                        0,
656                        true,
657                    )
658                    .map_err(|err| format!("native surface viewport render failed: {err}"))?;
659                log::debug!(
660                    "runmat-plot: native_surface.render_scene_with_overlay.single_axes_render_ok"
661                );
662            }
663
664            {
665                let render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
666                    label: Some("runmat-native-egui-overlay"),
667                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
668                        view: target_view,
669                        resolve_target: None,
670                        ops: wgpu::Operations {
671                            load: wgpu::LoadOp::Load,
672                            store: wgpu::StoreOp::Store,
673                        },
674                    })],
675                    depth_stencil_attachment: None,
676                    timestamp_writes: None,
677                    occlusion_query_set: None,
678                });
679                #[cfg(target_arch = "wasm32")]
680                let mut render_pass = render_pass.forget_lifetime();
681                #[cfg(not(target_arch = "wasm32"))]
682                let mut render_pass = render_pass;
683                overlay
684                    .egui_renderer
685                    .render(&mut render_pass, &paint_jobs, &screen_descriptor);
686                log::debug!(
687                    "runmat-plot: native_surface.render_scene_with_overlay.overlay_ok paint_jobs={} textures_set={} textures_free={}",
688                    paint_jobs.len(),
689                    full_output.textures_delta.set.len(),
690                    full_output.textures_delta.free.len()
691                );
692            }
693
694            for id in &full_output.textures_delta.free {
695                overlay.egui_renderer.free_texture(id);
696            }
697
698            Ok(RenderResult {
699                success: true,
700                data_bounds: self.renderer.data_bounds(),
701                vertex_count: scene_stats.total_vertices,
702                triangle_count: scene_stats.total_triangles,
703                render_time_ms: start_time.elapsed().as_secs_f64() * 1000.0,
704            })
705        }
706
707        #[cfg(not(feature = "egui-overlay"))]
708        {
709            self.renderer
710                .render_scene_to_target(encoder, target_view, &self.config)
711                .map_err(|err| format!("native surface render failed: {err}"))
712        }
713    }
714
715    fn apply_background_policy(&mut self) {
716        self.config.background_color = match self.background_policy {
717            BackgroundPolicy::ThemeDriven => {
718                self.renderer.theme.build_theme().get_background_color()
719            }
720            BackgroundPolicy::Explicit(color) => color,
721        };
722    }
723}
724
725fn is_default_figure_bg(bg: glam::Vec4) -> bool {
726    const EPS: f32 = 1e-3;
727    (bg.x - 1.0).abs() <= EPS
728        && (bg.y - 1.0).abs() <= EPS
729        && (bg.z - 1.0).abs() <= EPS
730        && (bg.w - 1.0).abs() <= EPS
731}
732
733async fn create_headless_context(
734    width: u32,
735    height: u32,
736) -> Result<NativeSurfaceRenderContext, String> {
737    // Export paths provide theme/plot colors in display (sRGB) space, just like interactive
738    // surface rendering on most backends. Using an sRGB attachment here applies an extra
739    // linear->sRGB conversion and visibly washes out captures.
740    let format = wgpu::TextureFormat::Rgba8Unorm;
741    if let Some(ctx) = crate::context::shared_wgpu_context() {
742        log::debug!(
743            "runmat-plot: native_surface.headless_context.branch_shared_context width={} height={}",
744            width,
745            height
746        );
747        return NativeSurfaceRenderContext::new(ctx.device, ctx.queue, width, height, format).await;
748    }
749
750    log::debug!(
751        "runmat-plot: native_surface.headless_context.branch_dedicated_context width={} height={}",
752        width,
753        height
754    );
755
756    let instance = panic::catch_unwind(|| {
757        crate::wgpu_compat::instance_new(wgpu::InstanceDescriptor::default())
758    })
759    .map_err(|payload| {
760        format!(
761            "{HEADLESS_GPU_CONTEXT_PANICKED_PREFIX} {}",
762            panic_payload_to_string(payload)
763        )
764    })?;
765    let adapter = instance
766        .request_adapter(&wgpu::RequestAdapterOptions {
767            power_preference: wgpu::PowerPreference::HighPerformance,
768            compatible_surface: None,
769            force_fallback_adapter: false,
770        })
771        .await
772        .ok_or(HEADLESS_GPU_ADAPTER_UNAVAILABLE)?;
773    let (device, queue) = adapter
774        .request_device(&crate::wgpu_compat::default_device_descriptor(), None)
775        .await
776        .map_err(|err| format!("{HEADLESS_GPU_DEVICE_CREATION_FAILED_PREFIX} {err}"))?;
777    let context =
778        NativeSurfaceRenderContext::new(Arc::new(device), Arc::new(queue), width, height, format)
779            .await?;
780    log::debug!(
781        "runmat-plot: native_surface.headless_context.ready width={} height={}",
782        width,
783        height
784    );
785    Ok(context)
786}
787
788pub fn is_headless_gpu_unavailable_error(err: &str) -> bool {
789    err.contains(HEADLESS_GPU_ADAPTER_UNAVAILABLE)
790        || err.contains(HEADLESS_GPU_DEVICE_CREATION_FAILED_PREFIX)
791        || err.contains(HEADLESS_GPU_CONTEXT_PANICKED_PREFIX)
792}
793
794fn panic_payload_to_string(payload: Box<dyn Any + Send>) -> String {
795    if let Some(message) = payload.downcast_ref::<&str>() {
796        (*message).to_string()
797    } else if let Some(message) = payload.downcast_ref::<String>() {
798        message.clone()
799    } else {
800        "unknown panic payload".to_string()
801    }
802}
803
804pub async fn render_figure_rgba_bytes_interactive_with_camera(
805    figure: Figure,
806    width: u32,
807    height: u32,
808    camera: &Camera,
809) -> Result<Vec<u8>, String> {
810    let mut context = create_headless_context(width.max(1), height.max(1)).await?;
811    context.render_to_rgba(&figure, Some(camera), None).await
812}
813
814pub async fn render_figure_rgba_bytes_interactive_with_camera_and_theme(
815    figure: Figure,
816    width: u32,
817    height: u32,
818    camera: &Camera,
819    theme: PlotThemeConfig,
820) -> Result<Vec<u8>, String> {
821    let mut context = create_headless_context(width.max(1), height.max(1)).await?;
822    context.set_theme_config(theme);
823    context.render_to_rgba(&figure, Some(camera), None).await
824}
825
826pub async fn render_figure_rgba_bytes_interactive_with_axes_cameras(
827    figure: Figure,
828    width: u32,
829    height: u32,
830    axes_cameras: &[Camera],
831) -> Result<Vec<u8>, String> {
832    let mut context = create_headless_context(width.max(1), height.max(1)).await?;
833    context
834        .render_to_rgba(&figure, None, Some(axes_cameras))
835        .await
836}
837
838pub async fn render_figure_rgba_bytes_interactive_with_axes_cameras_and_theme(
839    figure: Figure,
840    width: u32,
841    height: u32,
842    axes_cameras: &[Camera],
843    theme: PlotThemeConfig,
844) -> Result<Vec<u8>, String> {
845    let mut context = create_headless_context(width.max(1), height.max(1)).await?;
846    context.set_theme_config(theme);
847    context
848        .render_to_rgba(&figure, None, Some(axes_cameras))
849        .await
850}
851
852pub async fn render_figure_rgba_bytes_interactive(
853    figure: Figure,
854    width: u32,
855    height: u32,
856) -> Result<Vec<u8>, String> {
857    let mut context = create_headless_context(width.max(1), height.max(1)).await?;
858    context.render_to_rgba(&figure, None, None).await
859}
860
861pub async fn render_figure_rgba_bytes_interactive_and_theme(
862    figure: Figure,
863    width: u32,
864    height: u32,
865    theme: PlotThemeConfig,
866) -> Result<Vec<u8>, String> {
867    let mut context = create_headless_context(width.max(1), height.max(1)).await?;
868    context.set_theme_config(theme);
869    context.render_to_rgba(&figure, None, None).await
870}
871
872fn encode_png_bytes(width: u32, height: u32, rgba: &[u8]) -> Result<Vec<u8>, String> {
873    use image::{ImageBuffer, ImageFormat, Rgba};
874
875    let mut opaque = rgba.to_vec();
876    for pixel in opaque.chunks_exact_mut(4) {
877        pixel[3] = 255;
878    }
879
880    let image = ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, opaque)
881        .ok_or_else(|| "Failed to create image buffer for PNG encoding".to_string())?;
882    let mut out = std::io::Cursor::new(Vec::new());
883    image
884        .write_to(&mut out, ImageFormat::Png)
885        .map_err(|err| format!("Failed to encode PNG bytes: {err}"))?;
886    Ok(out.into_inner())
887}
888
889pub async fn render_figure_png_bytes_interactive(
890    figure: Figure,
891    width: u32,
892    height: u32,
893) -> Result<Vec<u8>, String> {
894    let rgba = render_figure_rgba_bytes_interactive(figure, width, height).await?;
895    encode_png_bytes(width.max(1), height.max(1), &rgba)
896}
897
898pub async fn render_figure_png_bytes_interactive_and_theme(
899    figure: Figure,
900    width: u32,
901    height: u32,
902    theme: PlotThemeConfig,
903) -> Result<Vec<u8>, String> {
904    let rgba = render_figure_rgba_bytes_interactive_and_theme(figure, width, height, theme).await?;
905    encode_png_bytes(width.max(1), height.max(1), &rgba)
906}
907
908pub async fn render_figure_png_bytes_interactive_and_theme_and_textmark(
909    figure: Figure,
910    width: u32,
911    height: u32,
912    theme: PlotThemeConfig,
913    textmark: Option<&str>,
914) -> Result<Vec<u8>, String> {
915    log::debug!(
916        "runmat-plot: render_figure_png_bytes_interactive_and_theme_and_textmark.start width={} height={} axes={} textmark={}",
917        width,
918        height,
919        figure.axes_metadata.len(),
920        textmark.unwrap_or("")
921    );
922    let mut context = create_headless_context(width.max(1), height.max(1)).await?;
923    log::debug!(
924        "runmat-plot: render_figure_png_bytes_interactive_and_theme_and_textmark.context_ready width={} height={}",
925        width.max(1),
926        height.max(1)
927    );
928    context.set_theme_config(theme);
929    context.set_textmark(textmark);
930    let rgba = context.render_to_rgba(&figure, None, None).await?;
931    log::debug!(
932        "runmat-plot: render_figure_png_bytes_interactive_and_theme_and_textmark.rgba_ready bytes={}",
933        rgba.len()
934    );
935    encode_png_bytes(width.max(1), height.max(1), &rgba)
936}
937
938pub async fn render_figure_png_bytes_interactive_with_camera(
939    figure: Figure,
940    width: u32,
941    height: u32,
942    camera: &Camera,
943) -> Result<Vec<u8>, String> {
944    let rgba =
945        render_figure_rgba_bytes_interactive_with_camera(figure, width, height, camera).await?;
946    encode_png_bytes(width.max(1), height.max(1), &rgba)
947}
948
949pub async fn render_figure_png_bytes_interactive_with_camera_and_theme(
950    figure: Figure,
951    width: u32,
952    height: u32,
953    camera: &Camera,
954    theme: PlotThemeConfig,
955) -> Result<Vec<u8>, String> {
956    let rgba = render_figure_rgba_bytes_interactive_with_camera_and_theme(
957        figure, width, height, camera, theme,
958    )
959    .await?;
960    encode_png_bytes(width.max(1), height.max(1), &rgba)
961}
962
963pub async fn render_figure_png_bytes_interactive_with_camera_and_theme_and_textmark(
964    figure: Figure,
965    width: u32,
966    height: u32,
967    camera: &Camera,
968    theme: PlotThemeConfig,
969    textmark: Option<&str>,
970) -> Result<Vec<u8>, String> {
971    let mut context = create_headless_context(width.max(1), height.max(1)).await?;
972    context.set_theme_config(theme);
973    context.set_textmark(textmark);
974    let rgba = context.render_to_rgba(&figure, Some(camera), None).await?;
975    encode_png_bytes(width.max(1), height.max(1), &rgba)
976}
977
978pub async fn render_figure_png_bytes_interactive_with_axes_cameras(
979    figure: Figure,
980    width: u32,
981    height: u32,
982    axes_cameras: &[Camera],
983) -> Result<Vec<u8>, String> {
984    let rgba =
985        render_figure_rgba_bytes_interactive_with_axes_cameras(figure, width, height, axes_cameras)
986            .await?;
987    encode_png_bytes(width.max(1), height.max(1), &rgba)
988}
989
990pub async fn render_figure_png_bytes_interactive_with_axes_cameras_and_theme(
991    figure: Figure,
992    width: u32,
993    height: u32,
994    axes_cameras: &[Camera],
995    theme: PlotThemeConfig,
996) -> Result<Vec<u8>, String> {
997    let rgba = render_figure_rgba_bytes_interactive_with_axes_cameras_and_theme(
998        figure,
999        width,
1000        height,
1001        axes_cameras,
1002        theme,
1003    )
1004    .await?;
1005    encode_png_bytes(width.max(1), height.max(1), &rgba)
1006}
1007
1008pub async fn render_figure_png_bytes_interactive_with_axes_cameras_and_theme_and_textmark(
1009    figure: Figure,
1010    width: u32,
1011    height: u32,
1012    axes_cameras: &[Camera],
1013    theme: PlotThemeConfig,
1014    textmark: Option<&str>,
1015) -> Result<Vec<u8>, String> {
1016    log::debug!(
1017        "runmat-plot: render_figure_png_bytes_interactive_with_axes_cameras_and_theme_and_textmark.start width={} height={} axes={} camera_overrides={} textmark={}",
1018        width,
1019        height,
1020        figure.axes_metadata.len(),
1021        axes_cameras.len(),
1022        textmark.unwrap_or("")
1023    );
1024    let mut context = create_headless_context(width.max(1), height.max(1)).await?;
1025    log::debug!(
1026        "runmat-plot: render_figure_png_bytes_interactive_with_axes_cameras_and_theme_and_textmark.context_ready width={} height={}",
1027        width.max(1),
1028        height.max(1)
1029    );
1030    context.set_theme_config(theme);
1031    context.set_textmark(textmark);
1032    let rgba = context
1033        .render_to_rgba(&figure, None, Some(axes_cameras))
1034        .await?;
1035    log::debug!(
1036        "runmat-plot: render_figure_png_bytes_interactive_with_axes_cameras_and_theme_and_textmark.rgba_ready bytes={}",
1037        rgba.len()
1038    );
1039    encode_png_bytes(width.max(1), height.max(1), &rgba)
1040}
1041
1042#[cfg(test)]
1043mod tests {
1044    use super::*;
1045
1046    #[test]
1047    fn headless_gpu_panic_errors_are_fallback_eligible() {
1048        assert!(is_headless_gpu_unavailable_error(
1049            "Headless GPU context creation panicked: called `Option::unwrap()` on a `None` value"
1050        ));
1051    }
1052}