Skip to main content

runmat_plot/export/
native_surface.rs

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