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