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};
12
13/// High-level event emitted whenever a figure changes.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct FigureEvent {
17    pub handle: u32,
18    pub kind: FigureEventKind,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub fingerprint: Option<String>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub figure: Option<FigureSnapshot>,
23}
24
25/// Event kind for figure lifecycle + updates.
26#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
27#[serde(rename_all = "lowercase")]
28pub enum FigureEventKind {
29    Created,
30    Updated,
31    Cleared,
32    Closed,
33}
34
35/// Snapshot of the figure state describing layout + plots.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct FigureSnapshot {
39    pub layout: FigureLayout,
40    pub metadata: FigureMetadata,
41    pub plots: Vec<PlotDescriptor>,
42}
43
44/// Full replay scene payload capable of reconstructing a figure.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub struct FigureScene {
48    pub schema_version: u32,
49    pub layout: FigureLayout,
50    pub metadata: FigureMetadata,
51    pub plots: Vec<ScenePlot>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(tag = "kind", rename_all = "snake_case")]
56pub enum ScenePlot {
57    Line {
58        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
59        x: Vec<f64>,
60        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
61        y: Vec<f64>,
62        color_rgba: [f32; 4],
63        line_width: f32,
64        line_style: String,
65        axes_index: u32,
66        label: Option<String>,
67        visible: bool,
68    },
69    ReferenceLine {
70        orientation: String,
71        #[serde(deserialize_with = "deserialize_f64_lossy")]
72        value: f64,
73        color_rgba: [f32; 4],
74        line_width: f32,
75        line_style: String,
76        label: Option<String>,
77        display_name: Option<String>,
78        label_orientation: String,
79        axes_index: u32,
80        visible: bool,
81    },
82    Scatter {
83        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
84        x: Vec<f64>,
85        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
86        y: Vec<f64>,
87        color_rgba: [f32; 4],
88        marker_size: f32,
89        marker_style: String,
90        axes_index: u32,
91        label: Option<String>,
92        visible: bool,
93    },
94    Bar {
95        labels: Vec<String>,
96        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
97        values: Vec<f64>,
98        #[serde(default, deserialize_with = "deserialize_option_vec_f64_lossy")]
99        histogram_bin_edges: Option<Vec<f64>>,
100        color_rgba: [f32; 4],
101        #[serde(default)]
102        outline_color_rgba: Option<[f32; 4]>,
103        bar_width: f32,
104        outline_width: f32,
105        orientation: String,
106        group_index: u32,
107        group_count: u32,
108        #[serde(default, deserialize_with = "deserialize_option_vec_f64_lossy")]
109        stack_offsets: Option<Vec<f64>>,
110        axes_index: u32,
111        label: Option<String>,
112        visible: bool,
113    },
114    ErrorBar {
115        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
116        x: Vec<f64>,
117        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
118        y: Vec<f64>,
119        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
120        err_low: Vec<f64>,
121        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
122        err_high: Vec<f64>,
123        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
124        x_err_low: Vec<f64>,
125        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
126        x_err_high: Vec<f64>,
127        orientation: String,
128        color_rgba: [f32; 4],
129        line_width: f32,
130        line_style: String,
131        cap_width: f32,
132        marker_style: Option<String>,
133        marker_size: Option<f32>,
134        marker_face_color: Option<[f32; 4]>,
135        marker_edge_color: Option<[f32; 4]>,
136        marker_filled: Option<bool>,
137        axes_index: u32,
138        label: Option<String>,
139        visible: bool,
140    },
141    Stairs {
142        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
143        x: Vec<f64>,
144        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
145        y: Vec<f64>,
146        color_rgba: [f32; 4],
147        line_width: f32,
148        axes_index: u32,
149        label: Option<String>,
150        visible: bool,
151    },
152    Stem {
153        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
154        x: Vec<f64>,
155        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
156        y: Vec<f64>,
157        #[serde(deserialize_with = "deserialize_f64_lossy")]
158        baseline: f64,
159        color_rgba: [f32; 4],
160        line_width: f32,
161        line_style: String,
162        baseline_color_rgba: [f32; 4],
163        baseline_visible: bool,
164        marker_color_rgba: [f32; 4],
165        marker_size: f32,
166        marker_filled: bool,
167        axes_index: u32,
168        label: Option<String>,
169        visible: bool,
170    },
171    Area {
172        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
173        x: Vec<f64>,
174        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
175        y: Vec<f64>,
176        #[serde(default, deserialize_with = "deserialize_option_vec_f64_lossy")]
177        lower_y: Option<Vec<f64>>,
178        #[serde(deserialize_with = "deserialize_f64_lossy")]
179        baseline: f64,
180        color_rgba: [f32; 4],
181        axes_index: u32,
182        label: Option<String>,
183        visible: bool,
184    },
185    Quiver {
186        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
187        x: Vec<f64>,
188        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
189        y: Vec<f64>,
190        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
191        u: Vec<f64>,
192        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
193        v: Vec<f64>,
194        color_rgba: [f32; 4],
195        line_width: f32,
196        scale: f32,
197        head_size: f32,
198        axes_index: u32,
199        label: Option<String>,
200        visible: bool,
201    },
202    Surface {
203        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
204        x: Vec<f64>,
205        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
206        y: Vec<f64>,
207        #[serde(deserialize_with = "deserialize_matrix_f64_lossy")]
208        z: Vec<Vec<f64>>,
209        colormap: String,
210        shading_mode: String,
211        wireframe: bool,
212        alpha: f32,
213        flatten_z: bool,
214        #[serde(default)]
215        image_mode: bool,
216        #[serde(default)]
217        color_grid_rgba: Option<Vec<Vec<[f32; 4]>>>,
218        #[serde(default, deserialize_with = "deserialize_option_pair_f64_lossy")]
219        color_limits: Option<[f64; 2]>,
220        axes_index: u32,
221        label: Option<String>,
222        visible: bool,
223    },
224    Patch {
225        #[serde(deserialize_with = "deserialize_vec_xyz_f32_lossy")]
226        vertices: Vec<[f32; 3]>,
227        faces: Vec<Vec<u32>>,
228        face_color_rgba: [f32; 4],
229        edge_color_rgba: [f32; 4],
230        face_color_mode: String,
231        edge_color_mode: String,
232        face_alpha: f32,
233        edge_alpha: f32,
234        line_width: f32,
235        axes_index: u32,
236        label: Option<String>,
237        visible: bool,
238        #[serde(default)]
239        force_3d: bool,
240    },
241    Mesh {
242        #[serde(deserialize_with = "deserialize_vec_xyz_f32_lossy")]
243        vertices: Vec<[f32; 3]>,
244        triangles: Vec<[u32; 3]>,
245        mesh_id: Option<String>,
246        face_color_rgba: [f32; 4],
247        edge_color_rgba: [f32; 4],
248        face_alpha: f32,
249        edge_alpha: f32,
250        edge_width: f32,
251        #[serde(default)]
252        edge_mode: String,
253        #[serde(default, skip_serializing_if = "Vec::is_empty")]
254        feature_edge_groups: Vec<u64>,
255        #[serde(default, skip_serializing_if = "Vec::is_empty")]
256        vertex_colors_rgba: Vec<[f32; 4]>,
257        #[serde(default, skip_serializing_if = "Vec::is_empty")]
258        triangle_colors_rgba: Vec<[f32; 4]>,
259        axes_index: u32,
260        label: Option<String>,
261        #[serde(default, skip_serializing_if = "Vec::is_empty")]
262        regions: Vec<SerializedMeshRegion>,
263        #[serde(default, skip_serializing_if = "Option::is_none")]
264        highlighted_region_id: Option<String>,
265        #[serde(default, skip_serializing_if = "Option::is_none")]
266        highlight_color_rgba: Option<[f32; 4]>,
267        #[serde(default, skip_serializing_if = "Option::is_none")]
268        scalar_field: Option<Box<SerializedMeshScalarField>>,
269        #[serde(default, skip_serializing_if = "Option::is_none")]
270        vector_field: Option<Box<SerializedMeshVectorField>>,
271        #[serde(default, skip_serializing_if = "Option::is_none")]
272        deformation: Option<Box<SerializedMeshDeformation>>,
273        visible: bool,
274    },
275    Line3 {
276        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
277        x: Vec<f64>,
278        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
279        y: Vec<f64>,
280        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
281        z: Vec<f64>,
282        color_rgba: [f32; 4],
283        line_width: f32,
284        line_style: String,
285        axes_index: u32,
286        label: Option<String>,
287        visible: bool,
288    },
289    Scatter3 {
290        #[serde(deserialize_with = "deserialize_vec_xyz_f32_lossy")]
291        points: Vec<[f32; 3]>,
292        #[serde(default, deserialize_with = "deserialize_vec_rgba_f32_lossy")]
293        colors_rgba: Vec<[f32; 4]>,
294        point_size: f32,
295        #[serde(default, deserialize_with = "deserialize_option_vec_f32_lossy")]
296        point_sizes: Option<Vec<f32>>,
297        axes_index: u32,
298        label: Option<String>,
299        visible: bool,
300    },
301    Contour {
302        vertices: Vec<SerializedVertex>,
303        bounds_min: [f32; 3],
304        bounds_max: [f32; 3],
305        base_z: f32,
306        line_width: f32,
307        axes_index: u32,
308        label: Option<String>,
309        visible: bool,
310        #[serde(default)]
311        force_3d: bool,
312    },
313    ContourFill {
314        vertices: Vec<SerializedVertex>,
315        bounds_min: [f32; 3],
316        bounds_max: [f32; 3],
317        axes_index: u32,
318        label: Option<String>,
319        visible: bool,
320    },
321    Pie {
322        #[serde(deserialize_with = "deserialize_vec_f64_lossy")]
323        values: Vec<f64>,
324        colors_rgba: Vec<[f32; 4]>,
325        slice_labels: Vec<String>,
326        label_format: Option<String>,
327        explode: Vec<bool>,
328        axes_index: u32,
329        label: Option<String>,
330        visible: bool,
331    },
332    Unsupported {
333        plot_kind: PlotKind,
334        axes_index: u32,
335        label: Option<String>,
336        visible: bool,
337    },
338}
339
340impl FigureSnapshot {
341    /// Capture a snapshot from a [`Figure`] reference.
342    pub fn capture(figure: &Figure) -> Self {
343        let (rows, cols) = figure.axes_grid();
344        let layout = FigureLayout {
345            axes_rows: rows as u32,
346            axes_cols: cols as u32,
347            axes_indices: figure
348                .plot_axes_indices()
349                .iter()
350                .map(|idx| *idx as u32)
351                .collect(),
352        };
353
354        let metadata = FigureMetadata::from_figure(figure);
355
356        let plots = figure
357            .plots()
358            .enumerate()
359            .map(|(idx, plot)| PlotDescriptor::from_plot(plot, figure_axis_index(figure, idx)))
360            .collect();
361
362        Self {
363            layout,
364            metadata,
365            plots,
366        }
367    }
368
369    pub fn fingerprint(&self) -> String {
370        const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
371        const FNV_PRIME: u64 = 0x100000001b3;
372
373        let bytes = serde_json::to_vec(self).unwrap_or_default();
374        let mut hash = FNV_OFFSET_BASIS;
375        for byte in bytes {
376            hash ^= u64::from(byte);
377            hash = hash.wrapping_mul(FNV_PRIME);
378        }
379        format!("fig:{hash:016x}")
380    }
381}
382
383impl FigureScene {
384    pub const SCHEMA_VERSION: u32 = 3;
385
386    pub fn capture(figure: &Figure) -> Self {
387        let snapshot = FigureSnapshot::capture(figure);
388        let plots = figure
389            .plots()
390            .enumerate()
391            .map(|(idx, plot)| ScenePlot::from_plot(plot, figure_axis_index(figure, idx)))
392            .collect();
393
394        Self {
395            schema_version: Self::SCHEMA_VERSION,
396            layout: snapshot.layout,
397            metadata: snapshot.metadata,
398            plots,
399        }
400    }
401
402    pub fn from_geometry_scene(scene: &crate::geometry_scene::GeometryScene) -> Self {
403        let mut figure = Figure::new()
404            .with_grid(scene.show_grid)
405            .with_legend(false)
406            .with_axis_equal(scene.axis_equal);
407        figure.title = scene.title.clone();
408        figure.x_label = Some("X".to_string());
409        figure.y_label = Some("Y".to_string());
410        figure.z_label = Some("Z".to_string());
411        figure.set_axes_view(0, -38.0, 24.0);
412        let snapshot = FigureSnapshot::capture(&figure);
413        let plots = scene
414            .chunks
415            .iter()
416            .filter_map(scene_chunk_to_mesh_plot)
417            .collect::<Vec<_>>();
418
419        Self {
420            schema_version: Self::SCHEMA_VERSION,
421            layout: FigureLayout {
422                axes_rows: 1,
423                axes_cols: 1,
424                axes_indices: vec![0; plots.len()],
425            },
426            metadata: snapshot.metadata,
427            plots,
428        }
429    }
430
431    pub fn into_figure(self) -> Result<Figure, String> {
432        self.validate_schema_version()?;
433
434        let mut figure = Figure::new();
435        figure.set_subplot_grid(
436            self.layout.axes_rows as usize,
437            self.layout.axes_cols as usize,
438        );
439        figure.active_axes_index = self.metadata.active_axes_index as usize;
440        if let Some(axes_metadata) = self.metadata.axes_metadata.clone() {
441            figure.axes_metadata = axes_metadata.into_iter().map(AxesMetadata::from).collect();
442            figure.set_active_axes_index(figure.active_axes_index);
443        } else {
444            figure.title = self.metadata.title;
445            figure.x_label = self.metadata.x_label;
446            figure.y_label = self.metadata.y_label;
447            figure.legend_enabled = self.metadata.legend_enabled;
448        }
449        figure.name = self.metadata.name;
450        figure.number_title = self.metadata.number_title;
451        figure.visible = self.metadata.visible;
452        figure.sg_title = self.metadata.sg_title;
453        figure.sg_title_style = self
454            .metadata
455            .sg_title_style
456            .map(TextStyle::from)
457            .unwrap_or_default();
458        figure.grid_enabled = self.metadata.grid_enabled;
459        figure.minor_grid_enabled = self.metadata.minor_grid_enabled;
460        figure.z_limits = self.metadata.z_limits.map(|[lo, hi]| (lo, hi));
461        figure.colorbar_enabled = self.metadata.colorbar_enabled;
462        figure.axis_equal = self.metadata.axis_equal;
463        figure.background_color = rgba_to_vec4(self.metadata.background_rgba);
464
465        for plot in self.plots {
466            plot.apply_to_figure(&mut figure)?;
467        }
468
469        Ok(figure)
470    }
471
472    pub fn into_geometry_scene(
473        self,
474        scene_id: impl Into<String>,
475        revision: u64,
476    ) -> Result<crate::GeometryScene, String> {
477        self.validate_schema_version()?;
478        let scene_id = scene_id.into();
479        let mut chunks = Vec::new();
480        for (plot_index, plot) in self.plots.into_iter().enumerate() {
481            append_geometry_scene_chunks(&scene_id, plot_index, plot, &mut chunks)?;
482        }
483        if chunks.is_empty() {
484            return Err("figure scene does not contain renderable mesh plots".to_string());
485        }
486        let mut scene = crate::GeometryScene::new(scene_id, revision, chunks).with_title(
487            self.metadata
488                .title
489                .unwrap_or_else(|| "Geometry Preview".to_string()),
490        );
491        scene.show_grid = self.metadata.grid_enabled;
492        scene.axis_equal = self.metadata.axis_equal;
493        Ok(scene)
494    }
495
496    fn validate_schema_version(&self) -> Result<(), String> {
497        if self.schema_version == 0 || self.schema_version > FigureScene::SCHEMA_VERSION {
498            return Err(format!(
499                "unsupported figure scene schema version {} (supported 1..={})",
500                self.schema_version,
501                FigureScene::SCHEMA_VERSION
502            ));
503        }
504        if self.schema_version < 2
505            && self
506                .plots
507                .iter()
508                .any(|plot| matches!(plot, ScenePlot::Patch { .. }))
509        {
510            return Err(format!(
511                "patch plots require figure scene schema version {}",
512                2
513            ));
514        }
515        if self.schema_version < 3
516            && self
517                .plots
518                .iter()
519                .any(|plot| matches!(plot, ScenePlot::Mesh { .. }))
520        {
521            return Err(format!(
522                "mesh plots require figure scene schema version {}",
523                3
524            ));
525        }
526        Ok(())
527    }
528}
529
530fn append_geometry_scene_chunks(
531    scene_id: &str,
532    plot_index: usize,
533    plot: ScenePlot,
534    chunks: &mut Vec<crate::GeometrySceneChunk>,
535) -> Result<(), String> {
536    let ScenePlot::Mesh {
537        vertices,
538        triangles,
539        mesh_id,
540        face_color_rgba,
541        edge_color_rgba,
542        face_alpha,
543        edge_alpha,
544        edge_width,
545        edge_mode,
546        feature_edge_groups,
547        vertex_colors_rgba,
548        triangle_colors_rgba,
549        axes_index: _,
550        label,
551        regions,
552        highlighted_region_id,
553        highlight_color_rgba,
554        scalar_field,
555        vector_field,
556        deformation,
557        visible,
558    } = plot
559    else {
560        return Ok(());
561    };
562
563    if !visible {
564        return Ok(());
565    }
566
567    let region_metadata = regions
568        .iter()
569        .cloned()
570        .map(crate::geometry_scene::GeometrySceneRegion::from)
571        .collect::<Vec<_>>();
572    let mesh_id_for_chunk = mesh_id
573        .clone()
574        .unwrap_or_else(|| format!("mesh_{}", plot_index + 1));
575    let mut mesh = MeshPlot::new(vertices.into_iter().map(xyz_to_vec3).collect(), triangles)?;
576    mesh.set_mesh_id(mesh_id.clone());
577    mesh.set_face_color(rgba_to_vec4(face_color_rgba));
578    mesh.set_edge_color(rgba_to_vec4(edge_color_rgba));
579    mesh.set_face_alpha(face_alpha);
580    mesh.set_edge_alpha(edge_alpha);
581    mesh.set_edge_width(edge_width);
582    mesh.set_edge_mode(parse_mesh_edge_mode(&edge_mode));
583    if !feature_edge_groups.is_empty() {
584        mesh.set_feature_edge_groups(Some(feature_edge_groups))?;
585    }
586    if !vertex_colors_rgba.is_empty() {
587        mesh.set_vertex_colors(Some(
588            vertex_colors_rgba.into_iter().map(rgba_to_vec4).collect(),
589        ))?;
590    }
591    if !triangle_colors_rgba.is_empty() {
592        mesh.set_triangle_colors(Some(
593            triangle_colors_rgba.into_iter().map(rgba_to_vec4).collect(),
594        ))?;
595    }
596    mesh.set_label(label.clone());
597    mesh.set_regions(regions.into_iter().map(Into::into).collect());
598    mesh.set_highlighted_region_id(highlighted_region_id);
599    if let Some(color) = highlight_color_rgba {
600        mesh.set_highlight_color(rgba_to_vec4(color));
601    }
602    if let Some(field) = scalar_field {
603        mesh.set_scalar_field(Some((*field).try_into()?))?;
604    }
605    if let Some(field) = vector_field {
606        mesh.set_vector_field(Some((*field).try_into()?))?;
607    }
608    if let Some(field) = deformation {
609        mesh.set_deformation(Some((*field).into()))?;
610    }
611
612    let face_render_data = mesh.render_data();
613    chunks.push(
614        crate::GeometrySceneChunk::from_render_data(
615            format!("{scene_id}:{mesh_id_for_chunk}:faces:{plot_index}"),
616            face_render_data,
617        )
618        .with_mesh_id(mesh_id_for_chunk.clone())
619        .with_label(label.clone().unwrap_or_else(|| mesh_id_for_chunk.clone()))
620        .with_regions(region_metadata),
621    );
622
623    if let Some(edge_render_data) = mesh.edge_render_data() {
624        chunks.push(
625            crate::GeometrySceneChunk::from_render_data(
626                format!("{scene_id}:{mesh_id_for_chunk}:edges:{plot_index}"),
627                edge_render_data,
628            )
629            .with_mesh_id(mesh_id_for_chunk.clone())
630            .with_label(format!(
631                "{} edges",
632                label.clone().unwrap_or_else(|| mesh_id_for_chunk.clone())
633            )),
634        );
635    }
636
637    if let Some(vector_render_data) = mesh.vector_render_data() {
638        chunks.push(
639            crate::GeometrySceneChunk::from_render_data(
640                format!("{scene_id}:{mesh_id_for_chunk}:vectors:{plot_index}"),
641                vector_render_data,
642            )
643            .with_mesh_id(mesh_id_for_chunk.clone())
644            .with_label(format!(
645                "{} vectors",
646                label.unwrap_or_else(|| mesh_id_for_chunk.clone())
647            )),
648        );
649    }
650
651    Ok(())
652}
653
654fn scene_chunk_to_mesh_plot(
655    chunk: &crate::geometry_scene::GeometrySceneChunk,
656) -> Option<ScenePlot> {
657    if chunk.render_data.pipeline_type != crate::core::PipelineType::Triangles {
658        return None;
659    }
660    let indices = chunk.indices.as_ref()?;
661    if indices.len() < 3 {
662        return None;
663    }
664    let triangles = indices
665        .chunks_exact(3)
666        .map(|item| [item[0], item[1], item[2]])
667        .collect::<Vec<_>>();
668    if triangles.is_empty() {
669        return None;
670    }
671    let vertices = chunk
672        .vertices
673        .iter()
674        .map(|vertex| vertex.position)
675        .collect::<Vec<_>>();
676    Some(ScenePlot::Mesh {
677        vertices,
678        triangles,
679        mesh_id: chunk.mesh_id.clone(),
680        face_color_rgba: chunk.material.albedo.to_array(),
681        edge_color_rgba: [0.08, 0.10, 0.13, 1.0],
682        face_alpha: chunk.material.albedo.w,
683        edge_alpha: 0.0,
684        edge_width: 0.0,
685        edge_mode: "none".to_string(),
686        feature_edge_groups: Vec::new(),
687        vertex_colors_rgba: Vec::new(),
688        triangle_colors_rgba: Vec::new(),
689        axes_index: 0,
690        label: chunk.label.clone(),
691        regions: chunk.regions.iter().map(Into::into).collect(),
692        highlighted_region_id: None,
693        highlight_color_rgba: Some([0.98, 0.78, 0.22, 1.0]),
694        scalar_field: None,
695        vector_field: None,
696        deformation: None,
697        visible: chunk.visible,
698    })
699}
700
701fn figure_axis_index(figure: &Figure, plot_index: usize) -> u32 {
702    figure
703        .plot_axes_indices()
704        .get(plot_index)
705        .copied()
706        .unwrap_or(0) as u32
707}
708
709/// Layout metadata describing subplot arrangement.
710#[derive(Debug, Clone, Serialize, Deserialize)]
711#[serde(rename_all = "camelCase")]
712pub struct FigureLayout {
713    pub axes_rows: u32,
714    pub axes_cols: u32,
715    pub axes_indices: Vec<u32>,
716}
717
718/// Figure-level metadata (title, labels, theming).
719#[derive(Debug, Clone, Serialize, Deserialize)]
720#[serde(rename_all = "camelCase")]
721pub struct FigureMetadata {
722    #[serde(skip_serializing_if = "Option::is_none")]
723    pub name: Option<String>,
724    #[serde(default = "default_true", skip_serializing_if = "is_true")]
725    pub number_title: bool,
726    #[serde(default = "default_true", skip_serializing_if = "is_true")]
727    pub visible: bool,
728    #[serde(skip_serializing_if = "Option::is_none")]
729    pub title: Option<String>,
730    #[serde(skip_serializing_if = "Option::is_none")]
731    pub sg_title: Option<String>,
732    #[serde(skip_serializing_if = "Option::is_none")]
733    pub sg_title_style: Option<SerializedTextStyle>,
734    #[serde(skip_serializing_if = "Option::is_none")]
735    pub x_label: Option<String>,
736    #[serde(skip_serializing_if = "Option::is_none")]
737    pub y_label: Option<String>,
738    pub grid_enabled: bool,
739    #[serde(default)]
740    pub minor_grid_enabled: bool,
741    pub legend_enabled: bool,
742    pub colorbar_enabled: bool,
743    pub axis_equal: bool,
744    pub background_rgba: [f32; 4],
745    #[serde(skip_serializing_if = "Option::is_none")]
746    pub colormap: Option<String>,
747    #[serde(skip_serializing_if = "Option::is_none")]
748    pub color_limits: Option<[f64; 2]>,
749    #[serde(skip_serializing_if = "Option::is_none")]
750    pub z_limits: Option<[f64; 2]>,
751    pub legend_entries: Vec<FigureLegendEntry>,
752    #[serde(default)]
753    pub active_axes_index: u32,
754    #[serde(skip_serializing_if = "Option::is_none")]
755    pub axes_metadata: Option<Vec<SerializedAxesMetadata>>,
756}
757
758impl FigureMetadata {
759    fn from_figure(figure: &Figure) -> Self {
760        let legend_entries = figure
761            .legend_entries()
762            .into_iter()
763            .map(FigureLegendEntry::from)
764            .collect();
765
766        Self {
767            name: figure.name.clone(),
768            number_title: figure.number_title,
769            visible: figure.visible,
770            title: figure.title.clone(),
771            sg_title: figure.sg_title.clone(),
772            sg_title_style: figure
773                .sg_title
774                .as_ref()
775                .map(|_| figure.sg_title_style.clone().into()),
776            x_label: figure.x_label.clone(),
777            y_label: figure.y_label.clone(),
778            grid_enabled: figure.grid_enabled,
779            minor_grid_enabled: figure.minor_grid_enabled,
780            legend_enabled: figure.legend_enabled,
781            colorbar_enabled: figure.colorbar_enabled,
782            axis_equal: figure.axis_equal,
783            background_rgba: vec4_to_rgba(figure.background_color),
784            colormap: Some(format!("{:?}", figure.colormap)),
785            color_limits: figure.color_limits.map(|(lo, hi)| [lo, hi]),
786            z_limits: figure.z_limits.map(|(lo, hi)| [lo, hi]),
787            legend_entries,
788            active_axes_index: figure.active_axes_index as u32,
789            axes_metadata: Some(
790                figure
791                    .axes_metadata
792                    .iter()
793                    .cloned()
794                    .map(SerializedAxesMetadata::from)
795                    .collect(),
796            ),
797        }
798    }
799}
800
801fn default_true() -> bool {
802    true
803}
804
805fn is_true(value: &bool) -> bool {
806    *value
807}
808
809fn is_false(value: &bool) -> bool {
810    !*value
811}
812
813#[derive(Debug, Clone, Serialize, Deserialize)]
814#[serde(rename_all = "camelCase")]
815pub struct SerializedTextStyle {
816    #[serde(skip_serializing_if = "Option::is_none")]
817    pub color_rgba: Option<[f32; 4]>,
818    #[serde(skip_serializing_if = "Option::is_none")]
819    pub font_size: Option<f32>,
820    #[serde(skip_serializing_if = "Option::is_none")]
821    pub font_weight: Option<String>,
822    #[serde(skip_serializing_if = "Option::is_none")]
823    pub font_angle: Option<String>,
824    #[serde(skip_serializing_if = "Option::is_none")]
825    pub interpreter: Option<String>,
826    pub visible: bool,
827}
828
829impl Default for SerializedTextStyle {
830    fn default() -> Self {
831        TextStyle::default().into()
832    }
833}
834
835impl From<TextStyle> for SerializedTextStyle {
836    fn from(value: TextStyle) -> Self {
837        Self {
838            color_rgba: value.color.map(vec4_to_rgba),
839            font_size: value.font_size,
840            font_weight: value.font_weight,
841            font_angle: value.font_angle,
842            interpreter: value.interpreter,
843            visible: value.visible,
844        }
845    }
846}
847
848impl From<SerializedTextStyle> for TextStyle {
849    fn from(value: SerializedTextStyle) -> Self {
850        Self {
851            color: value.color_rgba.map(rgba_to_vec4),
852            font_size: value.font_size,
853            font_weight: value.font_weight,
854            font_angle: value.font_angle,
855            interpreter: value.interpreter,
856            visible: value.visible,
857        }
858    }
859}
860
861#[derive(Debug, Clone, Serialize, Deserialize)]
862#[serde(rename_all = "camelCase")]
863pub struct SerializedLegendStyle {
864    #[serde(skip_serializing_if = "Option::is_none")]
865    pub location: Option<String>,
866    pub visible: bool,
867    #[serde(skip_serializing_if = "Option::is_none")]
868    pub font_size: Option<f32>,
869    #[serde(skip_serializing_if = "Option::is_none")]
870    pub font_weight: Option<String>,
871    #[serde(skip_serializing_if = "Option::is_none")]
872    pub font_angle: Option<String>,
873    #[serde(skip_serializing_if = "Option::is_none")]
874    pub interpreter: Option<String>,
875    #[serde(skip_serializing_if = "Option::is_none")]
876    pub box_visible: Option<bool>,
877    #[serde(skip_serializing_if = "Option::is_none")]
878    pub orientation: Option<String>,
879    #[serde(skip_serializing_if = "Option::is_none")]
880    pub text_color_rgba: Option<[f32; 4]>,
881}
882
883impl From<LegendStyle> for SerializedLegendStyle {
884    fn from(value: LegendStyle) -> Self {
885        Self {
886            location: value.location,
887            visible: value.visible,
888            font_size: value.font_size,
889            font_weight: value.font_weight,
890            font_angle: value.font_angle,
891            interpreter: value.interpreter,
892            box_visible: value.box_visible,
893            orientation: value.orientation,
894            text_color_rgba: value.text_color.map(vec4_to_rgba),
895        }
896    }
897}
898
899impl From<SerializedLegendStyle> for LegendStyle {
900    fn from(value: SerializedLegendStyle) -> Self {
901        Self {
902            location: value.location,
903            visible: value.visible,
904            font_size: value.font_size,
905            font_weight: value.font_weight,
906            font_angle: value.font_angle,
907            interpreter: value.interpreter,
908            box_visible: value.box_visible,
909            orientation: value.orientation,
910            text_color: value.text_color_rgba.map(rgba_to_vec4),
911        }
912    }
913}
914
915#[derive(Debug, Clone, Serialize, Deserialize)]
916#[serde(rename_all = "camelCase")]
917pub struct SerializedAxesMetadata {
918    #[serde(default, skip_serializing_if = "is_cartesian_axes_kind")]
919    pub axes_kind: SerializedAxesKind,
920    #[serde(skip_serializing_if = "Option::is_none")]
921    pub title: Option<String>,
922    #[serde(skip_serializing_if = "Option::is_none")]
923    pub x_label: Option<String>,
924    #[serde(skip_serializing_if = "Option::is_none")]
925    pub y_label: Option<String>,
926    #[serde(skip_serializing_if = "Option::is_none")]
927    pub z_label: Option<String>,
928    #[serde(default, skip_serializing_if = "Option::is_none")]
929    pub x_tick_labels: Option<Vec<String>>,
930    #[serde(default, skip_serializing_if = "Option::is_none")]
931    pub y_tick_labels: Option<Vec<String>>,
932    #[serde(skip_serializing_if = "Option::is_none")]
933    pub x_limits: Option<[f64; 2]>,
934    #[serde(skip_serializing_if = "Option::is_none")]
935    pub y_limits: Option<[f64; 2]>,
936    #[serde(skip_serializing_if = "Option::is_none")]
937    pub z_limits: Option<[f64; 2]>,
938    #[serde(default)]
939    pub x_log: bool,
940    #[serde(default)]
941    pub y_log: bool,
942    #[serde(skip_serializing_if = "Option::is_none")]
943    pub view_azimuth_deg: Option<f32>,
944    #[serde(skip_serializing_if = "Option::is_none")]
945    pub view_elevation_deg: Option<f32>,
946    #[serde(default)]
947    pub grid_enabled: bool,
948    #[serde(default)]
949    pub minor_grid_enabled: bool,
950    #[serde(default, skip_serializing_if = "is_false")]
951    pub minor_grid_explicit: bool,
952    #[serde(default)]
953    pub box_enabled: bool,
954    #[serde(default)]
955    pub axis_equal: bool,
956    pub legend_enabled: bool,
957    #[serde(default)]
958    pub colorbar_enabled: bool,
959    pub colormap: String,
960    #[serde(skip_serializing_if = "Option::is_none")]
961    pub color_limits: Option<[f64; 2]>,
962    #[serde(default)]
963    pub axes_style: SerializedTextStyle,
964    pub title_style: SerializedTextStyle,
965    pub x_label_style: SerializedTextStyle,
966    pub y_label_style: SerializedTextStyle,
967    pub z_label_style: SerializedTextStyle,
968    pub legend_style: SerializedLegendStyle,
969    #[serde(default, skip_serializing_if = "Vec::is_empty")]
970    pub world_text_annotations: Vec<SerializedTextAnnotation>,
971}
972
973#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
974#[serde(rename_all = "camelCase")]
975pub enum SerializedAxesKind {
976    #[default]
977    Cartesian,
978    Polar,
979}
980
981fn is_cartesian_axes_kind(value: &SerializedAxesKind) -> bool {
982    *value == SerializedAxesKind::Cartesian
983}
984
985impl From<AxesKind> for SerializedAxesKind {
986    fn from(value: AxesKind) -> Self {
987        match value {
988            AxesKind::Cartesian => Self::Cartesian,
989            AxesKind::Polar => Self::Polar,
990        }
991    }
992}
993
994impl From<SerializedAxesKind> for AxesKind {
995    fn from(value: SerializedAxesKind) -> Self {
996        match value {
997            SerializedAxesKind::Cartesian => Self::Cartesian,
998            SerializedAxesKind::Polar => Self::Polar,
999        }
1000    }
1001}
1002
1003#[derive(Debug, Clone, Serialize, Deserialize)]
1004#[serde(rename_all = "camelCase")]
1005pub struct SerializedTextAnnotation {
1006    pub position: [f32; 3],
1007    pub text: String,
1008    pub style: SerializedTextStyle,
1009}
1010
1011#[derive(Debug, Clone, Serialize, Deserialize)]
1012#[serde(rename_all = "camelCase")]
1013pub struct SerializedMeshRegion {
1014    pub region_id: String,
1015    #[serde(default, skip_serializing_if = "Option::is_none")]
1016    pub label: Option<String>,
1017    #[serde(default, skip_serializing_if = "Option::is_none")]
1018    pub tag: Option<String>,
1019    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1020    pub triangle_ranges: Vec<SerializedMeshTriangleRange>,
1021}
1022
1023#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
1024#[serde(rename_all = "camelCase")]
1025pub struct SerializedMeshTriangleRange {
1026    pub start: u32,
1027    pub count: u32,
1028}
1029
1030#[derive(Debug, Clone, Serialize, Deserialize)]
1031#[serde(rename_all = "camelCase")]
1032pub struct SerializedMeshScalarField {
1033    pub field_id: String,
1034    #[serde(default, skip_serializing_if = "Option::is_none")]
1035    pub label: Option<String>,
1036    pub location: String,
1037    #[serde(deserialize_with = "deserialize_vec_f32_lossy")]
1038    pub values: Vec<f32>,
1039    #[serde(default, skip_serializing_if = "Option::is_none")]
1040    pub color_limits: Option<[f32; 2]>,
1041    pub colormap: String,
1042    pub alpha: f32,
1043}
1044
1045#[derive(Debug, Clone, Serialize, Deserialize)]
1046#[serde(rename_all = "camelCase")]
1047pub struct SerializedMeshVectorField {
1048    pub field_id: String,
1049    #[serde(default, skip_serializing_if = "Option::is_none")]
1050    pub label: Option<String>,
1051    pub location: String,
1052    #[serde(deserialize_with = "deserialize_vec_xyz_f32_lossy")]
1053    pub vectors: Vec<[f32; 3]>,
1054    pub scale: f32,
1055    pub stride: usize,
1056    pub color_rgba: [f32; 4],
1057}
1058
1059#[derive(Debug, Clone, Serialize, Deserialize)]
1060#[serde(rename_all = "camelCase")]
1061pub struct SerializedMeshDeformation {
1062    pub field_id: String,
1063    #[serde(default, skip_serializing_if = "Option::is_none")]
1064    pub label: Option<String>,
1065    #[serde(deserialize_with = "deserialize_vec_xyz_f32_lossy")]
1066    pub displacements: Vec<[f32; 3]>,
1067    pub scale: f32,
1068}
1069
1070impl From<AxesMetadata> for SerializedAxesMetadata {
1071    fn from(value: AxesMetadata) -> Self {
1072        Self {
1073            axes_kind: value.axes_kind.into(),
1074            title: value.title,
1075            x_label: value.x_label,
1076            y_label: value.y_label,
1077            z_label: value.z_label,
1078            x_tick_labels: value.x_tick_labels,
1079            y_tick_labels: value.y_tick_labels,
1080            x_limits: value.x_limits.map(|(a, b)| [a, b]),
1081            y_limits: value.y_limits.map(|(a, b)| [a, b]),
1082            z_limits: value.z_limits.map(|(a, b)| [a, b]),
1083            x_log: value.x_log,
1084            y_log: value.y_log,
1085            view_azimuth_deg: value.view_azimuth_deg,
1086            view_elevation_deg: value.view_elevation_deg,
1087            grid_enabled: value.grid_enabled,
1088            minor_grid_enabled: value.minor_grid_enabled,
1089            minor_grid_explicit: value.minor_grid_explicit,
1090            box_enabled: value.box_enabled,
1091            axis_equal: value.axis_equal,
1092            legend_enabled: value.legend_enabled,
1093            colorbar_enabled: value.colorbar_enabled,
1094            colormap: format!("{:?}", value.colormap),
1095            color_limits: value.color_limits.map(|(a, b)| [a, b]),
1096            axes_style: value.axes_style.into(),
1097            title_style: value.title_style.into(),
1098            x_label_style: value.x_label_style.into(),
1099            y_label_style: value.y_label_style.into(),
1100            z_label_style: value.z_label_style.into(),
1101            legend_style: value.legend_style.into(),
1102            world_text_annotations: value
1103                .world_text_annotations
1104                .into_iter()
1105                .map(Into::into)
1106                .collect(),
1107        }
1108    }
1109}
1110
1111impl From<SerializedAxesMetadata> for AxesMetadata {
1112    fn from(value: SerializedAxesMetadata) -> Self {
1113        Self {
1114            axes_kind: value.axes_kind.into(),
1115            title: value.title,
1116            x_label: value.x_label,
1117            y_label: value.y_label,
1118            z_label: value.z_label,
1119            x_tick_labels: value.x_tick_labels,
1120            y_tick_labels: value.y_tick_labels,
1121            x_limits: value.x_limits.map(|[a, b]| (a, b)),
1122            y_limits: value.y_limits.map(|[a, b]| (a, b)),
1123            z_limits: value.z_limits.map(|[a, b]| (a, b)),
1124            x_log: value.x_log,
1125            y_log: value.y_log,
1126            view_azimuth_deg: value.view_azimuth_deg,
1127            view_elevation_deg: value.view_elevation_deg,
1128            view_revision: 0,
1129            grid_enabled: value.grid_enabled,
1130            minor_grid_enabled: value.minor_grid_enabled,
1131            minor_grid_explicit: value.minor_grid_explicit || value.minor_grid_enabled,
1132            box_enabled: value.box_enabled,
1133            axis_equal: value.axis_equal,
1134            legend_enabled: value.legend_enabled,
1135            colorbar_enabled: value.colorbar_enabled,
1136            colormap: parse_colormap_name(&value.colormap),
1137            color_limits: value.color_limits.map(|[a, b]| (a, b)),
1138            axes_style: value.axes_style.into(),
1139            title_style: value.title_style.into(),
1140            x_label_style: value.x_label_style.into(),
1141            y_label_style: value.y_label_style.into(),
1142            z_label_style: value.z_label_style.into(),
1143            legend_style: value.legend_style.into(),
1144            world_text_annotations: value
1145                .world_text_annotations
1146                .into_iter()
1147                .map(Into::into)
1148                .collect(),
1149        }
1150    }
1151}
1152
1153impl From<crate::plots::figure::TextAnnotation> for SerializedTextAnnotation {
1154    fn from(value: crate::plots::figure::TextAnnotation) -> Self {
1155        Self {
1156            position: value.position.to_array(),
1157            text: value.text,
1158            style: value.style.into(),
1159        }
1160    }
1161}
1162
1163impl From<SerializedTextAnnotation> for crate::plots::figure::TextAnnotation {
1164    fn from(value: SerializedTextAnnotation) -> Self {
1165        Self {
1166            position: glam::Vec3::from_array(value.position),
1167            text: value.text,
1168            style: value.style.into(),
1169        }
1170    }
1171}
1172
1173impl From<&MeshRegion> for SerializedMeshRegion {
1174    fn from(value: &MeshRegion) -> Self {
1175        Self {
1176            region_id: value.region_id.clone(),
1177            label: value.label.clone(),
1178            tag: value.tag.clone(),
1179            triangle_ranges: value
1180                .triangle_ranges
1181                .iter()
1182                .copied()
1183                .map(Into::into)
1184                .collect(),
1185        }
1186    }
1187}
1188
1189impl From<&crate::geometry_scene::GeometrySceneRegion> for SerializedMeshRegion {
1190    fn from(value: &crate::geometry_scene::GeometrySceneRegion) -> Self {
1191        Self {
1192            region_id: value.region_id.clone(),
1193            label: value.label.clone(),
1194            tag: value.tag.clone(),
1195            triangle_ranges: value
1196                .triangle_ranges
1197                .iter()
1198                .copied()
1199                .map(Into::into)
1200                .collect(),
1201        }
1202    }
1203}
1204
1205impl From<SerializedMeshRegion> for MeshRegion {
1206    fn from(value: SerializedMeshRegion) -> Self {
1207        MeshRegion {
1208            region_id: value.region_id,
1209            label: value.label,
1210            tag: value.tag,
1211            triangle_ranges: value.triangle_ranges.into_iter().map(Into::into).collect(),
1212        }
1213    }
1214}
1215
1216impl From<SerializedMeshRegion> for crate::geometry_scene::GeometrySceneRegion {
1217    fn from(value: SerializedMeshRegion) -> Self {
1218        crate::geometry_scene::GeometrySceneRegion::new(
1219            value.region_id,
1220            value.label,
1221            value.tag,
1222            value.triangle_ranges.into_iter().map(Into::into).collect(),
1223        )
1224    }
1225}
1226
1227impl From<MeshTriangleRange> for SerializedMeshTriangleRange {
1228    fn from(value: MeshTriangleRange) -> Self {
1229        Self {
1230            start: value.start,
1231            count: value.count,
1232        }
1233    }
1234}
1235
1236impl From<crate::geometry_scene::GeometrySceneTriangleRange> for SerializedMeshTriangleRange {
1237    fn from(value: crate::geometry_scene::GeometrySceneTriangleRange) -> Self {
1238        Self {
1239            start: value.start,
1240            count: value.count,
1241        }
1242    }
1243}
1244
1245impl From<SerializedMeshTriangleRange> for MeshTriangleRange {
1246    fn from(value: SerializedMeshTriangleRange) -> Self {
1247        Self::new(value.start, value.count)
1248    }
1249}
1250
1251impl From<SerializedMeshTriangleRange> for crate::geometry_scene::GeometrySceneTriangleRange {
1252    fn from(value: SerializedMeshTriangleRange) -> Self {
1253        Self::new(value.start, value.count)
1254    }
1255}
1256
1257impl From<&MeshScalarField> for SerializedMeshScalarField {
1258    fn from(value: &MeshScalarField) -> Self {
1259        Self {
1260            field_id: value.field_id.clone(),
1261            label: value.label.clone(),
1262            location: value.location.as_str().to_string(),
1263            values: value.values.clone(),
1264            color_limits: value.color_limits,
1265            colormap: value.colormap.clone(),
1266            alpha: value.alpha,
1267        }
1268    }
1269}
1270
1271impl TryFrom<SerializedMeshScalarField> for MeshScalarField {
1272    type Error = String;
1273
1274    fn try_from(value: SerializedMeshScalarField) -> Result<Self, Self::Error> {
1275        Ok(Self {
1276            field_id: value.field_id,
1277            label: value.label,
1278            location: MeshFieldLocation::parse(&value.location).ok_or_else(|| {
1279                format!("unknown mesh scalar field location '{}'", value.location)
1280            })?,
1281            values: value.values,
1282            color_limits: value.color_limits,
1283            colormap: value.colormap,
1284            alpha: value.alpha,
1285        })
1286    }
1287}
1288
1289impl From<&MeshVectorField> for SerializedMeshVectorField {
1290    fn from(value: &MeshVectorField) -> Self {
1291        Self {
1292            field_id: value.field_id.clone(),
1293            label: value.label.clone(),
1294            location: value.location.as_str().to_string(),
1295            vectors: value
1296                .vectors
1297                .iter()
1298                .map(|vector| vector.to_array())
1299                .collect(),
1300            scale: value.scale,
1301            stride: value.stride,
1302            color_rgba: vec4_to_rgba(value.color),
1303        }
1304    }
1305}
1306
1307impl TryFrom<SerializedMeshVectorField> for MeshVectorField {
1308    type Error = String;
1309
1310    fn try_from(value: SerializedMeshVectorField) -> Result<Self, Self::Error> {
1311        Ok(Self {
1312            field_id: value.field_id,
1313            label: value.label,
1314            location: MeshFieldLocation::parse(&value.location).ok_or_else(|| {
1315                format!("unknown mesh vector field location '{}'", value.location)
1316            })?,
1317            vectors: value.vectors.into_iter().map(Vec3::from_array).collect(),
1318            scale: value.scale,
1319            stride: value.stride,
1320            color: rgba_to_vec4(value.color_rgba),
1321        })
1322    }
1323}
1324
1325impl From<&MeshDeformation> for SerializedMeshDeformation {
1326    fn from(value: &MeshDeformation) -> Self {
1327        Self {
1328            field_id: value.field_id.clone(),
1329            label: value.label.clone(),
1330            displacements: value
1331                .displacements
1332                .iter()
1333                .map(|displacement| displacement.to_array())
1334                .collect(),
1335            scale: value.scale,
1336        }
1337    }
1338}
1339
1340impl From<SerializedMeshDeformation> for MeshDeformation {
1341    fn from(value: SerializedMeshDeformation) -> Self {
1342        Self {
1343            field_id: value.field_id,
1344            label: value.label,
1345            displacements: value
1346                .displacements
1347                .into_iter()
1348                .map(Vec3::from_array)
1349                .collect(),
1350            scale: value.scale,
1351        }
1352    }
1353}
1354
1355/// Descriptor for a single plot element within the figure.
1356#[derive(Debug, Clone, Serialize, Deserialize)]
1357#[serde(rename_all = "camelCase")]
1358pub struct PlotDescriptor {
1359    pub kind: PlotKind,
1360    #[serde(skip_serializing_if = "Option::is_none")]
1361    pub label: Option<String>,
1362    pub axes_index: u32,
1363    pub color_rgba: [f32; 4],
1364    pub visible: bool,
1365}
1366
1367impl PlotDescriptor {
1368    fn from_plot(plot: &PlotElement, axes_index: u32) -> Self {
1369        Self {
1370            kind: PlotKind::from(plot.plot_type()),
1371            label: plot.label(),
1372            axes_index,
1373            color_rgba: vec4_to_rgba(plot.color()),
1374            visible: plot.is_visible(),
1375        }
1376    }
1377}
1378
1379impl ScenePlot {
1380    fn from_plot(plot: &PlotElement, axes_index: u32) -> Self {
1381        match plot {
1382            PlotElement::Line(line) => Self::Line {
1383                x: line.x_data.clone(),
1384                y: line.y_data.clone(),
1385                color_rgba: vec4_to_rgba(line.color),
1386                line_width: line.line_width,
1387                line_style: format!("{:?}", line.line_style),
1388                axes_index,
1389                label: line.label.clone(),
1390                visible: line.visible,
1391            },
1392            PlotElement::ReferenceLine(line) => Self::ReferenceLine {
1393                orientation: match line.orientation {
1394                    ReferenceLineOrientation::Vertical => "vertical",
1395                    ReferenceLineOrientation::Horizontal => "horizontal",
1396                }
1397                .into(),
1398                value: line.value,
1399                color_rgba: vec4_to_rgba(line.color),
1400                line_width: line.line_width,
1401                line_style: format!("{:?}", line.line_style),
1402                label: line.label.clone(),
1403                display_name: line.display_name.clone(),
1404                label_orientation: line.label_orientation.clone(),
1405                axes_index,
1406                visible: line.visible,
1407            },
1408            PlotElement::Scatter(scatter) => Self::Scatter {
1409                x: scatter.x_data.clone(),
1410                y: scatter.y_data.clone(),
1411                color_rgba: vec4_to_rgba(scatter.color),
1412                marker_size: scatter.marker_size,
1413                marker_style: format!("{:?}", scatter.marker_style),
1414                axes_index,
1415                label: scatter.label.clone(),
1416                visible: scatter.visible,
1417            },
1418            PlotElement::Bar(bar) => Self::Bar {
1419                labels: bar.labels.clone(),
1420                values: bar.values().unwrap_or(&[]).to_vec(),
1421                histogram_bin_edges: bar.histogram_bin_edges().map(|edges| edges.to_vec()),
1422                color_rgba: vec4_to_rgba(bar.color),
1423                outline_color_rgba: bar.outline_color.map(vec4_to_rgba),
1424                bar_width: bar.bar_width,
1425                outline_width: bar.outline_width,
1426                orientation: format!("{:?}", bar.orientation),
1427                group_index: bar.group_index as u32,
1428                group_count: bar.group_count as u32,
1429                stack_offsets: bar.stack_offsets().map(|offsets| offsets.to_vec()),
1430                axes_index,
1431                label: bar.label.clone(),
1432                visible: bar.visible,
1433            },
1434            PlotElement::ErrorBar(error) => Self::ErrorBar {
1435                x: error.x.clone(),
1436                y: error.y.clone(),
1437                err_low: error.y_neg.clone(),
1438                err_high: error.y_pos.clone(),
1439                x_err_low: error.x_neg.clone(),
1440                x_err_high: error.x_pos.clone(),
1441                orientation: format!("{:?}", error.orientation),
1442                color_rgba: vec4_to_rgba(error.color),
1443                line_width: error.line_width,
1444                line_style: format!("{:?}", error.line_style),
1445                cap_width: error.cap_size,
1446                marker_style: error.marker.as_ref().map(|m| format!("{:?}", m.kind)),
1447                marker_size: error.marker.as_ref().map(|m| m.size),
1448                marker_face_color: error.marker.as_ref().map(|m| vec4_to_rgba(m.face_color)),
1449                marker_edge_color: error.marker.as_ref().map(|m| vec4_to_rgba(m.edge_color)),
1450                marker_filled: error.marker.as_ref().map(|m| m.filled),
1451                axes_index,
1452                label: error.label.clone(),
1453                visible: error.visible,
1454            },
1455            PlotElement::Stairs(stairs) => Self::Stairs {
1456                x: stairs.x.clone(),
1457                y: stairs.y.clone(),
1458                color_rgba: vec4_to_rgba(stairs.color),
1459                line_width: stairs.line_width,
1460                axes_index,
1461                label: stairs.label.clone(),
1462                visible: stairs.visible,
1463            },
1464            PlotElement::Stem(stem) => Self::Stem {
1465                x: stem.x.clone(),
1466                y: stem.y.clone(),
1467                baseline: stem.baseline,
1468                color_rgba: vec4_to_rgba(stem.color),
1469                line_width: stem.line_width,
1470                line_style: format!("{:?}", stem.line_style),
1471                baseline_color_rgba: vec4_to_rgba(stem.baseline_color),
1472                baseline_visible: stem.baseline_visible,
1473                marker_color_rgba: vec4_to_rgba(
1474                    stem.marker
1475                        .as_ref()
1476                        .map(|m| m.face_color)
1477                        .unwrap_or(stem.color),
1478                ),
1479                marker_size: stem.marker.as_ref().map(|m| m.size).unwrap_or(0.0),
1480                marker_filled: stem.marker.as_ref().map(|m| m.filled).unwrap_or(false),
1481                axes_index,
1482                label: stem.label.clone(),
1483                visible: stem.visible,
1484            },
1485            PlotElement::Area(area) => Self::Area {
1486                x: area.x.clone(),
1487                y: area.y.clone(),
1488                lower_y: area.lower_y.clone(),
1489                baseline: area.baseline,
1490                color_rgba: vec4_to_rgba(area.color),
1491                axes_index,
1492                label: area.label.clone(),
1493                visible: area.visible,
1494            },
1495            PlotElement::Quiver(quiver) => Self::Quiver {
1496                x: quiver.x.clone(),
1497                y: quiver.y.clone(),
1498                u: quiver.u.clone(),
1499                v: quiver.v.clone(),
1500                color_rgba: vec4_to_rgba(quiver.color),
1501                line_width: quiver.line_width,
1502                scale: quiver.scale,
1503                head_size: quiver.head_size,
1504                axes_index,
1505                label: quiver.label.clone(),
1506                visible: quiver.visible,
1507            },
1508            PlotElement::Surface(surface) => Self::Surface {
1509                x: surface.x_data.clone(),
1510                y: surface.y_data.clone(),
1511                z: surface.z_data.clone().unwrap_or_default(),
1512                colormap: format!("{:?}", surface.colormap),
1513                shading_mode: format!("{:?}", surface.shading_mode),
1514                wireframe: surface.wireframe,
1515                alpha: surface.alpha,
1516                flatten_z: surface.flatten_z,
1517                image_mode: surface.image_mode,
1518                color_grid_rgba: surface.color_grid.as_ref().map(|grid| {
1519                    grid.iter()
1520                        .map(|row| row.iter().map(|color| vec4_to_rgba(*color)).collect())
1521                        .collect()
1522                }),
1523                color_limits: surface.color_limits.map(|(lo, hi)| [lo, hi]),
1524                axes_index,
1525                label: surface.label.clone(),
1526                visible: surface.visible,
1527            },
1528            PlotElement::Patch(patch) => Self::Patch {
1529                vertices: patch
1530                    .vertices()
1531                    .iter()
1532                    .map(|point| vec3_to_xyz(*point))
1533                    .collect(),
1534                faces: patch
1535                    .faces()
1536                    .iter()
1537                    .map(|face| face.iter().map(|idx| *idx as u32).collect())
1538                    .collect(),
1539                face_color_rgba: vec4_to_rgba(patch.face_color()),
1540                edge_color_rgba: vec4_to_rgba(patch.edge_color()),
1541                face_color_mode: format!("{:?}", patch.face_color_mode()),
1542                edge_color_mode: format!("{:?}", patch.edge_color_mode()),
1543                face_alpha: patch.face_alpha(),
1544                edge_alpha: patch.edge_alpha(),
1545                line_width: patch.line_width(),
1546                axes_index,
1547                label: patch.label().map(str::to_string),
1548                visible: patch.is_visible(),
1549                force_3d: patch.force_3d(),
1550            },
1551            PlotElement::Mesh(mesh) => Self::Mesh {
1552                vertices: mesh
1553                    .vertices()
1554                    .iter()
1555                    .map(|point| vec3_to_xyz(*point))
1556                    .collect(),
1557                triangles: mesh.triangles().to_vec(),
1558                mesh_id: mesh.mesh_id().map(str::to_string),
1559                face_color_rgba: vec4_to_rgba(mesh.face_color()),
1560                edge_color_rgba: vec4_to_rgba(mesh.edge_color()),
1561                face_alpha: mesh.face_alpha(),
1562                edge_alpha: mesh.edge_alpha(),
1563                edge_width: mesh.edge_width(),
1564                edge_mode: mesh.edge_mode().as_str().to_string(),
1565                feature_edge_groups: mesh
1566                    .feature_edge_groups()
1567                    .map(|groups| groups.to_vec())
1568                    .unwrap_or_default(),
1569                vertex_colors_rgba: mesh
1570                    .vertex_colors()
1571                    .map(|colors| colors.iter().copied().map(vec4_to_rgba).collect())
1572                    .unwrap_or_default(),
1573                triangle_colors_rgba: mesh
1574                    .triangle_colors()
1575                    .map(|colors| colors.iter().copied().map(vec4_to_rgba).collect())
1576                    .unwrap_or_default(),
1577                axes_index,
1578                label: mesh.label().map(str::to_string),
1579                regions: mesh.regions().iter().map(Into::into).collect(),
1580                highlighted_region_id: mesh.highlighted_region_id().map(str::to_string),
1581                highlight_color_rgba: Some(vec4_to_rgba(mesh.highlight_color())),
1582                scalar_field: mesh.scalar_field().map(|field| Box::new(field.into())),
1583                vector_field: mesh.vector_field().map(|field| Box::new(field.into())),
1584                deformation: mesh.deformation().map(|field| Box::new(field.into())),
1585                visible: mesh.is_visible(),
1586            },
1587            PlotElement::Line3(line) => Self::Line3 {
1588                x: line.x_data.clone(),
1589                y: line.y_data.clone(),
1590                z: line.z_data.clone(),
1591                color_rgba: vec4_to_rgba(line.color),
1592                line_width: line.line_width,
1593                line_style: format!("{:?}", line.line_style),
1594                axes_index,
1595                label: line.label.clone(),
1596                visible: line.visible,
1597            },
1598            PlotElement::Scatter3(scatter3) => Self::Scatter3 {
1599                points: scatter3
1600                    .points
1601                    .iter()
1602                    .map(|point| vec3_to_xyz(*point))
1603                    .collect(),
1604                colors_rgba: scatter3
1605                    .colors
1606                    .iter()
1607                    .map(|color| vec4_to_rgba(*color))
1608                    .collect(),
1609                point_size: scatter3.point_size,
1610                point_sizes: scatter3.point_sizes.clone(),
1611                axes_index,
1612                label: scatter3.label.clone(),
1613                visible: scatter3.visible,
1614            },
1615            PlotElement::Contour(contour) => Self::Contour {
1616                vertices: contour
1617                    .cpu_vertices()
1618                    .unwrap_or(&[])
1619                    .iter()
1620                    .cloned()
1621                    .map(Into::into)
1622                    .collect(),
1623                bounds_min: vec3_to_xyz(contour.bounds().min),
1624                bounds_max: vec3_to_xyz(contour.bounds().max),
1625                base_z: contour.base_z,
1626                line_width: contour.line_width,
1627                axes_index,
1628                label: contour.label.clone(),
1629                visible: contour.visible,
1630                force_3d: contour.force_3d,
1631            },
1632            PlotElement::ContourFill(fill) => Self::ContourFill {
1633                vertices: fill
1634                    .cpu_vertices()
1635                    .unwrap_or(&[])
1636                    .iter()
1637                    .cloned()
1638                    .map(Into::into)
1639                    .collect(),
1640                bounds_min: vec3_to_xyz(fill.bounds().min),
1641                bounds_max: vec3_to_xyz(fill.bounds().max),
1642                axes_index,
1643                label: fill.label.clone(),
1644                visible: fill.visible,
1645            },
1646            PlotElement::Pie(pie) => Self::Pie {
1647                values: pie.values.clone(),
1648                colors_rgba: pie.colors.iter().map(|c| vec4_to_rgba(*c)).collect(),
1649                slice_labels: pie.slice_labels.clone(),
1650                label_format: pie.label_format.clone(),
1651                explode: pie.explode.clone(),
1652                axes_index,
1653                label: pie.label.clone(),
1654                visible: pie.visible,
1655            },
1656        }
1657    }
1658
1659    fn apply_to_figure(self, figure: &mut Figure) -> Result<(), String> {
1660        match self {
1661            ScenePlot::Line {
1662                x,
1663                y,
1664                color_rgba,
1665                line_width,
1666                line_style,
1667                axes_index,
1668                label,
1669                visible,
1670            } => {
1671                let mut line = LinePlot::new(x, y)?;
1672                line.set_color(rgba_to_vec4(color_rgba));
1673                line.set_line_width(line_width);
1674                line.set_line_style(parse_line_style(&line_style));
1675                line.label = label;
1676                line.set_visible(visible);
1677                figure.add_line_plot_on_axes(line, axes_index as usize);
1678            }
1679            ScenePlot::ReferenceLine {
1680                orientation,
1681                value,
1682                color_rgba,
1683                line_width,
1684                line_style,
1685                label,
1686                display_name,
1687                label_orientation,
1688                axes_index,
1689                visible,
1690            } => {
1691                let orientation = parse_reference_line_orientation(&orientation)?;
1692                let mut line = ReferenceLine::new(orientation, value)?.with_style(
1693                    rgba_to_vec4(color_rgba),
1694                    line_width,
1695                    parse_line_style(&line_style),
1696                );
1697                line.label = label;
1698                line.display_name = display_name;
1699                line.label_orientation = label_orientation;
1700                line.visible = visible;
1701                figure.add_reference_line_on_axes(line, axes_index as usize);
1702            }
1703            ScenePlot::Scatter {
1704                x,
1705                y,
1706                color_rgba,
1707                marker_size,
1708                marker_style,
1709                axes_index,
1710                label,
1711                visible,
1712            } => {
1713                let mut scatter = ScatterPlot::new(x, y)?;
1714                scatter.set_color(rgba_to_vec4(color_rgba));
1715                scatter.set_marker_size(marker_size);
1716                scatter.set_marker_style(parse_marker_style(&marker_style));
1717                scatter.label = label;
1718                scatter.set_visible(visible);
1719                figure.add_scatter_plot_on_axes(scatter, axes_index as usize);
1720            }
1721            ScenePlot::Bar {
1722                labels,
1723                values,
1724                histogram_bin_edges,
1725                color_rgba,
1726                outline_color_rgba,
1727                bar_width,
1728                outline_width,
1729                orientation,
1730                group_index,
1731                group_count,
1732                stack_offsets,
1733                axes_index,
1734                label,
1735                visible,
1736            } => {
1737                let mut bar = BarChart::new(labels, values)?
1738                    .with_style(rgba_to_vec4(color_rgba), bar_width)
1739                    .with_orientation(parse_bar_orientation(&orientation))
1740                    .with_group(group_index as usize, group_count as usize);
1741                if let Some(edges) = histogram_bin_edges {
1742                    bar.set_histogram_bin_edges(edges);
1743                }
1744                if let Some(offsets) = stack_offsets {
1745                    bar = bar.with_stack_offsets(offsets);
1746                }
1747                if let Some(outline) = outline_color_rgba {
1748                    bar = bar.with_outline(rgba_to_vec4(outline), outline_width);
1749                }
1750                bar.label = label;
1751                bar.set_visible(visible);
1752                figure.add_bar_chart_on_axes(bar, axes_index as usize);
1753            }
1754            ScenePlot::ErrorBar {
1755                x,
1756                y,
1757                err_low,
1758                err_high,
1759                x_err_low,
1760                x_err_high,
1761                orientation,
1762                color_rgba,
1763                line_width,
1764                line_style,
1765                cap_width,
1766                marker_style,
1767                marker_size,
1768                marker_face_color,
1769                marker_edge_color,
1770                marker_filled,
1771                axes_index,
1772                label,
1773                visible,
1774            } => {
1775                let mut error = if orientation.eq_ignore_ascii_case("Both") {
1776                    ErrorBar::new_both(x, y, x_err_low, x_err_high, err_low, err_high)?
1777                } else {
1778                    ErrorBar::new_vertical(x, y, err_low, err_high)?
1779                }
1780                .with_style(
1781                    rgba_to_vec4(color_rgba),
1782                    line_width,
1783                    parse_line_style_name(&line_style),
1784                    cap_width,
1785                );
1786                if let Some(size) = marker_size {
1787                    error.set_marker(Some(crate::plots::line::LineMarkerAppearance {
1788                        kind: parse_marker_style(marker_style.as_deref().unwrap_or("Circle")),
1789                        size,
1790                        edge_color: marker_edge_color
1791                            .map(rgba_to_vec4)
1792                            .unwrap_or(rgba_to_vec4(color_rgba)),
1793                        face_color: marker_face_color
1794                            .map(rgba_to_vec4)
1795                            .unwrap_or(rgba_to_vec4(color_rgba)),
1796                        filled: marker_filled.unwrap_or(false),
1797                    }));
1798                }
1799                error.label = label;
1800                error.set_visible(visible);
1801                figure.add_errorbar_on_axes(error, axes_index as usize);
1802            }
1803            ScenePlot::Stairs {
1804                x,
1805                y,
1806                color_rgba,
1807                line_width,
1808                axes_index,
1809                label,
1810                visible,
1811            } => {
1812                let mut stairs = StairsPlot::new(x, y)?;
1813                stairs.color = rgba_to_vec4(color_rgba);
1814                stairs.line_width = line_width;
1815                stairs.label = label;
1816                stairs.set_visible(visible);
1817                figure.add_stairs_plot_on_axes(stairs, axes_index as usize);
1818            }
1819            ScenePlot::Stem {
1820                x,
1821                y,
1822                baseline,
1823                color_rgba,
1824                line_width,
1825                line_style,
1826                baseline_color_rgba,
1827                baseline_visible,
1828                marker_color_rgba,
1829                marker_size,
1830                marker_filled,
1831                axes_index,
1832                label,
1833                visible,
1834            } => {
1835                let mut stem = StemPlot::new(x, y)?;
1836                stem = stem
1837                    .with_style(
1838                        rgba_to_vec4(color_rgba),
1839                        line_width,
1840                        parse_line_style_name(&line_style),
1841                        baseline,
1842                    )
1843                    .with_baseline_style(rgba_to_vec4(baseline_color_rgba), baseline_visible);
1844                if marker_size > 0.0 {
1845                    stem.set_marker(Some(crate::plots::line::LineMarkerAppearance {
1846                        kind: crate::plots::scatter::MarkerStyle::Circle,
1847                        size: marker_size,
1848                        edge_color: rgba_to_vec4(marker_color_rgba),
1849                        face_color: rgba_to_vec4(marker_color_rgba),
1850                        filled: marker_filled,
1851                    }));
1852                }
1853                stem.label = label;
1854                stem.set_visible(visible);
1855                figure.add_stem_plot_on_axes(stem, axes_index as usize);
1856            }
1857            ScenePlot::Area {
1858                x,
1859                y,
1860                lower_y,
1861                baseline,
1862                color_rgba,
1863                axes_index,
1864                label,
1865                visible,
1866            } => {
1867                let mut area = AreaPlot::new(x, y)?;
1868                if let Some(lower_y) = lower_y {
1869                    area = area.with_lower_curve(lower_y);
1870                }
1871                area.baseline = baseline;
1872                area.color = rgba_to_vec4(color_rgba);
1873                area.label = label;
1874                area.set_visible(visible);
1875                figure.add_area_plot_on_axes(area, axes_index as usize);
1876            }
1877            ScenePlot::Quiver {
1878                x,
1879                y,
1880                u,
1881                v,
1882                color_rgba,
1883                line_width,
1884                scale,
1885                head_size,
1886                axes_index,
1887                label,
1888                visible,
1889            } => {
1890                let mut quiver = QuiverPlot::new(x, y, u, v)?
1891                    .with_style(rgba_to_vec4(color_rgba), line_width, scale, head_size)
1892                    .with_label(label.unwrap_or_else(|| "Data".to_string()));
1893                quiver.set_visible(visible);
1894                figure.add_quiver_plot_on_axes(quiver, axes_index as usize);
1895            }
1896            ScenePlot::Surface {
1897                x,
1898                y,
1899                z,
1900                colormap,
1901                shading_mode,
1902                wireframe,
1903                alpha,
1904                flatten_z,
1905                image_mode,
1906                color_grid_rgba,
1907                color_limits,
1908                axes_index,
1909                label,
1910                visible,
1911            } => {
1912                let mut surface = SurfacePlot::new(x, y, z)?;
1913                surface.colormap = parse_colormap(&colormap);
1914                surface.shading_mode = parse_shading_mode(&shading_mode);
1915                surface.wireframe = wireframe;
1916                surface.alpha = alpha.clamp(0.0, 1.0);
1917                surface.flatten_z = flatten_z;
1918                surface.image_mode = image_mode;
1919                surface.color_grid = color_grid_rgba.map(|grid| {
1920                    grid.into_iter()
1921                        .map(|row| row.into_iter().map(rgba_to_vec4).collect())
1922                        .collect()
1923                });
1924                surface.color_limits = color_limits.map(|[lo, hi]| (lo, hi));
1925                surface.label = label;
1926                surface.visible = visible;
1927                figure.add_surface_plot_on_axes(surface, axes_index as usize);
1928            }
1929            ScenePlot::Patch {
1930                vertices,
1931                faces,
1932                face_color_rgba,
1933                edge_color_rgba,
1934                face_color_mode,
1935                edge_color_mode,
1936                face_alpha,
1937                edge_alpha,
1938                line_width,
1939                axes_index,
1940                label,
1941                visible,
1942                force_3d,
1943            } => {
1944                let vertices: Vec<Vec3> = vertices.into_iter().map(xyz_to_vec3).collect();
1945                let faces: Vec<Vec<usize>> = faces
1946                    .into_iter()
1947                    .map(|face| face.into_iter().map(|idx| idx as usize).collect())
1948                    .collect();
1949                let mut patch = PatchPlot::new(vertices, faces)?;
1950                patch.set_face_color(rgba_to_vec4(face_color_rgba));
1951                patch.set_edge_color(rgba_to_vec4(edge_color_rgba));
1952                patch.set_face_color_mode(parse_patch_face_color_mode(&face_color_mode));
1953                patch.set_edge_color_mode(parse_patch_edge_color_mode(&edge_color_mode));
1954                patch.set_face_alpha(face_alpha);
1955                patch.set_edge_alpha(edge_alpha);
1956                patch.set_line_width(line_width);
1957                patch.set_label(label);
1958                patch.set_visible(visible);
1959                patch.set_force_3d(force_3d);
1960                figure.add_patch_plot_on_axes(patch, axes_index as usize);
1961            }
1962            ScenePlot::Mesh {
1963                vertices,
1964                triangles,
1965                mesh_id,
1966                face_color_rgba,
1967                edge_color_rgba,
1968                face_alpha,
1969                edge_alpha,
1970                edge_width,
1971                edge_mode,
1972                feature_edge_groups,
1973                vertex_colors_rgba,
1974                triangle_colors_rgba,
1975                axes_index,
1976                label,
1977                regions,
1978                highlighted_region_id,
1979                highlight_color_rgba,
1980                scalar_field,
1981                vector_field,
1982                deformation,
1983                visible,
1984            } => {
1985                let vertices: Vec<Vec3> = vertices.into_iter().map(xyz_to_vec3).collect();
1986                let mut mesh = MeshPlot::new(vertices, triangles)?;
1987                mesh.set_mesh_id(mesh_id);
1988                mesh.set_face_color(rgba_to_vec4(face_color_rgba));
1989                mesh.set_edge_color(rgba_to_vec4(edge_color_rgba));
1990                mesh.set_face_alpha(face_alpha);
1991                mesh.set_edge_alpha(edge_alpha);
1992                mesh.set_edge_width(edge_width);
1993                mesh.set_edge_mode(parse_mesh_edge_mode(&edge_mode));
1994                if !feature_edge_groups.is_empty() {
1995                    mesh.set_feature_edge_groups(Some(feature_edge_groups))?;
1996                }
1997                if !vertex_colors_rgba.is_empty() {
1998                    mesh.set_vertex_colors(Some(
1999                        vertex_colors_rgba.into_iter().map(rgba_to_vec4).collect(),
2000                    ))?;
2001                }
2002                if !triangle_colors_rgba.is_empty() {
2003                    mesh.set_triangle_colors(Some(
2004                        triangle_colors_rgba.into_iter().map(rgba_to_vec4).collect(),
2005                    ))?;
2006                }
2007                mesh.set_label(label);
2008                mesh.set_regions(regions.into_iter().map(Into::into).collect());
2009                mesh.set_highlighted_region_id(highlighted_region_id);
2010                if let Some(color) = highlight_color_rgba {
2011                    mesh.set_highlight_color(rgba_to_vec4(color));
2012                }
2013                if let Some(field) = scalar_field {
2014                    mesh.set_scalar_field(Some((*field).try_into()?))?;
2015                }
2016                if let Some(field) = vector_field {
2017                    mesh.set_vector_field(Some((*field).try_into()?))?;
2018                }
2019                if let Some(field) = deformation {
2020                    mesh.set_deformation(Some((*field).into()))?;
2021                }
2022                mesh.set_visible(visible);
2023                figure.add_mesh_plot_on_axes(mesh, axes_index as usize);
2024            }
2025            ScenePlot::Line3 {
2026                x,
2027                y,
2028                z,
2029                color_rgba,
2030                line_width,
2031                line_style,
2032                axes_index,
2033                label,
2034                visible,
2035            } => {
2036                let mut plot = Line3Plot::new(x, y, z)?
2037                    .with_style(
2038                        rgba_to_vec4(color_rgba),
2039                        line_width,
2040                        parse_line_style_name(&line_style),
2041                    )
2042                    .with_label(label.unwrap_or_else(|| "Data".to_string()));
2043                plot.set_visible(visible);
2044                figure.add_line3_plot_on_axes(plot, axes_index as usize);
2045            }
2046            ScenePlot::Scatter3 {
2047                points,
2048                colors_rgba,
2049                point_size,
2050                point_sizes,
2051                axes_index,
2052                label,
2053                visible,
2054            } => {
2055                let points: Vec<Vec3> = points.into_iter().map(xyz_to_vec3).collect();
2056                let colors: Vec<Vec4> = colors_rgba.into_iter().map(rgba_to_vec4).collect();
2057                let mut scatter3 = Scatter3Plot::new(points)?;
2058                if !colors.is_empty() {
2059                    scatter3 = scatter3.with_colors(colors)?;
2060                }
2061                scatter3.point_size = point_size.max(1.0);
2062                scatter3.point_sizes = point_sizes;
2063                scatter3.label = label;
2064                scatter3.visible = visible;
2065                figure.add_scatter3_plot_on_axes(scatter3, axes_index as usize);
2066            }
2067            ScenePlot::Contour {
2068                vertices,
2069                bounds_min,
2070                bounds_max,
2071                base_z,
2072                line_width,
2073                axes_index,
2074                label,
2075                visible,
2076                force_3d,
2077            } => {
2078                let mut contour = ContourPlot::from_vertices(
2079                    vertices.into_iter().map(Into::into).collect(),
2080                    base_z,
2081                    serialized_bounds(bounds_min, bounds_max),
2082                )
2083                .with_line_width(line_width)
2084                .with_force_3d(force_3d);
2085                contour.label = label;
2086                contour.set_visible(visible);
2087                figure.add_contour_plot_on_axes(contour, axes_index as usize);
2088            }
2089            ScenePlot::ContourFill {
2090                vertices,
2091                bounds_min,
2092                bounds_max,
2093                axes_index,
2094                label,
2095                visible,
2096            } => {
2097                let mut fill = ContourFillPlot::from_vertices(
2098                    vertices.into_iter().map(Into::into).collect(),
2099                    serialized_bounds(bounds_min, bounds_max),
2100                );
2101                fill.label = label;
2102                fill.set_visible(visible);
2103                figure.add_contour_fill_plot_on_axes(fill, axes_index as usize);
2104            }
2105            ScenePlot::Pie {
2106                values,
2107                colors_rgba,
2108                slice_labels,
2109                label_format,
2110                explode,
2111                axes_index,
2112                label,
2113                visible,
2114            } => {
2115                let mut pie = crate::plots::PieChart::new(
2116                    values,
2117                    Some(colors_rgba.into_iter().map(rgba_to_vec4).collect()),
2118                )?
2119                .with_slice_labels(slice_labels)
2120                .with_explode(explode);
2121                if let Some(fmt) = label_format {
2122                    pie = pie.with_label_format(fmt);
2123                }
2124                pie.label = label;
2125                pie.set_visible(visible);
2126                figure.add_pie_chart_on_axes(pie, axes_index as usize);
2127            }
2128            ScenePlot::Unsupported { .. } => {}
2129        }
2130        Ok(())
2131    }
2132}
2133
2134fn parse_line_style(value: &str) -> crate::plots::LineStyle {
2135    match value {
2136        "Dashed" => crate::plots::LineStyle::Dashed,
2137        "Dotted" => crate::plots::LineStyle::Dotted,
2138        "DashDot" => crate::plots::LineStyle::DashDot,
2139        _ => crate::plots::LineStyle::Solid,
2140    }
2141}
2142
2143fn parse_bar_orientation(value: &str) -> crate::plots::bar::Orientation {
2144    match value {
2145        "Horizontal" => crate::plots::bar::Orientation::Horizontal,
2146        _ => crate::plots::bar::Orientation::Vertical,
2147    }
2148}
2149
2150fn parse_reference_line_orientation(value: &str) -> Result<ReferenceLineOrientation, String> {
2151    match value.to_ascii_lowercase().as_str() {
2152        "horizontal" => Ok(ReferenceLineOrientation::Horizontal),
2153        "vertical" => Ok(ReferenceLineOrientation::Vertical),
2154        _ => Err(format!(
2155            "unknown reference line orientation '{value}'; expected 'horizontal' or 'vertical'"
2156        )),
2157    }
2158}
2159
2160fn parse_marker_style(value: &str) -> MarkerStyle {
2161    match value {
2162        "Square" => MarkerStyle::Square,
2163        "Triangle" => MarkerStyle::Triangle,
2164        "Diamond" => MarkerStyle::Diamond,
2165        "Plus" => MarkerStyle::Plus,
2166        "Cross" => MarkerStyle::Cross,
2167        "Star" => MarkerStyle::Star,
2168        "Hexagon" => MarkerStyle::Hexagon,
2169        _ => MarkerStyle::Circle,
2170    }
2171}
2172
2173fn parse_colormap(value: &str) -> ColorMap {
2174    ColorMap::from_name(value).unwrap_or(ColorMap::Parula)
2175}
2176
2177fn parse_shading_mode(value: &str) -> ShadingMode {
2178    match value {
2179        "Flat" => ShadingMode::Flat,
2180        "Smooth" => ShadingMode::Smooth,
2181        "Faceted" => ShadingMode::Faceted,
2182        "None" => ShadingMode::None,
2183        _ => ShadingMode::Smooth,
2184    }
2185}
2186
2187fn parse_patch_face_color_mode(value: &str) -> PatchFaceColorMode {
2188    match value {
2189        "None" => PatchFaceColorMode::None,
2190        "Flat" => PatchFaceColorMode::Flat,
2191        _ => PatchFaceColorMode::Color,
2192    }
2193}
2194
2195fn parse_patch_edge_color_mode(value: &str) -> PatchEdgeColorMode {
2196    match value {
2197        "None" => PatchEdgeColorMode::None,
2198        _ => PatchEdgeColorMode::Color,
2199    }
2200}
2201
2202fn parse_mesh_edge_mode(value: &str) -> MeshEdgeMode {
2203    MeshEdgeMode::parse(value).unwrap_or_default()
2204}
2205
2206fn xyz_to_vec3(value: [f32; 3]) -> Vec3 {
2207    Vec3::new(value[0], value[1], value[2])
2208}
2209
2210fn serialized_bounds(min: [f32; 3], max: [f32; 3]) -> BoundingBox {
2211    BoundingBox::new(xyz_to_vec3(min), xyz_to_vec3(max))
2212}
2213
2214fn vec3_to_xyz(value: Vec3) -> [f32; 3] {
2215    [value.x, value.y, value.z]
2216}
2217
2218fn rgba_to_vec4(value: [f32; 4]) -> Vec4 {
2219    Vec4::new(value[0], value[1], value[2], value[3])
2220}
2221
2222#[derive(Debug, Clone, Serialize, Deserialize)]
2223#[serde(rename_all = "camelCase")]
2224pub struct SerializedVertex {
2225    position: [f32; 3],
2226    color_rgba: [f32; 4],
2227    normal: [f32; 3],
2228    tex_coords: [f32; 2],
2229}
2230
2231impl From<Vertex> for SerializedVertex {
2232    fn from(value: Vertex) -> Self {
2233        Self {
2234            position: value.position,
2235            color_rgba: value.color,
2236            normal: value.normal,
2237            tex_coords: value.tex_coords,
2238        }
2239    }
2240}
2241
2242impl From<SerializedVertex> for Vertex {
2243    fn from(value: SerializedVertex) -> Self {
2244        Self {
2245            position: value.position,
2246            color: value.color_rgba,
2247            normal: value.normal,
2248            tex_coords: value.tex_coords,
2249        }
2250    }
2251}
2252
2253/// Serialized legend entry for frontend rendering.
2254#[derive(Debug, Clone, Serialize, Deserialize)]
2255#[serde(rename_all = "camelCase")]
2256pub struct FigureLegendEntry {
2257    pub label: String,
2258    pub plot_type: PlotKind,
2259    pub color_rgba: [f32; 4],
2260}
2261
2262impl From<LegendEntry> for FigureLegendEntry {
2263    fn from(entry: LegendEntry) -> Self {
2264        Self {
2265            label: entry.label,
2266            plot_type: PlotKind::from(entry.plot_type),
2267            color_rgba: vec4_to_rgba(entry.color),
2268        }
2269    }
2270}
2271
2272/// Serializable plot kind values consumed by UI + transports.
2273#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2274#[serde(rename_all = "snake_case")]
2275pub enum PlotKind {
2276    Line,
2277    Line3,
2278    Scatter,
2279    Bar,
2280    ErrorBar,
2281    Stairs,
2282    Stem,
2283    Area,
2284    Quiver,
2285    Pie,
2286    Image,
2287    Surface,
2288    Mesh,
2289    Patch,
2290    Scatter3,
2291    Contour,
2292    ContourFill,
2293    ReferenceLine,
2294}
2295
2296impl From<PlotType> for PlotKind {
2297    fn from(value: PlotType) -> Self {
2298        match value {
2299            PlotType::Line => Self::Line,
2300            PlotType::Line3 => Self::Line3,
2301            PlotType::Scatter => Self::Scatter,
2302            PlotType::Bar => Self::Bar,
2303            PlotType::ErrorBar => Self::ErrorBar,
2304            PlotType::Stairs => Self::Stairs,
2305            PlotType::Stem => Self::Stem,
2306            PlotType::Area => Self::Area,
2307            PlotType::Quiver => Self::Quiver,
2308            PlotType::Pie => Self::Pie,
2309            PlotType::Surface => Self::Surface,
2310            PlotType::Mesh => Self::Mesh,
2311            PlotType::Patch => Self::Patch,
2312            PlotType::Scatter3 => Self::Scatter3,
2313            PlotType::Contour => Self::Contour,
2314            PlotType::ContourFill => Self::ContourFill,
2315            PlotType::ReferenceLine => Self::ReferenceLine,
2316        }
2317    }
2318}
2319
2320fn parse_line_style_name(name: &str) -> crate::plots::line::LineStyle {
2321    match name.to_ascii_lowercase().as_str() {
2322        "dashed" => crate::plots::line::LineStyle::Dashed,
2323        "dotted" => crate::plots::line::LineStyle::Dotted,
2324        "dashdot" => crate::plots::line::LineStyle::DashDot,
2325        _ => crate::plots::line::LineStyle::Solid,
2326    }
2327}
2328
2329fn parse_colormap_name(name: &str) -> crate::plots::surface::ColorMap {
2330    crate::plots::surface::ColorMap::from_name(name)
2331        .unwrap_or(crate::plots::surface::ColorMap::Parula)
2332}
2333
2334fn vec4_to_rgba(value: Vec4) -> [f32; 4] {
2335    [value.x, value.y, value.z, value.w]
2336}
2337
2338fn deserialize_f64_lossy<'de, D>(deserializer: D) -> Result<f64, D::Error>
2339where
2340    D: serde::Deserializer<'de>,
2341{
2342    let value = Option::<f64>::deserialize(deserializer)?;
2343    Ok(value.unwrap_or(f64::NAN))
2344}
2345
2346fn deserialize_vec_f64_lossy<'de, D>(deserializer: D) -> Result<Vec<f64>, D::Error>
2347where
2348    D: serde::Deserializer<'de>,
2349{
2350    let values = Vec::<Option<f64>>::deserialize(deserializer)?;
2351    Ok(values
2352        .into_iter()
2353        .map(|value| value.unwrap_or(f64::NAN))
2354        .collect())
2355}
2356
2357fn deserialize_vec_f32_lossy<'de, D>(deserializer: D) -> Result<Vec<f32>, D::Error>
2358where
2359    D: serde::Deserializer<'de>,
2360{
2361    let values = Vec::<Option<f32>>::deserialize(deserializer)?;
2362    Ok(values
2363        .into_iter()
2364        .map(|value| value.unwrap_or(f32::NAN))
2365        .collect())
2366}
2367
2368fn deserialize_option_vec_f64_lossy<'de, D>(deserializer: D) -> Result<Option<Vec<f64>>, D::Error>
2369where
2370    D: serde::Deserializer<'de>,
2371{
2372    let values = Option::<Vec<Option<f64>>>::deserialize(deserializer)?;
2373    Ok(values.map(|items| {
2374        items
2375            .into_iter()
2376            .map(|value| value.unwrap_or(f64::NAN))
2377            .collect()
2378    }))
2379}
2380
2381fn deserialize_matrix_f64_lossy<'de, D>(deserializer: D) -> Result<Vec<Vec<f64>>, D::Error>
2382where
2383    D: serde::Deserializer<'de>,
2384{
2385    let rows = Vec::<Vec<Option<f64>>>::deserialize(deserializer)?;
2386    Ok(rows
2387        .into_iter()
2388        .map(|row| {
2389            row.into_iter()
2390                .map(|value| value.unwrap_or(f64::NAN))
2391                .collect()
2392        })
2393        .collect())
2394}
2395
2396fn deserialize_option_pair_f64_lossy<'de, D>(deserializer: D) -> Result<Option<[f64; 2]>, D::Error>
2397where
2398    D: serde::Deserializer<'de>,
2399{
2400    let value = Option::<[Option<f64>; 2]>::deserialize(deserializer)?;
2401    Ok(value.map(|pair| [pair[0].unwrap_or(f64::NAN), pair[1].unwrap_or(f64::NAN)]))
2402}
2403
2404fn deserialize_option_vec_f32_lossy<'de, D>(deserializer: D) -> Result<Option<Vec<f32>>, D::Error>
2405where
2406    D: serde::Deserializer<'de>,
2407{
2408    let values = Option::<Vec<Option<f32>>>::deserialize(deserializer)?;
2409    Ok(values.map(|items| {
2410        items
2411            .into_iter()
2412            .map(|value| value.unwrap_or(f32::NAN))
2413            .collect()
2414    }))
2415}
2416
2417fn deserialize_vec_xyz_f32_lossy<'de, D>(deserializer: D) -> Result<Vec<[f32; 3]>, D::Error>
2418where
2419    D: serde::Deserializer<'de>,
2420{
2421    let values = Vec::<[Option<f32>; 3]>::deserialize(deserializer)?;
2422    Ok(values
2423        .into_iter()
2424        .map(|xyz| {
2425            [
2426                xyz[0].unwrap_or(f32::NAN),
2427                xyz[1].unwrap_or(f32::NAN),
2428                xyz[2].unwrap_or(f32::NAN),
2429            ]
2430        })
2431        .collect())
2432}
2433
2434fn deserialize_vec_rgba_f32_lossy<'de, D>(deserializer: D) -> Result<Vec<[f32; 4]>, D::Error>
2435where
2436    D: serde::Deserializer<'de>,
2437{
2438    let values = Vec::<[Option<f32>; 4]>::deserialize(deserializer)?;
2439    Ok(values
2440        .into_iter()
2441        .map(|rgba| {
2442            [
2443                rgba[0].unwrap_or(f32::NAN),
2444                rgba[1].unwrap_or(f32::NAN),
2445                rgba[2].unwrap_or(f32::NAN),
2446                rgba[3].unwrap_or(f32::NAN),
2447            ]
2448        })
2449        .collect())
2450}
2451
2452#[cfg(test)]
2453mod tests {
2454    use super::*;
2455    use crate::plots::{
2456        Figure, Line3Plot, LinePlot, PatchPlot, Scatter3Plot, ScatterPlot, SurfacePlot,
2457    };
2458    use glam::{Vec3, Vec4};
2459
2460    #[test]
2461    fn capture_snapshot_reflects_layout_and_metadata() {
2462        let mut figure = Figure::new()
2463            .with_title("Demo")
2464            .with_sg_title("Overview")
2465            .with_labels("X", "Y")
2466            .with_grid(false)
2467            .with_subplot_grid(1, 2);
2468        figure.set_name("Window Name");
2469        figure.set_number_title(false);
2470        figure.set_visible(false);
2471        figure.set_background_color(Vec4::new(0.0, 0.0, 0.0, 1.0));
2472        let line = LinePlot::new(vec![0.0, 1.0], vec![0.0, 1.0]).unwrap();
2473        figure.add_line_plot_on_axes(line, 1);
2474
2475        let snapshot = FigureSnapshot::capture(&figure);
2476        assert_eq!(snapshot.layout.axes_rows, 1);
2477        assert_eq!(snapshot.layout.axes_cols, 2);
2478        assert_eq!(snapshot.metadata.title.as_deref(), Some("Demo"));
2479        assert_eq!(snapshot.metadata.name.as_deref(), Some("Window Name"));
2480        assert!(!snapshot.metadata.number_title);
2481        assert!(!snapshot.metadata.visible);
2482        assert_eq!(snapshot.metadata.sg_title.as_deref(), Some("Overview"));
2483        assert_eq!(snapshot.metadata.background_rgba, [0.0, 0.0, 0.0, 1.0]);
2484        assert_eq!(snapshot.metadata.legend_entries.len(), 0);
2485        assert_eq!(snapshot.plots.len(), 1);
2486        assert_eq!(snapshot.plots[0].axes_index, 1);
2487        assert!(!snapshot.metadata.grid_enabled);
2488    }
2489
2490    #[test]
2491    fn sg_title_style_omitted_when_sg_title_absent() {
2492        let figure = Figure::new().with_title("Only regular title");
2493        let snapshot = FigureSnapshot::capture(&figure);
2494        assert!(snapshot.metadata.sg_title.is_none());
2495        assert!(
2496            snapshot.metadata.sg_title_style.is_none(),
2497            "sgTitleStyle must be None when sgTitle is absent"
2498        );
2499        let json = serde_json::to_string(&snapshot.metadata).unwrap();
2500        assert!(
2501            !json.contains("sgTitleStyle"),
2502            "sgTitleStyle must not appear in serialized JSON when sgTitle is absent"
2503        );
2504    }
2505
2506    #[test]
2507    fn figure_scene_roundtrip_reconstructs_supported_plots() {
2508        let mut figure = Figure::new().with_title("Replay").with_subplot_grid(1, 2);
2509        figure.set_name("Roundtrip");
2510        figure.set_number_title(false);
2511        figure.set_visible(false);
2512        let mut line = LinePlot::new(vec![0.0, 1.0], vec![1.0, 2.0]).unwrap();
2513        line.label = Some("line".to_string());
2514        figure.add_line_plot_on_axes(line, 0);
2515        let mut scatter = ScatterPlot::new(vec![0.0, 1.0, 2.0], vec![2.0, 3.0, 4.0]).unwrap();
2516        scatter.label = Some("scatter".to_string());
2517        figure.add_scatter_plot_on_axes(scatter, 1);
2518
2519        let scene = FigureScene::capture(&figure);
2520        let rebuilt = scene.into_figure().expect("scene restore should succeed");
2521        assert_eq!(rebuilt.axes_grid(), (1, 2));
2522        assert_eq!(rebuilt.plots().count(), 2);
2523        assert_eq!(rebuilt.title.as_deref(), Some("Replay"));
2524        assert_eq!(rebuilt.name.as_deref(), Some("Roundtrip"));
2525        assert!(!rebuilt.number_title);
2526        assert!(!rebuilt.visible);
2527    }
2528
2529    #[test]
2530    fn figure_scene_roundtrip_reconstructs_patch() {
2531        let mut figure = Figure::new();
2532        let mut patch = PatchPlot::new(
2533            vec![
2534                Vec3::new(0.0, 0.0, 0.0),
2535                Vec3::new(1.0, 0.0, 0.0),
2536                Vec3::new(0.0, 1.0, 0.0),
2537            ],
2538            vec![vec![0, 1, 2]],
2539        )
2540        .unwrap();
2541        patch.set_label(Some("tri".into()));
2542        patch.set_force_3d(true);
2543        figure.add_patch_plot(patch);
2544
2545        let scene = FigureScene::capture(&figure);
2546        assert_eq!(scene.schema_version, FigureScene::SCHEMA_VERSION);
2547        assert!(matches!(scene.plots.first(), Some(ScenePlot::Patch { .. })));
2548        let rebuilt = scene.into_figure().expect("patch scene restore");
2549        let Some(PlotElement::Patch(patch)) = rebuilt.plots().next() else {
2550            panic!("expected patch plot");
2551        };
2552        assert_eq!(patch.faces(), &[vec![0, 1, 2]]);
2553        assert_eq!(patch.label(), Some("tri"));
2554        assert!(patch.force_3d());
2555    }
2556
2557    #[test]
2558    fn figure_scene_rejects_invalid_schema_versions() {
2559        let mut scene = FigureScene::capture(&Figure::new());
2560        scene.schema_version = 0;
2561        let err = scene.clone().into_figure().expect_err("schema 0 must fail");
2562        assert!(err.contains("unsupported figure scene schema version 0"));
2563
2564        scene.schema_version = FigureScene::SCHEMA_VERSION + 1;
2565        let err = scene.into_figure().expect_err("future schema must fail");
2566        assert!(err.contains(&format!(
2567            "unsupported figure scene schema version {}",
2568            FigureScene::SCHEMA_VERSION + 1
2569        )));
2570    }
2571
2572    #[test]
2573    fn figure_scene_rejects_patch_in_older_schema() {
2574        let mut figure = Figure::new();
2575        figure.add_patch_plot(
2576            PatchPlot::new(
2577                vec![
2578                    Vec3::new(0.0, 0.0, 0.0),
2579                    Vec3::new(1.0, 0.0, 0.0),
2580                    Vec3::new(0.0, 1.0, 0.0),
2581                ],
2582                vec![vec![0, 1, 2]],
2583            )
2584            .unwrap(),
2585        );
2586
2587        let mut scene = FigureScene::capture(&figure);
2588        assert!(matches!(scene.plots.first(), Some(ScenePlot::Patch { .. })));
2589        scene.schema_version = 1;
2590
2591        let err = scene
2592            .into_figure()
2593            .expect_err("older patch schema must fail");
2594        assert!(err.contains("patch plots require figure scene schema version 2"));
2595    }
2596
2597    #[test]
2598    fn figure_scene_roundtrip_preserves_mesh_plot() {
2599        let mut figure = Figure::new();
2600        let mut mesh = MeshPlot::new(
2601            vec![
2602                Vec3::new(0.0, 0.0, 0.0),
2603                Vec3::new(1.0, 0.0, 0.0),
2604                Vec3::new(0.0, 1.0, 0.0),
2605            ],
2606            vec![[0, 1, 2]],
2607        )
2608        .unwrap();
2609        mesh.set_mesh_id(Some("mesh_1".to_string()));
2610        mesh.set_label(Some("mesh tri".to_string()));
2611        mesh.set_face_alpha(0.7);
2612        mesh.set_edge_width(0.25);
2613        mesh.set_edge_mode(MeshEdgeMode::Feature);
2614        mesh.set_feature_edge_groups(Some(vec![3]))
2615            .expect("feature group should be accepted");
2616        mesh.set_vertex_colors(Some(vec![
2617            Vec4::new(0.3, 0.4, 0.5, 1.0),
2618            Vec4::new(0.3, 0.4, 0.5, 1.0),
2619            Vec4::new(0.3, 0.4, 0.5, 1.0),
2620        ]))
2621        .expect("vertex colors should be accepted");
2622        mesh.set_triangle_colors(Some(vec![Vec4::new(0.3, 0.4, 0.5, 1.0)]))
2623            .expect("triangle color should be accepted");
2624        mesh.set_regions(vec![MeshRegion::new(
2625            "region_default",
2626            Some("Default Region".to_string()),
2627            Some("mesh_default".to_string()),
2628            vec![MeshTriangleRange::new(0, 1)],
2629        )]);
2630        mesh.set_highlighted_region_id(Some("region_default".to_string()));
2631        figure.add_mesh_plot(mesh);
2632
2633        let scene = FigureScene::capture(&figure);
2634        assert_eq!(scene.schema_version, FigureScene::SCHEMA_VERSION);
2635        assert!(matches!(scene.plots.first(), Some(ScenePlot::Mesh { .. })));
2636        let rebuilt = scene.into_figure().expect("mesh scene restore");
2637        let Some(PlotElement::Mesh(mesh)) = rebuilt.plots().next() else {
2638            panic!("expected mesh plot");
2639        };
2640        assert_eq!(mesh.mesh_id(), Some("mesh_1"));
2641        assert_eq!(mesh.triangles(), &[[0, 1, 2]]);
2642        assert_eq!(mesh.label(), Some("mesh tri"));
2643        assert!((mesh.face_alpha() - 0.7).abs() < f32::EPSILON);
2644        assert!((mesh.edge_width() - 0.25).abs() < f32::EPSILON);
2645        assert_eq!(mesh.edge_mode(), MeshEdgeMode::Feature);
2646        assert_eq!(mesh.feature_edge_groups().unwrap(), &[3]);
2647        assert_eq!(
2648            mesh.vertex_colors()
2649                .and_then(|colors| colors.first().copied()),
2650            Some(Vec4::new(0.3, 0.4, 0.5, 1.0))
2651        );
2652        assert_eq!(
2653            mesh.triangle_colors()
2654                .and_then(|colors| colors.first().copied()),
2655            Some(Vec4::new(0.3, 0.4, 0.5, 1.0))
2656        );
2657        assert_eq!(mesh.regions().len(), 1);
2658        assert_eq!(mesh.regions()[0].region_id, "region_default");
2659        assert_eq!(mesh.highlighted_region_id(), Some("region_default"));
2660    }
2661
2662    #[test]
2663    fn figure_scene_roundtrip_preserves_mesh_fea_overlays() {
2664        let mut figure = Figure::new();
2665        let mut mesh = MeshPlot::new(
2666            vec![
2667                Vec3::new(0.0, 0.0, 0.0),
2668                Vec3::new(1.0, 0.0, 0.0),
2669                Vec3::new(0.0, 1.0, 0.0),
2670            ],
2671            vec![[0, 1, 2]],
2672        )
2673        .unwrap();
2674        mesh.set_scalar_field(Some(MeshScalarField {
2675            field_id: "fea.structural.von_mises".to_string(),
2676            label: Some("Von Mises".to_string()),
2677            location: MeshFieldLocation::Vertex,
2678            values: vec![0.0, 0.5, 1.0],
2679            color_limits: Some([0.0, 1.0]),
2680            colormap: "viridis".to_string(),
2681            alpha: 0.8,
2682        }))
2683        .unwrap();
2684        mesh.set_vector_field(Some(MeshVectorField {
2685            field_id: "fea.em.flux_density".to_string(),
2686            label: Some("Flux density".to_string()),
2687            location: MeshFieldLocation::Triangle,
2688            vectors: vec![Vec3::new(0.0, 0.0, 1.0)],
2689            scale: 0.25,
2690            stride: 1,
2691            color: Vec4::new(0.9, 0.7, 0.2, 1.0),
2692        }))
2693        .unwrap();
2694        mesh.set_deformation(Some(MeshDeformation {
2695            field_id: "fea.structural.displacement".to_string(),
2696            label: Some("Displacement".to_string()),
2697            displacements: vec![Vec3::ZERO, Vec3::Z, Vec3::ZERO],
2698            scale: 0.5,
2699        }))
2700        .unwrap();
2701        figure.add_mesh_plot(mesh);
2702
2703        let rebuilt = FigureScene::capture(&figure)
2704            .into_figure()
2705            .expect("mesh scene restore");
2706        let Some(PlotElement::Mesh(mesh)) = rebuilt.plots().next() else {
2707            panic!("expected mesh plot");
2708        };
2709        assert_eq!(
2710            mesh.scalar_field().map(|field| field.field_id.as_str()),
2711            Some("fea.structural.von_mises")
2712        );
2713        assert_eq!(
2714            mesh.vector_field().map(|field| field.field_id.as_str()),
2715            Some("fea.em.flux_density")
2716        );
2717        assert_eq!(
2718            mesh.deformation().map(|field| field.field_id.as_str()),
2719            Some("fea.structural.displacement")
2720        );
2721    }
2722
2723    #[test]
2724    fn figure_scene_rejects_mesh_in_older_schema() {
2725        let mut figure = Figure::new();
2726        figure.add_mesh_plot(
2727            MeshPlot::new(
2728                vec![
2729                    Vec3::new(0.0, 0.0, 0.0),
2730                    Vec3::new(1.0, 0.0, 0.0),
2731                    Vec3::new(0.0, 1.0, 0.0),
2732                ],
2733                vec![[0, 1, 2]],
2734            )
2735            .unwrap(),
2736        );
2737
2738        let mut scene = FigureScene::capture(&figure);
2739        assert!(matches!(scene.plots.first(), Some(ScenePlot::Mesh { .. })));
2740        scene.schema_version = 2;
2741
2742        let err = scene
2743            .into_figure()
2744            .expect_err("older mesh schema must fail");
2745        assert!(err.contains("mesh plots require figure scene schema version 3"));
2746    }
2747
2748    #[test]
2749    fn figure_scene_rejects_unknown_reference_line_orientation() {
2750        let mut scene = FigureScene::capture(&Figure::new());
2751        scene.plots.push(ScenePlot::ReferenceLine {
2752            orientation: "VERTICAL".into(),
2753            value: 2.0,
2754            color_rgba: [0.1, 0.2, 0.3, 1.0],
2755            line_width: 1.0,
2756            line_style: "Solid".into(),
2757            label: None,
2758            display_name: None,
2759            label_orientation: "horizontal".into(),
2760            axes_index: 0,
2761            visible: true,
2762        });
2763
2764        let rebuilt = scene.clone().into_figure().expect("valid orientation");
2765        let PlotElement::ReferenceLine(line) = rebuilt.plots().next().unwrap() else {
2766            panic!("expected reference line")
2767        };
2768        assert!(matches!(
2769            line.orientation,
2770            ReferenceLineOrientation::Vertical
2771        ));
2772
2773        let ScenePlot::ReferenceLine { orientation, .. } = &mut scene.plots[0] else {
2774            panic!("expected reference line scene plot")
2775        };
2776        *orientation = "diagonal".into();
2777
2778        let err = scene
2779            .into_figure()
2780            .expect_err("unknown orientation must fail");
2781        assert!(err.contains("unknown reference line orientation 'diagonal'"));
2782    }
2783
2784    #[test]
2785    fn figure_scene_roundtrip_reconstructs_surface_and_scatter3() {
2786        let mut figure = Figure::new().with_title("Replay3D").with_subplot_grid(1, 2);
2787        let mut surface = SurfacePlot::new(
2788            vec![0.0, 1.0],
2789            vec![0.0, 1.0],
2790            vec![vec![0.0, 1.0], vec![1.0, 2.0]],
2791        )
2792        .expect("surface data should be valid");
2793        surface.label = Some("surface".to_string());
2794        figure.add_surface_plot_on_axes(surface, 0);
2795
2796        let mut scatter3 = Scatter3Plot::new(vec![
2797            Vec3::new(0.0, 0.0, 0.0),
2798            Vec3::new(1.0, 2.0, 3.0),
2799            Vec3::new(2.0, 3.0, 4.0),
2800        ])
2801        .expect("scatter3 data should be valid");
2802        scatter3.label = Some("scatter3".to_string());
2803        figure.add_scatter3_plot_on_axes(scatter3, 1);
2804
2805        let scene = FigureScene::capture(&figure);
2806        let rebuilt = scene.into_figure().expect("scene restore should succeed");
2807        assert_eq!(rebuilt.axes_grid(), (1, 2));
2808        assert_eq!(rebuilt.plots().count(), 2);
2809        assert_eq!(rebuilt.title.as_deref(), Some("Replay3D"));
2810        assert!(matches!(
2811            rebuilt.plots().next(),
2812            Some(PlotElement::Surface(_))
2813        ));
2814        assert!(matches!(
2815            rebuilt.plots().nth(1),
2816            Some(PlotElement::Scatter3(_))
2817        ));
2818    }
2819
2820    #[test]
2821    fn figure_scene_roundtrip_preserves_line3_plot() {
2822        let mut figure = Figure::new();
2823        let line3 = Line3Plot::new(vec![0.0, 1.0], vec![1.0, 2.0], vec![2.0, 3.0])
2824            .unwrap()
2825            .with_label("Trajectory");
2826        figure.add_line3_plot(line3);
2827
2828        let rebuilt = FigureScene::capture(&figure)
2829            .into_figure()
2830            .expect("scene restore should succeed");
2831
2832        let PlotElement::Line3(line3) = rebuilt.plots().next().unwrap() else {
2833            panic!("expected line3")
2834        };
2835        assert_eq!(line3.x_data, vec![0.0, 1.0]);
2836        assert_eq!(line3.z_data, vec![2.0, 3.0]);
2837        assert_eq!(line3.label.as_deref(), Some("Trajectory"));
2838    }
2839
2840    #[test]
2841    fn figure_scene_roundtrip_preserves_contour_and_fill_plots() {
2842        let mut figure = Figure::new();
2843        let bounds = BoundingBox::new(Vec3::new(-1.0, -2.0, 0.0), Vec3::new(3.0, 4.0, 0.0));
2844        let vertices = vec![Vertex {
2845            position: [0.0, 0.0, 0.0],
2846            color: [1.0, 0.0, 0.0, 1.0],
2847            normal: [0.0, 0.0, 1.0],
2848            tex_coords: [0.0, 0.0],
2849        }];
2850        let fill = ContourFillPlot::from_vertices(vertices.clone(), bounds).with_label("fill");
2851        let contour = ContourPlot::from_vertices(vertices, 0.0, bounds)
2852            .with_label("lines")
2853            .with_line_width(2.0);
2854        figure.add_contour_fill_plot(fill);
2855        figure.add_contour_plot(contour);
2856
2857        let rebuilt = FigureScene::capture(&figure)
2858            .into_figure()
2859            .expect("scene restore should succeed");
2860        assert!(matches!(
2861            rebuilt.plots().next(),
2862            Some(PlotElement::ContourFill(_))
2863        ));
2864        let Some(PlotElement::Contour(contour)) = rebuilt.plots().nth(1) else {
2865            panic!("expected contour")
2866        };
2867        assert_eq!(contour.line_width, 2.0);
2868    }
2869
2870    #[test]
2871    fn figure_scene_roundtrip_preserves_stem_style_surface() {
2872        let mut figure = Figure::new();
2873        let mut stem = StemPlot::new(vec![0.0, 1.0], vec![1.0, 2.0])
2874            .unwrap()
2875            .with_style(
2876                Vec4::new(1.0, 0.0, 0.0, 1.0),
2877                2.0,
2878                crate::plots::line::LineStyle::Dashed,
2879                -1.0,
2880            )
2881            .with_baseline_style(Vec4::new(0.0, 0.0, 0.0, 1.0), false)
2882            .with_label("Impulse");
2883        stem.set_marker(Some(crate::plots::line::LineMarkerAppearance {
2884            kind: crate::plots::scatter::MarkerStyle::Square,
2885            size: 8.0,
2886            edge_color: Vec4::new(0.0, 0.0, 0.0, 1.0),
2887            face_color: Vec4::new(1.0, 0.0, 0.0, 1.0),
2888            filled: true,
2889        }));
2890        figure.add_stem_plot(stem);
2891
2892        let rebuilt = FigureScene::capture(&figure)
2893            .into_figure()
2894            .expect("scene restore should succeed");
2895        let PlotElement::Stem(stem) = rebuilt.plots().next().unwrap() else {
2896            panic!("expected stem")
2897        };
2898        assert_eq!(stem.baseline, -1.0);
2899        assert_eq!(stem.line_width, 2.0);
2900        assert_eq!(stem.label.as_deref(), Some("Impulse"));
2901        assert!(!stem.baseline_visible);
2902        assert!(stem.marker.as_ref().map(|m| m.filled).unwrap_or(false));
2903        assert_eq!(stem.marker.as_ref().map(|m| m.size), Some(8.0));
2904    }
2905
2906    #[test]
2907    fn figure_scene_roundtrip_preserves_bar_plot() {
2908        let mut figure = Figure::new();
2909        let bar = BarChart::new(vec!["A".into(), "B".into()], vec![2.0, 3.5])
2910            .unwrap()
2911            .with_style(Vec4::new(0.2, 0.4, 0.8, 1.0), 0.95)
2912            .with_outline(Vec4::new(0.1, 0.1, 0.1, 1.0), 1.5)
2913            .with_label("Histogram")
2914            .with_stack_offsets(vec![1.0, 0.5]);
2915        figure.add_bar_chart(bar);
2916
2917        let rebuilt = FigureScene::capture(&figure)
2918            .into_figure()
2919            .expect("scene restore should succeed");
2920        let PlotElement::Bar(bar) = rebuilt.plots().next().unwrap() else {
2921            panic!("expected bar")
2922        };
2923        assert_eq!(bar.labels, vec!["A", "B"]);
2924        assert_eq!(bar.values().unwrap_or(&[]), &[2.0, 3.5]);
2925        assert_eq!(bar.bar_width, 0.95);
2926        assert_eq!(bar.outline_width, 1.5);
2927        assert_eq!(bar.label.as_deref(), Some("Histogram"));
2928        assert_eq!(bar.stack_offsets().unwrap_or(&[]), &[1.0, 0.5]);
2929        assert!(bar.histogram_bin_edges().is_none());
2930    }
2931
2932    #[test]
2933    fn figure_scene_roundtrip_preserves_histogram_bin_edges() {
2934        let mut figure = Figure::new();
2935        let mut bar = BarChart::new(vec!["bin1".into(), "bin2".into()], vec![4.0, 5.0]).unwrap();
2936        bar.set_histogram_bin_edges(vec![0.0, 0.5, 1.0]);
2937        figure.add_bar_chart(bar);
2938
2939        let rebuilt = FigureScene::capture(&figure)
2940            .into_figure()
2941            .expect("scene restore should succeed");
2942        let PlotElement::Bar(bar) = rebuilt.plots().next().unwrap() else {
2943            panic!("expected bar")
2944        };
2945        assert_eq!(bar.histogram_bin_edges().unwrap_or(&[]), &[0.0, 0.5, 1.0]);
2946    }
2947
2948    #[test]
2949    fn figure_scene_roundtrip_preserves_errorbar_style_surface() {
2950        let mut figure = Figure::new();
2951        let mut error = ErrorBar::new_vertical(
2952            vec![0.0, 1.0],
2953            vec![1.0, 2.0],
2954            vec![0.1, 0.2],
2955            vec![0.2, 0.3],
2956        )
2957        .unwrap()
2958        .with_style(
2959            Vec4::new(1.0, 0.0, 0.0, 1.0),
2960            2.0,
2961            crate::plots::line::LineStyle::Dashed,
2962            10.0,
2963        )
2964        .with_label("Err");
2965        error.set_marker(Some(crate::plots::line::LineMarkerAppearance {
2966            kind: crate::plots::scatter::MarkerStyle::Triangle,
2967            size: 8.0,
2968            edge_color: Vec4::new(0.0, 0.0, 0.0, 1.0),
2969            face_color: Vec4::new(1.0, 0.0, 0.0, 1.0),
2970            filled: true,
2971        }));
2972        figure.add_errorbar(error);
2973
2974        let rebuilt = FigureScene::capture(&figure)
2975            .into_figure()
2976            .expect("scene restore should succeed");
2977        let PlotElement::ErrorBar(error) = rebuilt.plots().next().unwrap() else {
2978            panic!("expected errorbar")
2979        };
2980        assert_eq!(error.line_width, 2.0);
2981        assert_eq!(error.cap_size, 10.0);
2982        assert_eq!(error.label.as_deref(), Some("Err"));
2983        assert_eq!(error.line_style, crate::plots::line::LineStyle::Dashed);
2984        assert!(error.marker.as_ref().map(|m| m.filled).unwrap_or(false));
2985    }
2986
2987    #[test]
2988    fn figure_scene_roundtrip_preserves_errorbar_both_direction() {
2989        let mut figure = Figure::new();
2990        let error = ErrorBar::new_both(
2991            vec![1.0, 2.0],
2992            vec![3.0, 4.0],
2993            vec![0.1, 0.2],
2994            vec![0.2, 0.3],
2995            vec![0.3, 0.4],
2996            vec![0.4, 0.5],
2997        )
2998        .unwrap();
2999        figure.add_errorbar(error);
3000        let rebuilt = FigureScene::capture(&figure)
3001            .into_figure()
3002            .expect("scene restore should succeed");
3003        let PlotElement::ErrorBar(error) = rebuilt.plots().next().unwrap() else {
3004            panic!("expected errorbar")
3005        };
3006        assert_eq!(
3007            error.orientation,
3008            crate::plots::errorbar::ErrorBarOrientation::Both
3009        );
3010        assert_eq!(error.x_neg, vec![0.1, 0.2]);
3011        assert_eq!(error.x_pos, vec![0.2, 0.3]);
3012    }
3013
3014    #[test]
3015    fn figure_scene_roundtrip_preserves_quiver_plot() {
3016        let mut figure = Figure::new();
3017        let quiver = QuiverPlot::new(
3018            vec![0.0, 1.0],
3019            vec![1.0, 2.0],
3020            vec![0.5, -0.5],
3021            vec![1.0, 0.25],
3022        )
3023        .unwrap()
3024        .with_style(Vec4::new(0.2, 0.3, 0.4, 1.0), 2.0, 1.5, 0.2)
3025        .with_label("Field");
3026        figure.add_quiver_plot(quiver);
3027
3028        let rebuilt = FigureScene::capture(&figure)
3029            .into_figure()
3030            .expect("scene restore should succeed");
3031        let PlotElement::Quiver(quiver) = rebuilt.plots().next().unwrap() else {
3032            panic!("expected quiver")
3033        };
3034        assert_eq!(quiver.u, vec![0.5, -0.5]);
3035        assert_eq!(quiver.v, vec![1.0, 0.25]);
3036        assert_eq!(quiver.line_width, 2.0);
3037        assert_eq!(quiver.scale, 1.5);
3038        assert_eq!(quiver.head_size, 0.2);
3039        assert_eq!(quiver.label.as_deref(), Some("Field"));
3040    }
3041
3042    #[test]
3043    fn figure_scene_roundtrip_preserves_image_surface_mode_and_color_grid() {
3044        let mut figure = Figure::new();
3045        let surface = SurfacePlot::new(
3046            vec![0.0, 1.0],
3047            vec![0.0, 1.0],
3048            vec![vec![0.0, 0.0], vec![0.0, 0.0]],
3049        )
3050        .unwrap()
3051        .with_flatten_z(true)
3052        .with_image_mode(true)
3053        .with_color_grid(vec![
3054            vec![Vec4::new(1.0, 0.0, 0.0, 1.0), Vec4::new(0.0, 1.0, 0.0, 1.0)],
3055            vec![Vec4::new(0.0, 0.0, 1.0, 1.0), Vec4::new(1.0, 1.0, 1.0, 1.0)],
3056        ]);
3057        figure.add_surface_plot(surface);
3058
3059        let rebuilt = FigureScene::capture(&figure)
3060            .into_figure()
3061            .expect("scene restore should succeed");
3062        let PlotElement::Surface(surface) = rebuilt.plots().next().unwrap() else {
3063            panic!("expected surface")
3064        };
3065        assert!(surface.flatten_z);
3066        assert!(surface.image_mode);
3067        assert!(surface.color_grid.is_some());
3068        assert_eq!(
3069            surface.color_grid.as_ref().unwrap()[0][0],
3070            Vec4::new(1.0, 0.0, 0.0, 1.0)
3071        );
3072    }
3073
3074    #[test]
3075    fn figure_scene_roundtrip_preserves_area_lower_curve() {
3076        let mut figure = Figure::new();
3077        let area = AreaPlot::new(vec![1.0, 2.0], vec![2.0, 3.0])
3078            .unwrap()
3079            .with_lower_curve(vec![0.5, 1.0])
3080            .with_label("Stacked");
3081        figure.add_area_plot(area);
3082
3083        let rebuilt = FigureScene::capture(&figure)
3084            .into_figure()
3085            .expect("scene restore should succeed");
3086        let PlotElement::Area(area) = rebuilt.plots().next().unwrap() else {
3087            panic!("expected area")
3088        };
3089        assert_eq!(area.lower_y, Some(vec![0.5, 1.0]));
3090        assert_eq!(area.label.as_deref(), Some("Stacked"));
3091    }
3092
3093    #[test]
3094    fn figure_scene_roundtrip_preserves_axes_local_limits_and_colormap_state() {
3095        let mut figure = Figure::new().with_subplot_grid(1, 2);
3096        figure.set_axes_limits(1, Some((1.0, 2.0)), Some((3.0, 4.0)));
3097        figure.set_axes_z_limits(1, Some((5.0, 6.0)));
3098        figure.set_axes_grid_enabled(1, false);
3099        figure.set_axes_minor_grid_enabled(1, true);
3100        figure.set_axes_box_enabled(1, false);
3101        figure.set_axes_axis_equal(1, true);
3102        figure.set_axes_kind(1, AxesKind::Polar);
3103        figure.set_axes_colorbar_enabled(1, true);
3104        figure.set_axes_colormap(1, ColorMap::Hot);
3105        figure.set_axes_color_limits(1, Some((0.0, 10.0)));
3106        figure.set_axes_style(
3107            1,
3108            TextStyle {
3109                font_size: Some(14.0),
3110                ..Default::default()
3111            },
3112        );
3113        figure.set_active_axes_index(1);
3114
3115        let rebuilt = FigureScene::capture(&figure)
3116            .into_figure()
3117            .expect("scene restore should succeed");
3118        let meta = rebuilt.axes_metadata(1).unwrap();
3119        assert_eq!(meta.x_limits, Some((1.0, 2.0)));
3120        assert_eq!(meta.y_limits, Some((3.0, 4.0)));
3121        assert_eq!(meta.z_limits, Some((5.0, 6.0)));
3122        assert!(!meta.grid_enabled);
3123        assert!(meta.minor_grid_enabled);
3124        assert!(meta.minor_grid_explicit);
3125        assert!(!meta.box_enabled);
3126        assert!(meta.axis_equal);
3127        assert_eq!(meta.axes_kind, AxesKind::Polar);
3128        assert!(meta.colorbar_enabled);
3129        assert_eq!(format!("{:?}", meta.colormap), "Hot");
3130        assert_eq!(meta.color_limits, Some((0.0, 10.0)));
3131        assert_eq!(meta.axes_style.font_size, Some(14.0));
3132    }
3133
3134    #[test]
3135    fn axes_metadata_deserializes_without_axes_style() {
3136        let json = r#"{
3137            "legendEnabled": true,
3138            "colormap": "Parula",
3139            "titleStyle": {"visible": true},
3140            "xLabelStyle": {"visible": true},
3141            "yLabelStyle": {"visible": true},
3142            "zLabelStyle": {"visible": true},
3143            "legendStyle": {"visible": true}
3144        }"#;
3145        let serialized: SerializedAxesMetadata = serde_json::from_str(json).unwrap();
3146        let metadata = AxesMetadata::from(serialized);
3147        assert!(metadata.axes_style.color.is_none());
3148        assert!(metadata.axes_style.font_size.is_none());
3149        assert!(metadata.axes_style.font_weight.is_none());
3150        assert!(metadata.axes_style.font_angle.is_none());
3151        assert!(metadata.axes_style.interpreter.is_none());
3152        assert!(metadata.axes_style.visible);
3153    }
3154
3155    #[test]
3156    fn figure_scene_roundtrip_preserves_axes_local_annotation_metadata() {
3157        let mut figure = Figure::new().with_subplot_grid(1, 2);
3158        figure.set_sg_title("All Panels");
3159        figure.set_sg_title_style(TextStyle {
3160            font_weight: Some("bold".into()),
3161            font_size: Some(20.0),
3162            ..Default::default()
3163        });
3164        figure.set_active_axes_index(0);
3165        figure.set_axes_title(0, "Left");
3166        figure.set_axes_xlabel(0, "LX");
3167        figure.set_axes_ylabel(0, "LY");
3168        figure.set_axes_legend_enabled(0, false);
3169        figure.set_axes_title(1, "Right");
3170        figure.set_axes_xlabel(1, "RX");
3171        figure.set_axes_ylabel(1, "RY");
3172        figure.set_axes_legend_enabled(1, true);
3173        figure.set_axes_legend_style(
3174            1,
3175            LegendStyle {
3176                location: Some("northeast".into()),
3177                font_weight: Some("bold".into()),
3178                orientation: Some("horizontal".into()),
3179                ..Default::default()
3180            },
3181        );
3182        if let Some(meta) = figure.axes_metadata.get_mut(0) {
3183            meta.title_style.font_weight = Some("bold".into());
3184            meta.title_style.font_angle = Some("italic".into());
3185        }
3186        figure.set_active_axes_index(1);
3187
3188        let rebuilt = FigureScene::capture(&figure)
3189            .into_figure()
3190            .expect("scene restore should succeed");
3191
3192        assert_eq!(rebuilt.active_axes_index, 1);
3193        assert_eq!(rebuilt.sg_title.as_deref(), Some("All Panels"));
3194        assert_eq!(rebuilt.sg_title_style.font_weight.as_deref(), Some("bold"));
3195        assert_eq!(rebuilt.sg_title_style.font_size, Some(20.0));
3196        assert_eq!(
3197            rebuilt.axes_metadata(0).and_then(|m| m.title.as_deref()),
3198            Some("Left")
3199        );
3200        assert_eq!(
3201            rebuilt.axes_metadata(0).and_then(|m| m.x_label.as_deref()),
3202            Some("LX")
3203        );
3204        assert_eq!(
3205            rebuilt.axes_metadata(0).and_then(|m| m.y_label.as_deref()),
3206            Some("LY")
3207        );
3208        assert!(!rebuilt.axes_metadata(0).unwrap().legend_enabled);
3209        assert_eq!(
3210            rebuilt
3211                .axes_metadata(0)
3212                .unwrap()
3213                .title_style
3214                .font_weight
3215                .as_deref(),
3216            Some("bold")
3217        );
3218        assert_eq!(
3219            rebuilt
3220                .axes_metadata(0)
3221                .unwrap()
3222                .title_style
3223                .font_angle
3224                .as_deref(),
3225            Some("italic")
3226        );
3227        assert_eq!(
3228            rebuilt.axes_metadata(1).and_then(|m| m.title.as_deref()),
3229            Some("Right")
3230        );
3231        assert_eq!(
3232            rebuilt.axes_metadata(1).and_then(|m| m.x_label.as_deref()),
3233            Some("RX")
3234        );
3235        assert_eq!(
3236            rebuilt.axes_metadata(1).and_then(|m| m.y_label.as_deref()),
3237            Some("RY")
3238        );
3239        assert_eq!(
3240            rebuilt
3241                .axes_metadata(1)
3242                .unwrap()
3243                .legend_style
3244                .location
3245                .as_deref(),
3246            Some("northeast")
3247        );
3248        assert_eq!(
3249            rebuilt
3250                .axes_metadata(1)
3251                .unwrap()
3252                .legend_style
3253                .font_weight
3254                .as_deref(),
3255            Some("bold")
3256        );
3257        assert_eq!(
3258            rebuilt
3259                .axes_metadata(1)
3260                .unwrap()
3261                .legend_style
3262                .orientation
3263                .as_deref(),
3264            Some("horizontal")
3265        );
3266    }
3267
3268    #[test]
3269    fn figure_scene_roundtrip_preserves_axes_local_log_modes() {
3270        let mut figure = Figure::new().with_subplot_grid(1, 2);
3271        figure.set_axes_log_modes(0, true, false);
3272        figure.set_axes_log_modes(1, false, true);
3273        figure.set_active_axes_index(1);
3274
3275        let rebuilt = FigureScene::capture(&figure)
3276            .into_figure()
3277            .expect("scene restore should succeed");
3278
3279        assert!(rebuilt.axes_metadata(0).unwrap().x_log);
3280        assert!(!rebuilt.axes_metadata(0).unwrap().y_log);
3281        assert!(!rebuilt.axes_metadata(1).unwrap().x_log);
3282        assert!(rebuilt.axes_metadata(1).unwrap().y_log);
3283        assert!(!rebuilt.x_log);
3284        assert!(rebuilt.y_log);
3285    }
3286
3287    #[test]
3288    fn figure_scene_roundtrip_preserves_zlabel_and_view_state() {
3289        let mut figure = Figure::new().with_subplot_grid(1, 2);
3290        figure.set_axes_zlabel(1, "Height");
3291        figure.set_axes_view(1, 45.0, 20.0);
3292        figure.set_active_axes_index(1);
3293
3294        let rebuilt = FigureScene::capture(&figure)
3295            .into_figure()
3296            .expect("scene restore should succeed");
3297
3298        assert_eq!(
3299            rebuilt.axes_metadata(1).unwrap().z_label.as_deref(),
3300            Some("Height")
3301        );
3302        assert_eq!(
3303            rebuilt.axes_metadata(1).unwrap().view_azimuth_deg,
3304            Some(45.0)
3305        );
3306        assert_eq!(
3307            rebuilt.axes_metadata(1).unwrap().view_elevation_deg,
3308            Some(20.0)
3309        );
3310        assert_eq!(rebuilt.z_label.as_deref(), Some("Height"));
3311    }
3312
3313    #[test]
3314    fn figure_scene_roundtrip_preserves_pie_metadata() {
3315        let mut figure = Figure::new();
3316        let pie = crate::plots::PieChart::new(vec![1.0, 2.0], None)
3317            .unwrap()
3318            .with_slice_labels(vec!["A".into(), "B".into()])
3319            .with_explode(vec![false, true]);
3320        figure.add_pie_chart(pie);
3321
3322        let rebuilt = FigureScene::capture(&figure)
3323            .into_figure()
3324            .expect("scene restore should succeed");
3325        let crate::plots::figure::PlotElement::Pie(pie) = rebuilt.plots().next().unwrap() else {
3326            panic!("expected pie")
3327        };
3328        assert_eq!(pie.slice_labels, vec!["A", "B"]);
3329        assert_eq!(pie.explode, vec![false, true]);
3330    }
3331
3332    #[test]
3333    fn scene_plot_deserialize_maps_null_numeric_values_to_nan() {
3334        let json = r#"{
3335          "schemaVersion": 1,
3336          "layout": { "axesRows": 1, "axesCols": 1, "axesIndices": [0] },
3337          "metadata": {
3338            "gridEnabled": true,
3339            "legendEnabled": false,
3340            "colorbarEnabled": false,
3341            "axisEqual": false,
3342            "backgroundRgba": [1,1,1,1],
3343            "legendEntries": []
3344          },
3345          "plots": [
3346            {
3347              "kind": "surface",
3348              "x": [0.0, null],
3349              "y": [0.0, 1.0],
3350              "z": [[0.0, null], [1.0, 2.0]],
3351              "colormap": "Parula",
3352              "shading_mode": "Smooth",
3353              "wireframe": false,
3354              "alpha": 1.0,
3355              "flatten_z": false,
3356              "color_limits": null,
3357              "axes_index": 0,
3358              "label": null,
3359              "visible": true
3360            }
3361          ]
3362        }"#;
3363        let scene: FigureScene = serde_json::from_str(json).expect("scene should deserialize");
3364        let ScenePlot::Surface { x, z, .. } = &scene.plots[0] else {
3365            panic!("expected surface plot");
3366        };
3367        assert!(x[1].is_nan());
3368        assert!(z[0][1].is_nan());
3369    }
3370
3371    #[test]
3372    fn scene_plot_deserialize_maps_null_scatter3_components_to_nan() {
3373        let json = r#"{
3374          "schemaVersion": 1,
3375          "layout": { "axesRows": 1, "axesCols": 1, "axesIndices": [0] },
3376          "metadata": {
3377            "gridEnabled": true,
3378            "legendEnabled": false,
3379            "colorbarEnabled": false,
3380            "axisEqual": false,
3381            "backgroundRgba": [1,1,1,1],
3382            "legendEntries": []
3383          },
3384          "plots": [
3385            {
3386              "kind": "scatter3",
3387              "points": [[0.0, 1.0, null], [1.0, null, 2.0]],
3388              "colors_rgba": [[0.2, 0.4, 0.6, 1.0], [0.1, 0.2, 0.3, 1.0]],
3389              "point_size": 6.0,
3390              "point_sizes": [3.0, null],
3391              "axes_index": 0,
3392              "label": null,
3393              "visible": true
3394            }
3395          ]
3396        }"#;
3397        let scene: FigureScene = serde_json::from_str(json).expect("scene should deserialize");
3398        let ScenePlot::Scatter3 {
3399            points,
3400            point_sizes,
3401            ..
3402        } = &scene.plots[0]
3403        else {
3404            panic!("expected scatter3 plot");
3405        };
3406        assert!(points[0][2].is_nan());
3407        assert!(points[1][1].is_nan());
3408        assert!(point_sizes.as_ref().unwrap()[1].is_nan());
3409    }
3410}