1use nalgebra::{Matrix6, SymmetricEigen, Vector6};
4use ogeom_core::{OgeomResult, ogeom_bail, parallel};
5use ogeom_math::{Matrix3, Point, Quaternion, Transform, Vector};
6use ogeom_mesh::Bvh;
7use ogeom_topo::Triangulation;
8
9use super::{FreeMotion, Icp, Registration, Starts, is_finite, orientation, rigid};
10use crate::distance::{DistanceOptions, Pseudonormals, to_mesh_with};
11
12pub fn icp(
30 source: &[Point],
31 source_normals: Option<&[Vector]>,
32 target: &Bvh,
33 opts: &Icp,
34) -> OgeomResult<Registration> {
35 let mesh = Mesh::of(target)?;
36 let samples = Samples::of(source, source_normals, opts)?;
37 let starts = starts(&samples, target, opts)?;
38 let runs = parallel::map_ordered(&starts, |_, &start| {
41 mesh.register(&samples, opts, start)
42 .map(|run| run.registration(&samples))
43 });
44 let mut best: Option<Registration> = None;
45 for found in runs {
46 let found = found?;
47 if best.as_ref().is_none_or(|b| found.score < b.score) {
48 best = Some(found);
49 }
50 }
51 best.map_or_else(|| ogeom_bail!(Domain, "no start to register from"), Ok)
52}
53
54pub(super) struct Mesh<'a> {
57 index: &'a Bvh,
58 normals: Pseudonormals,
59 cap: f64,
60}
61
62impl<'a> Mesh<'a> {
63 pub(super) fn of(index: &'a Bvh) -> OgeomResult<Self> {
64 if index.is_empty() {
65 ogeom_bail!(Domain, "an empty mesh registers nothing");
66 }
67 Ok(Self {
68 index,
69 normals: Pseudonormals::of(index),
70 cap: index.bounds().diagonal(),
71 })
72 }
73
74 fn cap(&self, opts: &Icp) -> f64 {
78 opts.max_distance.unwrap_or(self.cap)
79 }
80
81 pub(super) fn pair(&self, samples: &Samples, opts: &Icp, pose: &Pose) -> OgeomResult<Paired> {
84 let m = pose.q.to_matrix();
85 let moved: Vec<Point> = samples.points.iter().map(|&p| pose.apply(&m, p)).collect();
86 let normals: Option<Vec<Vector>> = samples
87 .normals
88 .as_ref()
89 .map(|n| n.iter().map(|&v| m * v).collect());
90 let options = DistanceOptions {
91 max: opts.max_distance.unwrap_or(f64::INFINITY),
92 facing_limit: opts.facing_limit,
93 signed: false,
94 };
95 let found = to_mesh_with(
96 self.index,
97 &self.normals,
98 &moved,
99 normals.as_deref(),
100 &options,
101 )?;
102 let cap = self.cap(opts);
103 let mut capped = 0.0;
104 let mut pairs = Vec::with_capacity(found.len());
105 for (i, closest) in found.into_iter().enumerate() {
106 match closest {
107 Some(c) => {
108 capped += c.distance.min(cap);
109 pairs.push(Pair {
110 sample: i,
111 at: moved[i],
112 foot: c.point,
113 normal: c.normal,
114 distance: c.distance,
115 });
116 }
117 None => capped += cap,
118 }
119 }
120 let distances: Vec<f64> = pairs.iter().map(|p| p.distance).collect();
121 let threshold = opts.cutoff.threshold(&distances);
122 pairs.retain(|p| p.distance <= threshold);
123 #[allow(
124 clippy::cast_precision_loss,
125 reason = "sample counts are far below 2^52"
126 )]
127 let score = capped / samples.points.len() as f64;
128 Ok(Paired { kept: pairs, score })
129 }
130
131 pub(super) fn register(&self, samples: &Samples, opts: &Icp, start: Pose) -> OgeomResult<Run> {
133 let reach = opts.tol.confusion();
134 let mut pose = start;
135 let mut converged = false;
136 for _ in 0..opts.max_iter {
137 let paired = self.pair(samples, opts, &pose)?;
138 if paired.kept.is_empty() {
139 break;
140 }
141 let step = if opts.point_to_plane {
142 NormalMatrix::of(&paired.kept).step()
143 } else {
144 let pairs = paired.kept.iter().map(|p| (p.at, p.foot, 1.0));
145 orientation(pairs).map_or(Pose::IDENTITY, |(q, t)| Pose { q, t })
146 };
147 let m = step.q.to_matrix();
148 let moves = paired
149 .kept
150 .iter()
151 .map(|p| (step.apply(&m, p.at) - p.at).magnitude())
152 .fold(0.0, f64::max);
153 pose = step.then(&pose);
154 if moves <= reach {
155 converged = true;
156 break;
157 }
158 }
159 let paired = self.pair(samples, opts, &pose)?;
160 Ok(Run {
161 pose,
162 converged,
163 paired,
164 })
165 }
166}
167
168pub(super) struct Samples {
170 pub(super) points: Vec<Point>,
171 pub(super) normals: Option<Vec<Vector>>,
172}
173
174impl Samples {
175 pub(super) fn of(
178 points: &[Point],
179 normals: Option<&[Vector]>,
180 opts: &Icp,
181 ) -> OgeomResult<Self> {
182 opts.check()?;
183 if points.is_empty() {
184 ogeom_bail!(Domain, "no points to register");
185 }
186 if points.iter().any(|&p| !is_finite(p)) {
187 ogeom_bail!(Domain, "a point to register is not finite");
188 }
189 if let Some(n) = normals
190 && n.len() != points.len()
191 {
192 ogeom_bail!(Dimension, "{} normals for {} points", n.len(), points.len());
193 }
194 if opts.facing_limit.is_some() && normals.is_none() {
195 ogeom_bail!(Domain, "a facing limit needs the points' normals");
196 }
197 let chosen = chosen(points.len(), opts.sample, opts.seed);
198 Ok(Self {
199 points: chosen.iter().map(|&i| points[i]).collect(),
200 normals: normals.map(|n| chosen.iter().map(|&i| n[i]).collect()),
201 })
202 }
203
204 pub(super) fn mapped(&self, f: impl Fn(Point) -> Point, g: impl Fn(Vector) -> Vector) -> Self {
206 Self {
207 points: self.points.iter().map(|&p| f(p)).collect(),
208 normals: self
209 .normals
210 .as_ref()
211 .map(|n| n.iter().map(|&v| g(v)).collect()),
212 }
213 }
214}
215
216fn chosen(count: usize, want: usize, seed: u64) -> Vec<usize> {
220 if want == 0 || want >= count {
221 return (0..count).collect();
222 }
223 let mut state = seed;
224 let mut next = move || {
225 state = state.wrapping_add(0x9e37_79b9_7f4a_7c15);
227 let mut z = state;
228 z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
229 z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
230 z ^ (z >> 31)
231 };
232 let mut taken = vec![false; count];
233 for j in count - want..count {
234 let bound = j as u64 + 1;
235 let t = usize::try_from(next() % bound).unwrap_or(j);
236 let pick = if taken[t] { j } else { t };
237 taken[pick] = true;
238 }
239 (0..count).filter(|&i| taken[i]).collect()
240}
241
242#[derive(Debug, Clone, Copy)]
244pub(super) struct Pose {
245 pub(super) q: Quaternion,
246 pub(super) t: Vector,
247}
248
249impl Pose {
250 pub(super) const IDENTITY: Self = Self {
251 q: Quaternion::IDENTITY,
252 t: Vector::ZERO,
253 };
254
255 fn of(t: &Transform, opts: &Icp) -> OgeomResult<Self> {
257 Ok(Self {
258 q: Quaternion::from_matrix(&t.linear(), opts.tol)?,
259 t: t.translation_vector(),
260 })
261 }
262
263 pub(super) fn apply(&self, m: &Matrix3, p: Point) -> Point {
265 Point::from_vector(*m * p.to_vector() + self.t)
266 }
267
268 fn then(&self, first: &Self) -> Self {
270 let q = self.q * first.q;
271 Self {
272 q: q * (1.0 / q.norm()),
273 t: self.q.to_matrix() * first.t + self.t,
274 }
275 }
276
277 pub(super) fn transform(&self) -> Transform {
278 rigid(self.q, self.t)
279 }
280}
281
282#[derive(Debug, Clone, Copy)]
284pub(super) struct Pair {
285 pub(super) sample: usize,
286 pub(super) at: Point,
287 pub(super) foot: Point,
288 normal: Vector,
290 pub(super) distance: f64,
291}
292
293pub(super) struct Paired {
295 pub(super) kept: Vec<Pair>,
297 pub(super) score: f64,
299}
300
301pub(super) struct Run {
303 pub(super) pose: Pose,
304 pub(super) converged: bool,
305 pub(super) paired: Paired,
306}
307
308impl Run {
309 fn registration(self, samples: &Samples) -> Registration {
310 let kept = &self.paired.kept;
311 let free_directions = if kept.is_empty() {
312 Vec::new()
313 } else {
314 NormalMatrix::of(kept).free()
315 };
316 Registration {
317 transform: self.pose.transform(),
318 rms: rms(kept),
319 inliers: kept.len(),
320 sampled: samples.points.len(),
321 converged: self.converged,
322 score: self.paired.score,
323 free_directions,
324 }
325 }
326}
327
328pub(super) fn rms(pairs: &[Pair]) -> f64 {
330 if pairs.is_empty() {
331 return 0.0;
332 }
333 #[allow(clippy::cast_precision_loss, reason = "pair counts are far below 2^52")]
334 let count = pairs.len() as f64;
335 (pairs.iter().map(|p| p.distance * p.distance).sum::<f64>() / count).sqrt()
336}
337
338struct NormalMatrix {
342 a: Matrix6<f64>,
343 g: Vector6<f64>,
344 centre: Point,
345 radius: f64,
346 noise: f64,
348 count: usize,
349}
350
351impl NormalMatrix {
352 fn of(pairs: &[Pair]) -> Self {
353 let first = pairs[0].at;
354 #[allow(clippy::cast_precision_loss, reason = "pair counts are far below 2^52")]
355 let count = pairs.len() as f64;
356 let shift = pairs.iter().fold(Vector::ZERO, |s, p| s + (p.at - first)) / count;
357 let centre = first + shift;
358 let spread = pairs
359 .iter()
360 .map(|p| (p.at - centre).square_magnitude())
361 .sum::<f64>()
362 / count;
363 let radius = spread.sqrt();
364 let scale = if radius > 0.0 { radius } else { 1.0 };
366 let mut a = Matrix6::zeros();
367 let mut g = Vector6::zeros();
368 let mut squares = 0.0;
369 for p in pairs {
370 let turn = (p.at - centre).cross(p.normal) / scale;
371 let row = Vector6::new(turn.x, turn.y, turn.z, p.normal.x, p.normal.y, p.normal.z);
372 let b = (p.foot - p.at).dot(p.normal);
373 a += row * row.transpose();
374 g += row * b;
375 squares += b * b;
376 }
377 Self {
378 a,
379 g,
380 centre,
381 radius: scale,
382 noise: (squares / count).sqrt(),
383 count: pairs.len(),
384 }
385 }
386
387 fn rounding(&self, largest: f64) -> f64 {
391 #[allow(clippy::cast_precision_loss, reason = "pair counts are far below 2^52")]
392 let count = self.count as f64;
393 largest * count * f64::EPSILON
394 }
395
396 fn step(&self) -> Pose {
400 let eigen = SymmetricEigen::new(self.a);
401 let largest = eigen.eigenvalues.max();
402 let floor = self.rounding(largest);
403 let mut x = Vector6::zeros();
404 for (j, &lambda) in eigen.eigenvalues.iter().enumerate() {
405 if lambda > floor && lambda > 0.0 {
406 let v = eigen.eigenvectors.column(j);
407 x += v * (v.dot(&self.g) / lambda);
408 }
409 }
410 let omega = Vector::new(x[0], x[1], x[2]) / self.radius;
411 let t = Vector::new(x[3], x[4], x[5]);
412 let angle = omega.magnitude();
413 let q = if angle > 0.0 {
414 let axis = omega / angle;
415 let (s, c) = (angle * 0.5).sin_cos();
416 Quaternion::new(c, axis.x * s, axis.y * s, axis.z * s)
417 } else {
418 Quaternion::IDENTITY
419 };
420 let c = self.centre.to_vector();
422 Pose {
423 q,
424 t: c - q.to_matrix() * c + t,
425 }
426 }
427
428 fn free(&self) -> Vec<FreeMotion> {
433 let eigen = SymmetricEigen::new(self.a);
434 let largest = eigen.eigenvalues.max();
435 let relative = self.noise / self.radius;
436 let threshold = (largest * relative * relative).max(self.rounding(largest));
437 let free: Vec<Vector6<f64>> = (0..6)
438 .filter(|&j| eigen.eigenvalues[j] <= threshold)
439 .map(|j| eigen.eigenvectors.column(j).into_owned())
440 .collect();
441 if free.is_empty() {
442 return Vec::new();
443 }
444 let k = free.len();
445 let turn = |v: &Vector6<f64>| nalgebra::Vector3::new(v[0], v[1], v[2]);
446 let gram = nalgebra::DMatrix::from_fn(k, k, |i, j| turn(&free[i]).dot(&turn(&free[j])));
447 let split = SymmetricEigen::new(gram);
448 let mut order: Vec<usize> = (0..k).collect();
449 order.sort_by(|&i, &j| {
450 split.eigenvalues[i]
451 .total_cmp(&split.eigenvalues[j])
452 .then(i.cmp(&j))
453 });
454 order
455 .into_iter()
456 .map(|c| {
457 let w = split.eigenvectors.column(c);
458 let mut v = free
459 .iter()
460 .zip(w.iter())
461 .fold(Vector6::zeros(), |s, (f, &wi)| s + f * wi);
462 let big = (0..6)
464 .max_by(|&i, &j| v[i].abs().total_cmp(&v[j].abs()).then(j.cmp(&i)))
465 .unwrap_or(0);
466 if v[big] < 0.0 {
467 v = -v;
468 }
469 FreeMotion {
470 about: self.centre,
471 radius: self.radius,
472 rotation: Vector::new(v[0], v[1], v[2]) / self.radius,
473 translation: Vector::new(v[3], v[4], v[5]),
474 }
475 })
476 .collect()
477 }
478}
479
480fn starts(samples: &Samples, target: &Bvh, opts: &Icp) -> OgeomResult<Vec<Pose>> {
482 match &opts.starts {
483 Starts::Identity => Ok(vec![Pose::IDENTITY]),
484 Starts::Given(given) => given.iter().map(|t| Pose::of(t, opts)).collect(),
485 Starts::PrincipalAxes24 => principal_starts(samples, target, opts),
486 }
487}
488
489fn principal_starts(samples: &Samples, target: &Bvh, opts: &Icp) -> OgeomResult<Vec<Pose>> {
492 let mut soup = Triangulation::new();
493 for t in 0..u32::try_from(target.triangle_count()).unwrap_or(u32::MAX) {
494 if let Some(corners) = target.triangle(t) {
495 let first = u32::try_from(soup.positions.len()).unwrap_or(u32::MAX);
496 soup.positions.extend(corners);
497 soup.triangles.push([first, first + 1, first + 2]);
498 }
499 }
500 let frame = crate::orient::principal_frame(&soup, opts.tol)?;
501 let to = frame.to_matrix();
502 let (centre, from) = point_axes(&samples.points);
503 let mut poses = Vec::with_capacity(24);
504 for perm in [
505 [0, 1, 2],
506 [0, 2, 1],
507 [1, 0, 2],
508 [1, 2, 0],
509 [2, 0, 1],
510 [2, 1, 0],
511 ] {
512 for signs in 0..8_u8 {
513 let mut q = [[0.0; 3]; 3];
514 for (row, &col) in perm.iter().enumerate() {
515 q[row][col] = if signs >> row & 1 == 0 { 1.0 } else { -1.0 };
516 }
517 let q = Matrix3::new(q);
518 if q.determinant() < 0.0 {
519 continue;
520 }
521 let r = to * q * from.transposed();
522 let rotation = Quaternion::from_matrix(&r, opts.tol)?;
523 poses.push(Pose {
524 q: rotation,
525 t: frame.origin().to_vector() - rotation.to_matrix() * centre.to_vector(),
526 });
527 }
528 }
529 Ok(poses)
530}
531
532fn point_axes(points: &[Point]) -> (Point, Matrix3) {
536 let first = points[0];
537 #[allow(
538 clippy::cast_precision_loss,
539 reason = "point counts are far below 2^52"
540 )]
541 let count = points.len() as f64;
542 let centre = first + points.iter().fold(Vector::ZERO, |s, &p| s + (p - first)) / count;
543 let mut c = nalgebra::Matrix3::<f64>::zeros();
544 for &p in points {
545 let d = p - centre;
546 let d = nalgebra::Vector3::new(d.x, d.y, d.z);
547 c += d * d.transpose();
548 }
549 let eigen = SymmetricEigen::new(c);
550 let column = |i: usize| {
551 let v = eigen.eigenvectors.column(i);
552 Vector::new(v[0], v[1], v[2])
553 };
554 let (x, y) = (column(0), column(1));
555 (centre, Matrix3::from_columns(x, y, x.cross(y)))
556}