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                    // CAD controls are surfaced by host UI, not native overlay.
620                    show_cad_overlay: false,
621                    font_scale: 1.25,
622                    show_axes: true,
623                    show_title: self.renderer.geometry_overlay().is_none(),
624                    title: self
625                        .renderer
626                        .overlay_title()
627                        .cloned()
628                        .or(Some("Plot".to_string())),
629                    x_label: self
630                        .renderer
631                        .overlay_x_label()
632                        .cloned()
633                        .or(Some("X".to_string())),
634                    y_label: self
635                        .renderer
636                        .overlay_y_label()
637                        .cloned()
638                        .or(Some("Y".to_string())),
639                    show_sidebar: false,
640                    ..Default::default()
641                };
642                let overlay_metrics = OverlayMetrics {
643                    vertex_count: scene_stats.total_vertices,
644                    triangle_count: scene_stats.total_triangles,
645                    render_time_ms: 0.0,
646                    fps: 60.0,
647                };
648                let frame_info = overlay.plot_overlay.render(
649                    ctx,
650                    &self.renderer,
651                    &overlay_config,
652                    overlay_metrics,
653                );
654                if let Some(textmark) = self.textmark.as_deref() {
655                    let screen = ctx.screen_rect();
656                    let anchor = egui::pos2(screen.max.x - 8.0, screen.max.y - 6.0);
657                    let font =
658                        egui::FontId::proportional(11.0 * overlay_config.font_scale.max(0.8));
659                    let layer = egui::LayerId::new(
660                        egui::Order::Foreground,
661                        egui::Id::new("runmat_export_textmark"),
662                    );
663                    let painter = ctx.layer_painter(layer);
664                    painter.text(
665                        anchor + egui::vec2(1.0, 1.0),
666                        egui::Align2::RIGHT_BOTTOM,
667                        textmark,
668                        font.clone(),
669                        egui::Color32::from_rgba_premultiplied(0, 0, 0, 72),
670                    );
671                    painter.text(
672                        anchor,
673                        egui::Align2::RIGHT_BOTTOM,
674                        textmark,
675                        font,
676                        egui::Color32::from_rgba_premultiplied(226, 234, 245, 96),
677                    );
678                }
679                plot_area_points = frame_info.plot_area;
680            });
681
682            let cad_actions = overlay.plot_overlay.take_cad_actions();
683            self.host_actions
684                .extend(apply_cad_overlay_actions(&mut self.renderer, cad_actions));
685            overlay.wants_pointer_input = overlay.plot_overlay.overlay_pointer_captured();
686            overlay.capture_regions_px = overlay
687                .plot_overlay
688                .overlay_capture_regions()
689                .into_iter()
690                .map(|[x0, y0, x1, y1]| [x0 * ppp, y0 * ppp, x1 * ppp, y1 * ppp])
691                .collect();
692
693            let paint_jobs = overlay
694                .egui_ctx
695                .tessellate(full_output.shapes, full_output.pixels_per_point);
696            for (id, image_delta) in &full_output.textures_delta.set {
697                overlay.egui_renderer.update_texture(
698                    &self.renderer.wgpu_renderer.device,
699                    &self.renderer.wgpu_renderer.queue,
700                    *id,
701                    image_delta,
702                );
703            }
704
705            let screen_descriptor = ScreenDescriptor {
706                size_in_pixels: [self.config.width.max(1), self.config.height.max(1)],
707                pixels_per_point: full_output.pixels_per_point,
708            };
709            overlay.egui_renderer.update_buffers(
710                &self.renderer.wgpu_renderer.device,
711                &self.renderer.wgpu_renderer.queue,
712                encoder,
713                &paint_jobs,
714                &screen_descriptor,
715            );
716
717            let (vx, vy, vw, vh) = if let Some(rect) = plot_area_points {
718                let vx = (rect.min.x * ppp).round().max(0.0) as u32;
719                let vy = (rect.min.y * ppp).round().max(0.0) as u32;
720                let vw = (rect.width() * ppp).round().max(1.0) as u32;
721                let vh = (rect.height() * ppp).round().max(1.0) as u32;
722                (vx, vy, vw, vh)
723            } else {
724                (0, 0, self.config.width.max(1), self.config.height.max(1))
725            };
726            let max_w = self.config.width.max(1);
727            let max_h = self.config.height.max(1);
728            let vx = vx.min(max_w.saturating_sub(1));
729            let vy = vy.min(max_h.saturating_sub(1));
730            let vw = vw.max(1).min(max_w.saturating_sub(vx).max(1));
731            let vh = vh.max(1).min(max_h.saturating_sub(vy).max(1));
732            let mut axes_viewports_px = vec![(vx, vy, vw, vh)];
733
734            if vw > 0 && vh > 0 {
735                self.renderer
736                    .camera_mut()
737                    .update_aspect_ratio((vw as f32) / (vh as f32));
738            }
739
740            let (rows, cols) = self.renderer.figure_axes_grid();
741            log::debug!(
742                "runmat-plot: native_surface.render_scene_with_overlay.axes_grid rows={} cols={} plot_area_present={}",
743                rows,
744                cols,
745                plot_area_points.is_some()
746            );
747            if rows * cols > 1 {
748                log::debug!(
749                    "runmat-plot: native_surface.render_scene_with_overlay.branch_subplot_axes"
750                );
751                let rect_points = plot_area_points.unwrap_or_else(|| {
752                    egui::Rect::from_min_size(
753                        egui::Pos2::new(0.0, 0.0),
754                        egui::Vec2::new(
755                            (self.config.width.max(1) as f32) / ppp,
756                            (self.config.height.max(1) as f32) / ppp,
757                        ),
758                    )
759                });
760                let existing_rect_count = overlay.plot_overlay.axes_plot_rects().len();
761                log::debug!(
762                    "runmat-plot: native_surface.render_scene_with_overlay.subplot_rect_source rows={} cols={} existing_rects={} expected_rects={}",
763                    rows,
764                    cols,
765                    existing_rect_count,
766                    rows * cols
767                );
768                let rects = if overlay.plot_overlay.axes_plot_rects().len() == rows * cols {
769                    log::debug!(
770                        "runmat-plot: native_surface.render_scene_with_overlay.subplot_rect_source_existing rects={}",
771                        existing_rect_count
772                    );
773                    overlay.plot_overlay.axes_plot_rects().to_vec()
774                } else {
775                    log::debug!(
776                        "runmat-plot: native_surface.render_scene_with_overlay.subplot_rect_source_compute rect_points=({}, {})..({}, {})",
777                        rect_points.min.x,
778                        rect_points.min.y,
779                        rect_points.max.x,
780                        rect_points.max.y
781                    );
782                    overlay.plot_overlay.compute_subplot_plot_rects_snapped(
783                        rect_points,
784                        &self.renderer,
785                        1.0,
786                        ppp,
787                    )
788                };
789                log::debug!(
790                    "runmat-plot: native_surface.render_scene_with_overlay.subplot_rects_ready rects={}",
791                    rects.len()
792                );
793                let sw = self.config.width as f32;
794                let sh = self.config.height as f32;
795                let mut viewports: Vec<(u32, u32, u32, u32)> = Vec::with_capacity(rects.len());
796                for (rect_index, r) in rects.into_iter().enumerate() {
797                    log::debug!(
798                        "runmat-plot: native_surface.render_scene_with_overlay.subplot_rect viewport_index={} rect=({}, {})..({}, {})",
799                        rect_index,
800                        r.min.x,
801                        r.min.y,
802                        r.max.x,
803                        r.max.y
804                    );
805                    let mut rx = (r.min.x * ppp).round().max(0.0);
806                    let mut ry = (r.min.y * ppp).round().max(0.0);
807                    let mut rw = (r.width() * ppp).round().max(1.0);
808                    let mut rh = (r.height() * ppp).round().max(1.0);
809                    if rx >= sw {
810                        rx = (sw - 1.0).max(0.0);
811                    }
812                    if ry >= sh {
813                        ry = (sh - 1.0).max(0.0);
814                    }
815                    if rx + rw > sw {
816                        rw = (sw - rx).max(1.0);
817                    }
818                    if ry + rh > sh {
819                        rh = (sh - ry).max(1.0);
820                    }
821                    viewports.push((rx as u32, ry as u32, rw as u32, rh as u32));
822                    log::debug!(
823                        "runmat-plot: native_surface.render_scene_with_overlay.subplot_viewport viewport_index={} viewport=({}, {}, {}, {})",
824                        rect_index,
825                        rx as u32,
826                        ry as u32,
827                        rw as u32,
828                        rh as u32
829                    );
830                }
831                log::debug!(
832                    "runmat-plot: native_surface.render_scene_with_overlay.subplot_viewports_ready count={}",
833                    viewports.len()
834                );
835                axes_viewports_px = viewports.clone();
836                let axes_plot_sizes_px: Vec<(u32, u32)> = viewports
837                    .iter()
838                    .map(|&(_, _, w, h)| (w.max(1), h.max(1)))
839                    .collect();
840                self.renderer
841                    .ensure_scene_viewport_dependent_geometry_for_axes(&axes_plot_sizes_px);
842                self.renderer
843                    .render_axes_to_viewports(
844                        encoder,
845                        target_view,
846                        &viewports,
847                        self.config.msaa_samples.max(1),
848                        &self.config,
849                    )
850                    .map_err(|err| format!("native surface subplot render failed: {err}"))?;
851                log::debug!(
852                    "runmat-plot: native_surface.render_scene_with_overlay.subplot_render_ok viewports={}",
853                    viewports.len()
854                );
855            } else {
856                log::debug!(
857                    "runmat-plot: native_surface.render_scene_with_overlay.branch_single_axes"
858                );
859                {
860                    let clear_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
861                        label: Some("runmat-native-single-axes-clear"),
862                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
863                            view: target_view,
864                            resolve_target: None,
865                            ops: wgpu::Operations {
866                                load: wgpu::LoadOp::Clear(wgpu::Color {
867                                    r: self.config.background_color.x as f64,
868                                    g: self.config.background_color.y as f64,
869                                    b: self.config.background_color.z as f64,
870                                    a: self.config.background_color.w as f64,
871                                }),
872                                store: wgpu::StoreOp::Store,
873                            },
874                        })],
875                        depth_stencil_attachment: None,
876                        timestamp_writes: None,
877                        occlusion_query_set: None,
878                    });
879                    drop(clear_pass);
880                }
881
882                let mut cfg = self.config.clone();
883                cfg.width = vw.max(1);
884                cfg.height = vh.max(1);
885                let cam = self
886                    .renderer
887                    .axes_camera(0)
888                    .cloned()
889                    .unwrap_or_else(|| self.renderer.camera().clone());
890                let axes_plot_sizes_px = vec![(vw.max(1), vh.max(1))];
891                self.renderer
892                    .ensure_scene_viewport_dependent_geometry_for_axes(&axes_plot_sizes_px);
893                self.renderer
894                    .render_camera_to_viewport(
895                        encoder,
896                        target_view,
897                        (vx, vy, vw, vh),
898                        &cfg,
899                        &cam,
900                        0,
901                        true,
902                    )
903                    .map_err(|err| format!("native surface viewport render failed: {err}"))?;
904                log::debug!(
905                    "runmat-plot: native_surface.render_scene_with_overlay.single_axes_render_ok"
906                );
907            }
908
909            {
910                let render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
911                    label: Some("runmat-native-egui-overlay"),
912                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
913                        view: target_view,
914                        resolve_target: None,
915                        ops: wgpu::Operations {
916                            load: wgpu::LoadOp::Load,
917                            store: wgpu::StoreOp::Store,
918                        },
919                    })],
920                    depth_stencil_attachment: None,
921                    timestamp_writes: None,
922                    occlusion_query_set: None,
923                });
924                #[cfg(target_arch = "wasm32")]
925                let mut render_pass = render_pass.forget_lifetime();
926                #[cfg(not(target_arch = "wasm32"))]
927                let mut render_pass = render_pass;
928                overlay
929                    .egui_renderer
930                    .render(&mut render_pass, &paint_jobs, &screen_descriptor);
931                log::debug!(
932                    "runmat-plot: native_surface.render_scene_with_overlay.overlay_ok paint_jobs={} textures_set={} textures_free={}",
933                    paint_jobs.len(),
934                    full_output.textures_delta.set.len(),
935                    full_output.textures_delta.free.len()
936                );
937            }
938
939            for id in &full_output.textures_delta.free {
940                overlay.egui_renderer.free_texture(id);
941            }
942
943            Ok(RenderResult {
944                success: true,
945                data_bounds: self.renderer.data_bounds(),
946                axes_viewports_px,
947                vertex_count: scene_stats.total_vertices,
948                triangle_count: scene_stats.total_triangles,
949                render_time_ms: start_time.elapsed().as_secs_f64() * 1000.0,
950            })
951        }
952
953        #[cfg(not(feature = "egui-overlay"))]
954        {
955            self.renderer
956                .render_scene_to_target(encoder, target_view, &self.config)
957                .map_err(|err| format!("native surface render failed: {err}"))
958        }
959    }
960
961    fn apply_background_policy(&mut self) {
962        self.config.background_color = match self.background_policy {
963            BackgroundPolicy::ThemeDriven => {
964                self.renderer.theme.build_theme().get_background_color()
965            }
966            BackgroundPolicy::Explicit(color) => color,
967        };
968    }
969}
970
971fn is_default_figure_bg(bg: glam::Vec4) -> bool {
972    const EPS: f32 = 1e-3;
973    (bg.x - 1.0).abs() <= EPS
974        && (bg.y - 1.0).abs() <= EPS
975        && (bg.z - 1.0).abs() <= EPS
976        && (bg.w - 1.0).abs() <= EPS
977}
978
979async fn create_headless_context(
980    width: u32,
981    height: u32,
982) -> Result<NativeSurfaceRenderContext, String> {
983    // Export paths provide theme/plot colors in display (sRGB) space, just like interactive
984    // surface rendering on most backends. Using an sRGB attachment here applies an extra
985    // linear->sRGB conversion and visibly washes out captures.
986    let format = wgpu::TextureFormat::Rgba8Unorm;
987    if let Some(ctx) = crate::context::shared_wgpu_context() {
988        log::debug!(
989            "runmat-plot: native_surface.headless_context.branch_shared_context width={} height={}",
990            width,
991            height
992        );
993        return NativeSurfaceRenderContext::new(ctx.device, ctx.queue, width, height, format).await;
994    }
995
996    log::debug!(
997        "runmat-plot: native_surface.headless_context.branch_dedicated_context width={} height={}",
998        width,
999        height
1000    );
1001
1002    let instance = panic::catch_unwind(|| {
1003        crate::wgpu_compat::instance_new(wgpu::InstanceDescriptor::default())
1004    })
1005    .map_err(|payload| {
1006        format!(
1007            "{HEADLESS_GPU_CONTEXT_PANICKED_PREFIX} {}",
1008            panic_payload_to_string(payload)
1009        )
1010    })?;
1011    let adapter = instance
1012        .request_adapter(&wgpu::RequestAdapterOptions {
1013            power_preference: wgpu::PowerPreference::HighPerformance,
1014            compatible_surface: None,
1015            force_fallback_adapter: false,
1016        })
1017        .await
1018        .ok_or(HEADLESS_GPU_ADAPTER_UNAVAILABLE)?;
1019    let (device, queue) = adapter
1020        .request_device(&crate::wgpu_compat::default_device_descriptor(), None)
1021        .await
1022        .map_err(|err| format!("{HEADLESS_GPU_DEVICE_CREATION_FAILED_PREFIX} {err}"))?;
1023    let context =
1024        NativeSurfaceRenderContext::new(Arc::new(device), Arc::new(queue), width, height, format)
1025            .await?;
1026    log::debug!(
1027        "runmat-plot: native_surface.headless_context.ready width={} height={}",
1028        width,
1029        height
1030    );
1031    Ok(context)
1032}
1033
1034pub fn is_headless_gpu_unavailable_error(err: &str) -> bool {
1035    err.contains(HEADLESS_GPU_ADAPTER_UNAVAILABLE)
1036        || err.contains(HEADLESS_GPU_DEVICE_CREATION_FAILED_PREFIX)
1037        || err.contains(HEADLESS_GPU_CONTEXT_PANICKED_PREFIX)
1038}
1039
1040fn panic_payload_to_string(payload: Box<dyn Any + Send>) -> String {
1041    if let Some(message) = payload.downcast_ref::<&str>() {
1042        (*message).to_string()
1043    } else if let Some(message) = payload.downcast_ref::<String>() {
1044        message.clone()
1045    } else {
1046        "unknown panic payload".to_string()
1047    }
1048}
1049
1050pub async fn render_figure_rgba_bytes_interactive_with_camera(
1051    figure: Figure,
1052    width: u32,
1053    height: u32,
1054    camera: &Camera,
1055) -> Result<Vec<u8>, String> {
1056    let mut context = create_headless_context(width.max(1), height.max(1)).await?;
1057    context.render_to_rgba(&figure, Some(camera), None).await
1058}
1059
1060pub async fn render_figure_rgba_bytes_interactive_with_camera_and_theme(
1061    figure: Figure,
1062    width: u32,
1063    height: u32,
1064    camera: &Camera,
1065    theme: PlotThemeConfig,
1066) -> Result<Vec<u8>, String> {
1067    let mut context = create_headless_context(width.max(1), height.max(1)).await?;
1068    context.set_theme_config(theme);
1069    context.render_to_rgba(&figure, Some(camera), None).await
1070}
1071
1072pub async fn render_figure_rgba_bytes_interactive_with_axes_cameras(
1073    figure: Figure,
1074    width: u32,
1075    height: u32,
1076    axes_cameras: &[Camera],
1077) -> Result<Vec<u8>, String> {
1078    let mut context = create_headless_context(width.max(1), height.max(1)).await?;
1079    context
1080        .render_to_rgba(&figure, None, Some(axes_cameras))
1081        .await
1082}
1083
1084pub async fn render_figure_rgba_bytes_interactive_with_axes_cameras_and_theme(
1085    figure: Figure,
1086    width: u32,
1087    height: u32,
1088    axes_cameras: &[Camera],
1089    theme: PlotThemeConfig,
1090) -> Result<Vec<u8>, String> {
1091    let mut context = create_headless_context(width.max(1), height.max(1)).await?;
1092    context.set_theme_config(theme);
1093    context
1094        .render_to_rgba(&figure, None, Some(axes_cameras))
1095        .await
1096}
1097
1098pub async fn render_figure_rgba_bytes_interactive(
1099    figure: Figure,
1100    width: u32,
1101    height: u32,
1102) -> Result<Vec<u8>, String> {
1103    let mut context = create_headless_context(width.max(1), height.max(1)).await?;
1104    context.render_to_rgba(&figure, None, None).await
1105}
1106
1107pub async fn render_figure_rgba_bytes_interactive_and_theme(
1108    figure: Figure,
1109    width: u32,
1110    height: u32,
1111    theme: PlotThemeConfig,
1112) -> Result<Vec<u8>, String> {
1113    let mut context = create_headless_context(width.max(1), height.max(1)).await?;
1114    context.set_theme_config(theme);
1115    context.render_to_rgba(&figure, None, None).await
1116}
1117
1118pub async fn render_geometry_scene_rgba_bytes_interactive_with_camera_and_theme(
1119    scene: GeometryScene,
1120    width: u32,
1121    height: u32,
1122    camera: &Camera,
1123    theme: PlotThemeConfig,
1124) -> Result<Vec<u8>, String> {
1125    let mut context = create_headless_context(width.max(1), height.max(1)).await?;
1126    context.set_theme_config(theme);
1127    context
1128        .render_geometry_scene_to_rgba(&scene, Some(camera))
1129        .await
1130}
1131
1132pub async fn render_geometry_scene_rgba_bytes_interactive_and_theme(
1133    scene: GeometryScene,
1134    width: u32,
1135    height: u32,
1136    theme: PlotThemeConfig,
1137) -> Result<Vec<u8>, String> {
1138    let mut context = create_headless_context(width.max(1), height.max(1)).await?;
1139    context.set_theme_config(theme);
1140    context.render_geometry_scene_to_rgba(&scene, None).await
1141}
1142
1143pub async fn render_geometry_scene_png_bytes_interactive_with_camera_and_theme(
1144    scene: GeometryScene,
1145    width: u32,
1146    height: u32,
1147    camera: &Camera,
1148    theme: PlotThemeConfig,
1149) -> Result<Vec<u8>, String> {
1150    let rgba = render_geometry_scene_rgba_bytes_interactive_with_camera_and_theme(
1151        scene, width, height, camera, theme,
1152    )
1153    .await?;
1154    encode_png_bytes(width.max(1), height.max(1), &rgba)
1155}
1156
1157pub async fn render_geometry_scene_png_bytes_interactive_and_theme(
1158    scene: GeometryScene,
1159    width: u32,
1160    height: u32,
1161    theme: PlotThemeConfig,
1162) -> Result<Vec<u8>, String> {
1163    let rgba =
1164        render_geometry_scene_rgba_bytes_interactive_and_theme(scene, width, height, theme).await?;
1165    encode_png_bytes(width.max(1), height.max(1), &rgba)
1166}
1167
1168fn encode_png_bytes(width: u32, height: u32, rgba: &[u8]) -> Result<Vec<u8>, String> {
1169    use image::{ImageBuffer, ImageFormat, Rgba};
1170
1171    let mut opaque = rgba.to_vec();
1172    for pixel in opaque.chunks_exact_mut(4) {
1173        pixel[3] = 255;
1174    }
1175
1176    let image = ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, opaque)
1177        .ok_or_else(|| "Failed to create image buffer for PNG encoding".to_string())?;
1178    let mut out = std::io::Cursor::new(Vec::new());
1179    image
1180        .write_to(&mut out, ImageFormat::Png)
1181        .map_err(|err| format!("Failed to encode PNG bytes: {err}"))?;
1182    Ok(out.into_inner())
1183}
1184
1185pub async fn render_figure_png_bytes_interactive(
1186    figure: Figure,
1187    width: u32,
1188    height: u32,
1189) -> Result<Vec<u8>, String> {
1190    let rgba = render_figure_rgba_bytes_interactive(figure, width, height).await?;
1191    encode_png_bytes(width.max(1), height.max(1), &rgba)
1192}
1193
1194pub async fn render_figure_png_bytes_interactive_and_theme(
1195    figure: Figure,
1196    width: u32,
1197    height: u32,
1198    theme: PlotThemeConfig,
1199) -> Result<Vec<u8>, String> {
1200    let rgba = render_figure_rgba_bytes_interactive_and_theme(figure, width, height, theme).await?;
1201    encode_png_bytes(width.max(1), height.max(1), &rgba)
1202}
1203
1204pub async fn render_figure_png_bytes_interactive_and_theme_and_textmark(
1205    figure: Figure,
1206    width: u32,
1207    height: u32,
1208    theme: PlotThemeConfig,
1209    textmark: Option<&str>,
1210) -> Result<Vec<u8>, String> {
1211    log::debug!(
1212        "runmat-plot: render_figure_png_bytes_interactive_and_theme_and_textmark.start width={} height={} axes={} textmark={}",
1213        width,
1214        height,
1215        figure.axes_metadata.len(),
1216        textmark.unwrap_or("")
1217    );
1218    let mut context = create_headless_context(width.max(1), height.max(1)).await?;
1219    log::debug!(
1220        "runmat-plot: render_figure_png_bytes_interactive_and_theme_and_textmark.context_ready width={} height={}",
1221        width.max(1),
1222        height.max(1)
1223    );
1224    context.set_theme_config(theme);
1225    context.set_textmark(textmark);
1226    let rgba = context.render_to_rgba(&figure, None, None).await?;
1227    log::debug!(
1228        "runmat-plot: render_figure_png_bytes_interactive_and_theme_and_textmark.rgba_ready bytes={}",
1229        rgba.len()
1230    );
1231    encode_png_bytes(width.max(1), height.max(1), &rgba)
1232}
1233
1234pub async fn render_figure_png_bytes_interactive_with_camera(
1235    figure: Figure,
1236    width: u32,
1237    height: u32,
1238    camera: &Camera,
1239) -> Result<Vec<u8>, String> {
1240    let rgba =
1241        render_figure_rgba_bytes_interactive_with_camera(figure, width, height, camera).await?;
1242    encode_png_bytes(width.max(1), height.max(1), &rgba)
1243}
1244
1245pub async fn render_figure_png_bytes_interactive_with_camera_and_theme(
1246    figure: Figure,
1247    width: u32,
1248    height: u32,
1249    camera: &Camera,
1250    theme: PlotThemeConfig,
1251) -> Result<Vec<u8>, String> {
1252    let rgba = render_figure_rgba_bytes_interactive_with_camera_and_theme(
1253        figure, width, height, camera, theme,
1254    )
1255    .await?;
1256    encode_png_bytes(width.max(1), height.max(1), &rgba)
1257}
1258
1259pub async fn render_figure_png_bytes_interactive_with_camera_and_theme_and_textmark(
1260    figure: Figure,
1261    width: u32,
1262    height: u32,
1263    camera: &Camera,
1264    theme: PlotThemeConfig,
1265    textmark: Option<&str>,
1266) -> Result<Vec<u8>, String> {
1267    let mut context = create_headless_context(width.max(1), height.max(1)).await?;
1268    context.set_theme_config(theme);
1269    context.set_textmark(textmark);
1270    let rgba = context.render_to_rgba(&figure, Some(camera), None).await?;
1271    encode_png_bytes(width.max(1), height.max(1), &rgba)
1272}
1273
1274pub async fn render_figure_png_bytes_interactive_with_axes_cameras(
1275    figure: Figure,
1276    width: u32,
1277    height: u32,
1278    axes_cameras: &[Camera],
1279) -> Result<Vec<u8>, String> {
1280    let rgba =
1281        render_figure_rgba_bytes_interactive_with_axes_cameras(figure, width, height, axes_cameras)
1282            .await?;
1283    encode_png_bytes(width.max(1), height.max(1), &rgba)
1284}
1285
1286pub async fn render_figure_png_bytes_interactive_with_axes_cameras_and_theme(
1287    figure: Figure,
1288    width: u32,
1289    height: u32,
1290    axes_cameras: &[Camera],
1291    theme: PlotThemeConfig,
1292) -> Result<Vec<u8>, String> {
1293    let rgba = render_figure_rgba_bytes_interactive_with_axes_cameras_and_theme(
1294        figure,
1295        width,
1296        height,
1297        axes_cameras,
1298        theme,
1299    )
1300    .await?;
1301    encode_png_bytes(width.max(1), height.max(1), &rgba)
1302}
1303
1304pub async fn render_figure_png_bytes_interactive_with_axes_cameras_and_theme_and_textmark(
1305    figure: Figure,
1306    width: u32,
1307    height: u32,
1308    axes_cameras: &[Camera],
1309    theme: PlotThemeConfig,
1310    textmark: Option<&str>,
1311) -> Result<Vec<u8>, String> {
1312    log::debug!(
1313        "runmat-plot: render_figure_png_bytes_interactive_with_axes_cameras_and_theme_and_textmark.start width={} height={} axes={} camera_overrides={} textmark={}",
1314        width,
1315        height,
1316        figure.axes_metadata.len(),
1317        axes_cameras.len(),
1318        textmark.unwrap_or("")
1319    );
1320    let mut context = create_headless_context(width.max(1), height.max(1)).await?;
1321    log::debug!(
1322        "runmat-plot: render_figure_png_bytes_interactive_with_axes_cameras_and_theme_and_textmark.context_ready width={} height={}",
1323        width.max(1),
1324        height.max(1)
1325    );
1326    context.set_theme_config(theme);
1327    context.set_textmark(textmark);
1328    let rgba = context
1329        .render_to_rgba(&figure, None, Some(axes_cameras))
1330        .await?;
1331    log::debug!(
1332        "runmat-plot: render_figure_png_bytes_interactive_with_axes_cameras_and_theme_and_textmark.rgba_ready bytes={}",
1333        rgba.len()
1334    );
1335    encode_png_bytes(width.max(1), height.max(1), &rgba)
1336}
1337
1338#[cfg(feature = "egui-overlay")]
1339fn apply_cad_overlay_actions(
1340    renderer: &mut PlotRenderer,
1341    actions: crate::overlay::cad_overlay::CadOverlayActions,
1342) -> Vec<NativeSurfaceHostAction> {
1343    let mut host_actions = Vec::new();
1344    if actions.reset_view {
1345        renderer.reset_geometry_view();
1346    }
1347    if actions.create_fea_study {
1348        host_actions.push(NativeSurfaceHostAction::CreateFeaStudy);
1349    }
1350    if let Some(preset) = actions.view_preset {
1351        renderer.set_camera_view_preset(preset);
1352    }
1353    if let Some(enabled) = actions.grid_enabled {
1354        renderer.set_overlay_grid_enabled(enabled);
1355    }
1356    if let Some(enabled) = actions.xray_enabled {
1357        renderer.set_geometry_xray_enabled(enabled);
1358    }
1359    for (owner_id, visible) in actions.owner_visibility {
1360        renderer.set_geometry_owner_visible(owner_id, visible);
1361    }
1362    host_actions
1363}
1364
1365#[cfg(test)]
1366mod tests {
1367    use super::*;
1368
1369    #[test]
1370    fn headless_gpu_panic_errors_are_fallback_eligible() {
1371        assert!(is_headless_gpu_unavailable_error(
1372            "Headless GPU context creation panicked: called `Option::unwrap()` on a `None` value"
1373        ));
1374    }
1375}