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