Skip to main content

runmat_plot/
event.rs

1use crate::core::{BoundingBox, Vertex};
2use crate::plots::{
3    AreaPlot, AxesMetadata, BarChart, ColorMap, ContourFillPlot, ContourPlot, ErrorBar, Figure,
4    LegendEntry, LegendStyle, Line3Plot, LinePlot, MarkerStyle, PatchEdgeColorMode,
5    PatchFaceColorMode, PatchPlot, PlotElement, PlotType, QuiverPlot, ReferenceLine,
6    ReferenceLineOrientation, Scatter3Plot, ScatterPlot, ShadingMode, StairsPlot, StemPlot,
7    SurfacePlot, TextStyle,
8};
9use glam::{Vec3, Vec4};
10use serde::{Deserialize, Serialize};
11
12/// High-level event emitted whenever a figure changes.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub struct FigureEvent {
16    pub handle: u32,
17    pub kind: FigureEventKind,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub fingerprint: Option<String>,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub figure: Option<FigureSnapshot>,
22}
23
24/// Event kind for figure lifecycle + updates.
25#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "lowercase")]
27pub enum FigureEventKind {
28    Created,
29    Updated,
30    Cleared,
31    Closed,
32}
33
34/// Snapshot of the figure state describing layout + plots.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(rename_all = "camelCase")]
37pub struct FigureSnapshot {
38    pub layout: FigureLayout,
39    pub metadata: FigureMetadata,
40    pub plots: Vec<PlotDescriptor>,
41}
42
43/// Full replay scene payload capable of reconstructing a figure.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct FigureScene {
47    pub schema_version: u32,
48    pub layout: FigureLayout,
49    pub metadata: FigureMetadata,
50    pub plots: Vec<ScenePlot>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(tag = "kind", rename_all = "snake_case")]
55pub enum ScenePlot {
56    Line {
57        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
58        x: Vec<f64>,
59        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
60        y: Vec<f64>,
61        color_rgba: [f32; 4],
62        line_width: f32,
63        line_style: String,
64        axes_index: u32,
65        label: Option<String>,
66        visible: bool,
67    },
68    ReferenceLine {
69        orientation: String,
70        #[serde(deserialize_with = "deserialize_f64_lossy")]
71        value: f64,
72        color_rgba: [f32; 4],
73        line_width: f32,
74        line_style: String,
75        label: Option<String>,
76        display_name: Option<String>,
77        label_orientation: String,
78        axes_index: u32,
79        visible: bool,
80    },
81    Scatter {
82        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
83        x: Vec<f64>,
84        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
85        y: Vec<f64>,
86        color_rgba: [f32; 4],
87        marker_size: f32,
88        marker_style: String,
89        axes_index: u32,
90        label: Option<String>,
91        visible: bool,
92    },
93    Bar {
94        labels: Vec<String>,
95        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
96        values: Vec<f64>,
97        #[serde(default, deserialize_with = "deserialize_option_vec_f64_lossy")]
98        histogram_bin_edges: Option<Vec<f64>>,
99        color_rgba: [f32; 4],
100        #[serde(default)]
101        outline_color_rgba: Option<[f32; 4]>,
102        bar_width: f32,
103        outline_width: f32,
104        orientation: String,
105        group_index: u32,
106        group_count: u32,
107        #[serde(default, deserialize_with = "deserialize_option_vec_f64_lossy")]
108        stack_offsets: Option<Vec<f64>>,
109        axes_index: u32,
110        label: Option<String>,
111        visible: bool,
112    },
113    ErrorBar {
114        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
115        x: Vec<f64>,
116        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
117        y: Vec<f64>,
118        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
119        err_low: Vec<f64>,
120        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
121        err_high: Vec<f64>,
122        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
123        x_err_low: Vec<f64>,
124        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
125        x_err_high: Vec<f64>,
126        orientation: String,
127        color_rgba: [f32; 4],
128        line_width: f32,
129        line_style: String,
130        cap_width: f32,
131        marker_style: Option<String>,
132        marker_size: Option<f32>,
133        marker_face_color: Option<[f32; 4]>,
134        marker_edge_color: Option<[f32; 4]>,
135        marker_filled: Option<bool>,
136        axes_index: u32,
137        label: Option<String>,
138        visible: bool,
139    },
140    Stairs {
141        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
142        x: Vec<f64>,
143        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
144        y: Vec<f64>,
145        color_rgba: [f32; 4],
146        line_width: f32,
147        axes_index: u32,
148        label: Option<String>,
149        visible: bool,
150    },
151    Stem {
152        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
153        x: Vec<f64>,
154        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
155        y: Vec<f64>,
156        #[serde(deserialize_with = "deserialize_f64_lossy")]
157        baseline: f64,
158        color_rgba: [f32; 4],
159        line_width: f32,
160        line_style: String,
161        baseline_color_rgba: [f32; 4],
162        baseline_visible: bool,
163        marker_color_rgba: [f32; 4],
164        marker_size: f32,
165        marker_filled: bool,
166        axes_index: u32,
167        label: Option<String>,
168        visible: bool,
169    },
170    Area {
171        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
172        x: Vec<f64>,
173        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
174        y: Vec<f64>,
175        #[serde(default, deserialize_with = "deserialize_option_vec_f64_lossy")]
176        lower_y: Option<Vec<f64>>,
177        #[serde(deserialize_with = "deserialize_f64_lossy")]
178        baseline: f64,
179        color_rgba: [f32; 4],
180        axes_index: u32,
181        label: Option<String>,
182        visible: bool,
183    },
184    Quiver {
185        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
186        x: Vec<f64>,
187        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
188        y: Vec<f64>,
189        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
190        u: Vec<f64>,
191        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
192        v: Vec<f64>,
193        color_rgba: [f32; 4],
194        line_width: f32,
195        scale: f32,
196        head_size: f32,
197        axes_index: u32,
198        label: Option<String>,
199        visible: bool,
200    },
201    Surface {
202        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
203        x: Vec<f64>,
204        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
205        y: Vec<f64>,
206        #[serde(deserialize_with = "deserialize_matrix_f64_lossy")]
207        z: Vec<Vec<f64>>,
208        colormap: String,
209        shading_mode: String,
210        wireframe: bool,
211        alpha: f32,
212        flatten_z: bool,
213        #[serde(default)]
214        image_mode: bool,
215        #[serde(default)]
216        color_grid_rgba: Option<Vec<Vec<[f32; 4]>>>,
217        #[serde(default, deserialize_with = "deserialize_option_pair_f64_lossy")]
218        color_limits: Option<[f64; 2]>,
219        axes_index: u32,
220        label: Option<String>,
221        visible: bool,
222    },
223    Patch {
224        #[serde(deserialize_with = "deserialize_vec_xyz_f32_lossy")]
225        vertices: Vec<[f32; 3]>,
226        faces: Vec<Vec<u32>>,
227        face_color_rgba: [f32; 4],
228        edge_color_rgba: [f32; 4],
229        face_color_mode: String,
230        edge_color_mode: String,
231        face_alpha: f32,
232        edge_alpha: f32,
233        line_width: f32,
234        axes_index: u32,
235        label: Option<String>,
236        visible: bool,
237        #[serde(default)]
238        force_3d: bool,
239    },
240    Line3 {
241        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
242        x: Vec<f64>,
243        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
244        y: Vec<f64>,
245        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
246        z: Vec<f64>,
247        color_rgba: [f32; 4],
248        line_width: f32,
249        line_style: String,
250        axes_index: u32,
251        label: Option<String>,
252        visible: bool,
253    },
254    Scatter3 {
255        #[serde(deserialize_with = "deserialize_vec_xyz_f32_lossy")]
256        points: Vec<[f32; 3]>,
257        #[serde(default, deserialize_with = "deserialize_vec_rgba_f32_lossy")]
258        colors_rgba: Vec<[f32; 4]>,
259        point_size: f32,
260        #[serde(default, deserialize_with = "deserialize_option_vec_f32_lossy")]
261        point_sizes: Option<Vec<f32>>,
262        axes_index: u32,
263        label: Option<String>,
264        visible: bool,
265    },
266    Contour {
267        vertices: Vec<SerializedVertex>,
268        bounds_min: [f32; 3],
269        bounds_max: [f32; 3],
270        base_z: f32,
271        line_width: f32,
272        axes_index: u32,
273        label: Option<String>,
274        visible: bool,
275        #[serde(default)]
276        force_3d: bool,
277    },
278    ContourFill {
279        vertices: Vec<SerializedVertex>,
280        bounds_min: [f32; 3],
281        bounds_max: [f32; 3],
282        axes_index: u32,
283        label: Option<String>,
284        visible: bool,
285    },
286    Pie {
287        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
288        values: Vec<f64>,
289        colors_rgba: Vec<[f32; 4]>,
290        slice_labels: Vec<String>,
291        label_format: Option<String>,
292        explode: Vec<bool>,
293        axes_index: u32,
294        label: Option<String>,
295        visible: bool,
296    },
297    Unsupported {
298        plot_kind: PlotKind,
299        axes_index: u32,
300        label: Option<String>,
301        visible: bool,
302    },
303}
304
305impl FigureSnapshot {
306    /// Capture a snapshot from a [`Figure`] reference.
307    pub fn capture(figure: &Figure) -> Self {
308        let (rows, cols) = figure.axes_grid();
309        let layout = FigureLayout {
310            axes_rows: rows as u32,
311            axes_cols: cols as u32,
312            axes_indices: figure
313                .plot_axes_indices()
314                .iter()
315                .map(|idx| *idx as u32)
316                .collect(),
317        };
318
319        let metadata = FigureMetadata::from_figure(figure);
320
321        let plots = figure
322            .plots()
323            .enumerate()
324            .map(|(idx, plot)| PlotDescriptor::from_plot(plot, figure_axis_index(figure, idx)))
325            .collect();
326
327        Self {
328            layout,
329            metadata,
330            plots,
331        }
332    }
333
334    pub fn fingerprint(&self) -> String {
335        const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
336        const FNV_PRIME: u64 = 0x100000001b3;
337
338        let bytes = serde_json::to_vec(self).unwrap_or_default();
339        let mut hash = FNV_OFFSET_BASIS;
340        for byte in bytes {
341            hash ^= u64::from(byte);
342            hash = hash.wrapping_mul(FNV_PRIME);
343        }
344        format!("fig:{hash:016x}")
345    }
346}
347
348impl FigureScene {
349    pub const SCHEMA_VERSION: u32 = 2;
350
351    pub fn capture(figure: &Figure) -> Self {
352        let snapshot = FigureSnapshot::capture(figure);
353        let plots = figure
354            .plots()
355            .enumerate()
356            .map(|(idx, plot)| ScenePlot::from_plot(plot, figure_axis_index(figure, idx)))
357            .collect();
358
359        Self {
360            schema_version: Self::SCHEMA_VERSION,
361            layout: snapshot.layout,
362            metadata: snapshot.metadata,
363            plots,
364        }
365    }
366
367    pub fn into_figure(self) -> Result<Figure, String> {
368        self.validate_schema_version()?;
369
370        let mut figure = Figure::new();
371        figure.set_subplot_grid(
372            self.layout.axes_rows as usize,
373            self.layout.axes_cols as usize,
374        );
375        figure.active_axes_index = self.metadata.active_axes_index as usize;
376        if let Some(axes_metadata) = self.metadata.axes_metadata.clone() {
377            figure.axes_metadata = axes_metadata.into_iter().map(AxesMetadata::from).collect();
378            figure.set_active_axes_index(figure.active_axes_index);
379        } else {
380            figure.title = self.metadata.title;
381            figure.x_label = self.metadata.x_label;
382            figure.y_label = self.metadata.y_label;
383            figure.legend_enabled = self.metadata.legend_enabled;
384        }
385        figure.name = self.metadata.name;
386        figure.number_title = self.metadata.number_title;
387        figure.visible = self.metadata.visible;
388        figure.sg_title = self.metadata.sg_title;
389        figure.sg_title_style = self
390            .metadata
391            .sg_title_style
392            .map(TextStyle::from)
393            .unwrap_or_default();
394        figure.grid_enabled = self.metadata.grid_enabled;
395        figure.minor_grid_enabled = self.metadata.minor_grid_enabled;
396        figure.z_limits = self.metadata.z_limits.map(|[lo, hi]| (lo, hi));
397        figure.colorbar_enabled = self.metadata.colorbar_enabled;
398        figure.axis_equal = self.metadata.axis_equal;
399        figure.background_color = rgba_to_vec4(self.metadata.background_rgba);
400
401        for plot in self.plots {
402            plot.apply_to_figure(&mut figure)?;
403        }
404
405        Ok(figure)
406    }
407
408    fn validate_schema_version(&self) -> Result<(), String> {
409        if self.schema_version == 0 || self.schema_version > FigureScene::SCHEMA_VERSION {
410            return Err(format!(
411                "unsupported figure scene schema version {} (supported 1..={})",
412                self.schema_version,
413                FigureScene::SCHEMA_VERSION
414            ));
415        }
416        if self.schema_version < FigureScene::SCHEMA_VERSION
417            && self
418                .plots
419                .iter()
420                .any(|plot| matches!(plot, ScenePlot::Patch { .. }))
421        {
422            return Err(format!(
423                "patch plots require figure scene schema version {}",
424                FigureScene::SCHEMA_VERSION
425            ));
426        }
427        Ok(())
428    }
429}
430
431fn figure_axis_index(figure: &Figure, plot_index: usize) -> u32 {
432    figure
433        .plot_axes_indices()
434        .get(plot_index)
435        .copied()
436        .unwrap_or(0) as u32
437}
438
439/// Layout metadata describing subplot arrangement.
440#[derive(Debug, Clone, Serialize, Deserialize)]
441#[serde(rename_all = "camelCase")]
442pub struct FigureLayout {
443    pub axes_rows: u32,
444    pub axes_cols: u32,
445    pub axes_indices: Vec<u32>,
446}
447
448/// Figure-level metadata (title, labels, theming).
449#[derive(Debug, Clone, Serialize, Deserialize)]
450#[serde(rename_all = "camelCase")]
451pub struct FigureMetadata {
452    #[serde(skip_serializing_if = "Option::is_none")]
453    pub name: Option<String>,
454    #[serde(default = "default_true", skip_serializing_if = "is_true")]
455    pub number_title: bool,
456    #[serde(default = "default_true", skip_serializing_if = "is_true")]
457    pub visible: bool,
458    #[serde(skip_serializing_if = "Option::is_none")]
459    pub title: Option<String>,
460    #[serde(skip_serializing_if = "Option::is_none")]
461    pub sg_title: Option<String>,
462    #[serde(skip_serializing_if = "Option::is_none")]
463    pub sg_title_style: Option<SerializedTextStyle>,
464    #[serde(skip_serializing_if = "Option::is_none")]
465    pub x_label: Option<String>,
466    #[serde(skip_serializing_if = "Option::is_none")]
467    pub y_label: Option<String>,
468    pub grid_enabled: bool,
469    #[serde(default)]
470    pub minor_grid_enabled: bool,
471    pub legend_enabled: bool,
472    pub colorbar_enabled: bool,
473    pub axis_equal: bool,
474    pub background_rgba: [f32; 4],
475    #[serde(skip_serializing_if = "Option::is_none")]
476    pub colormap: Option<String>,
477    #[serde(skip_serializing_if = "Option::is_none")]
478    pub color_limits: Option<[f64; 2]>,
479    #[serde(skip_serializing_if = "Option::is_none")]
480    pub z_limits: Option<[f64; 2]>,
481    pub legend_entries: Vec<FigureLegendEntry>,
482    #[serde(default)]
483    pub active_axes_index: u32,
484    #[serde(skip_serializing_if = "Option::is_none")]
485    pub axes_metadata: Option<Vec<SerializedAxesMetadata>>,
486}
487
488impl FigureMetadata {
489    fn from_figure(figure: &Figure) -> Self {
490        let legend_entries = figure
491            .legend_entries()
492            .into_iter()
493            .map(FigureLegendEntry::from)
494            .collect();
495
496        Self {
497            name: figure.name.clone(),
498            number_title: figure.number_title,
499            visible: figure.visible,
500            title: figure.title.clone(),
501            sg_title: figure.sg_title.clone(),
502            sg_title_style: figure
503                .sg_title
504                .as_ref()
505                .map(|_| figure.sg_title_style.clone().into()),
506            x_label: figure.x_label.clone(),
507            y_label: figure.y_label.clone(),
508            grid_enabled: figure.grid_enabled,
509            minor_grid_enabled: figure.minor_grid_enabled,
510            legend_enabled: figure.legend_enabled,
511            colorbar_enabled: figure.colorbar_enabled,
512            axis_equal: figure.axis_equal,
513            background_rgba: vec4_to_rgba(figure.background_color),
514            colormap: Some(format!("{:?}", figure.colormap)),
515            color_limits: figure.color_limits.map(|(lo, hi)| [lo, hi]),
516            z_limits: figure.z_limits.map(|(lo, hi)| [lo, hi]),
517            legend_entries,
518            active_axes_index: figure.active_axes_index as u32,
519            axes_metadata: Some(
520                figure
521                    .axes_metadata
522                    .iter()
523                    .cloned()
524                    .map(SerializedAxesMetadata::from)
525                    .collect(),
526            ),
527        }
528    }
529}
530
531fn default_true() -> bool {
532    true
533}
534
535fn is_true(value: &bool) -> bool {
536    *value
537}
538
539fn is_false(value: &bool) -> bool {
540    !*value
541}
542
543#[derive(Debug, Clone, Serialize, Deserialize)]
544#[serde(rename_all = "camelCase")]
545pub struct SerializedTextStyle {
546    #[serde(skip_serializing_if = "Option::is_none")]
547    pub color_rgba: Option<[f32; 4]>,
548    #[serde(skip_serializing_if = "Option::is_none")]
549    pub font_size: Option<f32>,
550    #[serde(skip_serializing_if = "Option::is_none")]
551    pub font_weight: Option<String>,
552    #[serde(skip_serializing_if = "Option::is_none")]
553    pub font_angle: Option<String>,
554    #[serde(skip_serializing_if = "Option::is_none")]
555    pub interpreter: Option<String>,
556    pub visible: bool,
557}
558
559impl Default for SerializedTextStyle {
560    fn default() -> Self {
561        TextStyle::default().into()
562    }
563}
564
565impl From<TextStyle> for SerializedTextStyle {
566    fn from(value: TextStyle) -> Self {
567        Self {
568            color_rgba: value.color.map(vec4_to_rgba),
569            font_size: value.font_size,
570            font_weight: value.font_weight,
571            font_angle: value.font_angle,
572            interpreter: value.interpreter,
573            visible: value.visible,
574        }
575    }
576}
577
578impl From<SerializedTextStyle> for TextStyle {
579    fn from(value: SerializedTextStyle) -> Self {
580        Self {
581            color: value.color_rgba.map(rgba_to_vec4),
582            font_size: value.font_size,
583            font_weight: value.font_weight,
584            font_angle: value.font_angle,
585            interpreter: value.interpreter,
586            visible: value.visible,
587        }
588    }
589}
590
591#[derive(Debug, Clone, Serialize, Deserialize)]
592#[serde(rename_all = "camelCase")]
593pub struct SerializedLegendStyle {
594    #[serde(skip_serializing_if = "Option::is_none")]
595    pub location: Option<String>,
596    pub visible: bool,
597    #[serde(skip_serializing_if = "Option::is_none")]
598    pub font_size: Option<f32>,
599    #[serde(skip_serializing_if = "Option::is_none")]
600    pub font_weight: Option<String>,
601    #[serde(skip_serializing_if = "Option::is_none")]
602    pub font_angle: Option<String>,
603    #[serde(skip_serializing_if = "Option::is_none")]
604    pub interpreter: Option<String>,
605    #[serde(skip_serializing_if = "Option::is_none")]
606    pub box_visible: Option<bool>,
607    #[serde(skip_serializing_if = "Option::is_none")]
608    pub orientation: Option<String>,
609    #[serde(skip_serializing_if = "Option::is_none")]
610    pub text_color_rgba: Option<[f32; 4]>,
611}
612
613impl From<LegendStyle> for SerializedLegendStyle {
614    fn from(value: LegendStyle) -> Self {
615        Self {
616            location: value.location,
617            visible: value.visible,
618            font_size: value.font_size,
619            font_weight: value.font_weight,
620            font_angle: value.font_angle,
621            interpreter: value.interpreter,
622            box_visible: value.box_visible,
623            orientation: value.orientation,
624            text_color_rgba: value.text_color.map(vec4_to_rgba),
625        }
626    }
627}
628
629impl From<SerializedLegendStyle> for LegendStyle {
630    fn from(value: SerializedLegendStyle) -> Self {
631        Self {
632            location: value.location,
633            visible: value.visible,
634            font_size: value.font_size,
635            font_weight: value.font_weight,
636            font_angle: value.font_angle,
637            interpreter: value.interpreter,
638            box_visible: value.box_visible,
639            orientation: value.orientation,
640            text_color: value.text_color_rgba.map(rgba_to_vec4),
641        }
642    }
643}
644
645#[derive(Debug, Clone, Serialize, Deserialize)]
646#[serde(rename_all = "camelCase")]
647pub struct SerializedAxesMetadata {
648    #[serde(skip_serializing_if = "Option::is_none")]
649    pub title: Option<String>,
650    #[serde(skip_serializing_if = "Option::is_none")]
651    pub x_label: Option<String>,
652    #[serde(skip_serializing_if = "Option::is_none")]
653    pub y_label: Option<String>,
654    #[serde(skip_serializing_if = "Option::is_none")]
655    pub z_label: Option<String>,
656    #[serde(default, skip_serializing_if = "Option::is_none")]
657    pub x_tick_labels: Option<Vec<String>>,
658    #[serde(default, skip_serializing_if = "Option::is_none")]
659    pub y_tick_labels: Option<Vec<String>>,
660    #[serde(skip_serializing_if = "Option::is_none")]
661    pub x_limits: Option<[f64; 2]>,
662    #[serde(skip_serializing_if = "Option::is_none")]
663    pub y_limits: Option<[f64; 2]>,
664    #[serde(skip_serializing_if = "Option::is_none")]
665    pub z_limits: Option<[f64; 2]>,
666    #[serde(default)]
667    pub x_log: bool,
668    #[serde(default)]
669    pub y_log: bool,
670    #[serde(skip_serializing_if = "Option::is_none")]
671    pub view_azimuth_deg: Option<f32>,
672    #[serde(skip_serializing_if = "Option::is_none")]
673    pub view_elevation_deg: Option<f32>,
674    #[serde(default)]
675    pub grid_enabled: bool,
676    #[serde(default)]
677    pub minor_grid_enabled: bool,
678    #[serde(default, skip_serializing_if = "is_false")]
679    pub minor_grid_explicit: bool,
680    #[serde(default)]
681    pub box_enabled: bool,
682    #[serde(default)]
683    pub axis_equal: bool,
684    pub legend_enabled: bool,
685    #[serde(default)]
686    pub colorbar_enabled: bool,
687    pub colormap: String,
688    #[serde(skip_serializing_if = "Option::is_none")]
689    pub color_limits: Option<[f64; 2]>,
690    #[serde(default)]
691    pub axes_style: SerializedTextStyle,
692    pub title_style: SerializedTextStyle,
693    pub x_label_style: SerializedTextStyle,
694    pub y_label_style: SerializedTextStyle,
695    pub z_label_style: SerializedTextStyle,
696    pub legend_style: SerializedLegendStyle,
697    #[serde(default, skip_serializing_if = "Vec::is_empty")]
698    pub world_text_annotations: Vec<SerializedTextAnnotation>,
699}
700
701#[derive(Debug, Clone, Serialize, Deserialize)]
702#[serde(rename_all = "camelCase")]
703pub struct SerializedTextAnnotation {
704    pub position: [f32; 3],
705    pub text: String,
706    pub style: SerializedTextStyle,
707}
708
709impl From<AxesMetadata> for SerializedAxesMetadata {
710    fn from(value: AxesMetadata) -> Self {
711        Self {
712            title: value.title,
713            x_label: value.x_label,
714            y_label: value.y_label,
715            z_label: value.z_label,
716            x_tick_labels: value.x_tick_labels,
717            y_tick_labels: value.y_tick_labels,
718            x_limits: value.x_limits.map(|(a, b)| [a, b]),
719            y_limits: value.y_limits.map(|(a, b)| [a, b]),
720            z_limits: value.z_limits.map(|(a, b)| [a, b]),
721            x_log: value.x_log,
722            y_log: value.y_log,
723            view_azimuth_deg: value.view_azimuth_deg,
724            view_elevation_deg: value.view_elevation_deg,
725            grid_enabled: value.grid_enabled,
726            minor_grid_enabled: value.minor_grid_enabled,
727            minor_grid_explicit: value.minor_grid_explicit,
728            box_enabled: value.box_enabled,
729            axis_equal: value.axis_equal,
730            legend_enabled: value.legend_enabled,
731            colorbar_enabled: value.colorbar_enabled,
732            colormap: format!("{:?}", value.colormap),
733            color_limits: value.color_limits.map(|(a, b)| [a, b]),
734            axes_style: value.axes_style.into(),
735            title_style: value.title_style.into(),
736            x_label_style: value.x_label_style.into(),
737            y_label_style: value.y_label_style.into(),
738            z_label_style: value.z_label_style.into(),
739            legend_style: value.legend_style.into(),
740            world_text_annotations: value
741                .world_text_annotations
742                .into_iter()
743                .map(Into::into)
744                .collect(),
745        }
746    }
747}
748
749impl From<SerializedAxesMetadata> for AxesMetadata {
750    fn from(value: SerializedAxesMetadata) -> Self {
751        Self {
752            title: value.title,
753            x_label: value.x_label,
754            y_label: value.y_label,
755            z_label: value.z_label,
756            x_tick_labels: value.x_tick_labels,
757            y_tick_labels: value.y_tick_labels,
758            x_limits: value.x_limits.map(|[a, b]| (a, b)),
759            y_limits: value.y_limits.map(|[a, b]| (a, b)),
760            z_limits: value.z_limits.map(|[a, b]| (a, b)),
761            x_log: value.x_log,
762            y_log: value.y_log,
763            view_azimuth_deg: value.view_azimuth_deg,
764            view_elevation_deg: value.view_elevation_deg,
765            view_revision: 0,
766            grid_enabled: value.grid_enabled,
767            minor_grid_enabled: value.minor_grid_enabled,
768            minor_grid_explicit: value.minor_grid_explicit || value.minor_grid_enabled,
769            box_enabled: value.box_enabled,
770            axis_equal: value.axis_equal,
771            legend_enabled: value.legend_enabled,
772            colorbar_enabled: value.colorbar_enabled,
773            colormap: parse_colormap_name(&value.colormap),
774            color_limits: value.color_limits.map(|[a, b]| (a, b)),
775            axes_style: value.axes_style.into(),
776            title_style: value.title_style.into(),
777            x_label_style: value.x_label_style.into(),
778            y_label_style: value.y_label_style.into(),
779            z_label_style: value.z_label_style.into(),
780            legend_style: value.legend_style.into(),
781            world_text_annotations: value
782                .world_text_annotations
783                .into_iter()
784                .map(Into::into)
785                .collect(),
786        }
787    }
788}
789
790impl From<crate::plots::figure::TextAnnotation> for SerializedTextAnnotation {
791    fn from(value: crate::plots::figure::TextAnnotation) -> Self {
792        Self {
793            position: value.position.to_array(),
794            text: value.text,
795            style: value.style.into(),
796        }
797    }
798}
799
800impl From<SerializedTextAnnotation> for crate::plots::figure::TextAnnotation {
801    fn from(value: SerializedTextAnnotation) -> Self {
802        Self {
803            position: glam::Vec3::from_array(value.position),
804            text: value.text,
805            style: value.style.into(),
806        }
807    }
808}
809
810/// Descriptor for a single plot element within the figure.
811#[derive(Debug, Clone, Serialize, Deserialize)]
812#[serde(rename_all = "camelCase")]
813pub struct PlotDescriptor {
814    pub kind: PlotKind,
815    #[serde(skip_serializing_if = "Option::is_none")]
816    pub label: Option<String>,
817    pub axes_index: u32,
818    pub color_rgba: [f32; 4],
819    pub visible: bool,
820}
821
822impl PlotDescriptor {
823    fn from_plot(plot: &PlotElement, axes_index: u32) -> Self {
824        Self {
825            kind: PlotKind::from(plot.plot_type()),
826            label: plot.label(),
827            axes_index,
828            color_rgba: vec4_to_rgba(plot.color()),
829            visible: plot.is_visible(),
830        }
831    }
832}
833
834impl ScenePlot {
835    fn from_plot(plot: &PlotElement, axes_index: u32) -> Self {
836        match plot {
837            PlotElement::Line(line) => Self::Line {
838                x: line.x_data.clone(),
839                y: line.y_data.clone(),
840                color_rgba: vec4_to_rgba(line.color),
841                line_width: line.line_width,
842                line_style: format!("{:?}", line.line_style),
843                axes_index,
844                label: line.label.clone(),
845                visible: line.visible,
846            },
847            PlotElement::ReferenceLine(line) => Self::ReferenceLine {
848                orientation: match line.orientation {
849                    ReferenceLineOrientation::Vertical => "vertical",
850                    ReferenceLineOrientation::Horizontal => "horizontal",
851                }
852                .into(),
853                value: line.value,
854                color_rgba: vec4_to_rgba(line.color),
855                line_width: line.line_width,
856                line_style: format!("{:?}", line.line_style),
857                label: line.label.clone(),
858                display_name: line.display_name.clone(),
859                label_orientation: line.label_orientation.clone(),
860                axes_index,
861                visible: line.visible,
862            },
863            PlotElement::Scatter(scatter) => Self::Scatter {
864                x: scatter.x_data.clone(),
865                y: scatter.y_data.clone(),
866                color_rgba: vec4_to_rgba(scatter.color),
867                marker_size: scatter.marker_size,
868                marker_style: format!("{:?}", scatter.marker_style),
869                axes_index,
870                label: scatter.label.clone(),
871                visible: scatter.visible,
872            },
873            PlotElement::Bar(bar) => Self::Bar {
874                labels: bar.labels.clone(),
875                values: bar.values().unwrap_or(&[]).to_vec(),
876                histogram_bin_edges: bar.histogram_bin_edges().map(|edges| edges.to_vec()),
877                color_rgba: vec4_to_rgba(bar.color),
878                outline_color_rgba: bar.outline_color.map(vec4_to_rgba),
879                bar_width: bar.bar_width,
880                outline_width: bar.outline_width,
881                orientation: format!("{:?}", bar.orientation),
882                group_index: bar.group_index as u32,
883                group_count: bar.group_count as u32,
884                stack_offsets: bar.stack_offsets().map(|offsets| offsets.to_vec()),
885                axes_index,
886                label: bar.label.clone(),
887                visible: bar.visible,
888            },
889            PlotElement::ErrorBar(error) => Self::ErrorBar {
890                x: error.x.clone(),
891                y: error.y.clone(),
892                err_low: error.y_neg.clone(),
893                err_high: error.y_pos.clone(),
894                x_err_low: error.x_neg.clone(),
895                x_err_high: error.x_pos.clone(),
896                orientation: format!("{:?}", error.orientation),
897                color_rgba: vec4_to_rgba(error.color),
898                line_width: error.line_width,
899                line_style: format!("{:?}", error.line_style),
900                cap_width: error.cap_size,
901                marker_style: error.marker.as_ref().map(|m| format!("{:?}", m.kind)),
902                marker_size: error.marker.as_ref().map(|m| m.size),
903                marker_face_color: error.marker.as_ref().map(|m| vec4_to_rgba(m.face_color)),
904                marker_edge_color: error.marker.as_ref().map(|m| vec4_to_rgba(m.edge_color)),
905                marker_filled: error.marker.as_ref().map(|m| m.filled),
906                axes_index,
907                label: error.label.clone(),
908                visible: error.visible,
909            },
910            PlotElement::Stairs(stairs) => Self::Stairs {
911                x: stairs.x.clone(),
912                y: stairs.y.clone(),
913                color_rgba: vec4_to_rgba(stairs.color),
914                line_width: stairs.line_width,
915                axes_index,
916                label: stairs.label.clone(),
917                visible: stairs.visible,
918            },
919            PlotElement::Stem(stem) => Self::Stem {
920                x: stem.x.clone(),
921                y: stem.y.clone(),
922                baseline: stem.baseline,
923                color_rgba: vec4_to_rgba(stem.color),
924                line_width: stem.line_width,
925                line_style: format!("{:?}", stem.line_style),
926                baseline_color_rgba: vec4_to_rgba(stem.baseline_color),
927                baseline_visible: stem.baseline_visible,
928                marker_color_rgba: vec4_to_rgba(
929                    stem.marker
930                        .as_ref()
931                        .map(|m| m.face_color)
932                        .unwrap_or(stem.color),
933                ),
934                marker_size: stem.marker.as_ref().map(|m| m.size).unwrap_or(0.0),
935                marker_filled: stem.marker.as_ref().map(|m| m.filled).unwrap_or(false),
936                axes_index,
937                label: stem.label.clone(),
938                visible: stem.visible,
939            },
940            PlotElement::Area(area) => Self::Area {
941                x: area.x.clone(),
942                y: area.y.clone(),
943                lower_y: area.lower_y.clone(),
944                baseline: area.baseline,
945                color_rgba: vec4_to_rgba(area.color),
946                axes_index,
947                label: area.label.clone(),
948                visible: area.visible,
949            },
950            PlotElement::Quiver(quiver) => Self::Quiver {
951                x: quiver.x.clone(),
952                y: quiver.y.clone(),
953                u: quiver.u.clone(),
954                v: quiver.v.clone(),
955                color_rgba: vec4_to_rgba(quiver.color),
956                line_width: quiver.line_width,
957                scale: quiver.scale,
958                head_size: quiver.head_size,
959                axes_index,
960                label: quiver.label.clone(),
961                visible: quiver.visible,
962            },
963            PlotElement::Surface(surface) => Self::Surface {
964                x: surface.x_data.clone(),
965                y: surface.y_data.clone(),
966                z: surface.z_data.clone().unwrap_or_default(),
967                colormap: format!("{:?}", surface.colormap),
968                shading_mode: format!("{:?}", surface.shading_mode),
969                wireframe: surface.wireframe,
970                alpha: surface.alpha,
971                flatten_z: surface.flatten_z,
972                image_mode: surface.image_mode,
973                color_grid_rgba: surface.color_grid.as_ref().map(|grid| {
974                    grid.iter()
975                        .map(|row| row.iter().map(|color| vec4_to_rgba(*color)).collect())
976                        .collect()
977                }),
978                color_limits: surface.color_limits.map(|(lo, hi)| [lo, hi]),
979                axes_index,
980                label: surface.label.clone(),
981                visible: surface.visible,
982            },
983            PlotElement::Patch(patch) => Self::Patch {
984                vertices: patch
985                    .vertices()
986                    .iter()
987                    .map(|point| vec3_to_xyz(*point))
988                    .collect(),
989                faces: patch
990                    .faces()
991                    .iter()
992                    .map(|face| face.iter().map(|idx| *idx as u32).collect())
993                    .collect(),
994                face_color_rgba: vec4_to_rgba(patch.face_color()),
995                edge_color_rgba: vec4_to_rgba(patch.edge_color()),
996                face_color_mode: format!("{:?}", patch.face_color_mode()),
997                edge_color_mode: format!("{:?}", patch.edge_color_mode()),
998                face_alpha: patch.face_alpha(),
999                edge_alpha: patch.edge_alpha(),
1000                line_width: patch.line_width(),
1001                axes_index,
1002                label: patch.label().map(str::to_string),
1003                visible: patch.is_visible(),
1004                force_3d: patch.force_3d(),
1005            },
1006            PlotElement::Line3(line) => Self::Line3 {
1007                x: line.x_data.clone(),
1008                y: line.y_data.clone(),
1009                z: line.z_data.clone(),
1010                color_rgba: vec4_to_rgba(line.color),
1011                line_width: line.line_width,
1012                line_style: format!("{:?}", line.line_style),
1013                axes_index,
1014                label: line.label.clone(),
1015                visible: line.visible,
1016            },
1017            PlotElement::Scatter3(scatter3) => Self::Scatter3 {
1018                points: scatter3
1019                    .points
1020                    .iter()
1021                    .map(|point| vec3_to_xyz(*point))
1022                    .collect(),
1023                colors_rgba: scatter3
1024                    .colors
1025                    .iter()
1026                    .map(|color| vec4_to_rgba(*color))
1027                    .collect(),
1028                point_size: scatter3.point_size,
1029                point_sizes: scatter3.point_sizes.clone(),
1030                axes_index,
1031                label: scatter3.label.clone(),
1032                visible: scatter3.visible,
1033            },
1034            PlotElement::Contour(contour) => Self::Contour {
1035                vertices: contour
1036                    .cpu_vertices()
1037                    .unwrap_or(&[])
1038                    .iter()
1039                    .cloned()
1040                    .map(Into::into)
1041                    .collect(),
1042                bounds_min: vec3_to_xyz(contour.bounds().min),
1043                bounds_max: vec3_to_xyz(contour.bounds().max),
1044                base_z: contour.base_z,
1045                line_width: contour.line_width,
1046                axes_index,
1047                label: contour.label.clone(),
1048                visible: contour.visible,
1049                force_3d: contour.force_3d,
1050            },
1051            PlotElement::ContourFill(fill) => Self::ContourFill {
1052                vertices: fill
1053                    .cpu_vertices()
1054                    .unwrap_or(&[])
1055                    .iter()
1056                    .cloned()
1057                    .map(Into::into)
1058                    .collect(),
1059                bounds_min: vec3_to_xyz(fill.bounds().min),
1060                bounds_max: vec3_to_xyz(fill.bounds().max),
1061                axes_index,
1062                label: fill.label.clone(),
1063                visible: fill.visible,
1064            },
1065            PlotElement::Pie(pie) => Self::Pie {
1066                values: pie.values.clone(),
1067                colors_rgba: pie.colors.iter().map(|c| vec4_to_rgba(*c)).collect(),
1068                slice_labels: pie.slice_labels.clone(),
1069                label_format: pie.label_format.clone(),
1070                explode: pie.explode.clone(),
1071                axes_index,
1072                label: pie.label.clone(),
1073                visible: pie.visible,
1074            },
1075        }
1076    }
1077
1078    fn apply_to_figure(self, figure: &mut Figure) -> Result<(), String> {
1079        match self {
1080            ScenePlot::Line {
1081                x,
1082                y,
1083                color_rgba,
1084                line_width,
1085                line_style,
1086                axes_index,
1087                label,
1088                visible,
1089            } => {
1090                let mut line = LinePlot::new(x, y)?;
1091                line.set_color(rgba_to_vec4(color_rgba));
1092                line.set_line_width(line_width);
1093                line.set_line_style(parse_line_style(&line_style));
1094                line.label = label;
1095                line.set_visible(visible);
1096                figure.add_line_plot_on_axes(line, axes_index as usize);
1097            }
1098            ScenePlot::ReferenceLine {
1099                orientation,
1100                value,
1101                color_rgba,
1102                line_width,
1103                line_style,
1104                label,
1105                display_name,
1106                label_orientation,
1107                axes_index,
1108                visible,
1109            } => {
1110                let orientation = parse_reference_line_orientation(&orientation)?;
1111                let mut line = ReferenceLine::new(orientation, value)?.with_style(
1112                    rgba_to_vec4(color_rgba),
1113                    line_width,
1114                    parse_line_style(&line_style),
1115                );
1116                line.label = label;
1117                line.display_name = display_name;
1118                line.label_orientation = label_orientation;
1119                line.visible = visible;
1120                figure.add_reference_line_on_axes(line, axes_index as usize);
1121            }
1122            ScenePlot::Scatter {
1123                x,
1124                y,
1125                color_rgba,
1126                marker_size,
1127                marker_style,
1128                axes_index,
1129                label,
1130                visible,
1131            } => {
1132                let mut scatter = ScatterPlot::new(x, y)?;
1133                scatter.set_color(rgba_to_vec4(color_rgba));
1134                scatter.set_marker_size(marker_size);
1135                scatter.set_marker_style(parse_marker_style(&marker_style));
1136                scatter.label = label;
1137                scatter.set_visible(visible);
1138                figure.add_scatter_plot_on_axes(scatter, axes_index as usize);
1139            }
1140            ScenePlot::Bar {
1141                labels,
1142                values,
1143                histogram_bin_edges,
1144                color_rgba,
1145                outline_color_rgba,
1146                bar_width,
1147                outline_width,
1148                orientation,
1149                group_index,
1150                group_count,
1151                stack_offsets,
1152                axes_index,
1153                label,
1154                visible,
1155            } => {
1156                let mut bar = BarChart::new(labels, values)?
1157                    .with_style(rgba_to_vec4(color_rgba), bar_width)
1158                    .with_orientation(parse_bar_orientation(&orientation))
1159                    .with_group(group_index as usize, group_count as usize);
1160                if let Some(edges) = histogram_bin_edges {
1161                    bar.set_histogram_bin_edges(edges);
1162                }
1163                if let Some(offsets) = stack_offsets {
1164                    bar = bar.with_stack_offsets(offsets);
1165                }
1166                if let Some(outline) = outline_color_rgba {
1167                    bar = bar.with_outline(rgba_to_vec4(outline), outline_width);
1168                }
1169                bar.label = label;
1170                bar.set_visible(visible);
1171                figure.add_bar_chart_on_axes(bar, axes_index as usize);
1172            }
1173            ScenePlot::ErrorBar {
1174                x,
1175                y,
1176                err_low,
1177                err_high,
1178                x_err_low,
1179                x_err_high,
1180                orientation,
1181                color_rgba,
1182                line_width,
1183                line_style,
1184                cap_width,
1185                marker_style,
1186                marker_size,
1187                marker_face_color,
1188                marker_edge_color,
1189                marker_filled,
1190                axes_index,
1191                label,
1192                visible,
1193            } => {
1194                let mut error = if orientation.eq_ignore_ascii_case("Both") {
1195                    ErrorBar::new_both(x, y, x_err_low, x_err_high, err_low, err_high)?
1196                } else {
1197                    ErrorBar::new_vertical(x, y, err_low, err_high)?
1198                }
1199                .with_style(
1200                    rgba_to_vec4(color_rgba),
1201                    line_width,
1202                    parse_line_style_name(&line_style),
1203                    cap_width,
1204                );
1205                if let Some(size) = marker_size {
1206                    error.set_marker(Some(crate::plots::line::LineMarkerAppearance {
1207                        kind: parse_marker_style(marker_style.as_deref().unwrap_or("Circle")),
1208                        size,
1209                        edge_color: marker_edge_color
1210                            .map(rgba_to_vec4)
1211                            .unwrap_or(rgba_to_vec4(color_rgba)),
1212                        face_color: marker_face_color
1213                            .map(rgba_to_vec4)
1214                            .unwrap_or(rgba_to_vec4(color_rgba)),
1215                        filled: marker_filled.unwrap_or(false),
1216                    }));
1217                }
1218                error.label = label;
1219                error.set_visible(visible);
1220                figure.add_errorbar_on_axes(error, axes_index as usize);
1221            }
1222            ScenePlot::Stairs {
1223                x,
1224                y,
1225                color_rgba,
1226                line_width,
1227                axes_index,
1228                label,
1229                visible,
1230            } => {
1231                let mut stairs = StairsPlot::new(x, y)?;
1232                stairs.color = rgba_to_vec4(color_rgba);
1233                stairs.line_width = line_width;
1234                stairs.label = label;
1235                stairs.set_visible(visible);
1236                figure.add_stairs_plot_on_axes(stairs, axes_index as usize);
1237            }
1238            ScenePlot::Stem {
1239                x,
1240                y,
1241                baseline,
1242                color_rgba,
1243                line_width,
1244                line_style,
1245                baseline_color_rgba,
1246                baseline_visible,
1247                marker_color_rgba,
1248                marker_size,
1249                marker_filled,
1250                axes_index,
1251                label,
1252                visible,
1253            } => {
1254                let mut stem = StemPlot::new(x, y)?;
1255                stem = stem
1256                    .with_style(
1257                        rgba_to_vec4(color_rgba),
1258                        line_width,
1259                        parse_line_style_name(&line_style),
1260                        baseline,
1261                    )
1262                    .with_baseline_style(rgba_to_vec4(baseline_color_rgba), baseline_visible);
1263                if marker_size > 0.0 {
1264                    stem.set_marker(Some(crate::plots::line::LineMarkerAppearance {
1265                        kind: crate::plots::scatter::MarkerStyle::Circle,
1266                        size: marker_size,
1267                        edge_color: rgba_to_vec4(marker_color_rgba),
1268                        face_color: rgba_to_vec4(marker_color_rgba),
1269                        filled: marker_filled,
1270                    }));
1271                }
1272                stem.label = label;
1273                stem.set_visible(visible);
1274                figure.add_stem_plot_on_axes(stem, axes_index as usize);
1275            }
1276            ScenePlot::Area {
1277                x,
1278                y,
1279                lower_y,
1280                baseline,
1281                color_rgba,
1282                axes_index,
1283                label,
1284                visible,
1285            } => {
1286                let mut area = AreaPlot::new(x, y)?;
1287                if let Some(lower_y) = lower_y {
1288                    area = area.with_lower_curve(lower_y);
1289                }
1290                area.baseline = baseline;
1291                area.color = rgba_to_vec4(color_rgba);
1292                area.label = label;
1293                area.set_visible(visible);
1294                figure.add_area_plot_on_axes(area, axes_index as usize);
1295            }
1296            ScenePlot::Quiver {
1297                x,
1298                y,
1299                u,
1300                v,
1301                color_rgba,
1302                line_width,
1303                scale,
1304                head_size,
1305                axes_index,
1306                label,
1307                visible,
1308            } => {
1309                let mut quiver = QuiverPlot::new(x, y, u, v)?
1310                    .with_style(rgba_to_vec4(color_rgba), line_width, scale, head_size)
1311                    .with_label(label.unwrap_or_else(|| "Data".to_string()));
1312                quiver.set_visible(visible);
1313                figure.add_quiver_plot_on_axes(quiver, axes_index as usize);
1314            }
1315            ScenePlot::Surface {
1316                x,
1317                y,
1318                z,
1319                colormap,
1320                shading_mode,
1321                wireframe,
1322                alpha,
1323                flatten_z,
1324                image_mode,
1325                color_grid_rgba,
1326                color_limits,
1327                axes_index,
1328                label,
1329                visible,
1330            } => {
1331                let mut surface = SurfacePlot::new(x, y, z)?;
1332                surface.colormap = parse_colormap(&colormap);
1333                surface.shading_mode = parse_shading_mode(&shading_mode);
1334                surface.wireframe = wireframe;
1335                surface.alpha = alpha.clamp(0.0, 1.0);
1336                surface.flatten_z = flatten_z;
1337                surface.image_mode = image_mode;
1338                surface.color_grid = color_grid_rgba.map(|grid| {
1339                    grid.into_iter()
1340                        .map(|row| row.into_iter().map(rgba_to_vec4).collect())
1341                        .collect()
1342                });
1343                surface.color_limits = color_limits.map(|[lo, hi]| (lo, hi));
1344                surface.label = label;
1345                surface.visible = visible;
1346                figure.add_surface_plot_on_axes(surface, axes_index as usize);
1347            }
1348            ScenePlot::Patch {
1349                vertices,
1350                faces,
1351                face_color_rgba,
1352                edge_color_rgba,
1353                face_color_mode,
1354                edge_color_mode,
1355                face_alpha,
1356                edge_alpha,
1357                line_width,
1358                axes_index,
1359                label,
1360                visible,
1361                force_3d,
1362            } => {
1363                let vertices: Vec<Vec3> = vertices.into_iter().map(xyz_to_vec3).collect();
1364                let faces: Vec<Vec<usize>> = faces
1365                    .into_iter()
1366                    .map(|face| face.into_iter().map(|idx| idx as usize).collect())
1367                    .collect();
1368                let mut patch = PatchPlot::new(vertices, faces)?;
1369                patch.set_face_color(rgba_to_vec4(face_color_rgba));
1370                patch.set_edge_color(rgba_to_vec4(edge_color_rgba));
1371                patch.set_face_color_mode(parse_patch_face_color_mode(&face_color_mode));
1372                patch.set_edge_color_mode(parse_patch_edge_color_mode(&edge_color_mode));
1373                patch.set_face_alpha(face_alpha);
1374                patch.set_edge_alpha(edge_alpha);
1375                patch.set_line_width(line_width);
1376                patch.set_label(label);
1377                patch.set_visible(visible);
1378                patch.set_force_3d(force_3d);
1379                figure.add_patch_plot_on_axes(patch, axes_index as usize);
1380            }
1381            ScenePlot::Line3 {
1382                x,
1383                y,
1384                z,
1385                color_rgba,
1386                line_width,
1387                line_style,
1388                axes_index,
1389                label,
1390                visible,
1391            } => {
1392                let mut plot = Line3Plot::new(x, y, z)?
1393                    .with_style(
1394                        rgba_to_vec4(color_rgba),
1395                        line_width,
1396                        parse_line_style_name(&line_style),
1397                    )
1398                    .with_label(label.unwrap_or_else(|| "Data".to_string()));
1399                plot.set_visible(visible);
1400                figure.add_line3_plot_on_axes(plot, axes_index as usize);
1401            }
1402            ScenePlot::Scatter3 {
1403                points,
1404                colors_rgba,
1405                point_size,
1406                point_sizes,
1407                axes_index,
1408                label,
1409                visible,
1410            } => {
1411                let points: Vec<Vec3> = points.into_iter().map(xyz_to_vec3).collect();
1412                let colors: Vec<Vec4> = colors_rgba.into_iter().map(rgba_to_vec4).collect();
1413                let mut scatter3 = Scatter3Plot::new(points)?;
1414                if !colors.is_empty() {
1415                    scatter3 = scatter3.with_colors(colors)?;
1416                }
1417                scatter3.point_size = point_size.max(1.0);
1418                scatter3.point_sizes = point_sizes;
1419                scatter3.label = label;
1420                scatter3.visible = visible;
1421                figure.add_scatter3_plot_on_axes(scatter3, axes_index as usize);
1422            }
1423            ScenePlot::Contour {
1424                vertices,
1425                bounds_min,
1426                bounds_max,
1427                base_z,
1428                line_width,
1429                axes_index,
1430                label,
1431                visible,
1432                force_3d,
1433            } => {
1434                let mut contour = ContourPlot::from_vertices(
1435                    vertices.into_iter().map(Into::into).collect(),
1436                    base_z,
1437                    serialized_bounds(bounds_min, bounds_max),
1438                )
1439                .with_line_width(line_width)
1440                .with_force_3d(force_3d);
1441                contour.label = label;
1442                contour.set_visible(visible);
1443                figure.add_contour_plot_on_axes(contour, axes_index as usize);
1444            }
1445            ScenePlot::ContourFill {
1446                vertices,
1447                bounds_min,
1448                bounds_max,
1449                axes_index,
1450                label,
1451                visible,
1452            } => {
1453                let mut fill = ContourFillPlot::from_vertices(
1454                    vertices.into_iter().map(Into::into).collect(),
1455                    serialized_bounds(bounds_min, bounds_max),
1456                );
1457                fill.label = label;
1458                fill.set_visible(visible);
1459                figure.add_contour_fill_plot_on_axes(fill, axes_index as usize);
1460            }
1461            ScenePlot::Pie {
1462                values,
1463                colors_rgba,
1464                slice_labels,
1465                label_format,
1466                explode,
1467                axes_index,
1468                label,
1469                visible,
1470            } => {
1471                let mut pie = crate::plots::PieChart::new(
1472                    values,
1473                    Some(colors_rgba.into_iter().map(rgba_to_vec4).collect()),
1474                )?
1475                .with_slice_labels(slice_labels)
1476                .with_explode(explode);
1477                if let Some(fmt) = label_format {
1478                    pie = pie.with_label_format(fmt);
1479                }
1480                pie.label = label;
1481                pie.set_visible(visible);
1482                figure.add_pie_chart_on_axes(pie, axes_index as usize);
1483            }
1484            ScenePlot::Unsupported { .. } => {}
1485        }
1486        Ok(())
1487    }
1488}
1489
1490fn parse_line_style(value: &str) -> crate::plots::LineStyle {
1491    match value {
1492        "Dashed" => crate::plots::LineStyle::Dashed,
1493        "Dotted" => crate::plots::LineStyle::Dotted,
1494        "DashDot" => crate::plots::LineStyle::DashDot,
1495        _ => crate::plots::LineStyle::Solid,
1496    }
1497}
1498
1499fn parse_bar_orientation(value: &str) -> crate::plots::bar::Orientation {
1500    match value {
1501        "Horizontal" => crate::plots::bar::Orientation::Horizontal,
1502        _ => crate::plots::bar::Orientation::Vertical,
1503    }
1504}
1505
1506fn parse_reference_line_orientation(value: &str) -> Result<ReferenceLineOrientation, String> {
1507    match value.to_ascii_lowercase().as_str() {
1508        "horizontal" => Ok(ReferenceLineOrientation::Horizontal),
1509        "vertical" => Ok(ReferenceLineOrientation::Vertical),
1510        _ => Err(format!(
1511            "unknown reference line orientation '{value}'; expected 'horizontal' or 'vertical'"
1512        )),
1513    }
1514}
1515
1516fn parse_marker_style(value: &str) -> MarkerStyle {
1517    match value {
1518        "Square" => MarkerStyle::Square,
1519        "Triangle" => MarkerStyle::Triangle,
1520        "Diamond" => MarkerStyle::Diamond,
1521        "Plus" => MarkerStyle::Plus,
1522        "Cross" => MarkerStyle::Cross,
1523        "Star" => MarkerStyle::Star,
1524        "Hexagon" => MarkerStyle::Hexagon,
1525        _ => MarkerStyle::Circle,
1526    }
1527}
1528
1529fn parse_colormap(value: &str) -> ColorMap {
1530    match value {
1531        "Jet" => ColorMap::Jet,
1532        "Hot" => ColorMap::Hot,
1533        "Cool" => ColorMap::Cool,
1534        "Spring" => ColorMap::Spring,
1535        "Summer" => ColorMap::Summer,
1536        "Autumn" => ColorMap::Autumn,
1537        "Winter" => ColorMap::Winter,
1538        "Gray" => ColorMap::Gray,
1539        "Bone" => ColorMap::Bone,
1540        "Copper" => ColorMap::Copper,
1541        "Pink" => ColorMap::Pink,
1542        "Lines" => ColorMap::Lines,
1543        "Viridis" => ColorMap::Viridis,
1544        "Plasma" => ColorMap::Plasma,
1545        "Inferno" => ColorMap::Inferno,
1546        "Magma" => ColorMap::Magma,
1547        "Turbo" => ColorMap::Turbo,
1548        "Parula" => ColorMap::Parula,
1549        _ => ColorMap::Parula,
1550    }
1551}
1552
1553fn parse_shading_mode(value: &str) -> ShadingMode {
1554    match value {
1555        "Flat" => ShadingMode::Flat,
1556        "Smooth" => ShadingMode::Smooth,
1557        "Faceted" => ShadingMode::Faceted,
1558        "None" => ShadingMode::None,
1559        _ => ShadingMode::Smooth,
1560    }
1561}
1562
1563fn parse_patch_face_color_mode(value: &str) -> PatchFaceColorMode {
1564    match value {
1565        "None" => PatchFaceColorMode::None,
1566        "Flat" => PatchFaceColorMode::Flat,
1567        _ => PatchFaceColorMode::Color,
1568    }
1569}
1570
1571fn parse_patch_edge_color_mode(value: &str) -> PatchEdgeColorMode {
1572    match value {
1573        "None" => PatchEdgeColorMode::None,
1574        _ => PatchEdgeColorMode::Color,
1575    }
1576}
1577
1578fn xyz_to_vec3(value: [f32; 3]) -> Vec3 {
1579    Vec3::new(value[0], value[1], value[2])
1580}
1581
1582fn serialized_bounds(min: [f32; 3], max: [f32; 3]) -> BoundingBox {
1583    BoundingBox::new(xyz_to_vec3(min), xyz_to_vec3(max))
1584}
1585
1586fn vec3_to_xyz(value: Vec3) -> [f32; 3] {
1587    [value.x, value.y, value.z]
1588}
1589
1590fn rgba_to_vec4(value: [f32; 4]) -> Vec4 {
1591    Vec4::new(value[0], value[1], value[2], value[3])
1592}
1593
1594#[derive(Debug, Clone, Serialize, Deserialize)]
1595#[serde(rename_all = "camelCase")]
1596pub struct SerializedVertex {
1597    position: [f32; 3],
1598    color_rgba: [f32; 4],
1599    normal: [f32; 3],
1600    tex_coords: [f32; 2],
1601}
1602
1603impl From<Vertex> for SerializedVertex {
1604    fn from(value: Vertex) -> Self {
1605        Self {
1606            position: value.position,
1607            color_rgba: value.color,
1608            normal: value.normal,
1609            tex_coords: value.tex_coords,
1610        }
1611    }
1612}
1613
1614impl From<SerializedVertex> for Vertex {
1615    fn from(value: SerializedVertex) -> Self {
1616        Self {
1617            position: value.position,
1618            color: value.color_rgba,
1619            normal: value.normal,
1620            tex_coords: value.tex_coords,
1621        }
1622    }
1623}
1624
1625/// Serialized legend entry for frontend rendering.
1626#[derive(Debug, Clone, Serialize, Deserialize)]
1627#[serde(rename_all = "camelCase")]
1628pub struct FigureLegendEntry {
1629    pub label: String,
1630    pub plot_type: PlotKind,
1631    pub color_rgba: [f32; 4],
1632}
1633
1634impl From<LegendEntry> for FigureLegendEntry {
1635    fn from(entry: LegendEntry) -> Self {
1636        Self {
1637            label: entry.label,
1638            plot_type: PlotKind::from(entry.plot_type),
1639            color_rgba: vec4_to_rgba(entry.color),
1640        }
1641    }
1642}
1643
1644/// Serializable plot kind values consumed by UI + transports.
1645#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1646#[serde(rename_all = "snake_case")]
1647pub enum PlotKind {
1648    Line,
1649    Line3,
1650    Scatter,
1651    Bar,
1652    ErrorBar,
1653    Stairs,
1654    Stem,
1655    Area,
1656    Quiver,
1657    Pie,
1658    Image,
1659    Surface,
1660    Patch,
1661    Scatter3,
1662    Contour,
1663    ContourFill,
1664    ReferenceLine,
1665}
1666
1667impl From<PlotType> for PlotKind {
1668    fn from(value: PlotType) -> Self {
1669        match value {
1670            PlotType::Line => Self::Line,
1671            PlotType::Line3 => Self::Line3,
1672            PlotType::Scatter => Self::Scatter,
1673            PlotType::Bar => Self::Bar,
1674            PlotType::ErrorBar => Self::ErrorBar,
1675            PlotType::Stairs => Self::Stairs,
1676            PlotType::Stem => Self::Stem,
1677            PlotType::Area => Self::Area,
1678            PlotType::Quiver => Self::Quiver,
1679            PlotType::Pie => Self::Pie,
1680            PlotType::Surface => Self::Surface,
1681            PlotType::Patch => Self::Patch,
1682            PlotType::Scatter3 => Self::Scatter3,
1683            PlotType::Contour => Self::Contour,
1684            PlotType::ContourFill => Self::ContourFill,
1685            PlotType::ReferenceLine => Self::ReferenceLine,
1686        }
1687    }
1688}
1689
1690fn parse_line_style_name(name: &str) -> crate::plots::line::LineStyle {
1691    match name.to_ascii_lowercase().as_str() {
1692        "dashed" => crate::plots::line::LineStyle::Dashed,
1693        "dotted" => crate::plots::line::LineStyle::Dotted,
1694        "dashdot" => crate::plots::line::LineStyle::DashDot,
1695        _ => crate::plots::line::LineStyle::Solid,
1696    }
1697}
1698
1699fn parse_colormap_name(name: &str) -> crate::plots::surface::ColorMap {
1700    match name.trim().to_ascii_lowercase().as_str() {
1701        "viridis" => crate::plots::surface::ColorMap::Viridis,
1702        "plasma" => crate::plots::surface::ColorMap::Plasma,
1703        "inferno" => crate::plots::surface::ColorMap::Inferno,
1704        "magma" => crate::plots::surface::ColorMap::Magma,
1705        "turbo" => crate::plots::surface::ColorMap::Turbo,
1706        "jet" => crate::plots::surface::ColorMap::Jet,
1707        "hot" => crate::plots::surface::ColorMap::Hot,
1708        "cool" => crate::plots::surface::ColorMap::Cool,
1709        "spring" => crate::plots::surface::ColorMap::Spring,
1710        "summer" => crate::plots::surface::ColorMap::Summer,
1711        "autumn" => crate::plots::surface::ColorMap::Autumn,
1712        "winter" => crate::plots::surface::ColorMap::Winter,
1713        "gray" | "grey" => crate::plots::surface::ColorMap::Gray,
1714        "bone" => crate::plots::surface::ColorMap::Bone,
1715        "copper" => crate::plots::surface::ColorMap::Copper,
1716        "pink" => crate::plots::surface::ColorMap::Pink,
1717        "lines" => crate::plots::surface::ColorMap::Lines,
1718        _ => crate::plots::surface::ColorMap::Parula,
1719    }
1720}
1721
1722fn vec4_to_rgba(value: Vec4) -> [f32; 4] {
1723    [value.x, value.y, value.z, value.w]
1724}
1725
1726fn deserialize_f64_lossy<'de, D>(deserializer: D) -> Result<f64, D::Error>
1727where
1728    D: serde::Deserializer<'de>,
1729{
1730    let value = Option::<f64>::deserialize(deserializer)?;
1731    Ok(value.unwrap_or(f64::NAN))
1732}
1733
1734fn deserialize_vec_f64_lossy<'de, D>(deserializer: D) -> Result<Vec<f64>, D::Error>
1735where
1736    D: serde::Deserializer<'de>,
1737{
1738    let values = Vec::<Option<f64>>::deserialize(deserializer)?;
1739    Ok(values
1740        .into_iter()
1741        .map(|value| value.unwrap_or(f64::NAN))
1742        .collect())
1743}
1744
1745fn deserialize_option_vec_f64_lossy<'de, D>(deserializer: D) -> Result<Option<Vec<f64>>, D::Error>
1746where
1747    D: serde::Deserializer<'de>,
1748{
1749    let values = Option::<Vec<Option<f64>>>::deserialize(deserializer)?;
1750    Ok(values.map(|items| {
1751        items
1752            .into_iter()
1753            .map(|value| value.unwrap_or(f64::NAN))
1754            .collect()
1755    }))
1756}
1757
1758fn deserialize_matrix_f64_lossy<'de, D>(deserializer: D) -> Result<Vec<Vec<f64>>, D::Error>
1759where
1760    D: serde::Deserializer<'de>,
1761{
1762    let rows = Vec::<Vec<Option<f64>>>::deserialize(deserializer)?;
1763    Ok(rows
1764        .into_iter()
1765        .map(|row| {
1766            row.into_iter()
1767                .map(|value| value.unwrap_or(f64::NAN))
1768                .collect()
1769        })
1770        .collect())
1771}
1772
1773fn deserialize_option_pair_f64_lossy<'de, D>(deserializer: D) -> Result<Option<[f64; 2]>, D::Error>
1774where
1775    D: serde::Deserializer<'de>,
1776{
1777    let value = Option::<[Option<f64>; 2]>::deserialize(deserializer)?;
1778    Ok(value.map(|pair| [pair[0].unwrap_or(f64::NAN), pair[1].unwrap_or(f64::NAN)]))
1779}
1780
1781fn deserialize_option_vec_f32_lossy<'de, D>(deserializer: D) -> Result<Option<Vec<f32>>, D::Error>
1782where
1783    D: serde::Deserializer<'de>,
1784{
1785    let values = Option::<Vec<Option<f32>>>::deserialize(deserializer)?;
1786    Ok(values.map(|items| {
1787        items
1788            .into_iter()
1789            .map(|value| value.unwrap_or(f32::NAN))
1790            .collect()
1791    }))
1792}
1793
1794fn deserialize_vec_xyz_f32_lossy<'de, D>(deserializer: D) -> Result<Vec<[f32; 3]>, D::Error>
1795where
1796    D: serde::Deserializer<'de>,
1797{
1798    let values = Vec::<[Option<f32>; 3]>::deserialize(deserializer)?;
1799    Ok(values
1800        .into_iter()
1801        .map(|xyz| {
1802            [
1803                xyz[0].unwrap_or(f32::NAN),
1804                xyz[1].unwrap_or(f32::NAN),
1805                xyz[2].unwrap_or(f32::NAN),
1806            ]
1807        })
1808        .collect())
1809}
1810
1811fn deserialize_vec_rgba_f32_lossy<'de, D>(deserializer: D) -> Result<Vec<[f32; 4]>, D::Error>
1812where
1813    D: serde::Deserializer<'de>,
1814{
1815    let values = Vec::<[Option<f32>; 4]>::deserialize(deserializer)?;
1816    Ok(values
1817        .into_iter()
1818        .map(|rgba| {
1819            [
1820                rgba[0].unwrap_or(f32::NAN),
1821                rgba[1].unwrap_or(f32::NAN),
1822                rgba[2].unwrap_or(f32::NAN),
1823                rgba[3].unwrap_or(f32::NAN),
1824            ]
1825        })
1826        .collect())
1827}
1828
1829#[cfg(test)]
1830mod tests {
1831    use super::*;
1832    use crate::plots::{
1833        Figure, Line3Plot, LinePlot, PatchPlot, Scatter3Plot, ScatterPlot, SurfacePlot,
1834    };
1835    use glam::{Vec3, Vec4};
1836
1837    #[test]
1838    fn capture_snapshot_reflects_layout_and_metadata() {
1839        let mut figure = Figure::new()
1840            .with_title("Demo")
1841            .with_sg_title("Overview")
1842            .with_labels("X", "Y")
1843            .with_grid(false)
1844            .with_subplot_grid(1, 2);
1845        figure.set_name("Window Name");
1846        figure.set_number_title(false);
1847        figure.set_visible(false);
1848        figure.set_background_color(Vec4::new(0.0, 0.0, 0.0, 1.0));
1849        let line = LinePlot::new(vec![0.0, 1.0], vec![0.0, 1.0]).unwrap();
1850        figure.add_line_plot_on_axes(line, 1);
1851
1852        let snapshot = FigureSnapshot::capture(&figure);
1853        assert_eq!(snapshot.layout.axes_rows, 1);
1854        assert_eq!(snapshot.layout.axes_cols, 2);
1855        assert_eq!(snapshot.metadata.title.as_deref(), Some("Demo"));
1856        assert_eq!(snapshot.metadata.name.as_deref(), Some("Window Name"));
1857        assert!(!snapshot.metadata.number_title);
1858        assert!(!snapshot.metadata.visible);
1859        assert_eq!(snapshot.metadata.sg_title.as_deref(), Some("Overview"));
1860        assert_eq!(snapshot.metadata.background_rgba, [0.0, 0.0, 0.0, 1.0]);
1861        assert_eq!(snapshot.metadata.legend_entries.len(), 0);
1862        assert_eq!(snapshot.plots.len(), 1);
1863        assert_eq!(snapshot.plots[0].axes_index, 1);
1864        assert!(!snapshot.metadata.grid_enabled);
1865    }
1866
1867    #[test]
1868    fn sg_title_style_omitted_when_sg_title_absent() {
1869        let figure = Figure::new().with_title("Only regular title");
1870        let snapshot = FigureSnapshot::capture(&figure);
1871        assert!(snapshot.metadata.sg_title.is_none());
1872        assert!(
1873            snapshot.metadata.sg_title_style.is_none(),
1874            "sgTitleStyle must be None when sgTitle is absent"
1875        );
1876        let json = serde_json::to_string(&snapshot.metadata).unwrap();
1877        assert!(
1878            !json.contains("sgTitleStyle"),
1879            "sgTitleStyle must not appear in serialized JSON when sgTitle is absent"
1880        );
1881    }
1882
1883    #[test]
1884    fn figure_scene_roundtrip_reconstructs_supported_plots() {
1885        let mut figure = Figure::new().with_title("Replay").with_subplot_grid(1, 2);
1886        figure.set_name("Roundtrip");
1887        figure.set_number_title(false);
1888        figure.set_visible(false);
1889        let mut line = LinePlot::new(vec![0.0, 1.0], vec![1.0, 2.0]).unwrap();
1890        line.label = Some("line".to_string());
1891        figure.add_line_plot_on_axes(line, 0);
1892        let mut scatter = ScatterPlot::new(vec![0.0, 1.0, 2.0], vec![2.0, 3.0, 4.0]).unwrap();
1893        scatter.label = Some("scatter".to_string());
1894        figure.add_scatter_plot_on_axes(scatter, 1);
1895
1896        let scene = FigureScene::capture(&figure);
1897        let rebuilt = scene.into_figure().expect("scene restore should succeed");
1898        assert_eq!(rebuilt.axes_grid(), (1, 2));
1899        assert_eq!(rebuilt.plots().count(), 2);
1900        assert_eq!(rebuilt.title.as_deref(), Some("Replay"));
1901        assert_eq!(rebuilt.name.as_deref(), Some("Roundtrip"));
1902        assert!(!rebuilt.number_title);
1903        assert!(!rebuilt.visible);
1904    }
1905
1906    #[test]
1907    fn figure_scene_roundtrip_reconstructs_patch() {
1908        let mut figure = Figure::new();
1909        let mut patch = PatchPlot::new(
1910            vec![
1911                Vec3::new(0.0, 0.0, 0.0),
1912                Vec3::new(1.0, 0.0, 0.0),
1913                Vec3::new(0.0, 1.0, 0.0),
1914            ],
1915            vec![vec![0, 1, 2]],
1916        )
1917        .unwrap();
1918        patch.set_label(Some("tri".into()));
1919        patch.set_force_3d(true);
1920        figure.add_patch_plot(patch);
1921
1922        let scene = FigureScene::capture(&figure);
1923        assert_eq!(scene.schema_version, FigureScene::SCHEMA_VERSION);
1924        assert!(matches!(scene.plots.first(), Some(ScenePlot::Patch { .. })));
1925        let rebuilt = scene.into_figure().expect("patch scene restore");
1926        let Some(PlotElement::Patch(patch)) = rebuilt.plots().next() else {
1927            panic!("expected patch plot");
1928        };
1929        assert_eq!(patch.faces(), &[vec![0, 1, 2]]);
1930        assert_eq!(patch.label(), Some("tri"));
1931        assert!(patch.force_3d());
1932    }
1933
1934    #[test]
1935    fn figure_scene_rejects_invalid_schema_versions() {
1936        let mut scene = FigureScene::capture(&Figure::new());
1937        scene.schema_version = 0;
1938        let err = scene.clone().into_figure().expect_err("schema 0 must fail");
1939        assert!(err.contains("unsupported figure scene schema version 0"));
1940
1941        scene.schema_version = FigureScene::SCHEMA_VERSION + 1;
1942        let err = scene.into_figure().expect_err("future schema must fail");
1943        assert!(err.contains(&format!(
1944            "unsupported figure scene schema version {}",
1945            FigureScene::SCHEMA_VERSION + 1
1946        )));
1947    }
1948
1949    #[test]
1950    fn figure_scene_rejects_patch_in_older_schema() {
1951        let mut figure = Figure::new();
1952        figure.add_patch_plot(
1953            PatchPlot::new(
1954                vec![
1955                    Vec3::new(0.0, 0.0, 0.0),
1956                    Vec3::new(1.0, 0.0, 0.0),
1957                    Vec3::new(0.0, 1.0, 0.0),
1958                ],
1959                vec![vec![0, 1, 2]],
1960            )
1961            .unwrap(),
1962        );
1963
1964        let mut scene = FigureScene::capture(&figure);
1965        assert!(matches!(scene.plots.first(), Some(ScenePlot::Patch { .. })));
1966        scene.schema_version = FigureScene::SCHEMA_VERSION - 1;
1967
1968        let err = scene
1969            .into_figure()
1970            .expect_err("older patch schema must fail");
1971        assert!(err.contains(&format!(
1972            "patch plots require figure scene schema version {}",
1973            FigureScene::SCHEMA_VERSION
1974        )));
1975    }
1976
1977    #[test]
1978    fn figure_scene_rejects_unknown_reference_line_orientation() {
1979        let mut scene = FigureScene::capture(&Figure::new());
1980        scene.plots.push(ScenePlot::ReferenceLine {
1981            orientation: "VERTICAL".into(),
1982            value: 2.0,
1983            color_rgba: [0.1, 0.2, 0.3, 1.0],
1984            line_width: 1.0,
1985            line_style: "Solid".into(),
1986            label: None,
1987            display_name: None,
1988            label_orientation: "horizontal".into(),
1989            axes_index: 0,
1990            visible: true,
1991        });
1992
1993        let rebuilt = scene.clone().into_figure().expect("valid orientation");
1994        let PlotElement::ReferenceLine(line) = rebuilt.plots().next().unwrap() else {
1995            panic!("expected reference line")
1996        };
1997        assert!(matches!(
1998            line.orientation,
1999            ReferenceLineOrientation::Vertical
2000        ));
2001
2002        let ScenePlot::ReferenceLine { orientation, .. } = &mut scene.plots[0] else {
2003            panic!("expected reference line scene plot")
2004        };
2005        *orientation = "diagonal".into();
2006
2007        let err = scene
2008            .into_figure()
2009            .expect_err("unknown orientation must fail");
2010        assert!(err.contains("unknown reference line orientation 'diagonal'"));
2011    }
2012
2013    #[test]
2014    fn figure_scene_roundtrip_reconstructs_surface_and_scatter3() {
2015        let mut figure = Figure::new().with_title("Replay3D").with_subplot_grid(1, 2);
2016        let mut surface = SurfacePlot::new(
2017            vec![0.0, 1.0],
2018            vec![0.0, 1.0],
2019            vec![vec![0.0, 1.0], vec![1.0, 2.0]],
2020        )
2021        .expect("surface data should be valid");
2022        surface.label = Some("surface".to_string());
2023        figure.add_surface_plot_on_axes(surface, 0);
2024
2025        let mut scatter3 = Scatter3Plot::new(vec![
2026            Vec3::new(0.0, 0.0, 0.0),
2027            Vec3::new(1.0, 2.0, 3.0),
2028            Vec3::new(2.0, 3.0, 4.0),
2029        ])
2030        .expect("scatter3 data should be valid");
2031        scatter3.label = Some("scatter3".to_string());
2032        figure.add_scatter3_plot_on_axes(scatter3, 1);
2033
2034        let scene = FigureScene::capture(&figure);
2035        let rebuilt = scene.into_figure().expect("scene restore should succeed");
2036        assert_eq!(rebuilt.axes_grid(), (1, 2));
2037        assert_eq!(rebuilt.plots().count(), 2);
2038        assert_eq!(rebuilt.title.as_deref(), Some("Replay3D"));
2039        assert!(matches!(
2040            rebuilt.plots().next(),
2041            Some(PlotElement::Surface(_))
2042        ));
2043        assert!(matches!(
2044            rebuilt.plots().nth(1),
2045            Some(PlotElement::Scatter3(_))
2046        ));
2047    }
2048
2049    #[test]
2050    fn figure_scene_roundtrip_preserves_line3_plot() {
2051        let mut figure = Figure::new();
2052        let line3 = Line3Plot::new(vec![0.0, 1.0], vec![1.0, 2.0], vec![2.0, 3.0])
2053            .unwrap()
2054            .with_label("Trajectory");
2055        figure.add_line3_plot(line3);
2056
2057        let rebuilt = FigureScene::capture(&figure)
2058            .into_figure()
2059            .expect("scene restore should succeed");
2060
2061        let PlotElement::Line3(line3) = rebuilt.plots().next().unwrap() else {
2062            panic!("expected line3")
2063        };
2064        assert_eq!(line3.x_data, vec![0.0, 1.0]);
2065        assert_eq!(line3.z_data, vec![2.0, 3.0]);
2066        assert_eq!(line3.label.as_deref(), Some("Trajectory"));
2067    }
2068
2069    #[test]
2070    fn figure_scene_roundtrip_preserves_contour_and_fill_plots() {
2071        let mut figure = Figure::new();
2072        let bounds = BoundingBox::new(Vec3::new(-1.0, -2.0, 0.0), Vec3::new(3.0, 4.0, 0.0));
2073        let vertices = vec![Vertex {
2074            position: [0.0, 0.0, 0.0],
2075            color: [1.0, 0.0, 0.0, 1.0],
2076            normal: [0.0, 0.0, 1.0],
2077            tex_coords: [0.0, 0.0],
2078        }];
2079        let fill = ContourFillPlot::from_vertices(vertices.clone(), bounds).with_label("fill");
2080        let contour = ContourPlot::from_vertices(vertices, 0.0, bounds)
2081            .with_label("lines")
2082            .with_line_width(2.0);
2083        figure.add_contour_fill_plot(fill);
2084        figure.add_contour_plot(contour);
2085
2086        let rebuilt = FigureScene::capture(&figure)
2087            .into_figure()
2088            .expect("scene restore should succeed");
2089        assert!(matches!(
2090            rebuilt.plots().next(),
2091            Some(PlotElement::ContourFill(_))
2092        ));
2093        let Some(PlotElement::Contour(contour)) = rebuilt.plots().nth(1) else {
2094            panic!("expected contour")
2095        };
2096        assert_eq!(contour.line_width, 2.0);
2097    }
2098
2099    #[test]
2100    fn figure_scene_roundtrip_preserves_stem_style_surface() {
2101        let mut figure = Figure::new();
2102        let mut stem = StemPlot::new(vec![0.0, 1.0], vec![1.0, 2.0])
2103            .unwrap()
2104            .with_style(
2105                Vec4::new(1.0, 0.0, 0.0, 1.0),
2106                2.0,
2107                crate::plots::line::LineStyle::Dashed,
2108                -1.0,
2109            )
2110            .with_baseline_style(Vec4::new(0.0, 0.0, 0.0, 1.0), false)
2111            .with_label("Impulse");
2112        stem.set_marker(Some(crate::plots::line::LineMarkerAppearance {
2113            kind: crate::plots::scatter::MarkerStyle::Square,
2114            size: 8.0,
2115            edge_color: Vec4::new(0.0, 0.0, 0.0, 1.0),
2116            face_color: Vec4::new(1.0, 0.0, 0.0, 1.0),
2117            filled: true,
2118        }));
2119        figure.add_stem_plot(stem);
2120
2121        let rebuilt = FigureScene::capture(&figure)
2122            .into_figure()
2123            .expect("scene restore should succeed");
2124        let PlotElement::Stem(stem) = rebuilt.plots().next().unwrap() else {
2125            panic!("expected stem")
2126        };
2127        assert_eq!(stem.baseline, -1.0);
2128        assert_eq!(stem.line_width, 2.0);
2129        assert_eq!(stem.label.as_deref(), Some("Impulse"));
2130        assert!(!stem.baseline_visible);
2131        assert!(stem.marker.as_ref().map(|m| m.filled).unwrap_or(false));
2132        assert_eq!(stem.marker.as_ref().map(|m| m.size), Some(8.0));
2133    }
2134
2135    #[test]
2136    fn figure_scene_roundtrip_preserves_bar_plot() {
2137        let mut figure = Figure::new();
2138        let bar = BarChart::new(vec!["A".into(), "B".into()], vec![2.0, 3.5])
2139            .unwrap()
2140            .with_style(Vec4::new(0.2, 0.4, 0.8, 1.0), 0.95)
2141            .with_outline(Vec4::new(0.1, 0.1, 0.1, 1.0), 1.5)
2142            .with_label("Histogram")
2143            .with_stack_offsets(vec![1.0, 0.5]);
2144        figure.add_bar_chart(bar);
2145
2146        let rebuilt = FigureScene::capture(&figure)
2147            .into_figure()
2148            .expect("scene restore should succeed");
2149        let PlotElement::Bar(bar) = rebuilt.plots().next().unwrap() else {
2150            panic!("expected bar")
2151        };
2152        assert_eq!(bar.labels, vec!["A", "B"]);
2153        assert_eq!(bar.values().unwrap_or(&[]), &[2.0, 3.5]);
2154        assert_eq!(bar.bar_width, 0.95);
2155        assert_eq!(bar.outline_width, 1.5);
2156        assert_eq!(bar.label.as_deref(), Some("Histogram"));
2157        assert_eq!(bar.stack_offsets().unwrap_or(&[]), &[1.0, 0.5]);
2158        assert!(bar.histogram_bin_edges().is_none());
2159    }
2160
2161    #[test]
2162    fn figure_scene_roundtrip_preserves_histogram_bin_edges() {
2163        let mut figure = Figure::new();
2164        let mut bar = BarChart::new(vec!["bin1".into(), "bin2".into()], vec![4.0, 5.0]).unwrap();
2165        bar.set_histogram_bin_edges(vec![0.0, 0.5, 1.0]);
2166        figure.add_bar_chart(bar);
2167
2168        let rebuilt = FigureScene::capture(&figure)
2169            .into_figure()
2170            .expect("scene restore should succeed");
2171        let PlotElement::Bar(bar) = rebuilt.plots().next().unwrap() else {
2172            panic!("expected bar")
2173        };
2174        assert_eq!(bar.histogram_bin_edges().unwrap_or(&[]), &[0.0, 0.5, 1.0]);
2175    }
2176
2177    #[test]
2178    fn figure_scene_roundtrip_preserves_errorbar_style_surface() {
2179        let mut figure = Figure::new();
2180        let mut error = ErrorBar::new_vertical(
2181            vec![0.0, 1.0],
2182            vec![1.0, 2.0],
2183            vec![0.1, 0.2],
2184            vec![0.2, 0.3],
2185        )
2186        .unwrap()
2187        .with_style(
2188            Vec4::new(1.0, 0.0, 0.0, 1.0),
2189            2.0,
2190            crate::plots::line::LineStyle::Dashed,
2191            10.0,
2192        )
2193        .with_label("Err");
2194        error.set_marker(Some(crate::plots::line::LineMarkerAppearance {
2195            kind: crate::plots::scatter::MarkerStyle::Triangle,
2196            size: 8.0,
2197            edge_color: Vec4::new(0.0, 0.0, 0.0, 1.0),
2198            face_color: Vec4::new(1.0, 0.0, 0.0, 1.0),
2199            filled: true,
2200        }));
2201        figure.add_errorbar(error);
2202
2203        let rebuilt = FigureScene::capture(&figure)
2204            .into_figure()
2205            .expect("scene restore should succeed");
2206        let PlotElement::ErrorBar(error) = rebuilt.plots().next().unwrap() else {
2207            panic!("expected errorbar")
2208        };
2209        assert_eq!(error.line_width, 2.0);
2210        assert_eq!(error.cap_size, 10.0);
2211        assert_eq!(error.label.as_deref(), Some("Err"));
2212        assert_eq!(error.line_style, crate::plots::line::LineStyle::Dashed);
2213        assert!(error.marker.as_ref().map(|m| m.filled).unwrap_or(false));
2214    }
2215
2216    #[test]
2217    fn figure_scene_roundtrip_preserves_errorbar_both_direction() {
2218        let mut figure = Figure::new();
2219        let error = ErrorBar::new_both(
2220            vec![1.0, 2.0],
2221            vec![3.0, 4.0],
2222            vec![0.1, 0.2],
2223            vec![0.2, 0.3],
2224            vec![0.3, 0.4],
2225            vec![0.4, 0.5],
2226        )
2227        .unwrap();
2228        figure.add_errorbar(error);
2229        let rebuilt = FigureScene::capture(&figure)
2230            .into_figure()
2231            .expect("scene restore should succeed");
2232        let PlotElement::ErrorBar(error) = rebuilt.plots().next().unwrap() else {
2233            panic!("expected errorbar")
2234        };
2235        assert_eq!(
2236            error.orientation,
2237            crate::plots::errorbar::ErrorBarOrientation::Both
2238        );
2239        assert_eq!(error.x_neg, vec![0.1, 0.2]);
2240        assert_eq!(error.x_pos, vec![0.2, 0.3]);
2241    }
2242
2243    #[test]
2244    fn figure_scene_roundtrip_preserves_quiver_plot() {
2245        let mut figure = Figure::new();
2246        let quiver = QuiverPlot::new(
2247            vec![0.0, 1.0],
2248            vec![1.0, 2.0],
2249            vec![0.5, -0.5],
2250            vec![1.0, 0.25],
2251        )
2252        .unwrap()
2253        .with_style(Vec4::new(0.2, 0.3, 0.4, 1.0), 2.0, 1.5, 0.2)
2254        .with_label("Field");
2255        figure.add_quiver_plot(quiver);
2256
2257        let rebuilt = FigureScene::capture(&figure)
2258            .into_figure()
2259            .expect("scene restore should succeed");
2260        let PlotElement::Quiver(quiver) = rebuilt.plots().next().unwrap() else {
2261            panic!("expected quiver")
2262        };
2263        assert_eq!(quiver.u, vec![0.5, -0.5]);
2264        assert_eq!(quiver.v, vec![1.0, 0.25]);
2265        assert_eq!(quiver.line_width, 2.0);
2266        assert_eq!(quiver.scale, 1.5);
2267        assert_eq!(quiver.head_size, 0.2);
2268        assert_eq!(quiver.label.as_deref(), Some("Field"));
2269    }
2270
2271    #[test]
2272    fn figure_scene_roundtrip_preserves_image_surface_mode_and_color_grid() {
2273        let mut figure = Figure::new();
2274        let surface = SurfacePlot::new(
2275            vec![0.0, 1.0],
2276            vec![0.0, 1.0],
2277            vec![vec![0.0, 0.0], vec![0.0, 0.0]],
2278        )
2279        .unwrap()
2280        .with_flatten_z(true)
2281        .with_image_mode(true)
2282        .with_color_grid(vec![
2283            vec![Vec4::new(1.0, 0.0, 0.0, 1.0), Vec4::new(0.0, 1.0, 0.0, 1.0)],
2284            vec![Vec4::new(0.0, 0.0, 1.0, 1.0), Vec4::new(1.0, 1.0, 1.0, 1.0)],
2285        ]);
2286        figure.add_surface_plot(surface);
2287
2288        let rebuilt = FigureScene::capture(&figure)
2289            .into_figure()
2290            .expect("scene restore should succeed");
2291        let PlotElement::Surface(surface) = rebuilt.plots().next().unwrap() else {
2292            panic!("expected surface")
2293        };
2294        assert!(surface.flatten_z);
2295        assert!(surface.image_mode);
2296        assert!(surface.color_grid.is_some());
2297        assert_eq!(
2298            surface.color_grid.as_ref().unwrap()[0][0],
2299            Vec4::new(1.0, 0.0, 0.0, 1.0)
2300        );
2301    }
2302
2303    #[test]
2304    fn figure_scene_roundtrip_preserves_area_lower_curve() {
2305        let mut figure = Figure::new();
2306        let area = AreaPlot::new(vec![1.0, 2.0], vec![2.0, 3.0])
2307            .unwrap()
2308            .with_lower_curve(vec![0.5, 1.0])
2309            .with_label("Stacked");
2310        figure.add_area_plot(area);
2311
2312        let rebuilt = FigureScene::capture(&figure)
2313            .into_figure()
2314            .expect("scene restore should succeed");
2315        let PlotElement::Area(area) = rebuilt.plots().next().unwrap() else {
2316            panic!("expected area")
2317        };
2318        assert_eq!(area.lower_y, Some(vec![0.5, 1.0]));
2319        assert_eq!(area.label.as_deref(), Some("Stacked"));
2320    }
2321
2322    #[test]
2323    fn figure_scene_roundtrip_preserves_axes_local_limits_and_colormap_state() {
2324        let mut figure = Figure::new().with_subplot_grid(1, 2);
2325        figure.set_axes_limits(1, Some((1.0, 2.0)), Some((3.0, 4.0)));
2326        figure.set_axes_z_limits(1, Some((5.0, 6.0)));
2327        figure.set_axes_grid_enabled(1, false);
2328        figure.set_axes_minor_grid_enabled(1, true);
2329        figure.set_axes_box_enabled(1, false);
2330        figure.set_axes_axis_equal(1, true);
2331        figure.set_axes_colorbar_enabled(1, true);
2332        figure.set_axes_colormap(1, ColorMap::Hot);
2333        figure.set_axes_color_limits(1, Some((0.0, 10.0)));
2334        figure.set_axes_style(
2335            1,
2336            TextStyle {
2337                font_size: Some(14.0),
2338                ..Default::default()
2339            },
2340        );
2341        figure.set_active_axes_index(1);
2342
2343        let rebuilt = FigureScene::capture(&figure)
2344            .into_figure()
2345            .expect("scene restore should succeed");
2346        let meta = rebuilt.axes_metadata(1).unwrap();
2347        assert_eq!(meta.x_limits, Some((1.0, 2.0)));
2348        assert_eq!(meta.y_limits, Some((3.0, 4.0)));
2349        assert_eq!(meta.z_limits, Some((5.0, 6.0)));
2350        assert!(!meta.grid_enabled);
2351        assert!(meta.minor_grid_enabled);
2352        assert!(meta.minor_grid_explicit);
2353        assert!(!meta.box_enabled);
2354        assert!(meta.axis_equal);
2355        assert!(meta.colorbar_enabled);
2356        assert_eq!(format!("{:?}", meta.colormap), "Hot");
2357        assert_eq!(meta.color_limits, Some((0.0, 10.0)));
2358        assert_eq!(meta.axes_style.font_size, Some(14.0));
2359    }
2360
2361    #[test]
2362    fn axes_metadata_deserializes_without_axes_style() {
2363        let json = r#"{
2364            "legendEnabled": true,
2365            "colormap": "Parula",
2366            "titleStyle": {"visible": true},
2367            "xLabelStyle": {"visible": true},
2368            "yLabelStyle": {"visible": true},
2369            "zLabelStyle": {"visible": true},
2370            "legendStyle": {"visible": true}
2371        }"#;
2372        let serialized: SerializedAxesMetadata = serde_json::from_str(json).unwrap();
2373        let metadata = AxesMetadata::from(serialized);
2374        assert!(metadata.axes_style.color.is_none());
2375        assert!(metadata.axes_style.font_size.is_none());
2376        assert!(metadata.axes_style.font_weight.is_none());
2377        assert!(metadata.axes_style.font_angle.is_none());
2378        assert!(metadata.axes_style.interpreter.is_none());
2379        assert!(metadata.axes_style.visible);
2380    }
2381
2382    #[test]
2383    fn figure_scene_roundtrip_preserves_axes_local_annotation_metadata() {
2384        let mut figure = Figure::new().with_subplot_grid(1, 2);
2385        figure.set_sg_title("All Panels");
2386        figure.set_sg_title_style(TextStyle {
2387            font_weight: Some("bold".into()),
2388            font_size: Some(20.0),
2389            ..Default::default()
2390        });
2391        figure.set_active_axes_index(0);
2392        figure.set_axes_title(0, "Left");
2393        figure.set_axes_xlabel(0, "LX");
2394        figure.set_axes_ylabel(0, "LY");
2395        figure.set_axes_legend_enabled(0, false);
2396        figure.set_axes_title(1, "Right");
2397        figure.set_axes_xlabel(1, "RX");
2398        figure.set_axes_ylabel(1, "RY");
2399        figure.set_axes_legend_enabled(1, true);
2400        figure.set_axes_legend_style(
2401            1,
2402            LegendStyle {
2403                location: Some("northeast".into()),
2404                font_weight: Some("bold".into()),
2405                orientation: Some("horizontal".into()),
2406                ..Default::default()
2407            },
2408        );
2409        if let Some(meta) = figure.axes_metadata.get_mut(0) {
2410            meta.title_style.font_weight = Some("bold".into());
2411            meta.title_style.font_angle = Some("italic".into());
2412        }
2413        figure.set_active_axes_index(1);
2414
2415        let rebuilt = FigureScene::capture(&figure)
2416            .into_figure()
2417            .expect("scene restore should succeed");
2418
2419        assert_eq!(rebuilt.active_axes_index, 1);
2420        assert_eq!(rebuilt.sg_title.as_deref(), Some("All Panels"));
2421        assert_eq!(rebuilt.sg_title_style.font_weight.as_deref(), Some("bold"));
2422        assert_eq!(rebuilt.sg_title_style.font_size, Some(20.0));
2423        assert_eq!(
2424            rebuilt.axes_metadata(0).and_then(|m| m.title.as_deref()),
2425            Some("Left")
2426        );
2427        assert_eq!(
2428            rebuilt.axes_metadata(0).and_then(|m| m.x_label.as_deref()),
2429            Some("LX")
2430        );
2431        assert_eq!(
2432            rebuilt.axes_metadata(0).and_then(|m| m.y_label.as_deref()),
2433            Some("LY")
2434        );
2435        assert!(!rebuilt.axes_metadata(0).unwrap().legend_enabled);
2436        assert_eq!(
2437            rebuilt
2438                .axes_metadata(0)
2439                .unwrap()
2440                .title_style
2441                .font_weight
2442                .as_deref(),
2443            Some("bold")
2444        );
2445        assert_eq!(
2446            rebuilt
2447                .axes_metadata(0)
2448                .unwrap()
2449                .title_style
2450                .font_angle
2451                .as_deref(),
2452            Some("italic")
2453        );
2454        assert_eq!(
2455            rebuilt.axes_metadata(1).and_then(|m| m.title.as_deref()),
2456            Some("Right")
2457        );
2458        assert_eq!(
2459            rebuilt.axes_metadata(1).and_then(|m| m.x_label.as_deref()),
2460            Some("RX")
2461        );
2462        assert_eq!(
2463            rebuilt.axes_metadata(1).and_then(|m| m.y_label.as_deref()),
2464            Some("RY")
2465        );
2466        assert_eq!(
2467            rebuilt
2468                .axes_metadata(1)
2469                .unwrap()
2470                .legend_style
2471                .location
2472                .as_deref(),
2473            Some("northeast")
2474        );
2475        assert_eq!(
2476            rebuilt
2477                .axes_metadata(1)
2478                .unwrap()
2479                .legend_style
2480                .font_weight
2481                .as_deref(),
2482            Some("bold")
2483        );
2484        assert_eq!(
2485            rebuilt
2486                .axes_metadata(1)
2487                .unwrap()
2488                .legend_style
2489                .orientation
2490                .as_deref(),
2491            Some("horizontal")
2492        );
2493    }
2494
2495    #[test]
2496    fn figure_scene_roundtrip_preserves_axes_local_log_modes() {
2497        let mut figure = Figure::new().with_subplot_grid(1, 2);
2498        figure.set_axes_log_modes(0, true, false);
2499        figure.set_axes_log_modes(1, false, true);
2500        figure.set_active_axes_index(1);
2501
2502        let rebuilt = FigureScene::capture(&figure)
2503            .into_figure()
2504            .expect("scene restore should succeed");
2505
2506        assert!(rebuilt.axes_metadata(0).unwrap().x_log);
2507        assert!(!rebuilt.axes_metadata(0).unwrap().y_log);
2508        assert!(!rebuilt.axes_metadata(1).unwrap().x_log);
2509        assert!(rebuilt.axes_metadata(1).unwrap().y_log);
2510        assert!(!rebuilt.x_log);
2511        assert!(rebuilt.y_log);
2512    }
2513
2514    #[test]
2515    fn figure_scene_roundtrip_preserves_zlabel_and_view_state() {
2516        let mut figure = Figure::new().with_subplot_grid(1, 2);
2517        figure.set_axes_zlabel(1, "Height");
2518        figure.set_axes_view(1, 45.0, 20.0);
2519        figure.set_active_axes_index(1);
2520
2521        let rebuilt = FigureScene::capture(&figure)
2522            .into_figure()
2523            .expect("scene restore should succeed");
2524
2525        assert_eq!(
2526            rebuilt.axes_metadata(1).unwrap().z_label.as_deref(),
2527            Some("Height")
2528        );
2529        assert_eq!(
2530            rebuilt.axes_metadata(1).unwrap().view_azimuth_deg,
2531            Some(45.0)
2532        );
2533        assert_eq!(
2534            rebuilt.axes_metadata(1).unwrap().view_elevation_deg,
2535            Some(20.0)
2536        );
2537        assert_eq!(rebuilt.z_label.as_deref(), Some("Height"));
2538    }
2539
2540    #[test]
2541    fn figure_scene_roundtrip_preserves_pie_metadata() {
2542        let mut figure = Figure::new();
2543        let pie = crate::plots::PieChart::new(vec![1.0, 2.0], None)
2544            .unwrap()
2545            .with_slice_labels(vec!["A".into(), "B".into()])
2546            .with_explode(vec![false, true]);
2547        figure.add_pie_chart(pie);
2548
2549        let rebuilt = FigureScene::capture(&figure)
2550            .into_figure()
2551            .expect("scene restore should succeed");
2552        let crate::plots::figure::PlotElement::Pie(pie) = rebuilt.plots().next().unwrap() else {
2553            panic!("expected pie")
2554        };
2555        assert_eq!(pie.slice_labels, vec!["A", "B"]);
2556        assert_eq!(pie.explode, vec![false, true]);
2557    }
2558
2559    #[test]
2560    fn scene_plot_deserialize_maps_null_numeric_values_to_nan() {
2561        let json = r#"{
2562          "schemaVersion": 1,
2563          "layout": { "axesRows": 1, "axesCols": 1, "axesIndices": [0] },
2564          "metadata": {
2565            "gridEnabled": true,
2566            "legendEnabled": false,
2567            "colorbarEnabled": false,
2568            "axisEqual": false,
2569            "backgroundRgba": [1,1,1,1],
2570            "legendEntries": []
2571          },
2572          "plots": [
2573            {
2574              "kind": "surface",
2575              "x": [0.0, null],
2576              "y": [0.0, 1.0],
2577              "z": [[0.0, null], [1.0, 2.0]],
2578              "colormap": "Parula",
2579              "shading_mode": "Smooth",
2580              "wireframe": false,
2581              "alpha": 1.0,
2582              "flatten_z": false,
2583              "color_limits": null,
2584              "axes_index": 0,
2585              "label": null,
2586              "visible": true
2587            }
2588          ]
2589        }"#;
2590        let scene: FigureScene = serde_json::from_str(json).expect("scene should deserialize");
2591        let ScenePlot::Surface { x, z, .. } = &scene.plots[0] else {
2592            panic!("expected surface plot");
2593        };
2594        assert!(x[1].is_nan());
2595        assert!(z[0][1].is_nan());
2596    }
2597
2598    #[test]
2599    fn scene_plot_deserialize_maps_null_scatter3_components_to_nan() {
2600        let json = r#"{
2601          "schemaVersion": 1,
2602          "layout": { "axesRows": 1, "axesCols": 1, "axesIndices": [0] },
2603          "metadata": {
2604            "gridEnabled": true,
2605            "legendEnabled": false,
2606            "colorbarEnabled": false,
2607            "axisEqual": false,
2608            "backgroundRgba": [1,1,1,1],
2609            "legendEntries": []
2610          },
2611          "plots": [
2612            {
2613              "kind": "scatter3",
2614              "points": [[0.0, 1.0, null], [1.0, null, 2.0]],
2615              "colors_rgba": [[0.2, 0.4, 0.6, 1.0], [0.1, 0.2, 0.3, 1.0]],
2616              "point_size": 6.0,
2617              "point_sizes": [3.0, null],
2618              "axes_index": 0,
2619              "label": null,
2620              "visible": true
2621            }
2622          ]
2623        }"#;
2624        let scene: FigureScene = serde_json::from_str(json).expect("scene should deserialize");
2625        let ScenePlot::Scatter3 {
2626            points,
2627            point_sizes,
2628            ..
2629        } = &scene.plots[0]
2630        else {
2631            panic!("expected scatter3 plot");
2632        };
2633        assert!(points[0][2].is_nan());
2634        assert!(points[1][1].is_nan());
2635        assert!(point_sizes.as_ref().unwrap()[1].is_nan());
2636    }
2637}