Skip to main content

poincare_lib/
analysis.rs

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/// The type of analysis to perform on a plot.
15#[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/// The form of data returned by an analysis.
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub enum AnalysisOutputKind {
50    /// Produces new derived plots (e.g. a normal field or fitted curve).
51    PlotSpec,
52    /// Produces a flat key/value numeric summary.
53    NumericReport,
54    /// Produces a labelled data table.
55    Table,
56    /// Produces a mix of plots, reports, and tables.
57    Composite,
58}
59
60/// What kind of data an analysis operates on.
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub enum AnalysisTargetKind {
63    /// Works from the symbolic plot definition (e.g. expression parsing).
64    Definition,
65    /// Works from sampled point data (e.g. CSV imports, point clouds).
66    SampledData,
67    /// Works from tessellated mesh geometry.
68    Geometry,
69    /// Requires two plots as input (e.g. intersection analysis).
70    PlotPair,
71}
72
73/// Describes one analysis that is available for a given plot, as returned by [`available_analyses`].
74#[derive(Clone, Debug, PartialEq)]
75pub struct AnalysisCapability {
76    pub kind: AnalysisKind,
77    pub target_kind: AnalysisTargetKind,
78    pub output_kind: AnalysisOutputKind,
79    /// Named parameters this analysis accepts (passed via [`AnalysisRequest::parameters`]).
80    pub parameters: Vec<&'static str>,
81}
82
83/// Specifies which plot(s) an analysis should run on.
84#[derive(Clone, Debug, PartialEq)]
85pub enum AnalysisTarget {
86    Plot { index: usize, name: Option<String> },
87    PlotPair { first: usize, second: usize },
88}
89
90/// Input to [`run_analysis`].
91#[derive(Clone, Debug, PartialEq)]
92pub struct AnalysisRequest {
93    pub kind: AnalysisKind,
94    pub target: AnalysisTarget,
95    /// Key/value string parameters; valid keys depend on the analysis kind.
96    pub parameters: Vec<(String, String)>,
97}
98
99/// Metadata attached to every [`AnalysisOutput`] describing what produced it.
100#[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/// A flat key/value numeric summary returned by some analyses.
109#[derive(Clone, Debug, PartialEq)]
110pub struct AnalysisReport {
111    pub title: String,
112    pub values: Vec<(String, String)>,
113}
114
115/// A labelled data table returned by some analyses.
116#[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/// One sample from a moving frame (Frenet, Bishop, or Darboux) along a curve.
124#[derive(Clone, Debug, PartialEq)]
125pub struct FrameSample {
126    /// Curve parameter value at this sample.
127    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/// A sampled moving frame field along a curve, used to drive animated frame overlays.
135#[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/// Result returned by [`run_analysis`].
144#[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/// Controls what kind of point groups [`sample_groups`] extracts from a plot.
169#[derive(Clone, Copy, Debug, PartialEq, Eq)]
170pub enum SampleGroupsKind {
171    /// Evaluate the plot as a parametric curve.
172    Curve,
173    /// Return the raw polyline segments from the plot.
174    Polyline,
175    /// Return the source control points for interpolation.
176    InterpolationSource,
177    /// Return imported sample data as-is.
178    SampleData,
179}
180
181/// Error returned by analysis functions when an operation is unsupported for the
182/// given plot type, or when input data fails validation.
183#[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
210/// Returns the analyses available for a given plot based on its definition type and metadata.
211pub 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
233/// Extract sampled point groups from a plot for use in curve analysis.
234pub 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
246/// Run an analysis on a plot and return the result.
247///
248/// Call [`available_analyses`] first to check which analyses are valid for the plot.
249pub 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(&params));
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, &params),
306        AnalysisKind::BishopFrame => return make_curve_frame_output(plot, request.kind, &params),
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(&params),
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, &params),
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}