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