Skip to main content

runmat_runtime/analysis/
figures.rs

1use glam::{Vec3, Vec4};
2use runmat_analysis_core::{
3    AnalysisField, AnalysisFieldValues, AnalysisModel, BoundaryConditionKind, LoadKind,
4};
5use runmat_analysis_fea::contracts::{
6    FEA_FIELD_STRUCTURAL_REACTION_FORCE, FEA_FIELD_STRUCTURAL_REACTION_MOMENT,
7    FEA_FIELD_STRUCTURAL_RESIDUAL_NORM, FEA_FIELD_STRUCTURAL_TOTAL_STRAIN_ENERGY,
8};
9use runmat_geometry_core::UnitSystem;
10use runmat_plot::plots::{
11    BarChart, Figure, LinePlot, MeshDeformation, MeshEdgeMode, MeshFieldLocation, MeshPlot,
12    MeshRegion, MeshScalarField, MeshTriangleRange, MeshVectorField, PlotElement,
13};
14
15use super::contracts::{
16    AnalysisFieldDescriptor, AnalysisFieldKind, AnalysisFieldLocation, AnalysisRenderTopology,
17    AnalysisResultsCompareData, AnalysisResultsCompareQuery, AnalysisRunKind, AnalysisRunResult,
18    AnalysisStudySpec, AnalysisTrendsData, AnalysisTrendsQuery,
19};
20use super::{analysis_results_compare_op, analysis_trends_op, collect_analysis_result_fields};
21use super::{run_kind, storage};
22use crate::geometry::{geometry_preview_figure, GeometryPreviewFigureOptions};
23use crate::operations::OperationContext;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum AnalysisGeneratedFigureKind {
27    MeshResult,
28    Summary,
29    Convergence,
30    Modal,
31    Electromagnetic,
32    Comparison,
33    Trend,
34}
35
36#[derive(Debug, Clone)]
37pub struct AnalysisGeneratedFigure {
38    pub kind: AnalysisGeneratedFigureKind,
39    pub title: String,
40    pub field_ids: Vec<String>,
41    pub topology_ids: Vec<String>,
42    pub warnings: Vec<String>,
43    pub figure: Figure,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum AnalysisFigureMeshSource {
48    Auto,
49    Solver,
50    Cad,
51    CadReference,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub struct AnalysisFigureGenerationOptions {
56    pub max_overlay_values: usize,
57    pub max_vector_glyphs: usize,
58    pub max_mesh_result_figures: usize,
59    pub max_mesh_geometry_bytes: usize,
60    pub edge_overlay_triangle_limit: usize,
61    pub mesh_source: AnalysisFigureMeshSource,
62    pub show_solver_mesh_edges: bool,
63    pub apply_deformation_overlay: bool,
64    pub include_comparison: bool,
65    pub include_trends: bool,
66}
67
68impl Default for AnalysisFigureGenerationOptions {
69    fn default() -> Self {
70        Self {
71            max_overlay_values: 1_500_000,
72            max_vector_glyphs: 40_000,
73            max_mesh_result_figures: 4,
74            max_mesh_geometry_bytes: 256 * 1024 * 1024,
75            edge_overlay_triangle_limit: 250_000,
76            mesh_source: AnalysisFigureMeshSource::Auto,
77            show_solver_mesh_edges: false,
78            apply_deformation_overlay: true,
79            include_comparison: true,
80            include_trends: true,
81        }
82    }
83}
84
85#[derive(Debug, Clone)]
86struct MeshCounts {
87    plot_index: usize,
88    vertices: usize,
89    triangles: usize,
90    vertex_volume_node_indices: Vec<Option<usize>>,
91    triangle_volume_element_indices: Vec<Option<usize>>,
92}
93
94#[derive(Debug, Clone)]
95struct ScalarOverlay {
96    field_id: String,
97    label: String,
98    location: MeshFieldLocation,
99    chunks: Vec<Vec<f32>>,
100}
101
102#[derive(Debug, Clone)]
103struct VectorOverlay {
104    field_id: String,
105    label: String,
106    location: MeshFieldLocation,
107    chunks: Vec<Vec<Vec3>>,
108    stride: usize,
109}
110
111#[derive(Debug, Clone)]
112struct DeformationOverlay {
113    field_id: String,
114    label: String,
115    chunks: Vec<Vec<Vec3>>,
116    scale: f32,
117}
118
119pub fn analysis_generate_study_run_figures(
120    study: &AnalysisStudySpec,
121    run_id: &str,
122    options: AnalysisFigureGenerationOptions,
123) -> Result<Vec<AnalysisGeneratedFigure>, String> {
124    let current = storage::load_run_result(run_id)?
125        .ok_or_else(|| format!("FEA run_id '{run_id}' was not found"))?;
126    let mut figures =
127        generate_run_figures(&study.geometry, study.model.as_ref(), &current, options);
128
129    if options.include_comparison {
130        if let Some(previous) = previous_run_of_kind(&current)? {
131            let query = AnalysisResultsCompareQuery {
132                baseline_run_id: previous.run_id.clone(),
133                candidate_run_id: current.run_id.clone(),
134            };
135            if let Ok(envelope) =
136                analysis_results_compare_op(query, OperationContext::new(None, None))
137            {
138                if let Some(figure) = comparison_figure(&envelope.data) {
139                    figures.push(figure);
140                }
141            }
142        }
143    }
144
145    if options.include_trends {
146        if let Ok(envelope) = analysis_trends_op(
147            AnalysisTrendsQuery::default(),
148            OperationContext::new(None, None),
149        ) {
150            figures.extend(trend_figures(&envelope.data));
151        }
152    }
153
154    Ok(figures)
155}
156
157fn generate_run_figures(
158    geometry: &runmat_geometry_core::GeometryAsset,
159    model: Option<&AnalysisModel>,
160    run: &AnalysisRunResult,
161    options: AnalysisFigureGenerationOptions,
162) -> Vec<AnalysisGeneratedFigure> {
163    let mut figures = Vec::new();
164    figures.extend(mesh_result_figures(geometry, model, run, options));
165    figures.extend(summary_figures(run));
166    figures.extend(convergence_figures(run));
167    figures
168}
169
170fn mesh_result_figures(
171    geometry: &runmat_geometry_core::GeometryAsset,
172    model: Option<&AnalysisModel>,
173    run: &AnalysisRunResult,
174    options: AnalysisFigureGenerationOptions,
175) -> Vec<AnalysisGeneratedFigure> {
176    let render_topology = run
177        .render_topology
178        .as_ref()
179        .filter(|topology| render_topology_has_meshes(topology));
180    if (render_topology.is_none() && geometry.surface_meshes.is_empty())
181        || options.max_mesh_result_figures == 0
182    {
183        return Vec::new();
184    }
185
186    let estimated_geometry_bytes = render_topology
187        .map(render_topology_mesh_bytes)
188        .unwrap_or_else(|| geometry_surface_mesh_bytes(geometry));
189    let mut per_run_mesh_figure_limit = options.max_mesh_result_figures;
190    let mut shared_warnings = Vec::new();
191    if estimated_geometry_bytes > options.max_mesh_geometry_bytes {
192        per_run_mesh_figure_limit = 1;
193        shared_warnings.push(format!(
194            "mesh result figure count capped to 1 because the render mesh is approximately {} bytes",
195            estimated_geometry_bytes
196        ));
197    }
198
199    let fields = collect_analysis_result_fields(run);
200    let probe =
201        match base_mesh_figure_for_run_source(geometry, render_topology, "FEA result", options) {
202            Some(figure) => figure,
203            None => {
204                return vec![warning_line_figure(
205                    AnalysisGeneratedFigureKind::MeshResult,
206                    "FEA result visualization",
207                    "Solver render topology and geometry preview are unavailable".to_string(),
208                )];
209            }
210        };
211    let mesh_counts = collect_mesh_counts_with_topology(&probe, render_topology);
212    if mesh_counts.is_empty() {
213        return Vec::new();
214    }
215
216    let deformation = if options.apply_deformation_overlay {
217        fields
218            .iter()
219            .filter(|field| is_deformation_candidate(&field.field_id))
220            .find_map(|field| deformation_overlay(field, &mesh_counts, &probe, options))
221    } else {
222        None
223    };
224
225    let mut figures = Vec::new();
226    if let Some(deformation) = deformation.as_ref() {
227        if figures.len() < per_run_mesh_figure_limit {
228            if let Some(mut figure) = base_mesh_figure(
229                geometry,
230                render_topology,
231                format!("FEA deformed shape: {}", deformation.field_id),
232                options,
233            ) {
234                let mut warnings = shared_warnings.clone();
235                append_deformed_mesh_overlay(&mut figure, deformation, &mesh_counts, &mut warnings);
236                figures.push(AnalysisGeneratedFigure {
237                    kind: AnalysisGeneratedFigureKind::MeshResult,
238                    title: format!("FEA deformed shape: {}", deformation.field_id),
239                    field_ids: vec![deformation.field_id.clone()],
240                    topology_ids: topology_ids_for_field_ids([deformation.field_id.as_str()]),
241                    warnings,
242                    figure,
243                });
244            }
245        }
246    }
247
248    let mut topology_warnings = Vec::new();
249    for field in &fields {
250        if figures.len() >= per_run_mesh_figure_limit {
251            break;
252        }
253        let Some(scalar) = scalar_overlay(field, &mesh_counts, options) else {
254            if let Some(warning) = field_topology_mismatch_warning(field, &mesh_counts) {
255                topology_warnings.push(warning);
256            }
257            continue;
258        };
259        let title = format!("FEA scalar field: {}", scalar.field_id);
260        let Some(mut figure) = base_mesh_figure(geometry, render_topology, title.clone(), options)
261        else {
262            continue;
263        };
264        let mut warnings = shared_warnings.clone();
265        apply_scalar_overlay(&mut figure, &scalar, &mesh_counts, &mut warnings);
266        if let Some(deformation) = deformation.as_ref() {
267            apply_deformation_to_existing_meshes(
268                &mut figure,
269                deformation,
270                &mesh_counts,
271                &mut warnings,
272            );
273        }
274        figure.colorbar_enabled = true;
275        figures.push(AnalysisGeneratedFigure {
276            kind: AnalysisGeneratedFigureKind::MeshResult,
277            title,
278            field_ids: vec![scalar.field_id],
279            topology_ids: topology_ids_for_fields(std::iter::once(field)),
280            warnings,
281            figure,
282        });
283    }
284
285    for field in &fields {
286        if figures.len() >= per_run_mesh_figure_limit {
287            break;
288        }
289        let Some(vector) = vector_overlay(field, &mesh_counts, options) else {
290            if let Some(warning) = field_topology_mismatch_warning(field, &mesh_counts) {
291                topology_warnings.push(warning);
292            }
293            continue;
294        };
295        let title = format!("FEA vector field: {}", vector.field_id);
296        let Some(mut figure) = base_mesh_figure(geometry, render_topology, title.clone(), options)
297        else {
298            continue;
299        };
300        let mut warnings = shared_warnings.clone();
301        apply_vector_overlay(&mut figure, &vector, &mesh_counts, &mut warnings);
302        if let Some(deformation) = deformation.as_ref() {
303            apply_deformation_to_existing_meshes(
304                &mut figure,
305                deformation,
306                &mesh_counts,
307                &mut warnings,
308            );
309        }
310        figures.push(AnalysisGeneratedFigure {
311            kind: AnalysisGeneratedFigureKind::MeshResult,
312            title,
313            field_ids: vec![vector.field_id],
314            topology_ids: topology_ids_for_fields(std::iter::once(field)),
315            warnings,
316            figure,
317        });
318    }
319
320    if figures.len() < per_run_mesh_figure_limit {
321        if let Some(figure) = boundary_region_figure(
322            geometry,
323            render_topology,
324            model,
325            options,
326            shared_warnings.clone(),
327        ) {
328            figures.push(figure);
329        }
330    }
331
332    if figures.is_empty() {
333        if let Some(warning) = topology_warnings.first() {
334            figures.push(warning_line_figure(
335                AnalysisGeneratedFigureKind::MeshResult,
336                "FEA field topology mismatch",
337                warning.clone(),
338            ));
339            return figures;
340        }
341        if let Some(figure) = base_mesh_figure(
342            geometry,
343            render_topology,
344            format!("FEA geometry result: {}", run.run_id),
345            options,
346        ) {
347            figures.push(AnalysisGeneratedFigure {
348                kind: AnalysisGeneratedFigureKind::MeshResult,
349                title: format!("FEA geometry result: {}", run.run_id),
350                field_ids: Vec::new(),
351                topology_ids: Vec::new(),
352                warnings: shared_warnings,
353                figure,
354            });
355        }
356    }
357
358    figures
359}
360
361fn boundary_region_figure(
362    geometry: &runmat_geometry_core::GeometryAsset,
363    render_topology: Option<&AnalysisRenderTopology>,
364    model: Option<&AnalysisModel>,
365    options: AnalysisFigureGenerationOptions,
366    mut warnings: Vec<String>,
367) -> Option<AnalysisGeneratedFigure> {
368    let model = model?;
369    let regions = authored_boundary_regions(model);
370    if regions.is_empty() {
371        return None;
372    }
373
374    let mut figure = base_mesh_figure(
375        geometry,
376        render_topology,
377        "FEA boundary regions".to_string(),
378        options,
379    )?;
380
381    let mut present = Vec::<String>::new();
382    for index in 0..figure.plots().count() {
383        let Some(PlotElement::Mesh(mesh)) = figure.get_plot_mut(index) else {
384            continue;
385        };
386        for region in &regions {
387            if mesh
388                .regions()
389                .iter()
390                .any(|mesh_region| mesh_region.region_id == region.region_id)
391            {
392                if !present.iter().any(|existing| existing == &region.region_id) {
393                    present.push(region.region_id.clone());
394                }
395                if mesh.highlighted_region_id().is_none() {
396                    mesh.set_highlighted_region_id(Some(region.region_id.clone()));
397                    mesh.set_highlight_color(region.highlight_color);
398                }
399            }
400        }
401        attach_authored_load_vectors(mesh, &regions, &mut warnings);
402    }
403
404    for region in &regions {
405        if !present.iter().any(|existing| existing == &region.region_id) {
406            warnings.push(format!(
407                "authored {} region '{}' is not present in solver render topology",
408                region.role, region.region_id
409            ));
410        }
411    }
412
413    if present.is_empty() && warnings.is_empty() {
414        return None;
415    }
416
417    Some(AnalysisGeneratedFigure {
418        kind: AnalysisGeneratedFigureKind::MeshResult,
419        title: "FEA boundary regions".to_string(),
420        field_ids: Vec::new(),
421        topology_ids: vec!["analysis_mesh".to_string()],
422        warnings,
423        figure,
424    })
425}
426
427#[derive(Debug, Clone)]
428struct AuthoredBoundaryRegion {
429    region_id: String,
430    role: &'static str,
431    highlight_color: Vec4,
432    load_vector: Option<Vec3>,
433    pressure_sign: Option<f32>,
434}
435
436fn authored_boundary_regions(model: &AnalysisModel) -> Vec<AuthoredBoundaryRegion> {
437    let mut regions = Vec::<AuthoredBoundaryRegion>::new();
438    for load in &model.loads {
439        if !load_kind_requires_boundary_region(&load.kind) {
440            continue;
441        }
442        push_authored_boundary_region(
443            &mut regions,
444            &load.region_id,
445            "load",
446            Vec4::new(0.98, 0.30, 0.54, 1.0),
447            load_vector_for_kind(&load.kind),
448            pressure_sign_for_kind(&load.kind),
449        );
450    }
451    for boundary_condition in &model.boundary_conditions {
452        if !boundary_condition_kind_uses_boundary_region(&boundary_condition.kind) {
453            continue;
454        }
455        push_authored_boundary_region(
456            &mut regions,
457            &boundary_condition.region_id,
458            "constraint",
459            Vec4::new(0.18, 0.78, 0.48, 1.0),
460            None,
461            None,
462        );
463    }
464    regions
465}
466
467fn push_authored_boundary_region(
468    regions: &mut Vec<AuthoredBoundaryRegion>,
469    region_id: &str,
470    role: &'static str,
471    highlight_color: Vec4,
472    load_vector: Option<Vec3>,
473    pressure_sign: Option<f32>,
474) {
475    if region_id.trim().is_empty()
476        || regions
477            .iter()
478            .any(|existing| existing.region_id == region_id && existing.role == role)
479    {
480        return;
481    }
482    regions.push(AuthoredBoundaryRegion {
483        region_id: region_id.to_string(),
484        role,
485        highlight_color,
486        load_vector,
487        pressure_sign,
488    });
489}
490
491fn load_kind_requires_boundary_region(kind: &LoadKind) -> bool {
492    matches!(
493        kind,
494        LoadKind::Force { .. }
495            | LoadKind::Moment { .. }
496            | LoadKind::Wrench { .. }
497            | LoadKind::Pressure { .. }
498    )
499}
500
501fn boundary_condition_kind_uses_boundary_region(_kind: &BoundaryConditionKind) -> bool {
502    true
503}
504
505fn load_vector_for_kind(kind: &LoadKind) -> Option<Vec3> {
506    let vector = match kind {
507        LoadKind::Force { fx, fy, fz } => {
508            Vec3::new(f64_to_f32(*fx)?, f64_to_f32(*fy)?, f64_to_f32(*fz)?)
509        }
510        LoadKind::Moment { mx, my, mz } => {
511            Vec3::new(f64_to_f32(*mx)?, f64_to_f32(*my)?, f64_to_f32(*mz)?)
512        }
513        LoadKind::Wrench { fx, fy, fz, .. } => {
514            Vec3::new(f64_to_f32(*fx)?, f64_to_f32(*fy)?, f64_to_f32(*fz)?)
515        }
516        _ => return None,
517    };
518    if vector.length_squared().is_finite() && vector.length_squared() > f32::EPSILON {
519        Some(vector.normalize())
520    } else if let LoadKind::Wrench { mx, my, mz, .. } = kind {
521        let moment = Vec3::new(f64_to_f32(*mx)?, f64_to_f32(*my)?, f64_to_f32(*mz)?);
522        if moment.length_squared().is_finite() && moment.length_squared() > f32::EPSILON {
523            Some(moment.normalize())
524        } else {
525            None
526        }
527    } else {
528        None
529    }
530}
531
532fn pressure_sign_for_kind(kind: &LoadKind) -> Option<f32> {
533    match kind {
534        LoadKind::Pressure { magnitude_pa } => {
535            let magnitude = f64_to_f32(*magnitude_pa)?;
536            if magnitude.is_finite() && magnitude.abs() > f32::EPSILON {
537                Some(-magnitude.signum())
538            } else {
539                None
540            }
541        }
542        _ => None,
543    }
544}
545
546fn attach_authored_load_vectors(
547    mesh: &mut MeshPlot,
548    regions: &[AuthoredBoundaryRegion],
549    warnings: &mut Vec<String>,
550) {
551    let mut vectors = vec![Vec3::ZERO; mesh.triangles().len()];
552    let mut has_vector = false;
553    for region in regions {
554        if region.load_vector.is_none() && region.pressure_sign.is_none() {
555            continue;
556        }
557        let Some(mesh_region) = mesh
558            .regions()
559            .iter()
560            .find(|mesh_region| mesh_region.region_id == region.region_id)
561        else {
562            continue;
563        };
564        for triangle_index in 0..vectors.len() {
565            if mesh_region.contains_triangle(triangle_index as u32) {
566                let Some(vector) = region.load_vector.or_else(|| {
567                    region.pressure_sign.and_then(|sign| {
568                        triangle_unit_normal(mesh, triangle_index).map(|normal| normal * sign)
569                    })
570                }) else {
571                    continue;
572                };
573                vectors[triangle_index] = vector;
574                has_vector = true;
575            }
576        }
577    }
578    if !has_vector {
579        return;
580    }
581    let mut field = MeshVectorField::new(
582        "authored.boundary_load_direction",
583        MeshFieldLocation::Triangle,
584        vectors,
585    );
586    field.label = Some("Authored boundary load direction".to_string());
587    field.scale = vector_scale(&field.vectors);
588    if let Err(err) = mesh.set_vector_field(Some(field)) {
589        warnings.push(format!(
590            "failed to attach authored boundary load vectors: {err}"
591        ));
592    }
593}
594
595fn triangle_unit_normal(mesh: &MeshPlot, triangle_index: usize) -> Option<Vec3> {
596    let triangle = *mesh.triangles().get(triangle_index)?;
597    let a = *mesh.vertices().get(triangle[0] as usize)?;
598    let b = *mesh.vertices().get(triangle[1] as usize)?;
599    let c = *mesh.vertices().get(triangle[2] as usize)?;
600    let normal = (b - a).cross(c - a);
601    if normal.length_squared().is_finite() && normal.length_squared() > f32::EPSILON {
602        Some(normal.normalize())
603    } else {
604        None
605    }
606}
607
608fn summary_figures(run: &AnalysisRunResult) -> Vec<AnalysisGeneratedFigure> {
609    let fields = collect_analysis_result_fields(run);
610    let Some(figure) = structural_result_summary_figure(&fields) else {
611        return Vec::new();
612    };
613    vec![figure]
614}
615
616fn structural_result_summary_figure(fields: &[AnalysisField]) -> Option<AnalysisGeneratedFigure> {
617    let mut labels = Vec::new();
618    let mut values = Vec::new();
619    let mut field_ids = Vec::new();
620
621    if let Some(value) = vector_field_total_magnitude(fields, FEA_FIELD_STRUCTURAL_REACTION_FORCE) {
622        labels.push("Reaction force norm".to_string());
623        values.push(value);
624        field_ids.push(FEA_FIELD_STRUCTURAL_REACTION_FORCE.to_string());
625    }
626    if let Some(value) = vector_field_total_magnitude(fields, FEA_FIELD_STRUCTURAL_REACTION_MOMENT)
627    {
628        labels.push("Reaction moment norm".to_string());
629        values.push(value);
630        field_ids.push(FEA_FIELD_STRUCTURAL_REACTION_MOMENT.to_string());
631    }
632    if let Some(value) = scalar_field_value(fields, FEA_FIELD_STRUCTURAL_TOTAL_STRAIN_ENERGY) {
633        labels.push("Total strain energy".to_string());
634        values.push(value);
635        field_ids.push(FEA_FIELD_STRUCTURAL_TOTAL_STRAIN_ENERGY.to_string());
636    }
637    if let Some(value) = scalar_field_value(fields, FEA_FIELD_STRUCTURAL_RESIDUAL_NORM) {
638        labels.push("Residual norm".to_string());
639        values.push(value);
640        field_ids.push(FEA_FIELD_STRUCTURAL_RESIDUAL_NORM.to_string());
641    }
642
643    if labels.is_empty() {
644        return None;
645    }
646    let mut chart = BarChart::new(labels, values).ok()?;
647    chart.label = Some("Structural summary".to_string());
648    chart.color = Vec4::new(0.30, 0.64, 0.58, 1.0);
649    let mut figure = Figure::new()
650        .with_title("FEA structural result summary")
651        .with_labels("Metric", "Value")
652        .with_grid(true);
653    figure.add_bar_chart(chart);
654    Some(AnalysisGeneratedFigure {
655        kind: AnalysisGeneratedFigureKind::Summary,
656        title: "FEA structural result summary".to_string(),
657        field_ids,
658        topology_ids: Vec::new(),
659        warnings: Vec::new(),
660        figure,
661    })
662}
663
664fn convergence_figures(run: &AnalysisRunResult) -> Vec<AnalysisGeneratedFigure> {
665    let mut figures = Vec::new();
666    if let Some(modal) = run.modal_results.as_ref() {
667        if !modal.eigenvalues_hz.is_empty() {
668            let labels = (1..=modal.eigenvalues_hz.len())
669                .map(|idx| format!("Mode {idx}"))
670                .collect::<Vec<_>>();
671            if let Ok(mut chart) = BarChart::new(labels, modal.eigenvalues_hz.clone()) {
672                chart.label = Some("Frequency".to_string());
673                chart.color = Vec4::new(0.33, 0.66, 0.96, 1.0);
674                let mut figure = Figure::new()
675                    .with_title("FEA modal frequencies")
676                    .with_labels("Mode", "Frequency (Hz)")
677                    .with_grid(true);
678                figure.add_bar_chart(chart);
679                figures.push(AnalysisGeneratedFigure {
680                    kind: AnalysisGeneratedFigureKind::Modal,
681                    title: "FEA modal frequencies".to_string(),
682                    field_ids: modal
683                        .mode_shapes
684                        .iter()
685                        .map(|field| field.field_id.clone())
686                        .collect(),
687                    topology_ids: topology_ids_for_fields(modal.mode_shapes.iter()),
688                    warnings: Vec::new(),
689                    figure,
690                });
691            }
692        }
693        if !modal.residual_norms.is_empty() {
694            figures.push(line_figure(
695                AnalysisGeneratedFigureKind::Convergence,
696                "FEA modal residuals",
697                "Mode",
698                "Residual norm",
699                vec![(
700                    "Residual".to_string(),
701                    index_axis(modal.residual_norms.len(), 1.0),
702                    modal.residual_norms.clone(),
703                    Vec4::new(0.93, 0.48, 0.26, 1.0),
704                )],
705                Vec::new(),
706                true,
707            ));
708        }
709    }
710
711    if let Some(thermal) = run.thermal_results.as_ref() {
712        if !thermal.residual_norms.is_empty() {
713            figures.push(line_figure(
714                AnalysisGeneratedFigureKind::Convergence,
715                "FEA thermal convergence",
716                "Time (s)",
717                "Residual norm",
718                vec![(
719                    "Thermal residual".to_string(),
720                    axis_or_index(&thermal.time_points_s, thermal.residual_norms.len()),
721                    thermal.residual_norms.clone(),
722                    Vec4::new(0.92, 0.38, 0.31, 1.0),
723                )],
724                thermal
725                    .temperature_snapshots
726                    .iter()
727                    .map(|field| field.field_id.clone())
728                    .collect(),
729                true,
730            ));
731        }
732    }
733
734    if let Some(transient) = run.transient_results.as_ref() {
735        if !transient.residual_norms.is_empty() {
736            figures.push(line_figure(
737                AnalysisGeneratedFigureKind::Convergence,
738                "FEA transient convergence",
739                "Time (s)",
740                "Residual norm",
741                vec![(
742                    "Transient residual".to_string(),
743                    axis_or_index(&transient.time_points_s, transient.residual_norms.len()),
744                    transient.residual_norms.clone(),
745                    Vec4::new(0.35, 0.72, 0.88, 1.0),
746                )],
747                transient
748                    .displacement_snapshots
749                    .iter()
750                    .map(|field| field.field_id.clone())
751                    .collect(),
752                true,
753            ));
754        }
755    }
756
757    if let Some(nonlinear) = run.nonlinear_results.as_ref() {
758        if !nonlinear.residual_norms.is_empty() {
759            figures.push(line_figure(
760                AnalysisGeneratedFigureKind::Convergence,
761                "FEA nonlinear convergence",
762                "Load factor",
763                "Norm",
764                vec![
765                    (
766                        "Residual".to_string(),
767                        axis_or_index(&nonlinear.load_factors, nonlinear.residual_norms.len()),
768                        nonlinear.residual_norms.clone(),
769                        Vec4::new(0.92, 0.38, 0.31, 1.0),
770                    ),
771                    (
772                        "Increment".to_string(),
773                        axis_or_index(&nonlinear.load_factors, nonlinear.increment_norms.len()),
774                        nonlinear.increment_norms.clone(),
775                        Vec4::new(0.33, 0.66, 0.96, 1.0),
776                    ),
777                ],
778                nonlinear
779                    .displacement_snapshots
780                    .iter()
781                    .map(|field| field.field_id.clone())
782                    .collect(),
783                true,
784            ));
785        }
786        if !nonlinear.iteration_counts.is_empty() {
787            figures.push(line_figure(
788                AnalysisGeneratedFigureKind::Convergence,
789                "FEA nonlinear iterations",
790                "Load factor",
791                "Iterations",
792                vec![(
793                    "Iterations".to_string(),
794                    axis_or_index(&nonlinear.load_factors, nonlinear.iteration_counts.len()),
795                    nonlinear
796                        .iteration_counts
797                        .iter()
798                        .map(|value| *value as f64)
799                        .collect(),
800                    Vec4::new(0.73, 0.62, 0.95, 1.0),
801                )],
802                Vec::new(),
803                false,
804            ));
805        }
806    }
807
808    if let Some(em) = run.electromagnetic_results.as_ref() {
809        if !em.sweep_frequency_hz.is_empty() && !em.sweep_peak_flux_density.is_empty() {
810            figures.push(line_figure(
811                AnalysisGeneratedFigureKind::Electromagnetic,
812                "FEA electromagnetic sweep",
813                "Frequency (Hz)",
814                "Peak flux density",
815                vec![(
816                    "Peak flux".to_string(),
817                    axis_or_index(&em.sweep_frequency_hz, em.sweep_peak_flux_density.len()),
818                    em.sweep_peak_flux_density.clone(),
819                    Vec4::new(0.28, 0.74, 0.57, 1.0),
820                )],
821                vec![
822                    em.vector_potential_real.field_id.clone(),
823                    em.magnetic_flux_density_magnitude.field_id.clone(),
824                ],
825                false,
826            ));
827        }
828        if !em.sweep_frequency_hz.is_empty() && !em.sweep_solve_quality.is_empty() {
829            figures.push(line_figure(
830                AnalysisGeneratedFigureKind::Electromagnetic,
831                "FEA electromagnetic solve quality",
832                "Frequency (Hz)",
833                "Solve quality",
834                vec![(
835                    "Solve quality".to_string(),
836                    axis_or_index(&em.sweep_frequency_hz, em.sweep_solve_quality.len()),
837                    em.sweep_solve_quality.clone(),
838                    Vec4::new(0.92, 0.68, 0.28, 1.0),
839                )],
840                vec![
841                    em.vector_potential_real.field_id.clone(),
842                    em.magnetic_flux_density_magnitude.field_id.clone(),
843                ],
844                false,
845            ));
846        }
847    }
848
849    figures
850}
851
852fn comparison_figure(data: &AnalysisResultsCompareData) -> Option<AnalysisGeneratedFigure> {
853    let mut labels = Vec::new();
854    let mut values = Vec::new();
855    push_bar_value(
856        &mut labels,
857        &mut values,
858        "Quality reasons",
859        Some(data.quality_reason_count_delta as f64),
860    );
861    push_bar_value(&mut labels, &mut values, "Solve ms", data.solve_ms_delta);
862    push_bar_value(
863        &mut labels,
864        &mut values,
865        "Failed increments",
866        data.failed_increment_delta.map(|value| value as f64),
867    );
868    push_bar_value(
869        &mut labels,
870        &mut values,
871        "Max iterations",
872        data.max_iteration_delta.map(|value| value as f64),
873    );
874    push_bar_value(
875        &mut labels,
876        &mut values,
877        "Spikes",
878        data.nonlinear_spike_count_delta.map(|value| value as f64),
879    );
880    push_bar_value(
881        &mut labels,
882        &mut values,
883        "Stalls",
884        data.nonlinear_stall_count_delta.map(|value| value as f64),
885    );
886    push_bar_value(
887        &mut labels,
888        &mut values,
889        "Publishable changed",
890        Some(if data.publishable_changed { 1.0 } else { 0.0 }),
891    );
892    push_bar_value(
893        &mut labels,
894        &mut values,
895        "Status changed",
896        Some(if data.run_status_changed { 1.0 } else { 0.0 }),
897    );
898    if labels.is_empty() {
899        return None;
900    }
901    let mut chart = BarChart::new(labels, values).ok()?;
902    chart.label = Some("Delta".to_string());
903    chart.color = Vec4::new(0.77, 0.58, 0.95, 1.0);
904    let mut figure = Figure::new()
905        .with_title("FEA run comparison")
906        .with_labels("Metric", "Candidate minus baseline")
907        .with_grid(true);
908    figure.add_bar_chart(chart);
909    Some(AnalysisGeneratedFigure {
910        kind: AnalysisGeneratedFigureKind::Comparison,
911        title: "FEA run comparison".to_string(),
912        field_ids: Vec::new(),
913        topology_ids: Vec::new(),
914        warnings: Vec::new(),
915        figure,
916    })
917}
918
919fn trend_figures(data: &AnalysisTrendsData) -> Vec<AnalysisGeneratedFigure> {
920    let mut figures = Vec::new();
921    let labels = data
922        .summaries
923        .iter()
924        .map(|summary| run_kind_label(summary.run_kind).to_string())
925        .collect::<Vec<_>>();
926    if labels.is_empty() {
927        return figures;
928    }
929
930    let solve_values = data
931        .summaries
932        .iter()
933        .map(|summary| summary.median_solve_ms.unwrap_or(0.0))
934        .collect::<Vec<_>>();
935    if solve_values.iter().any(|value| *value != 0.0) {
936        if let Ok(mut chart) = BarChart::new(labels.clone(), solve_values) {
937            chart.label = Some("Median solve".to_string());
938            chart.color = Vec4::new(0.31, 0.62, 0.91, 1.0);
939            let mut figure = Figure::new()
940                .with_title("FEA solve time trends")
941                .with_labels("Run family", "Median solve (ms)")
942                .with_grid(true);
943            figure.add_bar_chart(chart);
944            figures.push(AnalysisGeneratedFigure {
945                kind: AnalysisGeneratedFigureKind::Trend,
946                title: "FEA solve time trends".to_string(),
947                field_ids: Vec::new(),
948                topology_ids: Vec::new(),
949                warnings: Vec::new(),
950                figure,
951            });
952        }
953    }
954
955    let publishable_values = data
956        .summaries
957        .iter()
958        .map(|summary| summary.publishable_rate * 100.0)
959        .collect::<Vec<_>>();
960    if let Ok(mut chart) = BarChart::new(labels, publishable_values) {
961        chart.label = Some("Publishable rate".to_string());
962        chart.color = Vec4::new(0.32, 0.74, 0.56, 1.0);
963        let mut figure = Figure::new()
964            .with_title("FEA publishable result trends")
965            .with_labels("Run family", "Publishable (%)")
966            .with_grid(true);
967        figure.add_bar_chart(chart);
968        figures.push(AnalysisGeneratedFigure {
969            kind: AnalysisGeneratedFigureKind::Trend,
970            title: "FEA publishable result trends".to_string(),
971            field_ids: Vec::new(),
972            topology_ids: Vec::new(),
973            warnings: Vec::new(),
974            figure,
975        });
976    }
977
978    figures
979}
980
981fn base_mesh_figure(
982    geometry: &runmat_geometry_core::GeometryAsset,
983    render_topology: Option<&AnalysisRenderTopology>,
984    title: impl Into<String>,
985    options: AnalysisFigureGenerationOptions,
986) -> Option<Figure> {
987    base_mesh_figure_for_run_source(geometry, render_topology, title, options)
988}
989
990fn base_mesh_figure_for_run_source(
991    geometry: &runmat_geometry_core::GeometryAsset,
992    render_topology: Option<&AnalysisRenderTopology>,
993    title: impl Into<String>,
994    options: AnalysisFigureGenerationOptions,
995) -> Option<Figure> {
996    let title = title.into();
997    match options.mesh_source {
998        AnalysisFigureMeshSource::Auto => {
999            if let Some(figure) =
1000                cad_reference_mesh_figure(geometry, render_topology, title.clone(), options)
1001            {
1002                return Some(figure);
1003            }
1004            if let Some(topology) = render_topology {
1005                if let Ok(figure) = render_topology_figure(topology, title.clone(), options) {
1006                    return Some(figure);
1007                }
1008            }
1009            geometry_preview_figure(
1010                geometry,
1011                title,
1012                GeometryPreviewFigureOptions {
1013                    edge_overlay_triangle_limit: options.edge_overlay_triangle_limit,
1014                    ..GeometryPreviewFigureOptions::default()
1015                },
1016            )
1017            .ok()
1018        }
1019        AnalysisFigureMeshSource::Solver => render_topology
1020            .and_then(|topology| render_topology_figure(topology, title, options).ok()),
1021        AnalysisFigureMeshSource::Cad => geometry_preview_figure(
1022            geometry,
1023            title,
1024            GeometryPreviewFigureOptions {
1025                edge_overlay_triangle_limit: options.edge_overlay_triangle_limit,
1026                presentation: crate::geometry::GeometryPreviewPresentation::Cad,
1027                ..GeometryPreviewFigureOptions::default()
1028            },
1029        )
1030        .ok(),
1031        AnalysisFigureMeshSource::CadReference => {
1032            cad_reference_mesh_figure(geometry, render_topology, title.clone(), options).or_else(
1033                || {
1034                    render_topology
1035                        .and_then(|topology| render_topology_figure(topology, title, options).ok())
1036                },
1037            )
1038        }
1039    }
1040}
1041
1042fn cad_reference_mesh_figure(
1043    geometry: &runmat_geometry_core::GeometryAsset,
1044    render_topology: Option<&AnalysisRenderTopology>,
1045    title: impl Into<String>,
1046    options: AnalysisFigureGenerationOptions,
1047) -> Option<Figure> {
1048    let title = title.into();
1049    let mut figure = geometry_preview_figure(
1050        geometry,
1051        title.clone(),
1052        GeometryPreviewFigureOptions {
1053            edge_overlay_triangle_limit: options.edge_overlay_triangle_limit,
1054            presentation: crate::geometry::GeometryPreviewPresentation::Cad,
1055            ..GeometryPreviewFigureOptions::default()
1056        },
1057    )
1058    .ok()?;
1059    normalize_geometry_meshes_to_solver_units(&mut figure, geometry.units);
1060    if let Some(topology) = render_topology {
1061        if let Ok(solver) = render_topology_figure(topology, title, options) {
1062            append_mesh_plots(&mut figure, &solver);
1063        }
1064    }
1065    Some(figure)
1066}
1067
1068fn normalize_geometry_meshes_to_solver_units(figure: &mut Figure, units: UnitSystem) {
1069    let scale = geometry_unit_scale_to_meters(units);
1070    if (scale - 1.0).abs() <= f32::EPSILON {
1071        return;
1072    }
1073    for index in 0..figure.plots().count() {
1074        let Some(PlotElement::Mesh(mesh)) = figure.get_plot_mut(index) else {
1075            continue;
1076        };
1077        let vertices = mesh
1078            .vertices()
1079            .iter()
1080            .map(|vertex| *vertex * scale)
1081            .collect::<Vec<_>>();
1082        let _ = mesh.set_vertices(vertices);
1083    }
1084}
1085
1086fn geometry_unit_scale_to_meters(units: UnitSystem) -> f32 {
1087    match units {
1088        UnitSystem::Unspecified | UnitSystem::Meter => 1.0,
1089        UnitSystem::Millimeter => 0.001,
1090        UnitSystem::Inch => 0.0254,
1091    }
1092}
1093
1094fn append_mesh_plots(target: &mut Figure, source: &Figure) {
1095    for plot in source.plots() {
1096        if let PlotElement::Mesh(mesh) = plot {
1097            target.add_mesh_plot((**mesh).clone());
1098        }
1099    }
1100}
1101
1102fn render_topology_figure(
1103    topology: &AnalysisRenderTopology,
1104    title: impl Into<String>,
1105    options: AnalysisFigureGenerationOptions,
1106) -> Result<Figure, String> {
1107    if !render_topology_has_meshes(topology) {
1108        return Err("solver render topology does not contain renderable meshes".to_string());
1109    }
1110    let mut figure = Figure::new()
1111        .with_title(title)
1112        .with_labels("X", "Y")
1113        .with_grid(true)
1114        .with_axis_equal(true);
1115    figure.z_label = Some("Z".to_string());
1116
1117    for mesh in &topology.meshes {
1118        if mesh.vertices.is_empty() || mesh.triangles.is_empty() {
1119            continue;
1120        }
1121        let vertices = mesh
1122            .vertices
1123            .iter()
1124            .map(|vertex| {
1125                Ok(Vec3::new(
1126                    f64_to_f32(vertex[0]).ok_or_else(|| {
1127                        "solver render topology contains a non-renderable X coordinate".to_string()
1128                    })?,
1129                    f64_to_f32(vertex[1]).ok_or_else(|| {
1130                        "solver render topology contains a non-renderable Y coordinate".to_string()
1131                    })?,
1132                    f64_to_f32(vertex[2]).ok_or_else(|| {
1133                        "solver render topology contains a non-renderable Z coordinate".to_string()
1134                    })?,
1135                ))
1136            })
1137            .collect::<Result<Vec<_>, String>>()?;
1138        let mut plot = MeshPlot::new(vertices, mesh.triangles.clone())?;
1139        plot.set_mesh_id(Some(mesh.mesh_id.clone()));
1140        plot.set_regions(render_mesh_regions(&mesh.regions));
1141        plot.set_label(Some(format!(
1142            "{}: {} solver triangles",
1143            mesh.mesh_id,
1144            mesh.triangles.len()
1145        )));
1146        plot.set_face_color(Vec4::new(0.34, 0.57, 0.82, 1.0));
1147        plot.set_edge_color(Vec4::new(0.88, 0.93, 0.98, 0.82));
1148        plot.set_face_alpha(0.94);
1149        if options.show_solver_mesh_edges
1150            && mesh.triangles.len() <= options.edge_overlay_triangle_limit
1151        {
1152            plot.set_edge_mode(MeshEdgeMode::All);
1153            plot.set_edge_width(0.28);
1154        } else {
1155            plot.set_edge_mode(MeshEdgeMode::None);
1156            plot.set_edge_width(0.0);
1157        }
1158        figure.add_mesh_plot(plot);
1159    }
1160
1161    if collect_mesh_counts(&figure).is_empty() {
1162        Err("solver render topology did not produce any mesh plots".to_string())
1163    } else {
1164        Ok(figure)
1165    }
1166}
1167
1168fn render_mesh_regions(regions: &[super::contracts::AnalysisRenderRegion]) -> Vec<MeshRegion> {
1169    regions
1170        .iter()
1171        .filter_map(|region| {
1172            let ranges = region
1173                .triangle_ranges
1174                .iter()
1175                .filter(|range| range.count > 0)
1176                .map(|range| MeshTriangleRange::new(range.start, range.count))
1177                .collect::<Vec<_>>();
1178            if ranges.is_empty() {
1179                return None;
1180            }
1181            Some(MeshRegion::new(
1182                region.region_id.clone(),
1183                region.label.clone(),
1184                region.tag.clone(),
1185                ranges,
1186            ))
1187        })
1188        .collect()
1189}
1190
1191fn collect_mesh_counts(figure: &Figure) -> Vec<MeshCounts> {
1192    collect_mesh_counts_with_topology(figure, None)
1193}
1194
1195fn collect_mesh_counts_with_topology(
1196    figure: &Figure,
1197    topology: Option<&AnalysisRenderTopology>,
1198) -> Vec<MeshCounts> {
1199    let mut counts = Vec::new();
1200    let mut mesh_ordinal = 0usize;
1201    for (plot_index, plot) in figure.plots().enumerate() {
1202        if let PlotElement::Mesh(mesh) = plot {
1203            let topology_mesh = topology.and_then(|topology| {
1204                topology
1205                    .meshes
1206                    .iter()
1207                    .find(|render_mesh| mesh.mesh_id() == Some(render_mesh.mesh_id.as_str()))
1208                    .or_else(|| {
1209                        if mesh.mesh_id().is_none() {
1210                            topology.meshes.get(mesh_ordinal)
1211                        } else {
1212                            None
1213                        }
1214                    })
1215            });
1216            if topology.is_some() && topology_mesh.is_none() {
1217                continue;
1218            }
1219            let triangle_volume_element_indices = topology_mesh
1220                .filter(|render_mesh| {
1221                    render_mesh.triangle_volume_element_indices.len() == mesh.triangles().len()
1222                })
1223                .map(|render_mesh| render_mesh.triangle_volume_element_indices.clone())
1224                .unwrap_or_default();
1225            let vertex_volume_node_indices = topology_mesh
1226                .filter(|render_mesh| {
1227                    render_mesh.vertex_volume_node_indices.len() == mesh.vertices().len()
1228                })
1229                .map(|render_mesh| render_mesh.vertex_volume_node_indices.clone())
1230                .unwrap_or_default();
1231            counts.push(MeshCounts {
1232                plot_index,
1233                vertices: mesh.vertices().len(),
1234                triangles: mesh.triangles().len(),
1235                vertex_volume_node_indices,
1236                triangle_volume_element_indices,
1237            });
1238            if topology_mesh.is_some() {
1239                mesh_ordinal += 1;
1240            }
1241        }
1242    }
1243    counts
1244}
1245
1246fn field_topology_mismatch_warning(field: &AnalysisField, meshes: &[MeshCounts]) -> Option<String> {
1247    let values = host_values(field)?;
1248    if values.is_empty() || meshes.is_empty() {
1249        return None;
1250    }
1251    let descriptor = AnalysisFieldDescriptor::from_field(field);
1252    let topology_id = descriptor.topology_id.as_deref()?;
1253    let actual_entities = field_entity_count(field, &descriptor, values.len());
1254    if topology_id == "analysis_mesh" {
1255        match descriptor.location {
1256            AnalysisFieldLocation::Element => {
1257                if element_field_maps_to_render_triangles(meshes, actual_entities) {
1258                    return None;
1259                }
1260            }
1261            AnalysisFieldLocation::Node => {
1262                if node_field_maps_to_render_vertices(meshes, actual_entities) {
1263                    return None;
1264                }
1265            }
1266            _ => {}
1267        }
1268    }
1269    let total_vertices = meshes.iter().map(|mesh| mesh.vertices).sum::<usize>();
1270    let total_triangles = meshes.iter().map(|mesh| mesh.triangles).sum::<usize>();
1271    let (location, expected_entities) = match descriptor.location {
1272        AnalysisFieldLocation::Node => ("node", total_vertices),
1273        AnalysisFieldLocation::Element | AnalysisFieldLocation::BoundaryFace => {
1274            ("element", total_triangles)
1275        }
1276        AnalysisFieldLocation::Edge
1277        | AnalysisFieldLocation::InterfaceFace
1278        | AnalysisFieldLocation::Mode
1279        | AnalysisFieldLocation::Global
1280        | AnalysisFieldLocation::Unknown => return None,
1281    };
1282    if actual_entities == expected_entities && topology_id != "analysis_mesh" {
1283        return None;
1284    }
1285    Some(format!(
1286        "field '{}' uses topology_id={} location={} element_kind={} value_count={} render_vertex_count={} render_triangle_count={}; cannot map field to the current render mesh",
1287        field.field_id,
1288        topology_id,
1289        location,
1290        descriptor.element_kind.as_deref().unwrap_or("none"),
1291        actual_entities,
1292        total_vertices,
1293        total_triangles
1294    ))
1295}
1296
1297fn element_field_maps_to_render_triangles(meshes: &[MeshCounts], element_count: usize) -> bool {
1298    if element_count == 0 || meshes.is_empty() {
1299        return false;
1300    }
1301    meshes.iter().all(|mesh| {
1302        mesh.triangle_volume_element_indices.len() == mesh.triangles
1303            && mesh
1304                .triangle_volume_element_indices
1305                .iter()
1306                .all(|index| index.is_some_and(|index| index < element_count))
1307    })
1308}
1309
1310fn node_field_maps_to_render_vertices(meshes: &[MeshCounts], node_count: usize) -> bool {
1311    if node_count == 0 || meshes.is_empty() {
1312        return false;
1313    }
1314    meshes.iter().all(|mesh| {
1315        mesh.vertex_volume_node_indices.len() == mesh.vertices
1316            && mesh
1317                .vertex_volume_node_indices
1318                .iter()
1319                .all(|index| index.is_some_and(|index| index < node_count))
1320    })
1321}
1322
1323fn field_entity_count(
1324    field: &AnalysisField,
1325    descriptor: &AnalysisFieldDescriptor,
1326    value_count: usize,
1327) -> usize {
1328    if matches!(
1329        descriptor.location,
1330        AnalysisFieldLocation::Global | AnalysisFieldLocation::Mode
1331    ) {
1332        return value_count;
1333    }
1334    if let Some(first_dim) = field.shape.first().copied() {
1335        if field.shape.len() > 1 || descriptor.component_count.is_some() {
1336            return first_dim;
1337        }
1338    }
1339    value_count
1340}
1341
1342fn scalar_overlay(
1343    field: &AnalysisField,
1344    meshes: &[MeshCounts],
1345    options: AnalysisFigureGenerationOptions,
1346) -> Option<ScalarOverlay> {
1347    let values = host_values(field)?;
1348    let descriptor = AnalysisFieldDescriptor::from_field(field);
1349    if descriptor.topology_id.as_deref() == Some("analysis_mesh") {
1350        return match (&descriptor.kind, descriptor.location) {
1351            (AnalysisFieldKind::Scalar, AnalysisFieldLocation::Node) => {
1352                scalar_overlay_from_node_values(field, meshes, options)
1353            }
1354            (AnalysisFieldKind::Scalar, AnalysisFieldLocation::Element) => {
1355                scalar_overlay_from_element_values(field, meshes, options)
1356            }
1357            (AnalysisFieldKind::Vector, AnalysisFieldLocation::Node) => {
1358                scalar_overlay_from_node_vector_magnitudes(field, meshes, options)
1359            }
1360            _ => None,
1361        };
1362    }
1363    let total_vertices = meshes.iter().map(|mesh| mesh.vertices).sum::<usize>();
1364    let total_triangles = meshes.iter().map(|mesh| mesh.triangles).sum::<usize>();
1365    if values.len() == total_vertices {
1366        return scalar_overlay_from_values(
1367            field,
1368            MeshFieldLocation::Vertex,
1369            meshes.iter().map(|mesh| mesh.vertices),
1370            options,
1371        );
1372    }
1373    if let Some(overlay) = scalar_overlay_from_node_values(field, meshes, options) {
1374        return Some(overlay);
1375    }
1376    if values.len() == total_triangles {
1377        return scalar_overlay_from_values(
1378            field,
1379            MeshFieldLocation::Triangle,
1380            meshes.iter().map(|mesh| mesh.triangles),
1381            options,
1382        );
1383    }
1384    if let Some(overlay) = scalar_overlay_from_element_values(field, meshes, options) {
1385        return Some(overlay);
1386    }
1387    if let Some(vectors) = vectors_for_count(field, total_vertices) {
1388        if total_vertices <= options.max_overlay_values {
1389            let magnitudes = vectors
1390                .iter()
1391                .map(|vector| vector.length())
1392                .collect::<Vec<_>>();
1393            return Some(ScalarOverlay {
1394                field_id: format!("{}.magnitude", field.field_id),
1395                label: format!("{} magnitude", field.field_id),
1396                location: MeshFieldLocation::Vertex,
1397                chunks: split_f32(&magnitudes, meshes.iter().map(|mesh| mesh.vertices))?,
1398            });
1399        }
1400    }
1401    if let Some(vectors) = vectors_for_count(field, total_triangles) {
1402        if total_triangles <= options.max_overlay_values {
1403            let magnitudes = vectors
1404                .iter()
1405                .map(|vector| vector.length())
1406                .collect::<Vec<_>>();
1407            return Some(ScalarOverlay {
1408                field_id: format!("{}.magnitude", field.field_id),
1409                label: format!("{} magnitude", field.field_id),
1410                location: MeshFieldLocation::Triangle,
1411                chunks: split_f32(&magnitudes, meshes.iter().map(|mesh| mesh.triangles))?,
1412            });
1413        }
1414    }
1415    None
1416}
1417
1418fn scalar_overlay_from_node_vector_magnitudes(
1419    field: &AnalysisField,
1420    meshes: &[MeshCounts],
1421    options: AnalysisFigureGenerationOptions,
1422) -> Option<ScalarOverlay> {
1423    let descriptor = AnalysisFieldDescriptor::from_field(field);
1424    if descriptor.location != AnalysisFieldLocation::Node
1425        || descriptor.topology_id.as_deref() != Some("analysis_mesh")
1426    {
1427        return None;
1428    }
1429    let entity_count = field.shape.first().copied()?;
1430    let vectors = vectors_for_count(field, entity_count)?;
1431    let total_vertices = meshes.iter().map(|mesh| mesh.vertices).sum::<usize>();
1432    if total_vertices > options.max_overlay_values {
1433        return None;
1434    }
1435    let mut chunks = Vec::with_capacity(meshes.len());
1436    for mesh in meshes {
1437        if mesh.vertex_volume_node_indices.len() != mesh.vertices {
1438            return None;
1439        }
1440        let mut chunk = Vec::with_capacity(mesh.vertices);
1441        for node_index in &mesh.vertex_volume_node_indices {
1442            let node_index = (*node_index)?;
1443            chunk.push(vectors.get(node_index)?.length());
1444        }
1445        chunks.push(chunk);
1446    }
1447    Some(ScalarOverlay {
1448        field_id: format!("{}.magnitude", field.field_id),
1449        label: format!("{} magnitude boundary projection", field.field_id),
1450        location: MeshFieldLocation::Vertex,
1451        chunks,
1452    })
1453}
1454
1455fn scalar_overlay_from_element_values(
1456    field: &AnalysisField,
1457    meshes: &[MeshCounts],
1458    options: AnalysisFigureGenerationOptions,
1459) -> Option<ScalarOverlay> {
1460    let values = host_values(field)?;
1461    let descriptor = AnalysisFieldDescriptor::from_field(field);
1462    if descriptor.location != AnalysisFieldLocation::Element
1463        || descriptor.topology_id.as_deref() != Some("analysis_mesh")
1464        || field_entity_count(field, &descriptor, values.len()) != values.len()
1465    {
1466        return None;
1467    }
1468    let total_triangles = meshes.iter().map(|mesh| mesh.triangles).sum::<usize>();
1469    if total_triangles > options.max_overlay_values {
1470        return None;
1471    }
1472    let values = values
1473        .iter()
1474        .copied()
1475        .map(f64_to_f32)
1476        .collect::<Option<Vec<_>>>()?;
1477    let mut chunks = Vec::with_capacity(meshes.len());
1478    for mesh in meshes {
1479        if mesh.triangle_volume_element_indices.len() != mesh.triangles {
1480            return None;
1481        }
1482        let mut chunk = Vec::with_capacity(mesh.triangles);
1483        for element_index in &mesh.triangle_volume_element_indices {
1484            let element_index = (*element_index)?;
1485            chunk.push(*values.get(element_index)?);
1486        }
1487        chunks.push(chunk);
1488    }
1489    Some(ScalarOverlay {
1490        field_id: field.field_id.clone(),
1491        label: format!("{} boundary projection", field.field_id),
1492        location: MeshFieldLocation::Triangle,
1493        chunks,
1494    })
1495}
1496
1497fn scalar_overlay_from_node_values(
1498    field: &AnalysisField,
1499    meshes: &[MeshCounts],
1500    options: AnalysisFigureGenerationOptions,
1501) -> Option<ScalarOverlay> {
1502    let values = host_values(field)?;
1503    let descriptor = AnalysisFieldDescriptor::from_field(field);
1504    if descriptor.location != AnalysisFieldLocation::Node
1505        || descriptor.topology_id.as_deref() != Some("analysis_mesh")
1506        || field_entity_count(field, &descriptor, values.len()) != values.len()
1507    {
1508        return None;
1509    }
1510    let total_vertices = meshes.iter().map(|mesh| mesh.vertices).sum::<usize>();
1511    if total_vertices > options.max_overlay_values {
1512        return None;
1513    }
1514    let values = values
1515        .iter()
1516        .copied()
1517        .map(f64_to_f32)
1518        .collect::<Option<Vec<_>>>()?;
1519    let mut chunks = Vec::with_capacity(meshes.len());
1520    for mesh in meshes {
1521        if mesh.vertex_volume_node_indices.len() != mesh.vertices {
1522            return None;
1523        }
1524        let mut chunk = Vec::with_capacity(mesh.vertices);
1525        for node_index in &mesh.vertex_volume_node_indices {
1526            let node_index = (*node_index)?;
1527            chunk.push(*values.get(node_index)?);
1528        }
1529        chunks.push(chunk);
1530    }
1531    Some(ScalarOverlay {
1532        field_id: field.field_id.clone(),
1533        label: format!("{} boundary projection", field.field_id),
1534        location: MeshFieldLocation::Vertex,
1535        chunks,
1536    })
1537}
1538
1539fn scalar_overlay_from_values<I>(
1540    field: &AnalysisField,
1541    location: MeshFieldLocation,
1542    chunk_lengths: I,
1543    options: AnalysisFigureGenerationOptions,
1544) -> Option<ScalarOverlay>
1545where
1546    I: Iterator<Item = usize>,
1547{
1548    let values = host_values(field)?;
1549    if values.len() > options.max_overlay_values {
1550        return None;
1551    }
1552    let values = values
1553        .iter()
1554        .copied()
1555        .map(f64_to_f32)
1556        .collect::<Option<Vec<_>>>()?;
1557    Some(ScalarOverlay {
1558        field_id: field.field_id.clone(),
1559        label: field.field_id.clone(),
1560        location,
1561        chunks: split_f32(&values, chunk_lengths)?,
1562    })
1563}
1564
1565fn vector_overlay(
1566    field: &AnalysisField,
1567    meshes: &[MeshCounts],
1568    options: AnalysisFigureGenerationOptions,
1569) -> Option<VectorOverlay> {
1570    let descriptor = AnalysisFieldDescriptor::from_field(field);
1571    if descriptor.topology_id.as_deref() == Some("analysis_mesh")
1572        && descriptor.kind == AnalysisFieldKind::Vector
1573        && descriptor.location == AnalysisFieldLocation::Node
1574    {
1575        return vector_overlay_from_node_values(field, meshes, options);
1576    }
1577
1578    let total_vertices = meshes.iter().map(|mesh| mesh.vertices).sum::<usize>();
1579    if let Some(vectors) = vectors_for_count(field, total_vertices) {
1580        if total_vertices <= options.max_overlay_values {
1581            let stride = glyph_stride(total_vertices, options.max_vector_glyphs);
1582            return Some(VectorOverlay {
1583                field_id: field.field_id.clone(),
1584                label: field.field_id.clone(),
1585                location: MeshFieldLocation::Vertex,
1586                chunks: split_vec3(&vectors, meshes.iter().map(|mesh| mesh.vertices))?,
1587                stride,
1588            });
1589        }
1590    }
1591    if let Some(overlay) = vector_overlay_from_node_values(field, meshes, options) {
1592        return Some(overlay);
1593    }
1594
1595    let total_triangles = meshes.iter().map(|mesh| mesh.triangles).sum::<usize>();
1596    if let Some(vectors) = vectors_for_count(field, total_triangles) {
1597        if total_triangles <= options.max_overlay_values {
1598            let stride = glyph_stride(total_triangles, options.max_vector_glyphs);
1599            return Some(VectorOverlay {
1600                field_id: field.field_id.clone(),
1601                label: field.field_id.clone(),
1602                location: MeshFieldLocation::Triangle,
1603                chunks: split_vec3(&vectors, meshes.iter().map(|mesh| mesh.triangles))?,
1604                stride,
1605            });
1606        }
1607    }
1608    None
1609}
1610
1611fn vector_overlay_from_node_values(
1612    field: &AnalysisField,
1613    meshes: &[MeshCounts],
1614    options: AnalysisFigureGenerationOptions,
1615) -> Option<VectorOverlay> {
1616    let descriptor = AnalysisFieldDescriptor::from_field(field);
1617    if descriptor.location != AnalysisFieldLocation::Node
1618        || descriptor.topology_id.as_deref() != Some("analysis_mesh")
1619    {
1620        return None;
1621    }
1622    let entity_count = field.shape.first().copied()?;
1623    let vectors = vectors_for_count(field, entity_count)?;
1624    let total_vertices = meshes.iter().map(|mesh| mesh.vertices).sum::<usize>();
1625    if total_vertices > options.max_overlay_values {
1626        return None;
1627    }
1628    let mut chunks = Vec::with_capacity(meshes.len());
1629    for mesh in meshes {
1630        if mesh.vertex_volume_node_indices.len() != mesh.vertices {
1631            return None;
1632        }
1633        let mut chunk = Vec::with_capacity(mesh.vertices);
1634        for node_index in &mesh.vertex_volume_node_indices {
1635            let node_index = (*node_index)?;
1636            chunk.push(*vectors.get(node_index)?);
1637        }
1638        chunks.push(chunk);
1639    }
1640    Some(VectorOverlay {
1641        field_id: field.field_id.clone(),
1642        label: format!("{} boundary projection", field.field_id),
1643        location: MeshFieldLocation::Vertex,
1644        chunks,
1645        stride: glyph_stride(total_vertices, options.max_vector_glyphs),
1646    })
1647}
1648
1649fn deformation_overlay(
1650    field: &AnalysisField,
1651    meshes: &[MeshCounts],
1652    figure: &Figure,
1653    options: AnalysisFigureGenerationOptions,
1654) -> Option<DeformationOverlay> {
1655    let total_vertices = meshes.iter().map(|mesh| mesh.vertices).sum::<usize>();
1656    if total_vertices > options.max_overlay_values {
1657        return None;
1658    }
1659    let descriptor = AnalysisFieldDescriptor::from_field(field);
1660    if descriptor.topology_id.as_deref() == Some("analysis_mesh")
1661        && descriptor.kind == AnalysisFieldKind::Vector
1662        && descriptor.location == AnalysisFieldLocation::Node
1663    {
1664        return deformation_overlay_from_node_values(field, meshes, figure, options);
1665    }
1666    if let Some(vectors) = vectors_for_count(field, total_vertices) {
1667        let scale = deformation_scale(&vectors, figure);
1668        return Some(DeformationOverlay {
1669            field_id: field.field_id.clone(),
1670            label: field.field_id.clone(),
1671            chunks: split_vec3(&vectors, meshes.iter().map(|mesh| mesh.vertices))?,
1672            scale,
1673        });
1674    }
1675    None
1676}
1677
1678fn deformation_overlay_from_node_values(
1679    field: &AnalysisField,
1680    meshes: &[MeshCounts],
1681    figure: &Figure,
1682    options: AnalysisFigureGenerationOptions,
1683) -> Option<DeformationOverlay> {
1684    let total_vertices = meshes.iter().map(|mesh| mesh.vertices).sum::<usize>();
1685    if total_vertices > options.max_overlay_values {
1686        return None;
1687    }
1688    let descriptor = AnalysisFieldDescriptor::from_field(field);
1689    if descriptor.location != AnalysisFieldLocation::Node
1690        || descriptor.topology_id.as_deref() != Some("analysis_mesh")
1691    {
1692        return None;
1693    }
1694    let entity_count = field.shape.first().copied()?;
1695    let vectors = vectors_for_count(field, entity_count)?;
1696    let mut projected = Vec::with_capacity(total_vertices);
1697    let mut chunks = Vec::with_capacity(meshes.len());
1698    for mesh in meshes {
1699        if mesh.vertex_volume_node_indices.len() != mesh.vertices {
1700            return None;
1701        }
1702        let mut chunk = Vec::with_capacity(mesh.vertices);
1703        for node_index in &mesh.vertex_volume_node_indices {
1704            let node_index = (*node_index)?;
1705            let vector = *vectors.get(node_index)?;
1706            projected.push(vector);
1707            chunk.push(vector);
1708        }
1709        chunks.push(chunk);
1710    }
1711    let scale = deformation_scale(&projected, figure);
1712    Some(DeformationOverlay {
1713        field_id: field.field_id.clone(),
1714        label: format!("{} boundary projection", field.field_id),
1715        chunks,
1716        scale,
1717    })
1718}
1719
1720fn apply_scalar_overlay(
1721    figure: &mut Figure,
1722    overlay: &ScalarOverlay,
1723    meshes: &[MeshCounts],
1724    warnings: &mut Vec<String>,
1725) {
1726    for (mesh, values) in meshes.iter().zip(&overlay.chunks) {
1727        let Some(PlotElement::Mesh(plot)) = figure.get_plot_mut(mesh.plot_index) else {
1728            continue;
1729        };
1730        let mut field =
1731            MeshScalarField::new(overlay.field_id.clone(), overlay.location, values.clone());
1732        field.label = Some(overlay.label.clone());
1733        field.alpha = 0.92;
1734        if let Some(limits) = finite_limits(values) {
1735            field.color_limits = Some(limits);
1736        }
1737        if let Err(err) = plot.set_scalar_field(Some(field)) {
1738            warnings.push(format!(
1739                "failed to attach scalar field '{}' to mesh: {err}",
1740                overlay.field_id
1741            ));
1742        }
1743    }
1744}
1745
1746fn apply_vector_overlay(
1747    figure: &mut Figure,
1748    overlay: &VectorOverlay,
1749    meshes: &[MeshCounts],
1750    warnings: &mut Vec<String>,
1751) {
1752    for (mesh, vectors) in meshes.iter().zip(&overlay.chunks) {
1753        let Some(PlotElement::Mesh(plot)) = figure.get_plot_mut(mesh.plot_index) else {
1754            continue;
1755        };
1756        let mut field =
1757            MeshVectorField::new(overlay.field_id.clone(), overlay.location, vectors.clone());
1758        field.label = Some(overlay.label.clone());
1759        field.stride = overlay.stride.max(1);
1760        field.scale = vector_scale(vectors);
1761        if let Err(err) = plot.set_vector_field(Some(field)) {
1762            warnings.push(format!(
1763                "failed to attach vector field '{}' to mesh: {err}",
1764                overlay.field_id
1765            ));
1766        }
1767    }
1768}
1769
1770fn apply_deformation_to_existing_meshes(
1771    figure: &mut Figure,
1772    overlay: &DeformationOverlay,
1773    meshes: &[MeshCounts],
1774    warnings: &mut Vec<String>,
1775) {
1776    for (mesh, displacements) in meshes.iter().zip(&overlay.chunks) {
1777        let Some(PlotElement::Mesh(plot)) = figure.get_plot_mut(mesh.plot_index) else {
1778            continue;
1779        };
1780        let mut deformation = MeshDeformation::new(overlay.field_id.clone(), displacements.clone());
1781        deformation.label = Some(overlay.label.clone());
1782        deformation.scale = overlay.scale;
1783        if let Err(err) = plot.set_deformation(Some(deformation)) {
1784            warnings.push(format!(
1785                "failed to attach deformation field '{}' to mesh: {err}",
1786                overlay.field_id
1787            ));
1788        }
1789    }
1790}
1791
1792fn append_deformed_mesh_overlay(
1793    figure: &mut Figure,
1794    overlay: &DeformationOverlay,
1795    meshes: &[MeshCounts],
1796    warnings: &mut Vec<String>,
1797) {
1798    let clones = meshes
1799        .iter()
1800        .filter_map(|mesh| match figure.plots().nth(mesh.plot_index) {
1801            Some(PlotElement::Mesh(plot)) => Some(plot.clone()),
1802            _ => None,
1803        })
1804        .collect::<Vec<_>>();
1805
1806    for mesh in meshes {
1807        if let Some(PlotElement::Mesh(plot)) = figure.get_plot_mut(mesh.plot_index) {
1808            plot.set_face_alpha(0.14);
1809            plot.set_edge_alpha(0.72);
1810            plot.set_edge_width(plot.edge_width().max(0.28));
1811        }
1812    }
1813
1814    for (mut plot, displacements) in clones.into_iter().zip(&overlay.chunks) {
1815        plot.set_face_alpha(0.72);
1816        plot.set_edge_alpha(0.45);
1817        plot.set_face_color(Vec4::new(0.33, 0.66, 0.96, 1.0));
1818        plot.set_edge_color(Vec4::new(0.90, 0.95, 1.0, 0.55));
1819        let mut deformation = MeshDeformation::new(overlay.field_id.clone(), displacements.clone());
1820        deformation.label = Some(overlay.label.clone());
1821        deformation.scale = overlay.scale;
1822        if let Err(err) = plot.set_deformation(Some(deformation)) {
1823            warnings.push(format!(
1824                "failed to attach deformation field '{}' to mesh: {err}",
1825                overlay.field_id
1826            ));
1827            continue;
1828        }
1829        figure.add_mesh_plot(*plot);
1830    }
1831}
1832
1833fn line_figure(
1834    kind: AnalysisGeneratedFigureKind,
1835    title: &str,
1836    x_label: &str,
1837    y_label: &str,
1838    series: Vec<(String, Vec<f64>, Vec<f64>, Vec4)>,
1839    field_ids: Vec<String>,
1840    y_log: bool,
1841) -> AnalysisGeneratedFigure {
1842    let mut figure = Figure::new()
1843        .with_title(title)
1844        .with_labels(x_label, y_label)
1845        .with_grid(true);
1846    if y_log {
1847        figure = figure.with_ylog(true);
1848    }
1849    let mut warnings = Vec::new();
1850    for (label, x, y, color) in series {
1851        if x.is_empty() || y.is_empty() || x.len() != y.len() {
1852            continue;
1853        }
1854        match LinePlot::new(x, y) {
1855            Ok(mut line) => {
1856                line.label = Some(label);
1857                line.color = color;
1858                line.line_width = 1.8;
1859                figure.add_line_plot(line);
1860            }
1861            Err(err) => warnings.push(format!("failed to create line series: {err}")),
1862        }
1863    }
1864    AnalysisGeneratedFigure {
1865        kind,
1866        title: title.to_string(),
1867        topology_ids: topology_ids_for_field_ids(field_ids.iter().map(String::as_str)),
1868        field_ids,
1869        warnings,
1870        figure,
1871    }
1872}
1873
1874fn warning_line_figure(
1875    kind: AnalysisGeneratedFigureKind,
1876    title: &str,
1877    warning: String,
1878) -> AnalysisGeneratedFigure {
1879    let mut figure = Figure::new()
1880        .with_title(title)
1881        .with_labels("Step", "Value")
1882        .with_grid(true);
1883    if let Ok(mut line) = LinePlot::new(vec![0.0, 1.0], vec![0.0, 0.0]) {
1884        line.label = Some("No renderable mesh".to_string());
1885        figure.add_line_plot(line);
1886    }
1887    AnalysisGeneratedFigure {
1888        kind,
1889        title: title.to_string(),
1890        field_ids: Vec::new(),
1891        topology_ids: Vec::new(),
1892        warnings: vec![warning],
1893        figure,
1894    }
1895}
1896
1897fn topology_ids_for_fields<'a>(fields: impl IntoIterator<Item = &'a AnalysisField>) -> Vec<String> {
1898    let mut ids = Vec::new();
1899    for field in fields {
1900        if let Some(topology_id) = AnalysisFieldDescriptor::from_field(field).topology_id {
1901            if !ids.iter().any(|existing| existing == &topology_id) {
1902                ids.push(topology_id);
1903            }
1904        }
1905    }
1906    ids
1907}
1908
1909fn topology_ids_for_field_ids<'a>(field_ids: impl IntoIterator<Item = &'a str>) -> Vec<String> {
1910    let fields = field_ids
1911        .into_iter()
1912        .map(|field_id| AnalysisField::host_f64(field_id, vec![1], vec![0.0]))
1913        .collect::<Vec<_>>();
1914    topology_ids_for_fields(fields.iter())
1915}
1916
1917fn previous_run_of_kind(current: &AnalysisRunResult) -> Result<Option<AnalysisRunResult>, String> {
1918    let current_kind = run_kind(current);
1919    let mut candidates = storage::list_run_results()?
1920        .into_iter()
1921        .filter(|run| run.run_id != current.run_id && run_kind(run) == current_kind)
1922        .collect::<Vec<_>>();
1923    candidates.sort_by(|a, b| b.run_id.cmp(&a.run_id));
1924    Ok(candidates.into_iter().next())
1925}
1926
1927fn host_values(field: &AnalysisField) -> Option<&[f64]> {
1928    match &field.values {
1929        AnalysisFieldValues::HostF64(values) => Some(values.as_slice()),
1930        AnalysisFieldValues::DeviceRef(_) => None,
1931    }
1932}
1933
1934fn field_by_id<'a>(fields: &'a [AnalysisField], field_id: &str) -> Option<&'a AnalysisField> {
1935    fields.iter().find(|field| field.field_id == field_id)
1936}
1937
1938fn scalar_field_value(fields: &[AnalysisField], field_id: &str) -> Option<f64> {
1939    let field = field_by_id(fields, field_id)?;
1940    let values = host_values(field)?;
1941    if values.len() != 1 {
1942        return None;
1943    }
1944    values.first().copied().filter(|value| value.is_finite())
1945}
1946
1947fn vector_field_total_magnitude(fields: &[AnalysisField], field_id: &str) -> Option<f64> {
1948    let field = field_by_id(fields, field_id)?;
1949    let values = host_values(field)?;
1950    if values.is_empty() || !values.len().is_multiple_of(3) {
1951        return None;
1952    }
1953    let mut total = [0.0_f64; 3];
1954    for chunk in values.chunks_exact(3) {
1955        total[0] += chunk[0];
1956        total[1] += chunk[1];
1957        total[2] += chunk[2];
1958    }
1959    let magnitude = (total[0] * total[0] + total[1] * total[1] + total[2] * total[2]).sqrt();
1960    magnitude.is_finite().then_some(magnitude)
1961}
1962
1963fn vectors_for_count(field: &AnalysisField, count: usize) -> Option<Vec<Vec3>> {
1964    if count == 0 {
1965        return None;
1966    }
1967    let values = host_values(field)?;
1968    if values.len() == count * 3 {
1969        return values
1970            .chunks_exact(3)
1971            .map(|chunk| {
1972                Some(Vec3::new(
1973                    f64_to_f32(chunk[0])?,
1974                    f64_to_f32(chunk[1])?,
1975                    f64_to_f32(chunk[2])?,
1976                ))
1977            })
1978            .collect::<Option<Vec<_>>>();
1979    }
1980    match field.shape.as_slice() {
1981        [rows, cols] if *rows == count && *cols == 2 && values.len() == count * 2 => values
1982            .chunks_exact(2)
1983            .map(|chunk| Some(Vec3::new(f64_to_f32(chunk[0])?, f64_to_f32(chunk[1])?, 0.0)))
1984            .collect::<Option<Vec<_>>>(),
1985        [rows, cols] if *rows == 2 && *cols == count && values.len() == count * 2 => {
1986            let mut vectors = Vec::with_capacity(count);
1987            for idx in 0..count {
1988                vectors.push(Vec3::new(
1989                    f64_to_f32(values[idx])?,
1990                    f64_to_f32(values[count + idx])?,
1991                    0.0,
1992                ));
1993            }
1994            Some(vectors)
1995        }
1996        [rows, cols] if *rows == 3 && *cols == count && values.len() == count * 3 => {
1997            let mut vectors = Vec::with_capacity(count);
1998            for idx in 0..count {
1999                vectors.push(Vec3::new(
2000                    f64_to_f32(values[idx])?,
2001                    f64_to_f32(values[count + idx])?,
2002                    f64_to_f32(values[count * 2 + idx])?,
2003                ));
2004            }
2005            Some(vectors)
2006        }
2007        _ => None,
2008    }
2009}
2010
2011fn split_f32<I>(values: &[f32], lengths: I) -> Option<Vec<Vec<f32>>>
2012where
2013    I: Iterator<Item = usize>,
2014{
2015    let mut offset = 0usize;
2016    let mut chunks = Vec::new();
2017    for len in lengths {
2018        let end = offset.checked_add(len)?;
2019        chunks.push(values.get(offset..end)?.to_vec());
2020        offset = end;
2021    }
2022    if offset == values.len() {
2023        Some(chunks)
2024    } else {
2025        None
2026    }
2027}
2028
2029fn split_vec3<I>(values: &[Vec3], lengths: I) -> Option<Vec<Vec<Vec3>>>
2030where
2031    I: Iterator<Item = usize>,
2032{
2033    let mut offset = 0usize;
2034    let mut chunks = Vec::new();
2035    for len in lengths {
2036        let end = offset.checked_add(len)?;
2037        chunks.push(values.get(offset..end)?.to_vec());
2038        offset = end;
2039    }
2040    if offset == values.len() {
2041        Some(chunks)
2042    } else {
2043        None
2044    }
2045}
2046
2047fn finite_limits(values: &[f32]) -> Option<[f32; 2]> {
2048    let mut min = f32::INFINITY;
2049    let mut max = f32::NEG_INFINITY;
2050    for value in values.iter().copied().filter(|value| value.is_finite()) {
2051        min = min.min(value);
2052        max = max.max(value);
2053    }
2054    if min.is_finite() && max.is_finite() {
2055        Some([min, max])
2056    } else {
2057        None
2058    }
2059}
2060
2061fn f64_to_f32(value: f64) -> Option<f32> {
2062    if !value.is_finite() || value > f32::MAX as f64 || value < f32::MIN as f64 {
2063        None
2064    } else {
2065        Some(value as f32)
2066    }
2067}
2068
2069fn is_deformation_candidate(field_id: &str) -> bool {
2070    let normalized = field_id.to_ascii_lowercase();
2071    normalized.contains("displacement") || normalized.contains("mode_shape")
2072}
2073
2074fn deformation_scale(vectors: &[Vec3], figure: &Figure) -> f32 {
2075    let max_displacement = vectors
2076        .iter()
2077        .map(|vector| vector.length())
2078        .fold(0.0_f32, f32::max);
2079    if !max_displacement.is_finite() || max_displacement <= f32::EPSILON {
2080        return 1.0;
2081    }
2082    let mut min = Vec3::splat(f32::INFINITY);
2083    let mut max = Vec3::splat(f32::NEG_INFINITY);
2084    for plot in figure.plots() {
2085        if let PlotElement::Mesh(mesh) = plot {
2086            for vertex in mesh.vertices() {
2087                min = min.min(*vertex);
2088                max = max.max(*vertex);
2089            }
2090        }
2091    }
2092    let diagonal = (max - min).length();
2093    if !diagonal.is_finite() || diagonal <= f32::EPSILON {
2094        return 1.0;
2095    }
2096    ((diagonal * 0.08) / max_displacement).clamp(0.1, 1.0e6)
2097}
2098
2099fn vector_scale(vectors: &[Vec3]) -> f32 {
2100    let max_vector = vectors
2101        .iter()
2102        .map(|vector| vector.length())
2103        .fold(0.0_f32, f32::max);
2104    if max_vector.is_finite() && max_vector > f32::EPSILON {
2105        (1.0 / max_vector).clamp(0.001, 1.0e6)
2106    } else {
2107        1.0
2108    }
2109}
2110
2111fn glyph_stride(count: usize, max_glyphs: usize) -> usize {
2112    if max_glyphs == 0 || count <= max_glyphs {
2113        1
2114    } else {
2115        count.div_ceil(max_glyphs)
2116    }
2117}
2118
2119fn axis_or_index(axis: &[f64], count: usize) -> Vec<f64> {
2120    if axis.len() >= count {
2121        axis.iter().copied().take(count).collect()
2122    } else {
2123        index_axis(count, 1.0)
2124    }
2125}
2126
2127fn index_axis(count: usize, start: f64) -> Vec<f64> {
2128    (0..count).map(|idx| start + idx as f64).collect()
2129}
2130
2131fn push_bar_value(
2132    labels: &mut Vec<String>,
2133    values: &mut Vec<f64>,
2134    label: &str,
2135    value: Option<f64>,
2136) {
2137    if let Some(value) = value.filter(|value| value.is_finite()) {
2138        labels.push(label.to_string());
2139        values.push(value);
2140    }
2141}
2142
2143fn geometry_surface_mesh_bytes(geometry: &runmat_geometry_core::GeometryAsset) -> usize {
2144    geometry
2145        .surface_meshes
2146        .iter()
2147        .map(|mesh| {
2148            mesh.vertices.len() * 3 * std::mem::size_of::<f32>()
2149                + mesh.triangles.len() * 3 * std::mem::size_of::<u32>()
2150        })
2151        .sum()
2152}
2153
2154fn render_topology_has_meshes(topology: &AnalysisRenderTopology) -> bool {
2155    topology
2156        .meshes
2157        .iter()
2158        .any(|mesh| !mesh.vertices.is_empty() && !mesh.triangles.is_empty())
2159}
2160
2161fn render_topology_mesh_bytes(topology: &AnalysisRenderTopology) -> usize {
2162    topology
2163        .meshes
2164        .iter()
2165        .map(|mesh| {
2166            mesh.vertices.len() * 3 * std::mem::size_of::<f32>()
2167                + mesh.triangles.len() * 3 * std::mem::size_of::<u32>()
2168        })
2169        .sum()
2170}
2171
2172fn run_kind_label(kind: AnalysisRunKind) -> &'static str {
2173    match kind {
2174        AnalysisRunKind::LinearStatic => "Linear static",
2175        AnalysisRunKind::Modal => "Modal",
2176        AnalysisRunKind::Acoustic => "Acoustic",
2177        AnalysisRunKind::Thermal => "Thermal",
2178        AnalysisRunKind::Transient => "Transient",
2179        AnalysisRunKind::Cfd => "CFD",
2180        AnalysisRunKind::Cht => "CHT",
2181        AnalysisRunKind::Fsi => "FSI",
2182        AnalysisRunKind::Nonlinear => "Nonlinear",
2183        AnalysisRunKind::Electromagnetic => "Electromagnetic",
2184    }
2185}
2186
2187#[cfg(test)]
2188mod tests {
2189    use super::*;
2190    use runmat_analysis_core::{
2191        AnalysisModelId, AnalysisStep, AnalysisStepKind, BoundaryCondition, LoadCase,
2192        ReferenceFrame,
2193    };
2194
2195    fn simple_run_result(
2196        fields: Vec<AnalysisField>,
2197        render_topology: AnalysisRenderTopology,
2198    ) -> AnalysisRunResult {
2199        AnalysisRunResult {
2200            run_id: "run_test".to_string(),
2201            run: runmat_analysis_fea::FeaRunResult {
2202                backend: runmat_analysis_fea::ComputeBackend::Cpu,
2203                solver_backend: "cpu".to_string(),
2204                solver_device_apply_k_ratio: 0.0,
2205                solver_method: "test_solver".to_string(),
2206                preconditioner: "none".to_string(),
2207                solver_host_sync_count: 0,
2208                diagnostics: Vec::new(),
2209                fields,
2210            },
2211            render_topology: Some(render_topology),
2212            modal_results: None,
2213            thermal_results: None,
2214            transient_results: None,
2215            nonlinear_results: None,
2216            electromagnetic_results: None,
2217            model_validity: crate::analysis::contracts::QualityGate::Pass,
2218            solver_convergence: crate::analysis::contracts::QualityGate::Pass,
2219            result_quality: crate::analysis::contracts::QualityGate::Pass,
2220            run_status: crate::analysis::contracts::RunStatus::Publishable,
2221            publishable: true,
2222            quality_reasons: Vec::new(),
2223            provenance: crate::analysis::contracts::RunProvenance {
2224                backend: runmat_analysis_fea::ComputeBackend::Cpu,
2225                solver_backend: "cpu".to_string(),
2226                solver_device_apply_k_ratio: 0.0,
2227                solver_host_sync_count: 0,
2228                precision_mode: "double".to_string(),
2229                deterministic_mode: true,
2230                solver_method: "test_solver".to_string(),
2231                preconditioner: "none".to_string(),
2232                quality_policy: "strict".to_string(),
2233                fallback_events: Vec::new(),
2234            },
2235        }
2236    }
2237
2238    fn simple_geometry_asset() -> runmat_geometry_core::GeometryAsset {
2239        simple_geometry_asset_with_units(runmat_geometry_core::UnitSystem::Meter)
2240    }
2241
2242    fn simple_geometry_asset_with_units(
2243        units: runmat_geometry_core::UnitSystem,
2244    ) -> runmat_geometry_core::GeometryAsset {
2245        runmat_geometry_core::GeometryAsset {
2246            geometry_id: "geometry".to_string(),
2247            source: runmat_geometry_core::GeometrySource {
2248                path: "/tmp/generic.step".to_string(),
2249                sha256: "hash".to_string(),
2250                importer_version: "test/v1".to_string(),
2251            },
2252            source_geometry: runmat_geometry_core::SourceGeometry {
2253                kind: runmat_geometry_core::SourceGeometryKind::Mesh,
2254                assembly: None,
2255                material_evidence: Vec::new(),
2256                cad_evaluators: Vec::new(),
2257            },
2258            tessellation_profile: runmat_geometry_core::TessellationProfile::default(),
2259            units,
2260            revision: 1,
2261            meshes: vec![runmat_geometry_core::MeshDescriptor {
2262                mesh_id: "cad_surface".to_string(),
2263                kind: runmat_geometry_core::MeshKind::Surface,
2264                vertex_count: 3,
2265                element_count: 1,
2266            }],
2267            surface_meshes: vec![runmat_geometry_core::SurfaceMesh::new(
2268                "cad_surface",
2269                vec![[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0, 2.0, 0.0]],
2270                vec![[0, 1, 2]],
2271            )],
2272            regions: Vec::new(),
2273            region_entity_mappings: Vec::new(),
2274            diagnostics: Vec::new(),
2275        }
2276    }
2277
2278    fn simple_boundary_region_model() -> AnalysisModel {
2279        AnalysisModel {
2280            model_id: AnalysisModelId("model".to_string()),
2281            geometry_id: "geometry".to_string(),
2282            geometry_revision: 1,
2283            units: runmat_geometry_core::UnitSystem::Meter,
2284            frame: ReferenceFrame::Global,
2285            materials: Vec::new(),
2286            material_assignments: Vec::new(),
2287            structural: None,
2288            thermo_mechanical: None,
2289            electro_thermal: None,
2290            electromagnetic: None,
2291            cfd: None,
2292            interfaces: Vec::new(),
2293            boundary_conditions: vec![BoundaryCondition {
2294                bc_id: "fixed_bc".to_string(),
2295                region_id: "fixed".to_string(),
2296                kind: BoundaryConditionKind::Fixed,
2297            }],
2298            loads: vec![
2299                LoadCase {
2300                    load_id: "force_load".to_string(),
2301                    region_id: "loaded".to_string(),
2302                    kind: LoadKind::Force {
2303                        fx: 0.0,
2304                        fy: 0.0,
2305                        fz: -1.0,
2306                    },
2307                },
2308                LoadCase {
2309                    load_id: "body_force".to_string(),
2310                    region_id: "volume_only".to_string(),
2311                    kind: LoadKind::BodyForce {
2312                        gx: 0.0,
2313                        gy: 0.0,
2314                        gz: -9.81,
2315                    },
2316                },
2317            ],
2318            steps: vec![AnalysisStep {
2319                step_id: "static_step".to_string(),
2320                kind: AnalysisStepKind::Static,
2321            }],
2322        }
2323    }
2324
2325    fn simple_pressure_region_model() -> AnalysisModel {
2326        let mut model = simple_boundary_region_model();
2327        model.boundary_conditions.clear();
2328        model.loads = vec![LoadCase {
2329            load_id: "pressure_load".to_string(),
2330            region_id: "pressurized".to_string(),
2331            kind: LoadKind::Pressure { magnitude_pa: 5.0 },
2332        }];
2333        model
2334    }
2335
2336    fn simple_moment_region_model() -> AnalysisModel {
2337        let mut model = simple_boundary_region_model();
2338        model.boundary_conditions.clear();
2339        model.loads = vec![LoadCase {
2340            load_id: "axis_load".to_string(),
2341            region_id: "axis_region".to_string(),
2342            kind: LoadKind::Moment {
2343                mx: 0.0,
2344                my: 2.0,
2345                mz: 0.0,
2346            },
2347        }];
2348        model
2349    }
2350
2351    fn simple_wrench_moment_region_model() -> AnalysisModel {
2352        let mut model = simple_boundary_region_model();
2353        model.boundary_conditions.clear();
2354        model.loads = vec![LoadCase {
2355            load_id: "wrench_axis_load".to_string(),
2356            region_id: "wrench_axis_region".to_string(),
2357            kind: LoadKind::Wrench {
2358                fx: 0.0,
2359                fy: 0.0,
2360                fz: 0.0,
2361                mx: 0.0,
2362                my: 0.0,
2363                mz: 4.0,
2364                px: 0.0,
2365                py: 0.0,
2366                pz: 0.0,
2367            },
2368        }];
2369        model
2370    }
2371
2372    fn simple_render_topology() -> AnalysisRenderTopology {
2373        AnalysisRenderTopology {
2374            schema_version: "analysis_render_topology/v1".to_string(),
2375            source: crate::analysis::contracts::AnalysisRenderTopologySource::SolverPrep,
2376            meshes: vec![crate::analysis::contracts::AnalysisRenderMesh {
2377                mesh_id: "analysis_mesh".to_string(),
2378                vertices: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
2379                triangles: vec![[0, 1, 2]],
2380                regions: Vec::new(),
2381                vertex_volume_node_indices: vec![Some(0), Some(1), Some(2)],
2382                triangle_volume_element_indices: Vec::new(),
2383            }],
2384        }
2385    }
2386
2387    fn mapped_render_topology(
2388        vertex_volume_node_indices: Vec<Option<usize>>,
2389        triangle_volume_element_indices: Vec<Option<usize>>,
2390    ) -> AnalysisRenderTopology {
2391        AnalysisRenderTopology {
2392            schema_version: "analysis_render_topology/v1".to_string(),
2393            source: crate::analysis::contracts::AnalysisRenderTopologySource::AnalysisMesh,
2394            meshes: vec![crate::analysis::contracts::AnalysisRenderMesh {
2395                mesh_id: "analysis_mesh".to_string(),
2396                vertices: vec![
2397                    [0.0, 0.0, 0.0],
2398                    [1.0, 0.0, 0.0],
2399                    [0.0, 1.0, 0.0],
2400                    [0.0, 0.0, 1.0],
2401                ],
2402                triangles: vec![[0, 1, 2], [0, 2, 3], [0, 3, 1]],
2403                regions: Vec::new(),
2404                vertex_volume_node_indices,
2405                triangle_volume_element_indices,
2406            }],
2407        }
2408    }
2409
2410    fn first_mesh_plot(figure: &Figure) -> &MeshPlot {
2411        figure
2412            .plots()
2413            .find_map(|plot| match plot {
2414                PlotElement::Mesh(mesh) => Some(mesh),
2415                _ => None,
2416            })
2417            .expect("figure should include a mesh plot")
2418    }
2419
2420    fn analysis_mesh_plot(figure: &Figure) -> &MeshPlot {
2421        figure
2422            .plots()
2423            .find_map(|plot| match plot {
2424                PlotElement::Mesh(mesh) if mesh.mesh_id() == Some("analysis_mesh") => Some(mesh),
2425                _ => None,
2426            })
2427            .expect("figure should include an analysis mesh plot")
2428    }
2429
2430    fn analysis_mesh_plot_with_deformation(figure: &Figure) -> &MeshPlot {
2431        figure
2432            .plots()
2433            .find_map(|plot| match plot {
2434                PlotElement::Mesh(mesh)
2435                    if mesh.mesh_id() == Some("analysis_mesh") && mesh.deformation().is_some() =>
2436                {
2437                    Some(mesh)
2438                }
2439                _ => None,
2440            })
2441            .expect("figure should include a deformed analysis mesh plot")
2442    }
2443
2444    #[test]
2445    fn render_topology_edges_are_disabled_by_default() {
2446        let figure = render_topology_figure(
2447            &simple_render_topology(),
2448            "solver mesh",
2449            AnalysisFigureGenerationOptions::default(),
2450        )
2451        .expect("solver topology should render");
2452
2453        let plot = first_mesh_plot(&figure);
2454        assert_eq!(plot.edge_mode(), MeshEdgeMode::None);
2455        assert_eq!(plot.edge_width(), 0.0);
2456    }
2457
2458    #[test]
2459    fn render_topology_figure_attaches_boundary_regions_to_solver_mesh() {
2460        let mut topology = mapped_render_topology(
2461            vec![Some(0), Some(1), Some(2), Some(3)],
2462            vec![Some(0), Some(0), Some(0)],
2463        );
2464        topology.meshes[0].regions = vec![
2465            crate::analysis::contracts::AnalysisRenderRegion {
2466                region_id: "fixed".to_string(),
2467                label: Some("Fixed".to_string()),
2468                tag: Some("boundary".to_string()),
2469                triangle_ranges: vec![
2470                    crate::analysis::contracts::AnalysisRenderTriangleRange { start: 0, count: 1 },
2471                    crate::analysis::contracts::AnalysisRenderTriangleRange { start: 2, count: 1 },
2472                ],
2473            },
2474            crate::analysis::contracts::AnalysisRenderRegion {
2475                region_id: "loaded".to_string(),
2476                label: None,
2477                tag: Some("boundary".to_string()),
2478                triangle_ranges: vec![crate::analysis::contracts::AnalysisRenderTriangleRange {
2479                    start: 1,
2480                    count: 1,
2481                }],
2482            },
2483        ];
2484
2485        let figure = render_topology_figure(
2486            &topology,
2487            "solver mesh",
2488            AnalysisFigureGenerationOptions::default(),
2489        )
2490        .expect("solver topology should render");
2491        let plot = analysis_mesh_plot(&figure);
2492
2493        let fixed = plot
2494            .regions()
2495            .iter()
2496            .find(|region| region.region_id == "fixed")
2497            .expect("fixed region should be attached to mesh plot");
2498        assert_eq!(fixed.label.as_deref(), Some("Fixed"));
2499        assert_eq!(fixed.tag.as_deref(), Some("boundary"));
2500        assert!(fixed.contains_triangle(0));
2501        assert!(!fixed.contains_triangle(1));
2502        assert!(fixed.contains_triangle(2));
2503        assert_eq!(
2504            plot.region_for_triangle(1)
2505                .map(|region| region.region_id.as_str()),
2506            Some("loaded")
2507        );
2508    }
2509
2510    #[test]
2511    fn render_topology_edges_can_be_enabled() {
2512        let figure = render_topology_figure(
2513            &simple_render_topology(),
2514            "solver mesh",
2515            AnalysisFigureGenerationOptions {
2516                show_solver_mesh_edges: true,
2517                ..AnalysisFigureGenerationOptions::default()
2518            },
2519        )
2520        .expect("solver topology should render");
2521
2522        let plot = first_mesh_plot(&figure);
2523        assert_eq!(plot.edge_mode(), MeshEdgeMode::All);
2524        assert!(plot.edge_width() > 0.0);
2525    }
2526
2527    #[test]
2528    fn base_mesh_figure_can_force_solver_render_topology() {
2529        let geometry = simple_geometry_asset();
2530        let topology = simple_render_topology();
2531        let figure = base_mesh_figure_for_run_source(
2532            &geometry,
2533            Some(&topology),
2534            "solver source",
2535            AnalysisFigureGenerationOptions {
2536                mesh_source: AnalysisFigureMeshSource::Solver,
2537                ..AnalysisFigureGenerationOptions::default()
2538            },
2539        )
2540        .expect("solver source should render from topology");
2541
2542        let plot = first_mesh_plot(&figure);
2543        assert_eq!(plot.mesh_id(), Some("analysis_mesh"));
2544    }
2545
2546    #[test]
2547    fn base_mesh_figure_can_force_cad_geometry_source() {
2548        let geometry = simple_geometry_asset();
2549        let topology = simple_render_topology();
2550        let figure = base_mesh_figure_for_run_source(
2551            &geometry,
2552            Some(&topology),
2553            "cad source",
2554            AnalysisFigureGenerationOptions {
2555                mesh_source: AnalysisFigureMeshSource::Cad,
2556                ..AnalysisFigureGenerationOptions::default()
2557            },
2558        )
2559        .expect("CAD source should render from geometry");
2560
2561        let plot = first_mesh_plot(&figure);
2562        assert_eq!(plot.mesh_id(), Some("cad_surface"));
2563    }
2564
2565    #[test]
2566    fn base_mesh_figure_auto_layers_cad_context_and_solver_topology() {
2567        let geometry = simple_geometry_asset();
2568        let topology = simple_render_topology();
2569        let figure = base_mesh_figure_for_run_source(
2570            &geometry,
2571            Some(&topology),
2572            "layered result",
2573            AnalysisFigureGenerationOptions::default(),
2574        )
2575        .expect("auto source should render layered CAD and solver topology");
2576
2577        let mesh_ids = figure
2578            .plots()
2579            .filter_map(|plot| match plot {
2580                PlotElement::Mesh(mesh) => mesh.mesh_id(),
2581                _ => None,
2582            })
2583            .collect::<Vec<_>>();
2584        assert_eq!(mesh_ids, vec!["cad_surface", "analysis_mesh"]);
2585    }
2586
2587    #[test]
2588    fn base_mesh_figure_can_force_cad_reference_with_solver_topology() {
2589        let geometry = simple_geometry_asset();
2590        let topology = simple_render_topology();
2591        let figure = base_mesh_figure_for_run_source(
2592            &geometry,
2593            Some(&topology),
2594            "CAD reference result",
2595            AnalysisFigureGenerationOptions {
2596                mesh_source: AnalysisFigureMeshSource::CadReference,
2597                ..AnalysisFigureGenerationOptions::default()
2598            },
2599        )
2600        .expect("CAD reference source should layer CAD and solver topology");
2601
2602        let mesh_ids = figure
2603            .plots()
2604            .filter_map(|plot| match plot {
2605                PlotElement::Mesh(mesh) => mesh.mesh_id(),
2606                _ => None,
2607            })
2608            .collect::<Vec<_>>();
2609        assert_eq!(mesh_ids, vec!["cad_surface", "analysis_mesh"]);
2610    }
2611
2612    #[test]
2613    fn auto_layered_result_scales_geometry_context_to_solver_meters() {
2614        let mut geometry =
2615            simple_geometry_asset_with_units(runmat_geometry_core::UnitSystem::Millimeter);
2616        geometry.surface_meshes[0].vertices =
2617            vec![[0.0, 0.0, 0.0], [1000.0, 0.0, 0.0], [0.0, 1000.0, 0.0]];
2618        let topology = simple_render_topology();
2619
2620        let figure = base_mesh_figure_for_run_source(
2621            &geometry,
2622            Some(&topology),
2623            "layered result",
2624            AnalysisFigureGenerationOptions::default(),
2625        )
2626        .expect("auto source should render layered CAD and solver topology");
2627
2628        let cad = figure
2629            .plots()
2630            .find_map(|plot| match plot {
2631                PlotElement::Mesh(mesh) if mesh.mesh_id() == Some("cad_surface") => Some(mesh),
2632                _ => None,
2633            })
2634            .expect("CAD context mesh should be present");
2635        assert_eq!(cad.vertices()[1], Vec3::new(1.0, 0.0, 0.0));
2636        assert_eq!(cad.vertices()[2], Vec3::new(0.0, 1.0, 0.0));
2637    }
2638
2639    #[test]
2640    fn topology_mesh_counts_ignore_cad_context_meshes() {
2641        let geometry = simple_geometry_asset();
2642        let topology = simple_render_topology();
2643        let figure = base_mesh_figure_for_run_source(
2644            &geometry,
2645            Some(&topology),
2646            "layered result",
2647            AnalysisFigureGenerationOptions::default(),
2648        )
2649        .expect("auto source should render layered CAD and solver topology");
2650
2651        let counts = collect_mesh_counts_with_topology(&figure, Some(&topology));
2652
2653        assert_eq!(counts.len(), 1);
2654        assert_eq!(counts[0].vertices, topology.meshes[0].vertices.len());
2655        assert_eq!(counts[0].triangles, topology.meshes[0].triangles.len());
2656    }
2657
2658    #[test]
2659    fn mesh_result_figures_apply_mapped_solver_element_scalar_fields() {
2660        let run = simple_run_result(
2661            vec![AnalysisField::host_f64(
2662                "structural.von_mises",
2663                vec![3],
2664                vec![10.0, 20.0, 30.0],
2665            )],
2666            mapped_render_topology(
2667                vec![Some(0), Some(1), Some(2), Some(3)],
2668                vec![Some(2), Some(0), Some(1)],
2669            ),
2670        );
2671        let options = AnalysisFigureGenerationOptions {
2672            apply_deformation_overlay: false,
2673            max_mesh_result_figures: 1,
2674            ..AnalysisFigureGenerationOptions::default()
2675        };
2676
2677        let figures = mesh_result_figures(&simple_geometry_asset(), None, &run, options);
2678
2679        assert_eq!(figures.len(), 1);
2680        assert_eq!(figures[0].title, "FEA scalar field: structural.von_mises");
2681        let scalar = analysis_mesh_plot(&figures[0].figure)
2682            .scalar_field()
2683            .expect("scalar field should be attached to solver mesh");
2684        assert_eq!(scalar.location, MeshFieldLocation::Triangle);
2685        assert_eq!(scalar.values, vec![30.0, 10.0, 20.0]);
2686        assert!(scalar
2687            .label
2688            .as_deref()
2689            .is_some_and(|label| label.contains("boundary projection")));
2690        assert!(figures[0].warnings.is_empty());
2691    }
2692
2693    #[test]
2694    fn cad_reference_result_overlay_maps_fields_only_to_solver_mesh() {
2695        let run = simple_run_result(
2696            vec![AnalysisField::host_f64(
2697                "structural.von_mises",
2698                vec![3],
2699                vec![10.0, 20.0, 30.0],
2700            )],
2701            mapped_render_topology(
2702                vec![Some(0), Some(1), Some(2), Some(3)],
2703                vec![Some(2), Some(0), Some(1)],
2704            ),
2705        );
2706        let options = AnalysisFigureGenerationOptions {
2707            apply_deformation_overlay: false,
2708            max_mesh_result_figures: 1,
2709            mesh_source: AnalysisFigureMeshSource::CadReference,
2710            ..AnalysisFigureGenerationOptions::default()
2711        };
2712
2713        let figures = mesh_result_figures(&simple_geometry_asset(), None, &run, options);
2714
2715        assert_eq!(figures.len(), 1);
2716        let mesh_ids = figures[0]
2717            .figure
2718            .plots()
2719            .filter_map(|plot| match plot {
2720                PlotElement::Mesh(mesh) => mesh.mesh_id(),
2721                _ => None,
2722            })
2723            .collect::<Vec<_>>();
2724        assert_eq!(mesh_ids, vec!["cad_surface", "analysis_mesh"]);
2725
2726        let cad_plot = figures[0]
2727            .figure
2728            .plots()
2729            .find_map(|plot| match plot {
2730                PlotElement::Mesh(mesh) if mesh.mesh_id() == Some("cad_surface") => Some(mesh),
2731                _ => None,
2732            })
2733            .expect("CAD context mesh should be present");
2734        assert!(cad_plot.scalar_field().is_none());
2735
2736        let scalar = analysis_mesh_plot(&figures[0].figure)
2737            .scalar_field()
2738            .expect("scalar field should be attached to solver mesh");
2739        assert_eq!(scalar.location, MeshFieldLocation::Triangle);
2740        assert_eq!(scalar.values, vec![30.0, 10.0, 20.0]);
2741    }
2742
2743    #[test]
2744    fn mesh_result_figures_apply_mapped_solver_node_vector_and_deformation_fields() {
2745        let run = simple_run_result(
2746            vec![AnalysisField::host_f64(
2747                "structural.displacement",
2748                vec![4, 3],
2749                vec![
2750                    1.0, 0.0, 0.0, //
2751                    0.0, 2.0, 0.0, //
2752                    0.0, 0.0, 3.0, //
2753                    4.0, 0.0, 0.0,
2754                ],
2755            )],
2756            mapped_render_topology(
2757                vec![Some(2), Some(0), Some(3), Some(1)],
2758                vec![Some(0), Some(0), Some(0)],
2759            ),
2760        );
2761        let options = AnalysisFigureGenerationOptions {
2762            max_mesh_result_figures: 3,
2763            ..AnalysisFigureGenerationOptions::default()
2764        };
2765
2766        let figures = mesh_result_figures(&simple_geometry_asset(), None, &run, options);
2767
2768        let deformed = figures
2769            .iter()
2770            .find(|figure| figure.title == "FEA deformed shape: structural.displacement")
2771            .expect("deformed shape figure should be generated");
2772        let deformation = analysis_mesh_plot_with_deformation(&deformed.figure)
2773            .deformation()
2774            .expect("deformation should be attached to solver mesh");
2775        assert_eq!(
2776            deformation.displacements,
2777            vec![
2778                Vec3::new(0.0, 0.0, 3.0),
2779                Vec3::new(1.0, 0.0, 0.0),
2780                Vec3::new(4.0, 0.0, 0.0),
2781                Vec3::new(0.0, 2.0, 0.0)
2782            ]
2783        );
2784
2785        let magnitude = figures
2786            .iter()
2787            .find(|figure| figure.title == "FEA scalar field: structural.displacement.magnitude")
2788            .expect("mapped displacement magnitude figure should be generated");
2789        let scalar = analysis_mesh_plot(&magnitude.figure)
2790            .scalar_field()
2791            .expect("magnitude field should be attached to solver mesh");
2792        assert_eq!(scalar.location, MeshFieldLocation::Vertex);
2793        assert_eq!(scalar.values, vec![3.0, 1.0, 4.0, 2.0]);
2794
2795        let vector = figures
2796            .iter()
2797            .find(|figure| figure.title == "FEA vector field: structural.displacement")
2798            .expect("mapped displacement vector figure should be generated");
2799        let vector_field = analysis_mesh_plot(&vector.figure)
2800            .vector_field()
2801            .expect("vector field should be attached to solver mesh");
2802        assert_eq!(vector_field.location, MeshFieldLocation::Vertex);
2803        assert_eq!(
2804            vector_field.vectors,
2805            vec![
2806                Vec3::new(0.0, 0.0, 3.0),
2807                Vec3::new(1.0, 0.0, 0.0),
2808                Vec3::new(4.0, 0.0, 0.0),
2809                Vec3::new(0.0, 2.0, 0.0)
2810            ]
2811        );
2812    }
2813
2814    #[test]
2815    fn mesh_result_figures_report_unmapped_solver_element_fields() {
2816        let run = simple_run_result(
2817            vec![AnalysisField::host_f64(
2818                "structural.von_mises",
2819                vec![3],
2820                vec![10.0, 20.0, 30.0],
2821            )],
2822            mapped_render_topology(vec![Some(0), Some(1), Some(2), Some(3)], Vec::new()),
2823        );
2824        let options = AnalysisFigureGenerationOptions {
2825            apply_deformation_overlay: false,
2826            max_mesh_result_figures: 1,
2827            ..AnalysisFigureGenerationOptions::default()
2828        };
2829
2830        let figures = mesh_result_figures(&simple_geometry_asset(), None, &run, options);
2831
2832        assert_eq!(figures.len(), 1);
2833        assert_eq!(figures[0].title, "FEA field topology mismatch");
2834        assert!(figures[0]
2835            .warnings
2836            .iter()
2837            .any(|warning| warning.contains("structural.von_mises")));
2838        assert!(figures[0]
2839            .warnings
2840            .iter()
2841            .any(|warning| warning.contains("render_triangle_count=3")));
2842    }
2843
2844    #[test]
2845    fn mesh_result_figures_include_authored_boundary_region_figure() {
2846        let run = simple_run_result(Vec::new(), {
2847            let mut topology = mapped_render_topology(
2848                vec![Some(0), Some(1), Some(2), Some(3)],
2849                vec![Some(0), Some(0), Some(0)],
2850            );
2851            topology.meshes[0].regions = vec![crate::analysis::contracts::AnalysisRenderRegion {
2852                region_id: "loaded".to_string(),
2853                label: None,
2854                tag: Some("boundary".to_string()),
2855                triangle_ranges: vec![crate::analysis::contracts::AnalysisRenderTriangleRange {
2856                    start: 1,
2857                    count: 1,
2858                }],
2859            }];
2860            topology
2861        });
2862        let model = simple_boundary_region_model();
2863        let options = AnalysisFigureGenerationOptions {
2864            max_mesh_result_figures: 1,
2865            ..AnalysisFigureGenerationOptions::default()
2866        };
2867
2868        let figures = mesh_result_figures(&simple_geometry_asset(), Some(&model), &run, options);
2869
2870        assert_eq!(figures.len(), 1);
2871        assert_eq!(figures[0].title, "FEA boundary regions");
2872        assert!(figures[0]
2873            .warnings
2874            .iter()
2875            .any(|warning| warning.contains("authored constraint region 'fixed'")));
2876        assert!(!figures[0]
2877            .warnings
2878            .iter()
2879            .any(|warning| warning.contains("volume_only")));
2880        let plot = analysis_mesh_plot(&figures[0].figure);
2881        assert_eq!(plot.highlighted_region_id(), Some("loaded"));
2882        assert_eq!(
2883            plot.region_for_triangle(1)
2884                .map(|region| region.region_id.as_str()),
2885            Some("loaded")
2886        );
2887        let vector_field = plot
2888            .vector_field()
2889            .expect("authored load direction should be attached");
2890        assert_eq!(vector_field.location, MeshFieldLocation::Triangle);
2891        assert_eq!(vector_field.field_id, "authored.boundary_load_direction");
2892        assert_eq!(
2893            vector_field.vectors,
2894            vec![Vec3::ZERO, Vec3::new(0.0, 0.0, -1.0), Vec3::ZERO]
2895        );
2896    }
2897
2898    #[test]
2899    fn mesh_result_figures_show_moment_load_axis() {
2900        let run = simple_run_result(Vec::new(), {
2901            let mut topology = mapped_render_topology(
2902                vec![Some(0), Some(1), Some(2), Some(3)],
2903                vec![Some(0), Some(0), Some(0)],
2904            );
2905            topology.meshes[0].regions = vec![crate::analysis::contracts::AnalysisRenderRegion {
2906                region_id: "axis_region".to_string(),
2907                label: None,
2908                tag: Some("boundary".to_string()),
2909                triangle_ranges: vec![crate::analysis::contracts::AnalysisRenderTriangleRange {
2910                    start: 2,
2911                    count: 1,
2912                }],
2913            }];
2914            topology
2915        });
2916        let model = simple_moment_region_model();
2917        let options = AnalysisFigureGenerationOptions {
2918            max_mesh_result_figures: 1,
2919            ..AnalysisFigureGenerationOptions::default()
2920        };
2921
2922        let figures = mesh_result_figures(&simple_geometry_asset(), Some(&model), &run, options);
2923
2924        assert_eq!(figures.len(), 1);
2925        assert_eq!(figures[0].title, "FEA boundary regions");
2926        let plot = analysis_mesh_plot(&figures[0].figure);
2927        let vector_field = plot
2928            .vector_field()
2929            .expect("moment load axis should be attached");
2930        assert_eq!(vector_field.location, MeshFieldLocation::Triangle);
2931        assert_eq!(
2932            vector_field.vectors,
2933            vec![Vec3::ZERO, Vec3::ZERO, Vec3::new(0.0, 1.0, 0.0)]
2934        );
2935    }
2936
2937    #[test]
2938    fn mesh_result_figures_show_wrench_moment_axis_when_force_is_zero() {
2939        let run = simple_run_result(Vec::new(), {
2940            let mut topology = mapped_render_topology(
2941                vec![Some(0), Some(1), Some(2), Some(3)],
2942                vec![Some(0), Some(0), Some(0)],
2943            );
2944            topology.meshes[0].regions = vec![crate::analysis::contracts::AnalysisRenderRegion {
2945                region_id: "wrench_axis_region".to_string(),
2946                label: None,
2947                tag: Some("boundary".to_string()),
2948                triangle_ranges: vec![crate::analysis::contracts::AnalysisRenderTriangleRange {
2949                    start: 1,
2950                    count: 1,
2951                }],
2952            }];
2953            topology
2954        });
2955        let model = simple_wrench_moment_region_model();
2956        let options = AnalysisFigureGenerationOptions {
2957            max_mesh_result_figures: 1,
2958            ..AnalysisFigureGenerationOptions::default()
2959        };
2960
2961        let figures = mesh_result_figures(&simple_geometry_asset(), Some(&model), &run, options);
2962
2963        assert_eq!(figures.len(), 1);
2964        assert_eq!(figures[0].title, "FEA boundary regions");
2965        let plot = analysis_mesh_plot(&figures[0].figure);
2966        let vector_field = plot
2967            .vector_field()
2968            .expect("wrench moment axis should be attached");
2969        assert_eq!(vector_field.location, MeshFieldLocation::Triangle);
2970        assert_eq!(
2971            vector_field.vectors,
2972            vec![Vec3::ZERO, Vec3::new(0.0, 0.0, 1.0), Vec3::ZERO]
2973        );
2974    }
2975
2976    #[test]
2977    fn mesh_result_figures_show_pressure_load_direction_from_triangle_normal() {
2978        let run = simple_run_result(Vec::new(), {
2979            let mut topology = mapped_render_topology(
2980                vec![Some(0), Some(1), Some(2), Some(3)],
2981                vec![Some(0), Some(0), Some(0)],
2982            );
2983            topology.meshes[0].regions = vec![crate::analysis::contracts::AnalysisRenderRegion {
2984                region_id: "pressurized".to_string(),
2985                label: None,
2986                tag: Some("boundary".to_string()),
2987                triangle_ranges: vec![crate::analysis::contracts::AnalysisRenderTriangleRange {
2988                    start: 0,
2989                    count: 1,
2990                }],
2991            }];
2992            topology
2993        });
2994        let model = simple_pressure_region_model();
2995        let options = AnalysisFigureGenerationOptions {
2996            max_mesh_result_figures: 1,
2997            ..AnalysisFigureGenerationOptions::default()
2998        };
2999
3000        let figures = mesh_result_figures(&simple_geometry_asset(), Some(&model), &run, options);
3001
3002        assert_eq!(figures.len(), 1);
3003        assert_eq!(figures[0].title, "FEA boundary regions");
3004        let plot = analysis_mesh_plot(&figures[0].figure);
3005        let vector_field = plot
3006            .vector_field()
3007            .expect("pressure load direction should be attached");
3008        assert_eq!(vector_field.location, MeshFieldLocation::Triangle);
3009        assert_eq!(
3010            vector_field.vectors,
3011            vec![Vec3::new(0.0, 0.0, -1.0), Vec3::ZERO, Vec3::ZERO]
3012        );
3013    }
3014
3015    #[test]
3016    fn field_topology_warning_reports_solver_mesh_mismatch() {
3017        let field = AnalysisField::host_f64("structural.von_mises", vec![1], vec![42.0]);
3018        let meshes = vec![MeshCounts {
3019            plot_index: 0,
3020            vertices: 4,
3021            triangles: 12,
3022            vertex_volume_node_indices: Vec::new(),
3023            triangle_volume_element_indices: Vec::new(),
3024        }];
3025
3026        let warning = field_topology_mismatch_warning(&field, &meshes)
3027            .expect("mismatched Tetrahedron4 element field should produce a warning");
3028
3029        assert!(warning.contains("structural.von_mises"));
3030        assert!(warning.contains("topology_id=analysis_mesh"));
3031        assert!(warning.contains("element_kind=tetrahedron4"));
3032        assert!(warning.contains("value_count=1"));
3033        assert!(warning.contains("render_triangle_count=12"));
3034    }
3035
3036    #[test]
3037    fn field_topology_warning_accepts_mapped_solver_element_fields() {
3038        let field = AnalysisField::host_f64("structural.von_mises", vec![2], vec![10.0, 42.0]);
3039        let meshes = vec![MeshCounts {
3040            plot_index: 0,
3041            vertices: 5,
3042            triangles: 3,
3043            vertex_volume_node_indices: Vec::new(),
3044            triangle_volume_element_indices: vec![Some(0), Some(1), Some(1)],
3045        }];
3046
3047        assert_eq!(field_topology_mismatch_warning(&field, &meshes), None);
3048    }
3049
3050    #[test]
3051    fn field_topology_warning_accepts_mapped_solver_node_fields() {
3052        let field = AnalysisField::host_f64(
3053            "structural.displacement",
3054            vec![3, 3],
3055            vec![
3056                1.0, 0.0, 0.0, //
3057                0.0, 2.0, 0.0, //
3058                0.0, 0.0, 3.0,
3059            ],
3060        );
3061        let meshes = vec![MeshCounts {
3062            plot_index: 0,
3063            vertices: 2,
3064            triangles: 1,
3065            vertex_volume_node_indices: vec![Some(0), Some(2)],
3066            triangle_volume_element_indices: Vec::new(),
3067        }];
3068
3069        assert_eq!(field_topology_mismatch_warning(&field, &meshes), None);
3070    }
3071
3072    #[test]
3073    fn field_topology_warning_rejects_unmapped_solver_vertices() {
3074        let field = AnalysisField::host_f64(
3075            "structural.displacement",
3076            vec![3, 3],
3077            vec![
3078                1.0, 0.0, 0.0, //
3079                0.0, 2.0, 0.0, //
3080                0.0, 0.0, 3.0,
3081            ],
3082        );
3083        let meshes = vec![MeshCounts {
3084            plot_index: 0,
3085            vertices: 3,
3086            triangles: 1,
3087            vertex_volume_node_indices: vec![Some(0), None, Some(2)],
3088            triangle_volume_element_indices: Vec::new(),
3089        }];
3090
3091        let warning = field_topology_mismatch_warning(&field, &meshes)
3092            .expect("unmapped solver vertex should warn");
3093
3094        assert!(warning.contains("structural.displacement"));
3095        assert!(warning.contains("render_vertex_count=3"));
3096    }
3097
3098    #[test]
3099    fn field_topology_warning_rejects_stale_solver_vertex_indices() {
3100        let field = AnalysisField::host_f64(
3101            "structural.displacement",
3102            vec![3, 3],
3103            vec![
3104                1.0, 0.0, 0.0, //
3105                0.0, 2.0, 0.0, //
3106                0.0, 0.0, 3.0,
3107            ],
3108        );
3109        let meshes = vec![MeshCounts {
3110            plot_index: 0,
3111            vertices: 3,
3112            triangles: 1,
3113            vertex_volume_node_indices: vec![Some(0), Some(3), Some(2)],
3114            triangle_volume_element_indices: Vec::new(),
3115        }];
3116
3117        let warning = field_topology_mismatch_warning(&field, &meshes)
3118            .expect("stale solver vertex index should warn");
3119
3120        assert!(warning.contains("structural.displacement"));
3121        assert!(warning.contains("render_vertex_count=3"));
3122    }
3123
3124    #[test]
3125    fn field_topology_warning_rejects_unmapped_solver_triangles() {
3126        let field = AnalysisField::host_f64("structural.von_mises", vec![2], vec![10.0, 42.0]);
3127        let meshes = vec![MeshCounts {
3128            plot_index: 0,
3129            vertices: 5,
3130            triangles: 3,
3131            vertex_volume_node_indices: Vec::new(),
3132            triangle_volume_element_indices: vec![Some(0), None, Some(1)],
3133        }];
3134
3135        let warning = field_topology_mismatch_warning(&field, &meshes)
3136            .expect("unmapped solver triangle should warn");
3137
3138        assert!(warning.contains("structural.von_mises"));
3139        assert!(warning.contains("render_triangle_count=3"));
3140    }
3141
3142    #[test]
3143    fn scalar_overlay_projects_element_values_to_boundary_triangles() {
3144        let field = AnalysisField::host_f64("structural.von_mises", vec![2], vec![10.0, 42.0]);
3145        let meshes = vec![MeshCounts {
3146            plot_index: 0,
3147            vertices: 5,
3148            triangles: 3,
3149            vertex_volume_node_indices: Vec::new(),
3150            triangle_volume_element_indices: vec![Some(0), Some(1), Some(1)],
3151        }];
3152
3153        let overlay = scalar_overlay(&field, &meshes, AnalysisFigureGenerationOptions::default())
3154            .expect("element scalar field should project to boundary triangles");
3155
3156        assert_eq!(overlay.location, MeshFieldLocation::Triangle);
3157        assert_eq!(overlay.chunks, vec![vec![10.0, 42.0, 42.0]]);
3158    }
3159
3160    #[test]
3161    fn scalar_overlay_prefers_solver_element_mapping_when_counts_match() {
3162        let field =
3163            AnalysisField::host_f64("structural.von_mises", vec![3], vec![10.0, 20.0, 30.0]);
3164        let meshes = vec![MeshCounts {
3165            plot_index: 0,
3166            vertices: 5,
3167            triangles: 3,
3168            vertex_volume_node_indices: Vec::new(),
3169            triangle_volume_element_indices: vec![Some(2), Some(0), Some(1)],
3170        }];
3171
3172        let overlay = scalar_overlay(&field, &meshes, AnalysisFigureGenerationOptions::default())
3173            .expect("element scalar field should use explicit solver mapping");
3174
3175        assert_eq!(overlay.location, MeshFieldLocation::Triangle);
3176        assert_eq!(overlay.chunks, vec![vec![30.0, 10.0, 20.0]]);
3177    }
3178
3179    #[test]
3180    fn structural_summary_figure_reports_reactions_and_metrics() {
3181        let fields = vec![
3182            AnalysisField::host_f64(
3183                FEA_FIELD_STRUCTURAL_REACTION_FORCE,
3184                vec![2, 3],
3185                vec![3.0, 4.0, 0.0, 0.0, 0.0, 12.0],
3186            ),
3187            AnalysisField::host_f64(
3188                FEA_FIELD_STRUCTURAL_REACTION_MOMENT,
3189                vec![1, 3],
3190                vec![0.0, 0.0, 2.0],
3191            ),
3192            AnalysisField::host_f64(FEA_FIELD_STRUCTURAL_TOTAL_STRAIN_ENERGY, vec![1], vec![7.5]),
3193            AnalysisField::host_f64(FEA_FIELD_STRUCTURAL_RESIDUAL_NORM, vec![1], vec![0.001]),
3194        ];
3195
3196        assert_eq!(
3197            vector_field_total_magnitude(&fields, FEA_FIELD_STRUCTURAL_REACTION_FORCE),
3198            Some(13.0)
3199        );
3200        let figure = structural_result_summary_figure(&fields)
3201            .expect("structural summary should be generated");
3202
3203        assert_eq!(figure.kind, AnalysisGeneratedFigureKind::Summary);
3204        assert_eq!(figure.title, "FEA structural result summary");
3205        assert!(figure
3206            .field_ids
3207            .iter()
3208            .any(|field_id| field_id == FEA_FIELD_STRUCTURAL_REACTION_FORCE));
3209        assert!(figure
3210            .field_ids
3211            .iter()
3212            .any(|field_id| field_id == FEA_FIELD_STRUCTURAL_TOTAL_STRAIN_ENERGY));
3213        assert!(figure
3214            .figure
3215            .plots()
3216            .any(|plot| matches!(plot, PlotElement::Bar(_))));
3217    }
3218
3219    #[test]
3220    fn scalar_overlay_projects_node_values_to_render_vertices() {
3221        let field = AnalysisField::host_f64(
3222            "structural.nodal_von_mises",
3223            vec![4],
3224            vec![1.0, 2.0, 3.0, 4.0],
3225        );
3226        let meshes = vec![MeshCounts {
3227            plot_index: 0,
3228            vertices: 3,
3229            triangles: 1,
3230            vertex_volume_node_indices: vec![Some(0), Some(3), Some(1)],
3231            triangle_volume_element_indices: Vec::new(),
3232        }];
3233
3234        let overlay = scalar_overlay(&field, &meshes, AnalysisFigureGenerationOptions::default())
3235            .expect("node scalar field should project to render vertices");
3236
3237        assert_eq!(overlay.location, MeshFieldLocation::Vertex);
3238        assert_eq!(overlay.chunks, vec![vec![1.0, 4.0, 2.0]]);
3239    }
3240
3241    #[test]
3242    fn scalar_overlay_prefers_solver_node_mapping_when_counts_match() {
3243        let field =
3244            AnalysisField::host_f64("structural.nodal_von_mises", vec![3], vec![1.0, 2.0, 3.0]);
3245        let meshes = vec![MeshCounts {
3246            plot_index: 0,
3247            vertices: 3,
3248            triangles: 1,
3249            vertex_volume_node_indices: vec![Some(2), Some(0), Some(1)],
3250            triangle_volume_element_indices: Vec::new(),
3251        }];
3252
3253        let overlay = scalar_overlay(&field, &meshes, AnalysisFigureGenerationOptions::default())
3254            .expect("node scalar field should use explicit solver mapping");
3255
3256        assert_eq!(overlay.location, MeshFieldLocation::Vertex);
3257        assert_eq!(overlay.chunks, vec![vec![3.0, 1.0, 2.0]]);
3258    }
3259
3260    #[test]
3261    fn scalar_overlay_prefers_solver_node_mapping_for_vector_magnitude_when_counts_match() {
3262        let field = AnalysisField::host_f64(
3263            "structural.displacement",
3264            vec![3, 3],
3265            vec![
3266                1.0, 0.0, 0.0, //
3267                0.0, 2.0, 0.0, //
3268                0.0, 0.0, 3.0,
3269            ],
3270        );
3271        let meshes = vec![MeshCounts {
3272            plot_index: 0,
3273            vertices: 3,
3274            triangles: 1,
3275            vertex_volume_node_indices: vec![Some(2), Some(0), Some(1)],
3276            triangle_volume_element_indices: Vec::new(),
3277        }];
3278
3279        let overlay = scalar_overlay(&field, &meshes, AnalysisFigureGenerationOptions::default())
3280            .expect("node vector magnitude should use explicit solver mapping");
3281
3282        assert_eq!(overlay.location, MeshFieldLocation::Vertex);
3283        assert_eq!(overlay.chunks, vec![vec![3.0, 1.0, 2.0]]);
3284    }
3285
3286    #[test]
3287    fn vector_overlay_projects_node_values_to_render_vertices() {
3288        let field = AnalysisField::host_f64(
3289            "structural.displacement",
3290            vec![4, 3],
3291            vec![
3292                1.0, 0.0, 0.0, //
3293                0.0, 2.0, 0.0, //
3294                0.0, 0.0, 3.0, //
3295                4.0, 0.0, 0.0,
3296            ],
3297        );
3298        let meshes = vec![MeshCounts {
3299            plot_index: 0,
3300            vertices: 3,
3301            triangles: 1,
3302            vertex_volume_node_indices: vec![Some(0), Some(3), Some(1)],
3303            triangle_volume_element_indices: Vec::new(),
3304        }];
3305
3306        let overlay = vector_overlay(&field, &meshes, AnalysisFigureGenerationOptions::default())
3307            .expect("node vector field should project to render vertices");
3308
3309        assert_eq!(overlay.location, MeshFieldLocation::Vertex);
3310        assert_eq!(
3311            overlay.chunks,
3312            vec![vec![
3313                Vec3::new(1.0, 0.0, 0.0),
3314                Vec3::new(4.0, 0.0, 0.0),
3315                Vec3::new(0.0, 2.0, 0.0)
3316            ]]
3317        );
3318    }
3319
3320    #[test]
3321    fn vector_overlay_prefers_solver_node_mapping_when_counts_match() {
3322        let field = AnalysisField::host_f64(
3323            "structural.displacement",
3324            vec![3, 3],
3325            vec![
3326                1.0, 0.0, 0.0, //
3327                0.0, 2.0, 0.0, //
3328                0.0, 0.0, 3.0,
3329            ],
3330        );
3331        let meshes = vec![MeshCounts {
3332            plot_index: 0,
3333            vertices: 3,
3334            triangles: 1,
3335            vertex_volume_node_indices: vec![Some(2), Some(0), Some(1)],
3336            triangle_volume_element_indices: Vec::new(),
3337        }];
3338
3339        let overlay = vector_overlay(&field, &meshes, AnalysisFigureGenerationOptions::default())
3340            .expect("node vector field should use explicit solver mapping");
3341
3342        assert_eq!(overlay.location, MeshFieldLocation::Vertex);
3343        assert_eq!(
3344            overlay.chunks,
3345            vec![vec![
3346                Vec3::new(0.0, 0.0, 3.0),
3347                Vec3::new(1.0, 0.0, 0.0),
3348                Vec3::new(0.0, 2.0, 0.0)
3349            ]]
3350        );
3351    }
3352
3353    #[test]
3354    fn deformation_overlay_projects_node_values_to_render_vertices() {
3355        let field = AnalysisField::host_f64(
3356            "structural.displacement",
3357            vec![4, 3],
3358            vec![
3359                1.0, 0.0, 0.0, //
3360                0.0, 2.0, 0.0, //
3361                0.0, 0.0, 3.0, //
3362                4.0, 0.0, 0.0,
3363            ],
3364        );
3365        let meshes = vec![MeshCounts {
3366            plot_index: 0,
3367            vertices: 3,
3368            triangles: 1,
3369            vertex_volume_node_indices: vec![Some(0), Some(3), Some(1)],
3370            triangle_volume_element_indices: Vec::new(),
3371        }];
3372        let figure = render_topology_figure(
3373            &simple_render_topology(),
3374            "solver mesh",
3375            AnalysisFigureGenerationOptions::default(),
3376        )
3377        .expect("solver topology should render");
3378
3379        let overlay = deformation_overlay(
3380            &field,
3381            &meshes,
3382            &figure,
3383            AnalysisFigureGenerationOptions::default(),
3384        )
3385        .expect("node vector field should project to render deformation");
3386
3387        assert_eq!(
3388            overlay.chunks,
3389            vec![vec![
3390                Vec3::new(1.0, 0.0, 0.0),
3391                Vec3::new(4.0, 0.0, 0.0),
3392                Vec3::new(0.0, 2.0, 0.0)
3393            ]]
3394        );
3395    }
3396
3397    #[test]
3398    fn deformation_overlay_prefers_solver_node_mapping_when_counts_match() {
3399        let field = AnalysisField::host_f64(
3400            "structural.displacement",
3401            vec![3, 3],
3402            vec![
3403                1.0, 0.0, 0.0, //
3404                0.0, 2.0, 0.0, //
3405                0.0, 0.0, 3.0,
3406            ],
3407        );
3408        let meshes = vec![MeshCounts {
3409            plot_index: 0,
3410            vertices: 3,
3411            triangles: 1,
3412            vertex_volume_node_indices: vec![Some(2), Some(0), Some(1)],
3413            triangle_volume_element_indices: Vec::new(),
3414        }];
3415        let figure = render_topology_figure(
3416            &simple_render_topology(),
3417            "solver mesh",
3418            AnalysisFigureGenerationOptions::default(),
3419        )
3420        .expect("solver topology should render");
3421
3422        let overlay = deformation_overlay(
3423            &field,
3424            &meshes,
3425            &figure,
3426            AnalysisFigureGenerationOptions::default(),
3427        )
3428        .expect("node vector field should use explicit solver deformation mapping");
3429
3430        assert_eq!(
3431            overlay.chunks,
3432            vec![vec![
3433                Vec3::new(0.0, 0.0, 3.0),
3434                Vec3::new(1.0, 0.0, 0.0),
3435                Vec3::new(0.0, 2.0, 0.0)
3436            ]]
3437        );
3438    }
3439}