Skip to main content

runmat_runtime/builtins/plotting/core/
engine.rs

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(&centerline)),
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}