Skip to main content

runmat_runtime/builtins/plotting/core/
web.rs

1#[cfg(not(all(target_arch = "wasm32", feature = "plot-web")))]
2use super::common::ERR_PLOTTING_UNAVAILABLE;
3
4use crate::{build_runtime_error, BuiltinResult, RuntimeError};
5use serde::{Deserialize, Serialize};
6
7#[derive(Clone, Debug, Serialize, Deserialize)]
8#[serde(rename_all = "camelCase")]
9pub struct PlotSurfaceCameraState {
10    pub active_axes: usize,
11    pub axes: Vec<PlotCameraState>,
12}
13
14#[derive(Clone, Debug, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct PlotCameraState {
17    pub position: [f32; 3],
18    pub target: [f32; 3],
19    pub up: [f32; 3],
20    pub zoom: f32,
21    pub aspect_ratio: f32,
22    pub projection: PlotCameraProjection,
23}
24
25#[derive(Clone, Debug, Serialize, Deserialize)]
26#[serde(tag = "kind", rename_all = "camelCase")]
27pub enum PlotCameraProjection {
28    Perspective {
29        fov: f32,
30        near: f32,
31        far: f32,
32    },
33    Orthographic {
34        left: f32,
35        right: f32,
36        bottom: f32,
37        top: f32,
38        near: f32,
39        far: f32,
40    },
41}
42
43fn web_error(message: impl Into<String>) -> RuntimeError {
44    build_runtime_error(message)
45        .with_identifier("RunMat:plot:WebError")
46        .build()
47}
48
49#[allow(dead_code)]
50fn web_error_with_source(
51    message: impl Into<String>,
52    source: impl std::error::Error + Send + Sync + 'static,
53) -> RuntimeError {
54    build_runtime_error(message)
55        .with_identifier("RunMat:plot:WebError")
56        .with_source(source)
57        .build()
58}
59
60#[cfg(all(target_arch = "wasm32", feature = "plot-web"))]
61pub(crate) mod wasm {
62    use super::*;
63    use crate::builtins::plotting::state::{clone_figure, current_figure_revision, FigureHandle};
64    use log::debug;
65    use runmat_plot::core::PlotEvent;
66    use runmat_plot::styling::PlotThemeConfig;
67    use runmat_plot::web::WebRenderer;
68    use runmat_thread_local::runmat_thread_local;
69    use std::cell::RefCell;
70    use std::collections::HashMap;
71
72    runmat_thread_local! {
73        static SURFACES: RefCell<HashMap<u32, SurfaceEntry>> = RefCell::new(HashMap::new());
74        static ACTIVE_THEME: RefCell<PlotThemeConfig> = RefCell::new(PlotThemeConfig::default());
75    }
76
77    struct SurfaceEntry {
78        renderer: WebRenderer,
79        bound_handle: Option<u32>,
80        last_revision: Option<u64>,
81    }
82
83    pub(super) fn install_surface_impl(
84        surface_id: u32,
85        mut renderer: WebRenderer,
86    ) -> BuiltinResult<()> {
87        ACTIVE_THEME.with(|theme| {
88            renderer.set_theme_config(theme.borrow().clone());
89        });
90        SURFACES.with(|slot| {
91            slot.borrow_mut().insert(
92                surface_id,
93                SurfaceEntry {
94                    renderer,
95                    bound_handle: None,
96                    last_revision: None,
97                },
98            );
99        });
100        SURFACES.with(|slot| {
101            let keys: Vec<u32> = slot.borrow().keys().copied().collect();
102            debug!(
103                "plot-web: installed surface surface_id={surface_id} (active_surfaces={keys:?})"
104            );
105        });
106        Ok(())
107    }
108
109    pub(super) fn detach_surface_impl(surface_id: u32) {
110        SURFACES.with(|slot| {
111            slot.borrow_mut().remove(&surface_id);
112        });
113        SURFACES.with(|slot| {
114            let keys: Vec<u32> = slot.borrow().keys().copied().collect();
115            debug!("plot-web: detached surface surface_id={surface_id} (active_surfaces={keys:?})");
116        });
117    }
118
119    pub(super) fn clear_closed_figure_surfaces_impl(handle: u32) -> BuiltinResult<()> {
120        SURFACES.with(|slot| {
121            let mut map = slot.borrow_mut();
122            for entry in map.values_mut() {
123                if entry.bound_handle == Some(handle) {
124                    entry.bound_handle = None;
125                    entry.last_revision = None;
126                    entry
127                        .renderer
128                        .clear_surface()
129                        .map_err(|err| web_error(format!("Plotting failed: {err}")))?;
130                }
131            }
132            Ok(())
133        })
134    }
135
136    pub fn web_renderer_ready() -> bool {
137        SURFACES.with(|slot| !slot.borrow().is_empty())
138    }
139
140    pub(super) fn resize_surface_impl(
141        surface_id: u32,
142        width: u32,
143        height: u32,
144        pixels_per_point: f32,
145    ) -> BuiltinResult<()> {
146        SURFACES.with(|slot| {
147            let mut map = slot.borrow_mut();
148            let entry = map.get_mut(&surface_id).ok_or_else(|| {
149                web_error(format!(
150                    "Plotting surface {surface_id} not registered. Call createPlotSurface() first."
151                ))
152            })?;
153            entry.renderer.set_pixels_per_point(pixels_per_point);
154            entry
155                .renderer
156                .resize_surface(width, height)
157                .map_err(|err| web_error(format!("Plotting failed: {err}")))?;
158            Ok(())
159        })
160    }
161
162    pub(super) fn bind_surface_to_figure_impl(surface_id: u32, handle: u32) -> BuiltinResult<()> {
163        SURFACES.with(|slot| {
164            let mut map = slot.borrow_mut();
165            let entry = map.get_mut(&surface_id).ok_or_else(|| {
166                web_error(format!(
167                    "Plotting surface {surface_id} not registered. Call createPlotSurface() first."
168                ))
169            })?;
170            entry.bound_handle = Some(handle);
171            // Force a re-prime on next present.
172            entry.last_revision = None;
173            Ok(())
174        })
175    }
176
177    pub(super) fn set_theme_config_impl(theme: PlotThemeConfig) -> BuiltinResult<()> {
178        debug!(
179            "plot-web: runtime set_theme_config_impl variant={:?} custom_colors={}",
180            theme.variant,
181            theme.custom_colors.is_some()
182        );
183        ACTIVE_THEME.with(|slot| {
184            *slot.borrow_mut() = theme.clone();
185        });
186        SURFACES.with(|slot| {
187            let mut map = slot.borrow_mut();
188            debug!("plot-web: applying theme to {} surfaces", map.len());
189            for entry in map.values_mut() {
190                entry.renderer.set_theme_config(theme.clone());
191                if let Some(handle) = entry.bound_handle {
192                    if let Some(figure) = clone_figure(FigureHandle::from(handle)) {
193                        entry
194                            .renderer
195                            .render_figure(figure)
196                            .map_err(|err| web_error(format!("Plotting failed: {err}")))?;
197                    }
198                }
199            }
200            Ok(())
201        })
202    }
203
204    pub(super) fn current_theme_config_impl() -> PlotThemeConfig {
205        ACTIVE_THEME.with(|slot| slot.borrow().clone())
206    }
207
208    pub(super) fn present_figure_on_surface_impl(
209        surface_id: u32,
210        handle: u32,
211    ) -> BuiltinResult<()> {
212        // "Better" path: only invalidate cached render data if the handle actually changes.
213        SURFACES.with(|slot| {
214            let mut map = slot.borrow_mut();
215            let entry = map.get_mut(&surface_id).ok_or_else(|| {
216                web_error(format!(
217                    "Plotting surface {surface_id} not registered. Call createPlotSurface() first."
218                ))
219            })?;
220            if entry.bound_handle != Some(handle) {
221                entry.bound_handle = Some(handle);
222                entry.last_revision = None;
223            }
224            Ok::<(), RuntimeError>(())
225        })?;
226        present_surface_impl(surface_id)
227    }
228
229    pub(super) fn present_surface_impl(surface_id: u32) -> BuiltinResult<()> {
230        SURFACES.with(|slot| {
231            let mut map = slot.borrow_mut();
232            let entry = map.get_mut(&surface_id).ok_or_else(|| {
233                web_error(format!(
234                    "Plotting surface {surface_id} not registered. Call createPlotSurface() first."
235                ))
236            })?;
237            let handle = entry.bound_handle.ok_or_else(|| {
238                web_error(
239                    "Plotting surface is not bound to a figure handle. Call bindSurfaceToFigure().",
240                )
241            })?;
242            // "Better" path: only re-prime render data when the figure revision changed.
243            let current_rev = current_figure_revision(FigureHandle::from(handle));
244            if entry.last_revision != current_rev {
245                let figure = clone_figure(FigureHandle::from(handle))
246                    .ok_or_else(|| web_error(format!("figure handle {handle} does not exist")))?;
247                entry
248                    .renderer
249                    .render_figure(figure)
250                    .map_err(|err| web_error(format!("Plotting failed: {err}")))?;
251                entry.last_revision = current_rev;
252            }
253            entry
254                .renderer
255                .render_current_scene()
256                .map_err(|err| web_error(format!("Plotting failed: {err}")))?;
257            Ok(())
258        })
259    }
260
261    pub(super) fn handle_surface_event_impl(
262        surface_id: u32,
263        event: PlotEvent,
264    ) -> BuiltinResult<()> {
265        SURFACES.with(|slot| {
266            let mut map = slot.borrow_mut();
267            let entry = map.get_mut(&surface_id).ok_or_else(|| {
268                web_error(format!(
269                    "Plotting surface {surface_id} not registered. Call createPlotSurface() first."
270                ))
271            })?;
272            match &event {
273                PlotEvent::MousePress { .. }
274                | PlotEvent::MouseRelease { .. }
275                | PlotEvent::MouseWheel { .. } => {
276                    debug!("plot-web: surface_event(surface_id={surface_id}, event={event:?})");
277                }
278                PlotEvent::MouseMove { .. } | PlotEvent::Resize { .. } => {}
279                PlotEvent::KeyPress { .. } | PlotEvent::KeyRelease { .. } => {}
280            }
281            // If no figure was ever rendered, there's nothing to manipulate.
282            // Still accept the event (no-op) so the host doesn't have to special-case.
283            let _ = entry.renderer.handle_event(event);
284            // Camera interactions should re-render immediately, without requiring a figure revision bump.
285            entry
286                .renderer
287                .render_current_scene()
288                .map_err(|err| web_error(format!("Plotting failed: {err}")))?;
289            Ok(())
290        })
291    }
292
293    pub(super) fn fit_surface_extents_impl(surface_id: u32) -> BuiltinResult<()> {
294        SURFACES.with(|slot| {
295            let mut map = slot.borrow_mut();
296            let entry = map.get_mut(&surface_id).ok_or_else(|| {
297                web_error(format!(
298                    "Plotting surface {surface_id} not registered. Call createPlotSurface() first."
299                ))
300            })?;
301            entry.renderer.fit_extents();
302            entry
303                .renderer
304                .render_current_scene()
305                .map_err(|err| web_error(format!("Plotting failed: {err}")))?;
306            Ok(())
307        })
308    }
309
310    pub(super) fn reset_surface_camera_impl(surface_id: u32) -> BuiltinResult<()> {
311        SURFACES.with(|slot| {
312            let mut map = slot.borrow_mut();
313            let entry = map.get_mut(&surface_id).ok_or_else(|| {
314                web_error(format!(
315                    "Plotting surface {surface_id} not registered. Call createPlotSurface() first."
316                ))
317            })?;
318            entry.renderer.reset_camera_position();
319            entry
320                .renderer
321                .render_current_scene()
322                .map_err(|err| web_error(format!("Plotting failed: {err}")))?;
323            Ok(())
324        })
325    }
326
327    pub(super) fn get_surface_camera_state_impl(
328        surface_id: u32,
329    ) -> BuiltinResult<PlotSurfaceCameraState> {
330        SURFACES.with(|slot| {
331            let map = slot.borrow();
332            let entry = map.get(&surface_id).ok_or_else(|| {
333                web_error(format!(
334                    "Plotting surface {surface_id} not registered. Call createPlotSurface() first."
335                ))
336            })?;
337            Ok(convert_camera_state(entry.renderer.camera_state()))
338        })
339    }
340
341    pub(super) fn set_surface_camera_state_impl(
342        surface_id: u32,
343        state: PlotSurfaceCameraState,
344    ) -> BuiltinResult<()> {
345        SURFACES.with(|slot| {
346            let mut map = slot.borrow_mut();
347            let entry = map.get_mut(&surface_id).ok_or_else(|| {
348                web_error(format!(
349                    "Plotting surface {surface_id} not registered. Call createPlotSurface() first."
350                ))
351            })?;
352            entry
353                .renderer
354                .set_camera_state(&convert_camera_state_back(state));
355            entry
356                .renderer
357                .render_current_scene()
358                .map_err(|err| web_error(format!("Plotting failed: {err}")))?;
359            Ok(())
360        })
361    }
362
363    pub fn render_current_scene(handle: u32) -> BuiltinResult<()> {
364        debug!("plot-web: render_current_scene(handle={handle})");
365        // If nothing is currently bound to this handle, try to claim the lowest-id unbound
366        // surface. This ensures `drawnow()` / `pause()` can present even if the host hasn't
367        // explicitly bound a surface yet.
368        let needs_autobind = SURFACES.with(|slot| {
369            let map = slot.borrow();
370            !map.values().any(|entry| entry.bound_handle == Some(handle))
371        });
372        if needs_autobind {
373            let maybe_unbound_surface = SURFACES.with(|slot| {
374                let map = slot.borrow();
375                map.iter()
376                    .filter_map(|(surface_id, entry)| {
377                        if entry.bound_handle.is_none() {
378                            Some(*surface_id)
379                        } else {
380                            None
381                        }
382                    })
383                    .min()
384            });
385            if let Some(surface_id) = maybe_unbound_surface {
386                // Bind without forcing a full re-prime here; present_surface will set last_revision.
387                let _ = bind_surface_to_figure_impl(surface_id, handle);
388            }
389        }
390
391        // Render any surfaces that are currently bound to this handle.
392        let surface_ids: Vec<u32> = SURFACES.with(|slot| {
393            slot.borrow()
394                .iter()
395                .filter_map(|(surface_id, entry)| {
396                    if entry.bound_handle == Some(handle) {
397                        Some(*surface_id)
398                    } else {
399                        None
400                    }
401                })
402                .collect()
403        });
404        if surface_ids.is_empty() {
405            // No bound surfaces; nothing to do.
406            return Ok(());
407        }
408        for surface_id in surface_ids {
409            // Use caching logic in present_surface so we avoid re-priming unless revision changed.
410            present_surface_impl(surface_id)?;
411        }
412        Ok(())
413    }
414
415    pub fn invalidate_surface_revisions() {
416        SURFACES.with(|slot| {
417            let mut map = slot.borrow_mut();
418            for entry in map.values_mut() {
419                entry.last_revision = None;
420            }
421        });
422    }
423
424    // expose type to outer module
425    pub(super) use runmat_plot::web::WebRenderer as RendererType;
426}
427
428#[cfg(not(all(target_arch = "wasm32", feature = "plot-web")))]
429pub(crate) mod wasm {
430    use super::*;
431
432    pub struct RendererPlaceholder;
433
434    pub(super) fn install_surface_impl(
435        _surface_id: u32,
436        _renderer: RendererPlaceholder,
437    ) -> BuiltinResult<()> {
438        Err(web_error(ERR_PLOTTING_UNAVAILABLE))
439    }
440
441    pub(super) fn detach_surface_impl(_surface_id: u32) {}
442
443    pub(super) fn clear_closed_figure_surfaces_impl(_handle: u32) -> BuiltinResult<()> {
444        Err(web_error(ERR_PLOTTING_UNAVAILABLE))
445    }
446
447    pub fn web_renderer_ready() -> bool {
448        false
449    }
450
451    pub(super) use RendererPlaceholder as RendererType;
452
453    pub(super) fn resize_surface_impl(
454        _surface_id: u32,
455        _width: u32,
456        _height: u32,
457        _pixels_per_point: f32,
458    ) -> BuiltinResult<()> {
459        Err(web_error(ERR_PLOTTING_UNAVAILABLE))
460    }
461
462    pub fn render_current_scene(_handle: u32) -> BuiltinResult<()> {
463        Err(web_error(ERR_PLOTTING_UNAVAILABLE))
464    }
465
466    pub fn invalidate_surface_revisions() {}
467
468    pub(super) fn bind_surface_to_figure_impl(_surface_id: u32, _handle: u32) -> BuiltinResult<()> {
469        Err(web_error(ERR_PLOTTING_UNAVAILABLE))
470    }
471
472    pub(super) fn present_surface_impl(_surface_id: u32) -> BuiltinResult<()> {
473        Err(web_error(ERR_PLOTTING_UNAVAILABLE))
474    }
475
476    pub(super) fn present_figure_on_surface_impl(
477        _surface_id: u32,
478        _handle: u32,
479    ) -> BuiltinResult<()> {
480        Err(web_error(ERR_PLOTTING_UNAVAILABLE))
481    }
482
483    pub(super) fn fit_surface_extents_impl(_surface_id: u32) -> BuiltinResult<()> {
484        Err(web_error(ERR_PLOTTING_UNAVAILABLE))
485    }
486
487    pub(super) fn reset_surface_camera_impl(_surface_id: u32) -> BuiltinResult<()> {
488        Err(web_error(ERR_PLOTTING_UNAVAILABLE))
489    }
490
491    pub(super) fn get_surface_camera_state_impl(
492        _surface_id: u32,
493    ) -> BuiltinResult<PlotSurfaceCameraState> {
494        Err(web_error(ERR_PLOTTING_UNAVAILABLE))
495    }
496
497    pub(super) fn set_surface_camera_state_impl(
498        _surface_id: u32,
499        _state: PlotSurfaceCameraState,
500    ) -> BuiltinResult<()> {
501        Err(web_error(ERR_PLOTTING_UNAVAILABLE))
502    }
503
504    pub(super) fn set_theme_config_impl(
505        _theme: runmat_plot::styling::PlotThemeConfig,
506    ) -> BuiltinResult<()> {
507        Err(web_error(ERR_PLOTTING_UNAVAILABLE))
508    }
509
510    pub(super) fn current_theme_config_impl() -> runmat_plot::styling::PlotThemeConfig {
511        runmat_plot::styling::PlotThemeConfig::default()
512    }
513}
514
515pub use wasm::invalidate_surface_revisions;
516pub use wasm::render_current_scene;
517pub use wasm::web_renderer_ready;
518
519pub fn install_surface(surface_id: u32, renderer: wasm::RendererType) -> BuiltinResult<()> {
520    wasm::install_surface_impl(surface_id, renderer)
521}
522
523pub fn detach_surface(surface_id: u32) {
524    wasm::detach_surface_impl(surface_id)
525}
526
527pub fn clear_closed_figure_surfaces(handle: u32) -> BuiltinResult<()> {
528    wasm::clear_closed_figure_surfaces_impl(handle)
529}
530
531pub fn resize_surface(
532    surface_id: u32,
533    width: u32,
534    height: u32,
535    pixels_per_point: f32,
536) -> BuiltinResult<()> {
537    wasm::resize_surface_impl(surface_id, width, height, pixels_per_point)
538}
539
540pub fn bind_surface_to_figure(surface_id: u32, handle: u32) -> BuiltinResult<()> {
541    wasm::bind_surface_to_figure_impl(surface_id, handle)
542}
543
544pub fn present_surface(surface_id: u32) -> BuiltinResult<()> {
545    wasm::present_surface_impl(surface_id)
546}
547
548#[cfg(all(target_arch = "wasm32", feature = "plot-web"))]
549pub fn handle_plot_surface_event(
550    surface_id: u32,
551    event: runmat_plot::core::PlotEvent,
552) -> BuiltinResult<()> {
553    wasm::handle_surface_event_impl(surface_id, event)
554}
555
556pub fn present_figure_on_surface(surface_id: u32, handle: u32) -> BuiltinResult<()> {
557    wasm::present_figure_on_surface_impl(surface_id, handle)
558}
559
560pub fn fit_surface_extents(surface_id: u32) -> BuiltinResult<()> {
561    wasm::fit_surface_extents_impl(surface_id)
562}
563
564pub fn reset_surface_camera(surface_id: u32) -> BuiltinResult<()> {
565    wasm::reset_surface_camera_impl(surface_id)
566}
567
568pub fn get_surface_camera_state(surface_id: u32) -> BuiltinResult<PlotSurfaceCameraState> {
569    wasm::get_surface_camera_state_impl(surface_id)
570}
571
572pub fn set_surface_camera_state(
573    surface_id: u32,
574    state: PlotSurfaceCameraState,
575) -> BuiltinResult<()> {
576    wasm::set_surface_camera_state_impl(surface_id, state)
577}
578
579pub fn set_plot_theme_config(theme: runmat_plot::styling::PlotThemeConfig) -> BuiltinResult<()> {
580    wasm::set_theme_config_impl(theme)
581}
582
583pub fn current_plot_theme_config() -> runmat_plot::styling::PlotThemeConfig {
584    wasm::current_theme_config_impl()
585}
586
587#[cfg(all(target_arch = "wasm32", feature = "plot-web"))]
588fn convert_camera_state(state: runmat_plot::web::PlotSurfaceCameraState) -> PlotSurfaceCameraState {
589    PlotSurfaceCameraState {
590        active_axes: state.active_axes,
591        axes: state
592            .axes
593            .into_iter()
594            .map(|camera| PlotCameraState {
595                position: camera.position,
596                target: camera.target,
597                up: camera.up,
598                zoom: camera.zoom,
599                aspect_ratio: camera.aspect_ratio,
600                projection: match camera.projection {
601                    runmat_plot::web::PlotCameraProjection::Perspective { fov, near, far } => {
602                        PlotCameraProjection::Perspective { fov, near, far }
603                    }
604                    runmat_plot::web::PlotCameraProjection::Orthographic {
605                        left,
606                        right,
607                        bottom,
608                        top,
609                        near,
610                        far,
611                    } => PlotCameraProjection::Orthographic {
612                        left,
613                        right,
614                        bottom,
615                        top,
616                        near,
617                        far,
618                    },
619                },
620            })
621            .collect(),
622    }
623}
624
625#[cfg(all(target_arch = "wasm32", feature = "plot-web"))]
626fn convert_camera_state_back(
627    state: PlotSurfaceCameraState,
628) -> runmat_plot::web::PlotSurfaceCameraState {
629    runmat_plot::web::PlotSurfaceCameraState {
630        active_axes: state.active_axes,
631        axes: state
632            .axes
633            .into_iter()
634            .map(|camera| runmat_plot::web::PlotCameraState {
635                position: camera.position,
636                target: camera.target,
637                up: camera.up,
638                zoom: camera.zoom,
639                aspect_ratio: camera.aspect_ratio,
640                projection: match camera.projection {
641                    PlotCameraProjection::Perspective { fov, near, far } => {
642                        runmat_plot::web::PlotCameraProjection::Perspective { fov, near, far }
643                    }
644                    PlotCameraProjection::Orthographic {
645                        left,
646                        right,
647                        bottom,
648                        top,
649                        near,
650                        far,
651                    } => runmat_plot::web::PlotCameraProjection::Orthographic {
652                        left,
653                        right,
654                        bottom,
655                        top,
656                        near,
657                        far,
658                    },
659                },
660            })
661            .collect(),
662    }
663}
664
665// No render_web_canvas wrapper; web presentation is surface-driven.