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};
6#[cfg(feature = "plot-core")]
7use crate::builtins::plotting::clone_geometry_scene;
8use thiserror::Error;
9
10#[cfg(feature = "plot-core")]
11use crate::builtins::common::map_control_flow_with_builtin;
12use crate::{build_runtime_error, BuiltinResult, RuntimeError};
13
14#[derive(Debug, Error)]
15#[allow(dead_code)]
16enum PlottingBackendError {
17    #[error("interactive backend error: {0}")]
18    Interactive(String),
19    #[error("static backend error: {0}")]
20    Static(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    use runmat_plot::export::image::{ImageExportSettings, ImageExporter};
284    log::debug!(
285        "runmat-runtime: render_figure_snapshot.start handle={} width={} height={} textmark={}",
286        handle.as_u32(),
287        width,
288        height,
289        textmark.as_deref().unwrap_or("")
290    );
291    let figure = clone_figure(handle).ok_or_else(|| {
292        map_control_flow_with_builtin(
293            engine_error(format!("figure handle {} does not exist", handle.as_u32())),
294            SNAPSHOT_CONTEXT,
295        )
296    })?;
297    log::debug!(
298        "runmat-runtime: render_figure_snapshot.figure_cloned handle={} axes={} elements={}",
299        handle.as_u32(),
300        figure.axes_metadata.len(),
301        figure.statistics().total_plots
302    );
303    let mut settings = ImageExportSettings::default();
304    if width > 0 {
305        settings.width = width;
306    }
307    if height > 0 {
308        settings.height = height;
309    }
310    let mut exporter = ImageExporter::with_settings(settings)
311        .await
312        .map_err(|err| {
313            map_control_flow_with_builtin(
314                engine_error_with_source(
315                    "Plot export initialization failed.",
316                    PlottingBackendError::ImageExportInit(err),
317                ),
318                SNAPSHOT_CONTEXT,
319            )
320        })?;
321    exporter.set_theme_config(super::web::current_plot_theme_config());
322    exporter.set_textmark(textmark.as_deref());
323
324    let mut figure = figure;
325    let bytes = exporter
326        .render_png_bytes(&mut figure)
327        .await
328        .map_err(|err| {
329            log::warn!(
330            "runmat-runtime: render_figure_snapshot.failed handle={} width={} height={} error={}",
331            handle.as_u32(),
332            width,
333            height,
334            err
335        );
336            map_control_flow_with_builtin(
337                engine_error_with_source(
338                    format!("Plot export failed: {err}"),
339                    PlottingBackendError::ImageExport(err),
340                ),
341                SNAPSHOT_CONTEXT,
342            )
343        })?;
344    log::debug!(
345        "runmat-runtime: render_figure_snapshot.ok handle={} bytes={}",
346        handle.as_u32(),
347        bytes.len()
348    );
349    Ok(bytes)
350}
351
352#[cfg(feature = "plot-core")]
353pub async fn render_figure_snapshot_with_camera_state(
354    handle: FigureHandle,
355    width: u32,
356    height: u32,
357    camera_state: super::web::PlotSurfaceCameraState,
358    textmark: Option<String>,
359) -> BuiltinResult<Vec<u8>> {
360    const SNAPSHOT_CONTEXT: &str = "renderFigureImage";
361    use runmat_plot::export::image::{ImageExportSettings, ImageExporter};
362    log::debug!(
363        "runmat-runtime: render_figure_snapshot_with_camera_state.start handle={} width={} height={} axes={}",
364        handle.as_u32(),
365        width,
366        height,
367        camera_state.axes.len()
368    );
369    let figure = clone_figure(handle).ok_or_else(|| {
370        map_control_flow_with_builtin(
371            engine_error(format!("figure handle {} does not exist", handle.as_u32())),
372            SNAPSHOT_CONTEXT,
373        )
374    })?;
375
376    let axes_cameras: Vec<runmat_plot::core::Camera> = camera_state
377        .axes
378        .iter()
379        .map(surface_plot_camera_to_core_camera)
380        .collect();
381
382    let mut settings = ImageExportSettings::default();
383    if width > 0 {
384        settings.width = width;
385    }
386    if height > 0 {
387        settings.height = height;
388    }
389    let mut exporter = ImageExporter::with_settings(settings)
390        .await
391        .map_err(|err| {
392            map_control_flow_with_builtin(
393                engine_error_with_source(
394                    "Plot export initialization failed.",
395                    PlottingBackendError::ImageExportInit(err),
396                ),
397                SNAPSHOT_CONTEXT,
398            )
399        })?;
400    exporter.set_theme_config(super::web::current_plot_theme_config());
401    exporter.set_textmark(textmark.as_deref());
402
403    let mut figure = figure;
404    let bytes = if axes_cameras.is_empty() {
405        exporter.render_png_bytes(&mut figure).await
406    } else {
407        exporter
408            .render_png_bytes_with_axes_cameras(&mut figure, &axes_cameras)
409            .await
410    }
411    .map_err(|err| {
412        log::warn!(
413            "runmat-runtime: render_figure_snapshot_with_camera_state.failed handle={} axes={} error={}",
414            handle.as_u32(),
415            axes_cameras.len(),
416            err
417        );
418        map_control_flow_with_builtin(
419            engine_error_with_source(
420                format!("Plot export failed: {err}"),
421                PlottingBackendError::ImageExport(err),
422            ),
423            SNAPSHOT_CONTEXT,
424        )
425    })?;
426    log::debug!(
427        "runmat-runtime: render_figure_snapshot_with_camera_state.ok handle={} bytes={} axes={}",
428        handle.as_u32(),
429        bytes.len(),
430        axes_cameras.len()
431    );
432    Ok(bytes)
433}
434
435#[cfg(feature = "plot-core")]
436pub async fn render_geometry_scene_snapshot(
437    handle: u32,
438    width: u32,
439    height: u32,
440    view: Option<String>,
441) -> BuiltinResult<Vec<u8>> {
442    const SNAPSHOT_CONTEXT: &str = "renderGeometrySceneImage";
443    use runmat_plot::export::image::{ImageExportSettings, ImageExporter};
444
445    log::debug!(
446        "runmat-runtime: render_geometry_scene_snapshot.start handle={} width={} height={} view={}",
447        handle,
448        width,
449        height,
450        view.as_deref().unwrap_or("")
451    );
452    let scene = clone_geometry_scene(handle).ok_or_else(|| {
453        map_control_flow_with_builtin(
454            engine_error(format!("geometry scene handle {handle} does not exist")),
455            SNAPSHOT_CONTEXT,
456        )
457    })?;
458
459    let mut settings = ImageExportSettings::default();
460    if width > 0 {
461        settings.width = width;
462    }
463    if height > 0 {
464        settings.height = height;
465    }
466    let camera = geometry_scene_snapshot_camera(
467        scene.bounds,
468        settings.width,
469        settings.height,
470        parse_camera_view_preset(view.as_deref()),
471    );
472    let mut exporter = ImageExporter::with_settings(settings)
473        .await
474        .map_err(|err| {
475            map_control_flow_with_builtin(
476                engine_error_with_source(
477                    "Geometry export initialization failed.",
478                    PlottingBackendError::ImageExportInit(err),
479                ),
480                SNAPSHOT_CONTEXT,
481            )
482        })?;
483    exporter.set_theme_config(super::web::current_plot_theme_config());
484
485    let bytes = exporter
486        .render_geometry_scene_png_bytes_with_camera(&scene, &camera)
487        .await
488        .map_err(|err| {
489            log::warn!(
490                "runmat-runtime: render_geometry_scene_snapshot.failed handle={} error={}",
491                handle,
492                err
493            );
494            map_control_flow_with_builtin(
495                engine_error_with_source(
496                    format!("Geometry export failed: {err}"),
497                    PlottingBackendError::ImageExport(err),
498                ),
499                SNAPSHOT_CONTEXT,
500            )
501        })?;
502    log::debug!(
503        "runmat-runtime: render_geometry_scene_snapshot.ok handle={} bytes={}",
504        handle,
505        bytes.len()
506    );
507    Ok(bytes)
508}
509
510#[cfg(feature = "plot-core")]
511fn parse_camera_view_preset(value: Option<&str>) -> runmat_plot::core::CameraViewPreset {
512    use runmat_plot::core::CameraViewPreset;
513
514    let Some(value) = value else {
515        return CameraViewPreset::Perspective;
516    };
517    match value.trim().to_ascii_lowercase().as_str() {
518        "perspective" | "iso" | "isometric" => CameraViewPreset::Perspective,
519        "top" | "xy" => CameraViewPreset::Top,
520        "bottom" => CameraViewPreset::Bottom,
521        "front" | "xz" => CameraViewPreset::Front,
522        "back" => CameraViewPreset::Back,
523        "left" | "yz" => CameraViewPreset::Left,
524        "right" => CameraViewPreset::Right,
525        _ => CameraViewPreset::Perspective,
526    }
527}
528
529#[cfg(feature = "plot-core")]
530fn geometry_scene_snapshot_camera(
531    bounds: runmat_plot::core::BoundingBox,
532    width: u32,
533    height: u32,
534    preset: runmat_plot::core::CameraViewPreset,
535) -> runmat_plot::core::Camera {
536    use glam::Vec3;
537    use runmat_plot::core::{BoundingBox, Camera, CameraViewPreset};
538
539    let mut camera = Camera::new();
540    camera.update_aspect_ratio(width.max(1) as f32 / height.max(1) as f32);
541    let bounds = if bounds.min.is_finite()
542        && bounds.max.is_finite()
543        && (bounds.max - bounds.min).length() > 1.0e-6
544    {
545        bounds
546    } else {
547        BoundingBox {
548            min: Vec3::splat(-1.0),
549            max: Vec3::splat(1.0),
550        }
551    };
552    let (direction, up) = match preset {
553        CameraViewPreset::Perspective => (Vec3::new(1.0, -1.0, 1.0).normalize(), Vec3::Z),
554        CameraViewPreset::Top => (Vec3::Z, Vec3::Y),
555        CameraViewPreset::Bottom => (-Vec3::Z, Vec3::Y),
556        CameraViewPreset::Front => (-Vec3::Y, Vec3::Z),
557        CameraViewPreset::Back => (Vec3::Y, Vec3::Z),
558        CameraViewPreset::Left => (-Vec3::X, Vec3::Z),
559        CameraViewPreset::Right => (Vec3::X, Vec3::Z),
560    };
561    let center = (bounds.min + bounds.max) * 0.5;
562    camera.up = up;
563    camera.target = center;
564    camera.position = center + direction;
565    camera.fit_bounds(bounds.min, bounds.max);
566    camera
567}
568
569#[cfg(feature = "plot-core")]
570fn surface_plot_camera_to_core_camera(
571    state: &super::web::PlotCameraState,
572) -> runmat_plot::core::Camera {
573    let mut camera = runmat_plot::core::Camera::new();
574    camera.position = glam::Vec3::new(state.position[0], state.position[1], state.position[2]);
575    camera.target = glam::Vec3::new(state.target[0], state.target[1], state.target[2]);
576    camera.up = glam::Vec3::new(state.up[0], state.up[1], state.up[2]);
577    camera.zoom = state.zoom;
578    camera.aspect_ratio = state.aspect_ratio.max(0.000_1);
579    camera.projection = match state.projection {
580        super::web::PlotCameraProjection::Perspective { fov, near, far } => {
581            runmat_plot::core::camera::ProjectionType::Perspective {
582                fov,
583                near: near.max(1.0e-6),
584                far: far.max(near + 1.0e-6),
585            }
586        }
587        super::web::PlotCameraProjection::Orthographic {
588            left,
589            right,
590            bottom,
591            top,
592            near,
593            far,
594        } => runmat_plot::core::camera::ProjectionType::Orthographic {
595            left,
596            right,
597            bottom,
598            top,
599            near,
600            far,
601        },
602    };
603    camera.mark_dirty();
604    camera
605}
606
607#[cfg(feature = "gui")]
608pub(crate) mod native {
609    use super::super::state::{install_figure_observer, FigureEventKind, FigureEventView};
610    use super::*;
611    use once_cell::sync::OnceCell;
612    use runmat_plot::plots::Figure;
613    use std::sync::Arc;
614    use std::sync::RwLock;
615
616    static FIGURE_EVENT_BRIDGE: OnceCell<()> = OnceCell::new();
617    static PLOTTING_MODE_OVERRIDE: OnceCell<RwLock<Option<PlottingMode>>> = OnceCell::new();
618
619    #[derive(Debug, Clone, Copy)]
620    enum PlottingMode {
621        Auto,
622        Interactive,
623        Static,
624    }
625
626    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
627    pub enum RuntimePlottingMode {
628        Auto,
629        Interactive,
630        Static,
631    }
632
633    pub fn set_runtime_plotting_mode(mode: RuntimePlottingMode) {
634        let lock = PLOTTING_MODE_OVERRIDE.get_or_init(|| RwLock::new(None));
635        let mut guard = lock.write().expect("plotting mode lock poisoned");
636        *guard = Some(match mode {
637            RuntimePlottingMode::Auto => PlottingMode::Auto,
638            RuntimePlottingMode::Interactive => PlottingMode::Interactive,
639            RuntimePlottingMode::Static => PlottingMode::Static,
640        });
641    }
642
643    pub fn render(handle: FigureHandle, mut figure: Figure) -> BuiltinResult<String> {
644        ensure_figure_event_bridge();
645        match detect_mode() {
646            PlottingMode::Interactive => interactive_export(handle, &mut figure),
647            PlottingMode::Static => static_export(&mut figure, "plot.png"),
648            PlottingMode::Auto => interactive_export(handle, &mut figure),
649        }
650    }
651
652    fn detect_mode() -> PlottingMode {
653        if let Some(lock) = PLOTTING_MODE_OVERRIDE.get() {
654            let guard = lock.read().expect("plotting mode lock poisoned");
655            if let Some(mode) = *guard {
656                return mode;
657            }
658        }
659        PlottingMode::Auto
660    }
661
662    fn interactive_export(handle: FigureHandle, figure: &mut Figure) -> BuiltinResult<String> {
663        let figure_clone = figure.clone();
664        runmat_plot::render_interactive_with_handle(handle.as_u32(), figure_clone).map_err(|err| {
665            engine_error_with_source(
666                "Interactive plotting failed. Please check GPU/GUI system setup.",
667                PlottingBackendError::Interactive(err),
668            )
669        })
670    }
671
672    fn static_export(figure: &mut Figure, filename: &str) -> BuiltinResult<String> {
673        if figure.is_empty() {
674            return Err(engine_error("No plots found in figure to export"));
675        }
676        runmat_plot::show_plot_unified(figure.clone(), Some(filename))
677            .map(|_| format!("Plot saved to {filename}"))
678            .map_err(|err| {
679                engine_error_with_source("Plot export failed.", PlottingBackendError::Static(err))
680            })
681    }
682
683    fn ensure_figure_event_bridge() {
684        FIGURE_EVENT_BRIDGE.get_or_init(|| {
685            let observer: Arc<dyn for<'a> Fn(FigureEventView<'a>) + Send + Sync> =
686                Arc::new(|event: FigureEventView<'_>| match event.kind {
687                    FigureEventKind::Closed => {
688                        runmat_plot::gui::lifecycle::request_close(event.handle.as_u32());
689                    }
690                    FigureEventKind::Updated
691                        if event.figure.is_some_and(|figure| !figure.visible) =>
692                    {
693                        runmat_plot::gui::lifecycle::request_close(event.handle.as_u32());
694                    }
695                    FigureEventKind::Created
696                    | FigureEventKind::Updated
697                    | FigureEventKind::Cleared => {}
698                });
699            let _ = install_figure_observer(observer);
700        });
701    }
702}
703
704#[cfg(all(test, feature = "plot-core"))]
705mod tests {
706    use super::render_figure_snapshot;
707    use crate::builtins::plotting::plot::plot_builtin;
708    use crate::builtins::plotting::state::{
709        clear_figure, current_figure_handle, reset_hold_state_for_run, PlotTestLockGuard,
710    };
711    use crate::builtins::plotting::subplot::subplot_builtin;
712    use crate::builtins::plotting::tests::{ensure_plot_test_env, lock_plot_registry};
713    use crate::builtins::plotting::title::title_builtin;
714    use crate::builtins::plotting::xlabel::xlabel_builtin;
715    use crate::builtins::plotting::ylabel::ylabel_builtin;
716    use futures::executor::block_on;
717    use runmat_builtins::{Tensor, Value};
718
719    fn setup_plot_tests() -> PlotTestLockGuard {
720        let guard = lock_plot_registry();
721        ensure_plot_test_env();
722        reset_hold_state_for_run();
723        let _ = clear_figure(None);
724        guard
725    }
726
727    fn tensor_from(data: &[f64]) -> Tensor {
728        Tensor {
729            data: data.to_vec(),
730            shape: vec![data.len()],
731            rows: data.len(),
732            cols: 1,
733            dtype: runmat_builtins::NumericDType::F64,
734        }
735    }
736
737    #[test]
738    fn render_figure_snapshot_supports_margin_style_two_axes_lines() {
739        let _guard = setup_plot_tests();
740        let x_mm: Vec<f64> = (-30..=30).map(|i| i as f64).collect();
741        let y_mm: Vec<f64> = (-25..=25).map(|i| i as f64).collect();
742        let centerline: Vec<f64> = x_mm
743            .iter()
744            .map(|x| 25.0 + 18.0 * (-(x / 11.0).powi(2)).exp())
745            .collect();
746        let vertical: Vec<f64> = y_mm
747            .iter()
748            .map(|y| 25.0 + 20.0 * (-(y / 9.0).powi(2)).exp())
749            .collect();
750
751        subplot_builtin(Value::Num(1.0), Value::Num(2.0), Value::Num(1.0)).expect("subplot 1");
752        block_on(plot_builtin(vec![
753            Value::Tensor(tensor_from(&x_mm)),
754            Value::Tensor(tensor_from(&centerline)),
755        ]))
756        .expect("left plot");
757        title_builtin(vec![Value::String("Centerline slice".into())]).expect("left title");
758        xlabel_builtin(vec![Value::String("x (mm)".into())]).expect("left xlabel");
759        ylabel_builtin(vec![Value::String("temperature (C)".into())]).expect("left ylabel");
760
761        subplot_builtin(Value::Num(1.0), Value::Num(2.0), Value::Num(2.0)).expect("subplot 2");
762        block_on(plot_builtin(vec![
763            Value::Tensor(tensor_from(&y_mm)),
764            Value::Tensor(tensor_from(&vertical)),
765        ]))
766        .expect("right plot");
767        title_builtin(vec![Value::String("Vertical slice through source".into())])
768            .expect("right title");
769        xlabel_builtin(vec![Value::String("y (mm)".into())]).expect("right xlabel");
770        ylabel_builtin(vec![Value::String("temperature (C)".into())]).expect("right ylabel");
771
772        let handle = current_figure_handle();
773        let bytes =
774            block_on(render_figure_snapshot(handle, 1280, 720, None)).expect("snapshot render");
775        assert!(bytes.len() > 1000, "expected nontrivial PNG payload");
776    }
777}