1use crate::fit::{
2 evaluate_surface, evaluate_surface_from_vertices, fit_surface_from_vertices_with_path,
3 fit_surface_with_path, selection_scale, vertex_selection_scale,
4};
5use crate::numerical::recognition as numerical;
6use crate::{
7 AnalyticSurface, AnalyzedMesh, ConstraintMask, FitPath, Mesh, MeshAnalysisOptions,
8 MetadataTrust, RecognitionError, RecognitionOptions, RecognitionResult, SurfaceFitResult,
9 SurfaceHint, SurfaceRegion, SurfaceType, UnresolvedRegionDiagnostic,
10};
11use std::cmp::Ordering;
12use std::collections::{BTreeSet, VecDeque};
13use web_time::Instant;
14
15const TYPES: [SurfaceType; 5] = [
16 SurfaceType::Plane,
17 SurfaceType::Sphere,
18 SurfaceType::Cylinder,
19 SurfaceType::Cone,
20 SurfaceType::Torus,
21];
22
23const MODEL_COMPLEXITY_PENALTY_UNIT: f64 = numerical::MODEL_COMPLEXITY_PENALTY_UNIT;
24const HYPOTHESIS_DIVERSITY_PROBE_TRIANGLES: usize = 128;
28
29#[derive(Clone, Copy, Debug)]
30struct ModelRank {
31 numerically_exact: bool,
32 score: f64,
33}
34
35fn compare_model_rank(
36 left: ModelRank,
37 left_kind: SurfaceType,
38 right: ModelRank,
39 right_kind: SurfaceType,
40) -> Ordering {
41 right
42 .numerically_exact
43 .cmp(&left.numerically_exact)
44 .then_with(|| left.score.total_cmp(&right.score))
45 .then(left_kind.cmp(&right_kind))
46}
47
48pub fn reconstruct_surface(
54 mesh: &Mesh,
55 triangle_indices: &[usize],
56 hint: &SurfaceHint,
57 options: &RecognitionOptions,
58) -> Result<SurfaceFitResult, RecognitionError> {
59 options.validate()?;
60 let analyzed = mesh.analyze(&MeshAnalysisOptions {
61 feature_angle: options.feature_angle,
62 ..Default::default()
63 })?;
64 analyzed.validate_selection(triangle_indices)?;
65 reconstruct_analyzed(&analyzed, triangle_indices, hint, options)
66}
67
68pub fn reconstruct_surface_from_vertices(
76 mesh: &Mesh,
77 vertex_indices: &[usize],
78 hint: &SurfaceHint,
79 options: &RecognitionOptions,
80) -> Result<SurfaceFitResult, RecognitionError> {
81 options.validate()?;
82 let analyzed = mesh.analyze(&MeshAnalysisOptions {
83 feature_angle: options.feature_angle,
84 ..Default::default()
85 })?;
86 analyzed.validate_vertex_selection(vertex_indices)?;
87 reconstruct_selected(
88 &analyzed,
89 Selection::Vertices(vertex_indices),
90 hint,
91 options,
92 )
93}
94
95#[derive(Clone, Copy)]
96enum Selection<'a> {
97 Triangles(&'a [usize]),
98 Vertices(&'a [usize]),
99}
100
101impl Selection<'_> {
102 fn scale(self, mesh: &AnalyzedMesh) -> f64 {
103 match self {
104 Self::Triangles(ids) => selection_scale(mesh, ids),
105 Self::Vertices(ids) => vertex_selection_scale(mesh, ids),
106 }
107 }
108
109 fn fit(
110 self,
111 mesh: &AnalyzedMesh,
112 kind: SurfaceType,
113 initial: Option<AnalyticSurface>,
114 fixed: ConstraintMask,
115 options: &RecognitionOptions,
116 path: FitPath,
117 ) -> Result<SurfaceFitResult, RecognitionError> {
118 match self {
119 Self::Triangles(ids) => {
120 fit_surface_with_path(mesh, ids, kind, initial, fixed, options, path)
121 }
122 Self::Vertices(ids) => {
123 fit_surface_from_vertices_with_path(mesh, ids, kind, initial, fixed, options, path)
124 }
125 }
126 }
127
128 fn evaluate(
129 self,
130 mesh: &AnalyzedMesh,
131 surface: AnalyticSurface,
132 options: &RecognitionOptions,
133 path: FitPath,
134 ) -> Result<SurfaceFitResult, RecognitionError> {
135 match self {
136 Self::Triangles(ids) => evaluate_surface(mesh, ids, surface, options, path),
137 Self::Vertices(ids) => {
138 evaluate_surface_from_vertices(mesh, ids, surface, options, path)
139 }
140 }
141 }
142
143 fn accepted(self, model: &SurfaceFitResult, options: &RecognitionOptions, scale: f64) -> bool {
144 accepted_impl(
145 model,
146 options,
147 scale,
148 matches!(self, Self::Vertices(_))
149 || matches!(options.sampling, crate::SamplingMode::Vertices),
150 )
151 }
152}
153
154pub fn recognize_surfaces(
160 mesh: &Mesh,
161 options: &RecognitionOptions,
162) -> Result<Vec<SurfaceRegion>, RecognitionError> {
163 Ok(recognize_surfaces_with_unresolved(mesh, options)?.regions)
164}
165
166pub fn recognize_surfaces_with_unresolved(
172 mesh: &Mesh,
173 options: &RecognitionOptions,
174) -> Result<RecognitionResult, RecognitionError> {
175 options.validate()?;
176 let analyzed = mesh.analyze(&MeshAnalysisOptions {
177 feature_angle: options.feature_angle,
178 ..Default::default()
179 })?;
180 let mut assigned = vec![false; analyzed.triangles.len()];
181 let mut regions = Vec::new();
182 let mut metadata_failures = Vec::new();
183 for metadata in &analyzed.source_metadata {
185 let ids: Vec<_> = metadata
186 .triangle_indices
187 .iter()
188 .copied()
189 .filter(|&i| !assigned[i] && analyzed.triangles[i].area > 0.0)
190 .collect();
191 if ids.is_empty() {
192 continue;
193 }
194 let mut metadata_options = options.clone();
195 if let Some(source_tolerance) = metadata.source_tolerance {
196 metadata_options.distance_tolerance =
197 metadata_options.distance_tolerance.max(source_tolerance);
198 }
199 match reconstruct_analyzed(&analyzed, &ids, &metadata.hint, &metadata_options) {
200 Ok(mut fit)
201 if metadata
202 .orientation
203 .is_none_or(|orientation| orientation == fit.orientation) =>
204 {
205 if let Some(orientation) = metadata.orientation {
206 fit.orientation = orientation;
207 }
208 if metadata.source_tolerance.is_some() || metadata.orientation.is_some() {
209 fit.diagnostics.reason.push_str(
210 "; source orientation and tolerance metadata validated when supplied",
211 );
212 }
213 for &i in &ids {
214 assigned[i] = true;
215 }
216 regions.push(to_region(fit, ids));
217 }
218 Ok(fit) => metadata_failures.push((
219 metadata.clone(),
220 ids,
221 format!(
222 "supplied metadata orientation {:?} disagrees with reconstructed orientation {}",
223 metadata.orientation, fit.orientation
224 ),
225 )),
226 Err(error) => metadata_failures.push((metadata.clone(), ids, error.to_string())),
227 }
228 }
229 let remaining: Vec<_> = analyzed
230 .all_non_degenerate()
231 .into_iter()
232 .filter(|&i| !assigned[i])
233 .collect();
234 let components = if options.discover_regions {
235 analyzed.connected_components(&remaining, options.respect_features)
236 } else {
237 vec![remaining]
238 };
239 for component in components {
240 extract_component(&analyzed, &component, options, &mut regions);
241 }
242 if options.allow_disconnected_same_surface {
243 merge_disconnected_regions(&analyzed, options, &mut regions);
244 }
245 regions.sort_by_key(|region| {
246 region
247 .triangle_indices
248 .first()
249 .copied()
250 .unwrap_or(usize::MAX)
251 });
252 let mut covered = vec![false; analyzed.triangles.len()];
253 for region in ®ions {
254 for &id in ®ion.triangle_indices {
255 covered[id] = true;
256 }
257 }
258 let unresolved_triangles = analyzed
259 .all_non_degenerate()
260 .into_iter()
261 .filter(|&id| !covered[id])
262 .collect::<Vec<_>>();
263 let unresolved_set: BTreeSet<_> = unresolved_triangles.iter().copied().collect();
264 let mut unresolved_diagnostics = metadata_failures
265 .into_iter()
266 .filter_map(|(metadata, ids, metadata_error)| {
267 let mut triangle_indices: Vec<_> = ids
268 .into_iter()
269 .filter(|id| unresolved_set.contains(id))
270 .collect();
271 triangle_indices.sort_unstable();
272 triangle_indices.dedup();
273 (!triangle_indices.is_empty()).then(|| UnresolvedRegionDiagnostic {
274 triangle_indices,
275 source_face_id: metadata.source_face_id,
276 source_face_name: metadata.source_face_name,
277 source_surface_id: metadata.source_surface_id,
278 reason: format!(
279 "source metadata validation or reconstruction failed ({metadata_error}); generic analytic fallback also left this subset unresolved"
280 ),
281 })
282 })
283 .collect::<Vec<_>>();
284 unresolved_diagnostics.sort_by(|left, right| {
285 left.triangle_indices
286 .cmp(&right.triangle_indices)
287 .then_with(|| left.source_face_id.cmp(&right.source_face_id))
288 .then_with(|| left.source_face_name.cmp(&right.source_face_name))
289 .then_with(|| left.source_surface_id.cmp(&right.source_surface_id))
290 .then_with(|| left.reason.cmp(&right.reason))
291 });
292 Ok(RecognitionResult {
293 regions,
294 unresolved_triangles,
295 unresolved_diagnostics,
296 })
297}
298
299#[doc(hidden)]
304pub fn reconstruct_analyzed(
305 mesh: &AnalyzedMesh,
306 ids: &[usize],
307 hint: &SurfaceHint,
308 options: &RecognitionOptions,
309) -> Result<SurfaceFitResult, RecognitionError> {
310 options.validate()?;
314 mesh.validate_selection(ids)?;
315 reconstruct_selected(mesh, Selection::Triangles(ids), hint, options)
316}
317
318fn reconstruct_selected(
319 mesh: &AnalyzedMesh,
320 selection: Selection<'_>,
321 hint: &SurfaceHint,
322 options: &RecognitionOptions,
323) -> Result<SurfaceFitResult, RecognitionError> {
324 let scale = selection.scale(mesh);
325 let metadata_started = options.collect_phase_timings.then(Instant::now);
326 let metadata_trust = match hint {
327 SurfaceHint::Unknown => MetadataTrust::Unknown,
328 SurfaceHint::KnownType { .. } => MetadataTrust::TypeOnly,
329 SurfaceHint::InitialGuess { trust, .. } | SurfaceHint::Constrained { trust, .. } => *trust,
330 SurfaceHint::ExactCandidate { .. } => MetadataTrust::Exact,
331 };
332 let metadata_seconds = metadata_started.map(|started| started.elapsed().as_secs_f64());
333 let result = match hint {
334 SurfaceHint::Unknown => best_model(
335 mesh,
336 selection,
337 scale,
338 options,
339 FitPath::GenericRecognition,
340 None,
341 ConstraintMask::default(),
342 ),
343 SurfaceHint::KnownType { surface_type } => fit_result(
344 mesh,
345 selection,
346 *surface_type,
347 None,
348 ConstraintMask::default(),
349 scale,
350 options,
351 FitPath::KnownTypeFit,
352 true,
353 None,
354 ),
355 SurfaceHint::InitialGuess { surface, trust } => {
356 if matches!(
357 trust,
358 crate::MetadataTrust::Exact | crate::MetadataTrust::StrongHint
359 ) {
360 let mut valid = selection.evaluate(mesh, *surface, options, FitPath::HintReused)?;
361 if selection.accepted(&valid, options, scale) {
362 valid.diagnostics.reason = "supplied parameters validated".into();
363 valid.diagnostics.metadata_trust = metadata_trust;
364 valid.diagnostics.phase_timings.metadata_inspection_seconds = metadata_seconds;
365 return Ok(valid);
366 }
367 }
368 fit_result(
369 mesh,
370 selection,
371 surface.surface_type(),
372 Some(*surface),
373 ConstraintMask::default(),
374 scale,
375 options,
376 FitPath::UnconstrainedRefinement,
377 true,
378 Some(*surface),
379 )
380 }
381 SurfaceHint::Constrained {
382 surface_type,
383 constraints,
384 ..
385 } => {
386 if constraints.initial.is_none() && constraints.fixed != ConstraintMask::default() {
387 return Err(RecognitionError::InvalidSelection(
388 "fixed constraints require initial parameter values".into(),
389 ));
390 }
391 fit_result(
392 mesh,
393 selection,
394 *surface_type,
395 constraints.initial,
396 constraints.fixed,
397 scale,
398 options,
399 FitPath::ConstrainedRefinement,
400 true,
401 constraints.initial,
402 )
403 }
404 SurfaceHint::ExactCandidate { surface } => {
405 let mut valid =
406 selection.evaluate(mesh, *surface, options, FitPath::ExactCandidateReused)?;
407 if selection.accepted(&valid, options, scale) {
408 valid.diagnostics.fixed_parameters = ConstraintMask {
409 origin_or_center: true,
410 axis_or_normal: true,
411 radius: true,
412 major_radius: true,
413 angle: true,
414 };
415 valid.diagnostics.reason =
416 "exact source parameters validated and were reused unchanged".into();
417 valid.diagnostics.metadata_trust = metadata_trust;
418 valid.diagnostics.phase_timings.metadata_inspection_seconds = metadata_seconds;
419 return Ok(valid);
420 }
421 fit_result(
422 mesh,
423 selection,
424 surface.surface_type(),
425 Some(*surface),
426 ConstraintMask::default(),
427 scale,
428 options,
429 FitPath::HintRejectedFallback,
430 true,
431 Some(*surface),
432 )
433 .or_else(|_| {
434 best_model(
435 mesh,
436 selection,
437 scale,
438 options,
439 FitPath::HintRejectedFallback,
440 Some(*surface),
441 ConstraintMask::default(),
442 )
443 })
444 }
445 }?;
446 Ok(with_metadata_diagnostics(
447 result,
448 metadata_trust,
449 metadata_seconds,
450 ))
451}
452
453fn with_metadata_diagnostics(
454 mut result: SurfaceFitResult,
455 trust: MetadataTrust,
456 elapsed_seconds: Option<f64>,
457) -> SurfaceFitResult {
458 result.diagnostics.metadata_trust = trust;
459 result.diagnostics.phase_timings.metadata_inspection_seconds = elapsed_seconds;
460 result
461}
462
463#[allow(clippy::too_many_arguments)]
464fn fit_result(
465 mesh: &AnalyzedMesh,
466 selection: Selection<'_>,
467 kind: SurfaceType,
468 initial: Option<AnalyticSurface>,
469 fixed: ConstraintMask,
470 scale: f64,
471 options: &RecognitionOptions,
472 path: FitPath,
473 skipped: bool,
474 supplied: Option<AnalyticSurface>,
475) -> Result<SurfaceFitResult, RecognitionError> {
476 let mut fitted = selection.fit(mesh, kind, initial, fixed, options, path)?;
477 let evaluation_started = options.collect_phase_timings.then(Instant::now);
478 if !selection.accepted(&fitted, options, scale) {
479 return Err(RecognitionError::FitFailed {
480 surface: Some(kind.name()),
481 reason: format!(
482 "residuals exceed tolerance (max {:.3e}, normal {:.3e} rad)",
483 fitted.metrics.max_error, fitted.metrics.max_normal_error
484 ),
485 });
486 }
487 fitted.diagnostics.generic_classification_skipped = skipped;
488 fitted.diagnostics.supplied_surface = supplied;
489 fitted.diagnostics.reason = "requested model fitted and validated".into();
490 fitted
491 .diagnostics
492 .phase_timings
493 .candidate_evaluation_seconds =
494 evaluation_started.map(|started| started.elapsed().as_secs_f64());
495 Ok(fitted)
496}
497
498fn best_model(
499 mesh: &AnalyzedMesh,
500 selection: Selection<'_>,
501 scale: f64,
502 options: &RecognitionOptions,
503 path: FitPath,
504 supplied: Option<AnalyticSurface>,
505 fixed: ConstraintMask,
506) -> Result<SurfaceFitResult, RecognitionError> {
507 best_model_impl(mesh, selection, scale, options, path, supplied, fixed, true)
508}
509
510#[allow(clippy::too_many_arguments)]
511fn best_model_impl(
512 mesh: &AnalyzedMesh,
513 selection: Selection<'_>,
514 scale: f64,
515 options: &RecognitionOptions,
516 path: FitPath,
517 supplied: Option<AnalyticSurface>,
518 fixed: ConstraintMask,
519 use_score_bounds: bool,
520) -> Result<SurfaceFitResult, RecognitionError> {
521 let evaluation_started = options.collect_phase_timings.then(Instant::now);
522 let tolerance = options.distance_tolerance + options.relative_tolerance * scale.max(1.0);
523 let mut candidates = Vec::new();
524 let mut rejected = Vec::new();
525 for kind in TYPES {
526 match selection.fit(mesh, kind, None, fixed, options, path) {
527 Ok(model) if selection.accepted(&model, options, scale) => {
528 let rank = model_rank(
529 mesh,
530 selection,
531 kind,
532 &model,
533 tolerance,
534 options.normal_tolerance,
535 );
536 candidates.push((rank, kind, model));
537 }
538 Ok(model) => rejected.push((
539 kind,
540 format!(
541 "max distance {:.3e}, max normal {:.3e}",
542 model.metrics.max_error, model.metrics.max_normal_error
543 ),
544 )),
545 Err(e) => rejected.push((kind, e.to_string())),
546 }
547 if use_score_bounds {
548 if let Some(bound) = unseen_model_score_lower_bound(kind) {
549 if let Some((best_index, rank, best_kind)) = candidates
550 .iter()
551 .enumerate()
552 .min_by(|(_, a), (_, b)| compare_model_rank(a.0, a.1, b.0, b.1))
553 .map(|(index, candidate)| (index, candidate.0, candidate.1))
554 {
555 if rank.numerically_exact && rank.score < bound {
561 let (_, _, model) = candidates.remove(best_index);
562 record_tolerance_valid_losers(&mut rejected, candidates, best_kind, rank);
563 rejected.sort_by_key(|(candidate_kind, _)| *candidate_kind);
564 let reason = format!(
565 "{} selected after {} candidate(s) from a score below every unseen model's proven lower bound",
566 best_kind.name(),
567 kind as usize + 1,
568 );
569 return Ok(decorate_best_model(
570 model,
571 supplied,
572 fixed,
573 kind as usize + 1,
574 rejected,
575 &reason,
576 evaluation_started.as_ref(),
577 ));
578 }
579 }
580 }
581 }
582 }
583 let valid_sphere = candidates
591 .iter()
592 .any(|(_, kind, _)| *kind == SurfaceType::Sphere);
593 if valid_sphere {
594 let mut retained = Vec::with_capacity(candidates.len());
595 for (rank, kind, model) in candidates {
596 let sphere_limit_major_radius = match &model.surface {
597 AnalyticSurface::Torus(torus) if torus.major_radius <= tolerance => {
598 Some(torus.major_radius)
599 }
600 _ => None,
601 };
602 if kind == SurfaceType::Torus {
603 if let Some(major_radius) = sphere_limit_major_radius {
604 rejected.push((
605 kind,
606 format!(
607 "tolerance-valid candidate excluded as a non-identifiable sphere-limit torus: score {:.6e}, numerically_exact={}, major radius {major_radius:.3e} <= spatial tolerance {tolerance:.3e}; rms distance {:.3e}, max distance {:.3e}, rms normal {:.3e}, max normal {:.3e}",
608 rank.score,
609 rank.numerically_exact,
610 model.metrics.rms_error,
611 model.metrics.max_error,
612 model.metrics.rms_normal_error,
613 model.metrics.max_normal_error,
614 ),
615 ));
616 continue;
617 }
618 }
619 retained.push((rank, kind, model));
620 }
621 candidates = retained;
622 }
623 if let Some((cylinder_rank, _, cylinder)) = candidates
634 .iter()
635 .find(|(_, kind, _)| *kind == SurfaceType::Cylinder)
636 .cloned()
637 {
638 let mut retained = Vec::with_capacity(candidates.len());
639 for (rank, kind, model) in candidates {
640 if kind == SurfaceType::Torus
641 && torus_is_non_identifiable_cylinder_limit(
642 rank,
643 &model.metrics,
644 cylinder_rank,
645 &cylinder.metrics,
646 tolerance,
647 )
648 {
649 let rms_gain = (cylinder.metrics.rms_error - model.metrics.rms_error).max(0.0);
650 rejected.push((
651 kind,
652 format!(
653 "tolerance-valid candidate excluded as a non-identifiable cylinder-limit torus: score {:.6e}, numerically_exact={}, RMS-position gain {rms_gain:.3e} <= {:.3e} (1% of spatial tolerance), torus RMS normal {:.3e} >= cylinder RMS normal {:.3e}; rms distance {:.3e}, max distance {:.3e}, max normal {:.3e}",
654 rank.score,
655 rank.numerically_exact,
656 tolerance * numerical::TORUS_CYLINDER_LIMIT_RMS_GAIN_FRACTION,
657 model.metrics.rms_normal_error,
658 cylinder.metrics.rms_normal_error,
659 model.metrics.rms_error,
660 model.metrics.max_error,
661 model.metrics.max_normal_error,
662 ),
663 ));
664 continue;
665 }
666 retained.push((rank, kind, model));
667 }
668 candidates = retained;
669 }
670 if let (Some((torus_rank, _, torus)), Some((cylinder_rank, _, cylinder))) = (
678 candidates
679 .iter()
680 .find(|(_, kind, _)| *kind == SurfaceType::Torus)
681 .cloned(),
682 candidates
683 .iter()
684 .find(|(_, kind, _)| *kind == SurfaceType::Cylinder)
685 .cloned(),
686 ) {
687 let carrier_normal_disagreement = max_oriented_carrier_normal_disagreement(
688 mesh,
689 selection,
690 options.sampling,
691 &torus,
692 &cylinder,
693 );
694 if torus_observably_dominates_cylinder(
695 torus_rank,
696 &torus.metrics,
697 cylinder_rank,
698 &cylinder.metrics,
699 tolerance,
700 carrier_normal_disagreement,
701 ) {
702 let rms_position_gain = cylinder.metrics.rms_error - torus.metrics.rms_error;
703 candidates.retain(|(_, kind, _)| *kind != SurfaceType::Cylinder);
704 rejected.push((
705 SurfaceType::Cylinder,
706 format!(
707 "tolerance-valid cylinder excluded because the torus observably resolves second curvature: RMS-position gain {rms_position_gain:.3e} (threshold {:.3e}), oriented carrier-normal disagreement {:.3e} (threshold {:.3e}); torus RMS/max distance {:.3e}/{:.3e} versus cylinder {:.3e}/{:.3e}; torus RMS/max normal {:.3e}/{:.3e} versus cylinder {:.3e}/{:.3e}",
708 tolerance * numerical::TORUS_CYLINDER_LIMIT_RMS_GAIN_FRACTION,
709 carrier_normal_disagreement.unwrap_or(0.0),
710 numerical::EXACT_MODEL_NORMAL_ROUNDOFF_RADIANS,
711 torus.metrics.rms_error,
712 torus.metrics.max_error,
713 cylinder.metrics.rms_error,
714 cylinder.metrics.max_error,
715 torus.metrics.rms_normal_error,
716 torus.metrics.max_normal_error,
717 cylinder.metrics.rms_normal_error,
718 cylinder.metrics.max_normal_error,
719 ),
720 ));
721 }
722 }
723 candidates.sort_by(|a, b| compare_model_rank(a.0, a.1, b.0, b.1));
724 if candidates.is_empty() {
725 return Err(RecognitionError::FitFailed {
726 surface: None,
727 reason: "no analytic model passed distance, normal, and support gates".into(),
728 });
729 }
730 let (selected_rank, selected_kind, model) = candidates.remove(0);
731 record_tolerance_valid_losers(&mut rejected, candidates, selected_kind, selected_rank);
732 rejected.sort_by_key(|(candidate_kind, _)| *candidate_kind);
733 Ok(decorate_best_model(
734 model,
735 supplied,
736 fixed,
737 TYPES.len(),
738 rejected,
739 "best tolerance-valid model selected by numerical-exactness tier, residual score, and simplicity penalty",
740 evaluation_started.as_ref(),
741 ))
742}
743
744fn torus_observably_dominates_cylinder(
745 torus_rank: ModelRank,
746 torus: &crate::FitMetrics,
747 cylinder_rank: ModelRank,
748 cylinder: &crate::FitMetrics,
749 spatial_tolerance: f64,
750 carrier_normal_disagreement: Option<f64>,
751) -> bool {
752 !torus_rank.numerically_exact
753 && !cylinder_rank.numerically_exact
754 && torus.rms_error <= cylinder.rms_error
755 && torus.max_error <= cylinder.max_error
756 && torus.rms_normal_error <= cylinder.rms_normal_error
757 && torus.max_normal_error <= cylinder.max_normal_error
758 && ((cylinder.rms_error - torus.rms_error)
759 > spatial_tolerance * numerical::TORUS_CYLINDER_LIMIT_RMS_GAIN_FRACTION
760 || carrier_normal_disagreement.is_some_and(|disagreement| {
761 disagreement > numerical::EXACT_MODEL_NORMAL_ROUNDOFF_RADIANS
762 && torus.rms_normal_error < cylinder.rms_normal_error
763 }))
764}
765
766fn max_oriented_carrier_normal_disagreement(
767 mesh: &AnalyzedMesh,
768 selection: Selection<'_>,
769 sampling: crate::SamplingMode,
770 left: &SurfaceFitResult,
771 right: &SurfaceFitResult,
772) -> Option<f64> {
773 let mut max_disagreement = 0.0_f64;
774 let mut samples = 0_usize;
775 let mut measure = |point| -> Option<()> {
776 let left_normal = left.surface.normal_at(point)? * left.orientation as f64;
777 let right_normal = right.surface.normal_at(point)? * right.orientation as f64;
778 if !left_normal.is_finite() || !right_normal.is_finite() {
779 return None;
780 }
781 let disagreement = left_normal.dot(right_normal).clamp(-1.0, 1.0).acos();
782 if !disagreement.is_finite() {
783 return None;
784 }
785 max_disagreement = max_disagreement.max(disagreement);
786 samples += 1;
787 Some(())
788 };
789
790 match selection {
791 Selection::Triangles(ids) => {
792 if matches!(
793 sampling,
794 crate::SamplingMode::TriangleCentroids | crate::SamplingMode::CentroidsAndVertices
795 ) {
796 for &id in ids {
797 if mesh.triangles[id].area > 0.0 {
798 measure(mesh.triangles[id].centroid)?;
799 }
800 }
801 }
802 if matches!(
803 sampling,
804 crate::SamplingMode::Vertices | crate::SamplingMode::CentroidsAndVertices
805 ) {
806 let vertices: BTreeSet<_> = ids
807 .iter()
808 .filter(|&&id| mesh.triangles[id].area > 0.0)
809 .flat_map(|&id| mesh.triangles[id].vertices)
810 .collect();
811 for vertex in vertices {
812 measure(mesh.vertices[vertex])?;
813 }
814 }
815 }
816 Selection::Vertices(vertices) => {
817 for &vertex in vertices {
818 measure(mesh.vertices[vertex])?;
819 }
820 }
821 }
822 (samples > 0).then_some(max_disagreement)
823}
824
825fn torus_is_non_identifiable_cylinder_limit(
826 torus_rank: ModelRank,
827 torus: &crate::FitMetrics,
828 cylinder_rank: ModelRank,
829 cylinder: &crate::FitMetrics,
830 spatial_tolerance: f64,
831) -> bool {
832 (!torus_rank.numerically_exact || cylinder_rank.numerically_exact)
833 && torus.rms_normal_error >= cylinder.rms_normal_error
834 && (cylinder.rms_error - torus.rms_error).max(0.0)
835 <= spatial_tolerance * numerical::TORUS_CYLINDER_LIMIT_RMS_GAIN_FRACTION
836}
837
838fn record_tolerance_valid_losers(
839 rejected: &mut Vec<(SurfaceType, String)>,
840 mut candidates: Vec<(ModelRank, SurfaceType, SurfaceFitResult)>,
841 selected_kind: SurfaceType,
842 selected_rank: ModelRank,
843) {
844 candidates.sort_by_key(|(_, kind, _)| *kind);
845 rejected.extend(candidates.into_iter().map(|(rank, kind, model)| {
846 (
847 kind,
848 format!(
849 "tolerance-valid candidate not selected: score {:.6e}, numerically_exact={} versus selected {} score {:.6e}, numerically_exact={}; rms distance {:.3e}, max distance {:.3e}, rms normal {:.3e}, max normal {:.3e}",
850 rank.score,
851 rank.numerically_exact,
852 selected_kind.name(),
853 selected_rank.score,
854 selected_rank.numerically_exact,
855 model.metrics.rms_error,
856 model.metrics.max_error,
857 model.metrics.rms_normal_error,
858 model.metrics.max_normal_error,
859 ),
860 )
861 }));
862}
863
864fn unseen_model_score_lower_bound(after: SurfaceType) -> Option<f64> {
867 let degrees = match after {
868 SurfaceType::Plane => 4.0,
869 SurfaceType::Sphere => 5.0,
870 SurfaceType::Cylinder => 6.0,
871 SurfaceType::Cone => 8.0,
872 SurfaceType::Torus => return None,
873 };
874 Some(degrees * MODEL_COMPLEXITY_PENALTY_UNIT)
875}
876
877fn decorate_best_model(
878 mut answer: SurfaceFitResult,
879 supplied: Option<AnalyticSurface>,
880 fixed: ConstraintMask,
881 candidates_evaluated: usize,
882 rejected_competitors: Vec<(SurfaceType, String)>,
883 reason: &str,
884 evaluation_started: Option<&Instant>,
885) -> SurfaceFitResult {
886 answer.diagnostics.supplied_surface = supplied;
887 answer.diagnostics.fixed_parameters = fixed;
888 answer.diagnostics.generic_classification_skipped = false;
889 answer.diagnostics.reason = reason.into();
890 answer.diagnostics.candidates_evaluated = candidates_evaluated;
891 answer.diagnostics.rejected_competitors = rejected_competitors;
892 answer
893 .diagnostics
894 .phase_timings
895 .candidate_evaluation_seconds =
896 evaluation_started.map(|started| started.elapsed().as_secs_f64());
897 answer
898}
899
900fn model_rank(
901 mesh: &AnalyzedMesh,
902 selection: Selection<'_>,
903 kind: SurfaceType,
904 model: &SurfaceFitResult,
905 distance_tolerance: f64,
906 normal_tolerance: f64,
907) -> ModelRank {
908 let vertices: BTreeSet<_> = match selection {
909 Selection::Triangles(ids) => ids
910 .iter()
911 .flat_map(|&id| mesh.triangles[id].vertices)
912 .collect(),
913 Selection::Vertices(ids) => ids.iter().copied().collect(),
914 };
915 let coordinate_scale = vertices
916 .into_iter()
917 .map(|vertex| {
918 let point = mesh.vertices[vertex];
919 point.x.abs().max(point.y.abs()).max(point.z.abs())
920 })
921 .fold(1.0_f64, f64::max);
922 let position_roundoff =
923 numerical::EXACT_VERTEX_ROUNDOFF_MULTIPLIER * f64::EPSILON * coordinate_scale;
924 ModelRank {
925 numerically_exact: model.metrics.max_error <= position_roundoff
926 && model.metrics.max_normal_error <= numerical::EXACT_MODEL_NORMAL_ROUNDOFF_RADIANS,
927 score: model_selection_score(kind, &model.metrics, distance_tolerance, normal_tolerance),
928 }
929}
930
931fn model_selection_score(
932 kind: SurfaceType,
933 metrics: &crate::FitMetrics,
934 distance_tolerance: f64,
935 normal_tolerance: f64,
936) -> f64 {
937 let complexity = match kind {
938 SurfaceType::Plane => 3.,
939 SurfaceType::Sphere => 4.,
940 SurfaceType::Cylinder => 5.,
941 SurfaceType::Cone => 6.,
942 SurfaceType::Torus => 8.,
943 };
944 metrics.rms_error / distance_tolerance.max(numerical::SCORE_DISTANCE_DENOMINATOR_FLOOR)
950 + metrics.rms_normal_error / normal_tolerance.max(numerical::SCORE_NORMAL_DENOMINATOR_FLOOR)
951 + complexity * MODEL_COMPLEXITY_PENALTY_UNIT
952}
953
954fn accepted_impl(
957 model: &SurfaceFitResult,
958 options: &RecognitionOptions,
959 scale: f64,
960 vertex_samples_only: bool,
961) -> bool {
962 let distance = options.distance_tolerance + options.relative_tolerance * scale.max(1.0);
963 let numerical_position =
973 numerical::EXACT_VERTEX_ROUNDOFF_MULTIPLIER * f64::EPSILON * scale.max(1.0);
974 let exact_vertices_with_coherent_sense = vertex_samples_only
975 && model.metrics.max_error <= numerical_position
976 && model.metrics.rms_normal_error < std::f64::consts::FRAC_PI_2;
977 model.metrics.max_error <= distance
978 && (model.metrics.rms_normal_error <= options.normal_tolerance
982 || exact_vertices_with_coherent_sense)
983 && model.metrics.support_triangles >= options.minimum_support
984 && model.metrics.supported_area >= options.minimum_support_area
985}
986fn to_region(fit: SurfaceFitResult, ids: Vec<usize>) -> SurfaceRegion {
987 SurfaceRegion {
988 surface: fit.surface,
989 orientation: fit.orientation,
990 triangle_indices: ids,
991 metrics: fit.metrics,
992 confidence: fit.confidence,
993 diagnostics: fit.diagnostics,
994 }
995}
996
997fn merge_disconnected_regions(
998 mesh: &AnalyzedMesh,
999 options: &RecognitionOptions,
1000 regions: &mut Vec<SurfaceRegion>,
1001) {
1002 let mut left = 0;
1003 while left < regions.len() {
1004 let mut right = left + 1;
1005 while right < regions.len() {
1006 if regions[left].surface.surface_type() != regions[right].surface.surface_type()
1007 || regions[left].orientation != regions[right].orientation
1008 {
1009 right += 1;
1010 continue;
1011 }
1012 let mut ids = regions[left].triangle_indices.clone();
1013 ids.extend_from_slice(®ions[right].triangle_indices);
1014 ids.sort_unstable();
1015 let hint = SurfaceHint::InitialGuess {
1016 surface: regions[left].surface,
1017 trust: crate::MetadataTrust::InitialGuess,
1018 };
1019 let Ok(mut fit) = reconstruct_analyzed(mesh, &ids, &hint, options) else {
1020 right += 1;
1021 continue;
1022 };
1023 if fit.orientation != regions[left].orientation {
1024 right += 1;
1025 continue;
1026 }
1027 fit.diagnostics
1028 .reason
1029 .push_str("; disconnected supports were jointly refitted and merged by request");
1030 regions[left] = to_region(fit, ids);
1031 regions.remove(right);
1032 right = left + 1;
1034 }
1035 left += 1;
1036 }
1037}
1038
1039fn extract_component(
1040 mesh: &AnalyzedMesh,
1041 component: &[usize],
1042 options: &RecognitionOptions,
1043 out: &mut Vec<SurfaceRegion>,
1044) {
1045 if component.len() < options.minimum_support {
1046 return;
1047 }
1048 if let Ok(fit) = reconstruct_analyzed(mesh, component, &SurfaceHint::Unknown, options) {
1049 out.push(to_region(fit, component.to_vec()));
1050 return;
1051 }
1052 let mut remaining: BTreeSet<usize> = component.iter().copied().collect();
1056 let mut rng = SplitMix(options.deterministic_seed.unwrap_or(0x52414e534143));
1057 while remaining.len() >= options.minimum_support {
1058 let ids: Vec<_> = remaining.iter().copied().collect();
1059 let seed_orders: Vec<_> = TYPES
1064 .iter()
1065 .map(|_| shuffled_seed_order(&ids, &mut rng))
1066 .collect();
1067 let mut best: Option<(usize, ModelRank, SurfaceFitResult, Vec<usize>)> = None;
1068 let attempt_cap = options.max_hypotheses.min(ids.len() * TYPES.len());
1069 let mut attempts_required = attempt_cap;
1070 let mut attempt = 0;
1071 let mut region_growth_seconds = options.collect_phase_timings.then_some(0.0);
1072 while attempt < attempts_required {
1073 let attempt_index = attempt;
1074 attempt += 1;
1075 let kind_index = attempt_index % TYPES.len();
1076 let round = attempt_index / TYPES.len();
1077 let seed = seed_orders[kind_index][round];
1078 let kind = TYPES[kind_index];
1079 let sample_size = hypothesis_sample_triangles(kind);
1087 let mut probe_limit = HYPOTHESIS_DIVERSITY_PROBE_TRIANGLES
1088 .max(sample_size)
1089 .min(remaining.len());
1090 let mut probe_best: Option<(usize, ModelRank, SurfaceFitResult, Vec<usize>)> = None;
1091 loop {
1092 let probe = neighborhood(mesh, seed, &remaining, probe_limit);
1093 if probe.len() < sample_size {
1094 break;
1095 }
1096 let sample = diverse_hypothesis_sample(mesh, seed, &probe, sample_size);
1097 let mut grew_beyond_probe = false;
1098 let mut reconstructed_probe = false;
1099 if let Ok(candidate) = fit_surface_with_path(
1100 mesh,
1101 &sample,
1102 kind,
1103 None,
1104 ConstraintMask::default(),
1105 options,
1106 FitPath::GenericRecognition,
1107 ) {
1108 let growth_started = options.collect_phase_timings.then(Instant::now);
1109 let support = support_component(mesh, &ids, seed, candidate.surface, options);
1110 if let (Some(total), Some(started)) =
1111 (&mut region_growth_seconds, growth_started)
1112 {
1113 *total += started.elapsed().as_secs_f64();
1114 }
1115 grew_beyond_probe = support.len() > probe.len();
1116 if support.len() >= options.minimum_support {
1117 if let Ok(fit) = reconstruct_analyzed(
1118 mesh,
1119 &support,
1120 &SurfaceHint::InitialGuess {
1121 surface: candidate.surface,
1122 trust: crate::MetadataTrust::InitialGuess,
1123 },
1124 options,
1125 ) {
1126 reconstructed_probe = true;
1127 let area = fit.metrics.supported_area;
1128 let support_scale = selection_scale(mesh, &support);
1129 let support_tolerance = options.distance_tolerance
1130 + options.relative_tolerance * support_scale.max(1.0);
1131 let rank = model_rank(
1132 mesh,
1133 Selection::Triangles(&support),
1134 fit.surface.surface_type(),
1135 &fit,
1136 support_tolerance,
1137 options.normal_tolerance,
1138 );
1139 let replace =
1140 probe_best.as_ref().is_none_or(|(count, old_rank, old, _)| {
1141 support.len() > *count
1142 || (support.len() == *count
1143 && (area > old.metrics.supported_area
1144 || (area == old.metrics.supported_area
1145 && compare_model_rank(
1146 rank,
1147 fit.surface.surface_type(),
1148 *old_rank,
1149 old.surface.surface_type(),
1150 ) == Ordering::Less)))
1151 });
1152 if replace {
1153 probe_best = Some((support.len(), rank, fit, support));
1154 }
1155 }
1156 }
1157 }
1158 let exhausted = probe.len() == remaining.len() || probe.len() < probe_limit;
1159 if (grew_beyond_probe && reconstructed_probe) || exhausted {
1160 break;
1161 }
1162 probe_limit = probe_limit.saturating_mul(2).min(remaining.len());
1167 }
1168 let Some((_, rank, mut fit, support)) = probe_best else {
1169 continue;
1170 };
1171 fit.diagnostics.hypotheses_generated = attempt_index + 1;
1172 let area = fit.metrics.supported_area;
1173 let replace = best.as_ref().is_none_or(|(count, old_rank, old, _)| {
1174 support.len() > *count
1175 || (support.len() == *count
1176 && (area > old.metrics.supported_area
1177 || (area == old.metrics.supported_area
1178 && compare_model_rank(
1179 rank,
1180 fit.surface.surface_type(),
1181 *old_rank,
1182 old.surface.surface_type(),
1183 ) == Ordering::Less)))
1184 });
1185 if replace {
1186 let support_fraction = support.len() as f64 / ids.len() as f64;
1187 attempts_required = attempts_required.min(required_seed_hypotheses(
1188 options.confidence,
1189 support_fraction,
1190 TYPES.len(),
1191 attempt_cap,
1192 ));
1193 attempts_required = attempts_required.max(attempt);
1194 best = Some((support.len(), rank, fit, support));
1195 }
1196 }
1197 let Some((_, _, mut fit, support)) = best else {
1198 break;
1199 };
1200 fit.diagnostics.phase_timings.region_growth_seconds = region_growth_seconds;
1201 for id in &support {
1202 remaining.remove(id);
1203 }
1204 out.push(to_region(fit, support));
1205 }
1206}
1207
1208fn shuffled_seed_order(ids: &[usize], rng: &mut SplitMix) -> Vec<usize> {
1209 let mut order = ids.to_vec();
1210 for upper in (2..=order.len()).rev() {
1211 let upper_u64 = upper as u64;
1212 let zone = (u64::MAX / upper_u64) * upper_u64;
1215 let index = loop {
1216 let value = rng.next();
1217 if value < zone {
1218 break (value % upper_u64) as usize;
1219 }
1220 };
1221 order.swap(upper - 1, index);
1222 }
1223 order
1224}
1225
1226const fn hypothesis_sample_triangles(kind: SurfaceType) -> usize {
1235 match kind {
1236 SurfaceType::Plane => 3,
1237 SurfaceType::Sphere => 4,
1238 SurfaceType::Cylinder => 6,
1242 SurfaceType::Cone => 4,
1243 SurfaceType::Torus => 6,
1244 }
1245}
1246
1247fn diverse_hypothesis_sample(
1253 mesh: &AnalyzedMesh,
1254 seed: usize,
1255 probe: &[usize],
1256 count: usize,
1257) -> Vec<usize> {
1258 debug_assert!(probe.contains(&seed));
1259 debug_assert!(count > 0 && probe.len() >= count);
1260 let mut selected = Vec::with_capacity(count);
1261 selected.push(seed);
1262 while selected.len() < count {
1263 let mut best: Option<(f64, f64, usize)> = None;
1264 for &candidate in probe {
1265 if selected.contains(&candidate) {
1266 continue;
1267 }
1268 let triangle = &mesh.triangles[candidate];
1269 let (normal_gap, spatial_gap) = selected.iter().fold(
1270 (f64::INFINITY, f64::INFINITY),
1271 |(normal_gap, spatial_gap), &chosen| {
1272 let other = &mesh.triangles[chosen];
1273 (
1274 normal_gap
1275 .min(1.0 - triangle.normal.dot(other.normal).abs().clamp(0.0, 1.0)),
1276 spatial_gap.min((triangle.centroid - other.centroid).length_squared()),
1277 )
1278 },
1279 );
1280 let key = (normal_gap, spatial_gap, std::cmp::Reverse(candidate));
1281 if best
1282 .as_ref()
1283 .is_none_or(|&(best_normal, best_spatial, best_id)| {
1284 (normal_gap, spatial_gap, std::cmp::Reverse(candidate))
1285 > (best_normal, best_spatial, std::cmp::Reverse(best_id))
1286 })
1287 {
1288 best = Some((key.0, key.1, candidate));
1289 }
1290 }
1291 selected.push(best.expect("probe has enough distinct triangles").2);
1292 }
1293 selected.sort_unstable();
1294 selected
1295}
1296
1297fn required_seed_hypotheses(
1304 confidence: f64,
1305 support_fraction: f64,
1306 kinds: usize,
1307 cap: usize,
1308) -> usize {
1309 let minimum = kinds.min(cap).max(1);
1310 if confidence <= 0.0 || support_fraction >= 1.0 {
1311 return minimum;
1312 }
1313 if support_fraction <= 0.0 || cap <= minimum {
1314 return cap.max(1);
1315 }
1316 let rounds = ((1.0 - confidence).ln() / (1.0 - support_fraction).ln())
1317 .ceil()
1318 .max(1.0) as usize;
1319 rounds.saturating_mul(kinds).clamp(minimum, cap)
1320}
1321
1322fn neighborhood(
1323 mesh: &AnalyzedMesh,
1324 seed: usize,
1325 allowed: &BTreeSet<usize>,
1326 limit: usize,
1327) -> Vec<usize> {
1328 let mut seen = BTreeSet::new();
1329 let mut queue = VecDeque::from([seed]);
1330 seen.insert(seed);
1331 while let Some(id) = queue.pop_front() {
1332 if seen.len() >= limit {
1333 break;
1334 }
1335 for n in mesh.triangles[id].neighbors.iter().flatten() {
1336 if allowed.contains(n) && seen.insert(*n) {
1337 queue.push_back(*n);
1338 }
1339 }
1340 }
1341 seen.into_iter().collect()
1342}
1343fn triangle_support(
1344 mesh: &AnalyzedMesh,
1345 id: usize,
1346 surface: AnalyticSurface,
1347 options: &RecognitionOptions,
1348) -> bool {
1349 let t = &mesh.triangles[id];
1350 let tolerance =
1351 options.distance_tolerance + options.relative_tolerance * mesh.diagonal.max(1.0);
1352 if t.vertices
1353 .iter()
1354 .any(|&v| surface.signed_distance(mesh.vertices[v]).abs() > tolerance)
1355 {
1356 return false;
1357 }
1358 let Some(normal) = surface.normal_at(t.centroid) else {
1359 return false;
1360 };
1361 t.normal.dot(normal).abs().clamp(-1.0, 1.0).acos() <= options.normal_tolerance
1362}
1363fn support_component(
1364 mesh: &AnalyzedMesh,
1365 candidates: &[usize],
1366 seed: usize,
1367 surface: AnalyticSurface,
1368 options: &RecognitionOptions,
1369) -> Vec<usize> {
1370 let allowed: BTreeSet<_> = candidates
1371 .iter()
1372 .copied()
1373 .filter(|&i| triangle_support(mesh, i, surface, options))
1374 .collect();
1375 if !allowed.contains(&seed) {
1376 return Vec::new();
1377 }
1378 let mut seen = BTreeSet::from([seed]);
1379 let mut queue = VecDeque::from([seed]);
1380 while let Some(id) = queue.pop_front() {
1381 for n in mesh.triangles[id].neighbors.iter().flatten() {
1382 if allowed.contains(n) && seen.insert(*n) {
1383 queue.push_back(*n);
1384 }
1385 }
1386 }
1387 seen.into_iter().collect()
1388}
1389struct SplitMix(u64);
1390impl SplitMix {
1391 fn next(&mut self) -> u64 {
1392 self.0 = self.0.wrapping_add(0x9e3779b97f4a7c15);
1393 let mut z = self.0;
1394 z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
1395 z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
1396 z ^ (z >> 31)
1397 }
1398}
1399
1400