Skip to main content

runmat_plot/
event.rs

1use crate::core::{BoundingBox, Vertex};
2use crate::plots::{
3    AreaPlot, AxesKind, AxesMetadata, BarChart, ColorMap, ContourFillPlot, ContourPlot, ErrorBar,
4    Figure, LegendEntry, LegendStyle, Line3Plot, LinePlot, MarkerStyle, MeshDeformation,
5    MeshEdgeMode, MeshFieldLocation, MeshPlot, MeshRegion, MeshScalarField, MeshTriangleRange,
6    MeshVectorField, PatchEdgeColorMode, PatchFaceColorMode, PatchPlot, PlotElement, PlotType,
7    QuiverPlot, ReferenceLine, ReferenceLineOrientation, Scatter3Plot, ScatterPlot, ShadingMode,
8    StairsPlot, StemPlot, SurfacePlot, TextStyle,
9};
10use glam::{Vec3, Vec4};
11use serde::{Deserialize, Serialize};
12use std::{error::Error, fmt};
13
14/// High-level event emitted whenever a figure changes.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub struct FigureEvent {
18    pub handle: u32,
19    pub kind: FigureEventKind,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub fingerprint: Option<String>,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub figure: Option<FigureSnapshot>,
24}
25
26/// Event kind for figure lifecycle + updates.
27#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
28#[serde(rename_all = "lowercase")]
29pub enum FigureEventKind {
30    Created,
31    Updated,
32    Cleared,
33    Closed,
34}
35
36/// Snapshot of the figure state describing layout + plots.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(rename_all = "camelCase")]
39pub struct FigureSnapshot {
40    pub layout: FigureLayout,
41    pub metadata: FigureMetadata,
42    pub plots: Vec<PlotDescriptor>,
43}
44
45/// Full replay scene payload capable of reconstructing a figure.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct FigureScene {
49    pub schema_version: u32,
50    pub layout: FigureLayout,
51    pub metadata: FigureMetadata,
52    pub plots: Vec<ScenePlot>,
53}
54
55pub const DEFAULT_FIGURE_SCENE_EXPORT_BUDGET_BYTES: usize = 8 * 1024 * 1024;
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub struct SceneExportPolicy {
59    pub max_scene_bytes: usize,
60}
61
62impl Default for SceneExportPolicy {
63    fn default() -> Self {
64        Self {
65            max_scene_bytes: DEFAULT_FIGURE_SCENE_EXPORT_BUDGET_BYTES,
66        }
67    }
68}
69
70pub fn resolve_scene_export_policy(max_scene_bytes: Option<usize>) -> SceneExportPolicy {
71    SceneExportPolicy {
72        max_scene_bytes: max_scene_bytes
73            .filter(|bytes| *bytes > 0)
74            .unwrap_or(DEFAULT_FIGURE_SCENE_EXPORT_BUDGET_BYTES),
75    }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum SceneExportErrorKind {
80    BudgetExceeded,
81    UnexportableGpuData,
82    Serialization,
83    Readback,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct SceneExportError {
88    pub kind: SceneExportErrorKind,
89    pub message: String,
90}
91
92impl SceneExportError {
93    fn budget_exceeded(used: usize, added: usize, max: usize) -> Self {
94        Self {
95            kind: SceneExportErrorKind::BudgetExceeded,
96            message: format!(
97                "figure scene export exceeds budget: {} + {} bytes > {} bytes",
98                used, added, max
99            ),
100        }
101    }
102
103    fn unexportable(message: impl Into<String>) -> Self {
104        Self {
105            kind: SceneExportErrorKind::UnexportableGpuData,
106            message: message.into(),
107        }
108    }
109
110    fn serialization(message: impl Into<String>) -> Self {
111        Self {
112            kind: SceneExportErrorKind::Serialization,
113            message: message.into(),
114        }
115    }
116
117    fn readback(message: impl Into<String>) -> Self {
118        Self {
119            kind: SceneExportErrorKind::Readback,
120            message: message.into(),
121        }
122    }
123}
124
125impl fmt::Display for SceneExportError {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        f.write_str(&self.message)
128    }
129}
130
131impl Error for SceneExportError {}
132
133#[derive(Debug, Clone, Copy)]
134struct SceneExportBudget {
135    max_bytes: usize,
136    used_bytes: usize,
137}
138
139impl SceneExportBudget {
140    fn new(policy: SceneExportPolicy) -> Self {
141        Self {
142            max_bytes: policy.max_scene_bytes,
143            used_bytes: 0,
144        }
145    }
146
147    fn reserve_plot(&mut self, plot: &ScenePlot) -> Result<(), SceneExportError> {
148        let bytes = serde_json::to_vec(plot)
149            .map_err(|err| SceneExportError::serialization(err.to_string()))?;
150        self.reserve_bytes(bytes.len())
151    }
152
153    fn reserve_bytes(&mut self, byte_len: usize) -> Result<(), SceneExportError> {
154        let next = self.used_bytes.saturating_add(byte_len);
155        if next > self.max_bytes {
156            return Err(SceneExportError::budget_exceeded(
157                self.used_bytes,
158                byte_len,
159                self.max_bytes,
160            ));
161        }
162        self.used_bytes = next;
163        Ok(())
164    }
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
168#[serde(tag = "kind", rename_all = "snake_case")]
169pub enum ScenePlot {
170    Line {
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        color_rgba: [f32; 4],
176        line_width: f32,
177        line_style: String,
178        axes_index: u32,
179        label: Option<String>,
180        visible: bool,
181    },
182    ReferenceLine {
183        orientation: String,
184        #[serde(deserialize_with = "deserialize_f64_lossy")]
185        value: f64,
186        color_rgba: [f32; 4],
187        line_width: f32,
188        line_style: String,
189        label: Option<String>,
190        display_name: Option<String>,
191        label_orientation: String,
192        axes_index: u32,
193        visible: bool,
194    },
195    Scatter {
196        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
197        x: Vec<f64>,
198        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
199        y: Vec<f64>,
200        color_rgba: [f32; 4],
201        marker_size: f32,
202        marker_style: String,
203        axes_index: u32,
204        label: Option<String>,
205        visible: bool,
206    },
207    Bar {
208        labels: Vec<String>,
209        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
210        values: Vec<f64>,
211        #[serde(default, deserialize_with = "deserialize_option_vec_f64_lossy")]
212        histogram_bin_edges: Option<Vec<f64>>,
213        #[serde(default)]
214        polar_histogram: bool,
215        #[serde(default)]
216        polar_histogram_display_style: Option<String>,
217        color_rgba: [f32; 4],
218        #[serde(default)]
219        outline_color_rgba: Option<[f32; 4]>,
220        bar_width: f32,
221        outline_width: f32,
222        orientation: String,
223        group_index: u32,
224        group_count: u32,
225        #[serde(default, deserialize_with = "deserialize_option_vec_f64_lossy")]
226        stack_offsets: Option<Vec<f64>>,
227        axes_index: u32,
228        label: Option<String>,
229        visible: bool,
230    },
231    ErrorBar {
232        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
233        x: Vec<f64>,
234        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
235        y: Vec<f64>,
236        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
237        err_low: Vec<f64>,
238        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
239        err_high: Vec<f64>,
240        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
241        x_err_low: Vec<f64>,
242        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
243        x_err_high: Vec<f64>,
244        orientation: String,
245        color_rgba: [f32; 4],
246        line_width: f32,
247        line_style: String,
248        cap_width: f32,
249        marker_style: Option<String>,
250        marker_size: Option<f32>,
251        marker_face_color: Option<[f32; 4]>,
252        marker_edge_color: Option<[f32; 4]>,
253        marker_filled: Option<bool>,
254        axes_index: u32,
255        label: Option<String>,
256        visible: bool,
257    },
258    Stairs {
259        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
260        x: Vec<f64>,
261        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
262        y: Vec<f64>,
263        color_rgba: [f32; 4],
264        line_width: f32,
265        axes_index: u32,
266        label: Option<String>,
267        visible: bool,
268    },
269    Stem {
270        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
271        x: Vec<f64>,
272        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
273        y: Vec<f64>,
274        #[serde(deserialize_with = "deserialize_f64_lossy")]
275        baseline: f64,
276        color_rgba: [f32; 4],
277        line_width: f32,
278        line_style: String,
279        baseline_color_rgba: [f32; 4],
280        baseline_visible: bool,
281        marker_color_rgba: [f32; 4],
282        marker_size: f32,
283        marker_filled: bool,
284        axes_index: u32,
285        label: Option<String>,
286        visible: bool,
287    },
288    Area {
289        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
290        x: Vec<f64>,
291        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
292        y: Vec<f64>,
293        #[serde(default, deserialize_with = "deserialize_option_vec_f64_lossy")]
294        lower_y: Option<Vec<f64>>,
295        #[serde(deserialize_with = "deserialize_f64_lossy")]
296        baseline: f64,
297        color_rgba: [f32; 4],
298        axes_index: u32,
299        label: Option<String>,
300        visible: bool,
301    },
302    Quiver {
303        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
304        x: Vec<f64>,
305        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
306        y: Vec<f64>,
307        #[serde(default, deserialize_with = "deserialize_option_vec_f64_lossy")]
308        z: Option<Vec<f64>>,
309        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
310        u: Vec<f64>,
311        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
312        v: Vec<f64>,
313        #[serde(default, deserialize_with = "deserialize_option_vec_f64_lossy")]
314        w: Option<Vec<f64>>,
315        color_rgba: [f32; 4],
316        line_width: f32,
317        scale: f32,
318        head_size: f32,
319        axes_index: u32,
320        label: Option<String>,
321        visible: bool,
322    },
323    Surface {
324        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
325        x: Vec<f64>,
326        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
327        y: Vec<f64>,
328        #[serde(deserialize_with = "deserialize_matrix_f64_lossy")]
329        z: Vec<Vec<f64>>,
330        #[serde(default)]
331        x_grid: Option<Vec<Vec<f64>>>,
332        #[serde(default)]
333        y_grid: Option<Vec<Vec<f64>>>,
334        colormap: String,
335        shading_mode: String,
336        wireframe: bool,
337        alpha: f32,
338        flatten_z: bool,
339        #[serde(default)]
340        image_mode: bool,
341        #[serde(default)]
342        color_grid_rgba: Option<Vec<Vec<[f32; 4]>>>,
343        #[serde(default, deserialize_with = "deserialize_option_pair_f64_lossy")]
344        color_limits: Option<[f64; 2]>,
345        axes_index: u32,
346        label: Option<String>,
347        visible: bool,
348    },
349    Patch {
350        #[serde(deserialize_with = "deserialize_vec_xyz_f32_lossy")]
351        vertices: Vec<[f32; 3]>,
352        faces: Vec<Vec<u32>>,
353        face_color_rgba: [f32; 4],
354        edge_color_rgba: [f32; 4],
355        face_color_mode: String,
356        edge_color_mode: String,
357        face_alpha: f32,
358        edge_alpha: f32,
359        line_width: f32,
360        axes_index: u32,
361        label: Option<String>,
362        visible: bool,
363        #[serde(default)]
364        force_3d: bool,
365    },
366    Mesh {
367        #[serde(deserialize_with = "deserialize_vec_xyz_f32_lossy")]
368        vertices: Vec<[f32; 3]>,
369        triangles: Vec<[u32; 3]>,
370        mesh_id: Option<String>,
371        face_color_rgba: [f32; 4],
372        edge_color_rgba: [f32; 4],
373        face_alpha: f32,
374        edge_alpha: f32,
375        edge_width: f32,
376        #[serde(default)]
377        edge_mode: String,
378        #[serde(default, skip_serializing_if = "Vec::is_empty")]
379        feature_edge_groups: Vec<u64>,
380        #[serde(default, skip_serializing_if = "Vec::is_empty")]
381        vertex_colors_rgba: Vec<[f32; 4]>,
382        #[serde(default, skip_serializing_if = "Vec::is_empty")]
383        triangle_colors_rgba: Vec<[f32; 4]>,
384        axes_index: u32,
385        label: Option<String>,
386        #[serde(default, skip_serializing_if = "Vec::is_empty")]
387        regions: Vec<SerializedMeshRegion>,
388        #[serde(default, skip_serializing_if = "Option::is_none")]
389        highlighted_region_id: Option<String>,
390        #[serde(default, skip_serializing_if = "Option::is_none")]
391        highlight_color_rgba: Option<[f32; 4]>,
392        #[serde(default, skip_serializing_if = "Option::is_none")]
393        scalar_field: Option<Box<SerializedMeshScalarField>>,
394        #[serde(default, skip_serializing_if = "Option::is_none")]
395        vector_field: Option<Box<SerializedMeshVectorField>>,
396        #[serde(default, skip_serializing_if = "Option::is_none")]
397        deformation: Option<Box<SerializedMeshDeformation>>,
398        visible: bool,
399    },
400    Line3 {
401        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
402        x: Vec<f64>,
403        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
404        y: Vec<f64>,
405        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
406        z: Vec<f64>,
407        color_rgba: [f32; 4],
408        line_width: f32,
409        line_style: String,
410        axes_index: u32,
411        label: Option<String>,
412        visible: bool,
413    },
414    Scatter3 {
415        #[serde(deserialize_with = "deserialize_vec_xyz_f32_lossy")]
416        points: Vec<[f32; 3]>,
417        #[serde(default, deserialize_with = "deserialize_vec_rgba_f32_lossy")]
418        colors_rgba: Vec<[f32; 4]>,
419        point_size: f32,
420        #[serde(default, deserialize_with = "deserialize_option_vec_f32_lossy")]
421        point_sizes: Option<Vec<f32>>,
422        axes_index: u32,
423        label: Option<String>,
424        visible: bool,
425    },
426    Contour {
427        vertices: Vec<SerializedVertex>,
428        bounds_min: [f32; 3],
429        bounds_max: [f32; 3],
430        base_z: f32,
431        line_width: f32,
432        axes_index: u32,
433        label: Option<String>,
434        visible: bool,
435        #[serde(default)]
436        force_3d: bool,
437    },
438    ContourFill {
439        vertices: Vec<SerializedVertex>,
440        bounds_min: [f32; 3],
441        bounds_max: [f32; 3],
442        axes_index: u32,
443        label: Option<String>,
444        visible: bool,
445    },
446    Pie {
447        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
448        values: Vec<f64>,
449        colors_rgba: Vec<[f32; 4]>,
450        slice_labels: Vec<String>,
451        label_format: Option<String>,
452        explode: Vec<bool>,
453        axes_index: u32,
454        label: Option<String>,
455        visible: bool,
456    },
457    Unsupported {
458        plot_kind: PlotKind,
459        axes_index: u32,
460        label: Option<String>,
461        visible: bool,
462    },
463}
464
465impl FigureSnapshot {
466    /// Capture a snapshot from a [`Figure`] reference.
467    pub fn capture(figure: &Figure) -> Self {
468        let (rows, cols) = figure.axes_grid();
469        let layout = FigureLayout {
470            axes_rows: rows as u32,
471            axes_cols: cols as u32,
472            axes_indices: figure
473                .plot_axes_indices()
474                .iter()
475                .map(|idx| *idx as u32)
476                .collect(),
477        };
478
479        let metadata = FigureMetadata::from_figure(figure);
480
481        let plots = figure
482            .plots()
483            .enumerate()
484            .map(|(idx, plot)| PlotDescriptor::from_plot(plot, figure_axis_index(figure, idx)))
485            .collect();
486
487        Self {
488            layout,
489            metadata,
490            plots,
491        }
492    }
493
494    pub fn fingerprint(&self) -> String {
495        const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
496        const FNV_PRIME: u64 = 0x100000001b3;
497
498        let bytes = serde_json::to_vec(self).unwrap_or_default();
499        let mut hash = FNV_OFFSET_BASIS;
500        for byte in bytes {
501            hash ^= u64::from(byte);
502            hash = hash.wrapping_mul(FNV_PRIME);
503        }
504        format!("fig:{hash:016x}")
505    }
506}
507
508impl FigureScene {
509    pub const SCHEMA_VERSION: u32 = 3;
510
511    pub fn capture(figure: &Figure) -> Self {
512        let snapshot = FigureSnapshot::capture(figure);
513        let plots = figure
514            .plots()
515            .enumerate()
516            .map(|(idx, plot)| ScenePlot::from_plot(plot, figure_axis_index(figure, idx)))
517            .collect();
518
519        Self {
520            schema_version: Self::SCHEMA_VERSION,
521            layout: snapshot.layout,
522            metadata: snapshot.metadata,
523            plots,
524        }
525    }
526
527    pub async fn capture_for_export(
528        figure: &Figure,
529        policy: SceneExportPolicy,
530    ) -> Result<Self, SceneExportError> {
531        let snapshot = FigureSnapshot::capture(figure);
532        let mut budget = SceneExportBudget::new(policy);
533        let mut plots = Vec::new();
534
535        for (idx, plot) in figure.plots().enumerate() {
536            let scene_plot =
537                ScenePlot::from_plot_for_export(plot, figure_axis_index(figure, idx)).await?;
538            budget.reserve_plot(&scene_plot)?;
539            plots.push(scene_plot);
540        }
541
542        Ok(Self {
543            schema_version: Self::SCHEMA_VERSION,
544            layout: snapshot.layout,
545            metadata: snapshot.metadata,
546            plots,
547        })
548    }
549
550    pub fn from_geometry_scene(scene: &crate::geometry_scene::GeometryScene) -> Self {
551        let mut figure = Figure::new()
552            .with_grid(scene.show_grid)
553            .with_legend(false)
554            .with_axis_equal(scene.axis_equal);
555        figure.title = scene.title.clone();
556        figure.x_label = Some("X".to_string());
557        figure.y_label = Some("Y".to_string());
558        figure.z_label = Some("Z".to_string());
559        figure.set_axes_view(0, -38.0, 24.0);
560        let snapshot = FigureSnapshot::capture(&figure);
561        let plots = scene
562            .chunks
563            .iter()
564            .filter_map(scene_chunk_to_mesh_plot)
565            .collect::<Vec<_>>();
566
567        Self {
568            schema_version: Self::SCHEMA_VERSION,
569            layout: FigureLayout {
570                axes_rows: 1,
571                axes_cols: 1,
572                axes_indices: vec![0; plots.len()],
573            },
574            metadata: snapshot.metadata,
575            plots,
576        }
577    }
578
579    pub fn into_figure(self) -> Result<Figure, String> {
580        self.validate_schema_version()?;
581
582        let mut figure = Figure::new();
583        figure.set_subplot_grid(
584            self.layout.axes_rows as usize,
585            self.layout.axes_cols as usize,
586        );
587        figure.active_axes_index = self.metadata.active_axes_index as usize;
588        if let Some(axes_metadata) = self.metadata.axes_metadata.clone() {
589            figure.axes_metadata = axes_metadata.into_iter().map(AxesMetadata::from).collect();
590            figure.set_active_axes_index(figure.active_axes_index);
591        } else {
592            figure.title = self.metadata.title;
593            figure.x_label = self.metadata.x_label;
594            figure.y_label = self.metadata.y_label;
595            figure.legend_enabled = self.metadata.legend_enabled;
596            figure.set_axes_data_aspect_ratio(
597                figure.active_axes_index,
598                self.metadata.data_aspect_ratio,
599                self.metadata.data_aspect_ratio_mode.clone(),
600            );
601            if self.metadata.axis_equal {
602                figure.set_axes_axis_equal(figure.active_axes_index, true);
603            }
604        }
605        figure.name = self.metadata.name;
606        figure.number_title = self.metadata.number_title;
607        figure.visible = self.metadata.visible;
608        if let Some(position) = self.metadata.position {
609            validate_figure_position(position)?;
610            figure.set_position(position);
611        }
612        figure.sg_title = self.metadata.sg_title;
613        figure.sg_title_style = self
614            .metadata
615            .sg_title_style
616            .map(TextStyle::from)
617            .unwrap_or_default();
618        figure.grid_enabled = self.metadata.grid_enabled;
619        figure.minor_grid_enabled = self.metadata.minor_grid_enabled;
620        figure.z_limits = self.metadata.z_limits.map(|[lo, hi]| (lo, hi));
621        figure.colorbar_enabled = self.metadata.colorbar_enabled;
622        figure.axis_equal = self.metadata.axis_equal;
623        figure.background_color = rgba_to_vec4(self.metadata.background_rgba);
624
625        for plot in self.plots {
626            plot.apply_to_figure(&mut figure)?;
627        }
628
629        Ok(figure)
630    }
631
632    pub fn into_geometry_scene(
633        self,
634        scene_id: impl Into<String>,
635        revision: u64,
636    ) -> Result<crate::GeometryScene, String> {
637        self.validate_schema_version()?;
638        let scene_id = scene_id.into();
639        let mut chunks = Vec::new();
640        for (plot_index, plot) in self.plots.into_iter().enumerate() {
641            append_geometry_scene_chunks(&scene_id, plot_index, plot, &mut chunks)?;
642        }
643        if chunks.is_empty() {
644            return Err("figure scene does not contain renderable mesh plots".to_string());
645        }
646        let mut scene = crate::GeometryScene::new(scene_id, revision, chunks).with_title(
647            self.metadata
648                .title
649                .unwrap_or_else(|| "Geometry Preview".to_string()),
650        );
651        scene.show_grid = self.metadata.grid_enabled;
652        scene.axis_equal = self.metadata.axis_equal;
653        Ok(scene)
654    }
655
656    fn validate_schema_version(&self) -> Result<(), String> {
657        if self.schema_version == 0 || self.schema_version > FigureScene::SCHEMA_VERSION {
658            return Err(format!(
659                "unsupported figure scene schema version {} (supported 1..={})",
660                self.schema_version,
661                FigureScene::SCHEMA_VERSION
662            ));
663        }
664        if self.schema_version < 2
665            && self
666                .plots
667                .iter()
668                .any(|plot| matches!(plot, ScenePlot::Patch { .. }))
669        {
670            return Err(format!(
671                "patch plots require figure scene schema version {}",
672                2
673            ));
674        }
675        if self.schema_version < 3
676            && self
677                .plots
678                .iter()
679                .any(|plot| matches!(plot, ScenePlot::Mesh { .. }))
680        {
681            return Err(format!(
682                "mesh plots require figure scene schema version {}",
683                3
684            ));
685        }
686        Ok(())
687    }
688}
689
690fn validate_figure_position(position: [f64; 4]) -> Result<(), String> {
691    if !position.iter().all(|value| value.is_finite()) {
692        return Err("figure scene metadata Position values must be finite".to_string());
693    }
694    if position[2] <= 0.0 || position[3] <= 0.0 {
695        return Err("figure scene metadata Position width and height must be positive".to_string());
696    }
697    Ok(())
698}
699
700fn append_geometry_scene_chunks(
701    scene_id: &str,
702    plot_index: usize,
703    plot: ScenePlot,
704    chunks: &mut Vec<crate::GeometrySceneChunk>,
705) -> Result<(), String> {
706    let ScenePlot::Mesh {
707        vertices,
708        triangles,
709        mesh_id,
710        face_color_rgba,
711        edge_color_rgba,
712        face_alpha,
713        edge_alpha,
714        edge_width,
715        edge_mode,
716        feature_edge_groups,
717        vertex_colors_rgba,
718        triangle_colors_rgba,
719        axes_index: _,
720        label,
721        regions,
722        highlighted_region_id,
723        highlight_color_rgba,
724        scalar_field,
725        vector_field,
726        deformation,
727        visible,
728    } = plot
729    else {
730        return Ok(());
731    };
732
733    if !visible {
734        return Ok(());
735    }
736
737    let region_metadata = regions
738        .iter()
739        .cloned()
740        .map(crate::geometry_scene::GeometrySceneRegion::from)
741        .collect::<Vec<_>>();
742    let mesh_id_for_chunk = mesh_id
743        .clone()
744        .unwrap_or_else(|| format!("mesh_{}", plot_index + 1));
745    let mut mesh = MeshPlot::new(vertices.into_iter().map(xyz_to_vec3).collect(), triangles)?;
746    mesh.set_mesh_id(mesh_id.clone());
747    mesh.set_face_color(rgba_to_vec4(face_color_rgba));
748    mesh.set_edge_color(rgba_to_vec4(edge_color_rgba));
749    mesh.set_face_alpha(face_alpha);
750    mesh.set_edge_alpha(edge_alpha);
751    mesh.set_edge_width(edge_width);
752    mesh.set_edge_mode(parse_mesh_edge_mode(&edge_mode));
753    if !feature_edge_groups.is_empty() {
754        mesh.set_feature_edge_groups(Some(feature_edge_groups))?;
755    }
756    if !vertex_colors_rgba.is_empty() {
757        mesh.set_vertex_colors(Some(
758            vertex_colors_rgba.into_iter().map(rgba_to_vec4).collect(),
759        ))?;
760    }
761    if !triangle_colors_rgba.is_empty() {
762        mesh.set_triangle_colors(Some(
763            triangle_colors_rgba.into_iter().map(rgba_to_vec4).collect(),
764        ))?;
765    }
766    mesh.set_label(label.clone());
767    mesh.set_regions(regions.into_iter().map(Into::into).collect());
768    mesh.set_highlighted_region_id(highlighted_region_id);
769    if let Some(color) = highlight_color_rgba {
770        mesh.set_highlight_color(rgba_to_vec4(color));
771    }
772    if let Some(field) = scalar_field {
773        mesh.set_scalar_field(Some((*field).try_into()?))?;
774    }
775    if let Some(field) = vector_field {
776        mesh.set_vector_field(Some((*field).try_into()?))?;
777    }
778    if let Some(field) = deformation {
779        mesh.set_deformation(Some((*field).into()))?;
780    }
781
782    let face_render_data = mesh.render_data();
783    chunks.push(
784        crate::GeometrySceneChunk::from_render_data(
785            format!("{scene_id}:{mesh_id_for_chunk}:faces:{plot_index}"),
786            face_render_data,
787        )
788        .with_mesh_id(mesh_id_for_chunk.clone())
789        .with_label(label.clone().unwrap_or_else(|| mesh_id_for_chunk.clone()))
790        .with_regions(region_metadata),
791    );
792
793    if let Some(edge_render_data) = mesh.edge_render_data() {
794        chunks.push(
795            crate::GeometrySceneChunk::from_render_data(
796                format!("{scene_id}:{mesh_id_for_chunk}:edges:{plot_index}"),
797                edge_render_data,
798            )
799            .with_mesh_id(mesh_id_for_chunk.clone())
800            .with_label(format!(
801                "{} edges",
802                label.clone().unwrap_or_else(|| mesh_id_for_chunk.clone())
803            )),
804        );
805    }
806
807    if let Some(vector_render_data) = mesh.vector_render_data() {
808        chunks.push(
809            crate::GeometrySceneChunk::from_render_data(
810                format!("{scene_id}:{mesh_id_for_chunk}:vectors:{plot_index}"),
811                vector_render_data,
812            )
813            .with_mesh_id(mesh_id_for_chunk.clone())
814            .with_label(format!(
815                "{} vectors",
816                label.unwrap_or_else(|| mesh_id_for_chunk.clone())
817            )),
818        );
819    }
820
821    Ok(())
822}
823
824fn scene_chunk_to_mesh_plot(
825    chunk: &crate::geometry_scene::GeometrySceneChunk,
826) -> Option<ScenePlot> {
827    if chunk.render_data.pipeline_type != crate::core::PipelineType::Triangles {
828        return None;
829    }
830    let indices = chunk.indices.as_ref()?;
831    if indices.len() < 3 {
832        return None;
833    }
834    let triangles = indices
835        .chunks_exact(3)
836        .map(|item| [item[0], item[1], item[2]])
837        .collect::<Vec<_>>();
838    if triangles.is_empty() {
839        return None;
840    }
841    let vertices = chunk
842        .vertices
843        .iter()
844        .map(|vertex| vertex.position)
845        .collect::<Vec<_>>();
846    Some(ScenePlot::Mesh {
847        vertices,
848        triangles,
849        mesh_id: chunk.mesh_id.clone(),
850        face_color_rgba: chunk.material.albedo.to_array(),
851        edge_color_rgba: [0.08, 0.10, 0.13, 1.0],
852        face_alpha: chunk.material.albedo.w,
853        edge_alpha: 0.0,
854        edge_width: 0.0,
855        edge_mode: "none".to_string(),
856        feature_edge_groups: Vec::new(),
857        vertex_colors_rgba: Vec::new(),
858        triangle_colors_rgba: Vec::new(),
859        axes_index: 0,
860        label: chunk.label.clone(),
861        regions: chunk.regions.iter().map(Into::into).collect(),
862        highlighted_region_id: None,
863        highlight_color_rgba: Some([0.98, 0.78, 0.22, 1.0]),
864        scalar_field: None,
865        vector_field: None,
866        deformation: None,
867        visible: chunk.visible,
868    })
869}
870
871fn figure_axis_index(figure: &Figure, plot_index: usize) -> u32 {
872    figure
873        .plot_axes_indices()
874        .get(plot_index)
875        .copied()
876        .unwrap_or(0) as u32
877}
878
879/// Layout metadata describing subplot arrangement.
880#[derive(Debug, Clone, Serialize, Deserialize)]
881#[serde(rename_all = "camelCase")]
882pub struct FigureLayout {
883    pub axes_rows: u32,
884    pub axes_cols: u32,
885    pub axes_indices: Vec<u32>,
886}
887
888/// Figure-level metadata (title, labels, theming).
889#[derive(Debug, Clone, Serialize, Deserialize)]
890#[serde(rename_all = "camelCase")]
891pub struct FigureMetadata {
892    #[serde(skip_serializing_if = "Option::is_none")]
893    pub name: Option<String>,
894    #[serde(default = "default_true", skip_serializing_if = "is_true")]
895    pub number_title: bool,
896    #[serde(default = "default_true", skip_serializing_if = "is_true")]
897    pub visible: bool,
898    #[serde(skip_serializing_if = "Option::is_none")]
899    pub position: Option<[f64; 4]>,
900    #[serde(skip_serializing_if = "Option::is_none")]
901    pub title: Option<String>,
902    #[serde(skip_serializing_if = "Option::is_none")]
903    pub sg_title: Option<String>,
904    #[serde(skip_serializing_if = "Option::is_none")]
905    pub sg_title_style: Option<SerializedTextStyle>,
906    #[serde(skip_serializing_if = "Option::is_none")]
907    pub x_label: Option<String>,
908    #[serde(skip_serializing_if = "Option::is_none")]
909    pub y_label: Option<String>,
910    pub grid_enabled: bool,
911    #[serde(default)]
912    pub minor_grid_enabled: bool,
913    pub legend_enabled: bool,
914    pub colorbar_enabled: bool,
915    pub axis_equal: bool,
916    #[serde(
917        default = "default_data_aspect_ratio",
918        skip_serializing_if = "is_default_data_aspect_ratio"
919    )]
920    pub data_aspect_ratio: [f64; 3],
921    #[serde(
922        default = "default_data_aspect_ratio_mode",
923        skip_serializing_if = "is_auto_data_aspect_ratio_mode"
924    )]
925    pub data_aspect_ratio_mode: String,
926    pub background_rgba: [f32; 4],
927    #[serde(skip_serializing_if = "Option::is_none")]
928    pub colormap: Option<String>,
929    #[serde(skip_serializing_if = "Option::is_none")]
930    pub color_limits: Option<[f64; 2]>,
931    #[serde(skip_serializing_if = "Option::is_none")]
932    pub z_limits: Option<[f64; 2]>,
933    pub legend_entries: Vec<FigureLegendEntry>,
934    #[serde(default)]
935    pub active_axes_index: u32,
936    #[serde(skip_serializing_if = "Option::is_none")]
937    pub axes_metadata: Option<Vec<SerializedAxesMetadata>>,
938}
939
940impl FigureMetadata {
941    fn from_figure(figure: &Figure) -> Self {
942        let legend_entries = figure
943            .legend_entries()
944            .into_iter()
945            .map(FigureLegendEntry::from)
946            .collect();
947
948        Self {
949            name: figure.name.clone(),
950            number_title: figure.number_title,
951            visible: figure.visible,
952            position: Some(figure.position),
953            title: figure.title.clone(),
954            sg_title: figure.sg_title.clone(),
955            sg_title_style: figure
956                .sg_title
957                .as_ref()
958                .map(|_| figure.sg_title_style.clone().into()),
959            x_label: figure.x_label.clone(),
960            y_label: figure.y_label.clone(),
961            grid_enabled: figure.grid_enabled,
962            minor_grid_enabled: figure.minor_grid_enabled,
963            legend_enabled: figure.legend_enabled,
964            colorbar_enabled: figure.colorbar_enabled,
965            axis_equal: figure.axis_equal,
966            data_aspect_ratio: figure
967                .axes_metadata(figure.active_axes_index)
968                .map(|meta| meta.data_aspect_ratio)
969                .unwrap_or([1.0, 1.0, 1.0]),
970            data_aspect_ratio_mode: figure
971                .axes_metadata(figure.active_axes_index)
972                .map(|meta| meta.data_aspect_ratio_mode.clone())
973                .unwrap_or_else(|| "auto".into()),
974            background_rgba: vec4_to_rgba(figure.background_color),
975            colormap: Some(figure.colormap.to_serialized_token()),
976            color_limits: figure.color_limits.map(|(lo, hi)| [lo, hi]),
977            z_limits: figure.z_limits.map(|(lo, hi)| [lo, hi]),
978            legend_entries,
979            active_axes_index: figure.active_axes_index as u32,
980            axes_metadata: Some(
981                figure
982                    .axes_metadata
983                    .iter()
984                    .cloned()
985                    .map(SerializedAxesMetadata::from)
986                    .collect(),
987            ),
988        }
989    }
990}
991
992fn default_true() -> bool {
993    true
994}
995
996fn is_true(value: &bool) -> bool {
997    *value
998}
999
1000fn is_false(value: &bool) -> bool {
1001    !*value
1002}
1003
1004#[derive(Debug, Clone, Serialize, Deserialize)]
1005#[serde(rename_all = "camelCase")]
1006pub struct SerializedTextStyle {
1007    #[serde(skip_serializing_if = "Option::is_none")]
1008    pub color_rgba: Option<[f32; 4]>,
1009    #[serde(skip_serializing_if = "Option::is_none")]
1010    pub font_size: Option<f32>,
1011    #[serde(skip_serializing_if = "Option::is_none")]
1012    pub font_weight: Option<String>,
1013    #[serde(skip_serializing_if = "Option::is_none")]
1014    pub font_angle: Option<String>,
1015    #[serde(skip_serializing_if = "Option::is_none")]
1016    pub interpreter: Option<String>,
1017    pub visible: bool,
1018}
1019
1020impl Default for SerializedTextStyle {
1021    fn default() -> Self {
1022        TextStyle::default().into()
1023    }
1024}
1025
1026impl From<TextStyle> for SerializedTextStyle {
1027    fn from(value: TextStyle) -> Self {
1028        Self {
1029            color_rgba: value.color.map(vec4_to_rgba),
1030            font_size: value.font_size,
1031            font_weight: value.font_weight,
1032            font_angle: value.font_angle,
1033            interpreter: value.interpreter,
1034            visible: value.visible,
1035        }
1036    }
1037}
1038
1039impl From<SerializedTextStyle> for TextStyle {
1040    fn from(value: SerializedTextStyle) -> Self {
1041        Self {
1042            color: value.color_rgba.map(rgba_to_vec4),
1043            font_size: value.font_size,
1044            font_weight: value.font_weight,
1045            font_angle: value.font_angle,
1046            interpreter: value.interpreter,
1047            visible: value.visible,
1048        }
1049    }
1050}
1051
1052#[derive(Debug, Clone, Serialize, Deserialize)]
1053#[serde(rename_all = "camelCase")]
1054pub struct SerializedLegendStyle {
1055    #[serde(skip_serializing_if = "Option::is_none")]
1056    pub location: Option<String>,
1057    pub visible: bool,
1058    #[serde(skip_serializing_if = "Option::is_none")]
1059    pub font_size: Option<f32>,
1060    #[serde(skip_serializing_if = "Option::is_none")]
1061    pub font_weight: Option<String>,
1062    #[serde(skip_serializing_if = "Option::is_none")]
1063    pub font_angle: Option<String>,
1064    #[serde(skip_serializing_if = "Option::is_none")]
1065    pub interpreter: Option<String>,
1066    #[serde(skip_serializing_if = "Option::is_none")]
1067    pub box_visible: Option<bool>,
1068    #[serde(skip_serializing_if = "Option::is_none")]
1069    pub orientation: Option<String>,
1070    #[serde(skip_serializing_if = "Option::is_none")]
1071    pub text_color_rgba: Option<[f32; 4]>,
1072}
1073
1074impl From<LegendStyle> for SerializedLegendStyle {
1075    fn from(value: LegendStyle) -> Self {
1076        Self {
1077            location: value.location,
1078            visible: value.visible,
1079            font_size: value.font_size,
1080            font_weight: value.font_weight,
1081            font_angle: value.font_angle,
1082            interpreter: value.interpreter,
1083            box_visible: value.box_visible,
1084            orientation: value.orientation,
1085            text_color_rgba: value.text_color.map(vec4_to_rgba),
1086        }
1087    }
1088}
1089
1090impl From<SerializedLegendStyle> for LegendStyle {
1091    fn from(value: SerializedLegendStyle) -> Self {
1092        Self {
1093            location: value.location,
1094            visible: value.visible,
1095            font_size: value.font_size,
1096            font_weight: value.font_weight,
1097            font_angle: value.font_angle,
1098            interpreter: value.interpreter,
1099            box_visible: value.box_visible,
1100            orientation: value.orientation,
1101            text_color: value.text_color_rgba.map(rgba_to_vec4),
1102        }
1103    }
1104}
1105
1106#[derive(Debug, Clone, Serialize, Deserialize)]
1107#[serde(rename_all = "camelCase")]
1108pub struct SerializedAxesMetadata {
1109    #[serde(default, skip_serializing_if = "is_cartesian_axes_kind")]
1110    pub axes_kind: SerializedAxesKind,
1111    #[serde(default, skip_serializing_if = "Option::is_none")]
1112    pub overlay_parent: Option<usize>,
1113    #[serde(
1114        default = "default_y_axis_location",
1115        skip_serializing_if = "is_left_axis"
1116    )]
1117    pub y_axis_location: String,
1118    #[serde(
1119        default = "default_axes_position",
1120        skip_serializing_if = "is_default_axes_position"
1121    )]
1122    pub position: [f64; 4],
1123    #[serde(default, skip_serializing_if = "is_false")]
1124    pub position_explicit: bool,
1125    #[serde(
1126        default = "default_axes_units",
1127        skip_serializing_if = "is_normalized_units"
1128    )]
1129    pub units: String,
1130    #[serde(skip_serializing_if = "Option::is_none")]
1131    pub title: Option<String>,
1132    #[serde(default, skip_serializing_if = "Option::is_none")]
1133    pub subtitle: Option<String>,
1134    #[serde(skip_serializing_if = "Option::is_none")]
1135    pub x_label: Option<String>,
1136    #[serde(skip_serializing_if = "Option::is_none")]
1137    pub y_label: Option<String>,
1138    #[serde(skip_serializing_if = "Option::is_none")]
1139    pub z_label: Option<String>,
1140    #[serde(default, skip_serializing_if = "Option::is_none")]
1141    pub x_ticks: Option<Vec<f64>>,
1142    #[serde(default, skip_serializing_if = "Option::is_none")]
1143    pub y_ticks: Option<Vec<f64>>,
1144    #[serde(default, skip_serializing_if = "Option::is_none")]
1145    pub x_tick_labels: Option<Vec<String>>,
1146    #[serde(default, skip_serializing_if = "Option::is_none")]
1147    pub y_tick_labels: Option<Vec<String>>,
1148    #[serde(default, skip_serializing_if = "Option::is_none")]
1149    pub x_tick_format: Option<String>,
1150    #[serde(default, skip_serializing_if = "Option::is_none")]
1151    pub y_tick_format: Option<String>,
1152    #[serde(default, skip_serializing_if = "Option::is_none")]
1153    pub x_tick_label_rotation: Option<f64>,
1154    #[serde(default, skip_serializing_if = "Option::is_none")]
1155    pub y_tick_label_rotation: Option<f64>,
1156    #[serde(skip_serializing_if = "Option::is_none")]
1157    pub x_limits: Option<[f64; 2]>,
1158    #[serde(skip_serializing_if = "Option::is_none")]
1159    pub y_limits: Option<[f64; 2]>,
1160    #[serde(skip_serializing_if = "Option::is_none")]
1161    pub z_limits: Option<[f64; 2]>,
1162    #[serde(default)]
1163    pub x_log: bool,
1164    #[serde(default)]
1165    pub y_log: bool,
1166    #[serde(skip_serializing_if = "Option::is_none")]
1167    pub view_azimuth_deg: Option<f32>,
1168    #[serde(skip_serializing_if = "Option::is_none")]
1169    pub view_elevation_deg: Option<f32>,
1170    #[serde(default)]
1171    pub grid_enabled: bool,
1172    #[serde(default)]
1173    pub minor_grid_enabled: bool,
1174    #[serde(default, skip_serializing_if = "is_false")]
1175    pub minor_grid_explicit: bool,
1176    #[serde(default = "default_true", skip_serializing_if = "is_true")]
1177    pub hidden_line_removal: bool,
1178    #[serde(default)]
1179    pub box_enabled: bool,
1180    #[serde(default)]
1181    pub axis_equal: bool,
1182    #[serde(
1183        default = "default_data_aspect_ratio",
1184        skip_serializing_if = "is_default_data_aspect_ratio"
1185    )]
1186    pub data_aspect_ratio: [f64; 3],
1187    #[serde(
1188        default = "default_data_aspect_ratio_mode",
1189        skip_serializing_if = "is_auto_data_aspect_ratio_mode"
1190    )]
1191    pub data_aspect_ratio_mode: String,
1192    pub legend_enabled: bool,
1193    #[serde(default)]
1194    pub colorbar_enabled: bool,
1195    pub colormap: String,
1196    #[serde(default, skip_serializing_if = "Option::is_none")]
1197    pub color_order: Option<Vec<[f32; 3]>>,
1198    #[serde(skip_serializing_if = "Option::is_none")]
1199    pub color_limits: Option<[f64; 2]>,
1200    #[serde(default)]
1201    pub axes_style: SerializedTextStyle,
1202    pub title_style: SerializedTextStyle,
1203    #[serde(default)]
1204    pub subtitle_style: SerializedTextStyle,
1205    pub x_label_style: SerializedTextStyle,
1206    pub y_label_style: SerializedTextStyle,
1207    pub z_label_style: SerializedTextStyle,
1208    pub legend_style: SerializedLegendStyle,
1209    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1210    pub world_text_annotations: Vec<SerializedTextAnnotation>,
1211}
1212
1213#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1214#[serde(rename_all = "camelCase")]
1215pub enum SerializedAxesKind {
1216    #[default]
1217    Cartesian,
1218    Polar,
1219}
1220
1221fn is_cartesian_axes_kind(value: &SerializedAxesKind) -> bool {
1222    *value == SerializedAxesKind::Cartesian
1223}
1224
1225fn default_y_axis_location() -> String {
1226    "left".into()
1227}
1228
1229fn is_left_axis(value: &str) -> bool {
1230    value == "left"
1231}
1232
1233fn default_axes_position() -> [f64; 4] {
1234    [0.13, 0.11, 0.775, 0.815]
1235}
1236
1237fn is_default_axes_position(value: &[f64; 4]) -> bool {
1238    *value == default_axes_position()
1239}
1240
1241fn default_data_aspect_ratio() -> [f64; 3] {
1242    [1.0, 1.0, 1.0]
1243}
1244
1245fn is_default_data_aspect_ratio(value: &[f64; 3]) -> bool {
1246    *value == default_data_aspect_ratio()
1247}
1248
1249fn default_data_aspect_ratio_mode() -> String {
1250    "auto".into()
1251}
1252
1253fn is_auto_data_aspect_ratio_mode(value: &str) -> bool {
1254    value == "auto"
1255}
1256
1257fn default_axes_units() -> String {
1258    "normalized".into()
1259}
1260
1261fn is_normalized_units(value: &str) -> bool {
1262    value == "normalized"
1263}
1264
1265impl From<AxesKind> for SerializedAxesKind {
1266    fn from(value: AxesKind) -> Self {
1267        match value {
1268            AxesKind::Cartesian => Self::Cartesian,
1269            AxesKind::Polar => Self::Polar,
1270        }
1271    }
1272}
1273
1274impl From<SerializedAxesKind> for AxesKind {
1275    fn from(value: SerializedAxesKind) -> Self {
1276        match value {
1277            SerializedAxesKind::Cartesian => Self::Cartesian,
1278            SerializedAxesKind::Polar => Self::Polar,
1279        }
1280    }
1281}
1282
1283#[derive(Debug, Clone, Serialize, Deserialize)]
1284#[serde(rename_all = "camelCase")]
1285pub struct SerializedTextAnnotation {
1286    pub position: [f32; 3],
1287    pub text: String,
1288    pub style: SerializedTextStyle,
1289}
1290
1291#[derive(Debug, Clone, Serialize, Deserialize)]
1292#[serde(rename_all = "camelCase")]
1293pub struct SerializedMeshRegion {
1294    pub region_id: String,
1295    #[serde(default, skip_serializing_if = "Option::is_none")]
1296    pub label: Option<String>,
1297    #[serde(default, skip_serializing_if = "Option::is_none")]
1298    pub tag: Option<String>,
1299    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1300    pub triangle_ranges: Vec<SerializedMeshTriangleRange>,
1301}
1302
1303#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1304#[serde(rename_all = "camelCase")]
1305pub struct SerializedMeshTriangleRange {
1306    pub start: u32,
1307    pub count: u32,
1308}
1309
1310#[derive(Debug, Clone, Serialize, Deserialize)]
1311#[serde(rename_all = "camelCase")]
1312pub struct SerializedMeshScalarField {
1313    pub field_id: String,
1314    #[serde(default, skip_serializing_if = "Option::is_none")]
1315    pub label: Option<String>,
1316    pub location: String,
1317    #[serde(deserialize_with = "deserialize_vec_f32_lossy")]
1318    pub values: Vec<f32>,
1319    #[serde(default, skip_serializing_if = "Option::is_none")]
1320    pub color_limits: Option<[f32; 2]>,
1321    pub colormap: String,
1322    pub alpha: f32,
1323}
1324
1325#[derive(Debug, Clone, Serialize, Deserialize)]
1326#[serde(rename_all = "camelCase")]
1327pub struct SerializedMeshVectorField {
1328    pub field_id: String,
1329    #[serde(default, skip_serializing_if = "Option::is_none")]
1330    pub label: Option<String>,
1331    pub location: String,
1332    #[serde(deserialize_with = "deserialize_vec_xyz_f32_lossy")]
1333    pub vectors: Vec<[f32; 3]>,
1334    pub scale: f32,
1335    pub stride: usize,
1336    pub color_rgba: [f32; 4],
1337}
1338
1339#[derive(Debug, Clone, Serialize, Deserialize)]
1340#[serde(rename_all = "camelCase")]
1341pub struct SerializedMeshDeformation {
1342    pub field_id: String,
1343    #[serde(default, skip_serializing_if = "Option::is_none")]
1344    pub label: Option<String>,
1345    #[serde(deserialize_with = "deserialize_vec_xyz_f32_lossy")]
1346    pub displacements: Vec<[f32; 3]>,
1347    pub scale: f32,
1348}
1349
1350impl From<AxesMetadata> for SerializedAxesMetadata {
1351    fn from(value: AxesMetadata) -> Self {
1352        Self {
1353            axes_kind: value.axes_kind.into(),
1354            overlay_parent: value.overlay_parent,
1355            y_axis_location: value.y_axis_location,
1356            position: value.position,
1357            position_explicit: value.position_explicit,
1358            units: value.units,
1359            title: value.title,
1360            subtitle: value.subtitle,
1361            x_label: value.x_label,
1362            y_label: value.y_label,
1363            z_label: value.z_label,
1364            x_ticks: value.x_ticks,
1365            y_ticks: value.y_ticks,
1366            x_tick_labels: value.x_tick_labels,
1367            y_tick_labels: value.y_tick_labels,
1368            x_tick_format: value.x_tick_format,
1369            y_tick_format: value.y_tick_format,
1370            x_tick_label_rotation: value.x_tick_label_rotation,
1371            y_tick_label_rotation: value.y_tick_label_rotation,
1372            x_limits: value.x_limits.map(|(a, b)| [a, b]),
1373            y_limits: value.y_limits.map(|(a, b)| [a, b]),
1374            z_limits: value.z_limits.map(|(a, b)| [a, b]),
1375            x_log: value.x_log,
1376            y_log: value.y_log,
1377            view_azimuth_deg: value.view_azimuth_deg,
1378            view_elevation_deg: value.view_elevation_deg,
1379            grid_enabled: value.grid_enabled,
1380            minor_grid_enabled: value.minor_grid_enabled,
1381            minor_grid_explicit: value.minor_grid_explicit,
1382            hidden_line_removal: value.hidden_line_removal,
1383            box_enabled: value.box_enabled,
1384            axis_equal: value.axis_equal,
1385            data_aspect_ratio: value.data_aspect_ratio,
1386            data_aspect_ratio_mode: value.data_aspect_ratio_mode,
1387            legend_enabled: value.legend_enabled,
1388            colorbar_enabled: value.colorbar_enabled,
1389            colormap: value.colormap.to_serialized_token(),
1390            color_order: value
1391                .color_order
1392                .map(|colors| colors.into_iter().map(|c| [c.x, c.y, c.z]).collect()),
1393            color_limits: value.color_limits.map(|(a, b)| [a, b]),
1394            axes_style: value.axes_style.into(),
1395            title_style: value.title_style.into(),
1396            subtitle_style: value.subtitle_style.into(),
1397            x_label_style: value.x_label_style.into(),
1398            y_label_style: value.y_label_style.into(),
1399            z_label_style: value.z_label_style.into(),
1400            legend_style: value.legend_style.into(),
1401            world_text_annotations: value
1402                .world_text_annotations
1403                .into_iter()
1404                .map(Into::into)
1405                .collect(),
1406        }
1407    }
1408}
1409
1410impl From<SerializedAxesMetadata> for AxesMetadata {
1411    fn from(value: SerializedAxesMetadata) -> Self {
1412        Self {
1413            axes_kind: value.axes_kind.into(),
1414            overlay_parent: value.overlay_parent,
1415            y_axis_location: value.y_axis_location,
1416            position: value.position,
1417            position_explicit: value.position_explicit,
1418            units: value.units,
1419            title: value.title,
1420            subtitle: value.subtitle,
1421            x_label: value.x_label,
1422            y_label: value.y_label,
1423            z_label: value.z_label,
1424            x_ticks: value.x_ticks,
1425            y_ticks: value.y_ticks,
1426            x_tick_labels: value.x_tick_labels,
1427            y_tick_labels: value.y_tick_labels,
1428            x_tick_format: value.x_tick_format,
1429            y_tick_format: value.y_tick_format,
1430            x_tick_label_rotation: value.x_tick_label_rotation,
1431            y_tick_label_rotation: value.y_tick_label_rotation,
1432            x_limits: value.x_limits.map(|[a, b]| (a, b)),
1433            y_limits: value.y_limits.map(|[a, b]| (a, b)),
1434            z_limits: value.z_limits.map(|[a, b]| (a, b)),
1435            x_log: value.x_log,
1436            y_log: value.y_log,
1437            view_azimuth_deg: value.view_azimuth_deg,
1438            view_elevation_deg: value.view_elevation_deg,
1439            view_revision: 0,
1440            grid_enabled: value.grid_enabled,
1441            minor_grid_enabled: value.minor_grid_enabled,
1442            minor_grid_explicit: value.minor_grid_explicit || value.minor_grid_enabled,
1443            hidden_line_removal: value.hidden_line_removal,
1444            box_enabled: value.box_enabled,
1445            axis_equal: value.axis_equal,
1446            data_aspect_ratio: value.data_aspect_ratio,
1447            data_aspect_ratio_mode: value.data_aspect_ratio_mode,
1448            legend_enabled: value.legend_enabled,
1449            colorbar_enabled: value.colorbar_enabled,
1450            colormap: parse_colormap_name(&value.colormap),
1451            color_order: value.color_order.map(|colors| {
1452                colors
1453                    .into_iter()
1454                    .map(|[r, g, b]| glam::Vec4::new(r, g, b, 1.0))
1455                    .collect()
1456            }),
1457            color_limits: value.color_limits.map(|[a, b]| (a, b)),
1458            axes_style: value.axes_style.into(),
1459            title_style: value.title_style.into(),
1460            subtitle_style: value.subtitle_style.into(),
1461            x_label_style: value.x_label_style.into(),
1462            y_label_style: value.y_label_style.into(),
1463            z_label_style: value.z_label_style.into(),
1464            legend_style: value.legend_style.into(),
1465            world_text_annotations: value
1466                .world_text_annotations
1467                .into_iter()
1468                .map(Into::into)
1469                .collect(),
1470        }
1471    }
1472}
1473
1474impl From<crate::plots::figure::TextAnnotation> for SerializedTextAnnotation {
1475    fn from(value: crate::plots::figure::TextAnnotation) -> Self {
1476        Self {
1477            position: value.position.to_array(),
1478            text: value.text,
1479            style: value.style.into(),
1480        }
1481    }
1482}
1483
1484impl From<SerializedTextAnnotation> for crate::plots::figure::TextAnnotation {
1485    fn from(value: SerializedTextAnnotation) -> Self {
1486        Self {
1487            position: glam::Vec3::from_array(value.position),
1488            text: value.text,
1489            style: value.style.into(),
1490        }
1491    }
1492}
1493
1494impl From<&MeshRegion> for SerializedMeshRegion {
1495    fn from(value: &MeshRegion) -> Self {
1496        Self {
1497            region_id: value.region_id.clone(),
1498            label: value.label.clone(),
1499            tag: value.tag.clone(),
1500            triangle_ranges: value
1501                .triangle_ranges
1502                .iter()
1503                .copied()
1504                .map(Into::into)
1505                .collect(),
1506        }
1507    }
1508}
1509
1510impl From<&crate::geometry_scene::GeometrySceneRegion> for SerializedMeshRegion {
1511    fn from(value: &crate::geometry_scene::GeometrySceneRegion) -> Self {
1512        Self {
1513            region_id: value.region_id.clone(),
1514            label: value.label.clone(),
1515            tag: value.tag.clone(),
1516            triangle_ranges: value
1517                .triangle_ranges
1518                .iter()
1519                .copied()
1520                .map(Into::into)
1521                .collect(),
1522        }
1523    }
1524}
1525
1526impl From<SerializedMeshRegion> for MeshRegion {
1527    fn from(value: SerializedMeshRegion) -> Self {
1528        MeshRegion {
1529            region_id: value.region_id,
1530            label: value.label,
1531            tag: value.tag,
1532            triangle_ranges: value.triangle_ranges.into_iter().map(Into::into).collect(),
1533        }
1534    }
1535}
1536
1537impl From<SerializedMeshRegion> for crate::geometry_scene::GeometrySceneRegion {
1538    fn from(value: SerializedMeshRegion) -> Self {
1539        crate::geometry_scene::GeometrySceneRegion::new(
1540            value.region_id,
1541            value.label,
1542            value.tag,
1543            value.triangle_ranges.into_iter().map(Into::into).collect(),
1544        )
1545    }
1546}
1547
1548impl From<MeshTriangleRange> for SerializedMeshTriangleRange {
1549    fn from(value: MeshTriangleRange) -> Self {
1550        Self {
1551            start: value.start,
1552            count: value.count,
1553        }
1554    }
1555}
1556
1557impl From<crate::geometry_scene::GeometrySceneTriangleRange> for SerializedMeshTriangleRange {
1558    fn from(value: crate::geometry_scene::GeometrySceneTriangleRange) -> Self {
1559        Self {
1560            start: value.start,
1561            count: value.count,
1562        }
1563    }
1564}
1565
1566impl From<SerializedMeshTriangleRange> for MeshTriangleRange {
1567    fn from(value: SerializedMeshTriangleRange) -> Self {
1568        Self::new(value.start, value.count)
1569    }
1570}
1571
1572impl From<SerializedMeshTriangleRange> for crate::geometry_scene::GeometrySceneTriangleRange {
1573    fn from(value: SerializedMeshTriangleRange) -> Self {
1574        Self::new(value.start, value.count)
1575    }
1576}
1577
1578impl From<&MeshScalarField> for SerializedMeshScalarField {
1579    fn from(value: &MeshScalarField) -> Self {
1580        Self {
1581            field_id: value.field_id.clone(),
1582            label: value.label.clone(),
1583            location: value.location.as_str().to_string(),
1584            values: value.values.clone(),
1585            color_limits: value.color_limits,
1586            colormap: value.colormap.clone(),
1587            alpha: value.alpha,
1588        }
1589    }
1590}
1591
1592impl TryFrom<SerializedMeshScalarField> for MeshScalarField {
1593    type Error = String;
1594
1595    fn try_from(value: SerializedMeshScalarField) -> Result<Self, Self::Error> {
1596        Ok(Self {
1597            field_id: value.field_id,
1598            label: value.label,
1599            location: MeshFieldLocation::parse(&value.location).ok_or_else(|| {
1600                format!("unknown mesh scalar field location '{}'", value.location)
1601            })?,
1602            values: value.values,
1603            color_limits: value.color_limits,
1604            colormap: value.colormap,
1605            alpha: value.alpha,
1606        })
1607    }
1608}
1609
1610impl From<&MeshVectorField> for SerializedMeshVectorField {
1611    fn from(value: &MeshVectorField) -> Self {
1612        Self {
1613            field_id: value.field_id.clone(),
1614            label: value.label.clone(),
1615            location: value.location.as_str().to_string(),
1616            vectors: value
1617                .vectors
1618                .iter()
1619                .map(|vector| vector.to_array())
1620                .collect(),
1621            scale: value.scale,
1622            stride: value.stride,
1623            color_rgba: vec4_to_rgba(value.color),
1624        }
1625    }
1626}
1627
1628impl TryFrom<SerializedMeshVectorField> for MeshVectorField {
1629    type Error = String;
1630
1631    fn try_from(value: SerializedMeshVectorField) -> Result<Self, Self::Error> {
1632        Ok(Self {
1633            field_id: value.field_id,
1634            label: value.label,
1635            location: MeshFieldLocation::parse(&value.location).ok_or_else(|| {
1636                format!("unknown mesh vector field location '{}'", value.location)
1637            })?,
1638            vectors: value.vectors.into_iter().map(Vec3::from_array).collect(),
1639            scale: value.scale,
1640            stride: value.stride,
1641            color: rgba_to_vec4(value.color_rgba),
1642        })
1643    }
1644}
1645
1646impl From<&MeshDeformation> for SerializedMeshDeformation {
1647    fn from(value: &MeshDeformation) -> Self {
1648        Self {
1649            field_id: value.field_id.clone(),
1650            label: value.label.clone(),
1651            displacements: value
1652                .displacements
1653                .iter()
1654                .map(|displacement| displacement.to_array())
1655                .collect(),
1656            scale: value.scale,
1657        }
1658    }
1659}
1660
1661impl From<SerializedMeshDeformation> for MeshDeformation {
1662    fn from(value: SerializedMeshDeformation) -> Self {
1663        Self {
1664            field_id: value.field_id,
1665            label: value.label,
1666            displacements: value
1667                .displacements
1668                .into_iter()
1669                .map(Vec3::from_array)
1670                .collect(),
1671            scale: value.scale,
1672        }
1673    }
1674}
1675
1676/// Descriptor for a single plot element within the figure.
1677#[derive(Debug, Clone, Serialize, Deserialize)]
1678#[serde(rename_all = "camelCase")]
1679pub struct PlotDescriptor {
1680    pub kind: PlotKind,
1681    #[serde(skip_serializing_if = "Option::is_none")]
1682    pub label: Option<String>,
1683    pub axes_index: u32,
1684    pub color_rgba: [f32; 4],
1685    pub visible: bool,
1686}
1687
1688impl PlotDescriptor {
1689    fn from_plot(plot: &PlotElement, axes_index: u32) -> Self {
1690        Self {
1691            kind: PlotKind::from(plot.plot_type()),
1692            label: plot.label(),
1693            axes_index,
1694            color_rgba: vec4_to_rgba(plot.color()),
1695            visible: plot.is_visible(),
1696        }
1697    }
1698}
1699
1700fn validate_required_equal_lengths(
1701    kind: &str,
1702    fields: &[(&str, usize)],
1703) -> Result<(), SceneExportError> {
1704    let Some((first_name, first_len)) = fields.first().copied() else {
1705        return Ok(());
1706    };
1707    if first_len == 0 {
1708        return Err(SceneExportError::unexportable(format!(
1709            "{kind} is missing required {first_name} values"
1710        )));
1711    }
1712    for (name, len) in fields.iter().copied().skip(1) {
1713        if len == 0 {
1714            return Err(SceneExportError::unexportable(format!(
1715                "{kind} is missing required {name} values"
1716            )));
1717        }
1718        if len != first_len {
1719            return Err(SceneExportError::unexportable(format!(
1720                "{kind} length mismatch: {name} has {len} values, expected {first_len}"
1721            )));
1722        }
1723    }
1724    Ok(())
1725}
1726
1727fn validate_surface_grid<T>(
1728    kind: &str,
1729    x: &[f64],
1730    y: &[f64],
1731    grid: &[Vec<T>],
1732) -> Result<(), SceneExportError> {
1733    if x.is_empty() {
1734        return Err(SceneExportError::unexportable(format!(
1735            "{kind} is missing required x values"
1736        )));
1737    }
1738    if y.is_empty() {
1739        return Err(SceneExportError::unexportable(format!(
1740            "{kind} is missing required y values"
1741        )));
1742    }
1743    if grid.is_empty() {
1744        return Err(SceneExportError::unexportable(format!(
1745            "{kind} is missing required grid rows"
1746        )));
1747    }
1748    if grid.len() != x.len() {
1749        return Err(SceneExportError::unexportable(format!(
1750            "{kind} row count ({}) must match x length ({})",
1751            grid.len(),
1752            x.len()
1753        )));
1754    }
1755    for (row_idx, row) in grid.iter().enumerate() {
1756        if row.len() != y.len() {
1757            return Err(SceneExportError::unexportable(format!(
1758                "{kind} row {row_idx} length ({}) must match y length ({})",
1759                row.len(),
1760                y.len()
1761            )));
1762        }
1763    }
1764    Ok(())
1765}
1766
1767fn validate_matching_grid_shape<T, U>(
1768    kind: &str,
1769    reference: &[Vec<T>],
1770    grid: &[Vec<U>],
1771) -> Result<(), SceneExportError> {
1772    if grid.len() != reference.len() {
1773        return Err(SceneExportError::unexportable(format!(
1774            "{kind} row count ({}) must match surface row count ({})",
1775            grid.len(),
1776            reference.len()
1777        )));
1778    }
1779    for (row_idx, (reference_row, row)) in reference.iter().zip(grid).enumerate() {
1780        if row.len() != reference_row.len() {
1781            return Err(SceneExportError::unexportable(format!(
1782                "{kind} row {row_idx} length ({}) must match surface row length ({})",
1783                row.len(),
1784                reference_row.len()
1785            )));
1786        }
1787    }
1788    Ok(())
1789}
1790
1791fn validate_surface_coordinate_grids(
1792    kind: &str,
1793    x_grid: &[Vec<f64>],
1794    y_grid: &[Vec<f64>],
1795    z_grid: &[Vec<f64>],
1796) -> Result<(), SceneExportError> {
1797    if z_grid.is_empty() {
1798        return Err(SceneExportError::unexportable(format!(
1799            "{kind} is missing required grid rows"
1800        )));
1801    }
1802    validate_matching_grid_shape(kind, z_grid, x_grid)?;
1803    validate_matching_grid_shape(kind, z_grid, y_grid)?;
1804    Ok(())
1805}
1806
1807impl ScenePlot {
1808    async fn from_plot_for_export(
1809        plot: &PlotElement,
1810        axes_index: u32,
1811    ) -> Result<Self, SceneExportError> {
1812        let scene_plot = match plot {
1813            PlotElement::Line(line) => {
1814                let (x, y) = line
1815                    .export_scene_xy_data()
1816                    .await
1817                    .map_err(SceneExportError::readback)?;
1818                if x.is_empty() && y.is_empty() {
1819                    return Err(SceneExportError::unexportable(
1820                        "line plot has no exportable scene data",
1821                    ));
1822                }
1823                Self::Line {
1824                    x,
1825                    y,
1826                    color_rgba: vec4_to_rgba(line.color),
1827                    line_width: line.line_width,
1828                    line_style: format!("{:?}", line.line_style),
1829                    axes_index,
1830                    label: line.label.clone(),
1831                    visible: line.visible,
1832                }
1833            }
1834            PlotElement::ReferenceLine(_) => Self::from_plot(plot, axes_index),
1835            PlotElement::Scatter(scatter) => {
1836                let (x, y) = scatter
1837                    .export_scene_xy_data()
1838                    .await
1839                    .map_err(SceneExportError::readback)?;
1840                if x.is_empty() && y.is_empty() {
1841                    return Err(SceneExportError::unexportable(
1842                        "scatter plot has no exportable scene data",
1843                    ));
1844                }
1845                Self::Scatter {
1846                    x,
1847                    y,
1848                    color_rgba: vec4_to_rgba(scatter.color),
1849                    marker_size: scatter.marker_size,
1850                    marker_style: format!("{:?}", scatter.marker_style),
1851                    axes_index,
1852                    label: scatter.label.clone(),
1853                    visible: scatter.visible,
1854                }
1855            }
1856            PlotElement::Bar(bar) => {
1857                let values = bar
1858                    .export_scene_values()
1859                    .await
1860                    .map_err(SceneExportError::readback)?;
1861                if values.is_empty() {
1862                    return Err(SceneExportError::unexportable(
1863                        "bar chart has no exportable scene data",
1864                    ));
1865                }
1866                Self::Bar {
1867                    labels: bar.labels.clone(),
1868                    values,
1869                    histogram_bin_edges: bar.histogram_bin_edges().map(|edges| edges.to_vec()),
1870                    polar_histogram: bar.is_polar_histogram(),
1871                    polar_histogram_display_style: Some(format!(
1872                        "{:?}",
1873                        bar.polar_histogram_display_style()
1874                    )),
1875                    color_rgba: vec4_to_rgba(bar.color),
1876                    outline_color_rgba: bar.outline_color.map(vec4_to_rgba),
1877                    bar_width: bar.bar_width,
1878                    outline_width: bar.outline_width,
1879                    orientation: format!("{:?}", bar.orientation),
1880                    group_index: bar.group_index as u32,
1881                    group_count: bar.group_count as u32,
1882                    stack_offsets: bar.stack_offsets().map(|offsets| offsets.to_vec()),
1883                    axes_index,
1884                    label: bar.label.clone(),
1885                    visible: bar.visible,
1886                }
1887            }
1888            PlotElement::ErrorBar(error) => {
1889                let (x, y, y_neg, y_pos, x_neg, x_pos) = error
1890                    .export_scene_data()
1891                    .await
1892                    .map_err(SceneExportError::readback)?;
1893                if x.is_empty() && y.is_empty() {
1894                    return Err(SceneExportError::unexportable(
1895                        "errorbar plot has no exportable scene data",
1896                    ));
1897                }
1898                Self::ErrorBar {
1899                    x,
1900                    y,
1901                    err_low: y_neg,
1902                    err_high: y_pos,
1903                    x_err_low: x_neg,
1904                    x_err_high: x_pos,
1905                    orientation: format!("{:?}", error.orientation),
1906                    color_rgba: vec4_to_rgba(error.color),
1907                    line_width: error.line_width,
1908                    line_style: format!("{:?}", error.line_style),
1909                    cap_width: error.cap_size,
1910                    marker_style: error.marker.as_ref().map(|m| format!("{:?}", m.kind)),
1911                    marker_size: error.marker.as_ref().map(|m| m.size),
1912                    marker_face_color: error.marker.as_ref().map(|m| vec4_to_rgba(m.face_color)),
1913                    marker_edge_color: error.marker.as_ref().map(|m| vec4_to_rgba(m.edge_color)),
1914                    marker_filled: error.marker.as_ref().map(|m| m.filled),
1915                    axes_index,
1916                    label: error.label.clone(),
1917                    visible: error.visible,
1918                }
1919            }
1920            PlotElement::Stairs(stairs) => {
1921                let (x, y) = stairs
1922                    .export_scene_xy_data()
1923                    .await
1924                    .map_err(SceneExportError::readback)?;
1925                if x.is_empty() && y.is_empty() {
1926                    return Err(SceneExportError::unexportable(
1927                        "stairs plot has no exportable scene data",
1928                    ));
1929                }
1930                Self::Stairs {
1931                    x,
1932                    y,
1933                    color_rgba: vec4_to_rgba(stairs.color),
1934                    line_width: stairs.line_width,
1935                    axes_index,
1936                    label: stairs.label.clone(),
1937                    visible: stairs.visible,
1938                }
1939            }
1940            PlotElement::Stem(stem) => {
1941                let (x, y) = stem
1942                    .export_scene_xy_data()
1943                    .await
1944                    .map_err(SceneExportError::readback)?;
1945                if x.is_empty() && y.is_empty() {
1946                    return Err(SceneExportError::unexportable(
1947                        "stem plot has no exportable scene data",
1948                    ));
1949                }
1950                Self::Stem {
1951                    x,
1952                    y,
1953                    baseline: stem.baseline,
1954                    color_rgba: vec4_to_rgba(stem.color),
1955                    line_width: stem.line_width,
1956                    line_style: format!("{:?}", stem.line_style),
1957                    baseline_color_rgba: vec4_to_rgba(stem.baseline_color),
1958                    baseline_visible: stem.baseline_visible,
1959                    marker_color_rgba: vec4_to_rgba(
1960                        stem.marker
1961                            .as_ref()
1962                            .map(|m| m.face_color)
1963                            .unwrap_or(stem.color),
1964                    ),
1965                    marker_size: stem.marker.as_ref().map(|m| m.size).unwrap_or(0.0),
1966                    marker_filled: stem.marker.as_ref().map(|m| m.filled).unwrap_or(false),
1967                    axes_index,
1968                    label: stem.label.clone(),
1969                    visible: stem.visible,
1970                }
1971            }
1972            PlotElement::Area(area) => {
1973                let (x, y) = area
1974                    .export_scene_xy_data()
1975                    .await
1976                    .map_err(SceneExportError::readback)?;
1977                if x.is_empty() && y.is_empty() {
1978                    return Err(SceneExportError::unexportable(
1979                        "area plot has no exportable scene data",
1980                    ));
1981                }
1982                Self::Area {
1983                    x,
1984                    y,
1985                    lower_y: area.lower_y.clone(),
1986                    baseline: area.baseline,
1987                    color_rgba: vec4_to_rgba(area.color),
1988                    axes_index,
1989                    label: area.label.clone(),
1990                    visible: area.visible,
1991                }
1992            }
1993            PlotElement::Quiver(quiver) => {
1994                let (x, y, z, u, v, w) = quiver
1995                    .export_scene_vector_data()
1996                    .await
1997                    .map_err(SceneExportError::readback)?;
1998                if x.is_empty() && y.is_empty() && u.is_empty() && v.is_empty() {
1999                    return Err(SceneExportError::unexportable(
2000                        "quiver plot has no exportable scene data",
2001                    ));
2002                }
2003                Self::Quiver {
2004                    x,
2005                    y,
2006                    z,
2007                    u,
2008                    v,
2009                    w,
2010                    color_rgba: vec4_to_rgba(quiver.color),
2011                    line_width: quiver.line_width,
2012                    scale: quiver.scale,
2013                    head_size: quiver.head_size,
2014                    axes_index,
2015                    label: quiver.label.clone(),
2016                    visible: quiver.visible,
2017                }
2018            }
2019            PlotElement::Surface(surface) => {
2020                let (x, y, z) = surface
2021                    .export_scene_grid_data()
2022                    .await
2023                    .map_err(SceneExportError::readback)?;
2024                if x.is_empty() && y.is_empty() && z.is_empty() {
2025                    return Err(SceneExportError::unexportable(
2026                        "surface plot has no exportable scene data",
2027                    ));
2028                }
2029                let color_grid = surface
2030                    .export_scene_color_grid()
2031                    .await
2032                    .map_err(SceneExportError::readback)?;
2033                Self::Surface {
2034                    x,
2035                    y,
2036                    z,
2037                    x_grid: surface.x_grid.clone(),
2038                    y_grid: surface.y_grid.clone(),
2039                    colormap: surface.colormap.to_serialized_token(),
2040                    shading_mode: format!("{:?}", surface.shading_mode),
2041                    wireframe: surface.wireframe,
2042                    alpha: surface.alpha,
2043                    flatten_z: surface.flatten_z,
2044                    image_mode: surface.image_mode,
2045                    color_grid_rgba: color_grid.as_ref().map(|grid| {
2046                        grid.iter()
2047                            .map(|row| row.iter().map(|color| vec4_to_rgba(*color)).collect())
2048                            .collect()
2049                    }),
2050                    color_limits: surface.color_limits.map(|(lo, hi)| [lo, hi]),
2051                    axes_index,
2052                    label: surface.label.clone(),
2053                    visible: surface.visible,
2054                }
2055            }
2056            PlotElement::Patch(_) | PlotElement::Mesh(_) | PlotElement::Pie(_) => {
2057                Self::from_plot(plot, axes_index)
2058            }
2059            PlotElement::Line3(line) => {
2060                let (x, y, z) = line
2061                    .export_scene_xyz_data()
2062                    .await
2063                    .map_err(SceneExportError::readback)?;
2064                if x.is_empty() && y.is_empty() && z.is_empty() {
2065                    return Err(SceneExportError::unexportable(
2066                        "plot3 line has no exportable scene data",
2067                    ));
2068                }
2069                Self::Line3 {
2070                    x,
2071                    y,
2072                    z,
2073                    color_rgba: vec4_to_rgba(line.color),
2074                    line_width: line.line_width,
2075                    line_style: format!("{:?}", line.line_style),
2076                    axes_index,
2077                    label: line.label.clone(),
2078                    visible: line.visible,
2079                }
2080            }
2081            PlotElement::Scatter3(scatter3) => {
2082                let points = scatter3
2083                    .export_scene_points()
2084                    .await
2085                    .map_err(SceneExportError::readback)?;
2086                if points.is_empty() {
2087                    return Err(SceneExportError::unexportable(
2088                        "scatter3 plot has no exportable scene data",
2089                    ));
2090                }
2091                let colors = scatter3
2092                    .export_scene_colors(points.len())
2093                    .await
2094                    .map_err(SceneExportError::readback)?;
2095                Self::Scatter3 {
2096                    points: points.into_iter().map(vec3_to_xyz).collect(),
2097                    colors_rgba: colors.into_iter().map(vec4_to_rgba).collect(),
2098                    point_size: scatter3.point_size,
2099                    point_sizes: scatter3.point_sizes.clone(),
2100                    axes_index,
2101                    label: scatter3.label.clone(),
2102                    visible: scatter3.visible,
2103                }
2104            }
2105            PlotElement::Contour(contour) => {
2106                let vertices = contour
2107                    .export_scene_vertices()
2108                    .await
2109                    .map_err(SceneExportError::readback)?;
2110                if vertices.is_empty() {
2111                    return Err(SceneExportError::unexportable(
2112                        "contour plot has no exportable scene data",
2113                    ));
2114                }
2115                Self::Contour {
2116                    vertices: vertices.into_iter().map(Into::into).collect(),
2117                    bounds_min: vec3_to_xyz(contour.bounds().min),
2118                    bounds_max: vec3_to_xyz(contour.bounds().max),
2119                    base_z: contour.base_z,
2120                    line_width: contour.line_width,
2121                    axes_index,
2122                    label: contour.label.clone(),
2123                    visible: contour.visible,
2124                    force_3d: contour.force_3d,
2125                }
2126            }
2127            PlotElement::ContourFill(fill) => {
2128                let vertices = fill
2129                    .export_scene_vertices()
2130                    .await
2131                    .map_err(SceneExportError::readback)?;
2132                if vertices.is_empty() {
2133                    return Err(SceneExportError::unexportable(
2134                        "filled contour plot has no exportable scene data",
2135                    ));
2136                }
2137                Self::ContourFill {
2138                    vertices: vertices.into_iter().map(Into::into).collect(),
2139                    bounds_min: vec3_to_xyz(fill.bounds().min),
2140                    bounds_max: vec3_to_xyz(fill.bounds().max),
2141                    axes_index,
2142                    label: fill.label.clone(),
2143                    visible: fill.visible,
2144                }
2145            }
2146        };
2147        scene_plot.validate_exportable()?;
2148        Ok(scene_plot)
2149    }
2150
2151    fn validate_exportable(&self) -> Result<(), SceneExportError> {
2152        match self {
2153            ScenePlot::Line { x, y, .. }
2154            | ScenePlot::Scatter { x, y, .. }
2155            | ScenePlot::Stairs { x, y, .. }
2156            | ScenePlot::Stem { x, y, .. }
2157            | ScenePlot::Area { x, y, .. } => {
2158                validate_required_equal_lengths(
2159                    "plot X/Y scene data",
2160                    &[("x", x.len()), ("y", y.len())],
2161                )?;
2162            }
2163            ScenePlot::ErrorBar {
2164                x,
2165                y,
2166                err_low,
2167                err_high,
2168                x_err_low,
2169                x_err_high,
2170                ..
2171            } => {
2172                validate_required_equal_lengths(
2173                    "errorbar scene data",
2174                    &[
2175                        ("x", x.len()),
2176                        ("y", y.len()),
2177                        ("err_low", err_low.len()),
2178                        ("err_high", err_high.len()),
2179                        ("x_err_low", x_err_low.len()),
2180                        ("x_err_high", x_err_high.len()),
2181                    ],
2182                )?;
2183            }
2184            ScenePlot::Quiver {
2185                x, y, z, u, v, w, ..
2186            } => {
2187                validate_required_equal_lengths(
2188                    "quiver vector field scene data",
2189                    &[
2190                        ("x", x.len()),
2191                        ("y", y.len()),
2192                        ("u", u.len()),
2193                        ("v", v.len()),
2194                    ],
2195                )?;
2196                if let Some(z) = z {
2197                    validate_required_equal_lengths(
2198                        "quiver3 position scene data",
2199                        &[("x", x.len()), ("z", z.len())],
2200                    )?;
2201                }
2202                if let Some(w) = w {
2203                    validate_required_equal_lengths(
2204                        "quiver3 vector scene data",
2205                        &[("u", u.len()), ("w", w.len())],
2206                    )?;
2207                }
2208                if z.is_some() != w.is_some() {
2209                    return Err(SceneExportError::unexportable(
2210                        "quiver3 scene data requires both z and w arrays",
2211                    ));
2212                }
2213            }
2214            ScenePlot::Bar {
2215                labels,
2216                values,
2217                histogram_bin_edges,
2218                stack_offsets,
2219                ..
2220            } => {
2221                validate_required_equal_lengths(
2222                    "bar value scene data",
2223                    &[("labels", labels.len()), ("values", values.len())],
2224                )?;
2225                if let Some(edges) = histogram_bin_edges {
2226                    if edges.len() != values.len() + 1 {
2227                        return Err(SceneExportError::unexportable(format!(
2228                            "bar histogram bin edge count ({}) must be values length + 1 ({})",
2229                            edges.len(),
2230                            values.len() + 1
2231                        )));
2232                    }
2233                }
2234                if let Some(offsets) = stack_offsets {
2235                    if offsets.len() != values.len() {
2236                        return Err(SceneExportError::unexportable(format!(
2237                            "bar stack offset count ({}) must match value count ({})",
2238                            offsets.len(),
2239                            values.len()
2240                        )));
2241                    }
2242                }
2243            }
2244            ScenePlot::Surface {
2245                x,
2246                y,
2247                z,
2248                x_grid,
2249                y_grid,
2250                color_grid_rgba,
2251                ..
2252            } => {
2253                match (x_grid, y_grid) {
2254                    (Some(x_grid), Some(y_grid)) => validate_surface_coordinate_grids(
2255                        "surface coordinate grid scene data",
2256                        x_grid,
2257                        y_grid,
2258                        z,
2259                    )?,
2260                    (None, None) => validate_surface_grid("surface grid scene data", x, y, z)?,
2261                    _ => {
2262                        return Err(SceneExportError::unexportable(
2263                            "surface coordinate grid scene data must include both xGrid and yGrid",
2264                        ));
2265                    }
2266                }
2267                if let Some(color_grid) = color_grid_rgba {
2268                    validate_matching_grid_shape("surface color grid scene data", z, color_grid)?;
2269                }
2270            }
2271            ScenePlot::Patch {
2272                vertices, faces, ..
2273            } => {
2274                if vertices.is_empty() || faces.is_empty() {
2275                    return Err(SceneExportError::unexportable(
2276                        "patch plot has no exportable mesh scene data",
2277                    ));
2278                }
2279            }
2280            ScenePlot::Mesh {
2281                vertices,
2282                triangles,
2283                ..
2284            } => {
2285                if vertices.is_empty() || triangles.is_empty() {
2286                    return Err(SceneExportError::unexportable(
2287                        "mesh plot has no exportable mesh scene data",
2288                    ));
2289                }
2290            }
2291            ScenePlot::Line3 { x, y, z, .. } => {
2292                validate_required_equal_lengths(
2293                    "plot3 line scene data",
2294                    &[("x", x.len()), ("y", y.len()), ("z", z.len())],
2295                )?;
2296            }
2297            ScenePlot::Scatter3 {
2298                points,
2299                colors_rgba,
2300                point_sizes,
2301                ..
2302            } => {
2303                if points.is_empty() {
2304                    return Err(SceneExportError::unexportable(
2305                        "scatter3 plot has no exportable point scene data",
2306                    ));
2307                }
2308                if !colors_rgba.is_empty() && colors_rgba.len() != points.len() {
2309                    return Err(SceneExportError::unexportable(format!(
2310                        "scatter3 color count ({}) must match point count ({})",
2311                        colors_rgba.len(),
2312                        points.len()
2313                    )));
2314                }
2315                if let Some(sizes) = point_sizes {
2316                    if sizes.len() != points.len() {
2317                        return Err(SceneExportError::unexportable(format!(
2318                            "scatter3 point size count ({}) must match point count ({})",
2319                            sizes.len(),
2320                            points.len()
2321                        )));
2322                    }
2323                }
2324            }
2325            ScenePlot::Contour { vertices, .. } | ScenePlot::ContourFill { vertices, .. } => {
2326                if vertices.is_empty() {
2327                    return Err(SceneExportError::unexportable(
2328                        "contour plot has no exportable vertex scene data",
2329                    ));
2330                }
2331            }
2332            ScenePlot::Pie { values, .. } => {
2333                if values.is_empty() {
2334                    return Err(SceneExportError::unexportable(
2335                        "pie plot has no exportable value scene data",
2336                    ));
2337                }
2338            }
2339            ScenePlot::ReferenceLine { .. } => {}
2340            ScenePlot::Unsupported { plot_kind, .. } => {
2341                return Err(SceneExportError::unexportable(format!(
2342                    "unsupported plot kind cannot be exported: {plot_kind:?}"
2343                )));
2344            }
2345        }
2346        Ok(())
2347    }
2348
2349    fn from_plot(plot: &PlotElement, axes_index: u32) -> Self {
2350        match plot {
2351            PlotElement::Line(line) => Self::Line {
2352                x: line.x_data.clone(),
2353                y: line.y_data.clone(),
2354                color_rgba: vec4_to_rgba(line.color),
2355                line_width: line.line_width,
2356                line_style: format!("{:?}", line.line_style),
2357                axes_index,
2358                label: line.label.clone(),
2359                visible: line.visible,
2360            },
2361            PlotElement::ReferenceLine(line) => Self::ReferenceLine {
2362                orientation: match line.orientation {
2363                    ReferenceLineOrientation::Vertical => "vertical",
2364                    ReferenceLineOrientation::Horizontal => "horizontal",
2365                }
2366                .into(),
2367                value: line.value,
2368                color_rgba: vec4_to_rgba(line.color),
2369                line_width: line.line_width,
2370                line_style: format!("{:?}", line.line_style),
2371                label: line.label.clone(),
2372                display_name: line.display_name.clone(),
2373                label_orientation: line.label_orientation.clone(),
2374                axes_index,
2375                visible: line.visible,
2376            },
2377            PlotElement::Scatter(scatter) => Self::Scatter {
2378                x: scatter.x_data.clone(),
2379                y: scatter.y_data.clone(),
2380                color_rgba: vec4_to_rgba(scatter.color),
2381                marker_size: scatter.marker_size,
2382                marker_style: format!("{:?}", scatter.marker_style),
2383                axes_index,
2384                label: scatter.label.clone(),
2385                visible: scatter.visible,
2386            },
2387            PlotElement::Bar(bar) => Self::Bar {
2388                labels: bar.labels.clone(),
2389                values: bar.values().unwrap_or(&[]).to_vec(),
2390                histogram_bin_edges: bar.histogram_bin_edges().map(|edges| edges.to_vec()),
2391                color_rgba: vec4_to_rgba(bar.color),
2392                outline_color_rgba: bar.outline_color.map(vec4_to_rgba),
2393                bar_width: bar.bar_width,
2394                outline_width: bar.outline_width,
2395                orientation: format!("{:?}", bar.orientation),
2396                group_index: bar.group_index as u32,
2397                group_count: bar.group_count as u32,
2398                stack_offsets: bar.stack_offsets().map(|offsets| offsets.to_vec()),
2399                polar_histogram: bar.is_polar_histogram(),
2400                polar_histogram_display_style: Some(format!(
2401                    "{:?}",
2402                    bar.polar_histogram_display_style()
2403                )),
2404                axes_index,
2405                label: bar.label.clone(),
2406                visible: bar.visible,
2407            },
2408            PlotElement::ErrorBar(error) => Self::ErrorBar {
2409                x: error.x.clone(),
2410                y: error.y.clone(),
2411                err_low: error.y_neg.clone(),
2412                err_high: error.y_pos.clone(),
2413                x_err_low: error.x_neg.clone(),
2414                x_err_high: error.x_pos.clone(),
2415                orientation: format!("{:?}", error.orientation),
2416                color_rgba: vec4_to_rgba(error.color),
2417                line_width: error.line_width,
2418                line_style: format!("{:?}", error.line_style),
2419                cap_width: error.cap_size,
2420                marker_style: error.marker.as_ref().map(|m| format!("{:?}", m.kind)),
2421                marker_size: error.marker.as_ref().map(|m| m.size),
2422                marker_face_color: error.marker.as_ref().map(|m| vec4_to_rgba(m.face_color)),
2423                marker_edge_color: error.marker.as_ref().map(|m| vec4_to_rgba(m.edge_color)),
2424                marker_filled: error.marker.as_ref().map(|m| m.filled),
2425                axes_index,
2426                label: error.label.clone(),
2427                visible: error.visible,
2428            },
2429            PlotElement::Stairs(stairs) => Self::Stairs {
2430                x: stairs.x.clone(),
2431                y: stairs.y.clone(),
2432                color_rgba: vec4_to_rgba(stairs.color),
2433                line_width: stairs.line_width,
2434                axes_index,
2435                label: stairs.label.clone(),
2436                visible: stairs.visible,
2437            },
2438            PlotElement::Stem(stem) => Self::Stem {
2439                x: stem.x.clone(),
2440                y: stem.y.clone(),
2441                baseline: stem.baseline,
2442                color_rgba: vec4_to_rgba(stem.color),
2443                line_width: stem.line_width,
2444                line_style: format!("{:?}", stem.line_style),
2445                baseline_color_rgba: vec4_to_rgba(stem.baseline_color),
2446                baseline_visible: stem.baseline_visible,
2447                marker_color_rgba: vec4_to_rgba(
2448                    stem.marker
2449                        .as_ref()
2450                        .map(|m| m.face_color)
2451                        .unwrap_or(stem.color),
2452                ),
2453                marker_size: stem.marker.as_ref().map(|m| m.size).unwrap_or(0.0),
2454                marker_filled: stem.marker.as_ref().map(|m| m.filled).unwrap_or(false),
2455                axes_index,
2456                label: stem.label.clone(),
2457                visible: stem.visible,
2458            },
2459            PlotElement::Area(area) => Self::Area {
2460                x: area.x.clone(),
2461                y: area.y.clone(),
2462                lower_y: area.lower_y.clone(),
2463                baseline: area.baseline,
2464                color_rgba: vec4_to_rgba(area.color),
2465                axes_index,
2466                label: area.label.clone(),
2467                visible: area.visible,
2468            },
2469            PlotElement::Quiver(quiver) => Self::Quiver {
2470                x: quiver.x.clone(),
2471                y: quiver.y.clone(),
2472                z: quiver.z.clone(),
2473                u: quiver.u.clone(),
2474                v: quiver.v.clone(),
2475                w: quiver.w.clone(),
2476                color_rgba: vec4_to_rgba(quiver.color),
2477                line_width: quiver.line_width,
2478                scale: quiver.scale,
2479                head_size: quiver.head_size,
2480                axes_index,
2481                label: quiver.label.clone(),
2482                visible: quiver.visible,
2483            },
2484            PlotElement::Surface(surface) => Self::Surface {
2485                x: surface.x_data.clone(),
2486                y: surface.y_data.clone(),
2487                z: surface.z_data.clone().unwrap_or_default(),
2488                x_grid: surface.x_grid.clone(),
2489                y_grid: surface.y_grid.clone(),
2490                colormap: surface.colormap.to_serialized_token(),
2491                shading_mode: format!("{:?}", surface.shading_mode),
2492                wireframe: surface.wireframe,
2493                alpha: surface.alpha,
2494                flatten_z: surface.flatten_z,
2495                image_mode: surface.image_mode,
2496                color_grid_rgba: surface.color_grid.as_ref().map(|grid| {
2497                    grid.iter()
2498                        .map(|row| row.iter().map(|color| vec4_to_rgba(*color)).collect())
2499                        .collect()
2500                }),
2501                color_limits: surface.color_limits.map(|(lo, hi)| [lo, hi]),
2502                axes_index,
2503                label: surface.label.clone(),
2504                visible: surface.visible,
2505            },
2506            PlotElement::Patch(patch) => Self::Patch {
2507                vertices: patch
2508                    .vertices()
2509                    .iter()
2510                    .map(|point| vec3_to_xyz(*point))
2511                    .collect(),
2512                faces: patch
2513                    .faces()
2514                    .iter()
2515                    .map(|face| face.iter().map(|idx| *idx as u32).collect())
2516                    .collect(),
2517                face_color_rgba: vec4_to_rgba(patch.face_color()),
2518                edge_color_rgba: vec4_to_rgba(patch.edge_color()),
2519                face_color_mode: format!("{:?}", patch.face_color_mode()),
2520                edge_color_mode: format!("{:?}", patch.edge_color_mode()),
2521                face_alpha: patch.face_alpha(),
2522                edge_alpha: patch.edge_alpha(),
2523                line_width: patch.line_width(),
2524                axes_index,
2525                label: patch.label().map(str::to_string),
2526                visible: patch.is_visible(),
2527                force_3d: patch.force_3d(),
2528            },
2529            PlotElement::Mesh(mesh) => Self::Mesh {
2530                vertices: mesh
2531                    .vertices()
2532                    .iter()
2533                    .map(|point| vec3_to_xyz(*point))
2534                    .collect(),
2535                triangles: mesh.triangles().to_vec(),
2536                mesh_id: mesh.mesh_id().map(str::to_string),
2537                face_color_rgba: vec4_to_rgba(mesh.face_color()),
2538                edge_color_rgba: vec4_to_rgba(mesh.edge_color()),
2539                face_alpha: mesh.face_alpha(),
2540                edge_alpha: mesh.edge_alpha(),
2541                edge_width: mesh.edge_width(),
2542                edge_mode: mesh.edge_mode().as_str().to_string(),
2543                feature_edge_groups: mesh
2544                    .feature_edge_groups()
2545                    .map(|groups| groups.to_vec())
2546                    .unwrap_or_default(),
2547                vertex_colors_rgba: mesh
2548                    .vertex_colors()
2549                    .map(|colors| colors.iter().copied().map(vec4_to_rgba).collect())
2550                    .unwrap_or_default(),
2551                triangle_colors_rgba: mesh
2552                    .triangle_colors()
2553                    .map(|colors| colors.iter().copied().map(vec4_to_rgba).collect())
2554                    .unwrap_or_default(),
2555                axes_index,
2556                label: mesh.label().map(str::to_string),
2557                regions: mesh.regions().iter().map(Into::into).collect(),
2558                highlighted_region_id: mesh.highlighted_region_id().map(str::to_string),
2559                highlight_color_rgba: Some(vec4_to_rgba(mesh.highlight_color())),
2560                scalar_field: mesh.scalar_field().map(|field| Box::new(field.into())),
2561                vector_field: mesh.vector_field().map(|field| Box::new(field.into())),
2562                deformation: mesh.deformation().map(|field| Box::new(field.into())),
2563                visible: mesh.is_visible(),
2564            },
2565            PlotElement::Line3(line) => Self::Line3 {
2566                x: line.x_data.clone(),
2567                y: line.y_data.clone(),
2568                z: line.z_data.clone(),
2569                color_rgba: vec4_to_rgba(line.color),
2570                line_width: line.line_width,
2571                line_style: format!("{:?}", line.line_style),
2572                axes_index,
2573                label: line.label.clone(),
2574                visible: line.visible,
2575            },
2576            PlotElement::Scatter3(scatter3) => Self::Scatter3 {
2577                points: scatter3
2578                    .points
2579                    .iter()
2580                    .map(|point| vec3_to_xyz(*point))
2581                    .collect(),
2582                colors_rgba: scatter3
2583                    .colors
2584                    .iter()
2585                    .map(|color| vec4_to_rgba(*color))
2586                    .collect(),
2587                point_size: scatter3.point_size,
2588                point_sizes: scatter3.point_sizes.clone(),
2589                axes_index,
2590                label: scatter3.label.clone(),
2591                visible: scatter3.visible,
2592            },
2593            PlotElement::Contour(contour) => Self::Contour {
2594                vertices: contour
2595                    .cpu_vertices()
2596                    .unwrap_or(&[])
2597                    .iter()
2598                    .cloned()
2599                    .map(Into::into)
2600                    .collect(),
2601                bounds_min: vec3_to_xyz(contour.bounds().min),
2602                bounds_max: vec3_to_xyz(contour.bounds().max),
2603                base_z: contour.base_z,
2604                line_width: contour.line_width,
2605                axes_index,
2606                label: contour.label.clone(),
2607                visible: contour.visible,
2608                force_3d: contour.force_3d,
2609            },
2610            PlotElement::ContourFill(fill) => Self::ContourFill {
2611                vertices: fill
2612                    .cpu_vertices()
2613                    .unwrap_or(&[])
2614                    .iter()
2615                    .cloned()
2616                    .map(Into::into)
2617                    .collect(),
2618                bounds_min: vec3_to_xyz(fill.bounds().min),
2619                bounds_max: vec3_to_xyz(fill.bounds().max),
2620                axes_index,
2621                label: fill.label.clone(),
2622                visible: fill.visible,
2623            },
2624            PlotElement::Pie(pie) => Self::Pie {
2625                values: pie.values.clone(),
2626                colors_rgba: pie.colors.iter().map(|c| vec4_to_rgba(*c)).collect(),
2627                slice_labels: pie.slice_labels.clone(),
2628                label_format: pie.label_format.clone(),
2629                explode: pie.explode.clone(),
2630                axes_index,
2631                label: pie.label.clone(),
2632                visible: pie.visible,
2633            },
2634        }
2635    }
2636
2637    fn apply_to_figure(self, figure: &mut Figure) -> Result<(), String> {
2638        match self {
2639            ScenePlot::Line {
2640                x,
2641                y,
2642                color_rgba,
2643                line_width,
2644                line_style,
2645                axes_index,
2646                label,
2647                visible,
2648            } => {
2649                let mut line = LinePlot::new(x, y)?;
2650                line.set_color(rgba_to_vec4(color_rgba));
2651                line.set_line_width(line_width);
2652                line.set_line_style(parse_line_style(&line_style));
2653                line.label = label;
2654                line.set_visible(visible);
2655                figure.add_line_plot_on_axes(line, axes_index as usize);
2656            }
2657            ScenePlot::ReferenceLine {
2658                orientation,
2659                value,
2660                color_rgba,
2661                line_width,
2662                line_style,
2663                label,
2664                display_name,
2665                label_orientation,
2666                axes_index,
2667                visible,
2668            } => {
2669                let orientation = parse_reference_line_orientation(&orientation)?;
2670                let mut line = ReferenceLine::new(orientation, value)?.with_style(
2671                    rgba_to_vec4(color_rgba),
2672                    line_width,
2673                    parse_line_style(&line_style),
2674                );
2675                line.label = label;
2676                line.display_name = display_name;
2677                line.label_orientation = label_orientation;
2678                line.visible = visible;
2679                figure.add_reference_line_on_axes(line, axes_index as usize);
2680            }
2681            ScenePlot::Scatter {
2682                x,
2683                y,
2684                color_rgba,
2685                marker_size,
2686                marker_style,
2687                axes_index,
2688                label,
2689                visible,
2690            } => {
2691                let mut scatter = ScatterPlot::new(x, y)?;
2692                scatter.set_color(rgba_to_vec4(color_rgba));
2693                scatter.set_marker_size(marker_size);
2694                scatter.set_marker_style(parse_marker_style(&marker_style));
2695                scatter.label = label;
2696                scatter.set_visible(visible);
2697                figure.add_scatter_plot_on_axes(scatter, axes_index as usize);
2698            }
2699            ScenePlot::Bar {
2700                labels,
2701                values,
2702                histogram_bin_edges,
2703                polar_histogram,
2704                polar_histogram_display_style,
2705                color_rgba,
2706                outline_color_rgba,
2707                bar_width,
2708                outline_width,
2709                orientation,
2710                group_index,
2711                group_count,
2712                stack_offsets,
2713                axes_index,
2714                label,
2715                visible,
2716            } => {
2717                let mut bar = BarChart::new(labels, values)?
2718                    .with_style(rgba_to_vec4(color_rgba), bar_width)
2719                    .with_orientation(parse_bar_orientation(&orientation))
2720                    .with_group(group_index as usize, group_count as usize);
2721                if let Some(edges) = histogram_bin_edges {
2722                    bar.set_histogram_bin_edges(edges);
2723                }
2724                if polar_histogram {
2725                    bar.set_polar_histogram(true);
2726                    if let Some(style) = polar_histogram_display_style {
2727                        if style.eq_ignore_ascii_case("stairs") {
2728                            bar.set_polar_histogram_display_style(
2729                                crate::plots::PolarHistogramDisplayStyle::Stairs,
2730                            );
2731                        }
2732                    }
2733                }
2734                if let Some(offsets) = stack_offsets {
2735                    bar = bar.with_stack_offsets(offsets);
2736                }
2737                if let Some(outline) = outline_color_rgba {
2738                    bar = bar.with_outline(rgba_to_vec4(outline), outline_width);
2739                }
2740                bar.label = label;
2741                bar.set_visible(visible);
2742                figure.add_bar_chart_on_axes(bar, axes_index as usize);
2743            }
2744            ScenePlot::ErrorBar {
2745                x,
2746                y,
2747                err_low,
2748                err_high,
2749                x_err_low,
2750                x_err_high,
2751                orientation,
2752                color_rgba,
2753                line_width,
2754                line_style,
2755                cap_width,
2756                marker_style,
2757                marker_size,
2758                marker_face_color,
2759                marker_edge_color,
2760                marker_filled,
2761                axes_index,
2762                label,
2763                visible,
2764            } => {
2765                let mut error = if orientation.eq_ignore_ascii_case("Both") {
2766                    ErrorBar::new_both(x, y, x_err_low, x_err_high, err_low, err_high)?
2767                } else {
2768                    ErrorBar::new_vertical(x, y, err_low, err_high)?
2769                }
2770                .with_style(
2771                    rgba_to_vec4(color_rgba),
2772                    line_width,
2773                    parse_line_style_name(&line_style),
2774                    cap_width,
2775                );
2776                if let Some(size) = marker_size {
2777                    error.set_marker(Some(crate::plots::line::LineMarkerAppearance {
2778                        kind: parse_marker_style(marker_style.as_deref().unwrap_or("Circle")),
2779                        size,
2780                        edge_color: marker_edge_color
2781                            .map(rgba_to_vec4)
2782                            .unwrap_or(rgba_to_vec4(color_rgba)),
2783                        face_color: marker_face_color
2784                            .map(rgba_to_vec4)
2785                            .unwrap_or(rgba_to_vec4(color_rgba)),
2786                        filled: marker_filled.unwrap_or(false),
2787                    }));
2788                }
2789                error.label = label;
2790                error.set_visible(visible);
2791                figure.add_errorbar_on_axes(error, axes_index as usize);
2792            }
2793            ScenePlot::Stairs {
2794                x,
2795                y,
2796                color_rgba,
2797                line_width,
2798                axes_index,
2799                label,
2800                visible,
2801            } => {
2802                let mut stairs = StairsPlot::new(x, y)?;
2803                stairs.color = rgba_to_vec4(color_rgba);
2804                stairs.line_width = line_width;
2805                stairs.label = label;
2806                stairs.set_visible(visible);
2807                figure.add_stairs_plot_on_axes(stairs, axes_index as usize);
2808            }
2809            ScenePlot::Stem {
2810                x,
2811                y,
2812                baseline,
2813                color_rgba,
2814                line_width,
2815                line_style,
2816                baseline_color_rgba,
2817                baseline_visible,
2818                marker_color_rgba,
2819                marker_size,
2820                marker_filled,
2821                axes_index,
2822                label,
2823                visible,
2824            } => {
2825                let mut stem = StemPlot::new(x, y)?;
2826                stem = stem
2827                    .with_style(
2828                        rgba_to_vec4(color_rgba),
2829                        line_width,
2830                        parse_line_style_name(&line_style),
2831                        baseline,
2832                    )
2833                    .with_baseline_style(rgba_to_vec4(baseline_color_rgba), baseline_visible);
2834                if marker_size > 0.0 {
2835                    stem.set_marker(Some(crate::plots::line::LineMarkerAppearance {
2836                        kind: crate::plots::scatter::MarkerStyle::Circle,
2837                        size: marker_size,
2838                        edge_color: rgba_to_vec4(marker_color_rgba),
2839                        face_color: rgba_to_vec4(marker_color_rgba),
2840                        filled: marker_filled,
2841                    }));
2842                }
2843                stem.label = label;
2844                stem.set_visible(visible);
2845                figure.add_stem_plot_on_axes(stem, axes_index as usize);
2846            }
2847            ScenePlot::Area {
2848                x,
2849                y,
2850                lower_y,
2851                baseline,
2852                color_rgba,
2853                axes_index,
2854                label,
2855                visible,
2856            } => {
2857                let mut area = AreaPlot::new(x, y)?;
2858                if let Some(lower_y) = lower_y {
2859                    area = area.with_lower_curve(lower_y);
2860                }
2861                area.baseline = baseline;
2862                area.color = rgba_to_vec4(color_rgba);
2863                area.label = label;
2864                area.set_visible(visible);
2865                figure.add_area_plot_on_axes(area, axes_index as usize);
2866            }
2867            ScenePlot::Quiver {
2868                x,
2869                y,
2870                z,
2871                u,
2872                v,
2873                w,
2874                color_rgba,
2875                line_width,
2876                scale,
2877                head_size,
2878                axes_index,
2879                label,
2880                visible,
2881            } => {
2882                let mut quiver = if let (Some(z), Some(w)) = (z, w) {
2883                    QuiverPlot::new3d(x, y, z, u, v, w)?
2884                } else {
2885                    QuiverPlot::new(x, y, u, v)?
2886                }
2887                .with_style(rgba_to_vec4(color_rgba), line_width, scale, head_size)
2888                .with_label(label.unwrap_or_else(|| "Data".to_string()));
2889                quiver.set_visible(visible);
2890                figure.add_quiver_plot_on_axes(quiver, axes_index as usize);
2891            }
2892            ScenePlot::Surface {
2893                x,
2894                y,
2895                z,
2896                x_grid,
2897                y_grid,
2898                colormap,
2899                shading_mode,
2900                wireframe,
2901                alpha,
2902                flatten_z,
2903                image_mode,
2904                color_grid_rgba,
2905                color_limits,
2906                axes_index,
2907                label,
2908                visible,
2909            } => {
2910                let mut surface = match (x_grid, y_grid) {
2911                    (Some(x_grid), Some(y_grid)) => {
2912                        SurfacePlot::from_coordinate_grids(x_grid, y_grid, z)?
2913                    }
2914                    (None, None) => SurfacePlot::new(x, y, z)?,
2915                    _ => {
2916                        return Err(
2917                            "surface scene must include both xGrid and yGrid for coordinate grids"
2918                                .to_string(),
2919                        );
2920                    }
2921                };
2922                surface.colormap = parse_colormap(&colormap);
2923                surface.shading_mode = parse_shading_mode(&shading_mode);
2924                surface.wireframe = wireframe;
2925                surface.alpha = alpha.clamp(0.0, 1.0);
2926                surface.flatten_z = flatten_z;
2927                surface.image_mode = image_mode;
2928                surface.color_grid = color_grid_rgba.map(|grid| {
2929                    grid.into_iter()
2930                        .map(|row| row.into_iter().map(rgba_to_vec4).collect())
2931                        .collect()
2932                });
2933                surface.color_limits = color_limits.map(|[lo, hi]| (lo, hi));
2934                surface.label = label;
2935                surface.visible = visible;
2936                figure.add_surface_plot_on_axes(surface, axes_index as usize);
2937            }
2938            ScenePlot::Patch {
2939                vertices,
2940                faces,
2941                face_color_rgba,
2942                edge_color_rgba,
2943                face_color_mode,
2944                edge_color_mode,
2945                face_alpha,
2946                edge_alpha,
2947                line_width,
2948                axes_index,
2949                label,
2950                visible,
2951                force_3d,
2952            } => {
2953                let vertices: Vec<Vec3> = vertices.into_iter().map(xyz_to_vec3).collect();
2954                let faces: Vec<Vec<usize>> = faces
2955                    .into_iter()
2956                    .map(|face| face.into_iter().map(|idx| idx as usize).collect())
2957                    .collect();
2958                let mut patch = PatchPlot::new(vertices, faces)?;
2959                patch.set_face_color(rgba_to_vec4(face_color_rgba));
2960                patch.set_edge_color(rgba_to_vec4(edge_color_rgba));
2961                patch.set_face_color_mode(parse_patch_face_color_mode(&face_color_mode));
2962                patch.set_edge_color_mode(parse_patch_edge_color_mode(&edge_color_mode));
2963                patch.set_face_alpha(face_alpha);
2964                patch.set_edge_alpha(edge_alpha);
2965                patch.set_line_width(line_width);
2966                patch.set_label(label);
2967                patch.set_visible(visible);
2968                patch.set_force_3d(force_3d);
2969                figure.add_patch_plot_on_axes(patch, axes_index as usize);
2970            }
2971            ScenePlot::Mesh {
2972                vertices,
2973                triangles,
2974                mesh_id,
2975                face_color_rgba,
2976                edge_color_rgba,
2977                face_alpha,
2978                edge_alpha,
2979                edge_width,
2980                edge_mode,
2981                feature_edge_groups,
2982                vertex_colors_rgba,
2983                triangle_colors_rgba,
2984                axes_index,
2985                label,
2986                regions,
2987                highlighted_region_id,
2988                highlight_color_rgba,
2989                scalar_field,
2990                vector_field,
2991                deformation,
2992                visible,
2993            } => {
2994                let vertices: Vec<Vec3> = vertices.into_iter().map(xyz_to_vec3).collect();
2995                let mut mesh = MeshPlot::new(vertices, triangles)?;
2996                mesh.set_mesh_id(mesh_id);
2997                mesh.set_face_color(rgba_to_vec4(face_color_rgba));
2998                mesh.set_edge_color(rgba_to_vec4(edge_color_rgba));
2999                mesh.set_face_alpha(face_alpha);
3000                mesh.set_edge_alpha(edge_alpha);
3001                mesh.set_edge_width(edge_width);
3002                mesh.set_edge_mode(parse_mesh_edge_mode(&edge_mode));
3003                if !feature_edge_groups.is_empty() {
3004                    mesh.set_feature_edge_groups(Some(feature_edge_groups))?;
3005                }
3006                if !vertex_colors_rgba.is_empty() {
3007                    mesh.set_vertex_colors(Some(
3008                        vertex_colors_rgba.into_iter().map(rgba_to_vec4).collect(),
3009                    ))?;
3010                }
3011                if !triangle_colors_rgba.is_empty() {
3012                    mesh.set_triangle_colors(Some(
3013                        triangle_colors_rgba.into_iter().map(rgba_to_vec4).collect(),
3014                    ))?;
3015                }
3016                mesh.set_label(label);
3017                mesh.set_regions(regions.into_iter().map(Into::into).collect());
3018                mesh.set_highlighted_region_id(highlighted_region_id);
3019                if let Some(color) = highlight_color_rgba {
3020                    mesh.set_highlight_color(rgba_to_vec4(color));
3021                }
3022                if let Some(field) = scalar_field {
3023                    mesh.set_scalar_field(Some((*field).try_into()?))?;
3024                }
3025                if let Some(field) = vector_field {
3026                    mesh.set_vector_field(Some((*field).try_into()?))?;
3027                }
3028                if let Some(field) = deformation {
3029                    mesh.set_deformation(Some((*field).into()))?;
3030                }
3031                mesh.set_visible(visible);
3032                figure.add_mesh_plot_on_axes(mesh, axes_index as usize);
3033            }
3034            ScenePlot::Line3 {
3035                x,
3036                y,
3037                z,
3038                color_rgba,
3039                line_width,
3040                line_style,
3041                axes_index,
3042                label,
3043                visible,
3044            } => {
3045                let mut plot = Line3Plot::new(x, y, z)?
3046                    .with_style(
3047                        rgba_to_vec4(color_rgba),
3048                        line_width,
3049                        parse_line_style_name(&line_style),
3050                    )
3051                    .with_label(label.unwrap_or_else(|| "Data".to_string()));
3052                plot.set_visible(visible);
3053                figure.add_line3_plot_on_axes(plot, axes_index as usize);
3054            }
3055            ScenePlot::Scatter3 {
3056                points,
3057                colors_rgba,
3058                point_size,
3059                point_sizes,
3060                axes_index,
3061                label,
3062                visible,
3063            } => {
3064                let points: Vec<Vec3> = points.into_iter().map(xyz_to_vec3).collect();
3065                let colors: Vec<Vec4> = colors_rgba.into_iter().map(rgba_to_vec4).collect();
3066                let mut scatter3 = Scatter3Plot::new(points)?;
3067                if !colors.is_empty() {
3068                    scatter3 = scatter3.with_colors(colors)?;
3069                }
3070                scatter3.point_size = point_size.max(1.0);
3071                scatter3.point_sizes = point_sizes;
3072                scatter3.label = label;
3073                scatter3.visible = visible;
3074                figure.add_scatter3_plot_on_axes(scatter3, axes_index as usize);
3075            }
3076            ScenePlot::Contour {
3077                vertices,
3078                bounds_min,
3079                bounds_max,
3080                base_z,
3081                line_width,
3082                axes_index,
3083                label,
3084                visible,
3085                force_3d,
3086            } => {
3087                let mut contour = ContourPlot::from_vertices(
3088                    vertices.into_iter().map(Into::into).collect(),
3089                    base_z,
3090                    serialized_bounds(bounds_min, bounds_max),
3091                )
3092                .with_line_width(line_width)
3093                .with_force_3d(force_3d);
3094                contour.label = label;
3095                contour.set_visible(visible);
3096                figure.add_contour_plot_on_axes(contour, axes_index as usize);
3097            }
3098            ScenePlot::ContourFill {
3099                vertices,
3100                bounds_min,
3101                bounds_max,
3102                axes_index,
3103                label,
3104                visible,
3105            } => {
3106                let mut fill = ContourFillPlot::from_vertices(
3107                    vertices.into_iter().map(Into::into).collect(),
3108                    serialized_bounds(bounds_min, bounds_max),
3109                );
3110                fill.label = label;
3111                fill.set_visible(visible);
3112                figure.add_contour_fill_plot_on_axes(fill, axes_index as usize);
3113            }
3114            ScenePlot::Pie {
3115                values,
3116                colors_rgba,
3117                slice_labels,
3118                label_format,
3119                explode,
3120                axes_index,
3121                label,
3122                visible,
3123            } => {
3124                let mut pie = crate::plots::PieChart::new(
3125                    values,
3126                    Some(colors_rgba.into_iter().map(rgba_to_vec4).collect()),
3127                )?
3128                .with_slice_labels(slice_labels)
3129                .with_explode(explode);
3130                if let Some(fmt) = label_format {
3131                    pie = pie.with_label_format(fmt);
3132                }
3133                pie.label = label;
3134                pie.set_visible(visible);
3135                figure.add_pie_chart_on_axes(pie, axes_index as usize);
3136            }
3137            ScenePlot::Unsupported { .. } => {}
3138        }
3139        Ok(())
3140    }
3141}
3142
3143fn parse_line_style(value: &str) -> crate::plots::LineStyle {
3144    match value {
3145        "None" | "none" => crate::plots::LineStyle::None,
3146        "Dashed" => crate::plots::LineStyle::Dashed,
3147        "Dotted" => crate::plots::LineStyle::Dotted,
3148        "DashDot" => crate::plots::LineStyle::DashDot,
3149        _ => crate::plots::LineStyle::Solid,
3150    }
3151}
3152
3153fn parse_bar_orientation(value: &str) -> crate::plots::bar::Orientation {
3154    match value {
3155        "Horizontal" => crate::plots::bar::Orientation::Horizontal,
3156        _ => crate::plots::bar::Orientation::Vertical,
3157    }
3158}
3159
3160fn parse_reference_line_orientation(value: &str) -> Result<ReferenceLineOrientation, String> {
3161    match value.to_ascii_lowercase().as_str() {
3162        "horizontal" => Ok(ReferenceLineOrientation::Horizontal),
3163        "vertical" => Ok(ReferenceLineOrientation::Vertical),
3164        _ => Err(format!(
3165            "unknown reference line orientation '{value}'; expected 'horizontal' or 'vertical'"
3166        )),
3167    }
3168}
3169
3170fn parse_marker_style(value: &str) -> MarkerStyle {
3171    match value {
3172        "Square" => MarkerStyle::Square,
3173        "Triangle" => MarkerStyle::Triangle,
3174        "Diamond" => MarkerStyle::Diamond,
3175        "Plus" => MarkerStyle::Plus,
3176        "Cross" => MarkerStyle::Cross,
3177        "Star" => MarkerStyle::Star,
3178        "Hexagon" => MarkerStyle::Hexagon,
3179        _ => MarkerStyle::Circle,
3180    }
3181}
3182
3183fn parse_colormap(value: &str) -> ColorMap {
3184    ColorMap::from_serialized_token(value).unwrap_or(ColorMap::Parula)
3185}
3186
3187fn parse_shading_mode(value: &str) -> ShadingMode {
3188    match value {
3189        "Flat" => ShadingMode::Flat,
3190        "Smooth" => ShadingMode::Smooth,
3191        "Faceted" => ShadingMode::Faceted,
3192        "None" => ShadingMode::None,
3193        _ => ShadingMode::Smooth,
3194    }
3195}
3196
3197fn parse_patch_face_color_mode(value: &str) -> PatchFaceColorMode {
3198    match value {
3199        "None" => PatchFaceColorMode::None,
3200        "Flat" => PatchFaceColorMode::Flat,
3201        _ => PatchFaceColorMode::Color,
3202    }
3203}
3204
3205fn parse_patch_edge_color_mode(value: &str) -> PatchEdgeColorMode {
3206    match value {
3207        "None" => PatchEdgeColorMode::None,
3208        _ => PatchEdgeColorMode::Color,
3209    }
3210}
3211
3212fn parse_mesh_edge_mode(value: &str) -> MeshEdgeMode {
3213    MeshEdgeMode::parse(value).unwrap_or_default()
3214}
3215
3216fn xyz_to_vec3(value: [f32; 3]) -> Vec3 {
3217    Vec3::new(value[0], value[1], value[2])
3218}
3219
3220fn serialized_bounds(min: [f32; 3], max: [f32; 3]) -> BoundingBox {
3221    BoundingBox::new(xyz_to_vec3(min), xyz_to_vec3(max))
3222}
3223
3224fn vec3_to_xyz(value: Vec3) -> [f32; 3] {
3225    [value.x, value.y, value.z]
3226}
3227
3228fn rgba_to_vec4(value: [f32; 4]) -> Vec4 {
3229    Vec4::new(value[0], value[1], value[2], value[3])
3230}
3231
3232#[derive(Debug, Clone, Serialize, Deserialize)]
3233#[serde(rename_all = "camelCase")]
3234pub struct SerializedVertex {
3235    position: [f32; 3],
3236    color_rgba: [f32; 4],
3237    normal: [f32; 3],
3238    tex_coords: [f32; 2],
3239}
3240
3241impl From<Vertex> for SerializedVertex {
3242    fn from(value: Vertex) -> Self {
3243        Self {
3244            position: value.position,
3245            color_rgba: value.color,
3246            normal: value.normal,
3247            tex_coords: value.tex_coords,
3248        }
3249    }
3250}
3251
3252impl From<SerializedVertex> for Vertex {
3253    fn from(value: SerializedVertex) -> Self {
3254        Self {
3255            position: value.position,
3256            color: value.color_rgba,
3257            normal: value.normal,
3258            tex_coords: value.tex_coords,
3259        }
3260    }
3261}
3262
3263/// Serialized legend entry for frontend rendering.
3264#[derive(Debug, Clone, Serialize, Deserialize)]
3265#[serde(rename_all = "camelCase")]
3266pub struct FigureLegendEntry {
3267    pub label: String,
3268    pub plot_type: PlotKind,
3269    pub color_rgba: [f32; 4],
3270}
3271
3272impl From<LegendEntry> for FigureLegendEntry {
3273    fn from(entry: LegendEntry) -> Self {
3274        Self {
3275            label: entry.label,
3276            plot_type: PlotKind::from(entry.plot_type),
3277            color_rgba: vec4_to_rgba(entry.color),
3278        }
3279    }
3280}
3281
3282/// Serializable plot kind values consumed by UI + transports.
3283#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
3284#[serde(rename_all = "snake_case")]
3285pub enum PlotKind {
3286    Line,
3287    Line3,
3288    Scatter,
3289    Bar,
3290    ErrorBar,
3291    Stairs,
3292    Stem,
3293    Area,
3294    Quiver,
3295    Pie,
3296    Image,
3297    Surface,
3298    Mesh,
3299    Patch,
3300    Scatter3,
3301    Contour,
3302    ContourFill,
3303    ReferenceLine,
3304}
3305
3306impl From<PlotType> for PlotKind {
3307    fn from(value: PlotType) -> Self {
3308        match value {
3309            PlotType::Line => Self::Line,
3310            PlotType::Line3 => Self::Line3,
3311            PlotType::Scatter => Self::Scatter,
3312            PlotType::Bar => Self::Bar,
3313            PlotType::ErrorBar => Self::ErrorBar,
3314            PlotType::Stairs => Self::Stairs,
3315            PlotType::Stem => Self::Stem,
3316            PlotType::Area => Self::Area,
3317            PlotType::Quiver => Self::Quiver,
3318            PlotType::Pie => Self::Pie,
3319            PlotType::Surface => Self::Surface,
3320            PlotType::Mesh => Self::Mesh,
3321            PlotType::Patch => Self::Patch,
3322            PlotType::Scatter3 => Self::Scatter3,
3323            PlotType::Contour => Self::Contour,
3324            PlotType::ContourFill => Self::ContourFill,
3325            PlotType::ReferenceLine => Self::ReferenceLine,
3326        }
3327    }
3328}
3329
3330fn parse_line_style_name(name: &str) -> crate::plots::line::LineStyle {
3331    match name.to_ascii_lowercase().as_str() {
3332        "none" => crate::plots::line::LineStyle::None,
3333        "dashed" => crate::plots::line::LineStyle::Dashed,
3334        "dotted" => crate::plots::line::LineStyle::Dotted,
3335        "dashdot" => crate::plots::line::LineStyle::DashDot,
3336        _ => crate::plots::line::LineStyle::Solid,
3337    }
3338}
3339
3340fn parse_colormap_name(name: &str) -> crate::plots::surface::ColorMap {
3341    crate::plots::surface::ColorMap::from_serialized_token(name)
3342        .unwrap_or(crate::plots::surface::ColorMap::Parula)
3343}
3344
3345fn vec4_to_rgba(value: Vec4) -> [f32; 4] {
3346    [value.x, value.y, value.z, value.w]
3347}
3348
3349fn deserialize_f64_lossy<'de, D>(deserializer: D) -> Result<f64, D::Error>
3350where
3351    D: serde::Deserializer<'de>,
3352{
3353    let value = Option::<f64>::deserialize(deserializer)?;
3354    Ok(value.unwrap_or(f64::NAN))
3355}
3356
3357fn deserialize_vec_f64_lossy<'de, D>(deserializer: D) -> Result<Vec<f64>, D::Error>
3358where
3359    D: serde::Deserializer<'de>,
3360{
3361    let values = Vec::<Option<f64>>::deserialize(deserializer)?;
3362    Ok(values
3363        .into_iter()
3364        .map(|value| value.unwrap_or(f64::NAN))
3365        .collect())
3366}
3367
3368fn deserialize_vec_f32_lossy<'de, D>(deserializer: D) -> Result<Vec<f32>, D::Error>
3369where
3370    D: serde::Deserializer<'de>,
3371{
3372    let values = Vec::<Option<f32>>::deserialize(deserializer)?;
3373    Ok(values
3374        .into_iter()
3375        .map(|value| value.unwrap_or(f32::NAN))
3376        .collect())
3377}
3378
3379fn deserialize_option_vec_f64_lossy<'de, D>(deserializer: D) -> Result<Option<Vec<f64>>, D::Error>
3380where
3381    D: serde::Deserializer<'de>,
3382{
3383    let values = Option::<Vec<Option<f64>>>::deserialize(deserializer)?;
3384    Ok(values.map(|items| {
3385        items
3386            .into_iter()
3387            .map(|value| value.unwrap_or(f64::NAN))
3388            .collect()
3389    }))
3390}
3391
3392fn deserialize_matrix_f64_lossy<'de, D>(deserializer: D) -> Result<Vec<Vec<f64>>, D::Error>
3393where
3394    D: serde::Deserializer<'de>,
3395{
3396    let rows = Vec::<Vec<Option<f64>>>::deserialize(deserializer)?;
3397    Ok(rows
3398        .into_iter()
3399        .map(|row| {
3400            row.into_iter()
3401                .map(|value| value.unwrap_or(f64::NAN))
3402                .collect()
3403        })
3404        .collect())
3405}
3406
3407fn deserialize_option_pair_f64_lossy<'de, D>(deserializer: D) -> Result<Option<[f64; 2]>, D::Error>
3408where
3409    D: serde::Deserializer<'de>,
3410{
3411    let value = Option::<[Option<f64>; 2]>::deserialize(deserializer)?;
3412    Ok(value.map(|pair| [pair[0].unwrap_or(f64::NAN), pair[1].unwrap_or(f64::NAN)]))
3413}
3414
3415fn deserialize_option_vec_f32_lossy<'de, D>(deserializer: D) -> Result<Option<Vec<f32>>, D::Error>
3416where
3417    D: serde::Deserializer<'de>,
3418{
3419    let values = Option::<Vec<Option<f32>>>::deserialize(deserializer)?;
3420    Ok(values.map(|items| {
3421        items
3422            .into_iter()
3423            .map(|value| value.unwrap_or(f32::NAN))
3424            .collect()
3425    }))
3426}
3427
3428fn deserialize_vec_xyz_f32_lossy<'de, D>(deserializer: D) -> Result<Vec<[f32; 3]>, D::Error>
3429where
3430    D: serde::Deserializer<'de>,
3431{
3432    let values = Vec::<[Option<f32>; 3]>::deserialize(deserializer)?;
3433    Ok(values
3434        .into_iter()
3435        .map(|xyz| {
3436            [
3437                xyz[0].unwrap_or(f32::NAN),
3438                xyz[1].unwrap_or(f32::NAN),
3439                xyz[2].unwrap_or(f32::NAN),
3440            ]
3441        })
3442        .collect())
3443}
3444
3445fn deserialize_vec_rgba_f32_lossy<'de, D>(deserializer: D) -> Result<Vec<[f32; 4]>, D::Error>
3446where
3447    D: serde::Deserializer<'de>,
3448{
3449    let values = Vec::<[Option<f32>; 4]>::deserialize(deserializer)?;
3450    Ok(values
3451        .into_iter()
3452        .map(|rgba| {
3453            [
3454                rgba[0].unwrap_or(f32::NAN),
3455                rgba[1].unwrap_or(f32::NAN),
3456                rgba[2].unwrap_or(f32::NAN),
3457                rgba[3].unwrap_or(f32::NAN),
3458            ]
3459        })
3460        .collect())
3461}
3462
3463#[cfg(test)]
3464mod tests {
3465    use super::*;
3466    use crate::plots::{
3467        AreaPlot, BarChart, ContourFillPlot, ContourPlot, ErrorBar, Figure, Line3Plot, LinePlot,
3468        MeshPlot, PatchPlot, PieChart, PolarHistogramDisplayStyle, QuiverPlot, ReferenceLine,
3469        ReferenceLineOrientation, Scatter3Plot, ScatterPlot, StairsPlot, StemPlot, SurfacePlot,
3470    };
3471    use glam::{Vec3, Vec4};
3472
3473    #[test]
3474    fn async_scene_export_covers_every_plot_element_variant() {
3475        let bounds = BoundingBox::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 1.0));
3476        let cases: Vec<(&str, PlotElement)> = vec![
3477            (
3478                "line",
3479                PlotElement::Line(LinePlot::new(vec![0.0, 1.0], vec![1.0, 2.0]).unwrap()),
3480            ),
3481            (
3482                "scatter",
3483                PlotElement::Scatter(ScatterPlot::new(vec![0.0, 1.0], vec![2.0, 3.0]).unwrap()),
3484            ),
3485            (
3486                "bar",
3487                PlotElement::Bar(
3488                    BarChart::new(vec!["A".into(), "B".into()], vec![1.0, 2.0]).unwrap(),
3489                ),
3490            ),
3491            (
3492                "errorbar",
3493                PlotElement::ErrorBar(Box::new(
3494                    ErrorBar::new_vertical(
3495                        vec![0.0, 1.0],
3496                        vec![1.0, 2.0],
3497                        vec![0.1, 0.2],
3498                        vec![0.3, 0.4],
3499                    )
3500                    .unwrap(),
3501                )),
3502            ),
3503            (
3504                "stairs",
3505                PlotElement::Stairs(StairsPlot::new(vec![0.0, 1.0], vec![1.0, 2.0]).unwrap()),
3506            ),
3507            (
3508                "stem",
3509                PlotElement::Stem(StemPlot::new(vec![0.0, 1.0], vec![1.0, 2.0]).unwrap()),
3510            ),
3511            (
3512                "area",
3513                PlotElement::Area(AreaPlot::new(vec![0.0, 1.0], vec![1.0, 2.0]).unwrap()),
3514            ),
3515            (
3516                "quiver",
3517                PlotElement::Quiver(
3518                    QuiverPlot::new(vec![0.0], vec![0.0], vec![1.0], vec![1.0]).unwrap(),
3519                ),
3520            ),
3521            (
3522                "pie",
3523                PlotElement::Pie(PieChart::new(vec![1.0, 2.0], None).unwrap()),
3524            ),
3525            (
3526                "surface",
3527                PlotElement::Surface(
3528                    SurfacePlot::new(
3529                        vec![0.0, 1.0],
3530                        vec![0.0, 1.0],
3531                        vec![vec![0.0, 1.0], vec![1.0, 2.0]],
3532                    )
3533                    .unwrap(),
3534                ),
3535            ),
3536            (
3537                "mesh",
3538                PlotElement::Mesh(Box::new(
3539                    MeshPlot::new(
3540                        vec![
3541                            Vec3::new(0.0, 0.0, 0.0),
3542                            Vec3::new(1.0, 0.0, 0.0),
3543                            Vec3::new(0.0, 1.0, 0.0),
3544                        ],
3545                        vec![[0, 1, 2]],
3546                    )
3547                    .unwrap(),
3548                )),
3549            ),
3550            (
3551                "patch",
3552                PlotElement::Patch(
3553                    PatchPlot::new(
3554                        vec![
3555                            Vec3::new(0.0, 0.0, 0.0),
3556                            Vec3::new(1.0, 0.0, 0.0),
3557                            Vec3::new(0.0, 1.0, 0.0),
3558                        ],
3559                        vec![vec![0, 1, 2]],
3560                    )
3561                    .unwrap(),
3562                ),
3563            ),
3564            (
3565                "line3",
3566                PlotElement::Line3(
3567                    Line3Plot::new(vec![0.0, 1.0], vec![1.0, 2.0], vec![2.0, 3.0]).unwrap(),
3568                ),
3569            ),
3570            (
3571                "scatter3",
3572                PlotElement::Scatter3(Scatter3Plot::new(vec![Vec3::new(0.0, 0.0, 0.0)]).unwrap()),
3573            ),
3574            (
3575                "contour",
3576                PlotElement::Contour(ContourPlot::from_vertices(
3577                    vec![
3578                        Vertex::new(Vec3::new(0.0, 0.0, 0.0), Vec4::ONE),
3579                        Vertex::new(Vec3::new(1.0, 1.0, 0.0), Vec4::ONE),
3580                    ],
3581                    0.0,
3582                    bounds,
3583                )),
3584            ),
3585            (
3586                "contour_fill",
3587                PlotElement::ContourFill(ContourFillPlot::from_vertices(
3588                    vec![
3589                        Vertex::new(Vec3::new(0.0, 0.0, 0.0), Vec4::ONE),
3590                        Vertex::new(Vec3::new(1.0, 0.0, 0.0), Vec4::ONE),
3591                        Vertex::new(Vec3::new(0.0, 1.0, 0.0), Vec4::ONE),
3592                    ],
3593                    bounds,
3594                )),
3595            ),
3596            (
3597                "reference_line",
3598                PlotElement::ReferenceLine(
3599                    ReferenceLine::new(ReferenceLineOrientation::Vertical, 0.5).unwrap(),
3600                ),
3601            ),
3602        ];
3603
3604        for (name, plot) in cases {
3605            let scene_plot = futures::executor::block_on(ScenePlot::from_plot_for_export(&plot, 0))
3606                .unwrap_or_else(|err| panic!("{name} export failed: {err}"));
3607            scene_plot
3608                .validate_exportable()
3609                .unwrap_or_else(|err| panic!("{name} validation failed: {err}"));
3610        }
3611    }
3612
3613    #[test]
3614    fn capture_snapshot_reflects_layout_and_metadata() {
3615        let mut figure = Figure::new()
3616            .with_title("Demo")
3617            .with_sg_title("Overview")
3618            .with_labels("X", "Y")
3619            .with_grid(false)
3620            .with_subplot_grid(1, 2);
3621        figure.set_name("Window Name");
3622        figure.set_number_title(false);
3623        figure.set_visible(false);
3624        figure.set_background_color(Vec4::new(0.0, 0.0, 0.0, 1.0));
3625        let line = LinePlot::new(vec![0.0, 1.0], vec![0.0, 1.0]).unwrap();
3626        figure.add_line_plot_on_axes(line, 1);
3627
3628        let snapshot = FigureSnapshot::capture(&figure);
3629        assert_eq!(snapshot.layout.axes_rows, 1);
3630        assert_eq!(snapshot.layout.axes_cols, 2);
3631        assert_eq!(snapshot.metadata.title.as_deref(), Some("Demo"));
3632        assert_eq!(snapshot.metadata.name.as_deref(), Some("Window Name"));
3633        assert!(!snapshot.metadata.number_title);
3634        assert!(!snapshot.metadata.visible);
3635        assert_eq!(snapshot.metadata.sg_title.as_deref(), Some("Overview"));
3636        assert_eq!(snapshot.metadata.background_rgba, [0.0, 0.0, 0.0, 1.0]);
3637        assert_eq!(snapshot.metadata.legend_entries.len(), 0);
3638        assert_eq!(snapshot.plots.len(), 1);
3639        assert_eq!(snapshot.plots[0].axes_index, 1);
3640        assert!(!snapshot.metadata.grid_enabled);
3641    }
3642
3643    #[test]
3644    fn surface_scene_validation_uses_surface_plot_orientation() {
3645        let scene_plot = ScenePlot::Surface {
3646            x: vec![0.0, 1.0, 2.0],
3647            y: vec![10.0, 20.0],
3648            z: vec![vec![1.0, 2.0], vec![3.0, 4.0], vec![5.0, 6.0]],
3649            x_grid: None,
3650            y_grid: None,
3651            colormap: "Parula".to_string(),
3652            shading_mode: "Smooth".to_string(),
3653            wireframe: false,
3654            alpha: 1.0,
3655            flatten_z: false,
3656            image_mode: false,
3657            color_grid_rgba: Some(vec![
3658                vec![[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]],
3659                vec![[0.0, 0.0, 1.0, 1.0], [1.0, 1.0, 0.0, 1.0]],
3660                vec![[1.0, 0.0, 1.0, 1.0], [0.0, 1.0, 1.0, 1.0]],
3661            ]),
3662            color_limits: None,
3663            axes_index: 0,
3664            label: None,
3665            visible: true,
3666        };
3667        scene_plot.validate_exportable().unwrap();
3668
3669        let transposed = ScenePlot::Surface {
3670            x: vec![0.0, 1.0, 2.0],
3671            y: vec![10.0, 20.0],
3672            z: vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]],
3673            x_grid: None,
3674            y_grid: None,
3675            colormap: "Parula".to_string(),
3676            shading_mode: "Smooth".to_string(),
3677            wireframe: false,
3678            alpha: 1.0,
3679            flatten_z: false,
3680            image_mode: false,
3681            color_grid_rgba: None,
3682            color_limits: None,
3683            axes_index: 0,
3684            label: None,
3685            visible: true,
3686        };
3687        let err = transposed.validate_exportable().unwrap_err();
3688        assert!(err
3689            .to_string()
3690            .contains("row count (2) must match x length (3)"));
3691    }
3692
3693    #[test]
3694    fn image_mode_surface_scene_roundtrip_preserves_color_grid() {
3695        let snapshot = FigureSnapshot::capture(&Figure::new());
3696        let scene = FigureScene {
3697            schema_version: FigureScene::SCHEMA_VERSION,
3698            layout: snapshot.layout,
3699            metadata: snapshot.metadata,
3700            plots: vec![ScenePlot::Surface {
3701                x: vec![0.0, 1.0],
3702                y: vec![10.0, 20.0, 30.0],
3703                z: vec![vec![0.0, 0.0, 0.0], vec![0.0, 0.0, 0.0]],
3704                x_grid: None,
3705                y_grid: None,
3706                colormap: "Parula".to_string(),
3707                shading_mode: "None".to_string(),
3708                wireframe: false,
3709                alpha: 1.0,
3710                flatten_z: true,
3711                image_mode: true,
3712                color_grid_rgba: Some(vec![
3713                    vec![
3714                        [1.0, 0.0, 0.0, 1.0],
3715                        [0.0, 1.0, 0.0, 1.0],
3716                        [0.0, 0.0, 1.0, 1.0],
3717                    ],
3718                    vec![
3719                        [1.0, 1.0, 0.0, 1.0],
3720                        [1.0, 0.0, 1.0, 1.0],
3721                        [0.0, 1.0, 1.0, 1.0],
3722                    ],
3723                ]),
3724                color_limits: None,
3725                axes_index: 0,
3726                label: None,
3727                visible: true,
3728            }],
3729        };
3730
3731        let rebuilt = scene.into_figure().expect("image surface scene restores");
3732        let Some(PlotElement::Surface(surface)) = rebuilt.plots().next() else {
3733            panic!("expected surface plot");
3734        };
3735        assert!(surface.image_mode);
3736        assert!(surface.flatten_z);
3737        let grid = surface.color_grid.as_ref().expect("color grid");
3738        assert_eq!(grid.len(), 2);
3739        assert_eq!(grid[0].len(), 3);
3740        assert_eq!(grid[1][2], Vec4::new(0.0, 1.0, 1.0, 1.0));
3741    }
3742
3743    #[test]
3744    fn sg_title_style_omitted_when_sg_title_absent() {
3745        let figure = Figure::new().with_title("Only regular title");
3746        let snapshot = FigureSnapshot::capture(&figure);
3747        assert!(snapshot.metadata.sg_title.is_none());
3748        assert!(
3749            snapshot.metadata.sg_title_style.is_none(),
3750            "sgTitleStyle must be None when sgTitle is absent"
3751        );
3752        let json = serde_json::to_string(&snapshot.metadata).unwrap();
3753        assert!(
3754            !json.contains("sgTitleStyle"),
3755            "sgTitleStyle must not appear in serialized JSON when sgTitle is absent"
3756        );
3757    }
3758
3759    #[test]
3760    fn figure_scene_roundtrip_reconstructs_supported_plots() {
3761        let mut figure = Figure::new().with_title("Replay").with_subplot_grid(1, 2);
3762        figure.set_name("Roundtrip");
3763        figure.set_number_title(false);
3764        figure.set_visible(false);
3765        figure.set_position([100.0, 100.0, 1000.0, 700.0]);
3766        let mut line = LinePlot::new(vec![0.0, 1.0], vec![1.0, 2.0]).unwrap();
3767        line.label = Some("line".to_string());
3768        figure.add_line_plot_on_axes(line, 0);
3769        let mut scatter = ScatterPlot::new(vec![0.0, 1.0, 2.0], vec![2.0, 3.0, 4.0]).unwrap();
3770        scatter.label = Some("scatter".to_string());
3771        figure.add_scatter_plot_on_axes(scatter, 1);
3772
3773        let scene = FigureScene::capture(&figure);
3774        let rebuilt = scene.into_figure().expect("scene restore should succeed");
3775        assert_eq!(rebuilt.axes_grid(), (1, 2));
3776        assert_eq!(rebuilt.plots().count(), 2);
3777        assert_eq!(rebuilt.title.as_deref(), Some("Replay"));
3778        assert_eq!(rebuilt.name.as_deref(), Some("Roundtrip"));
3779        assert!(!rebuilt.number_title);
3780        assert!(!rebuilt.visible);
3781        assert_eq!(rebuilt.position, [100.0, 100.0, 1000.0, 700.0]);
3782    }
3783
3784    #[test]
3785    fn figure_scene_roundtrip_reconstructs_patch() {
3786        let mut figure = Figure::new();
3787        let mut patch = PatchPlot::new(
3788            vec![
3789                Vec3::new(0.0, 0.0, 0.0),
3790                Vec3::new(1.0, 0.0, 0.0),
3791                Vec3::new(0.0, 1.0, 0.0),
3792            ],
3793            vec![vec![0, 1, 2]],
3794        )
3795        .unwrap();
3796        patch.set_label(Some("tri".into()));
3797        patch.set_force_3d(true);
3798        figure.add_patch_plot(patch);
3799
3800        let scene = FigureScene::capture(&figure);
3801        assert_eq!(scene.schema_version, FigureScene::SCHEMA_VERSION);
3802        assert!(matches!(scene.plots.first(), Some(ScenePlot::Patch { .. })));
3803        let rebuilt = scene.into_figure().expect("patch scene restore");
3804        let Some(PlotElement::Patch(patch)) = rebuilt.plots().next() else {
3805            panic!("expected patch plot");
3806        };
3807        assert_eq!(patch.faces(), &[vec![0, 1, 2]]);
3808        assert_eq!(patch.label(), Some("tri"));
3809        assert!(patch.force_3d());
3810    }
3811
3812    #[test]
3813    fn figure_scene_rejects_invalid_schema_versions() {
3814        let mut scene = FigureScene::capture(&Figure::new());
3815        scene.schema_version = 0;
3816        let err = scene.clone().into_figure().expect_err("schema 0 must fail");
3817        assert!(err.contains("unsupported figure scene schema version 0"));
3818
3819        scene.schema_version = FigureScene::SCHEMA_VERSION + 1;
3820        let err = scene.into_figure().expect_err("future schema must fail");
3821        assert!(err.contains(&format!(
3822            "unsupported figure scene schema version {}",
3823            FigureScene::SCHEMA_VERSION + 1
3824        )));
3825    }
3826
3827    #[test]
3828    fn figure_scene_rejects_invalid_position_metadata() {
3829        let mut scene = FigureScene::capture(&Figure::new());
3830        scene.metadata.position = Some([0.0, 0.0, 0.0, 400.0]);
3831        let err = scene
3832            .clone()
3833            .into_figure()
3834            .expect_err("zero-width position must fail");
3835        assert!(err.contains("Position width and height must be positive"));
3836
3837        scene.metadata.position = Some([0.0, f64::NAN, 300.0, 400.0]);
3838        let err = scene
3839            .into_figure()
3840            .expect_err("non-finite position must fail");
3841        assert!(err.contains("Position values must be finite"));
3842    }
3843
3844    #[test]
3845    fn figure_scene_rejects_patch_in_older_schema() {
3846        let mut figure = Figure::new();
3847        figure.add_patch_plot(
3848            PatchPlot::new(
3849                vec![
3850                    Vec3::new(0.0, 0.0, 0.0),
3851                    Vec3::new(1.0, 0.0, 0.0),
3852                    Vec3::new(0.0, 1.0, 0.0),
3853                ],
3854                vec![vec![0, 1, 2]],
3855            )
3856            .unwrap(),
3857        );
3858
3859        let mut scene = FigureScene::capture(&figure);
3860        assert!(matches!(scene.plots.first(), Some(ScenePlot::Patch { .. })));
3861        scene.schema_version = 1;
3862
3863        let err = scene
3864            .into_figure()
3865            .expect_err("older patch schema must fail");
3866        assert!(err.contains("patch plots require figure scene schema version 2"));
3867    }
3868
3869    #[test]
3870    fn figure_scene_roundtrip_preserves_mesh_plot() {
3871        let mut figure = Figure::new();
3872        let mut mesh = MeshPlot::new(
3873            vec![
3874                Vec3::new(0.0, 0.0, 0.0),
3875                Vec3::new(1.0, 0.0, 0.0),
3876                Vec3::new(0.0, 1.0, 0.0),
3877            ],
3878            vec![[0, 1, 2]],
3879        )
3880        .unwrap();
3881        mesh.set_mesh_id(Some("mesh_1".to_string()));
3882        mesh.set_label(Some("mesh tri".to_string()));
3883        mesh.set_face_alpha(0.7);
3884        mesh.set_edge_width(0.25);
3885        mesh.set_edge_mode(MeshEdgeMode::Feature);
3886        mesh.set_feature_edge_groups(Some(vec![3]))
3887            .expect("feature group should be accepted");
3888        mesh.set_vertex_colors(Some(vec![
3889            Vec4::new(0.3, 0.4, 0.5, 1.0),
3890            Vec4::new(0.3, 0.4, 0.5, 1.0),
3891            Vec4::new(0.3, 0.4, 0.5, 1.0),
3892        ]))
3893        .expect("vertex colors should be accepted");
3894        mesh.set_triangle_colors(Some(vec![Vec4::new(0.3, 0.4, 0.5, 1.0)]))
3895            .expect("triangle color should be accepted");
3896        mesh.set_regions(vec![MeshRegion::new(
3897            "region_default",
3898            Some("Default Region".to_string()),
3899            Some("mesh_default".to_string()),
3900            vec![MeshTriangleRange::new(0, 1)],
3901        )]);
3902        mesh.set_highlighted_region_id(Some("region_default".to_string()));
3903        figure.add_mesh_plot(mesh);
3904
3905        let scene = FigureScene::capture(&figure);
3906        assert_eq!(scene.schema_version, FigureScene::SCHEMA_VERSION);
3907        assert!(matches!(scene.plots.first(), Some(ScenePlot::Mesh { .. })));
3908        let rebuilt = scene.into_figure().expect("mesh scene restore");
3909        let Some(PlotElement::Mesh(mesh)) = rebuilt.plots().next() else {
3910            panic!("expected mesh plot");
3911        };
3912        assert_eq!(mesh.mesh_id(), Some("mesh_1"));
3913        assert_eq!(mesh.triangles(), &[[0, 1, 2]]);
3914        assert_eq!(mesh.label(), Some("mesh tri"));
3915        assert!((mesh.face_alpha() - 0.7).abs() < f32::EPSILON);
3916        assert!((mesh.edge_width() - 0.25).abs() < f32::EPSILON);
3917        assert_eq!(mesh.edge_mode(), MeshEdgeMode::Feature);
3918        assert_eq!(mesh.feature_edge_groups().unwrap(), &[3]);
3919        assert_eq!(
3920            mesh.vertex_colors()
3921                .and_then(|colors| colors.first().copied()),
3922            Some(Vec4::new(0.3, 0.4, 0.5, 1.0))
3923        );
3924        assert_eq!(
3925            mesh.triangle_colors()
3926                .and_then(|colors| colors.first().copied()),
3927            Some(Vec4::new(0.3, 0.4, 0.5, 1.0))
3928        );
3929        assert_eq!(mesh.regions().len(), 1);
3930        assert_eq!(mesh.regions()[0].region_id, "region_default");
3931        assert_eq!(mesh.highlighted_region_id(), Some("region_default"));
3932    }
3933
3934    #[test]
3935    fn figure_scene_roundtrip_preserves_mesh_fea_overlays() {
3936        let mut figure = Figure::new();
3937        let mut mesh = MeshPlot::new(
3938            vec![
3939                Vec3::new(0.0, 0.0, 0.0),
3940                Vec3::new(1.0, 0.0, 0.0),
3941                Vec3::new(0.0, 1.0, 0.0),
3942            ],
3943            vec![[0, 1, 2]],
3944        )
3945        .unwrap();
3946        mesh.set_scalar_field(Some(MeshScalarField {
3947            field_id: "fea.structural.von_mises".to_string(),
3948            label: Some("Von Mises".to_string()),
3949            location: MeshFieldLocation::Vertex,
3950            values: vec![0.0, 0.5, 1.0],
3951            color_limits: Some([0.0, 1.0]),
3952            colormap: "viridis".to_string(),
3953            alpha: 0.8,
3954        }))
3955        .unwrap();
3956        mesh.set_vector_field(Some(MeshVectorField {
3957            field_id: "fea.em.flux_density".to_string(),
3958            label: Some("Flux density".to_string()),
3959            location: MeshFieldLocation::Triangle,
3960            vectors: vec![Vec3::new(0.0, 0.0, 1.0)],
3961            scale: 0.25,
3962            stride: 1,
3963            color: Vec4::new(0.9, 0.7, 0.2, 1.0),
3964        }))
3965        .unwrap();
3966        mesh.set_deformation(Some(MeshDeformation {
3967            field_id: "fea.structural.displacement".to_string(),
3968            label: Some("Displacement".to_string()),
3969            displacements: vec![Vec3::ZERO, Vec3::Z, Vec3::ZERO],
3970            scale: 0.5,
3971        }))
3972        .unwrap();
3973        figure.add_mesh_plot(mesh);
3974
3975        let rebuilt = FigureScene::capture(&figure)
3976            .into_figure()
3977            .expect("mesh scene restore");
3978        let Some(PlotElement::Mesh(mesh)) = rebuilt.plots().next() else {
3979            panic!("expected mesh plot");
3980        };
3981        assert_eq!(
3982            mesh.scalar_field().map(|field| field.field_id.as_str()),
3983            Some("fea.structural.von_mises")
3984        );
3985        assert_eq!(
3986            mesh.vector_field().map(|field| field.field_id.as_str()),
3987            Some("fea.em.flux_density")
3988        );
3989        assert_eq!(
3990            mesh.deformation().map(|field| field.field_id.as_str()),
3991            Some("fea.structural.displacement")
3992        );
3993    }
3994
3995    #[test]
3996    fn figure_scene_rejects_mesh_in_older_schema() {
3997        let mut figure = Figure::new();
3998        figure.add_mesh_plot(
3999            MeshPlot::new(
4000                vec![
4001                    Vec3::new(0.0, 0.0, 0.0),
4002                    Vec3::new(1.0, 0.0, 0.0),
4003                    Vec3::new(0.0, 1.0, 0.0),
4004                ],
4005                vec![[0, 1, 2]],
4006            )
4007            .unwrap(),
4008        );
4009
4010        let mut scene = FigureScene::capture(&figure);
4011        assert!(matches!(scene.plots.first(), Some(ScenePlot::Mesh { .. })));
4012        scene.schema_version = 2;
4013
4014        let err = scene
4015            .into_figure()
4016            .expect_err("older mesh schema must fail");
4017        assert!(err.contains("mesh plots require figure scene schema version 3"));
4018    }
4019
4020    #[test]
4021    fn figure_scene_rejects_unknown_reference_line_orientation() {
4022        let mut scene = FigureScene::capture(&Figure::new());
4023        scene.plots.push(ScenePlot::ReferenceLine {
4024            orientation: "VERTICAL".into(),
4025            value: 2.0,
4026            color_rgba: [0.1, 0.2, 0.3, 1.0],
4027            line_width: 1.0,
4028            line_style: "Solid".into(),
4029            label: None,
4030            display_name: None,
4031            label_orientation: "horizontal".into(),
4032            axes_index: 0,
4033            visible: true,
4034        });
4035
4036        let rebuilt = scene.clone().into_figure().expect("valid orientation");
4037        let PlotElement::ReferenceLine(line) = rebuilt.plots().next().unwrap() else {
4038            panic!("expected reference line")
4039        };
4040        assert!(matches!(
4041            line.orientation,
4042            ReferenceLineOrientation::Vertical
4043        ));
4044
4045        let ScenePlot::ReferenceLine { orientation, .. } = &mut scene.plots[0] else {
4046            panic!("expected reference line scene plot")
4047        };
4048        *orientation = "diagonal".into();
4049
4050        let err = scene
4051            .into_figure()
4052            .expect_err("unknown orientation must fail");
4053        assert!(err.contains("unknown reference line orientation 'diagonal'"));
4054    }
4055
4056    #[test]
4057    fn figure_scene_roundtrip_reconstructs_surface_and_scatter3() {
4058        let mut figure = Figure::new().with_title("Replay3D").with_subplot_grid(1, 2);
4059        let mut surface = SurfacePlot::new(
4060            vec![0.0, 1.0],
4061            vec![0.0, 1.0],
4062            vec![vec![0.0, 1.0], vec![1.0, 2.0]],
4063        )
4064        .expect("surface data should be valid");
4065        surface.label = Some("surface".to_string());
4066        figure.add_surface_plot_on_axes(surface, 0);
4067
4068        let mut scatter3 = Scatter3Plot::new(vec![
4069            Vec3::new(0.0, 0.0, 0.0),
4070            Vec3::new(1.0, 2.0, 3.0),
4071            Vec3::new(2.0, 3.0, 4.0),
4072        ])
4073        .expect("scatter3 data should be valid");
4074        scatter3.label = Some("scatter3".to_string());
4075        figure.add_scatter3_plot_on_axes(scatter3, 1);
4076
4077        let scene = FigureScene::capture(&figure);
4078        let rebuilt = scene.into_figure().expect("scene restore should succeed");
4079        assert_eq!(rebuilt.axes_grid(), (1, 2));
4080        assert_eq!(rebuilt.plots().count(), 2);
4081        assert_eq!(rebuilt.title.as_deref(), Some("Replay3D"));
4082        assert!(matches!(
4083            rebuilt.plots().next(),
4084            Some(PlotElement::Surface(_))
4085        ));
4086        assert!(matches!(
4087            rebuilt.plots().nth(1),
4088            Some(PlotElement::Scatter3(_))
4089        ));
4090    }
4091
4092    #[test]
4093    fn figure_scene_roundtrip_preserves_line3_plot() {
4094        let mut figure = Figure::new();
4095        let line3 = Line3Plot::new(vec![0.0, 1.0], vec![1.0, 2.0], vec![2.0, 3.0])
4096            .unwrap()
4097            .with_label("Trajectory");
4098        figure.add_line3_plot(line3);
4099
4100        let rebuilt = FigureScene::capture(&figure)
4101            .into_figure()
4102            .expect("scene restore should succeed");
4103
4104        let PlotElement::Line3(line3) = rebuilt.plots().next().unwrap() else {
4105            panic!("expected line3")
4106        };
4107        assert_eq!(line3.x_data, vec![0.0, 1.0]);
4108        assert_eq!(line3.z_data, vec![2.0, 3.0]);
4109        assert_eq!(line3.label.as_deref(), Some("Trajectory"));
4110    }
4111
4112    #[test]
4113    fn figure_scene_roundtrip_preserves_contour_and_fill_plots() {
4114        let mut figure = Figure::new();
4115        let bounds = BoundingBox::new(Vec3::new(-1.0, -2.0, 0.0), Vec3::new(3.0, 4.0, 0.0));
4116        let vertices = vec![Vertex {
4117            position: [0.0, 0.0, 0.0],
4118            color: [1.0, 0.0, 0.0, 1.0],
4119            normal: [0.0, 0.0, 1.0],
4120            tex_coords: [0.0, 0.0],
4121        }];
4122        let fill = ContourFillPlot::from_vertices(vertices.clone(), bounds).with_label("fill");
4123        let contour = ContourPlot::from_vertices(vertices, 0.0, bounds)
4124            .with_label("lines")
4125            .with_line_width(2.0);
4126        figure.add_contour_fill_plot(fill);
4127        figure.add_contour_plot(contour);
4128
4129        let rebuilt = FigureScene::capture(&figure)
4130            .into_figure()
4131            .expect("scene restore should succeed");
4132        assert!(matches!(
4133            rebuilt.plots().next(),
4134            Some(PlotElement::ContourFill(_))
4135        ));
4136        let Some(PlotElement::Contour(contour)) = rebuilt.plots().nth(1) else {
4137            panic!("expected contour")
4138        };
4139        assert_eq!(contour.line_width, 2.0);
4140    }
4141
4142    #[test]
4143    fn figure_scene_roundtrip_preserves_stem_style_surface() {
4144        let mut figure = Figure::new();
4145        let mut stem = StemPlot::new(vec![0.0, 1.0], vec![1.0, 2.0])
4146            .unwrap()
4147            .with_style(
4148                Vec4::new(1.0, 0.0, 0.0, 1.0),
4149                2.0,
4150                crate::plots::line::LineStyle::Dashed,
4151                -1.0,
4152            )
4153            .with_baseline_style(Vec4::new(0.0, 0.0, 0.0, 1.0), false)
4154            .with_label("Impulse");
4155        stem.set_marker(Some(crate::plots::line::LineMarkerAppearance {
4156            kind: crate::plots::scatter::MarkerStyle::Square,
4157            size: 8.0,
4158            edge_color: Vec4::new(0.0, 0.0, 0.0, 1.0),
4159            face_color: Vec4::new(1.0, 0.0, 0.0, 1.0),
4160            filled: true,
4161        }));
4162        figure.add_stem_plot(stem);
4163
4164        let rebuilt = FigureScene::capture(&figure)
4165            .into_figure()
4166            .expect("scene restore should succeed");
4167        let PlotElement::Stem(stem) = rebuilt.plots().next().unwrap() else {
4168            panic!("expected stem")
4169        };
4170        assert_eq!(stem.baseline, -1.0);
4171        assert_eq!(stem.line_width, 2.0);
4172        assert_eq!(stem.label.as_deref(), Some("Impulse"));
4173        assert!(!stem.baseline_visible);
4174        assert!(stem.marker.as_ref().map(|m| m.filled).unwrap_or(false));
4175        assert_eq!(stem.marker.as_ref().map(|m| m.size), Some(8.0));
4176    }
4177
4178    #[test]
4179    fn figure_scene_roundtrip_preserves_bar_plot() {
4180        let mut figure = Figure::new();
4181        let bar = BarChart::new(vec!["A".into(), "B".into()], vec![2.0, 3.5])
4182            .unwrap()
4183            .with_style(Vec4::new(0.2, 0.4, 0.8, 1.0), 0.95)
4184            .with_outline(Vec4::new(0.1, 0.1, 0.1, 1.0), 1.5)
4185            .with_label("Histogram")
4186            .with_stack_offsets(vec![1.0, 0.5]);
4187        figure.add_bar_chart(bar);
4188
4189        let rebuilt = FigureScene::capture(&figure)
4190            .into_figure()
4191            .expect("scene restore should succeed");
4192        let PlotElement::Bar(bar) = rebuilt.plots().next().unwrap() else {
4193            panic!("expected bar")
4194        };
4195        assert_eq!(bar.labels, vec!["A", "B"]);
4196        assert_eq!(bar.values().unwrap_or(&[]), &[2.0, 3.5]);
4197        assert_eq!(bar.bar_width, 0.95);
4198        assert_eq!(bar.outline_width, 1.5);
4199        assert_eq!(bar.label.as_deref(), Some("Histogram"));
4200        assert_eq!(bar.stack_offsets().unwrap_or(&[]), &[1.0, 0.5]);
4201        assert!(bar.histogram_bin_edges().is_none());
4202    }
4203
4204    #[test]
4205    fn figure_scene_roundtrip_preserves_histogram_bin_edges() {
4206        let mut figure = Figure::new();
4207        let mut bar = BarChart::new(vec!["bin1".into(), "bin2".into()], vec![4.0, 5.0]).unwrap();
4208        bar.set_histogram_bin_edges(vec![0.0, 0.5, 1.0]);
4209        figure.add_bar_chart(bar);
4210
4211        let rebuilt = FigureScene::capture(&figure)
4212            .into_figure()
4213            .expect("scene restore should succeed");
4214        let PlotElement::Bar(bar) = rebuilt.plots().next().unwrap() else {
4215            panic!("expected bar")
4216        };
4217        assert_eq!(bar.histogram_bin_edges().unwrap_or(&[]), &[0.0, 0.5, 1.0]);
4218    }
4219
4220    #[test]
4221    fn figure_scene_roundtrip_preserves_polar_histogram_state() {
4222        let mut figure = Figure::new();
4223        let mut bar = BarChart::new(vec!["bin1".into(), "bin2".into()], vec![4.0, 5.0]).unwrap();
4224        bar.set_histogram_bin_edges(vec![0.0, std::f64::consts::PI, std::f64::consts::TAU]);
4225        bar.set_polar_histogram(true);
4226        bar.set_polar_histogram_display_style(PolarHistogramDisplayStyle::Stairs);
4227        figure.add_bar_chart(bar);
4228
4229        let rebuilt = FigureScene::capture(&figure)
4230            .into_figure()
4231            .expect("scene restore should succeed");
4232        let PlotElement::Bar(bar) = rebuilt.plots().next().unwrap() else {
4233            panic!("expected bar")
4234        };
4235        assert!(bar.is_polar_histogram());
4236        assert_eq!(
4237            bar.polar_histogram_display_style(),
4238            PolarHistogramDisplayStyle::Stairs
4239        );
4240        assert_eq!(
4241            bar.histogram_bin_edges().unwrap_or(&[]),
4242            &[0.0, std::f64::consts::PI, std::f64::consts::TAU]
4243        );
4244    }
4245
4246    #[test]
4247    fn figure_scene_roundtrip_preserves_errorbar_style_surface() {
4248        let mut figure = Figure::new();
4249        let mut error = ErrorBar::new_vertical(
4250            vec![0.0, 1.0],
4251            vec![1.0, 2.0],
4252            vec![0.1, 0.2],
4253            vec![0.2, 0.3],
4254        )
4255        .unwrap()
4256        .with_style(
4257            Vec4::new(1.0, 0.0, 0.0, 1.0),
4258            2.0,
4259            crate::plots::line::LineStyle::Dashed,
4260            10.0,
4261        )
4262        .with_label("Err");
4263        error.set_marker(Some(crate::plots::line::LineMarkerAppearance {
4264            kind: crate::plots::scatter::MarkerStyle::Triangle,
4265            size: 8.0,
4266            edge_color: Vec4::new(0.0, 0.0, 0.0, 1.0),
4267            face_color: Vec4::new(1.0, 0.0, 0.0, 1.0),
4268            filled: true,
4269        }));
4270        figure.add_errorbar(error);
4271
4272        let rebuilt = FigureScene::capture(&figure)
4273            .into_figure()
4274            .expect("scene restore should succeed");
4275        let PlotElement::ErrorBar(error) = rebuilt.plots().next().unwrap() else {
4276            panic!("expected errorbar")
4277        };
4278        assert_eq!(error.line_width, 2.0);
4279        assert_eq!(error.cap_size, 10.0);
4280        assert_eq!(error.label.as_deref(), Some("Err"));
4281        assert_eq!(error.line_style, crate::plots::line::LineStyle::Dashed);
4282        assert!(error.marker.as_ref().map(|m| m.filled).unwrap_or(false));
4283    }
4284
4285    #[test]
4286    fn figure_scene_roundtrip_preserves_errorbar_both_direction() {
4287        let mut figure = Figure::new();
4288        let error = ErrorBar::new_both(
4289            vec![1.0, 2.0],
4290            vec![3.0, 4.0],
4291            vec![0.1, 0.2],
4292            vec![0.2, 0.3],
4293            vec![0.3, 0.4],
4294            vec![0.4, 0.5],
4295        )
4296        .unwrap();
4297        figure.add_errorbar(error);
4298        let rebuilt = FigureScene::capture(&figure)
4299            .into_figure()
4300            .expect("scene restore should succeed");
4301        let PlotElement::ErrorBar(error) = rebuilt.plots().next().unwrap() else {
4302            panic!("expected errorbar")
4303        };
4304        assert_eq!(
4305            error.orientation,
4306            crate::plots::errorbar::ErrorBarOrientation::Both
4307        );
4308        assert_eq!(error.x_neg, vec![0.1, 0.2]);
4309        assert_eq!(error.x_pos, vec![0.2, 0.3]);
4310    }
4311
4312    #[test]
4313    fn figure_scene_roundtrip_preserves_quiver_plot() {
4314        let mut figure = Figure::new();
4315        let quiver = QuiverPlot::new(
4316            vec![0.0, 1.0],
4317            vec![1.0, 2.0],
4318            vec![0.5, -0.5],
4319            vec![1.0, 0.25],
4320        )
4321        .unwrap()
4322        .with_style(Vec4::new(0.2, 0.3, 0.4, 1.0), 2.0, 1.5, 0.2)
4323        .with_label("Field");
4324        figure.add_quiver_plot(quiver);
4325
4326        let rebuilt = FigureScene::capture(&figure)
4327            .into_figure()
4328            .expect("scene restore should succeed");
4329        let PlotElement::Quiver(quiver) = rebuilt.plots().next().unwrap() else {
4330            panic!("expected quiver")
4331        };
4332        assert_eq!(quiver.u, vec![0.5, -0.5]);
4333        assert_eq!(quiver.v, vec![1.0, 0.25]);
4334        assert_eq!(quiver.line_width, 2.0);
4335        assert_eq!(quiver.scale, 1.5);
4336        assert_eq!(quiver.head_size, 0.2);
4337        assert_eq!(quiver.label.as_deref(), Some("Field"));
4338    }
4339
4340    #[test]
4341    fn figure_scene_roundtrip_preserves_quiver3_plot() {
4342        let mut figure = Figure::new();
4343        let quiver = QuiverPlot::new3d(
4344            vec![0.0, 1.0],
4345            vec![1.0, 2.0],
4346            vec![3.0, 4.0],
4347            vec![0.5, -0.5],
4348            vec![1.0, 0.25],
4349            vec![2.0, -1.0],
4350        )
4351        .unwrap()
4352        .with_style(Vec4::new(0.2, 0.3, 0.4, 1.0), 2.0, 1.5, 0.2)
4353        .with_label("Field3");
4354        figure.add_quiver_plot(quiver);
4355
4356        let rebuilt = FigureScene::capture(&figure)
4357            .into_figure()
4358            .expect("scene restore should succeed");
4359        let PlotElement::Quiver(quiver) = rebuilt.plots().next().unwrap() else {
4360            panic!("expected quiver")
4361        };
4362        assert_eq!(quiver.z.as_ref().unwrap(), &vec![3.0, 4.0]);
4363        assert_eq!(quiver.w.as_ref().unwrap(), &vec![2.0, -1.0]);
4364        assert_eq!(quiver.label.as_deref(), Some("Field3"));
4365    }
4366
4367    #[test]
4368    fn figure_scene_roundtrip_preserves_image_surface_mode_and_color_grid() {
4369        let mut figure = Figure::new();
4370        let surface = SurfacePlot::new(
4371            vec![0.0, 1.0],
4372            vec![0.0, 1.0],
4373            vec![vec![0.0, 0.0], vec![0.0, 0.0]],
4374        )
4375        .unwrap()
4376        .with_flatten_z(true)
4377        .with_image_mode(true)
4378        .with_color_grid(vec![
4379            vec![Vec4::new(1.0, 0.0, 0.0, 1.0), Vec4::new(0.0, 1.0, 0.0, 1.0)],
4380            vec![Vec4::new(0.0, 0.0, 1.0, 1.0), Vec4::new(1.0, 1.0, 1.0, 1.0)],
4381        ]);
4382        figure.add_surface_plot(surface);
4383
4384        let rebuilt = FigureScene::capture(&figure)
4385            .into_figure()
4386            .expect("scene restore should succeed");
4387        let PlotElement::Surface(surface) = rebuilt.plots().next().unwrap() else {
4388            panic!("expected surface")
4389        };
4390        assert!(surface.flatten_z);
4391        assert!(surface.image_mode);
4392        assert!(surface.color_grid.is_some());
4393        assert_eq!(
4394            surface.color_grid.as_ref().unwrap()[0][0],
4395            Vec4::new(1.0, 0.0, 0.0, 1.0)
4396        );
4397    }
4398
4399    #[test]
4400    fn figure_scene_roundtrip_preserves_parametric_surface_coordinate_grids() {
4401        let mut figure = Figure::new();
4402        let surface = SurfacePlot::from_coordinate_grids(
4403            vec![vec![0.0, 0.5], vec![0.2, 0.8]],
4404            vec![vec![0.0, 0.1], vec![0.7, 1.0]],
4405            vec![vec![1.0, 2.0], vec![3.0, 4.0]],
4406        )
4407        .unwrap();
4408        figure.add_surface_plot(surface);
4409
4410        let scene = FigureScene::capture(&figure);
4411        let ScenePlot::Surface {
4412            x_grid: Some(scene_x_grid),
4413            y_grid: Some(scene_y_grid),
4414            ..
4415        } = &scene.plots[0]
4416        else {
4417            panic!("expected exported coordinate grids");
4418        };
4419        assert_eq!(scene_x_grid[1][0], 0.2);
4420        assert_eq!(scene_y_grid[0][1], 0.1);
4421
4422        let rebuilt = scene.into_figure().expect("scene restore should succeed");
4423        let PlotElement::Surface(surface) = rebuilt.plots().next().unwrap() else {
4424            panic!("expected surface")
4425        };
4426        assert_eq!(surface.x_grid.as_ref().unwrap()[1][1], 0.8);
4427        assert_eq!(surface.y_grid.as_ref().unwrap()[1][0], 0.7);
4428    }
4429
4430    #[test]
4431    fn figure_scene_roundtrip_preserves_area_lower_curve() {
4432        let mut figure = Figure::new();
4433        let area = AreaPlot::new(vec![1.0, 2.0], vec![2.0, 3.0])
4434            .unwrap()
4435            .with_lower_curve(vec![0.5, 1.0])
4436            .with_label("Stacked");
4437        figure.add_area_plot(area);
4438
4439        let rebuilt = FigureScene::capture(&figure)
4440            .into_figure()
4441            .expect("scene restore should succeed");
4442        let PlotElement::Area(area) = rebuilt.plots().next().unwrap() else {
4443            panic!("expected area")
4444        };
4445        assert_eq!(area.lower_y, Some(vec![0.5, 1.0]));
4446        assert_eq!(area.label.as_deref(), Some("Stacked"));
4447    }
4448
4449    #[test]
4450    fn figure_scene_roundtrip_preserves_axes_local_limits_and_colormap_state() {
4451        let mut figure = Figure::new().with_subplot_grid(1, 2);
4452        figure.set_axes_limits(1, Some((1.0, 2.0)), Some((3.0, 4.0)));
4453        figure.set_axes_z_limits(1, Some((5.0, 6.0)));
4454        figure.set_axes_grid_enabled(1, false);
4455        figure.set_axes_minor_grid_enabled(1, true);
4456        figure.set_axes_hidden_line_removal(1, false);
4457        figure.set_axes_box_enabled(1, false);
4458        figure.set_axes_axis_equal(1, true);
4459        figure.set_axes_data_aspect_ratio(1, [1.0, 2.0, 3.0], "manual");
4460        figure.set_axes_kind(1, AxesKind::Polar);
4461        figure.set_axes_colorbar_enabled(1, true);
4462        figure.set_axes_colormap(1, ColorMap::Hot);
4463        figure.set_axes_color_limits(1, Some((0.0, 10.0)));
4464        figure.set_axes_tick_label_rotations(1, Some(30.0), Some(-45.0));
4465        figure.set_axes_style(
4466            1,
4467            TextStyle {
4468                font_size: Some(14.0),
4469                ..Default::default()
4470            },
4471        );
4472        figure.set_active_axes_index(1);
4473
4474        let rebuilt = FigureScene::capture(&figure)
4475            .into_figure()
4476            .expect("scene restore should succeed");
4477        let meta = rebuilt.axes_metadata(1).unwrap();
4478        assert_eq!(meta.x_limits, Some((1.0, 2.0)));
4479        assert_eq!(meta.y_limits, Some((3.0, 4.0)));
4480        assert_eq!(meta.z_limits, Some((5.0, 6.0)));
4481        assert!(!meta.grid_enabled);
4482        assert!(meta.minor_grid_enabled);
4483        assert!(meta.minor_grid_explicit);
4484        assert!(!meta.hidden_line_removal);
4485        assert!(!meta.box_enabled);
4486        assert!(!meta.axis_equal);
4487        assert_eq!(meta.data_aspect_ratio, [1.0, 2.0, 3.0]);
4488        assert_eq!(meta.data_aspect_ratio_mode, "manual");
4489        assert_eq!(meta.axes_kind, AxesKind::Polar);
4490        assert!(meta.colorbar_enabled);
4491        assert_eq!(format!("{:?}", meta.colormap), "Hot");
4492        assert_eq!(meta.color_limits, Some((0.0, 10.0)));
4493        assert_eq!(meta.x_tick_label_rotation, Some(30.0));
4494        assert_eq!(meta.y_tick_label_rotation, Some(-45.0));
4495        assert_eq!(meta.axes_style.font_size, Some(14.0));
4496    }
4497
4498    #[test]
4499    fn figure_scene_roundtrip_preserves_listed_colormap_rows() {
4500        let mut figure = Figure::new();
4501        let listed =
4502            ColorMap::from_rgb_rows(vec![[0.0, 0.25, 1.0], [1.0, 0.5, 0.0], [0.2, 0.3, 0.4]])
4503                .expect("listed colormap");
4504        figure.set_axes_colormap(0, listed);
4505
4506        let scene = FigureScene::capture(&figure);
4507        let token = scene.metadata.colormap.as_deref().expect("colormap token");
4508        assert!(token.starts_with("listed:"));
4509
4510        let rebuilt = scene.into_figure().expect("scene restore should succeed");
4511        let meta = rebuilt.axes_metadata(0).expect("axes metadata");
4512        let ColorMap::Listed(colors) = &meta.colormap else {
4513            panic!("expected listed colormap");
4514        };
4515        assert_eq!(
4516            colors.as_ref(),
4517            &[[0.0, 0.25, 1.0], [1.0, 0.5, 0.0], [0.2, 0.3, 0.4]]
4518        );
4519    }
4520
4521    #[test]
4522    fn axes_metadata_deserializes_without_axes_style() {
4523        let json = r#"{
4524            "legendEnabled": true,
4525            "colormap": "Parula",
4526            "titleStyle": {"visible": true},
4527            "xLabelStyle": {"visible": true},
4528            "yLabelStyle": {"visible": true},
4529            "zLabelStyle": {"visible": true},
4530            "legendStyle": {"visible": true}
4531        }"#;
4532        let serialized: SerializedAxesMetadata = serde_json::from_str(json).unwrap();
4533        let metadata = AxesMetadata::from(serialized);
4534        assert!(metadata.axes_style.color.is_none());
4535        assert!(metadata.axes_style.font_size.is_none());
4536        assert!(metadata.axes_style.font_weight.is_none());
4537        assert!(metadata.axes_style.font_angle.is_none());
4538        assert!(metadata.axes_style.interpreter.is_none());
4539        assert!(metadata.axes_style.visible);
4540    }
4541
4542    #[test]
4543    fn figure_scene_roundtrip_preserves_axes_local_annotation_metadata() {
4544        let mut figure = Figure::new().with_subplot_grid(1, 2);
4545        figure.set_sg_title("All Panels");
4546        figure.set_sg_title_style(TextStyle {
4547            font_weight: Some("bold".into()),
4548            font_size: Some(20.0),
4549            ..Default::default()
4550        });
4551        figure.set_active_axes_index(0);
4552        figure.set_axes_title(0, "Left");
4553        figure.set_axes_subtitle(0, "Left details");
4554        figure.set_axes_xlabel(0, "LX");
4555        figure.set_axes_ylabel(0, "LY");
4556        figure.set_axes_legend_enabled(0, false);
4557        figure.set_axes_title(1, "Right");
4558        figure.set_axes_xlabel(1, "RX");
4559        figure.set_axes_ylabel(1, "RY");
4560        figure.set_axes_legend_enabled(1, true);
4561        figure.set_axes_legend_style(
4562            1,
4563            LegendStyle {
4564                location: Some("northeast".into()),
4565                font_weight: Some("bold".into()),
4566                orientation: Some("horizontal".into()),
4567                ..Default::default()
4568            },
4569        );
4570        if let Some(meta) = figure.axes_metadata.get_mut(0) {
4571            meta.title_style.font_weight = Some("bold".into());
4572            meta.title_style.font_angle = Some("italic".into());
4573            meta.subtitle_style.font_size = Some(12.0);
4574            meta.subtitle_style.font_weight = Some("normal".into());
4575        }
4576        figure.set_active_axes_index(1);
4577
4578        let rebuilt = FigureScene::capture(&figure)
4579            .into_figure()
4580            .expect("scene restore should succeed");
4581
4582        assert_eq!(rebuilt.active_axes_index, 1);
4583        assert_eq!(rebuilt.sg_title.as_deref(), Some("All Panels"));
4584        assert_eq!(rebuilt.sg_title_style.font_weight.as_deref(), Some("bold"));
4585        assert_eq!(rebuilt.sg_title_style.font_size, Some(20.0));
4586        assert_eq!(
4587            rebuilt.axes_metadata(0).and_then(|m| m.title.as_deref()),
4588            Some("Left")
4589        );
4590        assert_eq!(
4591            rebuilt.axes_metadata(0).and_then(|m| m.subtitle.as_deref()),
4592            Some("Left details")
4593        );
4594        assert_eq!(
4595            rebuilt.axes_metadata(0).and_then(|m| m.x_label.as_deref()),
4596            Some("LX")
4597        );
4598        assert_eq!(
4599            rebuilt.axes_metadata(0).and_then(|m| m.y_label.as_deref()),
4600            Some("LY")
4601        );
4602        assert!(!rebuilt.axes_metadata(0).unwrap().legend_enabled);
4603        assert_eq!(
4604            rebuilt
4605                .axes_metadata(0)
4606                .unwrap()
4607                .title_style
4608                .font_weight
4609                .as_deref(),
4610            Some("bold")
4611        );
4612        assert_eq!(
4613            rebuilt
4614                .axes_metadata(0)
4615                .unwrap()
4616                .title_style
4617                .font_angle
4618                .as_deref(),
4619            Some("italic")
4620        );
4621        assert_eq!(
4622            rebuilt.axes_metadata(0).unwrap().subtitle_style.font_size,
4623            Some(12.0)
4624        );
4625        assert_eq!(
4626            rebuilt
4627                .axes_metadata(0)
4628                .unwrap()
4629                .subtitle_style
4630                .font_weight
4631                .as_deref(),
4632            Some("normal")
4633        );
4634        assert_eq!(
4635            rebuilt.axes_metadata(1).and_then(|m| m.title.as_deref()),
4636            Some("Right")
4637        );
4638        assert_eq!(
4639            rebuilt.axes_metadata(1).and_then(|m| m.x_label.as_deref()),
4640            Some("RX")
4641        );
4642        assert_eq!(
4643            rebuilt.axes_metadata(1).and_then(|m| m.y_label.as_deref()),
4644            Some("RY")
4645        );
4646        assert_eq!(
4647            rebuilt
4648                .axes_metadata(1)
4649                .unwrap()
4650                .legend_style
4651                .location
4652                .as_deref(),
4653            Some("northeast")
4654        );
4655        assert_eq!(
4656            rebuilt
4657                .axes_metadata(1)
4658                .unwrap()
4659                .legend_style
4660                .font_weight
4661                .as_deref(),
4662            Some("bold")
4663        );
4664        assert_eq!(
4665            rebuilt
4666                .axes_metadata(1)
4667                .unwrap()
4668                .legend_style
4669                .orientation
4670                .as_deref(),
4671            Some("horizontal")
4672        );
4673    }
4674
4675    #[test]
4676    fn figure_scene_roundtrip_preserves_axes_local_log_modes() {
4677        let mut figure = Figure::new().with_subplot_grid(1, 2);
4678        figure.set_axes_log_modes(0, true, false);
4679        figure.set_axes_log_modes(1, false, true);
4680        figure.set_active_axes_index(1);
4681
4682        let rebuilt = FigureScene::capture(&figure)
4683            .into_figure()
4684            .expect("scene restore should succeed");
4685
4686        assert!(rebuilt.axes_metadata(0).unwrap().x_log);
4687        assert!(!rebuilt.axes_metadata(0).unwrap().y_log);
4688        assert!(!rebuilt.axes_metadata(1).unwrap().x_log);
4689        assert!(rebuilt.axes_metadata(1).unwrap().y_log);
4690        assert!(!rebuilt.x_log);
4691        assert!(rebuilt.y_log);
4692    }
4693
4694    #[test]
4695    fn figure_scene_roundtrip_preserves_zlabel_and_view_state() {
4696        let mut figure = Figure::new().with_subplot_grid(1, 2);
4697        figure.set_axes_zlabel(1, "Height");
4698        figure.set_axes_view(1, 45.0, 20.0);
4699        figure.set_active_axes_index(1);
4700
4701        let rebuilt = FigureScene::capture(&figure)
4702            .into_figure()
4703            .expect("scene restore should succeed");
4704
4705        assert_eq!(
4706            rebuilt.axes_metadata(1).unwrap().z_label.as_deref(),
4707            Some("Height")
4708        );
4709        assert_eq!(
4710            rebuilt.axes_metadata(1).unwrap().view_azimuth_deg,
4711            Some(45.0)
4712        );
4713        assert_eq!(
4714            rebuilt.axes_metadata(1).unwrap().view_elevation_deg,
4715            Some(20.0)
4716        );
4717        assert_eq!(rebuilt.z_label.as_deref(), Some("Height"));
4718    }
4719
4720    #[test]
4721    fn figure_scene_roundtrip_preserves_pie_metadata() {
4722        let mut figure = Figure::new();
4723        let pie = crate::plots::PieChart::new(vec![1.0, 2.0], None)
4724            .unwrap()
4725            .with_slice_labels(vec!["A".into(), "B".into()])
4726            .with_explode(vec![false, true]);
4727        figure.add_pie_chart(pie);
4728
4729        let rebuilt = FigureScene::capture(&figure)
4730            .into_figure()
4731            .expect("scene restore should succeed");
4732        let crate::plots::figure::PlotElement::Pie(pie) = rebuilt.plots().next().unwrap() else {
4733            panic!("expected pie")
4734        };
4735        assert_eq!(pie.slice_labels, vec!["A", "B"]);
4736        assert_eq!(pie.explode, vec![false, true]);
4737    }
4738
4739    #[test]
4740    fn scene_plot_deserialize_maps_null_numeric_values_to_nan() {
4741        let json = r#"{
4742          "schemaVersion": 1,
4743          "layout": { "axesRows": 1, "axesCols": 1, "axesIndices": [0] },
4744          "metadata": {
4745            "gridEnabled": true,
4746            "legendEnabled": false,
4747            "colorbarEnabled": false,
4748            "axisEqual": false,
4749            "backgroundRgba": [1,1,1,1],
4750            "legendEntries": []
4751          },
4752          "plots": [
4753            {
4754              "kind": "surface",
4755              "x": [0.0, null],
4756              "y": [0.0, 1.0],
4757              "z": [[0.0, null], [1.0, 2.0]],
4758              "colormap": "Parula",
4759              "shading_mode": "Smooth",
4760              "wireframe": false,
4761              "alpha": 1.0,
4762              "flatten_z": false,
4763              "color_limits": null,
4764              "axes_index": 0,
4765              "label": null,
4766              "visible": true
4767            }
4768          ]
4769        }"#;
4770        let scene: FigureScene = serde_json::from_str(json).expect("scene should deserialize");
4771        let ScenePlot::Surface { x, z, .. } = &scene.plots[0] else {
4772            panic!("expected surface plot");
4773        };
4774        assert!(x[1].is_nan());
4775        assert!(z[0][1].is_nan());
4776    }
4777
4778    #[test]
4779    fn scene_plot_deserialize_maps_null_scatter3_components_to_nan() {
4780        let json = r#"{
4781          "schemaVersion": 1,
4782          "layout": { "axesRows": 1, "axesCols": 1, "axesIndices": [0] },
4783          "metadata": {
4784            "gridEnabled": true,
4785            "legendEnabled": false,
4786            "colorbarEnabled": false,
4787            "axisEqual": false,
4788            "backgroundRgba": [1,1,1,1],
4789            "legendEntries": []
4790          },
4791          "plots": [
4792            {
4793              "kind": "scatter3",
4794              "points": [[0.0, 1.0, null], [1.0, null, 2.0]],
4795              "colors_rgba": [[0.2, 0.4, 0.6, 1.0], [0.1, 0.2, 0.3, 1.0]],
4796              "point_size": 6.0,
4797              "point_sizes": [3.0, null],
4798              "axes_index": 0,
4799              "label": null,
4800              "visible": true
4801            }
4802          ]
4803        }"#;
4804        let scene: FigureScene = serde_json::from_str(json).expect("scene should deserialize");
4805        let ScenePlot::Scatter3 {
4806            points,
4807            point_sizes,
4808            ..
4809        } = &scene.plots[0]
4810        else {
4811            panic!("expected scatter3 plot");
4812        };
4813        assert!(points[0][2].is_nan());
4814        assert!(points[1][1].is_nan());
4815        assert!(point_sizes.as_ref().unwrap()[1].is_nan());
4816    }
4817}