1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
22use ogeom_math::{
23 Axis, Axis2, Circle, Circle2, Cone, Cylinder, Direction, Direction2, Frame, Plane, Point,
24 Point2, Sphere, Torus, Vector,
25};
26
27use crate::Cutoff;
28use crate::recognize::{self, Canonical};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Side {
33 Inside,
36 Outside,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Default)]
43pub struct FitFixed {
44 pub radius: Option<f64>,
47 pub direction: Option<Direction>,
50 pub axis: Option<Axis>,
53 pub half_angle: Option<f64>,
55}
56
57#[derive(Debug, Clone, PartialEq)]
61pub struct FitOptions<'a, S> {
62 pub normals: Option<&'a [Vector]>,
66 pub cutoff: Cutoff,
68 pub robust_start: bool,
73 pub fixed: FitFixed,
75 pub guess: Option<S>,
79 pub side: Option<Side>,
82 pub radius_range: Option<(f64, f64)>,
85}
86
87impl<S> Default for FitOptions<'_, S> {
88 fn default() -> Self {
89 Self {
90 normals: None,
91 cutoff: Cutoff::All,
92 robust_start: false,
93 fixed: FitFixed::default(),
94 guess: None,
95 side: None,
96 radius_range: None,
97 }
98 }
99}
100
101#[derive(Debug, Clone, PartialEq)]
103pub struct Fit<S> {
104 pub shape: S,
106 pub sigma: f64,
108 pub form: (f64, f64),
111 pub used: Vec<bool>,
113 pub residuals: Vec<f64>,
119 pub iterations: u32,
121 pub rounds: u32,
123 pub converged: bool,
126 pub side: Option<Side>,
129}
130
131pub fn plane(
142 points: &[Point],
143 options: &FitOptions<'_, Plane>,
144 tol: Tolerances,
145) -> OgeomResult<Fit<Plane>> {
146 let guess = options.guess.map(|p| State::about(p.origin(), p.normal()));
147 run(Kind::Plane, points, options, guess, tol)?
148 .shaped(|s| Ok(Plane::new(Frame::about(s.origin, s.axis))))
149}
150
151pub fn line3(
159 points: &[Point],
160 options: &FitOptions<'_, Axis>,
161 tol: Tolerances,
162) -> OgeomResult<Fit<Axis>> {
163 let guess = options.guess.map(|a| State::about(a.location, a.direction));
164 run(Kind::Line3, points, options, guess, tol)?.shaped(|s| Ok(Axis::new(s.origin, s.axis)))
165}
166
167pub fn sphere(
176 points: &[Point],
177 options: &FitOptions<'_, Sphere>,
178 tol: Tolerances,
179) -> OgeomResult<Fit<Sphere>> {
180 let guess = options.guess.map(|s| State {
181 radius: s.radius(),
182 ..State::about(s.centre(), Direction::Z)
183 });
184 run(Kind::Sphere, points, options, guess, tol)?
185 .shaped(|s| Sphere::centred(s.origin, s.radius, tol))
186}
187
188pub fn cylinder(
195 points: &[Point],
196 options: &FitOptions<'_, Cylinder>,
197 tol: Tolerances,
198) -> OgeomResult<Fit<Cylinder>> {
199 let guess = options.guess.map(|c| State {
200 radius: c.radius(),
201 ..State::about(c.frame().origin(), c.frame().z())
202 });
203 run(Kind::Cylinder, points, options, guess, tol)?
204 .shaped(|s| Cylinder::new(Frame::about(s.origin, s.axis), s.radius, tol))
205}
206
207pub fn cone(
219 points: &[Point],
220 options: &FitOptions<'_, Cone>,
221 tol: Tolerances,
222) -> OgeomResult<Fit<Cone>> {
223 let guess = options.guess.map(|c| State {
224 radius: c.reference_radius(),
225 second: c.half_angle(),
226 ..State::about(c.frame().origin(), c.frame().z())
227 });
228 run(Kind::Cone, points, options, guess, tol)?.shaped(|s| {
229 Cone::new(Frame::about(s.origin, s.axis), s.radius, s.second, tol).map_err(|_| {
230 ogeom_core::ogeom_err!(
231 Construction,
232 "the points have no taper a cone can take: half angle {} at radius {}",
233 s.second,
234 s.radius
235 )
236 })
237 })
238}
239
240pub fn torus(
250 points: &[Point],
251 options: &FitOptions<'_, Torus>,
252 tol: Tolerances,
253) -> OgeomResult<Fit<Torus>> {
254 let guess = options.guess.map(|t| State {
255 radius: t.minor_radius(),
256 second: t.major_radius(),
257 ..State::about(t.centre(), t.frame().z())
258 });
259 run(Kind::Torus, points, options, guess, tol)?
260 .shaped(|s| Torus::new(Frame::about(s.origin, s.axis), s.second, s.radius, tol))
261}
262
263pub fn circle3(
272 points: &[Point],
273 options: &FitOptions<'_, Circle>,
274 tol: Tolerances,
275) -> OgeomResult<Fit<Circle>> {
276 let guess = options.guess.map(|c| State {
277 radius: c.radius(),
278 ..State::about(c.centre(), c.frame().z())
279 });
280 run(Kind::Circle3, points, options, guess, tol)?
281 .shaped(|s| Circle::new(Frame::about(s.origin, s.axis), s.radius, tol))
282}
283
284pub fn circle2(
291 points: &[Point2],
292 options: &FitOptions<'_, Circle2>,
293 tol: Tolerances,
294) -> OgeomResult<Fit<Circle2>> {
295 let lifted: Vec<Point> = points.iter().map(|p| p.to_3d()).collect();
296 let guess = options.guess.map(|c| State {
297 radius: c.radius(),
298 ..State::about(c.centre().to_3d(), Direction::Z)
299 });
300 run(Kind::Circle2, &lifted, options, guess, tol)?
301 .shaped(|s| Circle2::centred(s.origin.xy(), s.radius, tol))
302}
303
304pub fn line2(
310 points: &[Point2],
311 options: &FitOptions<'_, Axis2>,
312 tol: Tolerances,
313) -> OgeomResult<Fit<Axis2>> {
314 let lifted: Vec<Point> = points.iter().map(|p| p.to_3d()).collect();
315 run(Kind::Line2, &lifted, options, None, tol)?.shaped(|s| {
316 Ok(Axis2 {
317 location: s.origin.xy(),
318 direction: Direction2::new(s.axis.vector().xy(), tol)?,
319 })
320 })
321}
322
323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
325pub(super) enum Kind {
326 Plane,
327 Line3,
328 Line2,
329 Sphere,
330 Circle2,
331 Circle3,
332 Cylinder,
333 Cone,
334 Torus,
335}
336
337impl Kind {
338 pub(super) const fn name(self) -> &'static str {
339 match self {
340 Self::Plane => "plane",
341 Self::Line3 => "line",
342 Self::Line2 => "line in the plane",
343 Self::Sphere => "sphere",
344 Self::Circle2 => "circle in the plane",
345 Self::Circle3 => "circle",
346 Self::Cylinder => "cylinder",
347 Self::Cone => "cone",
348 Self::Torus => "torus",
349 }
350 }
351
352 const fn closed(self) -> bool {
354 matches!(self, Self::Plane | Self::Line3 | Self::Line2)
355 }
356
357 const fn planar(self) -> bool {
359 matches!(self, Self::Line2 | Self::Circle2)
360 }
361
362 const fn sided(self) -> bool {
364 !matches!(self, Self::Plane | Self::Line3 | Self::Line2)
365 }
366
367 const fn radial(self) -> bool {
369 matches!(
370 self,
371 Self::Sphere | Self::Circle2 | Self::Circle3 | Self::Cylinder | Self::Torus
372 )
373 }
374
375 const fn directed(self) -> bool {
377 matches!(
378 self,
379 Self::Plane | Self::Line3 | Self::Circle3 | Self::Cylinder | Self::Cone | Self::Torus
380 )
381 }
382
383 const fn axial(self) -> bool {
385 matches!(
386 self,
387 Self::Circle3 | Self::Cylinder | Self::Cone | Self::Torus
388 )
389 }
390
391 const fn components(self) -> usize {
394 if matches!(self, Self::Circle3) { 2 } else { 1 }
395 }
396
397 const fn revolved(self) -> bool {
399 matches!(self, Self::Cylinder | Self::Cone | Self::Torus)
400 }
401}
402
403#[derive(Debug, Clone, Copy, PartialEq)]
408struct State {
409 origin: Point,
410 axis: Direction,
411 radius: f64,
412 second: f64,
413}
414
415impl State {
416 const fn about(origin: Point, axis: Direction) -> Self {
417 Self {
418 origin,
419 axis,
420 radius: 0.0,
421 second: 0.0,
422 }
423 }
424}
425
426const PARAMETERS: usize = 7;
431const TILT: [usize; 2] = [0, 1];
432const ACROSS: [usize; 2] = [2, 3];
433const ALONG: usize = 4;
434const RADIUS: usize = 5;
435const SECOND: usize = 6;
436
437fn free(kind: Kind, fixed: &FitFixed) -> [bool; PARAMETERS] {
439 let mut on = [false; PARAMETERS];
440 let tilt = fixed.direction.is_none() && fixed.axis.is_none();
441 let across = fixed.axis.is_none();
442 let radius = fixed.radius.is_none();
443 match kind {
444 Kind::Plane | Kind::Line3 | Kind::Line2 => {}
445 Kind::Sphere => {
446 on[ACROSS[0]] = true;
447 on[ACROSS[1]] = true;
448 on[ALONG] = true;
449 on[RADIUS] = radius;
450 }
451 Kind::Circle2 => {
452 on[ACROSS[0]] = true;
453 on[ACROSS[1]] = true;
454 on[RADIUS] = radius;
455 }
456 Kind::Cylinder | Kind::Cone | Kind::Torus | Kind::Circle3 => {
457 on[TILT[0]] = tilt;
458 on[TILT[1]] = tilt;
459 on[ACROSS[0]] = across;
460 on[ACROSS[1]] = across;
461 on[ALONG] = matches!(kind, Kind::Torus | Kind::Circle3);
462 on[RADIUS] = radius;
463 on[SECOND] = match kind {
464 Kind::Cone => fixed.half_angle.is_none(),
465 Kind::Torus => true,
466 _ => false,
467 };
468 }
469 }
470 on
471}
472
473pub(super) fn needed(kind: Kind, fixed: &FitFixed) -> usize {
475 let held = fixed.direction.is_some();
476 match kind {
477 Kind::Plane => {
478 if held {
479 1
480 } else {
481 3
482 }
483 }
484 Kind::Line3 | Kind::Line2 => {
485 if held {
486 1
487 } else {
488 2
489 }
490 }
491 _ => {
492 let count = free(kind, fixed).iter().filter(|on| **on).count();
493 count.div_ceil(kind.components()).max(1)
494 }
495 }
496}
497
498#[derive(Debug, Clone, Copy, PartialEq)]
501pub(super) struct Model {
502 kind: Kind,
503 state: State,
504 sense: f64,
507 oriented: bool,
510 free: [bool; PARAMETERS],
512 spread: [[f64; PARAMETERS]; PARAMETERS],
516}
517
518impl Model {
519 fn sided<S>(kind: Kind, state: State, fit: &Fit<S>) -> Self {
520 Self {
521 kind,
522 state,
523 sense: if fit.side == Some(Side::Outside) {
524 -1.0
525 } else {
526 1.0
527 },
528 oriented: fit.side.is_some(),
529 free: [false; PARAMETERS],
530 spread: [[0.0; PARAMETERS]; PARAMETERS],
531 }
532 }
533
534 pub(super) fn plane(fit: &Fit<Plane>) -> Self {
536 Self {
537 kind: Kind::Plane,
538 state: State::about(fit.shape.origin(), fit.shape.normal()),
539 sense: 1.0,
540 oriented: true,
541 free: [false; PARAMETERS],
542 spread: [[0.0; PARAMETERS]; PARAMETERS],
543 }
544 }
545
546 pub(super) fn sphere(fit: &Fit<Sphere>) -> Self {
547 let s = &fit.shape;
548 let state = State {
549 radius: s.radius(),
550 ..State::about(s.centre(), Direction::Z)
551 };
552 Self::sided(Kind::Sphere, state, fit)
553 }
554
555 pub(super) fn cylinder(fit: &Fit<Cylinder>) -> Self {
556 let c = &fit.shape;
557 let state = State {
558 radius: c.radius(),
559 ..State::about(c.frame().origin(), c.frame().z())
560 };
561 Self::sided(Kind::Cylinder, state, fit)
562 }
563
564 pub(super) fn cone(fit: &Fit<Cone>) -> Self {
565 let c = &fit.shape;
566 let state = State {
567 radius: c.reference_radius(),
568 second: c.half_angle(),
569 ..State::about(c.frame().origin(), c.frame().z())
570 };
571 Self::sided(Kind::Cone, state, fit)
572 }
573
574 pub(super) fn torus(fit: &Fit<Torus>) -> Self {
575 let t = &fit.shape;
576 let state = State {
577 radius: t.minor_radius(),
578 second: t.major_radius(),
579 ..State::about(t.centre(), t.frame().z())
580 };
581 Self::sided(Kind::Torus, state, fit)
582 }
583
584 pub(super) fn circle3(fit: &Fit<Circle>) -> Self {
587 let c = &fit.shape;
588 let state = State {
589 radius: c.radius(),
590 ..State::about(c.centre(), c.frame().z())
591 };
592 Self::sided(Kind::Circle3, state, fit)
593 }
594
595 pub(super) fn line3(fit: &Fit<Axis>) -> Self {
598 Self {
599 kind: Kind::Line3,
600 state: State::about(fit.shape.location, fit.shape.direction),
601 sense: 1.0,
602 oriented: false,
603 free: [false; PARAMETERS],
604 spread: [[0.0; PARAMETERS]; PARAMETERS],
605 }
606 }
607
608 pub(super) const fn components(&self) -> usize {
612 match self.kind {
613 Kind::Line3 => 2,
614 kind => kind.components(),
615 }
616 }
617
618 pub(super) fn moved_by(&self, p: Point, half: Vector) -> f64 {
623 if self.components() == 2 {
624 return half.magnitude();
625 }
626 let n = self.normal(p);
627 n.x.abs() * half.x + n.y.abs() * half.y + n.z.abs() * half.z
628 }
629
630 pub(super) fn residual(&self, p: Point) -> f64 {
632 self.sense * residual(self.kind, &self.state, p)
633 }
634
635 pub(super) fn normal(&self, p: Point) -> Vector {
638 outward(self.kind, &self.state, p) * self.sense
639 }
640
641 pub(super) const fn oriented(&self) -> bool {
644 self.oriented
645 }
646
647 pub(super) fn measured_over(
654 mut self,
655 points: &[Point],
656 used: &[bool],
657 fixed: &FitFixed,
658 ) -> Self {
659 let free = if self.kind == Kind::Plane {
660 let mut on = [false; PARAMETERS];
661 on[ALONG] = true;
662 let tilt = fixed.direction.is_none();
663 on[TILT[0]] = tilt;
664 on[TILT[1]] = tilt;
665 on
666 } else if self.kind == Kind::Line3 {
667 let mut on = [false; PARAMETERS];
668 let tilt = fixed.direction.is_none();
669 on[TILT[0]] = tilt;
670 on[TILT[1]] = tilt;
671 on[ACROSS[0]] = true;
672 on[ACROSS[1]] = true;
673 on
674 } else {
675 free(self.kind, fixed)
676 };
677 let index: Vec<usize> = (0..PARAMETERS).filter(|k| free[*k]).collect();
678 let size = index.len();
679 self.free = free;
680 self.spread = [[0.0; PARAMETERS]; PARAMETERS];
681 if size == 0 {
682 return self;
683 }
684 let frame = basis(self.kind, &self.state);
685 let mut normal = nalgebra::DMatrix::<f64>::zeros(size, size);
686 for (p, keep) in points.iter().zip(used) {
687 if !*keep {
688 continue;
689 }
690 let (rows, count) = self.rows(frame, *p);
691 for (_, d) in &rows[..count] {
692 for (i, &ki) in index.iter().enumerate() {
693 for (j, &kj) in index.iter().enumerate() {
694 normal[(i, j)] += d[ki] * d[kj];
695 }
696 }
697 }
698 }
699 let eigen = nalgebra::SymmetricEigen::new(normal);
700 let largest = eigen.eigenvalues.iter().copied().fold(0.0, f64::max);
701 #[allow(clippy::cast_precision_loss, reason = "a handful of parameters")]
702 let floor = largest * f64::EPSILON * size as f64;
703 for (k, value) in eigen.eigenvalues.iter().enumerate() {
704 if *value > floor {
705 let v = eigen.eigenvectors.column(k);
706 for (i, &ki) in index.iter().enumerate() {
707 for (j, &kj) in index.iter().enumerate() {
708 self.spread[ki][kj] += v[i] * v[j] / value;
709 }
710 }
711 }
712 }
713 self
714 }
715
716 pub(super) fn parameters(&self) -> usize {
718 self.free.iter().filter(|on| **on).count()
719 }
720
721 pub(super) fn leverage(&self, p: Point) -> f64 {
725 let (rows, count) = self.rows(basis(self.kind, &self.state), p);
726 let mut sum = 0.0;
727 for (_, d) in &rows[..count] {
728 for i in 0..PARAMETERS {
729 if !self.free[i] {
730 continue;
731 }
732 for j in 0..PARAMETERS {
733 if self.free[j] {
734 sum += d[i] * self.spread[i][j] * d[j];
735 }
736 }
737 }
738 }
739 sum
740 }
741
742 fn rows(&self, frame: [Vector; 3], p: Point) -> ([(f64, [f64; PARAMETERS]); 2], usize) {
747 let [e1, e2, a] = frame;
748 let w = p - self.state.origin;
749 match self.kind {
750 Kind::Plane => {
751 let mut d = [0.0; PARAMETERS];
752 d[TILT[0]] = w.dot(e1);
753 d[TILT[1]] = w.dot(e2);
754 d[ALONG] = -1.0;
755 ([(w.dot(a), d), (0.0, [0.0; PARAMETERS])], 1)
756 }
757 Kind::Line3 => {
758 let h = w.dot(a);
759 let mut out = [(0.0, [0.0; PARAMETERS]); 2];
760 for (i, e) in [e1, e2].into_iter().enumerate() {
761 out[i].0 = w.dot(e);
762 out[i].1[TILT[i]] = -h;
763 out[i].1[ACROSS[i]] = -1.0;
764 }
765 (out, 2)
766 }
767 _ => distances(self.kind, &self.state, frame, p),
768 }
769 }
770}
771
772struct Outcome {
774 state: State,
775 sigma: f64,
776 form: (f64, f64),
777 used: Vec<bool>,
778 residuals: Vec<f64>,
779 iterations: u32,
780 rounds: u32,
781 converged: bool,
782 side: Option<Side>,
783}
784
785impl Outcome {
786 fn shaped<S>(self, build: impl FnOnce(&State) -> OgeomResult<S>) -> OgeomResult<Fit<S>> {
787 Ok(Fit {
788 shape: build(&self.state)?,
789 sigma: self.sigma,
790 form: self.form,
791 used: self.used,
792 residuals: self.residuals,
793 iterations: self.iterations,
794 rounds: self.rounds,
795 converged: self.converged,
796 side: self.side,
797 })
798 }
799}
800
801const MOST_ROUNDS: u32 = 50;
805
806const START_POINTS: usize = 4096;
811
812const MEDIAN_IN_SIGMAS: f64 = 0.674_489_750_196_081_7;
816
817fn run<S>(
818 kind: Kind,
819 points: &[Point],
820 options: &FitOptions<'_, S>,
821 guess: Option<State>,
822 tol: Tolerances,
823) -> OgeomResult<Outcome> {
824 let normals = checked(kind, points, options)?;
825 let fixed = &options.fixed;
826 let needed = needed(kind, fixed);
827 if points.len() < needed {
828 ogeom_bail!(
829 Construction,
830 "a {} fit needs at least {needed} points, got {}",
831 kind.name(),
832 points.len()
833 );
834 }
835
836 let stride = points.len().div_ceil(START_POINTS).max(1);
837 let sample: Vec<Point> = points.iter().step_by(stride).copied().collect();
838 let sample_normals: Option<Vec<Vector>> = match &normals {
839 Some(n) => Some(n.iter().step_by(stride).copied().collect()),
840 None if kind.revolved() && start_reads_normals(kind, fixed) => {
841 Some(estimated_normals(&sample)?)
842 }
843 None => None,
844 };
845 let sample_normals = sample_normals.as_deref();
846 let given = normals.is_some();
847
848 let mut state = match guess {
849 Some(g) if !kind.closed() => pinned(kind, g, fixed),
850 _ if options.robust_start => consensus(kind, &sample, sample_normals, given, fixed, tol)?,
851 _ => start(kind, &sample, sample_normals, given, fixed, tol)?,
852 };
853
854 let mut used = vec![true; points.len()];
855 if options.robust_start && options.cutoff != Cutoff::All {
856 let sizes: Vec<f64> = points.iter().map(|p| residual(kind, &state, *p)).collect();
857 let threshold = robust_threshold(options.cutoff, &sizes);
858 for (keep, r) in used.iter_mut().zip(&sizes) {
859 *keep = r.abs() <= threshold;
860 }
861 refuse_too_few_kept(kind, &used, needed)?;
862 }
863
864 let mut iterations = 0_u32;
865 let mut rounds = 0_u32;
866 let mut converged;
867 let mut residuals;
868 loop {
869 rounds += 1;
870 let (next, steps, settled) = if kind.closed() {
871 (
872 closed_form(kind, &state, points, &used, fixed, tol)?,
873 0,
874 true,
875 )
876 } else {
877 least_squares(kind, state, &free(kind, fixed), points, &used, tol)
878 };
879 state = next;
880 iterations += steps;
881 converged = settled;
882 residuals = points
883 .iter()
884 .map(|p| residual(kind, &state, *p))
885 .collect::<Vec<_>>();
886 if options.cutoff == Cutoff::All {
887 break;
888 }
889 let kept: Vec<f64> = residuals
890 .iter()
891 .zip(&used)
892 .filter_map(|(r, keep)| keep.then_some(*r))
893 .collect();
894 let threshold = options.cutoff.threshold(&kept);
895 let next_used: Vec<bool> = residuals.iter().map(|r| r.abs() <= threshold).collect();
896 if next_used == used {
897 break;
898 }
899 refuse_too_few_kept(kind, &next_used, needed)?;
900 if rounds >= MOST_ROUNDS {
901 converged = false;
902 break;
903 }
904 used = next_used;
905 }
906 if kind == Kind::Cone && state.second < 0.0 {
907 state.axis = -state.axis;
909 state.second = -state.second;
910 }
911
912 let mut side = None;
913 if kind.sided() {
914 side = options.side.or_else(|| {
915 normals.as_deref().and_then(|n| {
916 let vote = facing(kind, &state, points, n, &used);
917 if vote > 0.0 {
918 Some(Side::Inside)
919 } else if vote < 0.0 {
920 Some(Side::Outside)
921 } else {
922 None
923 }
924 })
925 });
926 if side == Some(Side::Outside) {
927 for r in &mut residuals {
928 *r = -*r;
929 }
930 }
931 } else if kind == Kind::Plane
932 && let Some(n) = normals.as_deref()
933 && facing(kind, &state, points, n, &used) < 0.0
934 {
935 state.axis = -state.axis;
936 for r in &mut residuals {
937 *r = -*r;
938 }
939 }
940
941 let (mut sum, mut count) = (0.0, 0.0);
942 let mut form = (f64::INFINITY, f64::NEG_INFINITY);
943 for (r, keep) in residuals.iter().zip(&used) {
944 if *keep {
945 sum += r * r;
946 count += 1.0;
947 form = (form.0.min(*r), form.1.max(*r));
948 }
949 }
950 let sigma = (sum / count).sqrt();
951
952 if let Some((lo, hi)) = options.radius_range
953 && !(lo..=hi).contains(&state.radius)
954 {
955 ogeom_bail!(
956 Range,
957 "the fitted {} radius {} lies outside the range [{lo}, {hi}]",
958 kind.name(),
959 state.radius
960 );
961 }
962 Ok(Outcome {
963 state,
964 sigma,
965 form,
966 used,
967 residuals,
968 iterations,
969 rounds,
970 converged,
971 side,
972 })
973}
974
975fn checked<S>(
978 kind: Kind,
979 points: &[Point],
980 options: &FitOptions<'_, S>,
981) -> OgeomResult<Option<Vec<Vector>>> {
982 let name = kind.name();
983 if let Some(i) = points.iter().position(|p| !p.is_finite()) {
984 ogeom_bail!(Domain, "point {i} is not finite");
985 }
986 options.cutoff.check()?;
987 let fixed = &options.fixed;
988 let refuse = |what: &str| -> OgeomResult<()> {
989 ogeom_bail!(Domain, "{what} does not apply to a {name} fit")
990 };
991 if fixed.radius.is_some() && !kind.radial() {
992 refuse("a fixed radius")?;
993 }
994 if fixed.direction.is_some() && !kind.directed() {
995 refuse("a fixed direction")?;
996 }
997 if fixed.axis.is_some() && !kind.axial() {
998 refuse("a fixed axis")?;
999 }
1000 if fixed.half_angle.is_some() && kind != Kind::Cone {
1001 refuse("a fixed half angle")?;
1002 }
1003 if options.side.is_some() && !kind.sided() {
1004 refuse("a side")?;
1005 }
1006 if options.radius_range.is_some() && !kind.radial() {
1007 refuse("a radius range")?;
1008 }
1009 if options.normals.is_some() && kind.planar() {
1010 refuse("normals")?;
1011 }
1012 if fixed.direction.is_some() && fixed.axis.is_some() {
1013 ogeom_bail!(
1014 Domain,
1015 "a fixed axis holds its direction; give the axis or the direction, not both"
1016 );
1017 }
1018 if let Some(r) = fixed.radius
1019 && !(r.is_finite() && r > 0.0)
1020 {
1021 ogeom_bail!(Domain, "the fixed radius {r} must be finite and positive");
1022 }
1023 if let Some(a) = fixed.half_angle
1024 && !(a.is_finite() && a > 0.0 && a < core::f64::consts::FRAC_PI_2)
1025 {
1026 ogeom_bail!(
1027 Domain,
1028 "the fixed half angle {a} must lie strictly between 0 and pi/2"
1029 );
1030 }
1031 if let Some((lo, hi)) = options.radius_range
1032 && !(lo.is_finite() && hi.is_finite() && 0.0 <= lo && lo <= hi)
1033 {
1034 ogeom_bail!(
1035 Domain,
1036 "the radius range [{lo}, {hi}] must be finite, non-negative and in order"
1037 );
1038 }
1039 let Some(normals) = options.normals else {
1040 return Ok(None);
1041 };
1042 if normals.len() != points.len() {
1043 ogeom_bail!(
1044 Dimension,
1045 "{} normals for {} points",
1046 normals.len(),
1047 points.len()
1048 );
1049 }
1050 normals
1051 .iter()
1052 .enumerate()
1053 .map(|(i, n)| {
1054 let m = n.magnitude();
1055 if !(m.is_finite() && m > 0.0) {
1056 ogeom_bail!(Domain, "normal {i} has no direction");
1057 }
1058 Ok(*n / m)
1059 })
1060 .collect::<OgeomResult<Vec<_>>>()
1061 .map(Some)
1062}
1063
1064fn refuse_too_few_kept(kind: Kind, used: &[bool], needed: usize) -> OgeomResult<()> {
1065 let kept = used.iter().filter(|u| **u).count();
1066 if kept < needed {
1067 ogeom_bail!(
1068 Construction,
1069 "the cutoff keeps {kept} points, fewer than the {needed} a {} fit needs",
1070 kind.name()
1071 );
1072 }
1073 Ok(())
1074}
1075
1076fn robust_threshold(cutoff: Cutoff, residuals: &[f64]) -> f64 {
1080 let median = Cutoff::Median(1.0).threshold(residuals);
1081 match cutoff {
1082 Cutoff::Sigma(k) => k * median / MEDIAN_IN_SIGMAS,
1083 other => other.threshold(residuals),
1084 }
1085}
1086
1087fn facing(kind: Kind, state: &State, points: &[Point], normals: &[Vector], used: &[bool]) -> f64 {
1090 let mut sum = 0.0;
1091 for ((p, n), keep) in points.iter().zip(normals).zip(used) {
1092 if *keep {
1093 sum += n.dot(outward(kind, state, *p));
1094 }
1095 }
1096 sum
1097}
1098
1099fn outward(kind: Kind, state: &State, p: Point) -> Vector {
1101 let a = state.axis.vector();
1102 let w = p - state.origin;
1103 let h = w.dot(a);
1104 let q = w - a * h;
1105 let unit = |v: Vector| {
1106 let m = v.magnitude();
1107 if m > 0.0 { v / m } else { Vector::ZERO }
1108 };
1109 match kind {
1110 Kind::Plane => a,
1111 Kind::Sphere | Kind::Circle2 => unit(w),
1112 Kind::Line2 => Vector::new(-a.y, a.x, 0.0),
1113 Kind::Line3 | Kind::Cylinder | Kind::Circle3 => unit(q),
1114 Kind::Cone => {
1115 let (s, c) = state.second.sin_cos();
1116 unit(q) * c - a * s
1117 }
1118 Kind::Torus => {
1119 let rho = q.magnitude();
1120 let radial = unit(q);
1121 unit(radial * (rho - state.second) + a * h)
1122 }
1123 }
1124}
1125
1126fn residual(kind: Kind, state: &State, p: Point) -> f64 {
1130 let a = state.axis.vector();
1131 let w = p - state.origin;
1132 let h = w.dot(a);
1133 let rho = (w - a * h).magnitude();
1134 match kind {
1135 Kind::Plane => h,
1136 Kind::Line3 => rho,
1137 Kind::Line2 => a.x.mul_add(w.y, -(a.y * w.x)),
1138 Kind::Sphere | Kind::Circle2 => w.magnitude() - state.radius,
1139 Kind::Cylinder => rho - state.radius,
1140 Kind::Cone => {
1141 let (s, c) = state.second.sin_cos();
1142 (rho - state.radius).mul_add(c, -(h * s))
1143 }
1144 Kind::Torus => (rho - state.second).hypot(h) - state.radius,
1145 Kind::Circle3 => {
1146 let across = rho - state.radius;
1147 across.hypot(h).copysign(across)
1148 }
1149 }
1150}
1151
1152fn basis(kind: Kind, state: &State) -> [Vector; 3] {
1156 if matches!(kind, Kind::Sphere | Kind::Circle2) {
1157 return [Vector::X, Vector::Y, Vector::Z];
1158 }
1159 let a = state.axis;
1160 let e1 = a.any_perpendicular().vector();
1161 [e1, a.vector().cross(e1), a.vector()]
1162}
1163
1164fn distances(
1167 kind: Kind,
1168 state: &State,
1169 [e1, e2, a]: [Vector; 3],
1170 p: Point,
1171) -> ([(f64, [f64; PARAMETERS]); 2], usize) {
1172 let mut out = [(0.0, [0.0; PARAMETERS]); 2];
1173 let w = p - state.origin;
1174 if matches!(kind, Kind::Sphere | Kind::Circle2) {
1175 let length = w.magnitude();
1176 let u = if length > 0.0 { w / length } else { Vector::X };
1177 let d = &mut out[0].1;
1178 d[ACROSS[0]] = -u.x;
1179 d[ACROSS[1]] = -u.y;
1180 d[ALONG] = -u.z;
1181 d[RADIUS] = -1.0;
1182 out[0].0 = length - state.radius;
1183 return (out, 1);
1184 }
1185 let h = w.dot(a);
1186 let q = w - a * h;
1187 let rho = q.magnitude();
1188 let n = if rho > 0.0 { q / rho } else { e1 };
1189 let es = [e1, e2];
1190 let rho_across = es.map(|e| -n.dot(e));
1194 let rho_tilt = es.map(|e| -h * n.dot(e));
1195 let h_tilt = es.map(|e| w.dot(e));
1196 match kind {
1197 Kind::Cylinder => {
1198 let d = &mut out[0].1;
1199 for i in 0..2 {
1200 d[TILT[i]] = rho_tilt[i];
1201 d[ACROSS[i]] = rho_across[i];
1202 }
1203 d[RADIUS] = -1.0;
1204 out[0].0 = rho - state.radius;
1205 }
1206 Kind::Cone => {
1207 let (s, c) = state.second.sin_cos();
1208 let d = &mut out[0].1;
1209 for i in 0..2 {
1210 d[TILT[i]] = c.mul_add(rho_tilt[i], -(s * h_tilt[i]));
1211 d[ACROSS[i]] = c * rho_across[i];
1212 }
1213 d[RADIUS] = -c;
1214 d[SECOND] = (state.radius - rho).mul_add(s, -(h * c));
1215 out[0].0 = (rho - state.radius).mul_add(c, -(h * s));
1216 }
1217 Kind::Torus => {
1218 let off = rho - state.second;
1219 let tube = off.hypot(h);
1220 let (m, k) = if tube > 0.0 {
1221 (off / tube, h / tube)
1222 } else {
1223 (1.0, 0.0)
1224 };
1225 let d = &mut out[0].1;
1226 for i in 0..2 {
1227 d[TILT[i]] = m.mul_add(rho_tilt[i], k * h_tilt[i]);
1228 d[ACROSS[i]] = m * rho_across[i];
1229 }
1230 d[ALONG] = -k;
1231 d[RADIUS] = -1.0;
1232 d[SECOND] = -m;
1233 out[0].0 = tube - state.radius;
1234 }
1235 Kind::Circle3 => {
1236 let (height, across) = out.split_at_mut(1);
1237 let dh = &mut height[0].1;
1238 let dr = &mut across[0].1;
1239 for i in 0..2 {
1240 dh[TILT[i]] = h_tilt[i];
1241 dr[TILT[i]] = rho_tilt[i];
1242 dr[ACROSS[i]] = rho_across[i];
1243 }
1244 dh[ALONG] = -1.0;
1245 dr[RADIUS] = -1.0;
1246 height[0].0 = h;
1247 across[0].0 = rho - state.radius;
1248 return (out, 2);
1249 }
1250 Kind::Plane | Kind::Line3 | Kind::Line2 | Kind::Sphere | Kind::Circle2 => {}
1251 }
1252 (out, 1)
1253}
1254
1255fn stepped(
1259 kind: Kind,
1260 state: &State,
1261 [e1, e2, a]: [Vector; 3],
1262 step: &[f64; PARAMETERS],
1263 centroid: Point,
1264 tol: Tolerances,
1265) -> State {
1266 let mut next = *state;
1267 next.origin = state.origin + e1 * step[ACROSS[0]] + e2 * step[ACROSS[1]] + a * step[ALONG];
1268 if step[TILT[0]] != 0.0 || step[TILT[1]] != 0.0 {
1269 let tilted = a + e1 * step[TILT[0]] + e2 * step[TILT[1]];
1270 if let Ok(d) = Direction::new(tilted, tol) {
1271 next.axis = d;
1272 }
1273 }
1274 next.radius += step[RADIUS];
1275 next.second += step[SECOND];
1276 if matches!(kind, Kind::Cylinder | Kind::Cone) {
1277 let a = next.axis.vector();
1278 let shift = (centroid - next.origin).dot(a);
1279 next.origin += a * shift;
1280 if kind == Kind::Cone {
1281 next.radius = shift.mul_add(next.second.tan(), next.radius);
1283 }
1284 }
1285 next
1286}
1287
1288const MOST_STEPS: u32 = 100;
1292
1293fn least_squares(
1299 kind: Kind,
1300 mut state: State,
1301 free: &[bool; PARAMETERS],
1302 points: &[Point],
1303 used: &[bool],
1304 tol: Tolerances,
1305) -> (State, u32, bool) {
1306 let index: Vec<usize> = (0..PARAMETERS).filter(|k| free[*k]).collect();
1307 let size = index.len();
1308 if size == 0 {
1309 return (state, 0, true);
1310 }
1311 let kept: Vec<Point> = points
1312 .iter()
1313 .zip(used)
1314 .filter_map(|(p, keep)| keep.then_some(*p))
1315 .collect();
1316 let centroid = Point::from_vector(recognize::centroid(&kept));
1317 #[allow(
1318 clippy::cast_precision_loss,
1319 reason = "point counts are far below 2^52"
1320 )]
1321 let measured = (kept.len() * kind.components()) as f64;
1322 let cost = |s: &State| -> f64 {
1323 let frame = basis(kind, s);
1324 let mut sum = 0.0;
1325 for p in &kept {
1326 let (rows, count) = distances(kind, s, frame, *p);
1327 for (r, _) in &rows[..count] {
1328 sum += r * r;
1329 }
1330 }
1331 sum
1332 };
1333 let mut current = cost(&state);
1334 for step in 0..MOST_STEPS {
1335 let frame = basis(kind, &state);
1336 let mut normal = nalgebra::DMatrix::<f64>::zeros(size, size);
1337 let mut gradient = nalgebra::DVector::<f64>::zeros(size);
1338 for p in &kept {
1339 let (rows, count) = distances(kind, &state, frame, *p);
1340 for (r, d) in &rows[..count] {
1341 for (i, &ki) in index.iter().enumerate() {
1342 gradient[i] += d[ki] * r;
1343 for (j, &kj) in index.iter().enumerate().skip(i) {
1344 normal[(i, j)] += d[ki] * d[kj];
1345 }
1346 }
1347 }
1348 }
1349 for i in 0..size {
1350 for j in 0..i {
1351 normal[(i, j)] = normal[(j, i)];
1352 }
1353 }
1354 let eigen = nalgebra::SymmetricEigen::new(normal.clone());
1355 let largest = eigen.eigenvalues.iter().copied().fold(0.0, f64::max);
1356 #[allow(clippy::cast_precision_loss, reason = "a handful of parameters")]
1357 let floor = largest * f64::EPSILON * size as f64;
1358 let mut delta = nalgebra::DVector::<f64>::zeros(size);
1359 for (k, value) in eigen.eigenvalues.iter().enumerate() {
1360 if *value > floor {
1361 let v = eigen.eigenvectors.column(k);
1362 delta -= v * (v.dot(&gradient) / value);
1363 }
1364 }
1365 let movement = ((delta.dot(&(&normal * &delta))) / measured).sqrt();
1366 if !movement.is_finite() {
1367 return (state, step, false);
1368 }
1369 let mut scale = 1.0;
1370 let mut accepted = false;
1371 loop {
1372 let mut full = [0.0; PARAMETERS];
1373 for (i, &k) in index.iter().enumerate() {
1374 full[k] = delta[i] * scale;
1375 }
1376 let trial = stepped(kind, &state, frame, &full, centroid, tol);
1377 let c = cost(&trial);
1378 if c.is_finite() && c < current {
1379 state = trial;
1380 current = c;
1381 accepted = true;
1382 break;
1383 }
1384 if movement * scale <= tol.confusion() {
1385 break;
1386 }
1387 scale *= 0.5;
1388 }
1389 if movement <= tol.confusion() || !accepted {
1393 return (state, step + 1, true);
1394 }
1395 }
1396 (state, MOST_STEPS, false)
1397}
1398
1399fn closed_form(
1404 kind: Kind,
1405 last: &State,
1406 points: &[Point],
1407 used: &[bool],
1408 fixed: &FitFixed,
1409 tol: Tolerances,
1410) -> OgeomResult<State> {
1411 let kept: Vec<Point> = points
1412 .iter()
1413 .zip(used)
1414 .filter_map(|(p, keep)| keep.then_some(*p))
1415 .collect();
1416 let c = recognize::centroid(&kept);
1417 let origin = Point::from_vector(c);
1418 let axis = if let Some(d) = fixed.direction {
1419 d
1420 } else {
1421 let m = recognize::covariance(kept.iter().map(|p| p.to_vector() - c));
1422 let eigen = nalgebra::SymmetricEigen::new(m);
1423 let mut order = [0, 1, 2];
1424 order.sort_by(|i, j| eigen.eigenvalues[*i].total_cmp(&eigen.eigenvalues[*j]));
1425 #[allow(
1426 clippy::cast_precision_loss,
1427 reason = "point counts are far below 2^52"
1428 )]
1429 let count = kept.len() as f64;
1430 let spread = |k: usize| (eigen.eigenvalues[order[k]].max(0.0) / count).sqrt();
1431 let (pick, flat, what) = if kind == Kind::Plane {
1432 (order[0], spread(1), "one line")
1433 } else {
1434 (order[2], spread(2), "one point")
1435 };
1436 if flat <= tol.confusion() {
1437 ogeom_bail!(
1438 Construction,
1439 "the points' spread is degenerate for a {} fit: they lie within {} of {what}",
1440 kind.name(),
1441 tol.confusion()
1442 );
1443 }
1444 let v = eigen.eigenvectors.column(pick);
1445 Direction::new(Vector::new(v[0], v[1], v[2]), tol)?
1446 };
1447 let axis = if axis.vector().dot(last.axis.vector()) < 0.0 {
1448 -axis
1449 } else {
1450 axis
1451 };
1452 Ok(State::about(origin, axis))
1453}
1454
1455fn start_reads_normals(kind: Kind, fixed: &FitFixed) -> bool {
1458 fixed.axis.is_none() && !(kind == Kind::Cylinder && fixed.direction.is_some())
1459}
1460
1461const NORMAL_NEIGHBOURS: usize = 12;
1465
1466fn estimated_normals(points: &[Point]) -> OgeomResult<Vec<Vector>> {
1469 let index = ogeom_mesh::PointIndex::build(points)?;
1470 Ok(points
1471 .iter()
1472 .map(|p| {
1473 let near: Vec<Point> = index
1474 .k_nearest(*p, NORMAL_NEIGHBOURS, f64::INFINITY)
1475 .iter()
1476 .map(|n| points[n.index as usize])
1477 .collect();
1478 let c = recognize::centroid(&near);
1479 recognize::smallest_direction(recognize::covariance(
1480 near.iter().map(|q| q.to_vector() - c),
1481 ))
1482 })
1483 .collect())
1484}
1485
1486fn pinned(kind: Kind, mut state: State, fixed: &FitFixed) -> State {
1490 if let Some(r) = fixed.radius {
1491 state.radius = r;
1492 }
1493 if let Some(a) = fixed.half_angle {
1494 state.second = a;
1495 }
1496 let along = |d: Direction, current: Direction| {
1497 if d.vector().dot(current.vector()) < 0.0 {
1498 -d
1499 } else {
1500 d
1501 }
1502 };
1503 if let Some(d) = fixed.direction {
1504 state.axis = along(d, state.axis);
1505 }
1506 if let Some(axis) = fixed.axis {
1507 let d = along(axis.direction, state.axis);
1508 let foot = axis.location + d.vector() * (state.origin - axis.location).dot(d.vector());
1509 state.axis = d;
1510 state.origin = foot;
1511 }
1512 if kind == Kind::Cone && state.second < 0.0 {
1513 state.axis = -state.axis;
1514 state.second = -state.second;
1515 }
1516 state
1517}
1518
1519fn degenerate(kind: Kind, why: &str) -> ogeom_core::OgeomError {
1520 ogeom_core::ogeom_err!(
1521 Construction,
1522 "the points' spread is degenerate for a {} fit: {why}",
1523 kind.name()
1524 )
1525}
1526
1527fn start(
1530 kind: Kind,
1531 points: &[Point],
1532 normals: Option<&[Vector]>,
1533 given: bool,
1534 fixed: &FitFixed,
1535 tol: Tolerances,
1536) -> OgeomResult<State> {
1537 let c = Point::from_vector(recognize::centroid(points));
1538 let state = match kind {
1539 Kind::Plane | Kind::Line3 | Kind::Line2 => {
1540 let all = vec![true; points.len()];
1541 let seed = State::about(c, Direction::Z);
1542 let seed = closed_form(kind, &seed, points, &all, fixed, tol)?;
1543 let v = seed.axis.vector();
1544 let largest = [v.x, v.y, v.z]
1548 .into_iter()
1549 .fold(0.0_f64, |m, x| if x.abs() > m.abs() { x } else { m });
1550 if largest < 0.0 {
1551 State::about(seed.origin, -seed.axis)
1552 } else {
1553 seed
1554 }
1555 }
1556 Kind::Sphere => match recognize::fit_sphere(points, tol) {
1557 Some(Canonical::Sphere(s)) => State {
1558 radius: s.radius(),
1559 ..State::about(s.centre(), Direction::Z)
1560 },
1561 _ => return Err(degenerate(kind, "they lie on one plane")),
1562 },
1563 Kind::Circle2 => {
1564 let (centre, radius) = circle_in_plane(points, c, Direction::Z, tol)
1565 .ok_or_else(|| degenerate(kind, "they lie on one line"))?;
1566 State {
1567 radius,
1568 ..State::about(centre, Direction::Z)
1569 }
1570 }
1571 Kind::Circle3 => {
1572 if let Some(axis) = fixed.axis {
1573 let foot = on_line(axis, c);
1574 let radius = mean_distance(points, axis);
1575 State {
1576 radius,
1577 ..State::about(foot, axis.direction)
1578 }
1579 } else if let Some(d) = fixed.direction {
1580 let (centre, radius) = circle_in_plane(points, c, d, tol)
1581 .ok_or_else(|| degenerate(kind, "they lie on one line"))?;
1582 State {
1583 radius,
1584 ..State::about(centre, d)
1585 }
1586 } else {
1587 let (centre, normal, radius, _) = recognize::circle_through(points, tol)
1588 .ok_or_else(|| degenerate(kind, "they lie on one line"))?;
1589 State {
1590 radius,
1591 ..State::about(centre, normal)
1592 }
1593 }
1594 }
1595 Kind::Cylinder => {
1596 if let Some(axis) = fixed.axis {
1597 State {
1598 radius: mean_distance(points, axis),
1599 ..State::about(on_line(axis, c), axis.direction)
1600 }
1601 } else if let Some(d) = fixed.direction {
1602 let (centre, radius) = circle_in_plane(points, c, d, tol)
1603 .ok_or_else(|| degenerate(kind, "their shadow along the axis is a line"))?;
1604 State {
1605 radius,
1606 ..State::about(centre, d)
1607 }
1608 } else {
1609 let normals = normals.ok_or_else(|| degenerate(kind, "no normals"))?;
1610 match recognize::fit_cylinder(points, normals, tol) {
1611 Some(Canonical::Cylinder(cyl)) => State {
1612 radius: cyl.radius(),
1613 ..State::about(cyl.frame().origin(), cyl.frame().z())
1614 },
1615 _ => return Err(degenerate(kind, "their normals meet no one axis")),
1616 }
1617 }
1618 }
1619 Kind::Cone => {
1620 let (through, axis) = revolution_axis(kind, points, normals, fixed, tol)?;
1621 match recognize::ruled_about(points, through, axis, 0.0, tol) {
1622 Some(Canonical::Cone(cone)) => State {
1623 radius: cone.reference_radius(),
1624 second: cone.half_angle(),
1625 ..State::about(cone.frame().origin(), cone.frame().z())
1626 },
1627 Some(_) => {
1628 return Err(degenerate(
1629 kind,
1630 "they have no taper, and lie on a cylinder",
1631 ));
1632 }
1633 None => return Err(degenerate(kind, "they span no height along the axis")),
1634 }
1635 }
1636 Kind::Torus => torus_start(points, normals, given, fixed, tol)?,
1637 };
1638 Ok(pinned(kind, state, fixed))
1639}
1640
1641fn revolution_axis(
1644 kind: Kind,
1645 points: &[Point],
1646 normals: Option<&[Vector]>,
1647 fixed: &FitFixed,
1648 tol: Tolerances,
1649) -> OgeomResult<(Point, Direction)> {
1650 if let Some(axis) = fixed.axis {
1651 return Ok((axis.location, axis.direction));
1652 }
1653 let normals = normals.ok_or_else(|| degenerate(kind, "no normals"))?;
1654 let found = match fixed.direction {
1655 Some(d) => axis_along(points, normals, d).map(|p| (p, d)),
1656 None => recognize::revolution_axis(points, normals, tol),
1657 };
1658 found.ok_or_else(|| degenerate(kind, "their normals meet no one axis"))
1659}
1660
1661fn axis_along(points: &[Point], normals: &[Vector], d: Direction) -> Option<Point> {
1666 let reference = *points.first()?;
1667 let mut m = nalgebra::Matrix3::<f64>::zeros();
1668 let mut b = nalgebra::Vector3::<f64>::zeros();
1669 for (p, n) in points.iter().zip(normals) {
1670 let row = d.vector().cross(*n);
1671 let r = nalgebra::Vector3::new(row.x, row.y, row.z);
1672 m += r * r.transpose();
1673 b += r * (*p - reference).dot(row);
1674 }
1675 let scale = m.norm().max(f64::MIN_POSITIVE);
1676 let x = m.pseudo_inverse(scale * f64::EPSILON * 3.0).ok()? * b;
1677 Some(reference + Vector::new(x[0], x[1], x[2]))
1678}
1679
1680fn torus_start(
1685 points: &[Point],
1686 normals: Option<&[Vector]>,
1687 given: bool,
1688 fixed: &FitFixed,
1689 tol: Tolerances,
1690) -> OgeomResult<State> {
1691 let kind = Kind::Torus;
1692 let c = Point::from_vector(recognize::centroid(points));
1693 let mut best: Option<(f64, State)> = None;
1694 let mut consider = |state: State| {
1695 let rms: f64 = points
1696 .iter()
1697 .map(|p| residual(kind, &state, *p).powi(2))
1698 .sum();
1699 if rms.is_finite() && best.is_none_or(|(held, _)| rms < held) {
1700 best = Some((rms, state));
1701 }
1702 };
1703 if let Ok((through, axis)) = revolution_axis(kind, points, normals, fixed, tol) {
1704 let line = Axis::new(through, axis);
1705 let base = on_line(line, c);
1706 if let Some((major, height, minor)) = recognize::profile_circle(points, base, line) {
1707 consider(State {
1708 radius: minor,
1709 second: major,
1710 ..State::about(base + axis.vector() * height, axis)
1711 });
1712 }
1713 }
1714 if given
1715 && fixed.axis.is_none()
1716 && fixed.direction.is_none()
1717 && let Some(n) = normals
1718 && let Some(Canonical::Torus(t)) = recognize::fit_torus(points, n, tol)
1719 {
1720 consider(State {
1721 radius: t.minor_radius(),
1722 second: t.major_radius(),
1723 ..State::about(t.centre(), t.frame().z())
1724 });
1725 }
1726 best.map(|(_, s)| s)
1727 .ok_or_else(|| degenerate(kind, "they trace no circle about any axis"))
1728}
1729
1730fn circle_in_plane(
1734 points: &[Point],
1735 through: Point,
1736 normal: Direction,
1737 tol: Tolerances,
1738) -> Option<(Point, f64)> {
1739 let a = normal.vector();
1740 let e1 = normal.any_perpendicular().vector();
1741 let e2 = a.cross(e1);
1742 let mut m = nalgebra::Matrix3::zeros();
1743 let mut b = nalgebra::Vector3::zeros();
1744 for p in points {
1745 let d = *p - through;
1746 let (x, y) = (d.dot(e1), d.dot(e2));
1747 let row = nalgebra::Vector3::new(-2.0 * x, -2.0 * y, 1.0);
1748 m += row * row.transpose();
1749 b += row * -x.mul_add(x, y * y);
1750 }
1751 let solved = m.lu().solve(&b)?;
1752 let r2 = solved[0].mul_add(solved[0], solved[1] * solved[1]) - solved[2];
1753 (r2 > tol.confusion() * tol.confusion())
1754 .then(|| (through + e1 * solved[0] + e2 * solved[1], r2.sqrt()))
1755}
1756
1757fn on_line(axis: Axis, p: Point) -> Point {
1759 let d = axis.direction.vector();
1760 axis.location + d * (p - axis.location).dot(d)
1761}
1762
1763fn mean_distance(points: &[Point], axis: Axis) -> f64 {
1765 let d = axis.direction.vector();
1766 let sum: f64 = points
1767 .iter()
1768 .map(|p| {
1769 let w = *p - axis.location;
1770 (w - d * w.dot(d)).magnitude()
1771 })
1772 .sum();
1773 #[allow(
1774 clippy::cast_precision_loss,
1775 reason = "point counts are far below 2^52"
1776 )]
1777 let n = points.len() as f64;
1778 sum / n
1779}
1780
1781const CONSENSUS_MISS: f64 = 1e-3;
1784
1785fn consensus(
1790 kind: Kind,
1791 points: &[Point],
1792 normals: Option<&[Vector]>,
1793 given: bool,
1794 fixed: &FitFixed,
1795 tol: Tolerances,
1796) -> OgeomResult<State> {
1797 let size = needed(kind, &FitFixed::default()).max(needed(kind, fixed));
1798 if points.len() <= size {
1799 return start(kind, points, normals, given, fixed, tol);
1800 }
1801 let clean = 0.5_f64.powi(i32::try_from(size).unwrap_or(i32::MAX));
1802 #[allow(
1803 clippy::cast_possible_truncation,
1804 clippy::cast_sign_loss,
1805 reason = "a positive count of a few thousand"
1806 )]
1807 let draws = (CONSENSUS_MISS.ln() / (-clean).ln_1p()).ceil() as usize;
1808 let mut sequence = Draws::new();
1809 let mut best: Option<(f64, State)> = None;
1810 let mut picked: Vec<usize> = Vec::with_capacity(size);
1811 let mut sizes = vec![0.0; points.len()];
1812 for _ in 0..draws {
1813 picked.clear();
1814 while picked.len() < size {
1815 let i = sequence.below(points.len());
1816 if !picked.contains(&i) {
1817 picked.push(i);
1818 }
1819 }
1820 let subset: Vec<Point> = picked.iter().map(|i| points[*i]).collect();
1821 let subset_normals: Option<Vec<Vector>> =
1822 normals.map(|n| picked.iter().map(|i| n[*i]).collect());
1823 let Ok(candidate) = start(kind, &subset, subset_normals.as_deref(), given, fixed, tol)
1824 else {
1825 continue;
1826 };
1827 for (s, p) in sizes.iter_mut().zip(points) {
1828 *s = residual(kind, &candidate, *p);
1829 }
1830 let median = Cutoff::Median(1.0).threshold(&sizes);
1831 if median.is_finite() && best.is_none_or(|(held, _)| median < held) {
1832 best = Some((median, candidate));
1833 }
1834 }
1835 match best {
1836 Some((_, state)) => Ok(state),
1837 None => start(kind, points, normals, given, fixed, tol),
1838 }
1839}
1840
1841pub(super) struct Draws(u64);
1844
1845impl Draws {
1846 pub(super) const fn new() -> Self {
1848 Self(0x9E37_79B9_7F4A_7C15)
1849 }
1850
1851 pub(super) fn below(&mut self, n: usize) -> usize {
1853 let mut x = self.0;
1854 x ^= x >> 12;
1855 x ^= x << 25;
1856 x ^= x >> 27;
1857 self.0 = x;
1858 let wide = u128::from(x.wrapping_mul(0x2545_F491_4F6C_DD1D)) * n as u128;
1859 usize::try_from(wide >> 64).unwrap_or(0)
1860 }
1861}
1862
1863#[cfg(test)]
1864mod tests {
1865 #![allow(clippy::unwrap_used, reason = "test code")]
1866 use super::*;
1867
1868 const T: Tolerances = Tolerances::millimetres();
1869
1870 #[test]
1874 fn the_derivatives_are_the_distances_own() {
1875 let axis = Direction::new(Vector::new(0.2, -0.3, 1.0), T).unwrap();
1876 let state = State {
1877 origin: Point::new(1.0, 2.0, -1.0),
1878 axis,
1879 radius: 3.0,
1880 second: 0.4,
1881 };
1882 let torus = State {
1883 second: 9.0,
1884 ..state
1885 };
1886 let points = [
1887 Point::new(4.0, 1.0, 2.0),
1888 Point::new(-3.0, 5.0, 0.5),
1889 Point::new(2.5, -6.0, -4.0),
1890 Point::new(9.0, 3.0, 1.0),
1891 ];
1892 for (kind, at) in [
1893 (Kind::Sphere, state),
1894 (Kind::Circle3, state),
1895 (Kind::Cylinder, state),
1896 (Kind::Cone, state),
1897 (Kind::Torus, torus),
1898 ] {
1899 let frame = basis(kind, &at);
1900 for p in points {
1901 let (rows, count) = distances(kind, &at, frame, p);
1902 let on = free(kind, &FitFixed::default());
1903 for k in (0..PARAMETERS).filter(|k| on[*k]) {
1904 let h = 1e-6;
1905 let moved = |sign: f64| {
1906 let mut step = [0.0; PARAMETERS];
1907 step[k] = sign * h;
1908 let s = stepped(kind, &at, frame, &step, at.origin, T);
1911 distances(kind, &s, basis(kind, &s), p).0
1912 };
1913 let (up, down) = (moved(1.0), moved(-1.0));
1914 for c in 0..count {
1915 let numeric = (up[c].0 - down[c].0) / (2.0 * h);
1916 let analytic = rows[c].1[k];
1917 assert!(
1918 (numeric - analytic).abs() < 1e-6,
1919 "{kind:?} parameter {k} component {c}: {analytic} against {numeric}"
1920 );
1921 }
1922 }
1923 }
1924 }
1925 }
1926
1927 #[test]
1932 #[allow(clippy::cast_precision_loss, reason = "a few points")]
1933 fn a_planes_leverage_is_the_closed_form() {
1934 let points: Vec<Point> = (0..7)
1935 .flat_map(|i| (0..5).map(move |j| (i, j)))
1936 .map(|(i, j)| {
1937 let (x, y) = (f64::from(i) * 0.7, f64::from(j) * 1.3 - 0.4);
1938 Point::new(x, y, 0.01 * f64::from((i * 3 + j * 5) % 7) - 0.03)
1939 })
1940 .collect();
1941 let normals = vec![Vector::Z; points.len()];
1942 let options = FitOptions {
1943 normals: Some(&normals),
1944 ..FitOptions::default()
1945 };
1946 let fit = plane(&points, &options, T).unwrap();
1947 let model = Model::plane(&fit).measured_over(&points, &fit.used, &FitFixed::default());
1948 assert_eq!(model.parameters(), 3);
1949
1950 let n = fit.shape.normal().vector();
1951 let e1 = fit.shape.normal().any_perpendicular().vector();
1952 let e2 = n.cross(e1);
1953 let count = points.len() as f64;
1954 let centroid = points
1955 .iter()
1956 .fold(Vector::ZERO, |sum, p| sum + p.to_vector())
1957 / count;
1958 let across = |p: Point| {
1959 let w = p.to_vector() - centroid;
1960 (w.dot(e1), w.dot(e2))
1961 };
1962 let (mut a, mut b, mut c) = (0.0, 0.0, 0.0);
1963 for p in &points {
1964 let (u, v) = across(*p);
1965 a += u * u;
1966 b += u * v;
1967 c += v * v;
1968 }
1969 let det = a.mul_add(c, -(b * b));
1970 for p in [Point::new(9.0, -4.0, 0.2), Point::new(2.1, 2.2, 0.0)] {
1971 let (u, v) = across(p);
1972 let closed = 1.0 / count + (c * u * u - 2.0 * b * u * v + a * v * v) / det;
1973 let got = model.leverage(p);
1974 assert!(
1975 (got - closed).abs() <= 1e-12 * closed,
1976 "{got} against {closed}"
1977 );
1978 }
1979 }
1980
1981 #[test]
1986 #[allow(clippy::cast_precision_loss, reason = "a few points")]
1987 fn a_lines_leverage_is_the_closed_form() {
1988 let points: Vec<Point> = (0..11)
1989 .map(|i| {
1990 let t = f64::from(i) * 0.9 - 2.0;
1991 let wobble = 0.01 * f64::from((i * 3) % 7) - 0.03;
1992 Point::new(1.0 + 0.6 * t, -2.0 + 0.8 * t + wobble, 0.5 - wobble)
1993 })
1994 .collect();
1995 let fit = line3(&points, &FitOptions::default(), T).unwrap();
1996 let model = Model::line3(&fit).measured_over(&points, &fit.used, &FitFixed::default());
1997 assert_eq!(model.parameters(), 4);
1998 assert_eq!(model.components(), 2);
1999
2000 let d = fit.shape.direction.vector();
2001 let count = points.len() as f64;
2002 let centroid = points
2003 .iter()
2004 .fold(Vector::ZERO, |sum, p| sum + p.to_vector())
2005 / count;
2006 let along = |p: Point| (p.to_vector() - centroid).dot(d);
2007 let scatter: f64 = points.iter().map(|p| along(*p).powi(2)).sum();
2008 for p in [Point::new(9.0, -4.0, 0.2), Point::new(1.1, -1.9, 0.6)] {
2009 let s = along(p);
2010 let closed = 2.0 * (1.0 / count + s * s / scatter);
2011 let got = model.leverage(p);
2012 assert!(
2013 (got - closed).abs() <= 1e-12 * closed,
2014 "{got} against {closed}"
2015 );
2016 }
2017 }
2018}