Skip to main content

runmat_runtime/analysis/
figures.rs

1use glam::{Vec3, Vec4};
2use runmat_analysis_core::{AnalysisField, AnalysisFieldValues};
3use runmat_plot::plots::{
4    BarChart, Figure, LinePlot, MeshDeformation, MeshEdgeMode, MeshFieldLocation, MeshPlot,
5    MeshScalarField, MeshVectorField, PlotElement,
6};
7
8use super::contracts::{
9    AnalysisRenderTopology, AnalysisResultsCompareData, AnalysisResultsCompareQuery,
10    AnalysisRunKind, AnalysisRunResult, AnalysisStudySpec, AnalysisTrendsData, AnalysisTrendsQuery,
11};
12use super::{analysis_results_compare_op, analysis_trends_op, collect_analysis_result_fields};
13use super::{run_kind, storage};
14use crate::geometry::{geometry_preview_figure, GeometryPreviewFigureOptions};
15use crate::operations::OperationContext;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum AnalysisGeneratedFigureKind {
19    MeshResult,
20    Convergence,
21    Modal,
22    Electromagnetic,
23    Comparison,
24    Trend,
25}
26
27#[derive(Debug, Clone)]
28pub struct AnalysisGeneratedFigure {
29    pub kind: AnalysisGeneratedFigureKind,
30    pub title: String,
31    pub field_ids: Vec<String>,
32    pub warnings: Vec<String>,
33    pub figure: Figure,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct AnalysisFigureGenerationOptions {
38    pub max_overlay_values: usize,
39    pub max_vector_glyphs: usize,
40    pub max_mesh_result_figures: usize,
41    pub max_mesh_geometry_bytes: usize,
42    pub edge_overlay_triangle_limit: usize,
43    pub include_comparison: bool,
44    pub include_trends: bool,
45}
46
47impl Default for AnalysisFigureGenerationOptions {
48    fn default() -> Self {
49        Self {
50            max_overlay_values: 1_500_000,
51            max_vector_glyphs: 40_000,
52            max_mesh_result_figures: 4,
53            max_mesh_geometry_bytes: 256 * 1024 * 1024,
54            edge_overlay_triangle_limit: 250_000,
55            include_comparison: true,
56            include_trends: true,
57        }
58    }
59}
60
61#[derive(Debug, Clone)]
62struct MeshCounts {
63    plot_index: usize,
64    vertices: usize,
65    triangles: usize,
66}
67
68#[derive(Debug, Clone)]
69struct ScalarOverlay {
70    field_id: String,
71    label: String,
72    location: MeshFieldLocation,
73    chunks: Vec<Vec<f32>>,
74}
75
76#[derive(Debug, Clone)]
77struct VectorOverlay {
78    field_id: String,
79    label: String,
80    location: MeshFieldLocation,
81    chunks: Vec<Vec<Vec3>>,
82    stride: usize,
83}
84
85#[derive(Debug, Clone)]
86struct DeformationOverlay {
87    field_id: String,
88    label: String,
89    chunks: Vec<Vec<Vec3>>,
90    scale: f32,
91}
92
93pub fn analysis_generate_study_run_figures(
94    study: &AnalysisStudySpec,
95    run_id: &str,
96    options: AnalysisFigureGenerationOptions,
97) -> Result<Vec<AnalysisGeneratedFigure>, String> {
98    let current = storage::load_run_result(run_id)?
99        .ok_or_else(|| format!("FEA run_id '{run_id}' was not found"))?;
100    let mut figures = generate_run_figures(&study.geometry, &current, options);
101
102    if options.include_comparison {
103        if let Some(previous) = previous_run_of_kind(&current)? {
104            let query = AnalysisResultsCompareQuery {
105                baseline_run_id: previous.run_id.clone(),
106                candidate_run_id: current.run_id.clone(),
107            };
108            if let Ok(envelope) =
109                analysis_results_compare_op(query, OperationContext::new(None, None))
110            {
111                if let Some(figure) = comparison_figure(&envelope.data) {
112                    figures.push(figure);
113                }
114            }
115        }
116    }
117
118    if options.include_trends {
119        if let Ok(envelope) = analysis_trends_op(
120            AnalysisTrendsQuery::default(),
121            OperationContext::new(None, None),
122        ) {
123            figures.extend(trend_figures(&envelope.data));
124        }
125    }
126
127    Ok(figures)
128}
129
130fn generate_run_figures(
131    geometry: &runmat_geometry_core::GeometryAsset,
132    run: &AnalysisRunResult,
133    options: AnalysisFigureGenerationOptions,
134) -> Vec<AnalysisGeneratedFigure> {
135    let mut figures = Vec::new();
136    figures.extend(mesh_result_figures(geometry, run, options));
137    figures.extend(convergence_figures(run));
138    figures
139}
140
141fn mesh_result_figures(
142    geometry: &runmat_geometry_core::GeometryAsset,
143    run: &AnalysisRunResult,
144    options: AnalysisFigureGenerationOptions,
145) -> Vec<AnalysisGeneratedFigure> {
146    let render_topology = run
147        .render_topology
148        .as_ref()
149        .filter(|topology| render_topology_has_meshes(topology));
150    if (render_topology.is_none() && geometry.surface_meshes.is_empty())
151        || options.max_mesh_result_figures == 0
152    {
153        return Vec::new();
154    }
155
156    let estimated_geometry_bytes = render_topology
157        .map(render_topology_mesh_bytes)
158        .unwrap_or_else(|| geometry_surface_mesh_bytes(geometry));
159    let mut per_run_mesh_figure_limit = options.max_mesh_result_figures;
160    let mut shared_warnings = Vec::new();
161    if estimated_geometry_bytes > options.max_mesh_geometry_bytes {
162        per_run_mesh_figure_limit = 1;
163        shared_warnings.push(format!(
164            "mesh result figure count capped to 1 because the render mesh is approximately {} bytes",
165            estimated_geometry_bytes
166        ));
167    }
168
169    let fields = collect_analysis_result_fields(run);
170    let probe =
171        match base_mesh_figure_for_run_source(geometry, render_topology, "FEA result", options) {
172            Some(figure) => figure,
173            None => {
174                return vec![warning_line_figure(
175                    AnalysisGeneratedFigureKind::MeshResult,
176                    "FEA result visualization",
177                    "Solver render topology and geometry preview are unavailable".to_string(),
178                )]
179            }
180        };
181    let mesh_counts = collect_mesh_counts(&probe);
182    if mesh_counts.is_empty() {
183        return Vec::new();
184    }
185
186    let deformation = fields
187        .iter()
188        .filter(|field| is_deformation_candidate(&field.field_id))
189        .find_map(|field| deformation_overlay(field, &mesh_counts, &probe, options));
190
191    let mut figures = Vec::new();
192    if let Some(deformation) = deformation.as_ref() {
193        if figures.len() < per_run_mesh_figure_limit {
194            if let Some(mut figure) = base_mesh_figure(
195                geometry,
196                render_topology,
197                format!("FEA deformed shape: {}", deformation.field_id),
198                options,
199            ) {
200                let mut warnings = shared_warnings.clone();
201                append_deformed_mesh_overlay(&mut figure, deformation, &mesh_counts, &mut warnings);
202                figures.push(AnalysisGeneratedFigure {
203                    kind: AnalysisGeneratedFigureKind::MeshResult,
204                    title: format!("FEA deformed shape: {}", deformation.field_id),
205                    field_ids: vec![deformation.field_id.clone()],
206                    warnings,
207                    figure,
208                });
209            }
210        }
211    }
212
213    for field in &fields {
214        if figures.len() >= per_run_mesh_figure_limit {
215            break;
216        }
217        let Some(scalar) = scalar_overlay(field, &mesh_counts, options) else {
218            continue;
219        };
220        let title = format!("FEA scalar field: {}", scalar.field_id);
221        let Some(mut figure) = base_mesh_figure(geometry, render_topology, title.clone(), options)
222        else {
223            continue;
224        };
225        let mut warnings = shared_warnings.clone();
226        apply_scalar_overlay(&mut figure, &scalar, &mesh_counts, &mut warnings);
227        if let Some(deformation) = deformation.as_ref() {
228            apply_deformation_to_existing_meshes(
229                &mut figure,
230                deformation,
231                &mesh_counts,
232                &mut warnings,
233            );
234        }
235        figure.colorbar_enabled = true;
236        figures.push(AnalysisGeneratedFigure {
237            kind: AnalysisGeneratedFigureKind::MeshResult,
238            title,
239            field_ids: vec![scalar.field_id],
240            warnings,
241            figure,
242        });
243    }
244
245    for field in &fields {
246        if figures.len() >= per_run_mesh_figure_limit {
247            break;
248        }
249        let Some(vector) = vector_overlay(field, &mesh_counts, options) else {
250            continue;
251        };
252        let title = format!("FEA vector field: {}", vector.field_id);
253        let Some(mut figure) = base_mesh_figure(geometry, render_topology, title.clone(), options)
254        else {
255            continue;
256        };
257        let mut warnings = shared_warnings.clone();
258        apply_vector_overlay(&mut figure, &vector, &mesh_counts, &mut warnings);
259        if let Some(deformation) = deformation.as_ref() {
260            apply_deformation_to_existing_meshes(
261                &mut figure,
262                deformation,
263                &mesh_counts,
264                &mut warnings,
265            );
266        }
267        figures.push(AnalysisGeneratedFigure {
268            kind: AnalysisGeneratedFigureKind::MeshResult,
269            title,
270            field_ids: vec![vector.field_id],
271            warnings,
272            figure,
273        });
274    }
275
276    if figures.is_empty() {
277        if let Some(figure) = base_mesh_figure(
278            geometry,
279            render_topology,
280            format!("FEA geometry result: {}", run.run_id),
281            options,
282        ) {
283            figures.push(AnalysisGeneratedFigure {
284                kind: AnalysisGeneratedFigureKind::MeshResult,
285                title: format!("FEA geometry result: {}", run.run_id),
286                field_ids: Vec::new(),
287                warnings: shared_warnings,
288                figure,
289            });
290        }
291    }
292
293    figures
294}
295
296fn convergence_figures(run: &AnalysisRunResult) -> Vec<AnalysisGeneratedFigure> {
297    let mut figures = Vec::new();
298    if let Some(modal) = run.modal_results.as_ref() {
299        if !modal.eigenvalues_hz.is_empty() {
300            let labels = (1..=modal.eigenvalues_hz.len())
301                .map(|idx| format!("Mode {idx}"))
302                .collect::<Vec<_>>();
303            if let Ok(mut chart) = BarChart::new(labels, modal.eigenvalues_hz.clone()) {
304                chart.label = Some("Frequency".to_string());
305                chart.color = Vec4::new(0.33, 0.66, 0.96, 1.0);
306                let mut figure = Figure::new()
307                    .with_title("FEA modal frequencies")
308                    .with_labels("Mode", "Frequency (Hz)")
309                    .with_grid(true);
310                figure.add_bar_chart(chart);
311                figures.push(AnalysisGeneratedFigure {
312                    kind: AnalysisGeneratedFigureKind::Modal,
313                    title: "FEA modal frequencies".to_string(),
314                    field_ids: modal
315                        .mode_shapes
316                        .iter()
317                        .map(|field| field.field_id.clone())
318                        .collect(),
319                    warnings: Vec::new(),
320                    figure,
321                });
322            }
323        }
324        if !modal.residual_norms.is_empty() {
325            figures.push(line_figure(
326                AnalysisGeneratedFigureKind::Convergence,
327                "FEA modal residuals",
328                "Mode",
329                "Residual norm",
330                vec![(
331                    "Residual".to_string(),
332                    index_axis(modal.residual_norms.len(), 1.0),
333                    modal.residual_norms.clone(),
334                    Vec4::new(0.93, 0.48, 0.26, 1.0),
335                )],
336                Vec::new(),
337                true,
338            ));
339        }
340    }
341
342    if let Some(thermal) = run.thermal_results.as_ref() {
343        if !thermal.residual_norms.is_empty() {
344            figures.push(line_figure(
345                AnalysisGeneratedFigureKind::Convergence,
346                "FEA thermal convergence",
347                "Time (s)",
348                "Residual norm",
349                vec![(
350                    "Thermal residual".to_string(),
351                    axis_or_index(&thermal.time_points_s, thermal.residual_norms.len()),
352                    thermal.residual_norms.clone(),
353                    Vec4::new(0.92, 0.38, 0.31, 1.0),
354                )],
355                thermal
356                    .temperature_snapshots
357                    .iter()
358                    .map(|field| field.field_id.clone())
359                    .collect(),
360                true,
361            ));
362        }
363    }
364
365    if let Some(transient) = run.transient_results.as_ref() {
366        if !transient.residual_norms.is_empty() {
367            figures.push(line_figure(
368                AnalysisGeneratedFigureKind::Convergence,
369                "FEA transient convergence",
370                "Time (s)",
371                "Residual norm",
372                vec![(
373                    "Transient residual".to_string(),
374                    axis_or_index(&transient.time_points_s, transient.residual_norms.len()),
375                    transient.residual_norms.clone(),
376                    Vec4::new(0.35, 0.72, 0.88, 1.0),
377                )],
378                transient
379                    .displacement_snapshots
380                    .iter()
381                    .map(|field| field.field_id.clone())
382                    .collect(),
383                true,
384            ));
385        }
386    }
387
388    if let Some(nonlinear) = run.nonlinear_results.as_ref() {
389        if !nonlinear.residual_norms.is_empty() {
390            figures.push(line_figure(
391                AnalysisGeneratedFigureKind::Convergence,
392                "FEA nonlinear convergence",
393                "Load factor",
394                "Norm",
395                vec![
396                    (
397                        "Residual".to_string(),
398                        axis_or_index(&nonlinear.load_factors, nonlinear.residual_norms.len()),
399                        nonlinear.residual_norms.clone(),
400                        Vec4::new(0.92, 0.38, 0.31, 1.0),
401                    ),
402                    (
403                        "Increment".to_string(),
404                        axis_or_index(&nonlinear.load_factors, nonlinear.increment_norms.len()),
405                        nonlinear.increment_norms.clone(),
406                        Vec4::new(0.33, 0.66, 0.96, 1.0),
407                    ),
408                ],
409                nonlinear
410                    .displacement_snapshots
411                    .iter()
412                    .map(|field| field.field_id.clone())
413                    .collect(),
414                true,
415            ));
416        }
417        if !nonlinear.iteration_counts.is_empty() {
418            figures.push(line_figure(
419                AnalysisGeneratedFigureKind::Convergence,
420                "FEA nonlinear iterations",
421                "Load factor",
422                "Iterations",
423                vec![(
424                    "Iterations".to_string(),
425                    axis_or_index(&nonlinear.load_factors, nonlinear.iteration_counts.len()),
426                    nonlinear
427                        .iteration_counts
428                        .iter()
429                        .map(|value| *value as f64)
430                        .collect(),
431                    Vec4::new(0.73, 0.62, 0.95, 1.0),
432                )],
433                Vec::new(),
434                false,
435            ));
436        }
437    }
438
439    if let Some(em) = run.electromagnetic_results.as_ref() {
440        if !em.sweep_frequency_hz.is_empty() && !em.sweep_peak_flux_density.is_empty() {
441            figures.push(line_figure(
442                AnalysisGeneratedFigureKind::Electromagnetic,
443                "FEA electromagnetic sweep",
444                "Frequency (Hz)",
445                "Peak flux density",
446                vec![(
447                    "Peak flux".to_string(),
448                    axis_or_index(&em.sweep_frequency_hz, em.sweep_peak_flux_density.len()),
449                    em.sweep_peak_flux_density.clone(),
450                    Vec4::new(0.28, 0.74, 0.57, 1.0),
451                )],
452                vec![
453                    em.vector_potential_real.field_id.clone(),
454                    em.magnetic_flux_density_magnitude.field_id.clone(),
455                ],
456                false,
457            ));
458        }
459        if !em.sweep_frequency_hz.is_empty() && !em.sweep_solve_quality.is_empty() {
460            figures.push(line_figure(
461                AnalysisGeneratedFigureKind::Electromagnetic,
462                "FEA electromagnetic solve quality",
463                "Frequency (Hz)",
464                "Solve quality",
465                vec![(
466                    "Solve quality".to_string(),
467                    axis_or_index(&em.sweep_frequency_hz, em.sweep_solve_quality.len()),
468                    em.sweep_solve_quality.clone(),
469                    Vec4::new(0.92, 0.68, 0.28, 1.0),
470                )],
471                vec![
472                    em.vector_potential_real.field_id.clone(),
473                    em.magnetic_flux_density_magnitude.field_id.clone(),
474                ],
475                false,
476            ));
477        }
478    }
479
480    figures
481}
482
483fn comparison_figure(data: &AnalysisResultsCompareData) -> Option<AnalysisGeneratedFigure> {
484    let mut labels = Vec::new();
485    let mut values = Vec::new();
486    push_bar_value(
487        &mut labels,
488        &mut values,
489        "Quality reasons",
490        Some(data.quality_reason_count_delta as f64),
491    );
492    push_bar_value(&mut labels, &mut values, "Solve ms", data.solve_ms_delta);
493    push_bar_value(
494        &mut labels,
495        &mut values,
496        "Failed increments",
497        data.failed_increment_delta.map(|value| value as f64),
498    );
499    push_bar_value(
500        &mut labels,
501        &mut values,
502        "Max iterations",
503        data.max_iteration_delta.map(|value| value as f64),
504    );
505    push_bar_value(
506        &mut labels,
507        &mut values,
508        "Spikes",
509        data.nonlinear_spike_count_delta.map(|value| value as f64),
510    );
511    push_bar_value(
512        &mut labels,
513        &mut values,
514        "Stalls",
515        data.nonlinear_stall_count_delta.map(|value| value as f64),
516    );
517    push_bar_value(
518        &mut labels,
519        &mut values,
520        "Publishable changed",
521        Some(if data.publishable_changed { 1.0 } else { 0.0 }),
522    );
523    push_bar_value(
524        &mut labels,
525        &mut values,
526        "Status changed",
527        Some(if data.run_status_changed { 1.0 } else { 0.0 }),
528    );
529    if labels.is_empty() {
530        return None;
531    }
532    let mut chart = BarChart::new(labels, values).ok()?;
533    chart.label = Some("Delta".to_string());
534    chart.color = Vec4::new(0.77, 0.58, 0.95, 1.0);
535    let mut figure = Figure::new()
536        .with_title("FEA run comparison")
537        .with_labels("Metric", "Candidate minus baseline")
538        .with_grid(true);
539    figure.add_bar_chart(chart);
540    Some(AnalysisGeneratedFigure {
541        kind: AnalysisGeneratedFigureKind::Comparison,
542        title: "FEA run comparison".to_string(),
543        field_ids: Vec::new(),
544        warnings: Vec::new(),
545        figure,
546    })
547}
548
549fn trend_figures(data: &AnalysisTrendsData) -> Vec<AnalysisGeneratedFigure> {
550    let mut figures = Vec::new();
551    let labels = data
552        .summaries
553        .iter()
554        .map(|summary| run_kind_label(summary.run_kind).to_string())
555        .collect::<Vec<_>>();
556    if labels.is_empty() {
557        return figures;
558    }
559
560    let solve_values = data
561        .summaries
562        .iter()
563        .map(|summary| summary.median_solve_ms.unwrap_or(0.0))
564        .collect::<Vec<_>>();
565    if solve_values.iter().any(|value| *value != 0.0) {
566        if let Ok(mut chart) = BarChart::new(labels.clone(), solve_values) {
567            chart.label = Some("Median solve".to_string());
568            chart.color = Vec4::new(0.31, 0.62, 0.91, 1.0);
569            let mut figure = Figure::new()
570                .with_title("FEA solve time trends")
571                .with_labels("Run family", "Median solve (ms)")
572                .with_grid(true);
573            figure.add_bar_chart(chart);
574            figures.push(AnalysisGeneratedFigure {
575                kind: AnalysisGeneratedFigureKind::Trend,
576                title: "FEA solve time trends".to_string(),
577                field_ids: Vec::new(),
578                warnings: Vec::new(),
579                figure,
580            });
581        }
582    }
583
584    let publishable_values = data
585        .summaries
586        .iter()
587        .map(|summary| summary.publishable_rate * 100.0)
588        .collect::<Vec<_>>();
589    if let Ok(mut chart) = BarChart::new(labels, publishable_values) {
590        chart.label = Some("Publishable rate".to_string());
591        chart.color = Vec4::new(0.32, 0.74, 0.56, 1.0);
592        let mut figure = Figure::new()
593            .with_title("FEA publishable result trends")
594            .with_labels("Run family", "Publishable (%)")
595            .with_grid(true);
596        figure.add_bar_chart(chart);
597        figures.push(AnalysisGeneratedFigure {
598            kind: AnalysisGeneratedFigureKind::Trend,
599            title: "FEA publishable result trends".to_string(),
600            field_ids: Vec::new(),
601            warnings: Vec::new(),
602            figure,
603        });
604    }
605
606    figures
607}
608
609fn base_mesh_figure(
610    geometry: &runmat_geometry_core::GeometryAsset,
611    render_topology: Option<&AnalysisRenderTopology>,
612    title: impl Into<String>,
613    options: AnalysisFigureGenerationOptions,
614) -> Option<Figure> {
615    base_mesh_figure_for_run_source(geometry, render_topology, title, options)
616}
617
618fn base_mesh_figure_for_run_source(
619    geometry: &runmat_geometry_core::GeometryAsset,
620    render_topology: Option<&AnalysisRenderTopology>,
621    title: impl Into<String>,
622    options: AnalysisFigureGenerationOptions,
623) -> Option<Figure> {
624    let title = title.into();
625    if let Some(topology) = render_topology {
626        if let Ok(figure) = render_topology_figure(topology, title.clone(), options) {
627            return Some(figure);
628        }
629    }
630    geometry_preview_figure(
631        geometry,
632        title,
633        GeometryPreviewFigureOptions {
634            edge_overlay_triangle_limit: options.edge_overlay_triangle_limit,
635            ..GeometryPreviewFigureOptions::default()
636        },
637    )
638    .ok()
639}
640
641fn render_topology_figure(
642    topology: &AnalysisRenderTopology,
643    title: impl Into<String>,
644    options: AnalysisFigureGenerationOptions,
645) -> Result<Figure, String> {
646    if !render_topology_has_meshes(topology) {
647        return Err("solver render topology does not contain renderable meshes".to_string());
648    }
649    let mut figure = Figure::new()
650        .with_title(title)
651        .with_labels("X", "Y")
652        .with_grid(true)
653        .with_axis_equal(true);
654    figure.z_label = Some("Z".to_string());
655
656    for mesh in &topology.meshes {
657        if mesh.vertices.is_empty() || mesh.triangles.is_empty() {
658            continue;
659        }
660        let vertices = mesh
661            .vertices
662            .iter()
663            .map(|vertex| {
664                Ok(Vec3::new(
665                    f64_to_f32(vertex[0]).ok_or_else(|| {
666                        "solver render topology contains a non-renderable X coordinate".to_string()
667                    })?,
668                    f64_to_f32(vertex[1]).ok_or_else(|| {
669                        "solver render topology contains a non-renderable Y coordinate".to_string()
670                    })?,
671                    f64_to_f32(vertex[2]).ok_or_else(|| {
672                        "solver render topology contains a non-renderable Z coordinate".to_string()
673                    })?,
674                ))
675            })
676            .collect::<Result<Vec<_>, String>>()?;
677        let mut plot = MeshPlot::new(vertices, mesh.triangles.clone())?;
678        plot.set_mesh_id(Some(mesh.mesh_id.clone()));
679        plot.set_label(Some(format!(
680            "{}: {} solver triangles",
681            mesh.mesh_id,
682            mesh.triangles.len()
683        )));
684        plot.set_face_color(Vec4::new(0.34, 0.57, 0.82, 1.0));
685        plot.set_edge_color(Vec4::new(0.88, 0.93, 0.98, 0.82));
686        plot.set_face_alpha(0.94);
687        if mesh.triangles.len() > options.edge_overlay_triangle_limit {
688            plot.set_edge_mode(MeshEdgeMode::None);
689            plot.set_edge_width(0.0);
690        } else {
691            plot.set_edge_mode(MeshEdgeMode::All);
692            plot.set_edge_width(0.28);
693        }
694        figure.add_mesh_plot(plot);
695    }
696
697    if collect_mesh_counts(&figure).is_empty() {
698        Err("solver render topology did not produce any mesh plots".to_string())
699    } else {
700        Ok(figure)
701    }
702}
703
704fn collect_mesh_counts(figure: &Figure) -> Vec<MeshCounts> {
705    figure
706        .plots()
707        .enumerate()
708        .filter_map(|(plot_index, plot)| match plot {
709            PlotElement::Mesh(mesh) => Some(MeshCounts {
710                plot_index,
711                vertices: mesh.vertices().len(),
712                triangles: mesh.triangles().len(),
713            }),
714            _ => None,
715        })
716        .collect()
717}
718
719fn scalar_overlay(
720    field: &AnalysisField,
721    meshes: &[MeshCounts],
722    options: AnalysisFigureGenerationOptions,
723) -> Option<ScalarOverlay> {
724    let values = host_values(field)?;
725    let total_vertices = meshes.iter().map(|mesh| mesh.vertices).sum::<usize>();
726    let total_triangles = meshes.iter().map(|mesh| mesh.triangles).sum::<usize>();
727    if values.len() == total_vertices {
728        return scalar_overlay_from_values(
729            field,
730            MeshFieldLocation::Vertex,
731            meshes.iter().map(|mesh| mesh.vertices),
732            options,
733        );
734    }
735    if values.len() == total_triangles {
736        return scalar_overlay_from_values(
737            field,
738            MeshFieldLocation::Triangle,
739            meshes.iter().map(|mesh| mesh.triangles),
740            options,
741        );
742    }
743    if let Some(vectors) = vectors_for_count(field, total_vertices) {
744        if total_vertices <= options.max_overlay_values {
745            let magnitudes = vectors
746                .iter()
747                .map(|vector| vector.length())
748                .collect::<Vec<_>>();
749            return Some(ScalarOverlay {
750                field_id: format!("{}.magnitude", field.field_id),
751                label: format!("{} magnitude", field.field_id),
752                location: MeshFieldLocation::Vertex,
753                chunks: split_f32(&magnitudes, meshes.iter().map(|mesh| mesh.vertices))?,
754            });
755        }
756    }
757    if let Some(vectors) = vectors_for_count(field, total_triangles) {
758        if total_triangles <= options.max_overlay_values {
759            let magnitudes = vectors
760                .iter()
761                .map(|vector| vector.length())
762                .collect::<Vec<_>>();
763            return Some(ScalarOverlay {
764                field_id: format!("{}.magnitude", field.field_id),
765                label: format!("{} magnitude", field.field_id),
766                location: MeshFieldLocation::Triangle,
767                chunks: split_f32(&magnitudes, meshes.iter().map(|mesh| mesh.triangles))?,
768            });
769        }
770    }
771    None
772}
773
774fn scalar_overlay_from_values<I>(
775    field: &AnalysisField,
776    location: MeshFieldLocation,
777    chunk_lengths: I,
778    options: AnalysisFigureGenerationOptions,
779) -> Option<ScalarOverlay>
780where
781    I: Iterator<Item = usize>,
782{
783    let values = host_values(field)?;
784    if values.len() > options.max_overlay_values {
785        return None;
786    }
787    let values = values
788        .iter()
789        .copied()
790        .map(f64_to_f32)
791        .collect::<Option<Vec<_>>>()?;
792    Some(ScalarOverlay {
793        field_id: field.field_id.clone(),
794        label: field.field_id.clone(),
795        location,
796        chunks: split_f32(&values, chunk_lengths)?,
797    })
798}
799
800fn vector_overlay(
801    field: &AnalysisField,
802    meshes: &[MeshCounts],
803    options: AnalysisFigureGenerationOptions,
804) -> Option<VectorOverlay> {
805    let total_vertices = meshes.iter().map(|mesh| mesh.vertices).sum::<usize>();
806    if let Some(vectors) = vectors_for_count(field, total_vertices) {
807        if total_vertices <= options.max_overlay_values {
808            let stride = glyph_stride(total_vertices, options.max_vector_glyphs);
809            return Some(VectorOverlay {
810                field_id: field.field_id.clone(),
811                label: field.field_id.clone(),
812                location: MeshFieldLocation::Vertex,
813                chunks: split_vec3(&vectors, meshes.iter().map(|mesh| mesh.vertices))?,
814                stride,
815            });
816        }
817    }
818
819    let total_triangles = meshes.iter().map(|mesh| mesh.triangles).sum::<usize>();
820    if let Some(vectors) = vectors_for_count(field, total_triangles) {
821        if total_triangles <= options.max_overlay_values {
822            let stride = glyph_stride(total_triangles, options.max_vector_glyphs);
823            return Some(VectorOverlay {
824                field_id: field.field_id.clone(),
825                label: field.field_id.clone(),
826                location: MeshFieldLocation::Triangle,
827                chunks: split_vec3(&vectors, meshes.iter().map(|mesh| mesh.triangles))?,
828                stride,
829            });
830        }
831    }
832    None
833}
834
835fn deformation_overlay(
836    field: &AnalysisField,
837    meshes: &[MeshCounts],
838    figure: &Figure,
839    options: AnalysisFigureGenerationOptions,
840) -> Option<DeformationOverlay> {
841    let total_vertices = meshes.iter().map(|mesh| mesh.vertices).sum::<usize>();
842    if total_vertices > options.max_overlay_values {
843        return None;
844    }
845    let vectors = vectors_for_count(field, total_vertices)?;
846    let scale = deformation_scale(&vectors, figure);
847    Some(DeformationOverlay {
848        field_id: field.field_id.clone(),
849        label: field.field_id.clone(),
850        chunks: split_vec3(&vectors, meshes.iter().map(|mesh| mesh.vertices))?,
851        scale,
852    })
853}
854
855fn apply_scalar_overlay(
856    figure: &mut Figure,
857    overlay: &ScalarOverlay,
858    meshes: &[MeshCounts],
859    warnings: &mut Vec<String>,
860) {
861    for (mesh, values) in meshes.iter().zip(&overlay.chunks) {
862        let Some(PlotElement::Mesh(plot)) = figure.get_plot_mut(mesh.plot_index) else {
863            continue;
864        };
865        let mut field =
866            MeshScalarField::new(overlay.field_id.clone(), overlay.location, values.clone());
867        field.label = Some(overlay.label.clone());
868        field.alpha = 0.92;
869        if let Some(limits) = finite_limits(values) {
870            field.color_limits = Some(limits);
871        }
872        if let Err(err) = plot.set_scalar_field(Some(field)) {
873            warnings.push(format!(
874                "failed to attach scalar field '{}' to mesh: {err}",
875                overlay.field_id
876            ));
877        }
878    }
879}
880
881fn apply_vector_overlay(
882    figure: &mut Figure,
883    overlay: &VectorOverlay,
884    meshes: &[MeshCounts],
885    warnings: &mut Vec<String>,
886) {
887    for (mesh, vectors) in meshes.iter().zip(&overlay.chunks) {
888        let Some(PlotElement::Mesh(plot)) = figure.get_plot_mut(mesh.plot_index) else {
889            continue;
890        };
891        let mut field =
892            MeshVectorField::new(overlay.field_id.clone(), overlay.location, vectors.clone());
893        field.label = Some(overlay.label.clone());
894        field.stride = overlay.stride.max(1);
895        field.scale = vector_scale(vectors);
896        if let Err(err) = plot.set_vector_field(Some(field)) {
897            warnings.push(format!(
898                "failed to attach vector field '{}' to mesh: {err}",
899                overlay.field_id
900            ));
901        }
902    }
903}
904
905fn apply_deformation_to_existing_meshes(
906    figure: &mut Figure,
907    overlay: &DeformationOverlay,
908    meshes: &[MeshCounts],
909    warnings: &mut Vec<String>,
910) {
911    for (mesh, displacements) in meshes.iter().zip(&overlay.chunks) {
912        let Some(PlotElement::Mesh(plot)) = figure.get_plot_mut(mesh.plot_index) else {
913            continue;
914        };
915        let mut deformation = MeshDeformation::new(overlay.field_id.clone(), displacements.clone());
916        deformation.label = Some(overlay.label.clone());
917        deformation.scale = overlay.scale;
918        if let Err(err) = plot.set_deformation(Some(deformation)) {
919            warnings.push(format!(
920                "failed to attach deformation field '{}' to mesh: {err}",
921                overlay.field_id
922            ));
923        }
924    }
925}
926
927fn append_deformed_mesh_overlay(
928    figure: &mut Figure,
929    overlay: &DeformationOverlay,
930    meshes: &[MeshCounts],
931    warnings: &mut Vec<String>,
932) {
933    let clones = meshes
934        .iter()
935        .filter_map(|mesh| match figure.plots().nth(mesh.plot_index) {
936            Some(PlotElement::Mesh(plot)) => Some(plot.clone()),
937            _ => None,
938        })
939        .collect::<Vec<_>>();
940
941    for mesh in meshes {
942        if let Some(PlotElement::Mesh(plot)) = figure.get_plot_mut(mesh.plot_index) {
943            plot.set_face_alpha(0.14);
944            plot.set_edge_alpha(0.72);
945            plot.set_edge_width(plot.edge_width().max(0.28));
946        }
947    }
948
949    for (mut plot, displacements) in clones.into_iter().zip(&overlay.chunks) {
950        plot.set_face_alpha(0.72);
951        plot.set_edge_alpha(0.45);
952        plot.set_face_color(Vec4::new(0.33, 0.66, 0.96, 1.0));
953        plot.set_edge_color(Vec4::new(0.90, 0.95, 1.0, 0.55));
954        let mut deformation = MeshDeformation::new(overlay.field_id.clone(), displacements.clone());
955        deformation.label = Some(overlay.label.clone());
956        deformation.scale = overlay.scale;
957        if let Err(err) = plot.set_deformation(Some(deformation)) {
958            warnings.push(format!(
959                "failed to attach deformation field '{}' to mesh: {err}",
960                overlay.field_id
961            ));
962            continue;
963        }
964        figure.add_mesh_plot(*plot);
965    }
966}
967
968fn line_figure(
969    kind: AnalysisGeneratedFigureKind,
970    title: &str,
971    x_label: &str,
972    y_label: &str,
973    series: Vec<(String, Vec<f64>, Vec<f64>, Vec4)>,
974    field_ids: Vec<String>,
975    y_log: bool,
976) -> AnalysisGeneratedFigure {
977    let mut figure = Figure::new()
978        .with_title(title)
979        .with_labels(x_label, y_label)
980        .with_grid(true);
981    if y_log {
982        figure = figure.with_ylog(true);
983    }
984    let mut warnings = Vec::new();
985    for (label, x, y, color) in series {
986        if x.is_empty() || y.is_empty() || x.len() != y.len() {
987            continue;
988        }
989        match LinePlot::new(x, y) {
990            Ok(mut line) => {
991                line.label = Some(label);
992                line.color = color;
993                line.line_width = 1.8;
994                figure.add_line_plot(line);
995            }
996            Err(err) => warnings.push(format!("failed to create line series: {err}")),
997        }
998    }
999    AnalysisGeneratedFigure {
1000        kind,
1001        title: title.to_string(),
1002        field_ids,
1003        warnings,
1004        figure,
1005    }
1006}
1007
1008fn warning_line_figure(
1009    kind: AnalysisGeneratedFigureKind,
1010    title: &str,
1011    warning: String,
1012) -> AnalysisGeneratedFigure {
1013    let mut figure = Figure::new()
1014        .with_title(title)
1015        .with_labels("Step", "Value")
1016        .with_grid(true);
1017    if let Ok(mut line) = LinePlot::new(vec![0.0, 1.0], vec![0.0, 0.0]) {
1018        line.label = Some("No renderable mesh".to_string());
1019        figure.add_line_plot(line);
1020    }
1021    AnalysisGeneratedFigure {
1022        kind,
1023        title: title.to_string(),
1024        field_ids: Vec::new(),
1025        warnings: vec![warning],
1026        figure,
1027    }
1028}
1029
1030fn previous_run_of_kind(current: &AnalysisRunResult) -> Result<Option<AnalysisRunResult>, String> {
1031    let current_kind = run_kind(current);
1032    let mut candidates = storage::list_run_results()?
1033        .into_iter()
1034        .filter(|run| run.run_id != current.run_id && run_kind(run) == current_kind)
1035        .collect::<Vec<_>>();
1036    candidates.sort_by(|a, b| b.run_id.cmp(&a.run_id));
1037    Ok(candidates.into_iter().next())
1038}
1039
1040fn host_values(field: &AnalysisField) -> Option<&[f64]> {
1041    match &field.values {
1042        AnalysisFieldValues::HostF64(values) => Some(values.as_slice()),
1043        AnalysisFieldValues::DeviceRef(_) => None,
1044    }
1045}
1046
1047fn vectors_for_count(field: &AnalysisField, count: usize) -> Option<Vec<Vec3>> {
1048    if count == 0 {
1049        return None;
1050    }
1051    let values = host_values(field)?;
1052    if values.len() == count * 3 {
1053        return values
1054            .chunks_exact(3)
1055            .map(|chunk| {
1056                Some(Vec3::new(
1057                    f64_to_f32(chunk[0])?,
1058                    f64_to_f32(chunk[1])?,
1059                    f64_to_f32(chunk[2])?,
1060                ))
1061            })
1062            .collect::<Option<Vec<_>>>();
1063    }
1064    match field.shape.as_slice() {
1065        [rows, cols] if *rows == count && *cols == 2 && values.len() == count * 2 => values
1066            .chunks_exact(2)
1067            .map(|chunk| Some(Vec3::new(f64_to_f32(chunk[0])?, f64_to_f32(chunk[1])?, 0.0)))
1068            .collect::<Option<Vec<_>>>(),
1069        [rows, cols] if *rows == 2 && *cols == count && values.len() == count * 2 => {
1070            let mut vectors = Vec::with_capacity(count);
1071            for idx in 0..count {
1072                vectors.push(Vec3::new(
1073                    f64_to_f32(values[idx])?,
1074                    f64_to_f32(values[count + idx])?,
1075                    0.0,
1076                ));
1077            }
1078            Some(vectors)
1079        }
1080        [rows, cols] if *rows == 3 && *cols == count && values.len() == count * 3 => {
1081            let mut vectors = Vec::with_capacity(count);
1082            for idx in 0..count {
1083                vectors.push(Vec3::new(
1084                    f64_to_f32(values[idx])?,
1085                    f64_to_f32(values[count + idx])?,
1086                    f64_to_f32(values[count * 2 + idx])?,
1087                ));
1088            }
1089            Some(vectors)
1090        }
1091        _ => None,
1092    }
1093}
1094
1095fn split_f32<I>(values: &[f32], lengths: I) -> Option<Vec<Vec<f32>>>
1096where
1097    I: Iterator<Item = usize>,
1098{
1099    let mut offset = 0usize;
1100    let mut chunks = Vec::new();
1101    for len in lengths {
1102        let end = offset.checked_add(len)?;
1103        chunks.push(values.get(offset..end)?.to_vec());
1104        offset = end;
1105    }
1106    if offset == values.len() {
1107        Some(chunks)
1108    } else {
1109        None
1110    }
1111}
1112
1113fn split_vec3<I>(values: &[Vec3], lengths: I) -> Option<Vec<Vec<Vec3>>>
1114where
1115    I: Iterator<Item = usize>,
1116{
1117    let mut offset = 0usize;
1118    let mut chunks = Vec::new();
1119    for len in lengths {
1120        let end = offset.checked_add(len)?;
1121        chunks.push(values.get(offset..end)?.to_vec());
1122        offset = end;
1123    }
1124    if offset == values.len() {
1125        Some(chunks)
1126    } else {
1127        None
1128    }
1129}
1130
1131fn finite_limits(values: &[f32]) -> Option<[f32; 2]> {
1132    let mut min = f32::INFINITY;
1133    let mut max = f32::NEG_INFINITY;
1134    for value in values.iter().copied().filter(|value| value.is_finite()) {
1135        min = min.min(value);
1136        max = max.max(value);
1137    }
1138    if min.is_finite() && max.is_finite() {
1139        Some([min, max])
1140    } else {
1141        None
1142    }
1143}
1144
1145fn f64_to_f32(value: f64) -> Option<f32> {
1146    if !value.is_finite() || value > f32::MAX as f64 || value < f32::MIN as f64 {
1147        None
1148    } else {
1149        Some(value as f32)
1150    }
1151}
1152
1153fn is_deformation_candidate(field_id: &str) -> bool {
1154    let normalized = field_id.to_ascii_lowercase();
1155    normalized.contains("displacement") || normalized.contains("mode_shape")
1156}
1157
1158fn deformation_scale(vectors: &[Vec3], figure: &Figure) -> f32 {
1159    let max_displacement = vectors
1160        .iter()
1161        .map(|vector| vector.length())
1162        .fold(0.0_f32, f32::max);
1163    if !max_displacement.is_finite() || max_displacement <= f32::EPSILON {
1164        return 1.0;
1165    }
1166    let mut min = Vec3::splat(f32::INFINITY);
1167    let mut max = Vec3::splat(f32::NEG_INFINITY);
1168    for plot in figure.plots() {
1169        if let PlotElement::Mesh(mesh) = plot {
1170            for vertex in mesh.vertices() {
1171                min = min.min(*vertex);
1172                max = max.max(*vertex);
1173            }
1174        }
1175    }
1176    let diagonal = (max - min).length();
1177    if !diagonal.is_finite() || diagonal <= f32::EPSILON {
1178        return 1.0;
1179    }
1180    ((diagonal * 0.08) / max_displacement).clamp(0.1, 1.0e6)
1181}
1182
1183fn vector_scale(vectors: &[Vec3]) -> f32 {
1184    let max_vector = vectors
1185        .iter()
1186        .map(|vector| vector.length())
1187        .fold(0.0_f32, f32::max);
1188    if max_vector.is_finite() && max_vector > f32::EPSILON {
1189        (1.0 / max_vector).clamp(0.001, 1.0e6)
1190    } else {
1191        1.0
1192    }
1193}
1194
1195fn glyph_stride(count: usize, max_glyphs: usize) -> usize {
1196    if max_glyphs == 0 || count <= max_glyphs {
1197        1
1198    } else {
1199        count.div_ceil(max_glyphs)
1200    }
1201}
1202
1203fn axis_or_index(axis: &[f64], count: usize) -> Vec<f64> {
1204    if axis.len() >= count {
1205        axis.iter().copied().take(count).collect()
1206    } else {
1207        index_axis(count, 1.0)
1208    }
1209}
1210
1211fn index_axis(count: usize, start: f64) -> Vec<f64> {
1212    (0..count).map(|idx| start + idx as f64).collect()
1213}
1214
1215fn push_bar_value(
1216    labels: &mut Vec<String>,
1217    values: &mut Vec<f64>,
1218    label: &str,
1219    value: Option<f64>,
1220) {
1221    if let Some(value) = value.filter(|value| value.is_finite()) {
1222        labels.push(label.to_string());
1223        values.push(value);
1224    }
1225}
1226
1227fn geometry_surface_mesh_bytes(geometry: &runmat_geometry_core::GeometryAsset) -> usize {
1228    geometry
1229        .surface_meshes
1230        .iter()
1231        .map(|mesh| {
1232            mesh.vertices.len() * 3 * std::mem::size_of::<f32>()
1233                + mesh.triangles.len() * 3 * std::mem::size_of::<u32>()
1234        })
1235        .sum()
1236}
1237
1238fn render_topology_has_meshes(topology: &AnalysisRenderTopology) -> bool {
1239    topology
1240        .meshes
1241        .iter()
1242        .any(|mesh| !mesh.vertices.is_empty() && !mesh.triangles.is_empty())
1243}
1244
1245fn render_topology_mesh_bytes(topology: &AnalysisRenderTopology) -> usize {
1246    topology
1247        .meshes
1248        .iter()
1249        .map(|mesh| {
1250            mesh.vertices.len() * 3 * std::mem::size_of::<f32>()
1251                + mesh.triangles.len() * 3 * std::mem::size_of::<u32>()
1252        })
1253        .sum()
1254}
1255
1256fn run_kind_label(kind: AnalysisRunKind) -> &'static str {
1257    match kind {
1258        AnalysisRunKind::LinearStatic => "Linear static",
1259        AnalysisRunKind::Modal => "Modal",
1260        AnalysisRunKind::Acoustic => "Acoustic",
1261        AnalysisRunKind::Thermal => "Thermal",
1262        AnalysisRunKind::Transient => "Transient",
1263        AnalysisRunKind::Cfd => "CFD",
1264        AnalysisRunKind::Cht => "CHT",
1265        AnalysisRunKind::Fsi => "FSI",
1266        AnalysisRunKind::Nonlinear => "Nonlinear",
1267        AnalysisRunKind::Electromagnetic => "Electromagnetic",
1268    }
1269}