1use runmat_plot::plots::Figure;
2
3#[cfg(not(any(feature = "gui", all(target_arch = "wasm32", feature = "plot-web"))))]
4use super::common::ERR_PLOTTING_UNAVAILABLE;
5use super::state::{clone_figure, FigureHandle};
6use thiserror::Error;
7
8#[cfg(feature = "plot-core")]
9use crate::builtins::common::map_control_flow_with_builtin;
10use crate::{build_runtime_error, BuiltinResult, RuntimeError};
11
12#[derive(Debug, Error)]
13#[allow(dead_code)]
14enum PlottingBackendError {
15 #[error("interactive backend error: {0}")]
16 Interactive(String),
17 #[error("static backend error: {0}")]
18 Static(String),
19 #[error("jupyter backend error: {0}")]
20 Jupyter(String),
21 #[error("export initialization error: {0}")]
22 ImageExportInit(String),
23 #[error("export render error: {0}")]
24 ImageExport(String),
25}
26
27fn engine_error(message: impl Into<String>) -> RuntimeError {
28 build_runtime_error(message)
29 .with_identifier("RunMat:plot:EngineError")
30 .build()
31}
32
33fn engine_error_with_source(
34 message: impl Into<String>,
35 source: impl std::error::Error + Send + Sync + 'static,
36) -> RuntimeError {
37 build_runtime_error(message)
38 .with_identifier("RunMat:plot:EngineError")
39 .with_source(source)
40 .build()
41}
42
43#[cfg(not(all(target_arch = "wasm32", feature = "plot-web")))]
44pub fn render_figure(handle: FigureHandle, figure: Figure) -> BuiltinResult<String> {
45 #[cfg(feature = "gui")]
46 {
47 native::render(handle, figure)
48 }
49
50 #[cfg(not(feature = "gui"))]
51 {
52 let _ = handle;
53 let _ = figure;
54 Err(engine_error(ERR_PLOTTING_UNAVAILABLE))
55 }
56}
57
58#[cfg(feature = "plot-core")]
59pub async fn render_figure_png_bytes(
60 mut figure: Figure,
61 width: u32,
62 height: u32,
63) -> BuiltinResult<Vec<u8>> {
64 use runmat_plot::export::image::{ImageExportSettings, ImageExporter};
65
66 let mut settings = ImageExportSettings::default();
67 if width > 0 {
68 settings.width = width;
69 }
70 if height > 0 {
71 settings.height = height;
72 }
73
74 let mut exporter = ImageExporter::with_settings(settings)
75 .await
76 .map_err(|err| {
77 engine_error_with_source(
78 "Plot export initialization failed.",
79 PlottingBackendError::ImageExportInit(err),
80 )
81 })?;
82 exporter.set_theme_config(super::web::current_plot_theme_config());
83 exporter.render_png_bytes(&mut figure).await.map_err(|err| {
84 engine_error_with_source(
85 "Plot export failed.",
86 PlottingBackendError::ImageExport(err),
87 )
88 })
89}
90
91#[cfg(feature = "plot-core")]
92pub async fn render_figure_rgba_bytes(
93 mut figure: Figure,
94 width: u32,
95 height: u32,
96) -> BuiltinResult<Vec<u8>> {
97 use runmat_plot::export::image::{ImageExportSettings, ImageExporter};
98
99 let mut settings = ImageExportSettings::default();
100 if width > 0 {
101 settings.width = width;
102 }
103 if height > 0 {
104 settings.height = height;
105 }
106
107 let mut exporter = ImageExporter::with_settings(settings)
108 .await
109 .map_err(|err| {
110 engine_error_with_source(
111 "Plot export initialization failed.",
112 PlottingBackendError::ImageExportInit(err),
113 )
114 })?;
115 exporter.set_theme_config(super::web::current_plot_theme_config());
116 exporter
117 .render_rgba_bytes(&mut figure)
118 .await
119 .map_err(|err| {
120 engine_error_with_source(
121 "Plot export failed.",
122 PlottingBackendError::ImageExport(err),
123 )
124 })
125}
126
127#[cfg(feature = "plot-core")]
128pub async fn render_figure_png_bytes_with_camera(
129 mut figure: Figure,
130 width: u32,
131 height: u32,
132 camera: &runmat_plot::core::Camera,
133) -> BuiltinResult<Vec<u8>> {
134 use runmat_plot::export::image::{ImageExportSettings, ImageExporter};
135
136 let mut settings = ImageExportSettings::default();
137 if width > 0 {
138 settings.width = width;
139 }
140 if height > 0 {
141 settings.height = height;
142 }
143
144 let mut exporter = ImageExporter::with_settings(settings)
145 .await
146 .map_err(|err| {
147 engine_error_with_source(
148 "Plot export initialization failed.",
149 PlottingBackendError::ImageExportInit(err),
150 )
151 })?;
152 exporter.set_theme_config(super::web::current_plot_theme_config());
153 exporter
154 .render_png_bytes_with_camera(&mut figure, camera)
155 .await
156 .map_err(|err| {
157 engine_error_with_source(
158 "Plot export failed.",
159 PlottingBackendError::ImageExport(err),
160 )
161 })
162}
163
164#[cfg(feature = "plot-core")]
165pub async fn render_figure_rgba_bytes_with_camera(
166 mut figure: Figure,
167 width: u32,
168 height: u32,
169 camera: &runmat_plot::core::Camera,
170) -> BuiltinResult<Vec<u8>> {
171 use runmat_plot::export::image::{ImageExportSettings, ImageExporter};
172
173 let mut settings = ImageExportSettings::default();
174 if width > 0 {
175 settings.width = width;
176 }
177 if height > 0 {
178 settings.height = height;
179 }
180
181 let mut exporter = ImageExporter::with_settings(settings)
182 .await
183 .map_err(|err| {
184 engine_error_with_source(
185 "Plot export initialization failed.",
186 PlottingBackendError::ImageExportInit(err),
187 )
188 })?;
189 exporter.set_theme_config(super::web::current_plot_theme_config());
190 exporter
191 .render_rgba_bytes_with_camera(&mut figure, camera)
192 .await
193 .map_err(|err| {
194 engine_error_with_source(
195 "Plot export failed.",
196 PlottingBackendError::ImageExport(err),
197 )
198 })
199}
200
201#[cfg(feature = "plot-core")]
202pub async fn render_figure_png_bytes_with_axes_cameras(
203 mut figure: Figure,
204 width: u32,
205 height: u32,
206 axes_cameras: &[runmat_plot::core::Camera],
207) -> BuiltinResult<Vec<u8>> {
208 use runmat_plot::export::image::{ImageExportSettings, ImageExporter};
209
210 let mut settings = ImageExportSettings::default();
211 if width > 0 {
212 settings.width = width;
213 }
214 if height > 0 {
215 settings.height = height;
216 }
217
218 let mut exporter = ImageExporter::with_settings(settings)
219 .await
220 .map_err(|err| {
221 engine_error_with_source(
222 "Plot export initialization failed.",
223 PlottingBackendError::ImageExportInit(err),
224 )
225 })?;
226 exporter.set_theme_config(super::web::current_plot_theme_config());
227 exporter
228 .render_png_bytes_with_axes_cameras(&mut figure, axes_cameras)
229 .await
230 .map_err(|err| {
231 engine_error_with_source(
232 "Plot export failed.",
233 PlottingBackendError::ImageExport(err),
234 )
235 })
236}
237
238#[cfg(feature = "plot-core")]
239pub async fn render_figure_rgba_bytes_with_axes_cameras(
240 mut figure: Figure,
241 width: u32,
242 height: u32,
243 axes_cameras: &[runmat_plot::core::Camera],
244) -> BuiltinResult<Vec<u8>> {
245 use runmat_plot::export::image::{ImageExportSettings, ImageExporter};
246
247 let mut settings = ImageExportSettings::default();
248 if width > 0 {
249 settings.width = width;
250 }
251 if height > 0 {
252 settings.height = height;
253 }
254
255 let mut exporter = ImageExporter::with_settings(settings)
256 .await
257 .map_err(|err| {
258 engine_error_with_source(
259 "Plot export initialization failed.",
260 PlottingBackendError::ImageExportInit(err),
261 )
262 })?;
263 exporter.set_theme_config(super::web::current_plot_theme_config());
264 exporter
265 .render_rgba_bytes_with_axes_cameras(&mut figure, axes_cameras)
266 .await
267 .map_err(|err| {
268 engine_error_with_source(
269 "Plot export failed.",
270 PlottingBackendError::ImageExport(err),
271 )
272 })
273}
274
275#[cfg(feature = "plot-core")]
276pub async fn render_figure_snapshot(
277 handle: FigureHandle,
278 width: u32,
279 height: u32,
280 textmark: Option<String>,
281) -> BuiltinResult<Vec<u8>> {
282 const SNAPSHOT_CONTEXT: &str = "renderFigureImage";
283 log::debug!(
284 "runmat-runtime: render_figure_snapshot.start handle={} width={} height={} textmark={}",
285 handle.as_u32(),
286 width,
287 height,
288 textmark.as_deref().unwrap_or("")
289 );
290 let figure = clone_figure(handle).ok_or_else(|| {
291 map_control_flow_with_builtin(
292 engine_error(format!("figure handle {} does not exist", handle.as_u32())),
293 SNAPSHOT_CONTEXT,
294 )
295 })?;
296 log::debug!(
297 "runmat-runtime: render_figure_snapshot.figure_cloned handle={} axes={} elements={}",
298 handle.as_u32(),
299 figure.axes_metadata.len(),
300 figure.statistics().total_plots
301 );
302 let bytes = runmat_plot::export::native_surface::render_figure_png_bytes_interactive_and_theme_and_textmark(
303 figure,
304 width,
305 height,
306 super::web::current_plot_theme_config(),
307 textmark.as_deref(),
308 )
309 .await
310 .map_err(|err| {
311 log::warn!(
312 "runmat-runtime: render_figure_snapshot.failed handle={} width={} height={} error={}",
313 handle.as_u32(),
314 width,
315 height,
316 err
317 );
318 map_control_flow_with_builtin(
319 engine_error_with_source(
320 format!("Plot export failed: {err}"),
321 PlottingBackendError::ImageExport(err),
322 ),
323 SNAPSHOT_CONTEXT,
324 )
325 })?;
326 log::debug!(
327 "runmat-runtime: render_figure_snapshot.ok handle={} bytes={}",
328 handle.as_u32(),
329 bytes.len()
330 );
331 Ok(bytes)
332}
333
334#[cfg(feature = "plot-core")]
335pub async fn render_figure_snapshot_with_camera_state(
336 handle: FigureHandle,
337 width: u32,
338 height: u32,
339 camera_state: super::web::PlotSurfaceCameraState,
340 textmark: Option<String>,
341) -> BuiltinResult<Vec<u8>> {
342 const SNAPSHOT_CONTEXT: &str = "renderFigureImage";
343 log::debug!(
344 "runmat-runtime: render_figure_snapshot_with_camera_state.start handle={} width={} height={} axes={}",
345 handle.as_u32(),
346 width,
347 height,
348 camera_state.axes.len()
349 );
350 let figure = clone_figure(handle).ok_or_else(|| {
351 map_control_flow_with_builtin(
352 engine_error(format!("figure handle {} does not exist", handle.as_u32())),
353 SNAPSHOT_CONTEXT,
354 )
355 })?;
356
357 let axes_cameras: Vec<runmat_plot::core::Camera> = camera_state
358 .axes
359 .iter()
360 .map(surface_plot_camera_to_core_camera)
361 .collect();
362
363 if axes_cameras.is_empty() {
364 let bytes = runmat_plot::export::native_surface::render_figure_png_bytes_interactive_and_theme_and_textmark(
365 figure,
366 width,
367 height,
368 super::web::current_plot_theme_config(),
369 textmark.as_deref(),
370 )
371 .await
372 .map_err(|err| {
373 log::warn!(
374 "runmat-runtime: render_figure_snapshot_with_camera_state.fallback_failed handle={} error={}",
375 handle.as_u32(),
376 err
377 );
378 map_control_flow_with_builtin(
379 engine_error_with_source(
380 format!("Plot export failed: {err}"),
381 PlottingBackendError::ImageExport(err),
382 ),
383 SNAPSHOT_CONTEXT,
384 )
385 })?;
386 log::debug!(
387 "runmat-runtime: render_figure_snapshot_with_camera_state.fallback_ok handle={} bytes={}",
388 handle.as_u32(),
389 bytes.len()
390 );
391 return Ok(bytes);
392 }
393
394 let bytes = runmat_plot::export::native_surface::render_figure_png_bytes_interactive_with_axes_cameras_and_theme_and_textmark(
395 figure,
396 width,
397 height,
398 &axes_cameras,
399 super::web::current_plot_theme_config(),
400 textmark.as_deref(),
401 )
402 .await
403 .map_err(|err| {
404 log::warn!(
405 "runmat-runtime: render_figure_snapshot_with_camera_state.failed handle={} axes={} error={}",
406 handle.as_u32(),
407 axes_cameras.len(),
408 err
409 );
410 map_control_flow_with_builtin(
411 engine_error_with_source(
412 format!("Plot export failed: {err}"),
413 PlottingBackendError::ImageExport(err),
414 ),
415 SNAPSHOT_CONTEXT,
416 )
417 })?;
418 log::debug!(
419 "runmat-runtime: render_figure_snapshot_with_camera_state.ok handle={} bytes={} axes={}",
420 handle.as_u32(),
421 bytes.len(),
422 axes_cameras.len()
423 );
424 Ok(bytes)
425}
426
427#[cfg(feature = "plot-core")]
428fn surface_plot_camera_to_core_camera(
429 state: &super::web::PlotCameraState,
430) -> runmat_plot::core::Camera {
431 let mut camera = runmat_plot::core::Camera::new();
432 camera.position = glam::Vec3::new(state.position[0], state.position[1], state.position[2]);
433 camera.target = glam::Vec3::new(state.target[0], state.target[1], state.target[2]);
434 camera.up = glam::Vec3::new(state.up[0], state.up[1], state.up[2]);
435 camera.zoom = state.zoom;
436 camera.aspect_ratio = state.aspect_ratio.max(0.000_1);
437 camera.projection = match state.projection {
438 super::web::PlotCameraProjection::Perspective { fov, near, far } => {
439 runmat_plot::core::camera::ProjectionType::Perspective {
440 fov,
441 near: near.max(1.0e-6),
442 far: far.max(near + 1.0e-6),
443 }
444 }
445 super::web::PlotCameraProjection::Orthographic {
446 left,
447 right,
448 bottom,
449 top,
450 near,
451 far,
452 } => runmat_plot::core::camera::ProjectionType::Orthographic {
453 left,
454 right,
455 bottom,
456 top,
457 near,
458 far,
459 },
460 };
461 camera.mark_dirty();
462 camera
463}
464
465#[cfg(feature = "gui")]
466pub(crate) mod native {
467 use super::super::state::{install_figure_observer, FigureEventKind, FigureEventView};
468 use super::*;
469 use once_cell::sync::OnceCell;
470 use runmat_plot::plots::Figure;
471 use std::env;
472 use std::sync::Arc;
473
474 static FIGURE_EVENT_BRIDGE: OnceCell<()> = OnceCell::new();
475
476 #[derive(Debug, Clone, Copy)]
477 enum PlottingMode {
478 Auto,
479 Interactive,
480 Static,
481 Jupyter,
482 }
483
484 pub fn render(handle: FigureHandle, mut figure: Figure) -> BuiltinResult<String> {
485 ensure_figure_event_bridge();
486 match detect_mode() {
487 PlottingMode::Interactive => interactive_export(handle, &mut figure),
488 PlottingMode::Static => static_export(&mut figure, "plot.png"),
489 PlottingMode::Jupyter => jupyter_export(&mut figure),
490 PlottingMode::Auto => {
491 if env::var("JPY_PARENT_PID").is_ok() || env::var("JUPYTER_RUNTIME_DIR").is_ok() {
492 jupyter_export(&mut figure)
493 } else {
494 interactive_export(handle, &mut figure)
495 }
496 }
497 }
498 }
499
500 fn detect_mode() -> PlottingMode {
501 if let Ok(mode) = env::var("RUNMAT_PLOT_MODE") {
502 match mode.to_lowercase().as_str() {
503 "gui" => PlottingMode::Interactive,
504 "headless" | "static" => PlottingMode::Static,
505 "jupyter" => PlottingMode::Jupyter,
506 _ => PlottingMode::Auto,
507 }
508 } else {
509 PlottingMode::Auto
510 }
511 }
512
513 fn interactive_export(handle: FigureHandle, figure: &mut Figure) -> BuiltinResult<String> {
514 let figure_clone = figure.clone();
515 runmat_plot::render_interactive_with_handle(handle.as_u32(), figure_clone).map_err(|err| {
516 engine_error_with_source(
517 "Interactive plotting failed. Please check GPU/GUI system setup.",
518 PlottingBackendError::Interactive(err),
519 )
520 })
521 }
522
523 fn static_export(figure: &mut Figure, filename: &str) -> BuiltinResult<String> {
524 if figure.is_empty() {
525 return Err(engine_error("No plots found in figure to export"));
526 }
527 runmat_plot::show_plot_unified(figure.clone(), Some(filename))
528 .map(|_| format!("Plot saved to {filename}"))
529 .map_err(|err| {
530 engine_error_with_source("Plot export failed.", PlottingBackendError::Static(err))
531 })
532 }
533
534 #[cfg(feature = "jupyter")]
535 fn jupyter_export(figure: &mut Figure) -> BuiltinResult<String> {
536 use runmat_plot::jupyter::JupyterBackend;
537 let mut backend = JupyterBackend::new();
538 backend.display_figure(figure).map_err(|err| {
539 engine_error_with_source(
540 "Jupyter plotting failed.",
541 PlottingBackendError::Jupyter(err),
542 )
543 })
544 }
545
546 #[cfg(not(feature = "jupyter"))]
547 fn jupyter_export(_figure: &mut Figure) -> BuiltinResult<String> {
548 Err(engine_error("Jupyter feature not enabled"))
549 }
550
551 fn ensure_figure_event_bridge() {
552 FIGURE_EVENT_BRIDGE.get_or_init(|| {
553 let observer: Arc<dyn for<'a> Fn(FigureEventView<'a>) + Send + Sync> =
554 Arc::new(|event: FigureEventView<'_>| {
555 if let FigureEventKind::Closed = event.kind {
556 runmat_plot::gui::lifecycle::request_close(event.handle.as_u32());
557 }
558 });
559 let _ = install_figure_observer(observer);
560 });
561 }
562}
563
564#[cfg(all(test, feature = "plot-core"))]
565mod tests {
566 use super::render_figure_snapshot;
567 use crate::builtins::plotting::plot::plot_builtin;
568 use crate::builtins::plotting::state::{
569 clear_figure, current_figure_handle, reset_hold_state_for_run, PlotTestLockGuard,
570 };
571 use crate::builtins::plotting::subplot::subplot_builtin;
572 use crate::builtins::plotting::tests::{ensure_plot_test_env, lock_plot_registry};
573 use crate::builtins::plotting::title::title_builtin;
574 use crate::builtins::plotting::xlabel::xlabel_builtin;
575 use crate::builtins::plotting::ylabel::ylabel_builtin;
576 use futures::executor::block_on;
577 use runmat_builtins::{Tensor, Value};
578
579 fn setup_plot_tests() -> PlotTestLockGuard {
580 let guard = lock_plot_registry();
581 ensure_plot_test_env();
582 reset_hold_state_for_run();
583 let _ = clear_figure(None);
584 guard
585 }
586
587 fn tensor_from(data: &[f64]) -> Tensor {
588 Tensor {
589 data: data.to_vec(),
590 shape: vec![data.len()],
591 rows: data.len(),
592 cols: 1,
593 dtype: runmat_builtins::NumericDType::F64,
594 }
595 }
596
597 #[test]
598 fn render_figure_snapshot_supports_margin_style_two_axes_lines() {
599 let _guard = setup_plot_tests();
600 let x_mm: Vec<f64> = (-30..=30).map(|i| i as f64).collect();
601 let y_mm: Vec<f64> = (-25..=25).map(|i| i as f64).collect();
602 let centerline: Vec<f64> = x_mm
603 .iter()
604 .map(|x| 25.0 + 18.0 * (-(x / 11.0).powi(2)).exp())
605 .collect();
606 let vertical: Vec<f64> = y_mm
607 .iter()
608 .map(|y| 25.0 + 20.0 * (-(y / 9.0).powi(2)).exp())
609 .collect();
610
611 subplot_builtin(Value::Num(1.0), Value::Num(2.0), Value::Num(1.0)).expect("subplot 1");
612 block_on(plot_builtin(vec![
613 Value::Tensor(tensor_from(&x_mm)),
614 Value::Tensor(tensor_from(¢erline)),
615 ]))
616 .expect("left plot");
617 title_builtin(vec![Value::String("Centerline slice".into())]).expect("left title");
618 xlabel_builtin(vec![Value::String("x (mm)".into())]).expect("left xlabel");
619 ylabel_builtin(vec![Value::String("temperature (C)".into())]).expect("left ylabel");
620
621 subplot_builtin(Value::Num(1.0), Value::Num(2.0), Value::Num(2.0)).expect("subplot 2");
622 block_on(plot_builtin(vec![
623 Value::Tensor(tensor_from(&y_mm)),
624 Value::Tensor(tensor_from(&vertical)),
625 ]))
626 .expect("right plot");
627 title_builtin(vec![Value::String("Vertical slice through source".into())])
628 .expect("right title");
629 xlabel_builtin(vec![Value::String("y (mm)".into())]).expect("right xlabel");
630 ylabel_builtin(vec![Value::String("temperature (C)".into())]).expect("right ylabel");
631
632 let handle = current_figure_handle();
633 let bytes =
634 block_on(render_figure_snapshot(handle, 1280, 720, None)).expect("snapshot render");
635 assert!(bytes.len() > 1000, "expected nontrivial PNG payload");
636 }
637}