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(), ¤t, options);
128
129 if options.include_comparison {
130 if let Some(previous) = previous_run_of_kind(¤t)? {
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 ®ions {
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 == ®ion.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, ®ions, &mut warnings);
402 }
403
404 for region in ®ions {
405 if !present.iter().any(|existing| existing == ®ion.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, 0.0, 2.0, 0.0, 0.0, 0.0, 3.0, 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, 0.0, 2.0, 0.0, 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, 0.0, 2.0, 0.0, 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, 0.0, 2.0, 0.0, 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, 0.0, 2.0, 0.0, 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, 0.0, 2.0, 0.0, 0.0, 0.0, 3.0, 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, 0.0, 2.0, 0.0, 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, 0.0, 2.0, 0.0, 0.0, 0.0, 3.0, 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, 0.0, 2.0, 0.0, 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}