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