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