use crate::numerical::{scalar, step_validation as numerical};
use crate::{
brep::{analytic_truth_from_face, mesh_from_kernel},
reconstruct_analyzed, AnalyticSurface, AnalyzedMesh, ConstraintMask, MetadataTrust,
RecognitionOptions, SamplingMode, SurfaceConstraints, SurfaceFitResult, SurfaceHint, Vec3,
};
use serde::{Deserialize, Serialize};
use std::{fs, path::Path};
use web_time::Instant;
mod torus_fallback;
use torus_fallback::ObservationFallback;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StepValidationProgress {
pub phase: StepValidationPhase,
pub path: String,
pub solid_index: Option<usize>,
pub shell_index: Option<usize>,
pub face_index: Option<usize>,
pub face_id: Option<u64>,
pub mode: Option<ValidationMode>,
pub truth: Option<AnalyticSurface>,
pub triangles: Option<usize>,
pub vertices: Option<usize>,
pub passed: Option<bool>,
pub detail: Option<String>,
pub mode_report: Option<ModeValidationReport>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum StepValidationPhase {
ReadStarted,
ReadCompleted,
ImportStarted,
ImportCompleted,
FaceStarted,
TessellationStarted,
TessellationCompleted,
TessellationFailed,
ModeStarted,
ModeCompleted,
FaceCompleted,
FileCompleted,
Failed,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StepValidationOptions {
pub tessellation_slabs_u: usize,
pub tessellation_steps_v: usize,
pub distance_tolerance: f64,
pub relative_face_tolerance: f64,
pub normal_tolerance_degrees: f64,
pub max_hypotheses: usize,
pub max_refinement_iterations: usize,
#[serde(default = "all_validation_modes")]
pub enabled_modes: Vec<ValidationMode>,
}
impl Default for StepValidationOptions {
fn default() -> Self {
Self {
tessellation_slabs_u: 12,
tessellation_steps_v: 12,
distance_tolerance: 1.0e-7,
relative_face_tolerance: 1.0e-6,
normal_tolerance_degrees: 12.0,
max_hypotheses: 512,
max_refinement_iterations: 160,
enabled_modes: all_validation_modes(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StepFileReport {
pub path: String,
pub import_error: Option<String>,
pub failed_imported_solids: usize,
pub partial_import_error: Option<String>,
pub solids: usize,
pub faces_total: usize,
pub analytic_faces: usize,
#[serde(default)]
pub selected_analytic_faces: usize,
#[serde(default)]
pub validated_faces: usize,
pub unsupported_analytic_faces: usize,
#[serde(default)]
pub truth_extraction_failures: usize,
pub tessellation_failures: usize,
#[serde(default)]
pub tessellation_fallbacks: usize,
#[serde(default)]
pub tessellation_fallback_evidence: Vec<TessellationFallbackEvidence>,
#[serde(default)]
pub face_diagnostics: Vec<StepFaceDiagnostic>,
pub cases: Vec<FaceValidationReport>,
}
impl StepFileReport {
pub fn coverage_invariants_hold(&self) -> bool {
let unsupported_diagnostics = self
.face_diagnostics
.iter()
.filter(|diagnostic| {
matches!(
diagnostic.kind,
StepFaceDiagnosticKind::UnsupportedOrNonAnalytic
| StepFaceDiagnosticKind::TruthExtractionFailed
)
})
.count();
let extraction_failure_diagnostics = self
.face_diagnostics
.iter()
.filter(|diagnostic| {
matches!(
diagnostic.kind,
StepFaceDiagnosticKind::TruthExtractionFailed
)
})
.count();
self.faces_total == self.analytic_faces + self.unsupported_analytic_faces
&& self.validated_faces == self.cases.len()
&& self.selected_analytic_faces == self.validated_faces + self.tessellation_failures
&& unsupported_diagnostics == self.unsupported_analytic_faces
&& extraction_failure_diagnostics == self.truth_extraction_failures
&& self
.face_diagnostics
.iter()
.filter(|diagnostic| {
matches!(diagnostic.kind, StepFaceDiagnosticKind::TessellationFailed)
})
.count()
== self.tessellation_failures
&& self.tessellation_fallbacks == self.tessellation_fallback_evidence.len()
&& self
.tessellation_fallback_evidence
.iter()
.enumerate()
.all(|(index, evidence)| {
let method_matches_truth = match evidence.method {
TessellationFallbackMethod::WatertightFaceStride => {
matches!(evidence.truth, AnalyticSurface::Torus(_))
}
TessellationFallbackMethod::ProjectedTrimCurveTriangle => {
matches!(evidence.truth, AnalyticSurface::Plane(_))
&& evidence.triangles == 1
&& evidence.vertices == 3
}
};
method_matches_truth
&& evidence.chord_tolerance.is_finite()
&& evidence.chord_tolerance > 0.0
&& evidence.triangles > 0
&& evidence.vertices > 0
&& evidence.surface_projected_vertices == evidence.vertices
&& evidence.max_surface_projection_distance.is_finite()
&& evidence.max_surface_projection_distance >= 0.0
&& evidence.surface_projection_tolerance.is_finite()
&& evidence.surface_projection_tolerance > 0.0
&& evidence.max_surface_projection_distance
<= evidence.surface_projection_tolerance
&& self.tessellation_fallback_evidence[index + 1..]
.iter()
.all(|other| {
(
other.solid_index,
other.shell_index,
other.face_index,
other.face_id,
) != (
evidence.solid_index,
evidence.shell_index,
evidence.face_index,
evidence.face_id,
)
})
&& self.cases.iter().any(|case| {
case.solid_index == evidence.solid_index
&& case.shell_index == evidence.shell_index
&& case.face_index == evidence.face_index
&& case.face_id == evidence.face_id
})
})
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum TessellationFallbackMethod {
WatertightFaceStride,
ProjectedTrimCurveTriangle,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TessellationFallbackEvidence {
pub solid_index: usize,
pub shell_index: usize,
pub face_index: usize,
pub face_id: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub face_name: Option<String>,
pub truth: AnalyticSurface,
pub primary_failure: String,
pub method: TessellationFallbackMethod,
pub chord_tolerance: f64,
pub triangles: usize,
pub vertices: usize,
pub surface_projected_vertices: usize,
pub max_surface_projection_distance: f64,
pub surface_projection_tolerance: f64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum StepFaceDiagnosticKind {
UnsupportedOrNonAnalytic,
TruthExtractionFailed,
TessellationFailed,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StepFaceDiagnostic {
pub solid_index: usize,
pub shell_index: usize,
pub face_index: usize,
pub face_id: u64,
pub face_name: Option<String>,
pub kind: StepFaceDiagnosticKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub truth: Option<AnalyticSurface>,
pub detail: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FaceValidationReport {
pub solid_index: usize,
pub shell_index: usize,
pub face_index: usize,
pub face_id: u64,
pub face_name: Option<String>,
pub same_sense: bool,
pub truth: AnalyticSurface,
pub triangles: usize,
pub vertices: usize,
pub face_scale: f64,
pub distance_tolerance: f64,
pub tessellation_millis: f64,
pub modes: Vec<ModeValidationReport>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ModeValidationReport {
pub mode: ValidationMode,
pub elapsed_millis: f64,
pub error: Option<String>,
pub result: Option<SurfaceFitResult>,
#[serde(default)]
pub passed: bool,
#[serde(default)]
pub unobservable: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ambiguity_evidence: Option<CarrierAmbiguityEvidence>,
pub type_matches: bool,
#[serde(default)]
pub parameter_equivalent: bool,
#[serde(default)]
pub orientation_matches: bool,
#[serde(default)]
pub exact_parameters_reused: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fixed_parameters_preserved: Option<bool>,
pub parameter_error: Option<ParameterError>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CarrierAmbiguityEvidence {
pub alternative_surface: AnalyticSurface,
pub max_truth_error: f64,
pub max_alternative_error: f64,
pub max_oriented_normal_disagreement: f64,
pub position_threshold: f64,
pub normal_threshold: f64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ValidationMode {
Unknown,
KnownType,
InitialGuess,
Constrained,
ExactCandidate,
}
impl ValidationMode {
pub const ALL: [Self; 5] = [
Self::Unknown,
Self::KnownType,
Self::InitialGuess,
Self::Constrained,
Self::ExactCandidate,
];
pub fn cli_name(self) -> &'static str {
match self {
Self::Unknown => "unknown",
Self::KnownType => "known-type",
Self::InitialGuess => "initial-guess",
Self::Constrained => "constrained",
Self::ExactCandidate => "exact-candidate",
}
}
pub fn from_cli_name(value: &str) -> Option<Self> {
Self::ALL
.into_iter()
.find(|mode| mode.cli_name() == value.trim().to_ascii_lowercase())
}
}
fn all_validation_modes() -> Vec<ValidationMode> {
ValidationMode::ALL.to_vec()
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
pub struct ParameterError {
pub position: f64,
pub direction_degrees: f64,
pub radius: f64,
pub major_radius: f64,
pub angle_degrees: f64,
}
pub fn validate_step_file(path: &Path, validation: &StepValidationOptions) -> StepFileReport {
validate_step_file_faces(path, validation, &[])
}
pub fn validate_step_file_faces(
path: &Path,
validation: &StepValidationOptions,
face_ids: &[u64],
) -> StepFileReport {
validate_step_file_faces_with_progress(path, validation, face_ids, &mut |_| {})
}
#[allow(clippy::too_many_arguments)]
fn emit_progress(
sink: &mut dyn FnMut(&StepValidationProgress),
path: &str,
phase: StepValidationPhase,
location: Option<(usize, usize, usize, u64)>,
mode: Option<ValidationMode>,
truth: Option<AnalyticSurface>,
mesh_size: Option<(usize, usize)>,
passed: Option<bool>,
detail: Option<String>,
mode_report: Option<ModeValidationReport>,
) {
let (solid_index, shell_index, face_index, face_id) = location
.map(|value| (Some(value.0), Some(value.1), Some(value.2), Some(value.3)))
.unwrap_or((None, None, None, None));
sink(&StepValidationProgress {
phase,
path: path.to_owned(),
solid_index,
shell_index,
face_index,
face_id,
mode,
truth,
triangles: mesh_size.map(|value| value.0),
vertices: mesh_size.map(|value| value.1),
passed,
detail,
mode_report,
});
}
fn fallback_failure_detail(
evidence: Option<&TessellationFallbackEvidence>,
failure: &str,
) -> String {
evidence.map_or_else(
|| failure.to_owned(),
|evidence| {
format!(
"{}; {:?} fallback {failure}",
evidence.primary_failure, evidence.method
)
},
)
}
#[allow(clippy::too_many_arguments)]
fn record_tessellation_failure(
report: &mut StepFileReport,
progress: &mut dyn FnMut(&StepValidationProgress),
path: &str,
location: Option<(usize, usize, usize, u64)>,
solid_index: usize,
shell_index: usize,
face_index: usize,
face: &brep_kernel::FaceRecord,
truth: AnalyticSurface,
mesh_size: Option<(usize, usize)>,
detail: String,
) {
report.tessellation_failures += 1;
report.face_diagnostics.push(StepFaceDiagnostic {
solid_index,
shell_index,
face_index,
face_id: face.id,
face_name: face.name.clone(),
kind: StepFaceDiagnosticKind::TessellationFailed,
truth: Some(truth),
detail: detail.clone(),
});
emit_progress(
progress,
path,
StepValidationPhase::TessellationFailed,
location,
None,
Some(truth),
mesh_size,
Some(false),
Some(detail),
None,
);
}
pub fn validate_step_file_faces_with_progress(
path: &Path,
validation: &StepValidationOptions,
face_ids: &[u64],
progress: &mut dyn FnMut(&StepValidationProgress),
) -> StepFileReport {
let path_string = path.display().to_string();
let mut report = StepFileReport {
path: path_string.clone(),
import_error: None,
failed_imported_solids: 0,
partial_import_error: None,
solids: 0,
faces_total: 0,
analytic_faces: 0,
selected_analytic_faces: 0,
validated_faces: 0,
unsupported_analytic_faces: 0,
truth_extraction_failures: 0,
tessellation_failures: 0,
tessellation_fallbacks: 0,
tessellation_fallback_evidence: Vec::new(),
face_diagnostics: Vec::new(),
cases: Vec::new(),
};
emit_progress(
progress,
&path_string,
StepValidationPhase::ReadStarted,
None,
None,
None,
None,
None,
None,
None,
);
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(error) => {
report.import_error = Some(format!("read failed: {error}"));
emit_progress(
progress,
&path_string,
StepValidationPhase::Failed,
None,
None,
None,
None,
Some(false),
report.import_error.clone(),
None,
);
return report;
}
};
emit_progress(
progress,
&path_string,
StepValidationPhase::ReadCompleted,
None,
None,
None,
None,
Some(true),
Some(format!("{} bytes", text.len())),
None,
);
emit_progress(
progress,
&path_string,
StepValidationPhase::ImportStarted,
None,
None,
None,
None,
None,
None,
None,
);
let (solids, failed, partial) = match brep_kernel::import_step_report(&text) {
Ok(value) => value,
Err(error) => {
report.import_error = Some(error);
emit_progress(
progress,
&path_string,
StepValidationPhase::Failed,
None,
None,
None,
None,
Some(false),
report.import_error.clone(),
None,
);
return report;
}
};
report.failed_imported_solids = failed;
report.partial_import_error = partial;
report.solids = solids.len();
emit_progress(
progress,
&path_string,
StepValidationPhase::ImportCompleted,
None,
None,
None,
None,
Some(true),
Some(format!("solids={}, failed_solids={failed}", solids.len())),
None,
);
for (solid_index, solid) in solids.iter().enumerate() {
let mut global_face_index = 0usize;
let mut observation_fallback = ObservationFallback::new(
solid,
validation.distance_tolerance,
validation.relative_face_tolerance,
);
for (shell_index, shell) in solid.shells.iter().enumerate() {
for (face_index, face) in shell.faces.iter().enumerate() {
let source_face_index = global_face_index;
global_face_index += 1;
report.faces_total += 1;
let kernel_truth = match analytic_truth_from_face(face, face_index as u32) {
Ok(Some(truth)) => truth,
Ok(None) => {
report.unsupported_analytic_faces += 1;
report.face_diagnostics.push(StepFaceDiagnostic {
solid_index,
shell_index,
face_index,
face_id: face.id,
face_name: face.name.clone(),
kind: StepFaceDiagnosticKind::UnsupportedOrNonAnalytic,
truth: None,
detail: "face has no supported analytic carrier".into(),
});
continue;
}
Err(error) => {
report.unsupported_analytic_faces += 1;
report.truth_extraction_failures += 1;
report.face_diagnostics.push(StepFaceDiagnostic {
solid_index,
shell_index,
face_index,
face_id: face.id,
face_name: face.name.clone(),
kind: StepFaceDiagnosticKind::TruthExtractionFailed,
truth: None,
detail: error.to_string(),
});
continue;
}
};
let truth = kernel_truth.surface;
report.analytic_faces += 1;
if !face_ids.is_empty() && !face_ids.contains(&face.id) {
continue;
}
report.selected_analytic_faces += 1;
let location = Some((solid_index, shell_index, face_index, face.id));
emit_progress(
progress,
&path_string,
StepValidationPhase::FaceStarted,
location,
None,
Some(truth),
None,
None,
face.name.clone(),
None,
);
let started = Instant::now();
emit_progress(
progress,
&path_string,
StepValidationPhase::TessellationStarted,
location,
None,
Some(truth),
None,
None,
None,
None,
);
let mut fallback_evidence = None;
let kernel_mesh = match brep_kernel::tessellate_face(
face,
brep_kernel::TessellationOptions {
slabs_per_span_u: validation.tessellation_slabs_u.max(1),
steps_per_span_v: validation.tessellation_steps_v.max(1),
},
face_index as u32,
) {
Ok(mesh) if !mesh.indices.is_empty() => mesh,
Ok(_)
if matches!(
truth,
AnalyticSurface::Torus(_) | AnalyticSurface::Plane(_)
) =>
{
let primary_failure = "tessellator returned no triangles".to_owned();
let (method_name, fallback_result) = match truth {
AnalyticSurface::Torus(_) => (
torus_fallback::METHOD_NAME,
observation_fallback.tessellate(source_face_index, face),
),
AnalyticSurface::Plane(_) => (
torus_fallback::TRIM_CURVE_TRIANGLE_METHOD_NAME,
observation_fallback.tessellate_projected_trim_curve_triangle(
source_face_index,
face,
),
),
_ => unreachable!(),
};
match fallback_result {
Ok(fallback) if !fallback.mesh.indices.is_empty() => {
fallback_evidence = Some(TessellationFallbackEvidence {
solid_index,
shell_index,
face_index,
face_id: face.id,
face_name: face.name.clone(),
truth,
primary_failure,
method: fallback.method,
chord_tolerance: observation_fallback.chord_tolerance(),
triangles: fallback.mesh.indices.len() / 3,
vertices: fallback.mesh.positions.len() / 3,
surface_projected_vertices: fallback.surface_projected_vertices,
max_surface_projection_distance: fallback
.max_surface_projection_distance,
surface_projection_tolerance: fallback
.surface_projection_tolerance,
});
fallback.mesh
}
Ok(_) => {
let detail = format!(
"{primary_failure}; {} fallback returned no triangles",
method_name
);
record_tessellation_failure(
&mut report,
progress,
&path_string,
location,
solid_index,
shell_index,
face_index,
face,
truth,
None,
detail,
);
continue;
}
Err(error) => {
let detail = format!(
"{primary_failure}; {} fallback failed: {error}",
method_name
);
record_tessellation_failure(
&mut report,
progress,
&path_string,
location,
solid_index,
shell_index,
face_index,
face,
truth,
None,
detail,
);
continue;
}
}
}
Ok(_) => {
let detail = "tessellator returned no triangles".to_owned();
record_tessellation_failure(
&mut report,
progress,
&path_string,
location,
solid_index,
shell_index,
face_index,
face,
truth,
None,
detail,
);
continue;
}
Err(error) => {
report.tessellation_failures += 1;
report.face_diagnostics.push(StepFaceDiagnostic {
solid_index,
shell_index,
face_index,
face_id: face.id,
face_name: face.name.clone(),
kind: StepFaceDiagnosticKind::TessellationFailed,
truth: Some(truth),
detail: error.clone(),
});
emit_progress(
progress,
&path_string,
StepValidationPhase::TessellationFailed,
location,
None,
Some(truth),
None,
Some(false),
Some(error),
None,
);
continue;
}
};
let tessellation_millis = started.elapsed().as_secs_f64() * 1_000.0;
let mesh = match mesh_from_kernel(&kernel_mesh) {
Ok(mesh) => mesh,
Err(error) => {
let detail = fallback_failure_detail(
fallback_evidence.as_ref(),
&format!("observation mesh conversion failed: {error}"),
);
record_tessellation_failure(
&mut report,
progress,
&path_string,
location,
solid_index,
shell_index,
face_index,
face,
truth,
None,
detail,
);
continue;
}
};
let mesh_size = Some((mesh.triangles.len(), mesh.vertices.len()));
let scale = mesh_scale(&mesh.vertices);
let analyzed = match mesh.analyze(&crate::MeshAnalysisOptions::default()) {
Ok(analyzed) => analyzed,
Err(error) => {
let detail = fallback_failure_detail(
fallback_evidence.as_ref(),
&format!("observation mesh analysis failed: {error}"),
);
record_tessellation_failure(
&mut report,
progress,
&path_string,
location,
solid_index,
shell_index,
face_index,
face,
truth,
mesh_size,
detail,
);
continue;
}
};
if let Some(evidence) = fallback_evidence {
report.tessellation_fallbacks += 1;
report.tessellation_fallback_evidence.push(evidence);
}
emit_progress(
progress,
&path_string,
StepValidationPhase::TessellationCompleted,
location,
None,
Some(truth),
mesh_size,
Some(true),
Some(report.tessellation_fallback_evidence.last().filter(|evidence| {
evidence.solid_index == solid_index
&& evidence.shell_index == shell_index
&& evidence.face_index == face_index
}).map_or_else(
|| format!("{tessellation_millis:.3} ms"),
|evidence| format!(
"{tessellation_millis:.3} ms; fallback={:?}, primary_failure={}, chord_tolerance={:.6e}",
evidence.method, evidence.primary_failure, evidence.chord_tolerance
),
)),
None,
);
let tolerance = validation
.distance_tolerance
.max(validation.relative_face_tolerance * scale);
let options = RecognitionOptions {
distance_tolerance: tolerance,
relative_tolerance: 0.0,
normal_tolerance: validation.normal_tolerance_degrees.to_radians(),
minimum_support: 1,
max_hypotheses: validation.max_hypotheses.max(1),
max_refinement_iterations: validation.max_refinement_iterations.max(1),
discover_regions: false,
sampling: SamplingMode::Vertices,
..RecognitionOptions::default()
};
let triangles: Vec<_> = (0..mesh.triangles.len()).collect();
let initial_guess = perturbed_initial(truth, scale);
let fixed = constrained_fixed_parameters(truth);
let constrained_initial =
restore_fixed_parameters(truth, perturbed_initial(truth, scale), fixed);
let mut modes = Vec::with_capacity(validation.enabled_modes.len());
for mode in ValidationMode::ALL.into_iter().filter(|mode| {
validation.enabled_modes.is_empty() || validation.enabled_modes.contains(mode)
}) {
let hint = match mode {
ValidationMode::Unknown => SurfaceHint::Unknown,
ValidationMode::KnownType => SurfaceHint::KnownType {
surface_type: truth.surface_type(),
},
ValidationMode::InitialGuess => SurfaceHint::InitialGuess {
surface: initial_guess,
trust: MetadataTrust::InitialGuess,
},
ValidationMode::Constrained => SurfaceHint::Constrained {
surface_type: truth.surface_type(),
constraints: SurfaceConstraints {
initial: Some(constrained_initial),
fixed,
},
trust: MetadataTrust::StrongHint,
},
ValidationMode::ExactCandidate => {
SurfaceHint::ExactCandidate { surface: truth }
}
};
emit_progress(
progress,
&path_string,
StepValidationPhase::ModeStarted,
location,
Some(mode),
Some(truth),
mesh_size,
None,
None,
None,
);
let mut mode_report = run_mode(
mode,
&hint,
ModeRunInput {
mesh: &mesh,
analyzed: &analyzed,
triangles: &triangles,
options: &options,
truth,
expected_orientation: kernel_truth.orientation,
},
);
apply_ambiguity_evidence(&mut mode_report);
let mode_detail = mode_report.ambiguity_evidence.as_ref().map_or_else(
|| mode_report.error.clone(),
|evidence| {
Some(format!(
"carrier unobservable; sampled mesh also matches {}",
evidence.alternative_surface.surface_type().name()
))
},
);
emit_progress(
progress,
&path_string,
StepValidationPhase::ModeCompleted,
location,
Some(mode),
Some(truth),
mesh_size,
Some(mode_report.passed),
mode_detail,
Some(mode_report.clone()),
);
modes.push(mode_report);
}
report.cases.push(FaceValidationReport {
solid_index,
shell_index,
face_index,
face_id: face.id,
face_name: face.name.clone(),
same_sense: face.same_sense,
truth,
triangles: mesh.triangles.len(),
vertices: mesh.vertices.len(),
face_scale: scale,
distance_tolerance: tolerance,
tessellation_millis,
modes,
});
report.validated_faces += 1;
emit_progress(
progress,
&path_string,
StepValidationPhase::FaceCompleted,
location,
None,
Some(truth),
mesh_size,
Some(report.cases.last().is_some_and(|face| {
face.modes
.iter()
.all(|mode| mode.passed || mode.unobservable)
})),
None,
None,
);
}
}
}
debug_assert!(report.coverage_invariants_hold());
emit_progress(
progress,
&path_string,
StepValidationPhase::FileCompleted,
None,
None,
None,
None,
Some(report.cases.iter().all(|face| {
face.modes
.iter()
.all(|mode| mode.passed || mode.unobservable)
})),
Some(format!(
"analytic_faces={}, selected_analytic_faces={}, validated_faces={}, tessellation_failures={}, tessellation_fallbacks={}",
report.analytic_faces,
report.selected_analytic_faces,
report.validated_faces,
report.tessellation_failures,
report.tessellation_fallbacks
)),
None,
);
report
}
struct ModeRunInput<'a> {
mesh: &'a crate::Mesh,
analyzed: &'a AnalyzedMesh,
triangles: &'a [usize],
options: &'a RecognitionOptions,
truth: AnalyticSurface,
expected_orientation: i8,
}
fn run_mode(
mode: ValidationMode,
hint: &SurfaceHint,
input: ModeRunInput<'_>,
) -> ModeValidationReport {
let ModeRunInput {
mesh,
analyzed,
triangles,
options,
truth,
expected_orientation,
} = input;
let started = Instant::now();
match reconstruct_analyzed(analyzed, triangles, hint, options) {
Ok(result) => {
let type_matches = result.surface.surface_type() == truth.surface_type();
let parameter = type_matches.then(|| parameter_error(truth, result.surface));
let parameter_equivalent =
type_matches && carrier_parameters_equivalent(mesh, truth, result.surface);
let orientation_matches =
type_matches && orientation_against_truth(&result, truth) == expected_orientation;
let exact_parameters_reused = result.diagnostics.exact_parameters_reused;
let fixed_parameters_preserved =
matches!(mode, ValidationMode::Constrained).then(|| {
let fixed = constrained_fixed_parameters(truth);
result.diagnostics.fixed_parameters == fixed
&& fixed_parameters_match(truth, result.surface, fixed)
});
let ambiguity_evidence = (!parameter_equivalent)
.then(|| {
carrier_ambiguity_between(
mesh,
result.surface,
result.orientation,
truth,
expected_orientation,
)
})
.flatten();
let passed = type_matches
&& parameter_equivalent
&& orientation_matches
&& (!matches!(mode, ValidationMode::ExactCandidate) || exact_parameters_reused)
&& fixed_parameters_preserved.unwrap_or(true);
ModeValidationReport {
mode,
elapsed_millis: started.elapsed().as_secs_f64() * 1_000.0,
error: (!parameter_equivalent && type_matches).then(|| {
let error = parameter.unwrap_or_default();
format!(
"observable parameter mismatch: position={:.3e}, direction={:.3e}deg, radius={:.3e}, major_radius={:.3e}, angle={:.3e}deg",
error.position,
error.direction_degrees,
error.radius,
error.major_radius,
error.angle_degrees,
)
}),
parameter_error: parameter,
result: Some(result),
passed,
unobservable: false,
ambiguity_evidence,
type_matches,
parameter_equivalent,
orientation_matches,
exact_parameters_reused,
fixed_parameters_preserved,
}
}
Err(error) => ModeValidationReport {
mode,
elapsed_millis: started.elapsed().as_secs_f64() * 1_000.0,
error: Some(error.to_string()),
result: None,
passed: false,
unobservable: false,
ambiguity_evidence: None,
type_matches: false,
parameter_equivalent: false,
orientation_matches: false,
exact_parameters_reused: false,
fixed_parameters_preserved: matches!(mode, ValidationMode::Constrained)
.then_some(false),
parameter_error: None,
},
}
}
fn apply_ambiguity_evidence(report: &mut ModeValidationReport) {
let evidence_matches_returned_carrier = report
.result
.as_ref()
.zip(report.ambiguity_evidence.as_ref())
.is_some_and(|(result, evidence)| result.surface == evidence.alternative_surface);
let own_carrier_is_ambiguous = !report.passed
&& !matches!(report.mode, ValidationMode::ExactCandidate)
&& report.fixed_parameters_preserved != Some(false)
&& evidence_matches_returned_carrier;
if own_carrier_is_ambiguous {
report.unobservable = true;
report.error = None;
} else {
report.ambiguity_evidence = None;
report.unobservable = false;
}
}
fn carrier_ambiguity_between(
mesh: &crate::Mesh,
alternative_surface: AnalyticSurface,
alternative_orientation: i8,
truth: AnalyticSurface,
expected_orientation: i8,
) -> Option<CarrierAmbiguityEvidence> {
if mesh.vertices.is_empty() {
return None;
}
let (position_threshold, normal_threshold) = carrier_ambiguity_thresholds(mesh, truth);
let carriers_distinct = !carrier_parameters_equivalent(mesh, truth, alternative_surface);
if !carriers_distinct {
return None;
}
let mut max_truth_error = 0.0_f64;
let mut max_alternative_error = 0.0_f64;
let mut max_normal_disagreement = 0.0_f64;
for &point in &mesh.vertices {
if !point.is_finite() {
return None;
}
let truth_error = truth.signed_distance(point).abs();
let alternative_error = alternative_surface.signed_distance(point).abs();
let truth_normal = truth.normal_at(point)? * expected_orientation as f64;
let alternative_normal =
alternative_surface.normal_at(point)? * alternative_orientation as f64;
let normal_disagreement = truth_normal.dot(alternative_normal).clamp(-1.0, 1.0).acos();
if !truth_error.is_finite()
|| !alternative_error.is_finite()
|| !truth_normal.is_finite()
|| !alternative_normal.is_finite()
|| !normal_disagreement.is_finite()
{
return None;
}
max_truth_error = max_truth_error.max(truth_error);
max_alternative_error = max_alternative_error.max(alternative_error);
max_normal_disagreement = max_normal_disagreement.max(normal_disagreement);
}
if max_truth_error > position_threshold
|| max_alternative_error > position_threshold
|| max_normal_disagreement > normal_threshold
{
return None;
}
Some(CarrierAmbiguityEvidence {
alternative_surface,
max_truth_error,
max_alternative_error,
max_oriented_normal_disagreement: max_normal_disagreement,
position_threshold,
normal_threshold,
})
}
fn carrier_equivalence_thresholds(mesh: &crate::Mesh, truth: AnalyticSurface) -> (f64, f64) {
carrier_thresholds(mesh, truth, numerical::COORDINATE_ROUNDOFF_RELATIVE)
}
fn carrier_ambiguity_thresholds(mesh: &crate::Mesh, truth: AnalyticSurface) -> (f64, f64) {
carrier_thresholds(
mesh,
truth,
numerical::TESSELLATED_COORDINATE_EVIDENCE_ROUNDOFF_RELATIVE,
)
}
fn carrier_thresholds(
mesh: &crate::Mesh,
truth: AnalyticSurface,
coordinate_roundoff_relative: f64,
) -> (f64, f64) {
let coordinate_scale = mesh
.vertices
.iter()
.map(|point| point.x.abs().max(point.y.abs()).max(point.z.abs()))
.fold(1.0_f64, f64::max);
let cone_conditioning_scale = match truth {
AnalyticSurface::Cone(cone) => cone
.apex
.x
.abs()
.max(cone.apex.y.abs())
.max(cone.apex.z.abs()),
_ => 0.0,
};
let position_threshold = (coordinate_roundoff_relative * coordinate_scale)
.max(numerical::CONE_APEX_ROUNDOFF_RELATIVE * cone_conditioning_scale);
let normal_threshold = numerical::NORMAL_EQUIVALENCE_RADIANS;
(position_threshold, normal_threshold)
}
fn carrier_parameters_equivalent(
mesh: &crate::Mesh,
truth: AnalyticSurface,
actual: AnalyticSurface,
) -> bool {
if truth.surface_type() != actual.surface_type() {
return false;
}
let (position_threshold, normal_threshold) = carrier_equivalence_thresholds(mesh, truth);
let parameter = parameter_error(truth, actual);
parameter.position <= position_threshold
&& parameter.radius <= position_threshold
&& parameter.major_radius <= position_threshold
&& parameter.direction_degrees.to_radians() <= normal_threshold
&& parameter.angle_degrees.to_radians() <= normal_threshold
}
fn perturbed_initial(surface: AnalyticSurface, face_scale: f64) -> AnalyticSurface {
let position_delta =
face_scale.max(scalar::GEOMETRIC_SCALE_FLOOR) * numerical::INITIAL_PERTURBATION_RELATIVE;
let tilt = numerical::INITIAL_PERTURBATION_RELATIVE;
match surface {
AnalyticSurface::Plane(mut plane) => {
let tangent = plane
.normal
.orthonormal_basis()
.map(|basis| basis.0)
.unwrap_or(Vec3::X);
plane.origin += plane.normal * position_delta;
plane.normal = (plane.normal + tangent * tilt)
.normalized()
.unwrap_or(plane.normal);
AnalyticSurface::Plane(plane)
}
AnalyticSurface::Sphere(mut sphere) => {
sphere.center +=
Vec3::new(position_delta, -0.5 * position_delta, 0.25 * position_delta);
sphere.radius *= 1.001;
AnalyticSurface::Sphere(sphere)
}
AnalyticSurface::Cylinder(mut cylinder) => {
let tangent = cylinder
.axis
.orthonormal_basis()
.map(|basis| basis.0)
.unwrap_or(Vec3::X);
cylinder.axis_origin += tangent * position_delta;
cylinder.axis = (cylinder.axis + tangent * tilt)
.normalized()
.unwrap_or(cylinder.axis);
cylinder.radius *= 1.001;
AnalyticSurface::Cylinder(cylinder)
}
AnalyticSurface::Cone(mut cone) => {
let tangent = cone
.axis
.orthonormal_basis()
.map(|basis| basis.0)
.unwrap_or(Vec3::X);
cone.apex += tangent * position_delta;
cone.axis = (cone.axis + tangent * tilt)
.normalized()
.unwrap_or(cone.axis);
cone.half_angle += (std::f64::consts::FRAC_PI_4 - cone.half_angle)
* numerical::INITIAL_PERTURBATION_RELATIVE;
AnalyticSurface::Cone(cone)
}
AnalyticSurface::Torus(mut torus) => {
let tangent = torus
.axis
.orthonormal_basis()
.map(|basis| basis.0)
.unwrap_or(Vec3::X);
torus.center += tangent * position_delta;
torus.axis = (torus.axis + tangent * tilt)
.normalized()
.unwrap_or(torus.axis);
torus.major_radius *= 1.001;
torus.minor_radius *= 1.001;
AnalyticSurface::Torus(torus)
}
}
}
fn constrained_fixed_parameters(surface: AnalyticSurface) -> ConstraintMask {
match surface {
AnalyticSurface::Plane(_) => ConstraintMask {
axis_or_normal: true,
..ConstraintMask::default()
},
AnalyticSurface::Sphere(_) | AnalyticSurface::Cylinder(_) => ConstraintMask {
radius: true,
..ConstraintMask::default()
},
AnalyticSurface::Cone(_) => ConstraintMask {
angle: true,
..ConstraintMask::default()
},
AnalyticSurface::Torus(_) => ConstraintMask {
major_radius: true,
..ConstraintMask::default()
},
}
}
fn fixed_parameters_match(
expected: AnalyticSurface,
actual: AnalyticSurface,
fixed: ConstraintMask,
) -> bool {
match (expected, actual) {
(AnalyticSurface::Plane(a), AnalyticSurface::Plane(b)) => {
(!fixed.origin_or_center || a.origin == b.origin)
&& (!fixed.axis_or_normal || a.normal == b.normal)
}
(AnalyticSurface::Sphere(a), AnalyticSurface::Sphere(b)) => {
(!fixed.origin_or_center || a.center == b.center)
&& (!fixed.radius || a.radius == b.radius)
}
(AnalyticSurface::Cylinder(a), AnalyticSurface::Cylinder(b)) => {
(!fixed.origin_or_center || a.axis_origin == b.axis_origin)
&& (!fixed.axis_or_normal || a.axis == b.axis)
&& (!fixed.radius || a.radius == b.radius)
}
(AnalyticSurface::Cone(a), AnalyticSurface::Cone(b)) => {
(!fixed.origin_or_center || a.apex == b.apex)
&& (!fixed.axis_or_normal || a.axis == b.axis)
&& (!fixed.angle || a.half_angle == b.half_angle)
}
(AnalyticSurface::Torus(a), AnalyticSurface::Torus(b)) => {
(!fixed.origin_or_center || a.center == b.center)
&& (!fixed.axis_or_normal || a.axis == b.axis)
&& (!fixed.major_radius || a.major_radius == b.major_radius)
&& (!fixed.radius || a.minor_radius == b.minor_radius)
}
_ => false,
}
}
fn restore_fixed_parameters(
expected: AnalyticSurface,
perturbed: AnalyticSurface,
fixed: ConstraintMask,
) -> AnalyticSurface {
match (expected, perturbed) {
(AnalyticSurface::Plane(a), AnalyticSurface::Plane(mut b)) => {
if fixed.origin_or_center {
b.origin = a.origin;
}
if fixed.axis_or_normal {
b.normal = a.normal;
}
AnalyticSurface::Plane(b)
}
(AnalyticSurface::Sphere(a), AnalyticSurface::Sphere(mut b)) => {
if fixed.origin_or_center {
b.center = a.center;
}
if fixed.radius {
b.radius = a.radius;
}
AnalyticSurface::Sphere(b)
}
(AnalyticSurface::Cylinder(a), AnalyticSurface::Cylinder(mut b)) => {
if fixed.origin_or_center {
b.axis_origin = a.axis_origin;
}
if fixed.axis_or_normal {
b.axis = a.axis;
}
if fixed.radius {
b.radius = a.radius;
}
AnalyticSurface::Cylinder(b)
}
(AnalyticSurface::Cone(a), AnalyticSurface::Cone(mut b)) => {
if fixed.origin_or_center {
b.apex = a.apex;
}
if fixed.axis_or_normal {
b.axis = a.axis;
}
if fixed.angle {
b.half_angle = a.half_angle;
}
AnalyticSurface::Cone(b)
}
(AnalyticSurface::Torus(a), AnalyticSurface::Torus(mut b)) => {
if fixed.origin_or_center {
b.center = a.center;
}
if fixed.axis_or_normal {
b.axis = a.axis;
}
if fixed.major_radius {
b.major_radius = a.major_radius;
}
if fixed.radius {
b.minor_radius = a.minor_radius;
}
AnalyticSurface::Torus(b)
}
(_, perturbed) => perturbed,
}
}
fn parameter_error(expected: AnalyticSurface, actual: AnalyticSurface) -> ParameterError {
let mut error = ParameterError::default();
match (expected, actual) {
(AnalyticSurface::Plane(a), AnalyticSurface::Plane(b)) => {
error.position = (b.origin - a.origin).dot(a.normal).abs();
error.direction_degrees = direction_error(a.normal, b.normal);
}
(AnalyticSurface::Sphere(a), AnalyticSurface::Sphere(b)) => {
error.position = (a.center - b.center).length();
error.radius = (a.radius - b.radius).abs();
}
(AnalyticSurface::Cylinder(a), AnalyticSurface::Cylinder(b)) => {
error.position = line_error(a.axis_origin, a.axis, b.axis_origin, b.axis);
error.direction_degrees = direction_error(a.axis, b.axis);
error.radius = (a.radius - b.radius).abs();
}
(AnalyticSurface::Cone(a), AnalyticSurface::Cone(b)) => {
error.position = (a.apex - b.apex).length();
error.direction_degrees = oriented_direction_error(a.axis, b.axis);
error.angle_degrees = (a.half_angle - b.half_angle).abs().to_degrees();
}
(AnalyticSurface::Torus(a), AnalyticSurface::Torus(b)) => {
error.position = (a.center - b.center).length();
error.direction_degrees = direction_error(a.axis, b.axis);
error.major_radius = (a.major_radius - b.major_radius).abs();
error.radius = (a.minor_radius - b.minor_radius).abs();
}
_ => {}
}
error
}
fn orientation_against_truth(result: &SurfaceFitResult, truth: AnalyticSurface) -> i8 {
let gauge = match (truth, result.surface) {
(AnalyticSurface::Plane(expected), AnalyticSurface::Plane(actual))
if expected.normal.dot(actual.normal) < 0.0 =>
{
-1
}
_ => 1,
};
result.orientation * gauge
}
fn direction_error(a: Vec3, b: Vec3) -> f64 {
a.dot(b).abs().clamp(-1.0, 1.0).acos().to_degrees()
}
fn oriented_direction_error(a: Vec3, b: Vec3) -> f64 {
a.dot(b).clamp(-1.0, 1.0).acos().to_degrees()
}
fn line_error(a_origin: Vec3, a_axis: Vec3, b_origin: Vec3, b_axis: Vec3) -> f64 {
let direction = (a_axis + b_axis * a_axis.dot(b_axis).signum())
.normalized()
.unwrap_or(a_axis);
((b_origin - a_origin) - direction * (b_origin - a_origin).dot(direction)).length()
}
fn mesh_scale(vertices: &[Vec3]) -> f64 {
let mut min = vertices[0];
let mut max = vertices[0];
for &point in &vertices[1..] {
min.x = min.x.min(point.x);
min.y = min.y.min(point.y);
min.z = min.z.min(point.z);
max.x = max.x.max(point.x);
max.y = max.y.max(point.y);
max.z = max.z.max(point.z);
}
(max - min).length().max(scalar::GEOMETRIC_SCALE_FLOOR)
}
pub fn reports_to_csv(reports: &[StepFileReport]) -> String {
let mut out = String::from("path,solid,shell,face,face_id,truth,triangles,mode,success,type_matches,orientation_matches,exact_parameters_reused,fixed_parameters_preserved,rms_error,max_error,position_error,direction_degrees,radius_error,major_radius_error,angle_degrees,elapsed_ms,error,unobservable,ambiguity_alternative,ambiguity_truth_max,ambiguity_alternative_max,ambiguity_normal_max,parameter_equivalent,tessellation_fallback,fallback_method,fallback_chord_tolerance\n");
for file in reports {
for face in &file.cases {
let fallback = file.tessellation_fallback_evidence.iter().find(|evidence| {
evidence.solid_index == face.solid_index
&& evidence.shell_index == face.shell_index
&& evidence.face_index == face.face_index
&& evidence.face_id == face.face_id
});
for mode in &face.modes {
let fit = mode.result.as_ref();
let parameter = mode.parameter_error.unwrap_or_default();
let error = mode.error.as_deref().unwrap_or("").replace('"', "\"\"");
let ambiguity = mode.ambiguity_evidence.as_ref();
let row = [
format!("\"{}\"", file.path.replace('"', "\"\"")),
face.solid_index.to_string(),
face.shell_index.to_string(),
face.face_index.to_string(),
face.face_id.to_string(),
face.truth.surface_type().name().to_string(),
face.triangles.to_string(),
format!("{:?}", mode.mode),
mode.passed.to_string(),
mode.type_matches.to_string(),
mode.orientation_matches.to_string(),
mode.exact_parameters_reused.to_string(),
mode.fixed_parameters_preserved
.map(|value| value.to_string())
.unwrap_or_default(),
fit.map(|v| v.metrics.rms_error)
.unwrap_or(f64::NAN)
.to_string(),
fit.map(|v| v.metrics.max_error)
.unwrap_or(f64::NAN)
.to_string(),
parameter.position.to_string(),
parameter.direction_degrees.to_string(),
parameter.radius.to_string(),
parameter.major_radius.to_string(),
parameter.angle_degrees.to_string(),
mode.elapsed_millis.to_string(),
format!("\"{error}\""),
mode.unobservable.to_string(),
ambiguity
.map(|value| value.alternative_surface.surface_type().name())
.unwrap_or("")
.to_string(),
ambiguity
.map(|value| value.max_truth_error)
.unwrap_or(f64::NAN)
.to_string(),
ambiguity
.map(|value| value.max_alternative_error)
.unwrap_or(f64::NAN)
.to_string(),
ambiguity
.map(|value| value.max_oriented_normal_disagreement)
.unwrap_or(f64::NAN)
.to_string(),
mode.parameter_equivalent.to_string(),
fallback.is_some().to_string(),
fallback
.map(|evidence| format!("{:?}", evidence.method))
.unwrap_or_default(),
fallback
.map(|evidence| evidence.chord_tolerance.to_string())
.unwrap_or_default(),
];
out.push_str(&row.join(","));
out.push('\n');
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
synthetic, ConeSurface, CylinderSurface, FitPath, PlaneSurface, SurfaceType, TorusSurface,
};
#[test]
fn serialized_options_without_modes_default_to_all_five() {
let mut value = serde_json::to_value(StepValidationOptions::default()).unwrap();
value.as_object_mut().unwrap().remove("enabled_modes");
let options: StepValidationOptions = serde_json::from_value(value).unwrap();
assert_eq!(options.enabled_modes, ValidationMode::ALL);
for mode in ValidationMode::ALL {
assert_eq!(ValidationMode::from_cli_name(mode.cli_name()), Some(mode));
}
}
#[test]
fn sampled_nested_carriers_are_unobservable_only_at_roundoff_scale() {
let torus = AnalyticSurface::Torus(TorusSurface {
center: Vec3::ZERO,
axis: Vec3::Z,
major_radius: 2.0,
minor_radius: 0.2,
});
let plane = AnalyticSurface::Plane(PlaneSurface {
origin: Vec3::new(0.0, 0.0, -0.2),
normal: -Vec3::Z,
});
let points: Vec<_> = (0..16)
.map(|index| {
let angle = std::f64::consts::TAU * index as f64 / 16.0;
Vec3::new(2.0 * angle.cos(), 2.0 * angle.sin(), -0.2)
})
.collect();
let mesh = crate::Mesh::new(points.clone(), Vec::new());
let evidence = carrier_ambiguity_between(&mesh, plane, 1, torus, 1).unwrap();
assert_eq!(
evidence.alternative_surface.surface_type(),
SurfaceType::Plane
);
assert!(evidence.max_truth_error <= evidence.position_threshold);
assert!(evidence.max_alternative_error <= evidence.position_threshold);
assert!(evidence.max_oriented_normal_disagreement <= evidence.normal_threshold);
let inside = AnalyticSurface::Plane(PlaneSurface {
origin: Vec3::new(0.0, 0.0, -0.2 + evidence.position_threshold * 0.5),
normal: -Vec3::Z,
});
assert!(carrier_ambiguity_between(&mesh, inside, 1, torus, 1).is_some());
let outside = AnalyticSurface::Plane(PlaneSurface {
origin: Vec3::new(0.0, 0.0, -0.2 + evidence.position_threshold * 2.0),
normal: -Vec3::Z,
});
assert!(carrier_ambiguity_between(&mesh, outside, 1, torus, 1).is_none());
let non_finite_mesh = crate::Mesh::new(vec![Vec3::new(f64::NAN, 0.0, 0.0)], Vec::new());
assert!(carrier_ambiguity_between(&non_finite_mesh, plane, 1, torus, 1).is_none());
}
#[test]
fn tessellated_coordinate_ambiguity_keeps_strict_distance_and_normal_controls() {
let truth = AnalyticSurface::Cylinder(CylinderSurface {
axis_origin: Vec3::ZERO,
axis: Vec3::Z,
radius: 1.0,
});
let points: Vec<_> = (0..32)
.map(|index| {
let angle = std::f64::consts::TAU * index as f64 / 32.0;
Vec3::new(angle.cos(), angle.sin(), index as f64 / 31.0)
})
.collect();
let mesh = crate::Mesh::new(points, Vec::new());
let (strict_position, _) = carrier_equivalence_thresholds(&mesh, truth);
let (evidence_position, _) = carrier_ambiguity_thresholds(&mesh, truth);
assert_eq!(evidence_position, strict_position * 32.0);
let inside = AnalyticSurface::Cylinder(CylinderSurface {
radius: 1.0 + strict_position * 2.0,
..match truth {
AnalyticSurface::Cylinder(cylinder) => cylinder,
_ => unreachable!(),
}
});
assert!(!carrier_parameters_equivalent(&mesh, truth, inside));
let evidence = carrier_ambiguity_between(&mesh, inside, 1, truth, 1).unwrap();
assert!(evidence.max_truth_error <= evidence.position_threshold);
assert!(evidence.max_alternative_error <= evidence.position_threshold);
let outside = AnalyticSurface::Cylinder(CylinderSurface {
radius: 1.0 + evidence_position * 2.0,
..match truth {
AnalyticSurface::Cylinder(cylinder) => cylinder,
_ => unreachable!(),
}
});
assert!(carrier_ambiguity_between(&mesh, outside, 1, truth, 1).is_none());
assert!(carrier_ambiguity_between(&mesh, inside, -1, truth, 1).is_none());
}
#[test]
fn same_type_torus_parameters_require_equivalence_or_unobservable_evidence() {
let truth = AnalyticSurface::Torus(TorusSurface {
center: Vec3::new(0.0, 0.0, -56.198_384_197_012_6),
axis: Vec3::Z,
major_radius: 38.380_867,
minor_radius: 2.170_296_2,
});
let bad_minor_radius = 500_000.0_f64;
let bad_major_radius = 0.005_f64;
let radial = 38.380_867 - bad_major_radius;
let top_z = -56.198_384_197_012_6 + 2.170_296_2;
let bad_center_z = top_z - (bad_minor_radius * bad_minor_radius - radial * radial).sqrt();
let wrong = AnalyticSurface::Torus(TorusSurface {
center: Vec3::new(0.0, 0.0, bad_center_z),
axis: Vec3::Z,
major_radius: bad_major_radius,
minor_radius: bad_minor_radius,
});
let mesh = synthetic::tessellate(
truth,
synthetic::Patch {
u: [0.0, std::f64::consts::TAU],
v: [
std::f64::consts::FRAC_PI_2 - 2.2e-5,
std::f64::consts::FRAC_PI_2 + 2.2e-5,
],
u_segments: 32,
v_segments: 4,
..Default::default()
},
);
let (position_threshold, normal_threshold) = carrier_equivalence_thresholds(&mesh, truth);
assert!(!carrier_parameters_equivalent(&mesh, truth, wrong));
let mut max_distance = 0.0_f64;
let mut max_normal = 0.0_f64;
for &point in &mesh.vertices {
max_distance = max_distance.max(wrong.signed_distance(point).abs());
let truth_normal = truth.normal_at(point).unwrap();
let wrong_normal = wrong.normal_at(point).unwrap();
max_normal = max_normal.max(truth_normal.dot(wrong_normal).clamp(-1.0, 1.0).acos());
}
assert!(max_distance < 1.1e-4, "{max_distance:e}");
assert!(max_normal < 12.0_f64.to_radians(), "{max_normal:e}");
assert!(max_normal > normal_threshold, "{max_normal:e}");
assert!(position_threshold < 1.0e-9);
assert!(carrier_ambiguity_between(&mesh, wrong, 1, truth, 1).is_none());
let nested_truth = AnalyticSurface::Torus(TorusSurface {
center: Vec3::ZERO,
axis: Vec3::Z,
major_radius: 2.0,
minor_radius: 0.2,
});
let nested_alternative = AnalyticSurface::Torus(TorusSurface {
center: Vec3::new(0.0, 0.0, 0.1),
axis: Vec3::Z,
major_radius: 2.0,
minor_radius: 0.3,
});
let ring: Vec<_> = (0..16)
.map(|index| {
let angle = std::f64::consts::TAU * index as f64 / 16.0;
Vec3::new(2.0 * angle.cos(), 2.0 * angle.sin(), -0.2)
})
.collect();
let ring_mesh = crate::Mesh::new(ring, Vec::new());
assert!(!carrier_parameters_equivalent(
&ring_mesh,
nested_truth,
nested_alternative
));
assert!(
carrier_ambiguity_between(&ring_mesh, nested_alternative, 1, nested_truth, 1,)
.is_some()
);
}
#[test]
fn shallow_cone_and_cylinder_nested_limit_accounts_for_apex_conditioning() {
let apex = Vec3::new(0.0, 0.0, -1.0e8);
let half_angle = 1.0e-9_f64;
let cone = AnalyticSurface::Cone(ConeSurface {
apex,
axis: Vec3::Z,
half_angle,
});
let cylinder = AnalyticSurface::Cylinder(CylinderSurface {
axis_origin: Vec3::ZERO,
axis: Vec3::Z,
radius: 1.0e8 * half_angle.tan(),
});
let points: Vec<_> = (0..16)
.map(|index| {
let angle = std::f64::consts::TAU * index as f64 / 16.0;
Vec3::new(0.1 * angle.cos(), 0.1 * angle.sin(), 0.0)
})
.collect();
let mesh = crate::Mesh::new(points, Vec::new());
let evidence = carrier_ambiguity_between(&mesh, cylinder, 1, cone, 1).unwrap();
assert!(evidence.position_threshold < 1.0e-7);
assert!(evidence.max_oriented_normal_disagreement < 2.0e-9);
let displaced = AnalyticSurface::Cylinder(CylinderSurface {
axis_origin: Vec3::ZERO,
axis: Vec3::Z,
radius: 1.0e8 * half_angle.tan() + evidence.position_threshold * 2.0,
});
assert!(carrier_ambiguity_between(&mesh, displaced, 1, cone, 1).is_none());
}
#[test]
fn tessellated_cylinder_ambiguity_has_a_coordinate_conditioned_hard_cap() {
let truth = AnalyticSurface::Cylinder(CylinderSurface {
axis_origin: Vec3::ZERO,
axis: Vec3::Z,
radius: 1.0,
});
let alternative = AnalyticSurface::Cylinder(CylinderSurface {
axis_origin: Vec3::ZERO,
axis: Vec3::Z,
radius: 1.0 + 1.0e-10,
});
let points: Vec<_> = (0..32)
.map(|index| {
let angle = std::f64::consts::TAU * index as f64 / 32.0;
let radius = 1.0 + 5.0e-11;
Vec3::new(radius * angle.cos(), radius * angle.sin(), 0.0)
})
.collect();
let mesh = crate::Mesh::new(points, Vec::new());
assert!(!carrier_parameters_equivalent(&mesh, truth, alternative));
let evidence = carrier_ambiguity_between(&mesh, alternative, 1, truth, 1)
.expect("roundoff-scale cylinder carriers should be evidence-equivalent");
assert!(evidence.position_threshold > 1.0e-10);
let outside_cap = AnalyticSurface::Cylinder(CylinderSurface {
axis_origin: Vec3::ZERO,
axis: Vec3::Z,
radius: 1.0 + 4.0e-10,
});
assert!(carrier_ambiguity_between(&mesh, outside_cap, 1, truth, 1).is_none());
}
#[test]
fn observable_shallow_cones_and_tolerance_close_wrong_types_are_not_ambiguities() {
for half_angle in [normal_threshold_for_test() * 1.01, 1.0e-4, 1.0e-3] {
let height = 100.0;
let cone = AnalyticSurface::Cone(ConeSurface {
apex: Vec3::new(0.0, 0.0, -height),
axis: Vec3::Z,
half_angle,
});
let cylinder = AnalyticSurface::Cylinder(CylinderSurface {
axis_origin: Vec3::ZERO,
axis: Vec3::Z,
radius: height * half_angle.tan(),
});
let points: Vec<_> = (0..32)
.map(|index| {
let angle = std::f64::consts::TAU * index as f64 / 32.0;
Vec3::new(
height * half_angle.tan() * angle.cos(),
height * half_angle.tan() * angle.sin(),
0.0,
)
})
.collect();
let mesh = crate::Mesh::new(points, Vec::new());
assert!(
carrier_ambiguity_between(&mesh, cylinder, 1, cone, 1).is_none(),
"half_angle={half_angle} exceeds the quantitative normal boundary"
);
}
let truth = AnalyticSurface::Plane(PlaneSurface {
origin: Vec3::ZERO,
normal: Vec3::Z,
});
let wrong = AnalyticSurface::Sphere(crate::SphereSurface {
center: Vec3::new(0.0, 0.0, 1.0e6),
radius: 1.0e6 - 1.0e-6,
});
let mesh = crate::Mesh::new(
vec![
Vec3::new(-1.0e-3, -1.0e-3, 0.0),
Vec3::new(1.0e-3, -1.0e-3, 0.0),
Vec3::new(0.0, 1.0e-3, 0.0),
],
Vec::new(),
);
assert!(carrier_ambiguity_between(&mesh, wrong, -1, truth, 1).is_none());
}
fn normal_threshold_for_test() -> f64 {
1.28e-5
}
#[test]
fn ambiguity_requires_this_modes_own_successfully_returned_carrier() {
let evidence = |alternative_surface| CarrierAmbiguityEvidence {
alternative_surface,
max_truth_error: 0.0,
max_alternative_error: 0.0,
max_oriented_normal_disagreement: 0.0,
position_threshold: 1.0e-12,
normal_threshold: normal_threshold_for_test(),
};
let plane = AnalyticSurface::Plane(PlaneSurface {
origin: Vec3::ZERO,
normal: Vec3::Z,
});
let torus = AnalyticSurface::Torus(TorusSurface {
center: Vec3::ZERO,
axis: Vec3::Z,
major_radius: 2.0,
minor_radius: 0.2,
});
let fit = |surface| SurfaceFitResult {
surface,
orientation: 1,
metrics: crate::FitMetrics::default(),
confidence: 1.0,
diagnostics: crate::FitDiagnostics::default(),
};
let report = |mode, passed, result, ambiguity_evidence| ModeValidationReport {
mode,
elapsed_millis: 0.0,
error: Some("failed".into()),
result,
passed,
unobservable: true,
ambiguity_evidence,
type_matches: false,
parameter_equivalent: false,
orientation_matches: false,
exact_parameters_reused: false,
fixed_parameters_preserved: None,
parameter_error: None,
};
let mut exact = report(
ValidationMode::ExactCandidate,
false,
Some(fit(plane)),
Some(evidence(plane)),
);
apply_ambiguity_evidence(&mut exact);
assert!(!exact.unobservable && exact.ambiguity_evidence.is_none());
let mut passed = report(
ValidationMode::Unknown,
true,
Some(fit(plane)),
Some(evidence(plane)),
);
apply_ambiguity_evidence(&mut passed);
assert!(!passed.unobservable && passed.ambiguity_evidence.is_none());
let mut errored = report(
ValidationMode::KnownType,
false,
None,
Some(evidence(plane)),
);
apply_ambiguity_evidence(&mut errored);
assert!(!errored.unobservable && errored.ambiguity_evidence.is_none());
assert_eq!(errored.error.as_deref(), Some("failed"));
let mut cross_type = report(
ValidationMode::Unknown,
false,
Some(fit(plane)),
Some(evidence(plane)),
);
apply_ambiguity_evidence(&mut cross_type);
assert!(cross_type.unobservable && cross_type.ambiguity_evidence.is_some());
assert!(cross_type.error.is_none());
let mut same_type = report(
ValidationMode::KnownType,
false,
Some(fit(torus)),
Some(evidence(torus)),
);
apply_ambiguity_evidence(&mut same_type);
assert!(same_type.unobservable && same_type.ambiguity_evidence.is_some());
assert!(same_type.error.is_none());
let mut mismatched_witness = report(
ValidationMode::KnownType,
false,
Some(fit(torus)),
Some(evidence(plane)),
);
apply_ambiguity_evidence(&mut mismatched_witness);
assert!(!mismatched_witness.unobservable);
assert!(mismatched_witness.ambiguity_evidence.is_none());
assert_eq!(mismatched_witness.error.as_deref(), Some("failed"));
let mut broken_constraint = report(
ValidationMode::Constrained,
false,
Some(fit(torus)),
Some(evidence(torus)),
);
broken_constraint.fixed_parameters_preserved = Some(false);
apply_ambiguity_evidence(&mut broken_constraint);
assert!(!broken_constraint.unobservable);
assert!(broken_constraint.ambiguity_evidence.is_none());
assert_eq!(broken_constraint.error.as_deref(), Some("failed"));
}
#[test]
fn exact_candidate_and_passing_modes_never_inherit_ambiguity() {
let evidence = CarrierAmbiguityEvidence {
alternative_surface: AnalyticSurface::Plane(PlaneSurface {
origin: Vec3::ZERO,
normal: Vec3::Z,
}),
max_truth_error: 0.0,
max_alternative_error: 0.0,
max_oriented_normal_disagreement: 0.0,
position_threshold: 1.0e-12,
normal_threshold: normal_threshold_for_test(),
};
let report = |mode, passed| ModeValidationReport {
mode,
elapsed_millis: 0.0,
error: Some("failed".into()),
result: None,
passed,
unobservable: true,
ambiguity_evidence: Some(evidence.clone()),
type_matches: false,
parameter_equivalent: false,
orientation_matches: false,
exact_parameters_reused: false,
fixed_parameters_preserved: None,
parameter_error: None,
};
let mut exact = report(ValidationMode::ExactCandidate, false);
apply_ambiguity_evidence(&mut exact);
assert!(!exact.unobservable && exact.ambiguity_evidence.is_none());
let mut passed = report(ValidationMode::Unknown, true);
apply_ambiguity_evidence(&mut passed);
assert!(!passed.unobservable && passed.ambiguity_evidence.is_none());
let mut known = report(ValidationMode::KnownType, false);
apply_ambiguity_evidence(&mut known);
assert!(!known.unobservable && known.ambiguity_evidence.is_none());
}
#[test]
fn coverage_diagnostics_distinguish_skip_extraction_and_tessellation() {
let truth = AnalyticSurface::Plane(PlaneSurface {
origin: Vec3::ZERO,
normal: Vec3::Z,
});
let diagnostic = |face_id, kind, truth, detail: &str| StepFaceDiagnostic {
solid_index: 0,
shell_index: 0,
face_index: face_id as usize,
face_id,
face_name: None,
kind,
truth,
detail: detail.into(),
};
let report = StepFileReport {
path: "coverage.step".into(),
import_error: None,
failed_imported_solids: 0,
partial_import_error: None,
solids: 1,
faces_total: 3,
analytic_faces: 1,
selected_analytic_faces: 1,
validated_faces: 0,
unsupported_analytic_faces: 2,
truth_extraction_failures: 1,
tessellation_failures: 1,
tessellation_fallbacks: 0,
tessellation_fallback_evidence: Vec::new(),
face_diagnostics: vec![
diagnostic(
10,
StepFaceDiagnosticKind::UnsupportedOrNonAnalytic,
None,
"unsupported freeform carrier",
),
diagnostic(
11,
StepFaceDiagnosticKind::TruthExtractionFailed,
None,
"invalid analytic parameters",
),
diagnostic(
12,
StepFaceDiagnosticKind::TessellationFailed,
Some(truth),
"all triangles are degenerate",
),
],
cases: Vec::new(),
};
assert!(report.coverage_invariants_hold());
let value = serde_json::to_value(&report).unwrap();
assert_eq!(value["selected_analytic_faces"], 1);
assert_eq!(value["validated_faces"], 0);
assert_eq!(value["truth_extraction_failures"], 1);
assert_eq!(value["face_diagnostics"].as_array().unwrap().len(), 3);
assert_eq!(
value["face_diagnostics"][0]["kind"],
"UnsupportedOrNonAnalytic"
);
assert_eq!(
value["face_diagnostics"][1]["kind"],
"TruthExtractionFailed"
);
assert_eq!(value["face_diagnostics"][2]["kind"], "TessellationFailed");
assert!(value["face_diagnostics"][0].get("truth").is_none());
assert!(value["face_diagnostics"][2].get("truth").is_some());
let mut legacy_value = value;
let legacy_object = legacy_value.as_object_mut().unwrap();
legacy_object.remove("selected_analytic_faces");
legacy_object.remove("validated_faces");
legacy_object.remove("truth_extraction_failures");
legacy_object.remove("face_diagnostics");
let legacy: StepFileReport = serde_json::from_value(legacy_value).unwrap();
assert_eq!(legacy.selected_analytic_faces, 0);
assert_eq!(legacy.validated_faces, 0);
assert_eq!(legacy.truth_extraction_failures, 0);
assert!(legacy.face_diagnostics.is_empty());
let mut invalid = report.clone();
invalid.validated_faces = 1;
assert!(!invalid.coverage_invariants_hold());
let mut invalid = report;
invalid.face_diagnostics.pop();
assert!(!invalid.coverage_invariants_hold());
}
#[test]
fn ambiguity_json_and_csv_schema_are_consistent_and_backward_compatible() {
let truth = AnalyticSurface::Torus(TorusSurface {
center: Vec3::ZERO,
axis: Vec3::Z,
major_radius: 2.0,
minor_radius: 0.2,
});
let evidence = CarrierAmbiguityEvidence {
alternative_surface: AnalyticSurface::Plane(PlaneSurface {
origin: Vec3::new(0.0, 0.0, -0.2),
normal: -Vec3::Z,
}),
max_truth_error: 1.0e-15,
max_alternative_error: 2.0e-15,
max_oriented_normal_disagreement: 3.0e-9,
position_threshold: 4.0e-13,
normal_threshold: normal_threshold_for_test(),
};
let mode = ModeValidationReport {
mode: ValidationMode::KnownType,
elapsed_millis: 1.0,
error: Some("fit failed".into()),
result: None,
passed: false,
unobservable: true,
ambiguity_evidence: Some(evidence),
type_matches: false,
parameter_equivalent: false,
orientation_matches: false,
exact_parameters_reused: false,
fixed_parameters_preserved: None,
parameter_error: None,
};
let report = StepFileReport {
path: "ambiguous.step".into(),
import_error: None,
failed_imported_solids: 0,
partial_import_error: None,
solids: 1,
faces_total: 1,
analytic_faces: 1,
selected_analytic_faces: 1,
validated_faces: 1,
unsupported_analytic_faces: 0,
truth_extraction_failures: 0,
tessellation_failures: 0,
tessellation_fallbacks: 0,
tessellation_fallback_evidence: Vec::new(),
face_diagnostics: Vec::new(),
cases: vec![FaceValidationReport {
solid_index: 0,
shell_index: 0,
face_index: 0,
face_id: 7,
face_name: None,
same_sense: true,
truth,
triangles: 16,
vertices: 16,
face_scale: 1.0,
distance_tolerance: 1.0e-7,
tessellation_millis: 1.0,
modes: vec![mode.clone()],
}],
};
let value = serde_json::to_value(&report).unwrap();
let json_mode = &value["cases"][0]["modes"][0];
assert_eq!(json_mode["unobservable"], true);
assert_eq!(
json_mode["ambiguity_evidence"]["max_alternative_error"],
2.0e-15
);
let csv = reports_to_csv(&[report]);
let header: Vec<_> = csv.lines().next().unwrap().split(',').collect();
let row: Vec<_> = csv.lines().nth(1).unwrap().split(',').collect();
let column = |name| header.iter().position(|field| *field == name).unwrap();
assert_eq!(header.len(), 31);
assert_eq!(column("orientation_matches"), 10);
assert_eq!(column("ambiguity_normal_max"), 26);
assert_eq!(column("parameter_equivalent"), 27);
assert_eq!(column("tessellation_fallback"), 28);
assert_eq!(column("fallback_method"), 29);
assert_eq!(column("fallback_chord_tolerance"), 30);
assert_eq!(row[column("unobservable")], "true");
assert_eq!(row[column("parameter_equivalent")], "false");
assert_eq!(row[column("tessellation_fallback")], "false");
assert_eq!(row[column("fallback_method")], "");
assert_eq!(row[column("ambiguity_alternative")], "plane");
assert_eq!(
row[column("ambiguity_alternative_max")]
.parse::<f64>()
.unwrap(),
2.0e-15
);
assert_eq!(
row[column("ambiguity_normal_max")].parse::<f64>().unwrap(),
3.0e-9
);
let mut legacy = serde_json::to_value(mode).unwrap();
let object = legacy.as_object_mut().unwrap();
object.remove("unobservable");
object.remove("ambiguity_evidence");
object.remove("parameter_equivalent");
let legacy: ModeValidationReport = serde_json::from_value(legacy).unwrap();
assert!(
!legacy.unobservable
&& legacy.ambiguity_evidence.is_none()
&& !legacy.parameter_equivalent
);
}
#[test]
fn cone_axis_error_is_oriented_but_other_axes_are_not() {
let origin = Vec3::new(1.0, 2.0, 3.0);
let axis = Vec3::Z;
let opposite = -axis;
let cone = |axis| {
AnalyticSurface::Cone(ConeSurface {
apex: origin,
axis,
half_angle: 0.4,
})
};
assert!(
(parameter_error(cone(axis), cone(opposite)).direction_degrees - 180.0).abs() < 1e-12
);
let plane = |normal| AnalyticSurface::Plane(PlaneSurface { origin, normal });
assert!(parameter_error(plane(axis), plane(opposite)).direction_degrees < 1e-12);
let cylinder = |axis| {
AnalyticSurface::Cylinder(CylinderSurface {
axis_origin: origin,
axis,
radius: 2.0,
})
};
assert!(parameter_error(cylinder(axis), cylinder(opposite)).direction_degrees < 1e-12);
let torus = |axis| {
AnalyticSurface::Torus(TorusSurface {
center: origin,
axis,
major_radius: 3.0,
minor_radius: 1.0,
})
};
assert!(parameter_error(torus(axis), torus(opposite)).direction_degrees < 1e-12);
}
#[test]
fn initial_and_constrained_modes_cover_every_surface_type() {
let options = RecognitionOptions {
distance_tolerance: 1.0e-5,
relative_tolerance: 0.0,
normal_tolerance: 0.35,
minimum_support: 1,
max_refinement_iterations: 160,
sampling: SamplingMode::Vertices,
..RecognitionOptions::default()
};
for kind in [
SurfaceType::Plane,
SurfaceType::Sphere,
SurfaceType::Cylinder,
SurfaceType::Cone,
SurfaceType::Torus,
] {
let (truth, mesh) = synthetic::canonical(kind, 16);
let triangles: Vec<_> = (0..mesh.triangles.len()).collect();
let analyzed = mesh
.analyze(&crate::MeshAnalysisOptions::default())
.unwrap();
let scale = mesh_scale(&mesh.vertices);
let initial = run_mode(
ValidationMode::InitialGuess,
&SurfaceHint::InitialGuess {
surface: perturbed_initial(truth, scale),
trust: MetadataTrust::InitialGuess,
},
ModeRunInput {
mesh: &mesh,
analyzed: &analyzed,
triangles: &triangles,
options: &options,
truth,
expected_orientation: 1,
},
);
assert!(initial.passed, "{kind:?}: {initial:?}");
assert_eq!(
initial.result.as_ref().unwrap().diagnostics.path,
FitPath::UnconstrainedRefinement
);
let fixed = constrained_fixed_parameters(truth);
let constrained_initial =
restore_fixed_parameters(truth, perturbed_initial(truth, scale), fixed);
assert_ne!(constrained_initial, truth);
let constrained = run_mode(
ValidationMode::Constrained,
&SurfaceHint::Constrained {
surface_type: kind,
constraints: SurfaceConstraints {
initial: Some(constrained_initial),
fixed,
},
trust: MetadataTrust::StrongHint,
},
ModeRunInput {
mesh: &mesh,
analyzed: &analyzed,
triangles: &triangles,
options: &options,
truth,
expected_orientation: 1,
},
);
assert!(constrained.passed, "{kind:?}: {constrained:?}");
assert_eq!(constrained.fixed_parameters_preserved, Some(true));
assert_eq!(
constrained.result.as_ref().unwrap().diagnostics.path,
FitPath::ConstrainedRefinement
);
}
}
}