1use std::collections::HashMap;
2use std::f64::consts::PI;
3
4use glam::Vec3;
5use viewport_lib::{AttributeData, BuiltinColourmap, MeshData};
6
7use crate::{
8 ArrowAnnotation, ColormapSource, ColourMode, CurveInterpolation, CurveInterpolationKind,
9 Diagnostic, DiagnosticKind, DiagnosticLocation, PlotMetadata, PlotSpec, PlotStyle,
10 PointAnnotation, SliceAxis, TableDataSet, default_slice_position, eval_curve_point,
11 eval_with_vars, parse_curve_expr, parse_expr_with_vars, sample_curve_points,
12};
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum AnalysisKind {
17 PointCloudStatistics,
18 DataQualityChecks,
19 InterpolateCurve,
20 FitCurve,
21 DifferentiateCurve,
22 AxisDerivativeCurve,
23 IntegralCurve,
24 ArcLengthCurve,
25 CurvatureCurve,
26 TangentField,
27 NormalField,
28 BinormalField,
29 FrenetFrame,
30 BishopFrame,
31 DarbouxFrame,
32 SurfaceAlignedFrame,
33 ExtractPoints,
34 ScalarSlice,
35 VectorSlice,
36 GradientField,
37 DivergenceField,
38 CurlField,
39 SurfaceNormals,
40 SurfaceCurvature,
41 SurfaceArea,
42 SurfaceMeshQuality,
43 CurveSurfaceMeasurement,
44 SurfaceIntersection,
45}
46
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub enum AnalysisOutputKind {
50 PlotSpec,
52 NumericReport,
54 Table,
56 Composite,
58}
59
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub enum AnalysisTargetKind {
63 Definition,
65 SampledData,
67 Geometry,
69 PlotPair,
71}
72
73#[derive(Clone, Debug, PartialEq)]
75pub struct AnalysisCapability {
76 pub kind: AnalysisKind,
77 pub target_kind: AnalysisTargetKind,
78 pub output_kind: AnalysisOutputKind,
79 pub parameters: Vec<&'static str>,
81}
82
83#[derive(Clone, Debug, PartialEq)]
85pub enum AnalysisTarget {
86 Plot { index: usize, name: Option<String> },
87 PlotPair { first: usize, second: usize },
88}
89
90#[derive(Clone, Debug, PartialEq)]
92pub struct AnalysisRequest {
93 pub kind: AnalysisKind,
94 pub target: AnalysisTarget,
95 pub parameters: Vec<(String, String)>,
97}
98
99#[derive(Clone, Debug, PartialEq)]
101pub struct AnalysisProvenance {
102 pub kind: AnalysisKind,
103 pub source_plots: Vec<String>,
104 pub parameters: Vec<(String, String)>,
105 pub notes: Vec<String>,
106}
107
108#[derive(Clone, Debug, PartialEq)]
110pub struct AnalysisReport {
111 pub title: String,
112 pub values: Vec<(String, String)>,
113}
114
115#[derive(Clone, Debug, PartialEq)]
117pub struct AnalysisTable {
118 pub title: String,
119 pub columns: Vec<String>,
120 pub rows: Vec<Vec<String>>,
121}
122
123#[derive(Clone, Debug, PartialEq)]
125pub struct FrameSample {
126 pub parameter: f32,
128 pub position: [f32; 3],
129 pub tangent: [f32; 3],
130 pub normal: [f32; 3],
131 pub binormal: [f32; 3],
132}
133
134#[derive(Clone, Debug, PartialEq)]
136pub struct FrameField {
137 pub title: String,
138 pub source_plot: String,
139 pub frame_kind: AnalysisKind,
140 pub samples: Vec<FrameSample>,
141}
142
143#[derive(Clone, Debug)]
145pub enum AnalysisOutput {
146 DerivedPlots {
147 plots: Vec<PlotSpec>,
148 provenance: AnalysisProvenance,
149 },
150 Report {
151 report: AnalysisReport,
152 provenance: AnalysisProvenance,
153 },
154 Table {
155 table: AnalysisTable,
156 provenance: AnalysisProvenance,
157 },
158 Composite {
159 plots: Vec<PlotSpec>,
160 reports: Vec<AnalysisReport>,
161 tables: Vec<AnalysisTable>,
162 diagnostics: Vec<Diagnostic>,
163 frame_fields: Vec<FrameField>,
164 provenance: AnalysisProvenance,
165 },
166}
167
168#[derive(Clone, Copy, Debug, PartialEq, Eq)]
170pub enum SampleGroupsKind {
171 Curve,
173 Polyline,
175 InterpolationSource,
177 SampleData,
179}
180
181#[derive(Clone, Debug)]
184pub struct AnalysisError {
185 pub diagnostic: Diagnostic,
186}
187
188impl AnalysisError {
189 pub fn unsupported(message: impl Into<String>) -> Self {
190 Self {
191 diagnostic: Diagnostic::error(DiagnosticKind::Build, message),
192 }
193 }
194
195 pub fn invalid(message: impl Into<String>) -> Self {
196 Self {
197 diagnostic: Diagnostic::error(DiagnosticKind::Validation, message),
198 }
199 }
200}
201
202impl std::fmt::Display for AnalysisError {
203 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204 write!(f, "{}", self.diagnostic)
205 }
206}
207
208impl std::error::Error for AnalysisError {}
209
210pub fn available_analyses(plot: &PlotSpec) -> Vec<AnalysisCapability> {
212 let metadata = plot.metadata();
213 let mut capabilities = capabilities_for_metadata(&metadata);
214 if supports_point_cloud_statistics(plot) {
215 capabilities.push(AnalysisCapability {
216 kind: AnalysisKind::PointCloudStatistics,
217 target_kind: AnalysisTargetKind::SampledData,
218 output_kind: AnalysisOutputKind::Composite,
219 parameters: vec![],
220 });
221 }
222 if supports_data_quality_analysis(plot) {
223 capabilities.push(AnalysisCapability {
224 kind: AnalysisKind::DataQualityChecks,
225 target_kind: AnalysisTargetKind::SampledData,
226 output_kind: AnalysisOutputKind::Composite,
227 parameters: vec![],
228 });
229 }
230 capabilities
231}
232
233pub fn sample_groups(
235 plot: &PlotSpec,
236 kind: SampleGroupsKind,
237) -> Result<Vec<Vec<[f32; 3]>>, AnalysisError> {
238 match kind {
239 SampleGroupsKind::Curve => curve_sample_groups(plot),
240 SampleGroupsKind::Polyline => polyline_sample_groups(plot),
241 SampleGroupsKind::InterpolationSource => interpolation_source_groups(plot),
242 SampleGroupsKind::SampleData => sample_data_groups(plot),
243 }
244}
245
246pub fn run_analysis(
250 plot: &PlotSpec,
251 request: &AnalysisRequest,
252) -> Result<AnalysisOutput, AnalysisError> {
253 let params = parameter_map(&request.parameters);
254 let provenance = AnalysisProvenance {
255 kind: request.kind,
256 source_plots: vec![plot.name.clone()],
257 parameters: request.parameters.clone(),
258 notes: Vec::new(),
259 };
260
261 let plots = match request.kind {
262 AnalysisKind::PointCloudStatistics => return make_point_cloud_statistics_output(plot),
263 AnalysisKind::DataQualityChecks => return make_data_quality_output(plot),
264 AnalysisKind::ScalarSlice => vec![make_scalar_slice_plot(
265 plot,
266 parse_axis(params.get("axis").map(String::as_str)).unwrap_or(SliceAxis::Z),
267 params
268 .get("position")
269 .and_then(|value| value.parse::<f64>().ok()),
270 params
271 .get("contours")
272 .and_then(|value| value.parse::<usize>().ok()),
273 )?],
274 AnalysisKind::VectorSlice => vec![make_vector_slice_plot(
275 plot,
276 parse_axis(params.get("axis").map(String::as_str)).unwrap_or(SliceAxis::Z),
277 params
278 .get("position")
279 .and_then(|value| value.parse::<f64>().ok()),
280 )?],
281 AnalysisKind::GradientField => vec![make_gradient_plot(plot)?],
282 AnalysisKind::DivergenceField => vec![make_divergence_plot(plot)?],
283 AnalysisKind::CurlField => vec![make_curl_plot(plot)?],
284 AnalysisKind::DifferentiateCurve => vec![make_curve_derivative_plot(plot)?],
285 AnalysisKind::FitCurve => {
286 return make_curve_fit_output(plot, build_curve_fit_options(¶ms));
287 }
288 AnalysisKind::AxisDerivativeCurve => vec![make_axis_derivative_plot(
289 plot,
290 parse_axis_index(params.get("numerator_axis").map(String::as_str)).unwrap_or(1),
291 parse_axis_index(params.get("denominator_axis").map(String::as_str)).unwrap_or(0),
292 params.get("output_name").cloned(),
293 )?],
294 AnalysisKind::IntegralCurve => vec![make_curve_integral_plot(
295 plot,
296 params
297 .get("normalize_integral")
298 .is_none_or(|value| matches!(value.as_str(), "1" | "true" | "yes")),
299 )?],
300 AnalysisKind::ArcLengthCurve => vec![make_curve_arc_length_plot(plot)?],
301 AnalysisKind::CurvatureCurve => vec![make_curve_curvature_plot(plot)?],
302 AnalysisKind::TangentField => vec![make_curve_tangent_plot(plot)?],
303 AnalysisKind::NormalField => vec![make_curve_normal_plot(plot)?],
304 AnalysisKind::BinormalField => vec![make_curve_binormal_plot(plot)?],
305 AnalysisKind::FrenetFrame => return make_curve_frame_output(plot, request.kind, ¶ms),
306 AnalysisKind::BishopFrame => return make_curve_frame_output(plot, request.kind, ¶ms),
307 AnalysisKind::DarbouxFrame
308 | AnalysisKind::SurfaceAlignedFrame
309 | AnalysisKind::CurveSurfaceMeasurement => {
310 return Err(AnalysisError::unsupported(
311 "Curve-surface analyses require both curve and surface context.",
312 ));
313 }
314 AnalysisKind::ExtractPoints => vec![make_extracted_points_plot(plot)?],
315 AnalysisKind::InterpolateCurve => make_interpolated_plots(
316 plot,
317 build_interpolation(¶ms),
318 params.get("output_name").cloned(),
319 )?,
320 AnalysisKind::SurfaceNormals
321 | AnalysisKind::SurfaceCurvature
322 | AnalysisKind::SurfaceArea
323 | AnalysisKind::SurfaceMeshQuality => {
324 return Err(AnalysisError::unsupported(
325 "Surface geometry analyses operate on cached sampled surface meshes.",
326 ));
327 }
328 AnalysisKind::SurfaceIntersection => {
329 return Err(AnalysisError::unsupported(
330 "Surface intersection remains a geometry-level app workflow.",
331 ));
332 }
333 };
334
335 Ok(AnalysisOutput::DerivedPlots { plots, provenance })
336}
337
338fn supports_point_cloud_statistics(plot: &PlotSpec) -> bool {
339 sample_data_groups(plot)
340 .map(|groups| groups.iter().map(Vec::len).sum::<usize>() >= 1)
341 .unwrap_or(false)
342}
343
344fn supports_data_quality_analysis(plot: &PlotSpec) -> bool {
345 sample_data_groups(plot)
346 .map(|groups| groups.iter().map(Vec::len).sum::<usize>() >= 2)
347 .unwrap_or(false)
348}
349
350pub fn run_surface_mesh_analysis(
351 source: &PlotSpec,
352 kind: AnalysisKind,
353 meshes: &[&MeshData],
354 parameters: &[(String, String)],
355) -> Result<AnalysisOutput, AnalysisError> {
356 let combined = CombinedSurfaceMesh::from_meshes(meshes)?;
357 let params = parameter_map(parameters);
358 match kind {
359 AnalysisKind::SurfaceNormals => make_surface_normals_output(
360 source,
361 &combined,
362 params
363 .get("max_samples")
364 .and_then(|value| value.parse::<usize>().ok())
365 .unwrap_or(256),
366 params
367 .get("vector_scale")
368 .and_then(|value| value.parse::<f32>().ok())
369 .unwrap_or(1.0),
370 ),
371 AnalysisKind::SurfaceCurvature => make_surface_curvature_output(source, &combined, ¶ms),
372 AnalysisKind::SurfaceArea => make_surface_area_output(source, &combined),
373 AnalysisKind::SurfaceMeshQuality => make_surface_mesh_quality_output(source, &combined),
374 _ => Err(AnalysisError::unsupported(
375 "Not a surface-mesh analysis kind.",
376 )),
377 }
378}
379
380pub fn run_curve_surface_frame_analysis(
381 curve_source: &PlotSpec,
382 surface_source_name: &str,
383 kind: AnalysisKind,
384 meshes: &[&MeshData],
385 parameters: &[(String, String)],
386) -> Result<AnalysisOutput, AnalysisError> {
387 let combined = CombinedSurfaceMesh::from_meshes(meshes)?;
388 let params = parameter_map(parameters);
389 let groups = curve_sample_groups(curve_source)?;
390 let frame_fields = groups
391 .iter()
392 .enumerate()
393 .filter_map(|(index, group)| {
394 build_curve_surface_frame_field(
395 group,
396 curve_source,
397 surface_source_name,
398 kind,
399 &combined,
400 index,
401 groups.len(),
402 )
403 .ok()
404 })
405 .collect::<Vec<_>>();
406 if frame_fields.is_empty() {
407 return Err(AnalysisError::invalid(
408 "Surface-coupled frame analysis requires a sampled curve and a usable sampled surface mesh.",
409 ));
410 }
411
412 let _max_samples = params
413 .get("max_samples")
414 .and_then(|value| value.parse::<usize>().ok())
415 .unwrap_or(128);
416 let _vector_scale = params
417 .get("vector_scale")
418 .and_then(|value| value.parse::<f32>().ok())
419 .unwrap_or(1.0)
420 .max(0.01);
421
422 let reports = frame_fields
423 .iter()
424 .map(|field| frame_field_report(field))
425 .collect::<Vec<_>>();
426 let tables = frame_fields
427 .iter()
428 .map(|field| frame_field_table(field))
429 .collect::<Vec<_>>();
430
431 Ok(AnalysisOutput::Composite {
432 plots: Vec::new(),
433 reports,
434 tables,
435 diagnostics: Vec::new(),
436 frame_fields,
437 provenance: AnalysisProvenance {
438 kind,
439 source_plots: vec![curve_source.name.clone(), surface_source_name.to_string()],
440 parameters: parameters.to_vec(),
441 notes: vec![
442 "Surface-coupled frame normals are sampled from the nearest cached surface vertices.".to_string(),
443 ],
444 },
445 })
446}
447
448pub fn run_curve_surface_measurement_analysis(
449 curve_source: &PlotSpec,
450 surface_source_name: &str,
451 meshes: &[&MeshData],
452 parameters: &[(String, String)],
453) -> Result<AnalysisOutput, AnalysisError> {
454 let combined = CombinedSurfaceMesh::from_meshes(meshes)?;
455 let params = parameter_map(parameters);
456 let max_samples = params
457 .get("max_samples")
458 .and_then(|value| value.parse::<usize>().ok())
459 .unwrap_or(512)
460 .max(2);
461 let vector_scale = params
462 .get("vector_scale")
463 .and_then(|value| value.parse::<f32>().ok())
464 .unwrap_or(1.0)
465 .max(0.05);
466 make_curve_surface_measurement_output(
467 curve_source,
468 surface_source_name,
469 &combined,
470 max_samples,
471 vector_scale,
472 )
473}
474
475fn capabilities_for_metadata(metadata: &PlotMetadata) -> Vec<AnalysisCapability> {
476 let mut capabilities = Vec::new();
477
478 if metadata.style_caps.line {
479 capabilities.extend([
480 AnalysisCapability {
481 kind: AnalysisKind::InterpolateCurve,
482 target_kind: AnalysisTargetKind::SampledData,
483 output_kind: AnalysisOutputKind::PlotSpec,
484 parameters: vec![
485 "output_name",
486 "interpolation_kind",
487 "samples_per_segment",
488 "closed",
489 "smoothing_window",
490 ],
491 },
492 AnalysisCapability {
493 kind: AnalysisKind::FitCurve,
494 target_kind: AnalysisTargetKind::SampledData,
495 output_kind: AnalysisOutputKind::Composite,
496 parameters: vec![
497 "fit_method",
498 "output_name",
499 "degree",
500 "harmonics",
501 "smoothing_window",
502 "samples_per_segment",
503 "show_control_points",
504 "show_residual_plot",
505 ],
506 },
507 AnalysisCapability {
508 kind: AnalysisKind::DifferentiateCurve,
509 target_kind: AnalysisTargetKind::Definition,
510 output_kind: AnalysisOutputKind::PlotSpec,
511 parameters: vec![],
512 },
513 AnalysisCapability {
514 kind: AnalysisKind::AxisDerivativeCurve,
515 target_kind: AnalysisTargetKind::SampledData,
516 output_kind: AnalysisOutputKind::PlotSpec,
517 parameters: vec!["numerator_axis", "denominator_axis", "output_name"],
518 },
519 AnalysisCapability {
520 kind: AnalysisKind::IntegralCurve,
521 target_kind: AnalysisTargetKind::Definition,
522 output_kind: AnalysisOutputKind::PlotSpec,
523 parameters: vec!["normalize_integral"],
524 },
525 AnalysisCapability {
526 kind: AnalysisKind::ArcLengthCurve,
527 target_kind: AnalysisTargetKind::SampledData,
528 output_kind: AnalysisOutputKind::PlotSpec,
529 parameters: vec![],
530 },
531 AnalysisCapability {
532 kind: AnalysisKind::CurvatureCurve,
533 target_kind: AnalysisTargetKind::SampledData,
534 output_kind: AnalysisOutputKind::PlotSpec,
535 parameters: vec![],
536 },
537 AnalysisCapability {
538 kind: AnalysisKind::TangentField,
539 target_kind: AnalysisTargetKind::SampledData,
540 output_kind: AnalysisOutputKind::PlotSpec,
541 parameters: vec![],
542 },
543 AnalysisCapability {
544 kind: AnalysisKind::NormalField,
545 target_kind: AnalysisTargetKind::SampledData,
546 output_kind: AnalysisOutputKind::PlotSpec,
547 parameters: vec![],
548 },
549 AnalysisCapability {
550 kind: AnalysisKind::BinormalField,
551 target_kind: AnalysisTargetKind::SampledData,
552 output_kind: AnalysisOutputKind::PlotSpec,
553 parameters: vec![],
554 },
555 AnalysisCapability {
556 kind: AnalysisKind::ExtractPoints,
557 target_kind: AnalysisTargetKind::SampledData,
558 output_kind: AnalysisOutputKind::PlotSpec,
559 parameters: vec![],
560 },
561 AnalysisCapability {
562 kind: AnalysisKind::FrenetFrame,
563 target_kind: AnalysisTargetKind::SampledData,
564 output_kind: AnalysisOutputKind::Composite,
565 parameters: vec!["max_samples", "vector_scale"],
566 },
567 AnalysisCapability {
568 kind: AnalysisKind::BishopFrame,
569 target_kind: AnalysisTargetKind::SampledData,
570 output_kind: AnalysisOutputKind::Composite,
571 parameters: vec!["max_samples", "vector_scale"],
572 },
573 AnalysisCapability {
574 kind: AnalysisKind::DarbouxFrame,
575 target_kind: AnalysisTargetKind::PlotPair,
576 output_kind: AnalysisOutputKind::Composite,
577 parameters: vec!["max_samples", "vector_scale"],
578 },
579 AnalysisCapability {
580 kind: AnalysisKind::SurfaceAlignedFrame,
581 target_kind: AnalysisTargetKind::PlotPair,
582 output_kind: AnalysisOutputKind::Composite,
583 parameters: vec!["max_samples", "vector_scale"],
584 },
585 AnalysisCapability {
586 kind: AnalysisKind::CurveSurfaceMeasurement,
587 target_kind: AnalysisTargetKind::PlotPair,
588 output_kind: AnalysisOutputKind::Composite,
589 parameters: vec!["max_samples", "vector_scale"],
590 },
591 ]);
592 }
593
594 if metadata.coordinate_semantics == crate::CoordinateSemantics::CartesianVolume {
595 capabilities.extend([
596 AnalysisCapability {
597 kind: AnalysisKind::ScalarSlice,
598 target_kind: AnalysisTargetKind::Definition,
599 output_kind: AnalysisOutputKind::PlotSpec,
600 parameters: vec!["axis", "position", "contours"],
601 },
602 AnalysisCapability {
603 kind: AnalysisKind::VectorSlice,
604 target_kind: AnalysisTargetKind::Definition,
605 output_kind: AnalysisOutputKind::PlotSpec,
606 parameters: vec!["axis", "position"],
607 },
608 ]);
609 }
610
611 if metadata.required_variables == ["x".to_string(), "y".to_string(), "z".to_string()] {
612 capabilities.extend([
613 AnalysisCapability {
614 kind: AnalysisKind::GradientField,
615 target_kind: AnalysisTargetKind::Definition,
616 output_kind: AnalysisOutputKind::PlotSpec,
617 parameters: vec![],
618 },
619 AnalysisCapability {
620 kind: AnalysisKind::DivergenceField,
621 target_kind: AnalysisTargetKind::Definition,
622 output_kind: AnalysisOutputKind::PlotSpec,
623 parameters: vec![],
624 },
625 AnalysisCapability {
626 kind: AnalysisKind::CurlField,
627 target_kind: AnalysisTargetKind::Definition,
628 output_kind: AnalysisOutputKind::PlotSpec,
629 parameters: vec![],
630 },
631 ]);
632 }
633
634 if metadata.supports_surface_intersection {
635 capabilities.extend([
636 AnalysisCapability {
637 kind: AnalysisKind::SurfaceNormals,
638 target_kind: AnalysisTargetKind::Geometry,
639 output_kind: AnalysisOutputKind::PlotSpec,
640 parameters: vec![],
641 },
642 AnalysisCapability {
643 kind: AnalysisKind::SurfaceCurvature,
644 target_kind: AnalysisTargetKind::Geometry,
645 output_kind: AnalysisOutputKind::Composite,
646 parameters: vec![],
647 },
648 AnalysisCapability {
649 kind: AnalysisKind::SurfaceArea,
650 target_kind: AnalysisTargetKind::Geometry,
651 output_kind: AnalysisOutputKind::NumericReport,
652 parameters: vec![],
653 },
654 AnalysisCapability {
655 kind: AnalysisKind::SurfaceMeshQuality,
656 target_kind: AnalysisTargetKind::Geometry,
657 output_kind: AnalysisOutputKind::Composite,
658 parameters: vec![],
659 },
660 AnalysisCapability {
661 kind: AnalysisKind::SurfaceIntersection,
662 target_kind: AnalysisTargetKind::PlotPair,
663 output_kind: AnalysisOutputKind::PlotSpec,
664 parameters: vec!["samples", "tolerance"],
665 },
666 ]);
667 }
668
669 capabilities
670}
671
672fn make_scalar_slice_plot(
673 source: &PlotSpec,
674 axis: SliceAxis,
675 position: Option<f64>,
676 contour_count: Option<usize>,
677) -> Result<PlotSpec, AnalysisError> {
678 let (expression, parameters) = match &source.definition {
679 crate::PlotDefinition::ExprVolume {
680 expression,
681 parameters,
682 ..
683 }
684 | crate::PlotDefinition::ExprIsosurface {
685 expression,
686 parameters,
687 ..
688 } => (expression.clone(), parameters.clone()),
689 _ => {
690 return Err(AnalysisError::unsupported(
691 "Scalar slices require a scalar volume or isosurface source.",
692 ));
693 }
694 };
695 Ok(PlotSpec {
696 name: format!("{} Slice {}", axis.label(), source.name),
697 visible: true,
698 domain: source.domain.clone(),
699 resolution: source.resolution,
700 style: PlotStyle {
701 colour_mode: ColourMode::ByAttribute {
702 name: "value".to_string(),
703 kind: viewport_lib::AttributeKind::Vertex,
704 },
705 two_sided: true,
706 ..source.style.clone()
707 },
708 definition: crate::PlotDefinition::ScalarSlice {
709 expression,
710 parameters,
711 axis,
712 position: position.unwrap_or_else(|| default_slice_position(&source.domain, axis)),
713 contour_values: evenly_spaced_isovalues(contour_count.unwrap_or(8)),
714 contour_style: PlotStyle {
715 colour_mode: ColourMode::Solid([1.0, 0.95, 0.35, 1.0]),
716 line_width: 2.0,
717 ..PlotStyle::default()
718 },
719 },
720 })
721}
722
723fn make_vector_slice_plot(
724 source: &PlotSpec,
725 axis: SliceAxis,
726 position: Option<f64>,
727) -> Result<PlotSpec, AnalysisError> {
728 let (expression, parameters) = match &source.definition {
729 crate::PlotDefinition::ExprVectorField {
730 expression,
731 parameters,
732 } => (expression.clone(), parameters.clone()),
733 _ => {
734 return Err(AnalysisError::unsupported(
735 "Vector slices require a vector field source.",
736 ));
737 }
738 };
739 Ok(PlotSpec {
740 name: format!("{} Slice {}", axis.label(), source.name),
741 visible: true,
742 domain: source.domain.clone(),
743 resolution: source.resolution,
744 style: source.style.clone(),
745 definition: crate::PlotDefinition::VectorSlice {
746 expression,
747 parameters,
748 axis,
749 position: position.unwrap_or_else(|| default_slice_position(&source.domain, axis)),
750 },
751 })
752}
753
754fn make_gradient_plot(source: &PlotSpec) -> Result<PlotSpec, AnalysisError> {
755 let (expression, parameters) = match &source.definition {
756 crate::PlotDefinition::ExprVolume {
757 expression,
758 parameters,
759 ..
760 }
761 | crate::PlotDefinition::ExprIsosurface {
762 expression,
763 parameters,
764 ..
765 } => (expression.clone(), parameters.clone()),
766 _ => {
767 return Err(AnalysisError::unsupported(
768 "Gradient plots require a scalar volume or isosurface source.",
769 ));
770 }
771 };
772 Ok(PlotSpec {
773 name: format!("Gradient {}", source.name),
774 visible: true,
775 domain: source.domain.clone(),
776 resolution: source.resolution,
777 style: PlotStyle {
778 colour_mode: ColourMode::ByAttribute {
779 name: "magnitude".to_string(),
780 kind: viewport_lib::AttributeKind::Vertex,
781 },
782 glyph_scale: 0.8,
783 shading: crate::ShadingMode::Unlit,
784 ..PlotStyle::default()
785 },
786 definition: crate::PlotDefinition::GradientField {
787 expression,
788 parameters,
789 },
790 })
791}
792
793fn make_divergence_plot(source: &PlotSpec) -> Result<PlotSpec, AnalysisError> {
794 let (expression, parameters) = match &source.definition {
795 crate::PlotDefinition::ExprVectorField {
796 expression,
797 parameters,
798 } => (expression.clone(), parameters.clone()),
799 _ => {
800 return Err(AnalysisError::unsupported(
801 "Divergence plots require a vector field source.",
802 ));
803 }
804 };
805 Ok(PlotSpec {
806 name: format!("Divergence {}", source.name),
807 visible: true,
808 domain: source.domain.clone(),
809 resolution: source.resolution,
810 style: PlotStyle {
811 opacity: 0.3,
812 transfer_function: Some(crate::TransferFunction {
813 opacity_scale: 0.4,
814 threshold: None,
815 }),
816 ..PlotStyle::default()
817 },
818 definition: crate::PlotDefinition::DivergenceField {
819 expression,
820 parameters,
821 vol_resolution: [64, 64, 64],
822 },
823 })
824}
825
826fn make_curl_plot(source: &PlotSpec) -> Result<PlotSpec, AnalysisError> {
827 let (expression, parameters) = match &source.definition {
828 crate::PlotDefinition::ExprVectorField {
829 expression,
830 parameters,
831 } => (expression.clone(), parameters.clone()),
832 _ => {
833 return Err(AnalysisError::unsupported(
834 "Curl plots require a vector field source.",
835 ));
836 }
837 };
838 Ok(PlotSpec {
839 name: format!("Curl {}", source.name),
840 visible: true,
841 domain: source.domain.clone(),
842 resolution: source.resolution,
843 style: PlotStyle {
844 colour_mode: ColourMode::ByAttribute {
845 name: "magnitude".to_string(),
846 kind: viewport_lib::AttributeKind::Vertex,
847 },
848 glyph_scale: 0.8,
849 shading: crate::ShadingMode::Unlit,
850 ..PlotStyle::default()
851 },
852 definition: crate::PlotDefinition::CurlField {
853 expression,
854 parameters,
855 },
856 })
857}
858
859fn make_curve_derivative_plot(source: &PlotSpec) -> Result<PlotSpec, AnalysisError> {
860 let groups = curve_sample_groups(source)?;
861 let derived_groups = match &source.definition {
862 crate::PlotDefinition::ExprCartesianLine {
863 dep_var, ind_var, ..
864 } => groups
865 .iter()
866 .map(|group| derivative_cartesian_line_group(group, dep_var.as_str(), ind_var.as_str()))
867 .filter(|group| group.len() >= 2)
868 .collect(),
869 _ => groups
870 .iter()
871 .map(|group| derivative_curve_group(group))
872 .filter(|group| group.len() >= 2)
873 .collect(),
874 };
875 derived_polyline_plot(
876 source,
877 format!("Derivative {}", source.name),
878 [1.0, 0.55, 0.25, 1.0],
879 derived_groups,
880 )
881}
882
883fn make_curve_tangent_plot(source: &PlotSpec) -> Result<PlotSpec, AnalysisError> {
884 let groups = curve_sample_groups(source)?;
885 let derived_groups = groups
886 .iter()
887 .map(|group| tangent_curve_group(group))
888 .filter(|group| group.len() >= 2)
889 .collect();
890 derived_polyline_plot(
891 source,
892 format!("Tangent {}", source.name),
893 [0.25, 0.85, 0.45, 1.0],
894 derived_groups,
895 )
896}
897
898fn make_curve_integral_plot(
899 source: &PlotSpec,
900 normalize_integral: bool,
901) -> Result<PlotSpec, AnalysisError> {
902 let groups = curve_sample_groups(source)?;
903 let derived_groups = match &source.definition {
904 crate::PlotDefinition::ExprCartesianLine {
905 dep_var, ind_var, ..
906 } => groups
907 .iter()
908 .map(|group| {
909 integral_cartesian_line_group(
910 group,
911 dep_var.as_str(),
912 ind_var.as_str(),
913 normalize_integral,
914 )
915 })
916 .filter(|group| group.len() >= 2)
917 .collect(),
918 _ => groups
919 .iter()
920 .map(|group| integral_curve_group(group, normalize_integral))
921 .filter(|group| group.len() >= 2)
922 .collect(),
923 };
924 derived_polyline_plot(
925 source,
926 format!("Integral {}", source.name),
927 [0.45, 0.7, 1.0, 1.0],
928 derived_groups,
929 )
930}
931
932fn make_curve_arc_length_plot(source: &PlotSpec) -> Result<PlotSpec, AnalysisError> {
933 let groups = curve_sample_groups(source)?;
934 let derived_groups = match &source.definition {
935 crate::PlotDefinition::ExprCartesianLine {
936 dep_var, ind_var, ..
937 } => groups
938 .iter()
939 .map(|group| {
940 scalar_plot_cartesian_line_group(
941 group,
942 dep_var.as_str(),
943 ind_var.as_str(),
944 &cumulative_arc_lengths(group),
945 )
946 })
947 .filter(|group| group.len() >= 2)
948 .collect(),
949 _ => groups
950 .iter()
951 .map(|group| scalar_curve_group(group, &cumulative_arc_lengths(group)))
952 .filter(|group| group.len() >= 2)
953 .collect(),
954 };
955 derived_polyline_plot(
956 source,
957 format!("Arc Length {}", source.name),
958 [0.95, 0.85, 0.3, 1.0],
959 derived_groups,
960 )
961}
962
963fn make_curve_curvature_plot(source: &PlotSpec) -> Result<PlotSpec, AnalysisError> {
964 let groups = curve_sample_groups(source)?;
965 let derived_groups = match &source.definition {
966 crate::PlotDefinition::ExprCartesianLine {
967 dep_var, ind_var, ..
968 } => groups
969 .iter()
970 .map(|group| {
971 scalar_plot_cartesian_line_group(
972 group,
973 dep_var.as_str(),
974 ind_var.as_str(),
975 &curvature_values(group),
976 )
977 })
978 .filter(|group| group.len() >= 2)
979 .collect(),
980 _ => groups
981 .iter()
982 .map(|group| scalar_curve_group(group, &curvature_values(group)))
983 .filter(|group| group.len() >= 2)
984 .collect(),
985 };
986 derived_polyline_plot(
987 source,
988 format!("Curvature {}", source.name),
989 [0.8, 0.45, 1.0, 1.0],
990 derived_groups,
991 )
992}
993
994fn make_curve_normal_plot(source: &PlotSpec) -> Result<PlotSpec, AnalysisError> {
995 let groups = curve_sample_groups(source)?;
996 let derived_groups = groups
997 .iter()
998 .map(|group| normal_curve_group(group))
999 .filter(|group| group.len() >= 2)
1000 .collect();
1001 derived_polyline_plot(
1002 source,
1003 format!("Normal {}", source.name),
1004 [0.3, 0.8, 1.0, 1.0],
1005 derived_groups,
1006 )
1007}
1008
1009fn make_curve_binormal_plot(source: &PlotSpec) -> Result<PlotSpec, AnalysisError> {
1010 let groups = curve_sample_groups(source)?;
1011 let derived_groups = groups
1012 .iter()
1013 .map(|group| binormal_curve_group(group))
1014 .filter(|group| group.len() >= 2)
1015 .collect();
1016 derived_polyline_plot(
1017 source,
1018 format!("Binormal {}", source.name),
1019 [1.0, 0.45, 0.65, 1.0],
1020 derived_groups,
1021 )
1022}
1023
1024fn make_curve_frame_output(
1025 source: &PlotSpec,
1026 frame_kind: AnalysisKind,
1027 params: &HashMap<String, String>,
1028) -> Result<AnalysisOutput, AnalysisError> {
1029 let groups = curve_sample_groups(source)?;
1030 let frame_fields = groups
1031 .iter()
1032 .enumerate()
1033 .filter_map(|(index, group)| {
1034 build_curve_frame_field(group, source, frame_kind, index, groups.len()).ok()
1035 })
1036 .collect::<Vec<_>>();
1037 if frame_fields.is_empty() {
1038 return Err(AnalysisError::invalid(
1039 "Moving-frame analysis requires a sampled curve with at least two distinct points.",
1040 ));
1041 }
1042
1043 let max_samples = params
1044 .get("max_samples")
1045 .and_then(|value| value.parse::<usize>().ok())
1046 .unwrap_or(128);
1047 let vector_scale = params
1048 .get("vector_scale")
1049 .and_then(|value| value.parse::<f32>().ok())
1050 .unwrap_or(1.0)
1051 .max(0.01);
1052
1053 let reports = frame_fields
1054 .iter()
1055 .map(|field| frame_field_report(field))
1056 .collect::<Vec<_>>();
1057 let tables = frame_fields
1058 .iter()
1059 .map(|field| frame_field_table(field))
1060 .collect::<Vec<_>>();
1061
1062 Ok(AnalysisOutput::Composite {
1063 plots: Vec::new(),
1064 reports,
1065 tables,
1066 diagnostics: Vec::new(),
1067 frame_fields,
1068 provenance: AnalysisProvenance {
1069 kind: frame_kind,
1070 source_plots: vec![source.name.clone()],
1071 parameters: vec![
1072 ("max_samples".to_string(), max_samples.to_string()),
1073 ("vector_scale".to_string(), format!("{vector_scale:.4}")),
1074 ],
1075 notes: vec![
1076 "Frame parameters are cumulative sampled arc length values.".to_string(),
1077 "Bishop frames use rotation-minimizing transport across sampled tangents."
1078 .to_string(),
1079 ],
1080 },
1081 })
1082}
1083
1084fn make_axis_derivative_plot(
1085 source: &PlotSpec,
1086 numerator_axis: usize,
1087 denominator_axis: usize,
1088 output_name: Option<String>,
1089) -> Result<PlotSpec, AnalysisError> {
1090 if numerator_axis == denominator_axis {
1091 return Err(AnalysisError::invalid(
1092 "Numerator and denominator axes must be different.",
1093 ));
1094 }
1095 let groups = curve_sample_groups(source)?;
1096 let derived_groups: Vec<Vec<[f32; 3]>> = groups
1097 .iter()
1098 .map(|group| axis_derivative_group(group, numerator_axis, denominator_axis))
1099 .filter(|group| group.len() >= 2)
1100 .collect();
1101 if derived_groups.is_empty() {
1102 return Err(AnalysisError::invalid(
1103 "Could not compute an axis derivative from the selected curve.",
1104 ));
1105 }
1106 derived_polyline_plot(
1107 source,
1108 output_name.unwrap_or_else(|| {
1109 format!(
1110 "d{}/d{} {}",
1111 axis_name(numerator_axis),
1112 axis_name(denominator_axis),
1113 source.name
1114 )
1115 }),
1116 [1.0, 0.7, 0.25, 1.0],
1117 derived_groups,
1118 )
1119}
1120
1121fn make_extracted_points_plot(source: &PlotSpec) -> Result<PlotSpec, AnalysisError> {
1122 let positions = polyline_sample_groups(source)?
1123 .into_iter()
1124 .flatten()
1125 .collect::<Vec<_>>();
1126 Ok(PlotSpec {
1127 name: format!("Points from {}", source.name),
1128 visible: true,
1129 domain: source.domain.clone(),
1130 resolution: source.resolution,
1131 style: PlotStyle {
1132 colour_mode: ColourMode::Solid([0.35, 0.85, 1.0, 1.0]),
1133 point_size: 8.0,
1134 ..PlotStyle::default()
1135 },
1136 definition: crate::PlotDefinition::PointAnnotations {
1137 points: make_point_annotations(&positions, "Point"),
1138 show_labels: false,
1139 },
1140 })
1141}
1142
1143fn make_interpolated_plots(
1144 source: &PlotSpec,
1145 interpolation: CurveInterpolation,
1146 output_name: Option<String>,
1147) -> Result<Vec<PlotSpec>, AnalysisError> {
1148 let groups = interpolation_source_groups(source)?;
1149 if groups.is_empty() || groups.iter().all(Vec::is_empty) {
1150 return Err(AnalysisError::invalid(
1151 "The selected plot does not have usable point samples.",
1152 ));
1153 }
1154 if groups.iter().all(|group| group.len() < 2) {
1155 return Err(AnalysisError::invalid(
1156 "At least two points are required to interpolate a curve.",
1157 ));
1158 }
1159
1160 let base_name = output_name.unwrap_or_else(|| format!("Interpolated {}", source.name));
1161 let style = PlotStyle {
1162 colour_mode: ColourMode::Solid([0.95, 0.7, 0.2, 1.0]),
1163 line_width: 2.5,
1164 ..PlotStyle::default()
1165 };
1166
1167 if groups.len() == 1 {
1168 let points = groups.into_iter().next().unwrap_or_default();
1169 return Ok(vec![PlotSpec {
1170 name: base_name,
1171 visible: true,
1172 domain: source.domain.clone(),
1173 resolution: source.resolution,
1174 style,
1175 definition: crate::PlotDefinition::InterpolatedCurve {
1176 points,
1177 interpolation,
1178 },
1179 }]);
1180 }
1181
1182 Ok(groups
1183 .into_iter()
1184 .enumerate()
1185 .filter(|(_, group)| group.len() >= 2)
1186 .map(|(index, group)| PlotSpec {
1187 name: format!("{base_name} {}", index + 1),
1188 visible: true,
1189 domain: source.domain.clone(),
1190 resolution: source.resolution,
1191 style: style.clone(),
1192 definition: crate::PlotDefinition::InterpolatedCurve {
1193 points: group,
1194 interpolation,
1195 },
1196 })
1197 .collect())
1198}
1199
1200fn interpolation_source_groups(plot: &PlotSpec) -> Result<Vec<Vec<[f32; 3]>>, AnalysisError> {
1201 match &plot.definition {
1202 crate::PlotDefinition::PointAnnotations { points, .. } => {
1203 Ok(vec![points.iter().map(|point| point.position).collect()])
1204 }
1205 crate::PlotDefinition::ExprCurve { .. }
1206 | crate::PlotDefinition::ExprCartesianLine { .. }
1207 | crate::PlotDefinition::HelixCurve => curve_sample_groups(plot),
1208 crate::PlotDefinition::ImportedTable { definition } => match definition.validate() {
1209 Ok(TableDataSet::Curve { groups, .. }) => Ok(groups
1210 .iter()
1211 .map(|group| group.iter().map(|point| point.to_array()).collect())
1212 .collect()),
1213 Ok(TableDataSet::Scatter { points, .. }) => {
1214 Ok(vec![points.iter().map(|point| point.to_array()).collect()])
1215 }
1216 Ok(_) => Err(AnalysisError::unsupported(
1217 "Interpolation is not available for this imported table target.",
1218 )),
1219 Err(errors) => Err(table_errors(errors)),
1220 },
1221 crate::PlotDefinition::DerivedPolylineGroups { groups } => Ok(groups.clone()),
1222 crate::PlotDefinition::InterpolatedCurve { points, .. } => Ok(vec![points.clone()]),
1223 _ => Err(AnalysisError::unsupported(
1224 "Interpolation is available for point and ordered sample plots.",
1225 )),
1226 }
1227}
1228
1229fn polyline_sample_groups(plot: &PlotSpec) -> Result<Vec<Vec<[f32; 3]>>, AnalysisError> {
1230 match &plot.definition {
1231 crate::PlotDefinition::ExprCurve { .. }
1232 | crate::PlotDefinition::ExprCartesianLine { .. }
1233 | crate::PlotDefinition::HelixCurve => curve_sample_groups(plot),
1234 crate::PlotDefinition::ImportedTable { definition } => match definition.validate() {
1235 Ok(TableDataSet::Curve { groups, .. }) => Ok(groups
1236 .iter()
1237 .map(|group| group.iter().map(|point| point.to_array()).collect())
1238 .collect()),
1239 Ok(_) => Err(AnalysisError::unsupported(
1240 "Point extraction is only available for imported curve tables.",
1241 )),
1242 Err(errors) => Err(table_errors(errors)),
1243 },
1244 crate::PlotDefinition::DerivedPolylineGroups { groups } => Ok(groups.clone()),
1245 crate::PlotDefinition::InterpolatedCurve {
1246 points,
1247 interpolation,
1248 } => {
1249 let sampled = sample_curve_points(
1250 &points
1251 .iter()
1252 .map(|point| Vec3::from_array(*point))
1253 .collect::<Vec<_>>(),
1254 *interpolation,
1255 );
1256 Ok(vec![
1257 sampled.into_iter().map(|point| point.to_array()).collect(),
1258 ])
1259 }
1260 _ => Err(AnalysisError::unsupported(
1261 "Point extraction is available for polyline and interpolated curve plots.",
1262 )),
1263 }
1264}
1265
1266fn sample_data_groups(plot: &PlotSpec) -> Result<Vec<Vec<[f32; 3]>>, AnalysisError> {
1267 match &plot.definition {
1268 crate::PlotDefinition::ScatterCloud => Ok(vec![scatter_cloud_points()]),
1269 crate::PlotDefinition::PointAnnotations { points, .. } => {
1270 Ok(vec![points.iter().map(|point| point.position).collect()])
1271 }
1272 crate::PlotDefinition::ArrowAnnotations { arrows, .. } => {
1273 Ok(vec![arrows.iter().map(|arrow| arrow.origin).collect()])
1274 }
1275 crate::PlotDefinition::ExprCurve { .. }
1276 | crate::PlotDefinition::ExprCartesianLine { .. }
1277 | crate::PlotDefinition::HelixCurve => curve_sample_groups(plot),
1278 crate::PlotDefinition::ImportedTable { definition } => match definition.validate() {
1279 Ok(TableDataSet::Curve { groups, .. }) => Ok(groups
1280 .iter()
1281 .map(|group| group.iter().map(|point| point.to_array()).collect())
1282 .collect()),
1283 Ok(TableDataSet::Scatter { points, .. }) => {
1284 Ok(vec![points.iter().map(|point| point.to_array()).collect()])
1285 }
1286 Ok(TableDataSet::VectorField { samples, .. }) => Ok(vec![
1287 samples
1288 .iter()
1289 .map(|sample| sample.position.to_array())
1290 .collect(),
1291 ]),
1292 Ok(TableDataSet::SurfaceGrid { xs, ys, zs }) => {
1293 if xs.is_empty() || ys.is_empty() || zs.is_empty() {
1294 return Err(AnalysisError::invalid(
1295 "Imported surface grid does not contain enough samples.",
1296 ));
1297 }
1298 let ny = ys.len();
1299 let zs_ref = &zs;
1300 let points = xs
1301 .iter()
1302 .enumerate()
1303 .flat_map(|(ix, x)| {
1304 ys.iter().enumerate().filter_map(move |(iy, y)| {
1305 let index = ix.checked_mul(ny)?.checked_add(iy)?;
1306 let z = *zs_ref.get(index)?;
1307 Some([*x as f32, *y as f32, z as f32])
1308 })
1309 })
1310 .collect::<Vec<_>>();
1311 Ok(vec![points])
1312 }
1313 Err(errors) => Err(table_errors(errors)),
1314 },
1315 crate::PlotDefinition::DerivedPolylineGroups { groups } => Ok(groups.clone()),
1316 crate::PlotDefinition::InterpolatedCurve {
1317 points,
1318 interpolation,
1319 } => {
1320 let sampled = sample_curve_points(
1321 &points
1322 .iter()
1323 .map(|point| Vec3::from_array(*point))
1324 .collect::<Vec<_>>(),
1325 *interpolation,
1326 );
1327 Ok(vec![
1328 sampled.into_iter().map(|point| point.to_array()).collect(),
1329 ])
1330 }
1331 _ => Err(AnalysisError::unsupported(
1332 "Sample statistics require point-like or ordered sampled data.",
1333 )),
1334 }
1335}
1336
1337fn curve_sample_groups(plot: &PlotSpec) -> Result<Vec<Vec<[f32; 3]>>, AnalysisError> {
1338 match &plot.definition {
1339 crate::PlotDefinition::HelixCurve => {
1340 let steps = plot.resolution.u.max(2) as usize;
1341 let points = (0..steps)
1342 .map(|i| {
1343 let t = 20.0 * PI * i as f64 / (steps - 1) as f64;
1344 Vec3::new(
1345 (t.cos() * 3.0) as f32,
1346 (t.sin() * 3.0) as f32,
1347 (t * 0.15) as f32,
1348 )
1349 .to_array()
1350 })
1351 .collect();
1352 Ok(vec![points])
1353 }
1354 crate::PlotDefinition::ExprCurve {
1355 expression,
1356 parameters,
1357 t_range,
1358 } => {
1359 let parsed = parse_curve_expr(expression).map_err(parse_error)?;
1360 let steps = plot.resolution.u.max(2) as usize;
1361 let (t0, t1) = *t_range;
1362 let points = (0..steps)
1363 .map(|i| {
1364 let t = t0 + (i as f64 / (steps - 1) as f64) * (t1 - t0);
1365 let p = eval_curve_point(&parsed, t, parameters);
1366 Vec3::new(p.x as f32, p.y as f32, p.z as f32).to_array()
1367 })
1368 .collect();
1369 Ok(vec![points])
1370 }
1371 crate::PlotDefinition::ExprCartesianLine {
1372 dep_var,
1373 ind_var,
1374 expression,
1375 parameters,
1376 } => {
1377 let parsed =
1378 parse_expr_with_vars(expression, &[ind_var.as_str()]).map_err(parse_error)?;
1379 let steps = plot.resolution.u.max(2) as usize;
1380 let (t0, t1) = (*plot.domain.x.start(), *plot.domain.x.end());
1381 let dep = dep_var.clone();
1382 let ind = ind_var.clone();
1383 let points = (0..steps)
1384 .map(|i| {
1385 let t = t0 + (i as f64 / (steps - 1) as f64) * (t1 - t0);
1386 let vars: Vec<(&str, f64)> = parameters
1387 .iter()
1388 .map(|(n, v)| (n.as_str(), *v))
1389 .chain(std::iter::once((ind.as_str(), t)))
1390 .collect();
1391 let val = eval_with_vars(&parsed, &vars);
1392 cartesian_line_point(dep.as_str(), ind.as_str(), t as f32, val as f32)
1393 .to_array()
1394 })
1395 .collect();
1396 Ok(vec![points])
1397 }
1398 crate::PlotDefinition::ImportedTable { definition } => match definition.validate() {
1399 Ok(TableDataSet::Curve { groups, .. }) => Ok(groups
1400 .iter()
1401 .map(|group| group.iter().map(|point| point.to_array()).collect())
1402 .collect()),
1403 Ok(_) => Err(AnalysisError::unsupported(
1404 "Curve calculus tools require curve-like sample data.",
1405 )),
1406 Err(errors) => Err(table_errors(errors)),
1407 },
1408 crate::PlotDefinition::DerivedPolylineGroups { groups } => Ok(groups.clone()),
1409 crate::PlotDefinition::InterpolatedCurve {
1410 points,
1411 interpolation,
1412 } => {
1413 let sampled = sample_curve_points(
1414 &points
1415 .iter()
1416 .map(|point| Vec3::from_array(*point))
1417 .collect::<Vec<_>>(),
1418 *interpolation,
1419 );
1420 Ok(vec![
1421 sampled.into_iter().map(|point| point.to_array()).collect(),
1422 ])
1423 }
1424 _ => Err(AnalysisError::unsupported(
1425 "Curve calculus tools are available for curve and polyline plots.",
1426 )),
1427 }
1428}
1429
1430fn build_curve_frame_field(
1431 group: &[[f32; 3]],
1432 source: &PlotSpec,
1433 frame_kind: AnalysisKind,
1434 group_index: usize,
1435 group_count: usize,
1436) -> Result<FrameField, AnalysisError> {
1437 let points = group
1438 .iter()
1439 .map(|point| Vec3::from_array(*point))
1440 .collect::<Vec<_>>();
1441 if points.len() < 2 {
1442 return Err(AnalysisError::invalid(
1443 "Moving-frame analysis requires at least two sampled points.",
1444 ));
1445 }
1446
1447 let tangents = sampled_tangents(&points);
1448 if tangents
1449 .iter()
1450 .all(|tangent| tangent.length_squared() <= 1.0e-8)
1451 {
1452 return Err(AnalysisError::invalid(
1453 "Moving-frame analysis requires a curve with non-zero tangent variation.",
1454 ));
1455 }
1456 let (normals, binormals) = match frame_kind {
1457 AnalysisKind::FrenetFrame => frenet_frames(&points, &tangents),
1458 AnalysisKind::BishopFrame => bishop_frames(&tangents),
1459 _ => {
1460 return Err(AnalysisError::unsupported(
1461 "Unsupported moving-frame analysis kind.",
1462 ));
1463 }
1464 };
1465 let parameters = cumulative_arc_lengths(group);
1466 let title = if group_count > 1 {
1467 format!(
1468 "{} {} {}",
1469 frame_kind_label(frame_kind),
1470 source.name,
1471 group_index + 1
1472 )
1473 } else {
1474 format!("{} {}", frame_kind_label(frame_kind), source.name)
1475 };
1476
1477 Ok(FrameField {
1478 title,
1479 source_plot: source.name.clone(),
1480 frame_kind,
1481 samples: points
1482 .iter()
1483 .enumerate()
1484 .map(|(index, point)| FrameSample {
1485 parameter: parameters.get(index).copied().unwrap_or_default(),
1486 position: point.to_array(),
1487 tangent: tangents[index].to_array(),
1488 normal: normals[index].to_array(),
1489 binormal: binormals[index].to_array(),
1490 })
1491 .collect(),
1492 })
1493}
1494
1495fn build_curve_surface_frame_field(
1496 group: &[[f32; 3]],
1497 source: &PlotSpec,
1498 surface_name: &str,
1499 frame_kind: AnalysisKind,
1500 mesh: &CombinedSurfaceMesh,
1501 group_index: usize,
1502 group_count: usize,
1503) -> Result<FrameField, AnalysisError> {
1504 let points = group
1505 .iter()
1506 .map(|point| Vec3::from_array(*point))
1507 .collect::<Vec<_>>();
1508 if points.len() < 2 {
1509 return Err(AnalysisError::invalid(
1510 "Surface-coupled frame analysis requires at least two sampled points.",
1511 ));
1512 }
1513 let tangents = sampled_tangents(&points);
1514 let surface_normals = points
1515 .iter()
1516 .map(|point| nearest_surface_normal(mesh, *point))
1517 .collect::<Vec<_>>();
1518 let (normals, binormals) = match frame_kind {
1519 AnalysisKind::SurfaceAlignedFrame => surface_aligned_frames(&tangents, &surface_normals),
1520 AnalysisKind::DarbouxFrame => darboux_frames(&tangents, &surface_normals),
1521 _ => {
1522 return Err(AnalysisError::unsupported(
1523 "Unsupported surface-coupled frame analysis kind.",
1524 ));
1525 }
1526 };
1527 let parameters = cumulative_arc_lengths(group);
1528 let title = if group_count > 1 {
1529 format!(
1530 "{} {} on {} {}",
1531 frame_kind_label(frame_kind),
1532 source.name,
1533 surface_name,
1534 group_index + 1
1535 )
1536 } else {
1537 format!(
1538 "{} {} on {}",
1539 frame_kind_label(frame_kind),
1540 source.name,
1541 surface_name
1542 )
1543 };
1544
1545 Ok(FrameField {
1546 title,
1547 source_plot: source.name.clone(),
1548 frame_kind,
1549 samples: points
1550 .iter()
1551 .enumerate()
1552 .map(|(index, point)| FrameSample {
1553 parameter: parameters.get(index).copied().unwrap_or_default(),
1554 position: point.to_array(),
1555 tangent: tangents[index].to_array(),
1556 normal: normals[index].to_array(),
1557 binormal: binormals[index].to_array(),
1558 })
1559 .collect(),
1560 })
1561}
1562
1563fn frame_field_report(field: &FrameField) -> AnalysisReport {
1564 let total_length = field
1565 .samples
1566 .last()
1567 .map(|sample| sample.parameter)
1568 .unwrap_or(0.0);
1569 AnalysisReport {
1570 title: field.title.clone(),
1571 values: vec![
1572 (
1573 "Frame Kind".to_string(),
1574 frame_kind_label(field.frame_kind).to_string(),
1575 ),
1576 ("Sample Count".to_string(), field.samples.len().to_string()),
1577 ("Arc Length".to_string(), format_float(total_length)),
1578 ],
1579 }
1580}
1581
1582fn frame_field_table(field: &FrameField) -> AnalysisTable {
1583 AnalysisTable {
1584 title: format!("{} Samples", field.title),
1585 columns: vec![
1586 "row".to_string(),
1587 "s".to_string(),
1588 "x".to_string(),
1589 "y".to_string(),
1590 "z".to_string(),
1591 "tx".to_string(),
1592 "ty".to_string(),
1593 "tz".to_string(),
1594 "nx".to_string(),
1595 "ny".to_string(),
1596 "nz".to_string(),
1597 "bx".to_string(),
1598 "by".to_string(),
1599 "bz".to_string(),
1600 ],
1601 rows: field
1602 .samples
1603 .iter()
1604 .enumerate()
1605 .map(|(index, sample)| {
1606 vec![
1607 (index + 1).to_string(),
1608 format_float(sample.parameter),
1609 format_float(sample.position[0]),
1610 format_float(sample.position[1]),
1611 format_float(sample.position[2]),
1612 format_float(sample.tangent[0]),
1613 format_float(sample.tangent[1]),
1614 format_float(sample.tangent[2]),
1615 format_float(sample.normal[0]),
1616 format_float(sample.normal[1]),
1617 format_float(sample.normal[2]),
1618 format_float(sample.binormal[0]),
1619 format_float(sample.binormal[1]),
1620 format_float(sample.binormal[2]),
1621 ]
1622 })
1623 .collect(),
1624 }
1625}
1626
1627fn frame_kind_label(kind: AnalysisKind) -> &'static str {
1628 match kind {
1629 AnalysisKind::FrenetFrame => "Frenet Frame",
1630 AnalysisKind::BishopFrame => "Bishop Frame",
1631 AnalysisKind::DarbouxFrame => "Darboux Frame",
1632 AnalysisKind::SurfaceAlignedFrame => "Surface-Aligned Frame",
1633 _ => "Frame",
1634 }
1635}
1636
1637fn sampled_tangents(points: &[Vec3]) -> Vec<Vec3> {
1638 (0..points.len())
1639 .map(|index| {
1640 let prev = if index == 0 {
1641 points[index]
1642 } else {
1643 points[index - 1]
1644 };
1645 let next = if index + 1 >= points.len() {
1646 points[index]
1647 } else {
1648 points[index + 1]
1649 };
1650 let tangent = if index == 0 {
1651 next - points[index]
1652 } else if index + 1 >= points.len() {
1653 points[index] - prev
1654 } else {
1655 next - prev
1656 };
1657 tangent.normalize_or_zero()
1658 })
1659 .collect()
1660}
1661
1662fn frenet_frames(points: &[Vec3], tangents: &[Vec3]) -> (Vec<Vec3>, Vec<Vec3>) {
1663 let mut normals = Vec::with_capacity(points.len());
1664 let mut binormals = Vec::with_capacity(points.len());
1665 let mut previous_normal: Option<Vec3> = None;
1666
1667 for index in 0..points.len() {
1668 let tangent = tangents[index];
1669 let prev_tangent = tangents
1670 .get(index.saturating_sub(1))
1671 .copied()
1672 .unwrap_or(tangent);
1673 let next_tangent = tangents
1674 .get((index + 1).min(tangents.len() - 1))
1675 .copied()
1676 .unwrap_or(tangent);
1677 let tangent_delta = if index == 0 {
1678 next_tangent - tangent
1679 } else if index + 1 >= tangents.len() {
1680 tangent - prev_tangent
1681 } else {
1682 next_tangent - prev_tangent
1683 };
1684 let mut normal = orthogonalized(tangent_delta, tangent);
1685 if normal.length_squared() <= 1.0e-8 {
1686 normal = previous_normal
1687 .map(|prev| orthogonalized(prev, tangent))
1688 .filter(|candidate| candidate.length_squared() > 1.0e-8)
1689 .unwrap_or_else(|| arbitrary_perpendicular(tangent));
1690 }
1691 normal = normal.normalize_or_zero();
1692 let mut binormal = tangent.cross(normal);
1693 if binormal.length_squared() <= 1.0e-8 {
1694 normal = arbitrary_perpendicular(tangent);
1695 binormal = tangent.cross(normal);
1696 }
1697 binormal = binormal.normalize_or_zero();
1698 normal = binormal.cross(tangent).normalize_or_zero();
1699 previous_normal = Some(normal);
1700 normals.push(normal);
1701 binormals.push(binormal);
1702 }
1703
1704 (normals, binormals)
1705}
1706
1707fn bishop_frames(tangents: &[Vec3]) -> (Vec<Vec3>, Vec<Vec3>) {
1708 let first_tangent = tangents
1709 .iter()
1710 .copied()
1711 .find(|tangent| tangent.length_squared() > 1.0e-8)
1712 .unwrap_or(Vec3::X);
1713 let mut normals = Vec::with_capacity(tangents.len());
1714 let mut binormals = Vec::with_capacity(tangents.len());
1715 let mut current_normal = arbitrary_perpendicular(first_tangent);
1716
1717 for (index, tangent) in tangents.iter().copied().enumerate() {
1718 if index > 0 {
1719 let previous_tangent = tangents[index - 1];
1720 current_normal = rotate_minimizing(previous_tangent, tangent, current_normal);
1721 current_normal = orthogonalized(current_normal, tangent).normalize_or_zero();
1722 if current_normal.length_squared() <= 1.0e-8 {
1723 current_normal = arbitrary_perpendicular(tangent);
1724 }
1725 } else {
1726 current_normal = orthogonalized(current_normal, tangent).normalize_or_zero();
1727 }
1728 let binormal = tangent.cross(current_normal).normalize_or_zero();
1729 current_normal = binormal.cross(tangent).normalize_or_zero();
1730 normals.push(current_normal);
1731 binormals.push(binormal);
1732 }
1733
1734 (normals, binormals)
1735}
1736
1737fn surface_aligned_frames(tangents: &[Vec3], surface_normals: &[Vec3]) -> (Vec<Vec3>, Vec<Vec3>) {
1738 let mut normals = Vec::with_capacity(tangents.len());
1739 let mut binormals = Vec::with_capacity(tangents.len());
1740 for (tangent, surface_normal) in tangents
1741 .iter()
1742 .copied()
1743 .zip(surface_normals.iter().copied())
1744 {
1745 let mut normal = orthogonalized(surface_normal, tangent).normalize_or_zero();
1746 if normal.length_squared() <= 1.0e-8 {
1747 normal = arbitrary_perpendicular(tangent);
1748 }
1749 let binormal = tangent.cross(normal).normalize_or_zero();
1750 normals.push(normal);
1751 binormals.push(binormal);
1752 }
1753 (normals, binormals)
1754}
1755
1756fn darboux_frames(tangents: &[Vec3], surface_normals: &[Vec3]) -> (Vec<Vec3>, Vec<Vec3>) {
1757 let mut normals = Vec::with_capacity(tangents.len());
1758 let mut binormals = Vec::with_capacity(tangents.len());
1759 for (tangent, surface_normal) in tangents
1760 .iter()
1761 .copied()
1762 .zip(surface_normals.iter().copied())
1763 {
1764 let mut surface_normal = orthogonalized(surface_normal, tangent).normalize_or_zero();
1765 if surface_normal.length_squared() <= 1.0e-8 {
1766 surface_normal = arbitrary_perpendicular(tangent);
1767 }
1768 let mut geodesic = surface_normal.cross(tangent).normalize_or_zero();
1769 if geodesic.length_squared() <= 1.0e-8 {
1770 geodesic = arbitrary_perpendicular(tangent);
1771 }
1772 normals.push(geodesic);
1773 binormals.push(surface_normal);
1774 }
1775 (normals, binormals)
1776}
1777
1778fn nearest_surface_normal(mesh: &CombinedSurfaceMesh, point: Vec3) -> Vec3 {
1779 let mut best_distance = f32::INFINITY;
1780 let mut best_normal = Vec3::Z;
1781 for triangle in mesh.indices.chunks_exact(3) {
1782 let a = mesh.positions[triangle[0] as usize];
1783 let b = mesh.positions[triangle[1] as usize];
1784 let c = mesh.positions[triangle[2] as usize];
1785 let normal = (b - a).cross(c - a).normalize_or_zero();
1786 if normal.length_squared() <= 1.0e-8 {
1787 continue;
1788 }
1789 let centroid = (a + b + c) / 3.0;
1790 let distance = centroid.distance_squared(point);
1791 if distance < best_distance {
1792 best_distance = distance;
1793 best_normal = normal;
1794 }
1795 }
1796 best_normal
1797}
1798
1799fn rotate_minimizing(previous_tangent: Vec3, tangent: Vec3, normal: Vec3) -> Vec3 {
1800 if previous_tangent.length_squared() <= 1.0e-8 || tangent.length_squared() <= 1.0e-8 {
1801 return normal;
1802 }
1803 let dot = previous_tangent.dot(tangent).clamp(-1.0, 1.0);
1804 if dot > 0.9999 {
1805 return normal;
1806 }
1807 if dot < -0.9999 {
1808 let axis = arbitrary_perpendicular(previous_tangent);
1809 return glam::Quat::from_axis_angle(axis.normalize_or_zero(), std::f32::consts::PI)
1810 * normal;
1811 }
1812 glam::Quat::from_rotation_arc(previous_tangent, tangent) * normal
1813}
1814
1815fn orthogonalized(vector: Vec3, tangent: Vec3) -> Vec3 {
1816 vector - tangent * vector.dot(tangent)
1817}
1818
1819fn arbitrary_perpendicular(tangent: Vec3) -> Vec3 {
1820 let reference = if tangent.z.abs() < 0.9 {
1821 Vec3::Z
1822 } else {
1823 Vec3::Y
1824 };
1825 tangent.cross(reference).normalize_or_zero()
1826}
1827
1828fn make_point_cloud_statistics_output(plot: &PlotSpec) -> Result<AnalysisOutput, AnalysisError> {
1829 let groups = sample_data_groups(plot)?;
1830 let samples = flatten_sample_groups(&groups);
1831 if samples.is_empty() {
1832 return Err(AnalysisError::invalid(
1833 "Point-cloud statistics require at least one sample.",
1834 ));
1835 }
1836
1837 let centroid = samples.iter().copied().sum::<Vec3>() / samples.len() as f32;
1838 let (bbox_min, bbox_max) = bounds_for_points(&samples);
1839 let extent = bbox_max - bbox_min;
1840 let covariance = covariance_matrix(&samples, centroid);
1841 let variance = Vec3::new(
1842 covariance.x_axis.x,
1843 covariance.y_axis.y,
1844 covariance.z_axis.z,
1845 );
1846 let principal_components = principal_components(covariance);
1847
1848 let reports = vec![AnalysisReport {
1849 title: format!("Point Statistics {}", plot.name),
1850 values: vec![
1851 ("Sample Count".to_string(), samples.len().to_string()),
1852 ("Sequence Count".to_string(), groups.len().to_string()),
1853 ("Centroid".to_string(), format_vec3(centroid)),
1854 ("Bounds Min".to_string(), format_vec3(bbox_min)),
1855 ("Bounds Max".to_string(), format_vec3(bbox_max)),
1856 ("Extent".to_string(), format_vec3(extent)),
1857 (
1858 "Variance".to_string(),
1859 format!("{:.5}, {:.5}, {:.5}", variance.x, variance.y, variance.z),
1860 ),
1861 ],
1862 }];
1863 let tables = vec![
1864 sample_positions_table(&groups),
1865 AnalysisTable {
1866 title: "Covariance Matrix".to_string(),
1867 columns: vec![
1868 "axis".to_string(),
1869 "x".to_string(),
1870 "y".to_string(),
1871 "z".to_string(),
1872 ],
1873 rows: vec![
1874 vec![
1875 "covariance row 1".to_string(),
1876 format_float(covariance.x_axis.x),
1877 format_float(covariance.y_axis.x),
1878 format_float(covariance.z_axis.x),
1879 ],
1880 vec![
1881 "covariance row 2".to_string(),
1882 format_float(covariance.x_axis.y),
1883 format_float(covariance.y_axis.y),
1884 format_float(covariance.z_axis.y),
1885 ],
1886 vec![
1887 "covariance row 3".to_string(),
1888 format_float(covariance.x_axis.z),
1889 format_float(covariance.y_axis.z),
1890 format_float(covariance.z_axis.z),
1891 ],
1892 ],
1893 },
1894 AnalysisTable {
1895 title: "Principal Components".to_string(),
1896 columns: vec![
1897 "component".to_string(),
1898 "eigenvalue".to_string(),
1899 "direction".to_string(),
1900 ],
1901 rows: principal_components
1902 .iter()
1903 .enumerate()
1904 .map(|(index, (eigenvalue, direction))| {
1905 vec![
1906 format!("PC{}", index + 1),
1907 format_float(*eigenvalue),
1908 format_vec3(*direction),
1909 ]
1910 })
1911 .collect(),
1912 },
1913 ];
1914 let plots = vec![
1915 PlotSpec {
1916 name: format!("Centroid {}", plot.name),
1917 visible: true,
1918 domain: plot.domain.clone(),
1919 resolution: plot.resolution,
1920 style: PlotStyle {
1921 colour_mode: ColourMode::Solid([1.0, 0.86, 0.3, 1.0]),
1922 point_size: 9.0,
1923 ..PlotStyle::default()
1924 },
1925 definition: crate::PlotDefinition::PointAnnotations {
1926 points: vec![PointAnnotation {
1927 position: centroid.to_array(),
1928 label: "Centroid".to_string(),
1929 }],
1930 show_labels: true,
1931 },
1932 },
1933 PlotSpec {
1934 name: format!("PCA Axes {}", plot.name),
1935 visible: true,
1936 domain: plot.domain.clone(),
1937 resolution: plot.resolution,
1938 style: PlotStyle {
1939 colour_mode: ColourMode::Colormap {
1940 colormap: ColormapSource::Builtin(BuiltinColourmap::RdBu),
1941 scalar_range: Some((-1.0, 1.0)),
1942 },
1943 glyph_scale: 1.0,
1944 shading: crate::ShadingMode::Unlit,
1945 ..PlotStyle::default()
1946 },
1947 definition: crate::PlotDefinition::ArrowAnnotations {
1948 arrows: principal_components
1949 .iter()
1950 .enumerate()
1951 .map(|(index, (eigenvalue, direction))| ArrowAnnotation {
1952 origin: centroid.to_array(),
1953 vector: (*direction * (eigenvalue.max(0.0).sqrt() * 2.5)).to_array(),
1954 label: format!("PC{}", index + 1),
1955 })
1956 .collect(),
1957 show_labels: true,
1958 },
1959 },
1960 ];
1961 Ok(AnalysisOutput::Composite {
1962 plots,
1963 reports,
1964 tables,
1965 diagnostics: Vec::new(),
1966 frame_fields: Vec::new(),
1967 provenance: AnalysisProvenance {
1968 kind: AnalysisKind::PointCloudStatistics,
1969 source_plots: vec![plot.name.clone()],
1970 parameters: Vec::new(),
1971 notes: vec!["Computed from sampled point positions.".to_string()],
1972 },
1973 })
1974}
1975
1976fn scatter_cloud_points() -> Vec<[f32; 3]> {
1977 (0..200)
1978 .map(|i| {
1979 glam::Vec3::new(
1980 (i as f32 * 0.37).sin() * 5.0,
1981 (i as f32 * 0.73).cos() * 5.0,
1982 (i as f32 * 0.11).sin() * 5.0,
1983 )
1984 .to_array()
1985 })
1986 .collect()
1987}
1988
1989fn make_data_quality_output(plot: &PlotSpec) -> Result<AnalysisOutput, AnalysisError> {
1990 let groups = sample_data_groups(plot)?;
1991 let indexed = flatten_indexed_sample_groups(&groups);
1992 if indexed.len() < 2 {
1993 return Err(AnalysisError::invalid(
1994 "Data quality checks require at least two samples.",
1995 ));
1996 }
1997 let samples = indexed
1998 .iter()
1999 .map(|sample| sample.position)
2000 .collect::<Vec<_>>();
2001 let centroid = samples.iter().copied().sum::<Vec3>() / samples.len() as f32;
2002 let (bbox_min, bbox_max) = bounds_for_points(&samples);
2003 let diag = bbox_max.distance(bbox_min).max(1.0e-4);
2004 let duplicate_rows = exact_duplicate_rows(&indexed);
2005
2006 let heavy_checks = samples.len() <= 4_000;
2007 let nearest = if heavy_checks {
2008 nearest_neighbor_stats(&indexed)
2009 } else {
2010 None
2011 };
2012 let near_duplicate_epsilon = diag * 1.0e-3;
2013 let near_duplicate_samples = nearest
2014 .as_ref()
2015 .map(|stats| {
2016 stats
2017 .nearest_distances
2018 .iter()
2019 .enumerate()
2020 .filter(|(_, distance)| **distance > 0.0 && **distance <= near_duplicate_epsilon)
2021 .map(|(index, distance)| (indexed[index].clone(), *distance))
2022 .collect::<Vec<_>>()
2023 })
2024 .unwrap_or_default();
2025 let (distance_mean, distance_std) = mean_std(
2026 &samples
2027 .iter()
2028 .map(|point| point.distance(centroid))
2029 .collect::<Vec<_>>(),
2030 );
2031 let outlier_threshold = distance_mean + distance_std * 2.5;
2032 let outliers = indexed
2033 .iter()
2034 .filter(|sample| sample.position.distance(centroid) > outlier_threshold)
2035 .cloned()
2036 .collect::<Vec<_>>();
2037 let sparse_samples = nearest
2038 .as_ref()
2039 .map(|stats| {
2040 stats
2041 .nearest_distances
2042 .iter()
2043 .enumerate()
2044 .filter(|(_, distance)| **distance > stats.mean + stats.std * 2.0)
2045 .map(|(index, _)| indexed[index].clone())
2046 .collect::<Vec<_>>()
2047 })
2048 .unwrap_or_default();
2049 let monotonicity_rows = groups
2050 .iter()
2051 .enumerate()
2052 .filter(|(_, group)| group.len() >= 2)
2053 .map(|(index, group)| {
2054 vec![
2055 format!("sequence {}", index + 1),
2056 monotonicity_label(group, 0),
2057 monotonicity_label(group, 1),
2058 monotonicity_label(group, 2),
2059 ]
2060 })
2061 .collect::<Vec<_>>();
2062
2063 let mut diagnostics = Vec::new();
2064 if !duplicate_rows.is_empty() {
2065 diagnostics.push(Diagnostic::warning(
2066 DiagnosticKind::Validation,
2067 format!(
2068 "Detected {} exact duplicate sample(s).",
2069 duplicate_rows.len()
2070 ),
2071 ));
2072 }
2073 if !near_duplicate_samples.is_empty() {
2074 diagnostics.push(Diagnostic::warning(
2075 DiagnosticKind::Validation,
2076 format!(
2077 "Detected {} near-duplicate sample(s) within {:.4}.",
2078 near_duplicate_samples.len(),
2079 near_duplicate_epsilon
2080 ),
2081 ));
2082 }
2083 if !outliers.is_empty() {
2084 diagnostics.push(Diagnostic::warning(
2085 DiagnosticKind::Validation,
2086 format!("Detected {} positional outlier(s).", outliers.len()),
2087 ));
2088 }
2089 if !sparse_samples.is_empty() {
2090 diagnostics.push(Diagnostic::warning(
2091 DiagnosticKind::Validation,
2092 format!("Detected {} sparse-region sample(s).", sparse_samples.len()),
2093 ));
2094 }
2095 if !heavy_checks {
2096 diagnostics.push(Diagnostic::warning(
2097 DiagnosticKind::Build,
2098 "Skipped nearest-neighbor heavy checks for datasets above 4000 samples.",
2099 ));
2100 }
2101 diagnostics.extend(
2102 duplicate_rows
2103 .iter()
2104 .take(12)
2105 .map(|sample| sample_warning(sample, "Exact duplicate sample.".to_string())),
2106 );
2107 diagnostics.extend(
2108 near_duplicate_samples
2109 .iter()
2110 .take(12)
2111 .map(|(sample, distance)| {
2112 sample_warning(
2113 sample,
2114 format!(
2115 "Near-duplicate sample with nearest-neighbour distance {:.4}.",
2116 distance
2117 ),
2118 )
2119 }),
2120 );
2121 diagnostics.extend(sparse_samples.iter().take(12).map(|sample| {
2122 let nearest_distance = nearest
2123 .as_ref()
2124 .and_then(|stats| stats.nearest_distances.get(sample.sample_index))
2125 .copied()
2126 .unwrap_or_default();
2127 sample_warning(
2128 sample,
2129 format!(
2130 "Sparse-region sample with nearest-neighbour distance {:.4}.",
2131 nearest_distance
2132 ),
2133 )
2134 }));
2135 diagnostics.extend(outliers.iter().take(12).map(|sample| {
2136 sample_warning(
2137 sample,
2138 format!(
2139 "Outlier distance {:.4} exceeds threshold {:.4}.",
2140 sample.position.distance(centroid),
2141 outlier_threshold
2142 ),
2143 )
2144 }));
2145
2146 let mut reports = vec![AnalysisReport {
2147 title: format!("Data Quality {}", plot.name),
2148 values: vec![
2149 ("Sample Count".to_string(), samples.len().to_string()),
2150 (
2151 "Exact Duplicates".to_string(),
2152 duplicate_rows.len().to_string(),
2153 ),
2154 (
2155 "Near Duplicates".to_string(),
2156 near_duplicate_samples.len().to_string(),
2157 ),
2158 ("Outliers".to_string(), outliers.len().to_string()),
2159 (
2160 "Sparse Samples".to_string(),
2161 sparse_samples.len().to_string(),
2162 ),
2163 ],
2164 }];
2165 if let Some(stats) = &nearest {
2166 reports.push(AnalysisReport {
2167 title: "Spacing Diagnostics".to_string(),
2168 values: vec![
2169 ("Nearest Min".to_string(), format_float(stats.min)),
2170 ("Nearest Mean".to_string(), format_float(stats.mean)),
2171 ("Nearest Median".to_string(), format_float(stats.median)),
2172 ("Nearest Max".to_string(), format_float(stats.max)),
2173 ("Nearest Std".to_string(), format_float(stats.std)),
2174 ],
2175 });
2176 }
2177
2178 let mut tables = vec![sample_positions_table(&groups)];
2179 if !duplicate_rows.is_empty() {
2180 tables.push(flagged_samples_table(
2181 "Exact Duplicates",
2182 "duplicate_count",
2183 duplicate_rows
2184 .iter()
2185 .map(|sample| {
2186 (
2187 sample,
2188 sample_position_occurrences(&indexed, sample.position),
2189 )
2190 })
2191 .take(250),
2192 ));
2193 }
2194 if !near_duplicate_samples.is_empty() {
2195 tables.push(flagged_samples_table(
2196 "Near Duplicates",
2197 "nearest_distance",
2198 near_duplicate_samples
2199 .iter()
2200 .map(|(sample, distance)| (sample, *distance))
2201 .take(250),
2202 ));
2203 }
2204 if !sparse_samples.is_empty() {
2205 tables.push(flagged_samples_table(
2206 "Sparse Samples",
2207 "nearest_distance",
2208 sparse_samples
2209 .iter()
2210 .map(|sample| {
2211 let nearest_distance = nearest
2212 .as_ref()
2213 .and_then(|stats| stats.nearest_distances.get(sample.sample_index))
2214 .copied()
2215 .unwrap_or_default();
2216 (sample, nearest_distance)
2217 })
2218 .take(250),
2219 ));
2220 }
2221 if !outliers.is_empty() {
2222 tables.push(flagged_samples_table(
2223 "Outliers",
2224 "distance_from_centroid",
2225 outliers
2226 .iter()
2227 .map(|sample| (sample, sample.position.distance(centroid)))
2228 .take(250),
2229 ));
2230 }
2231 if !monotonicity_rows.is_empty() {
2232 tables.push(AnalysisTable {
2233 title: "Monotonicity by Sequence".to_string(),
2234 columns: vec![
2235 "sequence".to_string(),
2236 "x monotonic".to_string(),
2237 "y monotonic".to_string(),
2238 "z monotonic".to_string(),
2239 ],
2240 rows: monotonicity_rows,
2241 });
2242 }
2243
2244 let mut plots = Vec::new();
2245 if !outliers.is_empty() {
2246 plots.push(PlotSpec {
2247 name: format!("Outliers {}", plot.name),
2248 visible: true,
2249 domain: plot.domain.clone(),
2250 resolution: plot.resolution,
2251 style: PlotStyle {
2252 colour_mode: ColourMode::Solid([1.0, 0.35, 0.35, 1.0]),
2253 point_size: 8.0,
2254 ..PlotStyle::default()
2255 },
2256 definition: crate::PlotDefinition::PointAnnotations {
2257 points: outliers
2258 .iter()
2259 .map(|sample| PointAnnotation {
2260 position: sample.position.to_array(),
2261 label: String::new(),
2262 })
2263 .collect(),
2264 show_labels: false,
2265 },
2266 });
2267 }
2268
2269 Ok(AnalysisOutput::Composite {
2270 plots,
2271 reports,
2272 tables,
2273 diagnostics,
2274 frame_fields: Vec::new(),
2275 provenance: AnalysisProvenance {
2276 kind: AnalysisKind::DataQualityChecks,
2277 source_plots: vec![plot.name.clone()],
2278 parameters: Vec::new(),
2279 notes: vec!["Ordered-sequence checks operate per sampled sequence.".to_string()],
2280 },
2281 })
2282}
2283
2284struct CombinedSurfaceMesh {
2285 positions: Vec<Vec3>,
2286 indices: Vec<u32>,
2287 angle_distortion: Vec<f32>,
2288 area_distortion: Vec<f32>,
2289}
2290
2291impl CombinedSurfaceMesh {
2292 fn from_meshes(meshes: &[&MeshData]) -> Result<Self, AnalysisError> {
2293 if meshes.is_empty() {
2294 return Err(AnalysisError::invalid(
2295 "Surface analysis requires at least one sampled surface mesh.",
2296 ));
2297 }
2298
2299 let mut positions = Vec::new();
2300 let mut indices = Vec::new();
2301 let mut angle_distortion = Vec::new();
2302 let mut area_distortion = Vec::new();
2303
2304 for mesh in meshes {
2305 if mesh.positions.is_empty() || mesh.indices.len() < 3 {
2306 continue;
2307 }
2308 let base = positions.len() as u32;
2309 positions.extend(mesh.positions.iter().copied().map(Vec3::from_array));
2310 indices.extend(mesh.indices.iter().map(|index| base + *index));
2311
2312 if let Some(AttributeData::Face(values)) = mesh.attributes.get("angle_distortion") {
2313 angle_distortion.extend(values.iter().copied());
2314 }
2315 if let Some(AttributeData::Face(values)) = mesh.attributes.get("area_distortion") {
2316 area_distortion.extend(values.iter().copied());
2317 }
2318 }
2319
2320 if positions.is_empty() || indices.len() < 3 {
2321 return Err(AnalysisError::invalid(
2322 "Surface analysis requires cached triangle mesh geometry.",
2323 ));
2324 }
2325
2326 Ok(Self {
2327 positions,
2328 indices,
2329 angle_distortion,
2330 area_distortion,
2331 })
2332 }
2333}
2334
2335struct SurfaceCurvatureSummary {
2336 boundary_vertices: Vec<bool>,
2337 mean_curvature: Vec<f32>,
2338 gaussian_curvature: Vec<f32>,
2339 principal_max: Vec<f32>,
2340 principal_min: Vec<f32>,
2341 neighbors: Vec<Vec<usize>>,
2342}
2343
2344#[derive(Clone)]
2345struct SurfaceExtremum {
2346 vertex_index: usize,
2347 position: Vec3,
2348 value: f32,
2349 label: String,
2350}
2351
2352#[derive(Clone)]
2353struct MeshQualityFaceRow {
2354 face_index: usize,
2355 centroid: Vec3,
2356 area: f32,
2357 min_angle_deg: f32,
2358 max_angle_deg: f32,
2359 aspect_ratio: f32,
2360 angle_distortion: Option<f32>,
2361 area_distortion: Option<f32>,
2362 score: f32,
2363}
2364
2365fn make_surface_normals_output(
2366 source: &PlotSpec,
2367 mesh: &CombinedSurfaceMesh,
2368 max_samples: usize,
2369 vector_scale: f32,
2370) -> Result<AnalysisOutput, AnalysisError> {
2371 let normals = compute_vertex_normals(&mesh.positions, &mesh.indices);
2372 let (bbox_min, bbox_max) = bounds_for_points(&mesh.positions);
2373 let diagonal = bbox_max.distance(bbox_min);
2374 let scale = (diagonal * 0.06 * vector_scale.max(0.05)).max(0.01);
2375 let sampled = sampled_vertex_indices(mesh.positions.len(), max_samples.max(1));
2376 let plot = PlotSpec {
2377 name: format!("Surface Normals {}", source.name),
2378 visible: true,
2379 domain: source.domain.clone(),
2380 resolution: source.resolution,
2381 style: PlotStyle {
2382 colour_mode: ColourMode::Colormap {
2383 colormap: ColormapSource::Builtin(BuiltinColourmap::RdBu),
2384 scalar_range: Some((-1.0, 1.0)),
2385 },
2386 glyph_scale: 1.0,
2387 shading: crate::ShadingMode::Unlit,
2388 ..PlotStyle::default()
2389 },
2390 definition: crate::PlotDefinition::ArrowAnnotations {
2391 arrows: sampled
2392 .into_iter()
2393 .map(|index| ArrowAnnotation {
2394 origin: mesh.positions[index].to_array(),
2395 vector: (normals[index] * scale).to_array(),
2396 label: String::new(),
2397 })
2398 .collect(),
2399 show_labels: false,
2400 },
2401 };
2402 Ok(AnalysisOutput::DerivedPlots {
2403 plots: vec![plot],
2404 provenance: AnalysisProvenance {
2405 kind: AnalysisKind::SurfaceNormals,
2406 source_plots: vec![source.name.clone()],
2407 parameters: vec![
2408 ("max_samples".to_string(), max_samples.to_string()),
2409 ("vector_scale".to_string(), format_float(vector_scale)),
2410 ],
2411 notes: vec!["Normals sampled from cached surface vertices.".to_string()],
2412 },
2413 })
2414}
2415
2416fn make_surface_area_output(
2417 source: &PlotSpec,
2418 mesh: &CombinedSurfaceMesh,
2419) -> Result<AnalysisOutput, AnalysisError> {
2420 let face_areas = triangle_areas(&mesh.positions, &mesh.indices);
2421 let total_area = face_areas.iter().sum::<f32>();
2422 let (min_area, mean_area, max_area) = min_mean_max(&face_areas);
2423 Ok(AnalysisOutput::Report {
2424 report: AnalysisReport {
2425 title: format!("Surface Area {}", source.name),
2426 values: vec![
2427 ("Vertex Count".to_string(), mesh.positions.len().to_string()),
2428 (
2429 "Triangle Count".to_string(),
2430 (mesh.indices.len() / 3).to_string(),
2431 ),
2432 ("Surface Area".to_string(), format_float(total_area)),
2433 ("Triangle Area Min".to_string(), format_float(min_area)),
2434 ("Triangle Area Mean".to_string(), format_float(mean_area)),
2435 ("Triangle Area Max".to_string(), format_float(max_area)),
2436 ],
2437 },
2438 provenance: AnalysisProvenance {
2439 kind: AnalysisKind::SurfaceArea,
2440 source_plots: vec![source.name.clone()],
2441 parameters: Vec::new(),
2442 notes: vec!["Surface area is estimated from cached triangle geometry.".to_string()],
2443 },
2444 })
2445}
2446
2447fn make_surface_curvature_output(
2448 source: &PlotSpec,
2449 mesh: &CombinedSurfaceMesh,
2450 params: &HashMap<String, String>,
2451) -> Result<AnalysisOutput, AnalysisError> {
2452 let summary = estimate_surface_curvatures(mesh);
2453 let quantity = params
2454 .get("quantity")
2455 .map(String::as_str)
2456 .unwrap_or("mean_curvature");
2457 let (quantity_label, values) = match quantity {
2458 "gaussian_curvature" => ("Gaussian Curvature", summary.gaussian_curvature.clone()),
2459 "k_max" => ("Principal Max Curvature", summary.principal_max.clone()),
2460 "k_min" => ("Principal Min Curvature", summary.principal_min.clone()),
2461 _ => ("Mean Curvature", summary.mean_curvature.clone()),
2462 };
2463 let show_extrema = params
2464 .get("show_extrema")
2465 .is_none_or(|value| matches!(value.as_str(), "1" | "true" | "yes"));
2466 let ridge_extrema = local_principal_maxima(mesh, &summary, 8);
2467 let valley_extrema = local_principal_minima(mesh, &summary, 8);
2468 let gaussian_peak =
2469 top_vertex_extrema(mesh, &summary.gaussian_curvature, 2, "Gaussian Peak", true);
2470 let gaussian_pit =
2471 top_vertex_extrema(mesh, &summary.gaussian_curvature, 2, "Gaussian Pit", false);
2472
2473 let mut marker_points = Vec::new();
2474 marker_points.extend(ridge_extrema.iter().cloned());
2475 marker_points.extend(valley_extrema.iter().cloned());
2476 marker_points.extend(gaussian_peak.iter().cloned());
2477 marker_points.extend(gaussian_pit.iter().cloned());
2478
2479 let mean_abs = summary
2480 .mean_curvature
2481 .iter()
2482 .map(|value| value.abs())
2483 .collect::<Vec<_>>();
2484 let gaussian_abs = summary
2485 .gaussian_curvature
2486 .iter()
2487 .map(|value| value.abs())
2488 .collect::<Vec<_>>();
2489 let principal_abs = summary
2490 .principal_max
2491 .iter()
2492 .zip(summary.principal_min.iter())
2493 .map(|(k1, k2)| k1.abs().max(k2.abs()))
2494 .collect::<Vec<_>>();
2495
2496 let (range_min, _, range_max) = min_mean_max(&values);
2497 let mut plots = vec![PlotSpec {
2498 name: format!("{quantity_label} {}", source.name),
2499 visible: true,
2500 domain: source.domain.clone(),
2501 resolution: source.resolution,
2502 style: PlotStyle {
2503 colour_mode: ColourMode::Colormap {
2504 colormap: ColormapSource::Builtin(BuiltinColourmap::Coolwarm),
2505 scalar_range: Some((range_min, range_max)),
2506 },
2507 opacity: 0.92,
2508 two_sided: true,
2509 ..PlotStyle::default()
2510 },
2511 definition: crate::PlotDefinition::DerivedSurfaceMesh {
2512 positions: mesh
2513 .positions
2514 .iter()
2515 .map(|position| position.to_array())
2516 .collect(),
2517 indices: mesh.indices.clone(),
2518 values: values.clone(),
2519 value_name: quantity.to_string(),
2520 },
2521 }];
2522
2523 if show_extrema && !marker_points.is_empty() {
2524 plots.push(PlotSpec {
2525 name: format!("Curvature Markers {}", source.name),
2526 visible: true,
2527 domain: source.domain.clone(),
2528 resolution: source.resolution,
2529 style: PlotStyle {
2530 colour_mode: ColourMode::Solid([1.0, 0.6, 0.18, 1.0]),
2531 point_size: 8.0,
2532 ..PlotStyle::default()
2533 },
2534 definition: crate::PlotDefinition::PointAnnotations {
2535 points: marker_points
2536 .iter()
2537 .map(|marker| PointAnnotation {
2538 position: marker.position.to_array(),
2539 label: String::new(),
2540 })
2541 .collect(),
2542 show_labels: false,
2543 },
2544 });
2545 }
2546
2547 let reports = vec![AnalysisReport {
2548 title: format!("Surface Curvature {}", source.name),
2549 values: vec![
2550 ("Vertex Count".to_string(), mesh.positions.len().to_string()),
2551 (
2552 "Triangle Count".to_string(),
2553 (mesh.indices.len() / 3).to_string(),
2554 ),
2555 (
2556 "Boundary Vertices".to_string(),
2557 summary
2558 .boundary_vertices
2559 .iter()
2560 .filter(|value| **value)
2561 .count()
2562 .to_string(),
2563 ),
2564 (
2565 "Mean |H|".to_string(),
2566 format_float(mean_abs.iter().sum::<f32>() / mean_abs.len().max(1) as f32),
2567 ),
2568 (
2569 "Max |H|".to_string(),
2570 format_float(mean_abs.iter().copied().fold(0.0, f32::max)),
2571 ),
2572 (
2573 "Mean |K|".to_string(),
2574 format_float(gaussian_abs.iter().sum::<f32>() / gaussian_abs.len().max(1) as f32),
2575 ),
2576 (
2577 "Max |K|".to_string(),
2578 format_float(gaussian_abs.iter().copied().fold(0.0, f32::max)),
2579 ),
2580 (
2581 "Max |Principal|".to_string(),
2582 format_float(principal_abs.iter().copied().fold(0.0, f32::max)),
2583 ),
2584 ],
2585 }];
2586
2587 let mut tables = vec![AnalysisTable {
2588 title: "Vertex Curvature Samples".to_string(),
2589 columns: vec![
2590 "vertex".to_string(),
2591 "x".to_string(),
2592 "y".to_string(),
2593 "z".to_string(),
2594 "mean_curvature".to_string(),
2595 "gaussian_curvature".to_string(),
2596 "k_max".to_string(),
2597 "k_min".to_string(),
2598 "boundary".to_string(),
2599 ],
2600 rows: mesh
2601 .positions
2602 .iter()
2603 .enumerate()
2604 .map(|(index, position)| {
2605 vec![
2606 (index + 1).to_string(),
2607 format_float(position.x),
2608 format_float(position.y),
2609 format_float(position.z),
2610 format_float(summary.mean_curvature[index]),
2611 format_float(summary.gaussian_curvature[index]),
2612 format_float(summary.principal_max[index]),
2613 format_float(summary.principal_min[index]),
2614 summary.boundary_vertices[index].to_string(),
2615 ]
2616 })
2617 .collect(),
2618 }];
2619
2620 let mut extrema_rows = Vec::new();
2621 extrema_rows.extend(ridge_extrema.iter().cloned());
2622 extrema_rows.extend(valley_extrema.iter().cloned());
2623 extrema_rows.extend(gaussian_peak.iter().cloned());
2624 extrema_rows.extend(gaussian_pit.iter().cloned());
2625 if !extrema_rows.is_empty() {
2626 tables.push(AnalysisTable {
2627 title: "Curvature Extrema".to_string(),
2628 columns: vec![
2629 "vertex".to_string(),
2630 "label".to_string(),
2631 "value".to_string(),
2632 "x".to_string(),
2633 "y".to_string(),
2634 "z".to_string(),
2635 ],
2636 rows: extrema_rows
2637 .iter()
2638 .map(|row| {
2639 vec![
2640 (row.vertex_index + 1).to_string(),
2641 row.label.clone(),
2642 format_float(row.value),
2643 format_float(row.position.x),
2644 format_float(row.position.y),
2645 format_float(row.position.z),
2646 ]
2647 })
2648 .collect(),
2649 });
2650 }
2651
2652 Ok(AnalysisOutput::Composite {
2653 plots,
2654 reports,
2655 tables,
2656 diagnostics: Vec::new(),
2657 frame_fields: Vec::new(),
2658 provenance: AnalysisProvenance {
2659 kind: AnalysisKind::SurfaceCurvature,
2660 source_plots: vec![source.name.clone()],
2661 parameters: vec![
2662 ("quantity".to_string(), quantity.to_string()),
2663 ("show_extrema".to_string(), show_extrema.to_string()),
2664 ],
2665 notes: vec![
2666 "Curvature estimates use discrete triangle-mesh angle-defect and cotangent-weight formulas."
2667 .to_string(),
2668 ],
2669 },
2670 })
2671}
2672
2673struct CurveSurfaceSample {
2674 group_index: usize,
2675 sample_index: usize,
2676 position: Vec3,
2677 projected: Vec3,
2678 distance: f32,
2679}
2680
2681fn make_curve_surface_measurement_output(
2682 curve_source: &PlotSpec,
2683 surface_source_name: &str,
2684 mesh: &CombinedSurfaceMesh,
2685 max_samples: usize,
2686 vector_scale: f32,
2687) -> Result<AnalysisOutput, AnalysisError> {
2688 let groups = curve_sample_groups(curve_source)?;
2689 let sampled_groups = groups
2690 .iter()
2691 .map(|group| sample_curve_group_for_measurement(group, max_samples))
2692 .filter(|group| group.len() >= 2)
2693 .collect::<Vec<_>>();
2694 if sampled_groups.is_empty() {
2695 return Err(AnalysisError::invalid(
2696 "Curve-surface measurement requires at least two sampled curve points.",
2697 ));
2698 }
2699
2700 let mut rows = Vec::new();
2701 let mut projected_groups = Vec::new();
2702 let mut deviation_arrows = Vec::new();
2703 let mut original_length = 0.0_f32;
2704 let mut projected_length = 0.0_f32;
2705
2706 let (bbox_min, bbox_max) = bounds_for_points(&mesh.positions);
2707 let diagonal = bbox_max.distance(bbox_min).max(1.0);
2708 let arrow_stride = (sampled_groups.iter().map(Vec::len).sum::<usize>() / 64).max(1);
2709
2710 for (group_index, group) in sampled_groups.iter().enumerate() {
2711 let mut projected_group = Vec::with_capacity(group.len());
2712 let mut previous_curve = None;
2713 let mut previous_projected = None;
2714 for (sample_index, point) in group.iter().copied().enumerate() {
2715 let projected = closest_point_on_surface(mesh, point);
2716 let distance = point.distance(projected);
2717 if let Some(previous) = previous_curve {
2718 original_length += point.distance(previous);
2719 }
2720 if let Some(previous) = previous_projected {
2721 projected_length += projected.distance(previous);
2722 }
2723 previous_curve = Some(point);
2724 previous_projected = Some(projected);
2725 projected_group.push(projected.to_array());
2726 rows.push(CurveSurfaceSample {
2727 group_index,
2728 sample_index,
2729 position: point,
2730 projected,
2731 distance,
2732 });
2733 if sample_index % arrow_stride == 0 && distance > diagonal * 1.0e-5 {
2734 deviation_arrows.push(ArrowAnnotation {
2735 origin: projected.to_array(),
2736 vector: ((point - projected) * vector_scale).to_array(),
2737 label: String::new(),
2738 });
2739 }
2740 }
2741 projected_groups.push(projected_group);
2742 }
2743
2744 let distances = rows.iter().map(|row| row.distance).collect::<Vec<_>>();
2745 let (min_distance, mean_distance, max_distance) = min_mean_max(&distances);
2746 let rms_distance =
2747 (distances.iter().map(|value| value * value).sum::<f32>() / distances.len() as f32).sqrt();
2748
2749 let mut plots = vec![PlotSpec {
2750 name: format!("Projected {} on {}", curve_source.name, surface_source_name),
2751 visible: true,
2752 domain: curve_source.domain.clone(),
2753 resolution: curve_source.resolution,
2754 style: PlotStyle {
2755 colour_mode: ColourMode::Solid([0.2, 0.9, 0.95, 1.0]),
2756 line_width: 3.0,
2757 ..PlotStyle::default()
2758 },
2759 definition: crate::PlotDefinition::DerivedPolylineGroups {
2760 groups: projected_groups,
2761 },
2762 }];
2763 if !deviation_arrows.is_empty() {
2764 plots.push(PlotSpec {
2765 name: format!("Deviation {} to {}", curve_source.name, surface_source_name),
2766 visible: true,
2767 domain: curve_source.domain.clone(),
2768 resolution: curve_source.resolution,
2769 style: PlotStyle {
2770 colour_mode: ColourMode::Solid([1.0, 0.35, 0.25, 1.0]),
2771 glyph_scale: 1.0,
2772 shading: crate::ShadingMode::Unlit,
2773 ..PlotStyle::default()
2774 },
2775 definition: crate::PlotDefinition::ArrowAnnotations {
2776 arrows: deviation_arrows,
2777 show_labels: false,
2778 },
2779 });
2780 }
2781
2782 Ok(AnalysisOutput::Composite {
2783 plots,
2784 reports: vec![AnalysisReport {
2785 title: format!("Curve-Surface Measurement {}", curve_source.name),
2786 values: vec![
2787 ("Target Surface".to_string(), surface_source_name.to_string()),
2788 ("Sample Count".to_string(), rows.len().to_string()),
2789 ("Curve Length".to_string(), format_float(original_length)),
2790 ("Projected Length".to_string(), format_float(projected_length)),
2791 (
2792 "Length Difference".to_string(),
2793 format_float(projected_length - original_length),
2794 ),
2795 ("Deviation Min".to_string(), format_float(min_distance)),
2796 ("Deviation Mean".to_string(), format_float(mean_distance)),
2797 ("Deviation RMS".to_string(), format_float(rms_distance)),
2798 ("Deviation Max".to_string(), format_float(max_distance)),
2799 ],
2800 }],
2801 tables: vec![AnalysisTable {
2802 title: "Curve Surface Samples".to_string(),
2803 columns: vec![
2804 "group".to_string(),
2805 "sample".to_string(),
2806 "x".to_string(),
2807 "y".to_string(),
2808 "z".to_string(),
2809 "projected_x".to_string(),
2810 "projected_y".to_string(),
2811 "projected_z".to_string(),
2812 "deviation".to_string(),
2813 ],
2814 rows: rows
2815 .iter()
2816 .map(|row| {
2817 vec![
2818 (row.group_index + 1).to_string(),
2819 (row.sample_index + 1).to_string(),
2820 format_float(row.position.x),
2821 format_float(row.position.y),
2822 format_float(row.position.z),
2823 format_float(row.projected.x),
2824 format_float(row.projected.y),
2825 format_float(row.projected.z),
2826 format_float(row.distance),
2827 ]
2828 })
2829 .collect(),
2830 }],
2831 diagnostics: Vec::new(),
2832 frame_fields: Vec::new(),
2833 provenance: AnalysisProvenance {
2834 kind: AnalysisKind::CurveSurfaceMeasurement,
2835 source_plots: vec![curve_source.name.clone(), surface_source_name.to_string()],
2836 parameters: vec![
2837 ("max_samples".to_string(), max_samples.to_string()),
2838 ("vector_scale".to_string(), format_float(vector_scale)),
2839 ],
2840 notes: vec![
2841 "Curve points are projected to the nearest point on cached target-surface triangles."
2842 .to_string(),
2843 ],
2844 },
2845 })
2846}
2847
2848fn sample_curve_group_for_measurement(group: &[[f32; 3]], max_samples: usize) -> Vec<Vec3> {
2849 if group.len() <= max_samples {
2850 return group.iter().copied().map(Vec3::from_array).collect();
2851 }
2852 sampled_vertex_indices(group.len(), max_samples)
2853 .into_iter()
2854 .map(|index| Vec3::from_array(group[index]))
2855 .collect()
2856}
2857
2858fn closest_point_on_surface(mesh: &CombinedSurfaceMesh, point: Vec3) -> Vec3 {
2859 let mut closest = mesh.positions.first().copied().unwrap_or(Vec3::ZERO);
2860 let mut best_distance_sq = point.distance_squared(closest);
2861 for tri in mesh.indices.chunks_exact(3) {
2862 let a = mesh.positions[tri[0] as usize];
2863 let b = mesh.positions[tri[1] as usize];
2864 let c = mesh.positions[tri[2] as usize];
2865 let candidate = closest_point_on_triangle(point, a, b, c);
2866 let distance_sq = point.distance_squared(candidate);
2867 if distance_sq < best_distance_sq {
2868 best_distance_sq = distance_sq;
2869 closest = candidate;
2870 }
2871 }
2872 closest
2873}
2874
2875fn closest_point_on_triangle(point: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
2876 let ab = b - a;
2877 let ac = c - a;
2878 let ap = point - a;
2879 let d1 = ab.dot(ap);
2880 let d2 = ac.dot(ap);
2881 if d1 <= 0.0 && d2 <= 0.0 {
2882 return a;
2883 }
2884
2885 let bp = point - b;
2886 let d3 = ab.dot(bp);
2887 let d4 = ac.dot(bp);
2888 if d3 >= 0.0 && d4 <= d3 {
2889 return b;
2890 }
2891
2892 let vc = d1 * d4 - d3 * d2;
2893 if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
2894 let v = d1 / (d1 - d3);
2895 return a + ab * v;
2896 }
2897
2898 let cp = point - c;
2899 let d5 = ab.dot(cp);
2900 let d6 = ac.dot(cp);
2901 if d6 >= 0.0 && d5 <= d6 {
2902 return c;
2903 }
2904
2905 let vb = d5 * d2 - d1 * d6;
2906 if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
2907 let w = d2 / (d2 - d6);
2908 return a + ac * w;
2909 }
2910
2911 let va = d3 * d6 - d5 * d4;
2912 if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
2913 let w = (d4 - d3) / ((d4 - d3) + (d5 - d6));
2914 return b + (c - b) * w;
2915 }
2916
2917 let denom = 1.0 / (va + vb + vc);
2918 let v = vb * denom;
2919 let w = vc * denom;
2920 a + ab * v + ac * w
2921}
2922
2923fn make_surface_mesh_quality_output(
2924 source: &PlotSpec,
2925 mesh: &CombinedSurfaceMesh,
2926) -> Result<AnalysisOutput, AnalysisError> {
2927 let faces = mesh_quality_rows(mesh);
2928 let poor_faces = faces
2929 .iter()
2930 .filter(|row| row.score > 0.0)
2931 .cloned()
2932 .collect::<Vec<_>>();
2933 let area_values = faces.iter().map(|row| row.area).collect::<Vec<_>>();
2934 let aspect_values = faces.iter().map(|row| row.aspect_ratio).collect::<Vec<_>>();
2935 let min_angles = faces
2936 .iter()
2937 .map(|row| row.min_angle_deg)
2938 .collect::<Vec<_>>();
2939 let angle_distortion_values = faces
2940 .iter()
2941 .filter_map(|row| row.angle_distortion)
2942 .collect::<Vec<_>>();
2943 let area_distortion_values = faces
2944 .iter()
2945 .filter_map(|row| row.area_distortion)
2946 .collect::<Vec<_>>();
2947
2948 let mut diagnostics = Vec::new();
2949 if !poor_faces.is_empty() {
2950 diagnostics.push(Diagnostic::warning(
2951 DiagnosticKind::Validation,
2952 format!(
2953 "Detected {} mesh-quality warning face(s).",
2954 poor_faces.len()
2955 ),
2956 ));
2957 diagnostics.extend(poor_faces.iter().take(12).map(|row| {
2958 Diagnostic::warning(
2959 DiagnosticKind::Validation,
2960 format!(
2961 "Face quality warning: aspect {:.3}, min angle {:.2}deg.",
2962 row.aspect_ratio, row.min_angle_deg
2963 ),
2964 )
2965 .with_location(
2966 DiagnosticLocation::new()
2967 .with_component("face")
2968 .with_row(row.face_index + 1),
2969 )
2970 }));
2971 }
2972
2973 let reports = vec![AnalysisReport {
2974 title: format!("Surface Mesh Quality {}", source.name),
2975 values: vec![
2976 ("Triangle Count".to_string(), faces.len().to_string()),
2977 ("Warning Faces".to_string(), poor_faces.len().to_string()),
2978 (
2979 "Triangle Area Min".to_string(),
2980 format_float(area_values.iter().copied().fold(f32::INFINITY, f32::min)),
2981 ),
2982 (
2983 "Triangle Area Mean".to_string(),
2984 format_float(area_values.iter().sum::<f32>() / area_values.len().max(1) as f32),
2985 ),
2986 (
2987 "Aspect Ratio Mean".to_string(),
2988 format_float(aspect_values.iter().sum::<f32>() / aspect_values.len().max(1) as f32),
2989 ),
2990 (
2991 "Aspect Ratio Max".to_string(),
2992 format_float(aspect_values.iter().copied().fold(0.0, f32::max)),
2993 ),
2994 (
2995 "Min Angle".to_string(),
2996 format_float(min_angles.iter().copied().fold(f32::INFINITY, f32::min)),
2997 ),
2998 (
2999 "Mean Angle Distortion".to_string(),
3000 format_optional_mean(&angle_distortion_values),
3001 ),
3002 (
3003 "Mean Area Distortion".to_string(),
3004 format_optional_mean(&area_distortion_values),
3005 ),
3006 ],
3007 }];
3008
3009 let mut tables = vec![AnalysisTable {
3010 title: "Mesh Face Statistics".to_string(),
3011 columns: vec![
3012 "face".to_string(),
3013 "cx".to_string(),
3014 "cy".to_string(),
3015 "cz".to_string(),
3016 "area".to_string(),
3017 "min_angle_deg".to_string(),
3018 "max_angle_deg".to_string(),
3019 "aspect_ratio".to_string(),
3020 "angle_distortion".to_string(),
3021 "area_distortion".to_string(),
3022 "warning_score".to_string(),
3023 ],
3024 rows: faces
3025 .iter()
3026 .map(|row| {
3027 vec![
3028 (row.face_index + 1).to_string(),
3029 format_float(row.centroid.x),
3030 format_float(row.centroid.y),
3031 format_float(row.centroid.z),
3032 format_float(row.area),
3033 format_float(row.min_angle_deg),
3034 format_float(row.max_angle_deg),
3035 format_float(row.aspect_ratio),
3036 row.angle_distortion
3037 .map(format_float)
3038 .unwrap_or_else(|| "n/a".to_string()),
3039 row.area_distortion
3040 .map(format_float)
3041 .unwrap_or_else(|| "n/a".to_string()),
3042 format_float(row.score),
3043 ]
3044 })
3045 .collect(),
3046 }];
3047
3048 if !poor_faces.is_empty() {
3049 tables.push(AnalysisTable {
3050 title: "Mesh Quality Alerts".to_string(),
3051 columns: vec![
3052 "face".to_string(),
3053 "area".to_string(),
3054 "min_angle_deg".to_string(),
3055 "aspect_ratio".to_string(),
3056 "warning_score".to_string(),
3057 ],
3058 rows: poor_faces
3059 .iter()
3060 .take(250)
3061 .map(|row| {
3062 vec![
3063 (row.face_index + 1).to_string(),
3064 format_float(row.area),
3065 format_float(row.min_angle_deg),
3066 format_float(row.aspect_ratio),
3067 format_float(row.score),
3068 ]
3069 })
3070 .collect(),
3071 });
3072 }
3073
3074 let plots = if poor_faces.is_empty() {
3075 Vec::new()
3076 } else {
3077 vec![PlotSpec {
3078 name: format!("Mesh Quality Alerts {}", source.name),
3079 visible: true,
3080 domain: source.domain.clone(),
3081 resolution: source.resolution,
3082 style: PlotStyle {
3083 colour_mode: ColourMode::Solid([1.0, 0.35, 0.3, 1.0]),
3084 point_size: 7.5,
3085 ..PlotStyle::default()
3086 },
3087 definition: crate::PlotDefinition::PointAnnotations {
3088 points: poor_faces
3089 .iter()
3090 .take(32)
3091 .map(|row| PointAnnotation {
3092 position: row.centroid.to_array(),
3093 label: String::new(),
3094 })
3095 .collect(),
3096 show_labels: false,
3097 },
3098 }]
3099 };
3100
3101 Ok(AnalysisOutput::Composite {
3102 plots,
3103 reports,
3104 tables,
3105 diagnostics,
3106 frame_fields: Vec::new(),
3107 provenance: AnalysisProvenance {
3108 kind: AnalysisKind::SurfaceMeshQuality,
3109 source_plots: vec![source.name.clone()],
3110 parameters: Vec::new(),
3111 notes: vec![
3112 "Mesh quality uses per-face area, angle, aspect-ratio, and distortion summaries."
3113 .to_string(),
3114 ],
3115 },
3116 })
3117}
3118
3119fn estimate_surface_curvatures(mesh: &CombinedSurfaceMesh) -> SurfaceCurvatureSummary {
3120 let vertex_count = mesh.positions.len();
3121 let mut vertex_normals_accum = vec![Vec3::ZERO; vertex_count];
3122 let mut vertex_area = vec![0.0_f32; vertex_count];
3123 let mut angle_sum = vec![0.0_f32; vertex_count];
3124 let mut edge_weights: HashMap<(usize, usize), f32> = HashMap::new();
3125 let mut edge_counts: HashMap<(usize, usize), u32> = HashMap::new();
3126
3127 for tri in mesh.indices.chunks_exact(3) {
3128 let i0 = tri[0] as usize;
3129 let i1 = tri[1] as usize;
3130 let i2 = tri[2] as usize;
3131 let p0 = mesh.positions[i0];
3132 let p1 = mesh.positions[i1];
3133 let p2 = mesh.positions[i2];
3134
3135 let face_cross = (p1 - p0).cross(p2 - p0);
3136 let area = 0.5 * face_cross.length();
3137 let face_normal = face_cross.normalize_or_zero();
3138 vertex_normals_accum[i0] += face_normal * area;
3139 vertex_normals_accum[i1] += face_normal * area;
3140 vertex_normals_accum[i2] += face_normal * area;
3141 vertex_area[i0] += area / 3.0;
3142 vertex_area[i1] += area / 3.0;
3143 vertex_area[i2] += area / 3.0;
3144
3145 let angle0 = angle_between_3d_local(p1 - p0, p2 - p0);
3146 let angle1 = angle_between_3d_local(p2 - p1, p0 - p1);
3147 let angle2 = angle_between_3d_local(p0 - p2, p1 - p2);
3148 angle_sum[i0] += angle0;
3149 angle_sum[i1] += angle1;
3150 angle_sum[i2] += angle2;
3151
3152 accumulate_edge_weight(
3153 &mut edge_weights,
3154 &mut edge_counts,
3155 i1,
3156 i2,
3157 cotangent(angle0),
3158 );
3159 accumulate_edge_weight(
3160 &mut edge_weights,
3161 &mut edge_counts,
3162 i0,
3163 i2,
3164 cotangent(angle1),
3165 );
3166 accumulate_edge_weight(
3167 &mut edge_weights,
3168 &mut edge_counts,
3169 i0,
3170 i1,
3171 cotangent(angle2),
3172 );
3173 }
3174
3175 let vertex_normals = vertex_normals_accum
3176 .into_iter()
3177 .map(|normal| normal.normalize_or_zero())
3178 .collect::<Vec<_>>();
3179 let mut boundary_vertices = vec![false; vertex_count];
3180 let mut neighbors = vec![Vec::new(); vertex_count];
3181 let mut mean_curvature_normal_sum = vec![Vec3::ZERO; vertex_count];
3182
3183 for (&(a, b), &weight) in &edge_weights {
3184 neighbors[a].push(b);
3185 neighbors[b].push(a);
3186 mean_curvature_normal_sum[a] += weight * (mesh.positions[a] - mesh.positions[b]);
3187 mean_curvature_normal_sum[b] += weight * (mesh.positions[b] - mesh.positions[a]);
3188 if edge_counts.get(&(a, b)).copied().unwrap_or(0) == 1 {
3189 boundary_vertices[a] = true;
3190 boundary_vertices[b] = true;
3191 }
3192 }
3193
3194 let mut mean_curvature = vec![0.0_f32; vertex_count];
3195 let mut gaussian_curvature = vec![0.0_f32; vertex_count];
3196 let mut principal_max = vec![0.0_f32; vertex_count];
3197 let mut principal_min = vec![0.0_f32; vertex_count];
3198
3199 for index in 0..vertex_count {
3200 let area = vertex_area[index].max(1.0e-6);
3201 let mean_normal = mean_curvature_normal_sum[index] / (2.0 * area);
3202 let signed_mean = 0.5 * mean_normal.dot(vertex_normals[index]);
3203 let gaussian = ((if boundary_vertices[index] {
3204 std::f32::consts::PI
3205 } else {
3206 std::f32::consts::TAU
3207 }) - angle_sum[index])
3208 / area;
3209 let discriminant = (signed_mean * signed_mean - gaussian).max(0.0).sqrt();
3210 mean_curvature[index] = signed_mean;
3211 gaussian_curvature[index] = gaussian;
3212 principal_max[index] = signed_mean + discriminant;
3213 principal_min[index] = signed_mean - discriminant;
3214 }
3215
3216 SurfaceCurvatureSummary {
3217 boundary_vertices,
3218 mean_curvature,
3219 gaussian_curvature,
3220 principal_max,
3221 principal_min,
3222 neighbors,
3223 }
3224}
3225
3226fn mesh_quality_rows(mesh: &CombinedSurfaceMesh) -> Vec<MeshQualityFaceRow> {
3227 mesh.indices
3228 .chunks_exact(3)
3229 .enumerate()
3230 .map(|(face_index, tri)| {
3231 let p0 = mesh.positions[tri[0] as usize];
3232 let p1 = mesh.positions[tri[1] as usize];
3233 let p2 = mesh.positions[tri[2] as usize];
3234 let edge_lengths = [(p1 - p0).length(), (p2 - p1).length(), (p0 - p2).length()];
3235 let longest = edge_lengths.iter().copied().fold(0.0, f32::max);
3236 let shortest = edge_lengths
3237 .iter()
3238 .copied()
3239 .fold(f32::INFINITY, f32::min)
3240 .max(1.0e-6);
3241 let aspect_ratio = longest / shortest;
3242 let angles = triangle_angles_3d_local([p0, p1, p2]).map(f32::to_degrees);
3243 let min_angle_deg = angles.iter().copied().fold(f32::INFINITY, f32::min);
3244 let max_angle_deg = angles.iter().copied().fold(0.0, f32::max);
3245 let area = 0.5 * (p1 - p0).cross(p2 - p0).length();
3246 let angle_distortion = mesh.angle_distortion.get(face_index).copied();
3247 let area_distortion = mesh.area_distortion.get(face_index).copied();
3248 let score = mesh_quality_score(area, min_angle_deg, max_angle_deg, aspect_ratio);
3249 MeshQualityFaceRow {
3250 face_index,
3251 centroid: (p0 + p1 + p2) / 3.0,
3252 area,
3253 min_angle_deg,
3254 max_angle_deg,
3255 aspect_ratio,
3256 angle_distortion,
3257 area_distortion,
3258 score,
3259 }
3260 })
3261 .collect()
3262}
3263
3264fn local_principal_maxima(
3265 mesh: &CombinedSurfaceMesh,
3266 summary: &SurfaceCurvatureSummary,
3267 limit: usize,
3268) -> Vec<SurfaceExtremum> {
3269 local_extrema(
3270 mesh,
3271 &summary.principal_max,
3272 &summary.neighbors,
3273 limit,
3274 true,
3275 )
3276 .into_iter()
3277 .filter(|extremum| extremum.value > 0.0)
3278 .map(|mut extremum| {
3279 extremum.label = "Ridge".to_string();
3280 extremum
3281 })
3282 .collect()
3283}
3284
3285fn local_principal_minima(
3286 mesh: &CombinedSurfaceMesh,
3287 summary: &SurfaceCurvatureSummary,
3288 limit: usize,
3289) -> Vec<SurfaceExtremum> {
3290 local_extrema(
3291 mesh,
3292 &summary.principal_min,
3293 &summary.neighbors,
3294 limit,
3295 false,
3296 )
3297 .into_iter()
3298 .filter(|extremum| extremum.value < 0.0)
3299 .map(|mut extremum| {
3300 extremum.label = "Valley".to_string();
3301 extremum
3302 })
3303 .collect()
3304}
3305
3306fn local_extrema(
3307 mesh: &CombinedSurfaceMesh,
3308 values: &[f32],
3309 neighbors: &[Vec<usize>],
3310 limit: usize,
3311 maxima: bool,
3312) -> Vec<SurfaceExtremum> {
3313 let mut extrema = values
3314 .iter()
3315 .enumerate()
3316 .filter_map(|(index, value)| {
3317 let neighbour_values = neighbors[index]
3318 .iter()
3319 .filter_map(|neighbor| values.get(*neighbor))
3320 .copied()
3321 .collect::<Vec<_>>();
3322 if neighbour_values.is_empty() {
3323 return None;
3324 }
3325 let is_extremum = if maxima {
3326 neighbour_values.iter().all(|other| *value >= *other)
3327 && neighbour_values.iter().any(|other| *value > *other)
3328 } else {
3329 neighbour_values.iter().all(|other| *value <= *other)
3330 && neighbour_values.iter().any(|other| *value < *other)
3331 };
3332 is_extremum.then_some(SurfaceExtremum {
3333 vertex_index: index,
3334 position: mesh.positions[index],
3335 value: *value,
3336 label: String::new(),
3337 })
3338 })
3339 .collect::<Vec<_>>();
3340
3341 extrema.sort_by(|a, b| {
3342 b.value
3343 .abs()
3344 .partial_cmp(&a.value.abs())
3345 .unwrap_or(std::cmp::Ordering::Equal)
3346 });
3347 extrema.truncate(limit);
3348 extrema
3349}
3350
3351fn top_vertex_extrema(
3352 mesh: &CombinedSurfaceMesh,
3353 values: &[f32],
3354 limit: usize,
3355 label: &str,
3356 descending: bool,
3357) -> Vec<SurfaceExtremum> {
3358 let mut rows = values
3359 .iter()
3360 .enumerate()
3361 .map(|(index, value)| SurfaceExtremum {
3362 vertex_index: index,
3363 position: mesh.positions[index],
3364 value: *value,
3365 label: label.to_string(),
3366 })
3367 .collect::<Vec<_>>();
3368 rows.sort_by(|a, b| {
3369 if descending {
3370 b.value
3371 .partial_cmp(&a.value)
3372 .unwrap_or(std::cmp::Ordering::Equal)
3373 } else {
3374 a.value
3375 .partial_cmp(&b.value)
3376 .unwrap_or(std::cmp::Ordering::Equal)
3377 }
3378 });
3379 rows.truncate(limit);
3380 rows
3381}
3382
3383fn compute_vertex_normals(positions: &[Vec3], indices: &[u32]) -> Vec<Vec3> {
3384 let mut normals = vec![Vec3::ZERO; positions.len()];
3385 for tri in indices.chunks_exact(3) {
3386 let i0 = tri[0] as usize;
3387 let i1 = tri[1] as usize;
3388 let i2 = tri[2] as usize;
3389 let cross = (positions[i1] - positions[i0]).cross(positions[i2] - positions[i0]);
3390 normals[i0] += cross;
3391 normals[i1] += cross;
3392 normals[i2] += cross;
3393 }
3394 normals
3395 .into_iter()
3396 .map(|normal| normal.normalize_or_zero())
3397 .collect()
3398}
3399
3400fn triangle_areas(positions: &[Vec3], indices: &[u32]) -> Vec<f32> {
3401 indices
3402 .chunks_exact(3)
3403 .map(|tri| {
3404 let p0 = positions[tri[0] as usize];
3405 let p1 = positions[tri[1] as usize];
3406 let p2 = positions[tri[2] as usize];
3407 0.5 * (p1 - p0).cross(p2 - p0).length()
3408 })
3409 .collect()
3410}
3411
3412fn sampled_vertex_indices(vertex_count: usize, max_samples: usize) -> Vec<usize> {
3413 if vertex_count <= max_samples {
3414 return (0..vertex_count).collect();
3415 }
3416 let step = ((vertex_count as f32 / max_samples as f32).ceil() as usize).max(1);
3417 (0..vertex_count).step_by(step).collect()
3418}
3419
3420fn accumulate_edge_weight(
3421 edge_weights: &mut HashMap<(usize, usize), f32>,
3422 edge_counts: &mut HashMap<(usize, usize), u32>,
3423 a: usize,
3424 b: usize,
3425 weight: f32,
3426) {
3427 let key = if a < b { (a, b) } else { (b, a) };
3428 *edge_weights.entry(key).or_insert(0.0) += weight;
3429 *edge_counts.entry(key).or_insert(0) += 1;
3430}
3431
3432fn cotangent(angle: f32) -> f32 {
3433 let sin = angle.sin().abs().max(1.0e-6);
3434 angle.cos() / sin
3435}
3436
3437fn triangle_angles_3d_local(points: [Vec3; 3]) -> [f32; 3] {
3438 [
3439 angle_between_3d_local(points[1] - points[0], points[2] - points[0]),
3440 angle_between_3d_local(points[0] - points[1], points[2] - points[1]),
3441 angle_between_3d_local(points[0] - points[2], points[1] - points[2]),
3442 ]
3443}
3444
3445fn angle_between_3d_local(a: Vec3, b: Vec3) -> f32 {
3446 let denom = a.length() * b.length();
3447 if denom <= 1.0e-8 {
3448 0.0
3449 } else {
3450 (a.dot(b) / denom).clamp(-1.0, 1.0).acos()
3451 }
3452}
3453
3454fn mesh_quality_score(area: f32, min_angle_deg: f32, max_angle_deg: f32, aspect_ratio: f32) -> f32 {
3455 let mut score = 0.0;
3456 if area <= 1.0e-8 {
3457 score += 10.0;
3458 }
3459 if min_angle_deg < 15.0 {
3460 score += (15.0 - min_angle_deg) / 15.0;
3461 }
3462 if max_angle_deg > 150.0 {
3463 score += (max_angle_deg - 150.0) / 30.0;
3464 }
3465 if aspect_ratio > 4.0 {
3466 score += (aspect_ratio - 4.0) / 2.0;
3467 }
3468 score
3469}
3470
3471fn min_mean_max(values: &[f32]) -> (f32, f32, f32) {
3472 if values.is_empty() {
3473 return (0.0, 0.0, 0.0);
3474 }
3475 (
3476 values.iter().copied().fold(f32::INFINITY, f32::min),
3477 values.iter().sum::<f32>() / values.len() as f32,
3478 values.iter().copied().fold(f32::NEG_INFINITY, f32::max),
3479 )
3480}
3481
3482fn format_optional_mean(values: &[f32]) -> String {
3483 if values.is_empty() {
3484 "n/a".to_string()
3485 } else {
3486 format_float(values.iter().sum::<f32>() / values.len() as f32)
3487 }
3488}
3489
3490#[derive(Clone)]
3491struct IndexedSample {
3492 sequence_index: usize,
3493 sequence_row: usize,
3494 sample_index: usize,
3495 position: Vec3,
3496}
3497
3498struct NearestNeighborSummary {
3499 nearest_distances: Vec<f32>,
3500 min: f32,
3501 mean: f32,
3502 median: f32,
3503 max: f32,
3504 std: f32,
3505}
3506
3507fn flatten_sample_groups(groups: &[Vec<[f32; 3]>]) -> Vec<Vec3> {
3508 groups
3509 .iter()
3510 .flat_map(|group| group.iter().map(|point| Vec3::from_array(*point)))
3511 .collect()
3512}
3513
3514fn flatten_indexed_sample_groups(groups: &[Vec<[f32; 3]>]) -> Vec<IndexedSample> {
3515 groups
3516 .iter()
3517 .enumerate()
3518 .flat_map(|(sequence_index, group)| {
3519 group
3520 .iter()
3521 .enumerate()
3522 .map(move |(row, point)| IndexedSample {
3523 sequence_index: sequence_index + 1,
3524 sequence_row: row + 1,
3525 sample_index: 0,
3526 position: Vec3::from_array(*point),
3527 })
3528 })
3529 .enumerate()
3530 .map(|(sample_index, mut sample)| {
3531 sample.sample_index = sample_index;
3532 sample
3533 })
3534 .collect()
3535}
3536
3537fn bounds_for_points(points: &[Vec3]) -> (Vec3, Vec3) {
3538 let mut min = Vec3::splat(f32::INFINITY);
3539 let mut max = Vec3::splat(f32::NEG_INFINITY);
3540 for point in points {
3541 min = min.min(*point);
3542 max = max.max(*point);
3543 }
3544 (min, max)
3545}
3546
3547fn covariance_matrix(points: &[Vec3], centroid: Vec3) -> glam::Mat3 {
3548 let mut xx = 0.0_f32;
3549 let mut xy = 0.0_f32;
3550 let mut xz = 0.0_f32;
3551 let mut yy = 0.0_f32;
3552 let mut yz = 0.0_f32;
3553 let mut zz = 0.0_f32;
3554 for point in points {
3555 let d = *point - centroid;
3556 xx += d.x * d.x;
3557 xy += d.x * d.y;
3558 xz += d.x * d.z;
3559 yy += d.y * d.y;
3560 yz += d.y * d.z;
3561 zz += d.z * d.z;
3562 }
3563 let scale = 1.0 / points.len().max(1) as f32;
3564 glam::Mat3::from_cols_array(&[
3565 xx * scale,
3566 xy * scale,
3567 xz * scale,
3568 xy * scale,
3569 yy * scale,
3570 yz * scale,
3571 xz * scale,
3572 yz * scale,
3573 zz * scale,
3574 ])
3575}
3576
3577fn principal_components(covariance: glam::Mat3) -> [(f32, Vec3); 3] {
3578 let mut components = [(0.0_f32, Vec3::X), (0.0_f32, Vec3::Y), (0.0_f32, Vec3::Z)];
3579 let mut matrix = covariance;
3580 for component in &mut components {
3581 let direction = power_iteration(matrix);
3582 let eigenvalue = direction.dot(matrix * direction).max(0.0);
3583 *component = (eigenvalue, direction);
3584 matrix -= eigenvalue * outer_product(direction);
3585 }
3586 components.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
3587 if components[2].1.length_squared() < 1.0e-6 {
3588 components[2].1 = components[0].1.cross(components[1].1).normalize_or_zero();
3589 }
3590 components
3591}
3592
3593fn power_iteration(matrix: glam::Mat3) -> Vec3 {
3594 let mut v = Vec3::new(0.71, 0.53, 0.47).normalize();
3595 for _ in 0..16 {
3596 let next = matrix * v;
3597 if next.length_squared() <= 1.0e-8 {
3598 break;
3599 }
3600 v = next.normalize();
3601 }
3602 v.normalize_or_zero()
3603}
3604
3605fn outer_product(v: Vec3) -> glam::Mat3 {
3606 glam::Mat3::from_cols(
3607 Vec3::new(v.x * v.x, v.x * v.y, v.x * v.z),
3608 Vec3::new(v.y * v.x, v.y * v.y, v.y * v.z),
3609 Vec3::new(v.z * v.x, v.z * v.y, v.z * v.z),
3610 )
3611}
3612
3613fn exact_duplicate_rows(samples: &[IndexedSample]) -> Vec<IndexedSample> {
3614 let mut seen = std::collections::HashMap::new();
3615 let mut duplicates = Vec::new();
3616 for sample in samples {
3617 let key = (
3618 sample.position.x.to_bits(),
3619 sample.position.y.to_bits(),
3620 sample.position.z.to_bits(),
3621 );
3622 if seen.insert(key, sample.sequence_row).is_some() {
3623 duplicates.push(sample.clone());
3624 }
3625 }
3626 duplicates
3627}
3628
3629fn nearest_neighbor_stats(samples: &[IndexedSample]) -> Option<NearestNeighborSummary> {
3630 if samples.len() < 2 {
3631 return None;
3632 }
3633 let mut nearest_distances = vec![f32::INFINITY; samples.len()];
3634 for i in 0..samples.len() {
3635 for j in (i + 1)..samples.len() {
3636 let distance = samples[i].position.distance(samples[j].position);
3637 nearest_distances[i] = nearest_distances[i].min(distance);
3638 nearest_distances[j] = nearest_distances[j].min(distance);
3639 }
3640 }
3641 let filtered = nearest_distances
3642 .iter()
3643 .copied()
3644 .filter(|distance| distance.is_finite())
3645 .collect::<Vec<_>>();
3646 if filtered.is_empty() {
3647 return None;
3648 }
3649 let mut sorted = filtered.clone();
3650 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
3651 let (mean, std) = mean_std(&filtered);
3652 Some(NearestNeighborSummary {
3653 min: *sorted.first().unwrap_or(&0.0),
3654 mean,
3655 median: sorted[sorted.len() / 2],
3656 max: *sorted.last().unwrap_or(&0.0),
3657 std,
3658 nearest_distances,
3659 })
3660}
3661
3662fn mean_std(values: &[f32]) -> (f32, f32) {
3663 if values.is_empty() {
3664 return (0.0, 0.0);
3665 }
3666 let mean = values.iter().sum::<f32>() / values.len() as f32;
3667 let variance = values
3668 .iter()
3669 .map(|value| {
3670 let d = *value - mean;
3671 d * d
3672 })
3673 .sum::<f32>()
3674 / values.len() as f32;
3675 (mean, variance.sqrt())
3676}
3677
3678fn monotonicity_label(group: &[[f32; 3]], axis: usize) -> String {
3679 if axis_is_monotonic(group, axis) {
3680 let first = axis_value(Vec3::from_array(group[0]), axis);
3681 let last = axis_value(Vec3::from_array(*group.last().unwrap_or(&group[0])), axis);
3682 if last >= first {
3683 "increasing".to_string()
3684 } else {
3685 "decreasing".to_string()
3686 }
3687 } else {
3688 "mixed".to_string()
3689 }
3690}
3691
3692fn format_float(value: f32) -> String {
3693 format!("{value:.5}")
3694}
3695
3696fn format_vec3(value: Vec3) -> String {
3697 format!("{:.5}, {:.5}, {:.5}", value.x, value.y, value.z)
3698}
3699
3700fn sample_positions_table(groups: &[Vec<[f32; 3]>]) -> AnalysisTable {
3701 AnalysisTable {
3702 title: "Sample Positions".to_string(),
3703 columns: vec![
3704 "sequence".to_string(),
3705 "row".to_string(),
3706 "x".to_string(),
3707 "y".to_string(),
3708 "z".to_string(),
3709 ],
3710 rows: groups
3711 .iter()
3712 .enumerate()
3713 .flat_map(|(sequence_idx, group)| {
3714 group.iter().enumerate().map(move |(row_idx, point)| {
3715 vec![
3716 (sequence_idx + 1).to_string(),
3717 (row_idx + 1).to_string(),
3718 format_float(point[0]),
3719 format_float(point[1]),
3720 format_float(point[2]),
3721 ]
3722 })
3723 })
3724 .collect(),
3725 }
3726}
3727
3728fn flagged_samples_table<'a>(
3729 title: impl Into<String>,
3730 metric_label: &str,
3731 rows: impl IntoIterator<Item = (&'a IndexedSample, f32)>,
3732) -> AnalysisTable {
3733 AnalysisTable {
3734 title: title.into(),
3735 columns: vec![
3736 "sequence".to_string(),
3737 "row".to_string(),
3738 "sample".to_string(),
3739 "x".to_string(),
3740 "y".to_string(),
3741 "z".to_string(),
3742 metric_label.to_string(),
3743 ],
3744 rows: rows
3745 .into_iter()
3746 .map(|(sample, metric)| {
3747 vec![
3748 sample.sequence_index.to_string(),
3749 sample.sequence_row.to_string(),
3750 (sample.sample_index + 1).to_string(),
3751 format_float(sample.position.x),
3752 format_float(sample.position.y),
3753 format_float(sample.position.z),
3754 format_float(metric),
3755 ]
3756 })
3757 .collect(),
3758 }
3759}
3760
3761fn sample_position_occurrences(samples: &[IndexedSample], position: Vec3) -> f32 {
3762 samples
3763 .iter()
3764 .filter(|sample| sample.position == position)
3765 .count() as f32
3766}
3767
3768fn sample_warning(sample: &IndexedSample, message: String) -> Diagnostic {
3769 Diagnostic::warning(DiagnosticKind::Validation, message).with_location(
3770 DiagnosticLocation::new()
3771 .with_component(format!("sequence {} sample", sample.sequence_index))
3772 .with_row(sample.sequence_row),
3773 )
3774}
3775
3776fn fit_curve_group(
3777 source: &PlotSpec,
3778 group: &[[f32; 3]],
3779 options: &CurveFitOptions,
3780) -> Result<CurveFitResult, AnalysisError> {
3781 match &source.definition {
3782 crate::PlotDefinition::ExprCartesianLine {
3783 dep_var, ind_var, ..
3784 } => fit_cartesian_line_group(group, dep_var.as_str(), ind_var.as_str(), options),
3785 _ => fit_parametric_curve_group(group, options),
3786 }
3787}
3788
3789fn fit_cartesian_line_group(
3790 group: &[[f32; 3]],
3791 dep_var: &str,
3792 ind_var: &str,
3793 options: &CurveFitOptions,
3794) -> Result<CurveFitResult, AnalysisError> {
3795 if group.len() < 2 {
3796 return Err(AnalysisError::invalid(
3797 "Need at least two points to fit a curve.",
3798 ));
3799 }
3800 let xs: Vec<f64> = group
3801 .iter()
3802 .filter_map(|point| {
3803 cartesian_axis_value(Vec3::from_array(*point), ind_var).map(|v| v as f64)
3804 })
3805 .collect();
3806 let ys: Vec<f64> = group
3807 .iter()
3808 .filter_map(|point| {
3809 cartesian_axis_value(Vec3::from_array(*point), dep_var).map(|v| v as f64)
3810 })
3811 .collect();
3812 if xs.len() != group.len() || ys.len() != group.len() {
3813 return Err(AnalysisError::invalid(
3814 "Could not read curve axes for fitting.",
3815 ));
3816 }
3817 let evaluation_count = resampled_point_count(group.len(), options.samples_per_segment);
3818 let x_eval = evenly_spaced_range(*xs.first().unwrap(), *xs.last().unwrap(), evaluation_count);
3819
3820 match options.method {
3821 CurveFitMethod::Polynomial => {
3822 let fit = polynomial_fit(&xs, &ys, options.degree, None)?;
3823 let fitted_group = x_eval
3824 .iter()
3825 .map(|x| {
3826 cartesian_line_point(dep_var, ind_var, *x as f32, fit.evaluate(*x) as f32)
3827 .to_array()
3828 })
3829 .collect();
3830 let residual_values = xs
3831 .iter()
3832 .zip(&ys)
3833 .map(|(x, y)| (fit.evaluate(*x) - *y) as f32)
3834 .collect();
3835 Ok(CurveFitResult {
3836 fitted_group,
3837 residual_values,
3838 })
3839 }
3840 CurveFitMethod::RobustPolynomial => {
3841 let fit = robust_polynomial_fit_scalar(&xs, &ys, options.degree)?;
3842 let fitted_group = x_eval
3843 .iter()
3844 .map(|x| {
3845 cartesian_line_point(dep_var, ind_var, *x as f32, fit.evaluate(*x) as f32)
3846 .to_array()
3847 })
3848 .collect();
3849 let residual_values = xs
3850 .iter()
3851 .zip(&ys)
3852 .map(|(x, y)| (fit.evaluate(*x) - *y) as f32)
3853 .collect();
3854 Ok(CurveFitResult {
3855 fitted_group,
3856 residual_values,
3857 })
3858 }
3859 CurveFitMethod::Fourier => {
3860 let fit = fourier_fit(&xs, &ys, options.harmonics)?;
3861 let fitted_group = x_eval
3862 .iter()
3863 .map(|x| {
3864 cartesian_line_point(dep_var, ind_var, *x as f32, fit.evaluate(*x) as f32)
3865 .to_array()
3866 })
3867 .collect();
3868 let residual_values = xs
3869 .iter()
3870 .zip(&ys)
3871 .map(|(x, y)| (fit.evaluate(*x) - *y) as f32)
3872 .collect();
3873 Ok(CurveFitResult {
3874 fitted_group,
3875 residual_values,
3876 })
3877 }
3878 CurveFitMethod::Spline => {
3879 let smoothed = smooth_scalar_values(&ys, options.smoothing_window);
3880 let control_points: Vec<[f32; 3]> = xs
3881 .iter()
3882 .zip(smoothed.iter())
3883 .map(|(x, y)| {
3884 cartesian_line_point(dep_var, ind_var, *x as f32, *y as f32).to_array()
3885 })
3886 .collect();
3887 let fitted_group = sample_curve_points(
3888 &control_points
3889 .iter()
3890 .map(|point| Vec3::from_array(*point))
3891 .collect::<Vec<_>>(),
3892 CurveInterpolation {
3893 kind: CurveInterpolationKind::CentripetalCatmullRom,
3894 samples_per_segment: options.samples_per_segment,
3895 closed: false,
3896 smoothing_window: options.smoothing_window,
3897 },
3898 )
3899 .into_iter()
3900 .map(|point| point.to_array())
3901 .collect();
3902 let residual_values = ys
3903 .iter()
3904 .zip(smoothed.iter())
3905 .map(|(observed, fitted)| (*fitted - *observed) as f32)
3906 .collect();
3907 Ok(CurveFitResult {
3908 fitted_group,
3909 residual_values,
3910 })
3911 }
3912 }
3913}
3914
3915fn fit_parametric_curve_group(
3916 group: &[[f32; 3]],
3917 options: &CurveFitOptions,
3918) -> Result<CurveFitResult, AnalysisError> {
3919 if group.len() < 2 {
3920 return Err(AnalysisError::invalid(
3921 "Need at least two points to fit a curve.",
3922 ));
3923 }
3924 let ts = evenly_spaced_parameter_values(group.len());
3925 let evaluation_ts = evenly_spaced_parameter_values(resampled_point_count(
3926 group.len(),
3927 options.samples_per_segment,
3928 ));
3929 let positions: Vec<Vec3> = group.iter().map(|point| Vec3::from_array(*point)).collect();
3930
3931 match options.method {
3932 CurveFitMethod::Polynomial => {
3933 let fit = polynomial_fit_vector(&ts, &positions, options.degree, None)?;
3934 let fitted_group = evaluation_ts
3935 .iter()
3936 .map(|t| fit.evaluate(*t).to_array())
3937 .collect();
3938 let residual_values = ts
3939 .iter()
3940 .zip(positions.iter())
3941 .map(|(t, observed)| fit.evaluate(*t).distance(*observed))
3942 .collect();
3943 Ok(CurveFitResult {
3944 fitted_group,
3945 residual_values,
3946 })
3947 }
3948 CurveFitMethod::RobustPolynomial => {
3949 let fit = robust_polynomial_fit_vector(&ts, &positions, options.degree)?;
3950 let fitted_group = evaluation_ts
3951 .iter()
3952 .map(|t| fit.evaluate(*t).to_array())
3953 .collect();
3954 let residual_values = ts
3955 .iter()
3956 .zip(positions.iter())
3957 .map(|(t, observed)| fit.evaluate(*t).distance(*observed))
3958 .collect();
3959 Ok(CurveFitResult {
3960 fitted_group,
3961 residual_values,
3962 })
3963 }
3964 CurveFitMethod::Fourier => {
3965 let fit = fourier_fit_vector(&ts, &positions, options.harmonics)?;
3966 let fitted_group = evaluation_ts
3967 .iter()
3968 .map(|t| fit.evaluate(*t).to_array())
3969 .collect();
3970 let residual_values = ts
3971 .iter()
3972 .zip(positions.iter())
3973 .map(|(t, observed)| fit.evaluate(*t).distance(*observed))
3974 .collect();
3975 Ok(CurveFitResult {
3976 fitted_group,
3977 residual_values,
3978 })
3979 }
3980 CurveFitMethod::Spline => {
3981 let smoothed = smooth_vec3_values(&positions, options.smoothing_window);
3982 let fitted_group = sample_curve_points(
3983 &smoothed,
3984 CurveInterpolation {
3985 kind: CurveInterpolationKind::CentripetalCatmullRom,
3986 samples_per_segment: options.samples_per_segment,
3987 closed: false,
3988 smoothing_window: options.smoothing_window,
3989 },
3990 )
3991 .into_iter()
3992 .map(|point| point.to_array())
3993 .collect();
3994 let residual_values = positions
3995 .iter()
3996 .zip(smoothed.iter())
3997 .map(|(observed, fitted)| fitted.distance(*observed))
3998 .collect();
3999 Ok(CurveFitResult {
4000 fitted_group,
4001 residual_values,
4002 })
4003 }
4004 }
4005}
4006
4007fn derived_polyline_plot(
4008 source: &PlotSpec,
4009 name: String,
4010 color: [f32; 4],
4011 groups: Vec<Vec<[f32; 3]>>,
4012) -> Result<PlotSpec, AnalysisError> {
4013 if groups.is_empty() {
4014 return Err(AnalysisError::invalid(
4015 "The selected plot did not produce enough samples for this analysis.",
4016 ));
4017 }
4018 Ok(PlotSpec {
4019 name,
4020 visible: true,
4021 domain: source.domain.clone(),
4022 resolution: source.resolution,
4023 style: PlotStyle {
4024 colour_mode: ColourMode::Solid(color),
4025 line_width: 2.25,
4026 ..PlotStyle::default()
4027 },
4028 definition: crate::PlotDefinition::DerivedPolylineGroups { groups },
4029 })
4030}
4031
4032fn make_curve_fit_output(
4033 source: &PlotSpec,
4034 options: CurveFitOptions,
4035) -> Result<AnalysisOutput, AnalysisError> {
4036 let groups = curve_sample_groups(source)?;
4037 if groups.is_empty() || groups.iter().all(|group| group.len() < 2) {
4038 return Err(AnalysisError::invalid(
4039 "Curve fitting requires at least one sampled curve with two or more points.",
4040 ));
4041 }
4042
4043 let mut fitted_groups = Vec::new();
4044 let mut residual_groups = Vec::new();
4045 let mut control_points = Vec::new();
4046 let mut residual_samples = Vec::new();
4047 let mut total_points = 0usize;
4048
4049 for group in &groups {
4050 if group.len() < 2 {
4051 continue;
4052 }
4053 total_points += group.len();
4054 control_points.extend(group.iter().copied());
4055 let fit = fit_curve_group(source, group, &options)?;
4056 if fit.fitted_group.len() >= 2 {
4057 fitted_groups.push(fit.fitted_group);
4058 }
4059 if fit.residual_values.len() >= 2 {
4060 let residual_group = match &source.definition {
4061 crate::PlotDefinition::ExprCartesianLine {
4062 dep_var, ind_var, ..
4063 } => scalar_plot_cartesian_line_group(
4064 group,
4065 dep_var.as_str(),
4066 ind_var.as_str(),
4067 &fit.residual_values,
4068 ),
4069 _ => scalar_curve_group(group, &fit.residual_values),
4070 };
4071 if residual_group.len() >= 2 {
4072 residual_groups.push(residual_group);
4073 }
4074 }
4075 residual_samples.extend(fit.residual_values.iter().map(|value| value.abs()));
4076 }
4077
4078 if fitted_groups.is_empty() {
4079 return Err(AnalysisError::invalid(
4080 "Curve fitting could not produce a fitted curve from the selected source.",
4081 ));
4082 }
4083
4084 let mut plots = vec![PlotSpec {
4085 name: options.output_name.clone().unwrap_or_else(|| {
4086 format!("{} {}", curve_fit_method_label(options.method), source.name)
4087 }),
4088 visible: true,
4089 domain: source.domain.clone(),
4090 resolution: source.resolution,
4091 style: PlotStyle {
4092 colour_mode: ColourMode::Solid([1.0, 0.72, 0.25, 1.0]),
4093 line_width: 2.5,
4094 ..PlotStyle::default()
4095 },
4096 definition: crate::PlotDefinition::DerivedPolylineGroups {
4097 groups: fitted_groups,
4098 },
4099 }];
4100
4101 if options.show_control_points && !control_points.is_empty() {
4102 plots.push(PlotSpec {
4103 name: format!("Control Points {}", source.name),
4104 visible: true,
4105 domain: source.domain.clone(),
4106 resolution: source.resolution,
4107 style: PlotStyle {
4108 colour_mode: ColourMode::Solid([0.35, 0.85, 1.0, 1.0]),
4109 point_size: 7.0,
4110 ..PlotStyle::default()
4111 },
4112 definition: crate::PlotDefinition::PointAnnotations {
4113 points: control_points
4114 .iter()
4115 .enumerate()
4116 .map(|(index, position)| PointAnnotation {
4117 position: *position,
4118 label: format!("Control {}", index + 1),
4119 })
4120 .collect(),
4121 show_labels: false,
4122 },
4123 });
4124 }
4125
4126 if options.show_residual_plot && !residual_groups.is_empty() {
4127 plots.push(PlotSpec {
4128 name: format!("Residuals {}", source.name),
4129 visible: true,
4130 domain: source.domain.clone(),
4131 resolution: source.resolution,
4132 style: PlotStyle {
4133 colour_mode: ColourMode::Solid([1.0, 0.35, 0.35, 1.0]),
4134 line_width: 2.0,
4135 ..PlotStyle::default()
4136 },
4137 definition: crate::PlotDefinition::DerivedPolylineGroups {
4138 groups: residual_groups,
4139 },
4140 });
4141 }
4142
4143 let rms = if residual_samples.is_empty() {
4144 0.0
4145 } else {
4146 (residual_samples
4147 .iter()
4148 .map(|value| value * value)
4149 .sum::<f32>()
4150 / residual_samples.len() as f32)
4151 .sqrt()
4152 };
4153 let max_residual = residual_samples.iter().copied().fold(0.0_f32, f32::max);
4154
4155 let mut report_values = vec![
4156 (
4157 "Method".to_string(),
4158 curve_fit_method_label(options.method).to_string(),
4159 ),
4160 ("Samples".to_string(), total_points.to_string()),
4161 ("RMS Residual".to_string(), format!("{rms:.6}")),
4162 ("Max Residual".to_string(), format!("{max_residual:.6}")),
4163 ];
4164 match options.method {
4165 CurveFitMethod::Polynomial | CurveFitMethod::RobustPolynomial => {
4166 report_values.push(("Degree".to_string(), options.degree.to_string()));
4167 }
4168 CurveFitMethod::Fourier => {
4169 report_values.push(("Harmonics".to_string(), options.harmonics.to_string()));
4170 }
4171 CurveFitMethod::Spline => {
4172 report_values.push((
4173 "Smoothing Window".to_string(),
4174 options.smoothing_window.to_string(),
4175 ));
4176 }
4177 }
4178
4179 Ok(AnalysisOutput::Composite {
4180 plots,
4181 reports: vec![AnalysisReport {
4182 title: format!("Curve Fit {}", source.name),
4183 values: report_values,
4184 }],
4185 tables: Vec::new(),
4186 diagnostics: Vec::new(),
4187 frame_fields: Vec::new(),
4188 provenance: AnalysisProvenance {
4189 kind: AnalysisKind::FitCurve,
4190 source_plots: vec![source.name.clone()],
4191 parameters: vec![
4192 ("fit_method".to_string(), curve_fit_method_key(options.method).to_string()),
4193 ("degree".to_string(), options.degree.to_string()),
4194 ("harmonics".to_string(), options.harmonics.to_string()),
4195 ("smoothing_window".to_string(), options.smoothing_window.to_string()),
4196 (
4197 "samples_per_segment".to_string(),
4198 options.samples_per_segment.to_string(),
4199 ),
4200 ],
4201 notes: vec![
4202 "Derived output includes a fitted curve plus optional control-point preview and residual plot.".to_string(),
4203 ],
4204 },
4205 })
4206}
4207
4208#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4209enum CurveFitMethod {
4210 Polynomial,
4211 RobustPolynomial,
4212 Spline,
4213 Fourier,
4214}
4215
4216#[derive(Clone, Debug)]
4217struct CurveFitOptions {
4218 method: CurveFitMethod,
4219 output_name: Option<String>,
4220 degree: usize,
4221 harmonics: usize,
4222 smoothing_window: u32,
4223 samples_per_segment: u32,
4224 show_control_points: bool,
4225 show_residual_plot: bool,
4226}
4227
4228#[derive(Clone, Debug)]
4229struct CurveFitResult {
4230 fitted_group: Vec<[f32; 3]>,
4231 residual_values: Vec<f32>,
4232}
4233
4234fn parameter_map(parameters: &[(String, String)]) -> HashMap<String, String> {
4235 parameters.iter().cloned().collect()
4236}
4237
4238fn build_curve_fit_options(params: &HashMap<String, String>) -> CurveFitOptions {
4239 CurveFitOptions {
4240 method: parse_curve_fit_method(params.get("fit_method").map(String::as_str))
4241 .unwrap_or(CurveFitMethod::Polynomial),
4242 output_name: params.get("output_name").cloned(),
4243 degree: params
4244 .get("degree")
4245 .and_then(|value| value.parse::<usize>().ok())
4246 .unwrap_or(5)
4247 .clamp(1, 12),
4248 harmonics: params
4249 .get("harmonics")
4250 .and_then(|value| value.parse::<usize>().ok())
4251 .unwrap_or(3)
4252 .clamp(1, 16),
4253 smoothing_window: normalized_window_value(
4254 params
4255 .get("smoothing_window")
4256 .and_then(|value| value.parse::<u32>().ok())
4257 .unwrap_or(7),
4258 ),
4259 samples_per_segment: params
4260 .get("samples_per_segment")
4261 .and_then(|value| value.parse::<u32>().ok())
4262 .unwrap_or(8)
4263 .clamp(1, 64),
4264 show_control_points: params
4265 .get("show_control_points")
4266 .is_none_or(|value| matches!(value.as_str(), "1" | "true" | "yes")),
4267 show_residual_plot: params
4268 .get("show_residual_plot")
4269 .is_none_or(|value| matches!(value.as_str(), "1" | "true" | "yes")),
4270 }
4271}
4272
4273fn build_interpolation(params: &HashMap<String, String>) -> CurveInterpolation {
4274 CurveInterpolation {
4275 kind: parse_interpolation_kind(params.get("interpolation_kind").map(String::as_str))
4276 .unwrap_or(CurveInterpolationKind::Linear),
4277 samples_per_segment: params
4278 .get("samples_per_segment")
4279 .and_then(|value| value.parse::<u32>().ok())
4280 .unwrap_or(1),
4281 closed: params
4282 .get("closed")
4283 .is_some_and(|value| matches!(value.as_str(), "1" | "true" | "yes")),
4284 smoothing_window: normalized_window_value(
4285 params
4286 .get("smoothing_window")
4287 .and_then(|value| value.parse::<u32>().ok())
4288 .unwrap_or(5),
4289 ),
4290 }
4291}
4292
4293fn parse_curve_fit_method(value: Option<&str>) -> Option<CurveFitMethod> {
4294 match value? {
4295 "polynomial" => Some(CurveFitMethod::Polynomial),
4296 "robust_polynomial" => Some(CurveFitMethod::RobustPolynomial),
4297 "spline" => Some(CurveFitMethod::Spline),
4298 "fourier" => Some(CurveFitMethod::Fourier),
4299 _ => None,
4300 }
4301}
4302
4303fn curve_fit_method_label(method: CurveFitMethod) -> &'static str {
4304 match method {
4305 CurveFitMethod::Polynomial => "Fit (Polynomial)",
4306 CurveFitMethod::RobustPolynomial => "Fit (Robust Polynomial)",
4307 CurveFitMethod::Spline => "Fit (Spline / Smoothed Catmull-Rom)",
4308 CurveFitMethod::Fourier => "Fit (Fourier Series)",
4309 }
4310}
4311
4312fn curve_fit_method_key(method: CurveFitMethod) -> &'static str {
4313 match method {
4314 CurveFitMethod::Polynomial => "polynomial",
4315 CurveFitMethod::RobustPolynomial => "robust_polynomial",
4316 CurveFitMethod::Spline => "spline",
4317 CurveFitMethod::Fourier => "fourier",
4318 }
4319}
4320
4321#[derive(Clone, Debug)]
4322struct PolynomialFit {
4323 coeffs: Vec<f64>,
4324 x_min: f64,
4325 x_max: f64,
4326}
4327
4328impl PolynomialFit {
4329 fn evaluate(&self, x: f64) -> f64 {
4330 evaluate_polynomial(
4331 &self.coeffs,
4332 normalize_domain_value(x, self.x_min, self.x_max),
4333 )
4334 }
4335}
4336
4337#[derive(Clone, Debug)]
4338struct PolynomialVectorFit {
4339 x: PolynomialFit,
4340 y: PolynomialFit,
4341 z: PolynomialFit,
4342}
4343
4344impl PolynomialVectorFit {
4345 fn evaluate(&self, t: f64) -> Vec3 {
4346 Vec3::new(
4347 self.x.evaluate(t) as f32,
4348 self.y.evaluate(t) as f32,
4349 self.z.evaluate(t) as f32,
4350 )
4351 }
4352}
4353
4354#[derive(Clone, Debug)]
4355struct FourierFit {
4356 constant: f64,
4357 cos_coeffs: Vec<f64>,
4358 sin_coeffs: Vec<f64>,
4359 x_min: f64,
4360 x_max: f64,
4361}
4362
4363impl FourierFit {
4364 fn evaluate(&self, x: f64) -> f64 {
4365 let theta = normalized_angle(x, self.x_min, self.x_max);
4366 let mut value = self.constant;
4367 for (index, (cos_coeff, sin_coeff)) in self
4368 .cos_coeffs
4369 .iter()
4370 .zip(self.sin_coeffs.iter())
4371 .enumerate()
4372 {
4373 let k = index as f64 + 1.0;
4374 value += *cos_coeff * (k * theta).cos() + *sin_coeff * (k * theta).sin();
4375 }
4376 value
4377 }
4378}
4379
4380#[derive(Clone, Debug)]
4381struct FourierVectorFit {
4382 x: FourierFit,
4383 y: FourierFit,
4384 z: FourierFit,
4385}
4386
4387impl FourierVectorFit {
4388 fn evaluate(&self, t: f64) -> Vec3 {
4389 Vec3::new(
4390 self.x.evaluate(t) as f32,
4391 self.y.evaluate(t) as f32,
4392 self.z.evaluate(t) as f32,
4393 )
4394 }
4395}
4396
4397fn polynomial_fit(
4398 xs: &[f64],
4399 ys: &[f64],
4400 degree: usize,
4401 weights: Option<&[f64]>,
4402) -> Result<PolynomialFit, AnalysisError> {
4403 let x_min = *xs
4404 .first()
4405 .ok_or_else(|| AnalysisError::invalid("No samples for fit."))?;
4406 let x_max = *xs
4407 .last()
4408 .ok_or_else(|| AnalysisError::invalid("No samples for fit."))?;
4409 let normalized: Vec<f64> = xs
4410 .iter()
4411 .map(|x| normalize_domain_value(*x, x_min, x_max))
4412 .collect();
4413 let coeffs = weighted_polynomial_coefficients(&normalized, ys, degree, weights)?;
4414 Ok(PolynomialFit {
4415 coeffs,
4416 x_min,
4417 x_max,
4418 })
4419}
4420
4421fn robust_polynomial_fit_scalar(
4422 xs: &[f64],
4423 ys: &[f64],
4424 degree: usize,
4425) -> Result<PolynomialFit, AnalysisError> {
4426 let mut weights = vec![1.0_f64; xs.len()];
4427 let mut fit = polynomial_fit(xs, ys, degree, Some(&weights))?;
4428 for _ in 0..5 {
4429 let residuals: Vec<f64> = xs
4430 .iter()
4431 .zip(ys.iter())
4432 .map(|(x, y)| (fit.evaluate(*x) - *y).abs())
4433 .collect();
4434 weights = huber_weights(&residuals);
4435 fit = polynomial_fit(xs, ys, degree, Some(&weights))?;
4436 }
4437 Ok(fit)
4438}
4439
4440fn polynomial_fit_vector(
4441 ts: &[f64],
4442 positions: &[Vec3],
4443 degree: usize,
4444 weights: Option<&[f64]>,
4445) -> Result<PolynomialVectorFit, AnalysisError> {
4446 let xs: Vec<f64> = positions.iter().map(|position| position.x as f64).collect();
4447 let ys: Vec<f64> = positions.iter().map(|position| position.y as f64).collect();
4448 let zs: Vec<f64> = positions.iter().map(|position| position.z as f64).collect();
4449 Ok(PolynomialVectorFit {
4450 x: polynomial_fit(ts, &xs, degree, weights)?,
4451 y: polynomial_fit(ts, &ys, degree, weights)?,
4452 z: polynomial_fit(ts, &zs, degree, weights)?,
4453 })
4454}
4455
4456fn robust_polynomial_fit_vector(
4457 ts: &[f64],
4458 positions: &[Vec3],
4459 degree: usize,
4460) -> Result<PolynomialVectorFit, AnalysisError> {
4461 let mut weights = vec![1.0_f64; ts.len()];
4462 let mut fit = polynomial_fit_vector(ts, positions, degree, Some(&weights))?;
4463 for _ in 0..5 {
4464 let residuals: Vec<f64> = ts
4465 .iter()
4466 .zip(positions.iter())
4467 .map(|(t, position)| fit.evaluate(*t).distance(*position) as f64)
4468 .collect();
4469 weights = huber_weights(&residuals);
4470 fit = polynomial_fit_vector(ts, positions, degree, Some(&weights))?;
4471 }
4472 Ok(fit)
4473}
4474
4475fn fourier_fit(xs: &[f64], ys: &[f64], harmonics: usize) -> Result<FourierFit, AnalysisError> {
4476 let x_min = *xs
4477 .first()
4478 .ok_or_else(|| AnalysisError::invalid("No samples for fit."))?;
4479 let x_max = *xs
4480 .last()
4481 .ok_or_else(|| AnalysisError::invalid("No samples for fit."))?;
4482 let design = xs
4483 .iter()
4484 .map(|x| fourier_basis(normalized_angle(*x, x_min, x_max), harmonics))
4485 .collect::<Vec<_>>();
4486 let coeffs = linear_least_squares(&design, ys, None)?;
4487 Ok(FourierFit {
4488 constant: coeffs[0],
4489 cos_coeffs: (0..harmonics).map(|index| coeffs[1 + index * 2]).collect(),
4490 sin_coeffs: (0..harmonics).map(|index| coeffs[2 + index * 2]).collect(),
4491 x_min,
4492 x_max,
4493 })
4494}
4495
4496fn fourier_fit_vector(
4497 ts: &[f64],
4498 positions: &[Vec3],
4499 harmonics: usize,
4500) -> Result<FourierVectorFit, AnalysisError> {
4501 let xs: Vec<f64> = positions.iter().map(|position| position.x as f64).collect();
4502 let ys: Vec<f64> = positions.iter().map(|position| position.y as f64).collect();
4503 let zs: Vec<f64> = positions.iter().map(|position| position.z as f64).collect();
4504 Ok(FourierVectorFit {
4505 x: fourier_fit(ts, &xs, harmonics)?,
4506 y: fourier_fit(ts, &ys, harmonics)?,
4507 z: fourier_fit(ts, &zs, harmonics)?,
4508 })
4509}
4510
4511fn weighted_polynomial_coefficients(
4512 xs: &[f64],
4513 ys: &[f64],
4514 degree: usize,
4515 weights: Option<&[f64]>,
4516) -> Result<Vec<f64>, AnalysisError> {
4517 let design = xs
4518 .iter()
4519 .map(|x| polynomial_basis(*x, degree))
4520 .collect::<Vec<_>>();
4521 linear_least_squares(&design, ys, weights)
4522}
4523
4524fn linear_least_squares(
4525 design: &[Vec<f64>],
4526 ys: &[f64],
4527 weights: Option<&[f64]>,
4528) -> Result<Vec<f64>, AnalysisError> {
4529 if design.is_empty() || ys.is_empty() || design.len() != ys.len() {
4530 return Err(AnalysisError::invalid("Invalid least-squares system."));
4531 }
4532 let cols = design[0].len();
4533 let mut ata = vec![vec![0.0_f64; cols]; cols];
4534 let mut atb = vec![0.0_f64; cols];
4535 for (row_index, row) in design.iter().enumerate() {
4536 let weight = weights
4537 .and_then(|values| values.get(row_index).copied())
4538 .unwrap_or(1.0)
4539 .max(1.0e-8);
4540 for i in 0..cols {
4541 atb[i] += weight * row[i] * ys[row_index];
4542 for j in 0..cols {
4543 ata[i][j] += weight * row[i] * row[j];
4544 }
4545 }
4546 }
4547 solve_linear_system(ata, atb)
4548}
4549
4550fn solve_linear_system(
4551 mut matrix: Vec<Vec<f64>>,
4552 mut rhs: Vec<f64>,
4553) -> Result<Vec<f64>, AnalysisError> {
4554 let size = rhs.len();
4555 for pivot in 0..size {
4556 let mut best_row = pivot;
4557 let mut best_value = matrix[pivot][pivot].abs();
4558 for row in (pivot + 1)..size {
4559 let value = matrix[row][pivot].abs();
4560 if value > best_value {
4561 best_value = value;
4562 best_row = row;
4563 }
4564 }
4565 if best_value <= 1.0e-10 {
4566 return Err(AnalysisError::invalid(
4567 "Curve fit is ill-conditioned for the selected method and parameters.",
4568 ));
4569 }
4570 if best_row != pivot {
4571 matrix.swap(pivot, best_row);
4572 rhs.swap(pivot, best_row);
4573 }
4574 let scale = matrix[pivot][pivot];
4575 for col in pivot..size {
4576 matrix[pivot][col] /= scale;
4577 }
4578 rhs[pivot] /= scale;
4579 for row in 0..size {
4580 if row == pivot {
4581 continue;
4582 }
4583 let factor = matrix[row][pivot];
4584 if factor.abs() <= 1.0e-12 {
4585 continue;
4586 }
4587 for col in pivot..size {
4588 matrix[row][col] -= factor * matrix[pivot][col];
4589 }
4590 rhs[row] -= factor * rhs[pivot];
4591 }
4592 }
4593 Ok(rhs)
4594}
4595
4596fn polynomial_basis(x: f64, degree: usize) -> Vec<f64> {
4597 let mut basis = vec![1.0_f64; degree + 1];
4598 for index in 1..=degree {
4599 basis[index] = basis[index - 1] * x;
4600 }
4601 basis
4602}
4603
4604fn fourier_basis(theta: f64, harmonics: usize) -> Vec<f64> {
4605 let mut basis = Vec::with_capacity(1 + harmonics * 2);
4606 basis.push(1.0);
4607 for harmonic in 1..=harmonics {
4608 let angle = theta * harmonic as f64;
4609 basis.push(angle.cos());
4610 basis.push(angle.sin());
4611 }
4612 basis
4613}
4614
4615fn normalize_domain_value(x: f64, x_min: f64, x_max: f64) -> f64 {
4616 let range = (x_max - x_min).abs();
4617 if range <= f64::EPSILON {
4618 0.0
4619 } else {
4620 ((x - x_min) / range) * 2.0 - 1.0
4621 }
4622}
4623
4624fn normalized_angle(x: f64, x_min: f64, x_max: f64) -> f64 {
4625 let range = (x_max - x_min).abs();
4626 if range <= f64::EPSILON {
4627 0.0
4628 } else {
4629 ((x - x_min) / range) * 2.0 * PI
4630 }
4631}
4632
4633fn evaluate_polynomial(coeffs: &[f64], x: f64) -> f64 {
4634 coeffs.iter().rev().fold(0.0, |acc, coeff| acc * x + coeff)
4635}
4636
4637fn huber_weights(residuals: &[f64]) -> Vec<f64> {
4638 let scale = residual_scale(residuals).max(1.0e-6);
4639 residuals
4640 .iter()
4641 .map(|residual| {
4642 let normalized = residual.abs() / (1.5 * scale);
4643 if normalized <= 1.0 {
4644 1.0
4645 } else {
4646 1.0 / normalized
4647 }
4648 })
4649 .collect()
4650}
4651
4652fn residual_scale(residuals: &[f64]) -> f64 {
4653 if residuals.is_empty() {
4654 return 1.0;
4655 }
4656 let mut sorted = residuals
4657 .iter()
4658 .map(|value| value.abs())
4659 .collect::<Vec<_>>();
4660 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
4661 sorted[sorted.len() / 2]
4662}
4663
4664fn smooth_scalar_values(values: &[f64], smoothing_window: u32) -> Vec<f64> {
4665 let radius = normalized_window_value(smoothing_window) as isize / 2;
4666 (0..values.len())
4667 .map(|index| {
4668 let mut total = 0.0_f64;
4669 let mut count = 0.0_f64;
4670 for offset in -radius..=radius {
4671 let sample_index =
4672 (index as isize + offset).clamp(0, values.len() as isize - 1) as usize;
4673 total += values[sample_index];
4674 count += 1.0;
4675 }
4676 total / count
4677 })
4678 .collect()
4679}
4680
4681fn smooth_vec3_values(values: &[Vec3], smoothing_window: u32) -> Vec<Vec3> {
4682 let radius = normalized_window_value(smoothing_window) as isize / 2;
4683 (0..values.len())
4684 .map(|index| {
4685 let mut total = Vec3::ZERO;
4686 let mut count = 0.0_f32;
4687 for offset in -radius..=radius {
4688 let sample_index =
4689 (index as isize + offset).clamp(0, values.len() as isize - 1) as usize;
4690 total += values[sample_index];
4691 count += 1.0;
4692 }
4693 total / count
4694 })
4695 .collect()
4696}
4697
4698fn resampled_point_count(control_point_count: usize, samples_per_segment: u32) -> usize {
4699 let segments = control_point_count.saturating_sub(1);
4700 (segments * samples_per_segment.max(1) as usize + 1).max(control_point_count)
4701}
4702
4703fn evenly_spaced_range(start: f64, end: f64, count: usize) -> Vec<f64> {
4704 if count <= 1 {
4705 return vec![start];
4706 }
4707 (0..count)
4708 .map(|index| start + (end - start) * index as f64 / (count - 1) as f64)
4709 .collect()
4710}
4711
4712fn evenly_spaced_parameter_values(count: usize) -> Vec<f64> {
4713 evenly_spaced_range(0.0, 1.0, count)
4714}
4715
4716fn parse_interpolation_kind(value: Option<&str>) -> Option<CurveInterpolationKind> {
4717 match value? {
4718 "linear" => Some(CurveInterpolationKind::Linear),
4719 "catmull_rom" => Some(CurveInterpolationKind::CatmullRom),
4720 "centripetal_catmull_rom" => Some(CurveInterpolationKind::CentripetalCatmullRom),
4721 "moving_average" => Some(CurveInterpolationKind::MovingAverage),
4722 "savitzky_golay" => Some(CurveInterpolationKind::SavitzkyGolay),
4723 _ => None,
4724 }
4725}
4726
4727fn parse_axis(value: Option<&str>) -> Option<SliceAxis> {
4728 match value?.to_ascii_lowercase().as_str() {
4729 "x" => Some(SliceAxis::X),
4730 "y" => Some(SliceAxis::Y),
4731 "z" => Some(SliceAxis::Z),
4732 _ => None,
4733 }
4734}
4735
4736fn parse_axis_index(value: Option<&str>) -> Option<usize> {
4737 match value?.to_ascii_lowercase().as_str() {
4738 "x" | "0" => Some(0),
4739 "y" | "1" => Some(1),
4740 "z" | "2" => Some(2),
4741 _ => None,
4742 }
4743}
4744
4745fn evenly_spaced_isovalues(count: usize) -> Vec<f32> {
4746 let count = count.max(1);
4747 if count == 1 {
4748 return vec![0.0];
4749 }
4750 (0..count)
4751 .map(|i| -0.9 + 1.8 * i as f32 / (count - 1) as f32)
4752 .collect()
4753}
4754
4755fn parse_error(error: impl ToString) -> AnalysisError {
4756 AnalysisError {
4757 diagnostic: Diagnostic::error(DiagnosticKind::Parse, error.to_string()),
4758 }
4759}
4760
4761fn table_errors(errors: Vec<crate::TableValidationError>) -> AnalysisError {
4762 let summary = errors
4763 .into_iter()
4764 .map(|error| error.display())
4765 .collect::<Vec<_>>()
4766 .join("; ");
4767 AnalysisError::invalid(summary)
4768}
4769
4770fn make_point_annotations(points: &[[f32; 3]], _prefix: &str) -> Vec<PointAnnotation> {
4771 points
4772 .iter()
4773 .map(|position| PointAnnotation {
4774 position: *position,
4775 label: String::new(),
4776 })
4777 .collect()
4778}
4779
4780fn axis_name(axis: usize) -> &'static str {
4781 match axis {
4782 0 => "x",
4783 1 => "y",
4784 _ => "z",
4785 }
4786}
4787
4788fn normalized_window_value(window: u32) -> u32 {
4789 let mut normalized = window.max(3);
4790 if normalized % 2 == 0 {
4791 normalized += 1;
4792 }
4793 normalized
4794}
4795
4796fn derivative_curve_group(group: &[[f32; 3]]) -> Vec<[f32; 3]> {
4797 if group.len() < 2 {
4798 return Vec::new();
4799 }
4800 if let Some(layout) = detect_planar_graph_layout(group) {
4801 return derivative_planar_graph_group(group, layout);
4802 }
4803 (0..group.len())
4804 .map(|index| finite_difference(group, index).to_array())
4805 .collect()
4806}
4807
4808fn tangent_curve_group(group: &[[f32; 3]]) -> Vec<[f32; 3]> {
4809 if group.len() < 2 {
4810 return Vec::new();
4811 }
4812 (0..group.len())
4813 .map(|index| {
4814 finite_difference(group, index)
4815 .normalize_or_zero()
4816 .to_array()
4817 })
4818 .collect()
4819}
4820
4821fn finite_difference(group: &[[f32; 3]], index: usize) -> Vec3 {
4822 let current = Vec3::from_array(group[index]);
4823 if index == 0 {
4824 let next = Vec3::from_array(group[1]);
4825 return next - current;
4826 }
4827 if index + 1 == group.len() {
4828 let prev = Vec3::from_array(group[index - 1]);
4829 return current - prev;
4830 }
4831 let prev = Vec3::from_array(group[index - 1]);
4832 let next = Vec3::from_array(group[index + 1]);
4833 (next - prev) * 0.5
4834}
4835
4836fn integral_curve_group(group: &[[f32; 3]], normalize_integral: bool) -> Vec<[f32; 3]> {
4837 if group.len() < 2 {
4838 return Vec::new();
4839 }
4840 if let Some(layout) = detect_planar_graph_layout(group) {
4841 return integral_planar_graph_group(group, layout, normalize_integral);
4842 }
4843 let mut out = Vec::with_capacity(group.len());
4844 let mut accum = Vec3::ZERO;
4845 out.push(accum.to_array());
4846 let dt = 1.0_f32 / (group.len() - 1) as f32;
4847 for pair in group.windows(2) {
4848 let a = Vec3::from_array(pair[0]);
4849 let b = Vec3::from_array(pair[1]);
4850 accum += (a + b) * 0.5 * dt;
4851 out.push(accum.to_array());
4852 }
4853 out
4854}
4855
4856fn cumulative_arc_lengths(group: &[[f32; 3]]) -> Vec<f32> {
4857 if group.is_empty() {
4858 return Vec::new();
4859 }
4860 let mut out = Vec::with_capacity(group.len());
4861 let mut total = 0.0_f32;
4862 out.push(total);
4863 for pair in group.windows(2) {
4864 let a = Vec3::from_array(pair[0]);
4865 let b = Vec3::from_array(pair[1]);
4866 total += b.distance(a);
4867 out.push(total);
4868 }
4869 out
4870}
4871
4872fn curvature_values(group: &[[f32; 3]]) -> Vec<f32> {
4873 if group.len() < 3 {
4874 return vec![0.0; group.len()];
4875 }
4876 let mut values = vec![0.0_f32; group.len()];
4877 for index in 1..(group.len() - 1) {
4878 let a = Vec3::from_array(group[index - 1]);
4879 let b = Vec3::from_array(group[index]);
4880 let c = Vec3::from_array(group[index + 1]);
4881 let ab = b - a;
4882 let bc = c - b;
4883 let ac = c - a;
4884 let denom = ab.length() * bc.length() * ac.length();
4885 if denom > 1.0e-6 {
4886 values[index] = 2.0 * ab.cross(ac).length() / denom;
4887 }
4888 }
4889 values[0] = values[1];
4890 values[group.len() - 1] = values[group.len() - 2];
4891 values
4892}
4893
4894fn scalar_curve_group(group: &[[f32; 3]], values: &[f32]) -> Vec<[f32; 3]> {
4895 if group.len() != values.len() || group.len() < 2 {
4896 return Vec::new();
4897 }
4898 let denom = (group.len() - 1) as f32;
4899 values
4900 .iter()
4901 .enumerate()
4902 .map(|(index, value)| Vec3::new(index as f32 / denom, *value, 0.0).to_array())
4903 .collect()
4904}
4905
4906fn scalar_plot_cartesian_line_group(
4907 group: &[[f32; 3]],
4908 dep_var: &str,
4909 ind_var: &str,
4910 values: &[f32],
4911) -> Vec<[f32; 3]> {
4912 if group.len() != values.len() || group.len() < 2 {
4913 return Vec::new();
4914 }
4915 group
4916 .iter()
4917 .zip(values.iter())
4918 .filter_map(|(point, value)| {
4919 let independent = cartesian_axis_value(Vec3::from_array(*point), ind_var)?;
4920 Some(cartesian_line_point(dep_var, ind_var, independent, *value).to_array())
4921 })
4922 .collect()
4923}
4924
4925fn axis_derivative_group(
4926 group: &[[f32; 3]],
4927 numerator_axis: usize,
4928 denominator_axis: usize,
4929) -> Vec<[f32; 3]> {
4930 if group.len() < 2 {
4931 return Vec::new();
4932 }
4933 (0..group.len())
4934 .filter_map(|index| {
4935 let point = Vec3::from_array(group[index]);
4936 let denominator = axis_value(point, denominator_axis);
4937 let derivative = axis_derivative_value(group, index, numerator_axis, denominator_axis)?;
4938 let constant_axis =
4939 (0..3).find(|axis| *axis != numerator_axis && *axis != denominator_axis);
4940 let constant_value = constant_axis.map(|axis| average_axis_value(group, axis));
4941 Some(
4942 point_on_axes(
4943 denominator_axis,
4944 numerator_axis,
4945 denominator,
4946 derivative,
4947 constant_axis.zip(constant_value),
4948 )
4949 .to_array(),
4950 )
4951 })
4952 .collect()
4953}
4954
4955fn derivative_cartesian_line_group(
4956 group: &[[f32; 3]],
4957 dep_var: &str,
4958 ind_var: &str,
4959) -> Vec<[f32; 3]> {
4960 if group.len() < 2 {
4961 return Vec::new();
4962 }
4963 (0..group.len())
4964 .filter_map(|index| {
4965 let current = Vec3::from_array(group[index]);
4966 let current_ind = cartesian_axis_value(current, ind_var)?;
4967 let derivative = scalar_derivative(group, index, dep_var, ind_var)?;
4968 Some(cartesian_line_point(dep_var, ind_var, current_ind, derivative).to_array())
4969 })
4970 .collect()
4971}
4972
4973fn scalar_derivative(
4974 group: &[[f32; 3]],
4975 index: usize,
4976 dep_var: &str,
4977 ind_var: &str,
4978) -> Option<f32> {
4979 if group.len() < 2 {
4980 return None;
4981 }
4982 let (a, b) = if index == 0 {
4983 (0, 1)
4984 } else if index + 1 == group.len() {
4985 (group.len() - 2, group.len() - 1)
4986 } else {
4987 (index - 1, index + 1)
4988 };
4989 let pa = Vec3::from_array(group[a]);
4990 let pb = Vec3::from_array(group[b]);
4991 let da = cartesian_axis_value(pa, dep_var)?;
4992 let db = cartesian_axis_value(pb, dep_var)?;
4993 let ia = cartesian_axis_value(pa, ind_var)?;
4994 let ib = cartesian_axis_value(pb, ind_var)?;
4995 let denom = ib - ia;
4996 if denom.abs() <= 1.0e-6 {
4997 return Some(0.0);
4998 }
4999 Some((db - da) / denom)
5000}
5001
5002fn axis_derivative_value(
5003 group: &[[f32; 3]],
5004 index: usize,
5005 numerator_axis: usize,
5006 denominator_axis: usize,
5007) -> Option<f32> {
5008 if group.len() < 2 {
5009 return None;
5010 }
5011 let (a, b) = if index == 0 {
5012 (0, 1)
5013 } else if index + 1 == group.len() {
5014 (group.len() - 2, group.len() - 1)
5015 } else {
5016 (index - 1, index + 1)
5017 };
5018 let pa = Vec3::from_array(group[a]);
5019 let pb = Vec3::from_array(group[b]);
5020 let na = axis_value(pa, numerator_axis);
5021 let nb = axis_value(pb, numerator_axis);
5022 let da = axis_value(pa, denominator_axis);
5023 let db = axis_value(pb, denominator_axis);
5024 let denom = db - da;
5025 if denom.abs() <= 1.0e-6 {
5026 return Some(0.0);
5027 }
5028 Some((nb - na) / denom)
5029}
5030
5031fn cartesian_axis_value(point: Vec3, axis: &str) -> Option<f32> {
5032 match axis {
5033 "x" => Some(point.x),
5034 "y" => Some(point.y),
5035 "z" => Some(point.z),
5036 _ => None,
5037 }
5038}
5039
5040fn axis_value(point: Vec3, axis: usize) -> f32 {
5041 match axis {
5042 0 => point.x,
5043 1 => point.y,
5044 _ => point.z,
5045 }
5046}
5047
5048fn set_axis_value(point: &mut Vec3, axis: usize, value: f32) {
5049 match axis {
5050 0 => point.x = value,
5051 1 => point.y = value,
5052 _ => point.z = value,
5053 }
5054}
5055
5056fn point_on_axes(
5057 independent_axis: usize,
5058 dependent_axis: usize,
5059 independent: f32,
5060 dependent: f32,
5061 constant_axis: Option<(usize, f32)>,
5062) -> Vec3 {
5063 let mut point = Vec3::ZERO;
5064 set_axis_value(&mut point, independent_axis, independent);
5065 set_axis_value(&mut point, dependent_axis, dependent);
5066 if let Some((axis, value)) = constant_axis {
5067 set_axis_value(&mut point, axis, value);
5068 }
5069 point
5070}
5071
5072#[derive(Clone, Copy, Debug)]
5073struct PlanarGraphLayout {
5074 independent_axis: usize,
5075 dependent_axis: usize,
5076 constant_axis: usize,
5077 constant_value: f32,
5078}
5079
5080fn detect_planar_graph_layout(group: &[[f32; 3]]) -> Option<PlanarGraphLayout> {
5081 if group.len() < 2 {
5082 return None;
5083 }
5084 let ranges = (0..3)
5085 .map(|axis| axis_range(group, axis))
5086 .collect::<Vec<_>>();
5087 let constant_axis = ranges
5088 .iter()
5089 .enumerate()
5090 .min_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
5091 .map(|(axis, _)| axis)?;
5092 if ranges[constant_axis] > 1.0e-3 {
5093 return None;
5094 }
5095 let varying_axes = (0..3)
5096 .filter(|axis| *axis != constant_axis)
5097 .collect::<Vec<_>>();
5098 let monotonic_axes = varying_axes
5099 .iter()
5100 .copied()
5101 .filter(|axis| axis_is_monotonic(group, *axis))
5102 .collect::<Vec<_>>();
5103 let independent_axis = if monotonic_axes.len() == 1 {
5104 monotonic_axes[0]
5105 } else {
5106 *varying_axes.iter().max_by(|a, b| {
5107 ranges[**a]
5108 .partial_cmp(&ranges[**b])
5109 .unwrap_or(std::cmp::Ordering::Equal)
5110 })?
5111 };
5112 let dependent_axis = varying_axes
5113 .into_iter()
5114 .find(|axis| *axis != independent_axis)?;
5115 Some(PlanarGraphLayout {
5116 independent_axis,
5117 dependent_axis,
5118 constant_axis,
5119 constant_value: average_axis_value(group, constant_axis),
5120 })
5121}
5122
5123fn derivative_planar_graph_group(group: &[[f32; 3]], layout: PlanarGraphLayout) -> Vec<[f32; 3]> {
5124 (0..group.len())
5125 .filter_map(|index| {
5126 let point = Vec3::from_array(group[index]);
5127 let independent = axis_value(point, layout.independent_axis);
5128 let derivative = axis_derivative_value(
5129 group,
5130 index,
5131 layout.dependent_axis,
5132 layout.independent_axis,
5133 )?;
5134 Some(
5135 point_on_axes(
5136 layout.independent_axis,
5137 layout.dependent_axis,
5138 independent,
5139 derivative,
5140 Some((layout.constant_axis, layout.constant_value)),
5141 )
5142 .to_array(),
5143 )
5144 })
5145 .collect()
5146}
5147
5148fn integral_planar_graph_group(
5149 group: &[[f32; 3]],
5150 layout: PlanarGraphLayout,
5151 normalize_integral: bool,
5152) -> Vec<[f32; 3]> {
5153 let mut out = Vec::with_capacity(group.len());
5154 let start_independent = axis_value(Vec3::from_array(group[0]), layout.independent_axis);
5155 let mut accum = 0.0_f32;
5156 out.push(
5157 point_on_axes(
5158 layout.independent_axis,
5159 layout.dependent_axis,
5160 start_independent,
5161 accum,
5162 Some((layout.constant_axis, layout.constant_value)),
5163 )
5164 .to_array(),
5165 );
5166 for pair in group.windows(2) {
5167 let a = Vec3::from_array(pair[0]);
5168 let b = Vec3::from_array(pair[1]);
5169 let ia = axis_value(a, layout.independent_axis);
5170 let ib = axis_value(b, layout.independent_axis);
5171 let da = axis_value(a, layout.dependent_axis);
5172 let db = axis_value(b, layout.dependent_axis);
5173 accum += (da + db) * 0.5 * (ib - ia);
5174 out.push(
5175 point_on_axes(
5176 layout.independent_axis,
5177 layout.dependent_axis,
5178 ib,
5179 accum,
5180 Some((layout.constant_axis, layout.constant_value)),
5181 )
5182 .to_array(),
5183 );
5184 }
5185 if normalize_integral {
5186 normalize_planar_graph_group(&mut out, layout.dependent_axis);
5187 }
5188 out
5189}
5190
5191fn normalize_planar_graph_group(group: &mut [[f32; 3]], dependent_axis: usize) {
5192 if group.is_empty() {
5193 return;
5194 }
5195 let mean = group
5196 .iter()
5197 .map(|point| axis_value(Vec3::from_array(*point), dependent_axis))
5198 .sum::<f32>()
5199 / group.len() as f32;
5200 for point in group {
5201 let mut vec = Vec3::from_array(*point);
5202 let value = axis_value(vec, dependent_axis);
5203 set_axis_value(&mut vec, dependent_axis, value - mean);
5204 *point = vec.to_array();
5205 }
5206}
5207
5208fn normalize_cartesian_line_group(group: &mut [[f32; 3]], dep_var: &str) {
5209 let Some(dep_axis) = parse_axis_name(dep_var) else {
5210 return;
5211 };
5212 normalize_planar_graph_group(group, dep_axis);
5213}
5214
5215fn parse_axis_name(axis: &str) -> Option<usize> {
5216 match axis {
5217 "x" => Some(0),
5218 "y" => Some(1),
5219 "z" => Some(2),
5220 _ => None,
5221 }
5222}
5223
5224fn axis_range(group: &[[f32; 3]], axis: usize) -> f32 {
5225 let mut min = f32::INFINITY;
5226 let mut max = f32::NEG_INFINITY;
5227 for point in group {
5228 let value = axis_value(Vec3::from_array(*point), axis);
5229 min = min.min(value);
5230 max = max.max(value);
5231 }
5232 max - min
5233}
5234
5235fn average_axis_value(group: &[[f32; 3]], axis: usize) -> f32 {
5236 group
5237 .iter()
5238 .map(|point| axis_value(Vec3::from_array(*point), axis))
5239 .sum::<f32>()
5240 / group.len() as f32
5241}
5242
5243fn axis_is_monotonic(group: &[[f32; 3]], axis: usize) -> bool {
5244 let epsilon = 1.0e-6_f32;
5245 let mut nondecreasing = true;
5246 let mut nonincreasing = true;
5247 for pair in group.windows(2) {
5248 let a = axis_value(Vec3::from_array(pair[0]), axis);
5249 let b = axis_value(Vec3::from_array(pair[1]), axis);
5250 if b + epsilon < a {
5251 nondecreasing = false;
5252 }
5253 if b > a + epsilon {
5254 nonincreasing = false;
5255 }
5256 }
5257 nondecreasing || nonincreasing
5258}
5259
5260fn cartesian_line_point(dep_var: &str, ind_var: &str, independent: f32, dependent: f32) -> Vec3 {
5261 match (dep_var, ind_var) {
5262 ("y", "x") => Vec3::new(independent, dependent, 0.0),
5263 ("z", "x") => Vec3::new(independent, 0.0, dependent),
5264 ("z", "y") => Vec3::new(0.0, independent, dependent),
5265 ("x", "y") => Vec3::new(dependent, independent, 0.0),
5266 ("x", "z") => Vec3::new(dependent, 0.0, independent),
5267 ("y", "z") => Vec3::new(0.0, dependent, independent),
5268 _ => Vec3::new(independent, dependent, 0.0),
5269 }
5270}
5271
5272fn integral_cartesian_line_group(
5273 group: &[[f32; 3]],
5274 dep_var: &str,
5275 ind_var: &str,
5276 normalize_integral: bool,
5277) -> Vec<[f32; 3]> {
5278 if group.len() < 2 {
5279 return Vec::new();
5280 }
5281 let mut out = Vec::with_capacity(group.len());
5282 let start_independent =
5283 cartesian_axis_value(Vec3::from_array(group[0]), ind_var).unwrap_or(0.0);
5284 let mut accum = 0.0_f32;
5285 out.push(cartesian_line_point(dep_var, ind_var, start_independent, accum).to_array());
5286 for pair in group.windows(2) {
5287 let a = Vec3::from_array(pair[0]);
5288 let b = Vec3::from_array(pair[1]);
5289 let ia = match cartesian_axis_value(a, ind_var) {
5290 Some(value) => value,
5291 None => continue,
5292 };
5293 let ib = match cartesian_axis_value(b, ind_var) {
5294 Some(value) => value,
5295 None => continue,
5296 };
5297 let da = match cartesian_axis_value(a, dep_var) {
5298 Some(value) => value,
5299 None => continue,
5300 };
5301 let db = match cartesian_axis_value(b, dep_var) {
5302 Some(value) => value,
5303 None => continue,
5304 };
5305 accum += (da + db) * 0.5 * (ib - ia);
5306 out.push(cartesian_line_point(dep_var, ind_var, ib, accum).to_array());
5307 }
5308 if normalize_integral {
5309 normalize_cartesian_line_group(&mut out, dep_var);
5310 }
5311 out
5312}
5313
5314fn normal_curve_group(group: &[[f32; 3]]) -> Vec<[f32; 3]> {
5315 if group.len() < 3 {
5316 return Vec::new();
5317 }
5318 let tangents = tangent_vectors(group);
5319 tangents
5320 .iter()
5321 .enumerate()
5322 .map(|(index, _)| {
5323 let dt = if index == 0 {
5324 tangents[1] - tangents[0]
5325 } else if index + 1 == tangents.len() {
5326 tangents[index] - tangents[index - 1]
5327 } else {
5328 (tangents[index + 1] - tangents[index - 1]) * 0.5
5329 };
5330 dt.normalize_or_zero().to_array()
5331 })
5332 .collect()
5333}
5334
5335fn binormal_curve_group(group: &[[f32; 3]]) -> Vec<[f32; 3]> {
5336 if group.len() < 3 {
5337 return Vec::new();
5338 }
5339 let tangents = tangent_vectors(group);
5340 let normals = normal_curve_group(group);
5341 tangents
5342 .iter()
5343 .zip(normals.iter())
5344 .map(|(tangent, normal)| {
5345 tangent
5346 .cross(Vec3::from_array(*normal))
5347 .normalize_or_zero()
5348 .to_array()
5349 })
5350 .collect()
5351}
5352
5353fn tangent_vectors(group: &[[f32; 3]]) -> Vec<Vec3> {
5354 if group.len() < 2 {
5355 return Vec::new();
5356 }
5357 (0..group.len())
5358 .map(|index| finite_difference(group, index).normalize_or_zero())
5359 .collect()
5360}
5361
5362#[cfg(test)]
5363mod tests {
5364 use super::*;
5365 use crate::{Domain, PlotDefinition, PlotSpec, PlotStyle, Resolution};
5366 use viewport_lib::MeshData;
5367
5368 fn approx_eq(a: f32, b: f32) -> bool {
5369 (a - b).abs() <= 1.0e-3
5370 }
5371
5372 fn point_annotations_plot(points: &[[f32; 3]]) -> PlotSpec {
5373 PlotSpec {
5374 name: "Points".to_string(),
5375 visible: true,
5376 domain: Domain::default(),
5377 resolution: Resolution::default(),
5378 style: PlotStyle::default(),
5379 definition: PlotDefinition::PointAnnotations {
5380 points: points
5381 .iter()
5382 .enumerate()
5383 .map(|(index, position)| PointAnnotation {
5384 position: *position,
5385 label: format!("P{}", index + 1),
5386 })
5387 .collect(),
5388 show_labels: false,
5389 },
5390 }
5391 }
5392
5393 fn square_surface_plot() -> PlotSpec {
5394 PlotSpec {
5395 name: "Surface".to_string(),
5396 visible: true,
5397 domain: Domain::default(),
5398 resolution: Resolution::default(),
5399 style: PlotStyle::default(),
5400 definition: PlotDefinition::GridSurface,
5401 }
5402 }
5403
5404 fn square_surface_mesh() -> MeshData {
5405 let mut mesh = MeshData::default();
5406 mesh.positions = vec![
5407 [0.0, 0.0, 0.0],
5408 [1.0, 0.0, 0.0],
5409 [1.0, 1.0, 0.0],
5410 [0.0, 1.0, 0.0],
5411 ];
5412 mesh.indices = vec![0, 1, 2, 0, 2, 3];
5413 mesh
5414 }
5415
5416 #[test]
5417 fn derivative_of_planar_xz_curve_stays_in_xz_plane() {
5418 let group = vec![[0.0, 4.0, 0.0], [1.0, 4.0, 1.0], [2.0, 4.0, 0.0]];
5419
5420 let derived = derivative_curve_group(&group);
5421
5422 assert_eq!(derived.len(), 3);
5423 assert!(approx_eq(derived[1][0], 1.0));
5424 assert!(approx_eq(derived[1][1], 4.0));
5425 assert!(approx_eq(derived[1][2], 0.0));
5426 }
5427
5428 #[test]
5429 fn axis_derivative_preserves_constant_plane_axis() {
5430 let group = vec![[0.0, 4.0, 0.0], [1.0, 4.0, 1.0], [2.0, 4.0, 0.0]];
5431
5432 let derived = axis_derivative_group(&group, 2, 0);
5433
5434 assert_eq!(derived.len(), 3);
5435 assert!(approx_eq(derived[1][0], 1.0));
5436 assert!(approx_eq(derived[1][1], 4.0));
5437 assert!(approx_eq(derived[1][2], 0.0));
5438 }
5439
5440 #[test]
5441 fn integral_of_planar_xz_curve_stays_in_xz_plane() {
5442 let group = vec![[0.0, 4.0, 0.0], [1.0, 4.0, 1.0], [2.0, 4.0, 0.0]];
5443
5444 let derived = integral_curve_group(&group, false);
5445
5446 assert_eq!(derived.len(), 3);
5447 assert!(approx_eq(derived[0][1], 4.0));
5448 assert!(approx_eq(derived[1][1], 4.0));
5449 assert!(approx_eq(derived[2][1], 4.0));
5450 assert!(approx_eq(derived[2][2], 1.0));
5451 }
5452
5453 #[test]
5454 fn repeated_integral_of_sine_stays_centered_when_normalized() {
5455 let group = (-80..=80)
5456 .map(|i| {
5457 let x = i as f32 * 0.1;
5458 [x, 4.0, x.sin()]
5459 })
5460 .collect::<Vec<_>>();
5461
5462 let first = integral_curve_group(&group, true);
5463 let second = integral_curve_group(&first, true);
5464
5465 assert_eq!(second.len(), group.len());
5466 let mean = second.iter().map(|point| point[2]).sum::<f32>() / second.len() as f32;
5467 let left = second.first().map(|point| point[2]).unwrap_or_default();
5468 let right = second.last().map(|point| point[2]).unwrap_or_default();
5469
5470 assert!(mean.abs() < 1.0e-3);
5471 assert!((left - right).abs() < 0.2);
5472 }
5473
5474 #[test]
5475 fn phase_three_quality_output_titles_and_links_flagged_samples() {
5476 let plot = point_annotations_plot(&[
5477 [0.0, 0.0, 0.0],
5478 [0.0, 0.0, 0.0],
5479 [0.0001, 0.0, 0.0],
5480 [2.0, 0.0, 0.0],
5481 [20.0, 0.0, 0.0],
5482 ]);
5483
5484 let output = make_data_quality_output(&plot).expect("quality output");
5485 let AnalysisOutput::Composite {
5486 reports: _,
5487 tables,
5488 diagnostics,
5489 ..
5490 } = output
5491 else {
5492 panic!("expected composite output");
5493 };
5494
5495 let table_titles = tables
5496 .iter()
5497 .map(|table| table.title.as_str())
5498 .collect::<Vec<_>>();
5499 assert!(table_titles.contains(&"Sample Positions"));
5500 assert!(table_titles.contains(&"Exact Duplicates"));
5501 assert!(table_titles.contains(&"Near Duplicates"));
5502
5503 let linked_messages = diagnostics
5504 .iter()
5505 .filter_map(|diagnostic| {
5506 diagnostic.location.as_ref().map(|location| {
5507 (
5508 diagnostic.message.as_str(),
5509 location.component.as_deref(),
5510 location.row,
5511 )
5512 })
5513 })
5514 .collect::<Vec<_>>();
5515
5516 assert!(linked_messages.iter().any(|(message, component, row)| {
5517 message.contains("Exact duplicate")
5518 && *component == Some("sequence 1 sample")
5519 && *row == Some(2)
5520 }));
5521 assert!(linked_messages.iter().any(|(message, component, row)| {
5522 message.contains("Near-duplicate")
5523 && *component == Some("sequence 1 sample")
5524 && *row == Some(3)
5525 }));
5526 }
5527
5528 #[test]
5529 fn surface_area_and_curvature_on_plane_are_stable() {
5530 let plot = square_surface_plot();
5531 let mesh = square_surface_mesh();
5532
5533 let area = run_surface_mesh_analysis(&plot, AnalysisKind::SurfaceArea, &[&mesh], &[])
5534 .expect("surface area");
5535 let AnalysisOutput::Report { report, .. } = area else {
5536 panic!("expected report output");
5537 };
5538 assert!(
5539 report
5540 .values
5541 .iter()
5542 .any(|(label, value)| { label == "Surface Area" && value == "1.00000" })
5543 );
5544
5545 let curvature =
5546 run_surface_mesh_analysis(&plot, AnalysisKind::SurfaceCurvature, &[&mesh], &[])
5547 .expect("surface curvature");
5548 let AnalysisOutput::Composite { tables, .. } = curvature else {
5549 panic!("expected composite output");
5550 };
5551 let sample_table = tables
5552 .iter()
5553 .find(|table| table.title == "Vertex Curvature Samples")
5554 .expect("curvature sample table");
5555 assert!(
5556 sample_table
5557 .rows
5558 .iter()
5559 .all(|row| { row[4].parse::<f32>().unwrap_or(1.0).abs() < 1.0e-3 })
5560 );
5561 }
5562
5563 #[test]
5564 fn frenet_frame_output_emits_tables_and_axis_plots() {
5565 let plot = PlotSpec {
5566 name: "Curve".to_string(),
5567 visible: true,
5568 domain: Domain::default(),
5569 resolution: Resolution { u: 32, v: 1 },
5570 style: PlotStyle::default(),
5571 definition: PlotDefinition::HelixCurve,
5572 };
5573
5574 let request = AnalysisRequest {
5575 kind: AnalysisKind::FrenetFrame,
5576 target: AnalysisTarget::Plot {
5577 index: 0,
5578 name: Some(plot.name.clone()),
5579 },
5580 parameters: vec![
5581 ("max_samples".to_string(), "24".to_string()),
5582 ("vector_scale".to_string(), "0.5".to_string()),
5583 ],
5584 };
5585 let output = run_analysis(&plot, &request).expect("frenet frame output");
5586 let AnalysisOutput::Composite {
5587 plots,
5588 tables,
5589 frame_fields,
5590 ..
5591 } = output
5592 else {
5593 panic!("expected composite output");
5594 };
5595
5596 assert!(plots.is_empty());
5597 assert_eq!(frame_fields.len(), 1);
5598 assert_eq!(frame_fields[0].frame_kind, AnalysisKind::FrenetFrame);
5599 assert!(tables.iter().any(|table| table.title.contains("Samples")));
5600 }
5601
5602 #[test]
5603 fn bishop_frames_stay_orthonormal_on_planar_curve() {
5604 let points = (0..8)
5605 .map(|index| {
5606 let t = index as f32 * 0.3;
5607 [t.cos(), t.sin(), 0.0]
5608 })
5609 .collect::<Vec<_>>();
5610
5611 let field = build_curve_frame_field(
5612 &points,
5613 &point_annotations_plot(&points),
5614 AnalysisKind::BishopFrame,
5615 0,
5616 1,
5617 )
5618 .expect("bishop frame field");
5619
5620 for sample in field.samples {
5621 let t = Vec3::from_array(sample.tangent);
5622 let n = Vec3::from_array(sample.normal);
5623 let b = Vec3::from_array(sample.binormal);
5624 assert!(approx_eq(t.length(), 1.0));
5625 assert!(approx_eq(n.length(), 1.0));
5626 assert!(approx_eq(b.length(), 1.0));
5627 assert!(t.dot(n).abs() < 1.0e-3);
5628 assert!(t.dot(b).abs() < 1.0e-3);
5629 assert!(n.dot(b).abs() < 1.0e-3);
5630 }
5631 }
5632
5633 #[test]
5634 fn surface_aligned_frame_uses_surface_context() {
5635 let curve = PlotSpec {
5636 name: "Curve".to_string(),
5637 visible: true,
5638 domain: Domain::default(),
5639 resolution: Resolution::default(),
5640 style: PlotStyle::default(),
5641 definition: PlotDefinition::DerivedPolylineGroups {
5642 groups: vec![vec![[0.0, 0.0, 0.0], [0.5, 0.0, 0.0], [1.0, 0.0, 0.0]]],
5643 },
5644 };
5645 let surface = square_surface_mesh();
5646 let output = run_curve_surface_frame_analysis(
5647 &curve,
5648 "Surface",
5649 AnalysisKind::SurfaceAlignedFrame,
5650 &[&surface],
5651 &[("max_samples".to_string(), "8".to_string())],
5652 )
5653 .expect("surface aligned frame");
5654 let AnalysisOutput::Composite { frame_fields, .. } = output else {
5655 panic!("expected composite output");
5656 };
5657 let field = frame_fields.first().expect("frame field");
5658 assert_eq!(field.frame_kind, AnalysisKind::SurfaceAlignedFrame);
5659 let first = field.samples.first().expect("sample");
5660 assert!(first.normal[2].abs() > 0.9);
5661 }
5662}