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