1use serde::{Deserialize, Serialize};
22use thiserror::Error;
23
24use crate::{ContentReference, GridGeometry, ValidationError};
25
26pub const REGISTRATION_SCHEMA: &str = "openbnct.registration/0.1.0";
28
29const ROTATION_TOLERANCE: f64 = 1.0e-4;
34
35#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct RigidTransform {
40 pub rotation: [f64; 9],
42 pub translation_mm: [f64; 3],
44}
45
46impl RigidTransform {
47 pub fn identity() -> Self {
48 Self {
49 rotation: [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
50 translation_mm: [0.0; 3],
51 }
52 }
53
54 pub fn validate(&self) -> Result<(), RegistrationError> {
57 let r = &self.rotation;
58 if r.iter()
59 .chain(self.translation_mm.iter())
60 .any(|v| !v.is_finite())
61 {
62 return Err(RegistrationError::InvalidTransform(
63 "transform contains non-finite values".into(),
64 ));
65 }
66 let dot = |a: [f64; 3], b: [f64; 3]| a[0].mul_add(b[0], a[1].mul_add(b[1], a[2] * b[2]));
67 let columns = |c: usize| [r[c], r[3 + c], r[6 + c]];
68 let (c0, c1, c2) = (columns(0), columns(1), columns(2));
69 if (dot(c0, c0) - 1.0).abs() > ROTATION_TOLERANCE
70 || (dot(c1, c1) - 1.0).abs() > ROTATION_TOLERANCE
71 || (dot(c2, c2) - 1.0).abs() > ROTATION_TOLERANCE
72 || dot(c0, c1).abs() > ROTATION_TOLERANCE
73 || dot(c0, c2).abs() > ROTATION_TOLERANCE
74 || dot(c1, c2).abs() > ROTATION_TOLERANCE
75 {
76 return Err(RegistrationError::InvalidTransform(
77 "rotation is not orthonormal within tolerance".into(),
78 ));
79 }
80 let determinant = r[0] * (r[4] * r[8] - r[5] * r[7]) - r[1] * (r[3] * r[8] - r[5] * r[6])
81 + r[2] * (r[3] * r[7] - r[4] * r[6]);
82 if (determinant - 1.0).abs() > ROTATION_TOLERANCE {
83 return Err(RegistrationError::InvalidTransform(format!(
84 "rotation determinant {determinant} is not +1 (reflection or scale)"
85 )));
86 }
87 Ok(())
88 }
89
90 pub fn apply(&self, point: [f64; 3]) -> [f64; 3] {
92 let r = &self.rotation;
93 [
94 r[0].mul_add(point[0], r[1].mul_add(point[1], r[2] * point[2]))
95 + self.translation_mm[0],
96 r[3].mul_add(point[0], r[4].mul_add(point[1], r[5] * point[2]))
97 + self.translation_mm[1],
98 r[6].mul_add(point[0], r[7].mul_add(point[1], r[8] * point[2]))
99 + self.translation_mm[2],
100 ]
101 }
102
103 pub fn inverse(&self) -> Self {
105 let r = &self.rotation;
106 let rt = [r[0], r[3], r[6], r[1], r[4], r[7], r[2], r[5], r[8]];
107 let t = self.translation_mm;
108 Self {
109 rotation: rt,
110 translation_mm: [
111 -(rt[0] * t[0] + rt[1] * t[1] + rt[2] * t[2]),
112 -(rt[3] * t[0] + rt[4] * t[1] + rt[5] * t[2]),
113 -(rt[6] * t[0] + rt[7] * t[1] + rt[8] * t[2]),
114 ],
115 }
116 }
117
118 pub fn compose(outer: &Self, inner: &Self) -> Self {
120 let (ro, ri) = (&outer.rotation, &inner.rotation);
121 let mut rotation = [0.0; 9];
122 for row in 0..3 {
123 for col in 0..3 {
124 rotation[row * 3 + col] = ro[row * 3] * ri[col]
125 + ro[row * 3 + 1] * ri[3 + col]
126 + ro[row * 3 + 2] * ri[6 + col];
127 }
128 }
129 Self {
130 rotation,
131 translation_mm: outer.apply_from_rotation(inner.translation_mm),
132 }
133 }
134
135 fn apply_from_rotation(&self, point: [f64; 3]) -> [f64; 3] {
136 self.apply(point)
137 }
138
139 pub fn apply_to_geometry(&self, geometry: &GridGeometry) -> GridGeometry {
143 let d = &geometry.direction;
144 let r = &self.rotation;
145 let mut direction = [0.0; 9];
146 for row in 0..3 {
147 for col in 0..3 {
148 direction[row * 3 + col] =
149 r[row * 3] * d[col] + r[row * 3 + 1] * d[3 + col] + r[row * 3 + 2] * d[6 + col];
150 }
151 }
152 GridGeometry {
153 shape: geometry.shape,
154 spacing_mm: geometry.spacing_mm,
155 origin_mm: self.apply(geometry.origin_mm),
156 direction,
157 }
158 }
159}
160
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
165#[serde(deny_unknown_fields)]
166pub struct LandmarkPair {
167 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub name: Option<String>,
169 pub moving_lps_mm: [f64; 3],
170 pub fixed_lps_mm: [f64; 3],
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
176pub enum RegistrationMethod {
177 LandmarkLeastSquares,
180 Declared,
185}
186
187#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
191#[serde(deny_unknown_fields)]
192pub struct Registration {
193 #[serde(deserialize_with = "crate::deserialize_contract_id")]
194 pub schema_version: String,
195 pub id: String,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub moving: Option<ContentReference>,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub fixed: Option<ContentReference>,
202 pub transform: RigidTransform,
203 pub method: RegistrationMethod,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub landmarks: Option<Vec<LandmarkPair>>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub rms_residual_mm: Option<f64>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub note: Option<String>,
213}
214
215impl Registration {
216 pub fn validate(&self) -> Result<(), RegistrationError> {
217 if !crate::schema_matches(&self.schema_version, REGISTRATION_SCHEMA) {
218 return Err(RegistrationError::UnsupportedSchema(
219 self.schema_version.clone(),
220 ));
221 }
222 if self.id.trim().is_empty() {
223 return Err(RegistrationError::Invalid(
224 "registration id is empty".into(),
225 ));
226 }
227 self.transform.validate()?;
228 for reference in [&self.moving, &self.fixed].into_iter().flatten() {
229 reference
230 .validate()
231 .map_err(|_| RegistrationError::Invalid("image reference is invalid".into()))?;
232 }
233 match self.method {
234 RegistrationMethod::LandmarkLeastSquares => {
235 let landmarks = self.landmarks.as_ref().ok_or_else(|| {
236 RegistrationError::Invalid(
237 "landmark_least_squares records must carry the landmark pairs".into(),
238 )
239 })?;
240 if landmarks.len() < 3 {
241 return Err(RegistrationError::Invalid(
242 "landmark_least_squares requires at least three pairs".into(),
243 ));
244 }
245 for pair in landmarks {
246 if pair
247 .moving_lps_mm
248 .iter()
249 .chain(pair.fixed_lps_mm.iter())
250 .any(|v| !v.is_finite())
251 {
252 return Err(RegistrationError::Invalid(
253 "landmark coordinates must be finite".into(),
254 ));
255 }
256 }
257 if self
258 .rms_residual_mm
259 .is_none_or(|rms| !rms.is_finite() || rms < 0.0)
260 {
261 return Err(RegistrationError::Invalid(
262 "landmark_least_squares requires a finite non-negative rms_residual_mm"
263 .into(),
264 ));
265 }
266 }
267 RegistrationMethod::Declared => {
268 if self.landmarks.is_some() || self.rms_residual_mm.is_some() {
269 return Err(RegistrationError::Invalid(
270 "declared registrations carry no landmark evidence".into(),
271 ));
272 }
273 }
274 }
275 Ok(())
276 }
277}
278
279pub fn fit_landmark_transform(
288 landmarks: &[LandmarkPair],
289) -> Result<(RigidTransform, f64), RegistrationError> {
290 if landmarks.len() < 3 {
291 return Err(RegistrationError::DegenerateLandmarks(
292 "at least three landmark pairs are required",
293 ));
294 }
295 for (index, pair) in landmarks.iter().enumerate() {
296 if pair
297 .moving_lps_mm
298 .iter()
299 .chain(pair.fixed_lps_mm.iter())
300 .any(|v| !v.is_finite())
301 {
302 return Err(RegistrationError::Invalid(format!(
303 "landmark {index} contains non-finite coordinates"
304 )));
305 }
306 }
307 let n = landmarks.len() as f64;
308 let centroid = |pick: fn(&LandmarkPair) -> [f64; 3]| -> [f64; 3] {
309 let mut c = [0.0; 3];
310 for pair in landmarks {
311 let p = pick(pair);
312 for axis in 0..3 {
313 c[axis] += p[axis];
314 }
315 }
316 [c[0] / n, c[1] / n, c[2] / n]
317 };
318 let moving_centroid = centroid(|p| p.moving_lps_mm);
319 let fixed_centroid = centroid(|p| p.fixed_lps_mm);
320
321 let centered: Vec<([f64; 3], [f64; 3])> = landmarks
322 .iter()
323 .map(|pair| {
324 let m = pair.moving_lps_mm;
325 let f = pair.fixed_lps_mm;
326 (
327 [
328 m[0] - moving_centroid[0],
329 m[1] - moving_centroid[1],
330 m[2] - moving_centroid[2],
331 ],
332 [
333 f[0] - fixed_centroid[0],
334 f[1] - fixed_centroid[1],
335 f[2] - fixed_centroid[2],
336 ],
337 )
338 })
339 .collect();
340
341 let mut max_d2 = 0.0;
345 let (mut a, mut b) = ([0.0; 3], [0.0; 3]);
346 for (m0, _) in ¢ered {
347 for (m1, _) in ¢ered {
348 let d2 = (0..3).map(|ax| (m1[ax] - m0[ax]).powi(2)).sum::<f64>();
349 if d2 > max_d2 {
350 max_d2 = d2;
351 a = *m0;
352 b = *m1;
353 }
354 }
355 }
356 let line_len = max_d2.sqrt();
357 if line_len <= f64::EPSILON {
358 return Err(RegistrationError::DegenerateLandmarks(
359 "moving landmarks are coincident",
360 ));
361 }
362 let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
363 let mut max_area2 = 0.0;
364 for (m, _) in ¢ered {
365 let am = [m[0] - a[0], m[1] - a[1], m[2] - a[2]];
366 let cross = [
367 am[1] * ab[2] - am[2] * ab[1],
368 am[2] * ab[0] - am[0] * ab[2],
369 am[0] * ab[1] - am[1] * ab[0],
370 ];
371 let area2: f64 = cross.iter().map(|v| v * v).sum();
372 if area2 > max_area2 {
373 max_area2 = area2;
374 }
375 }
376 if max_area2.sqrt() <= line_len * line_len * 1.0e-9 {
379 return Err(RegistrationError::DegenerateLandmarks(
380 "moving landmarks are collinear",
381 ));
382 }
383
384 let mut s = [[0.0; 3]; 3];
387 for (m, f) in ¢ered {
388 for row in 0..3 {
389 for col in 0..3 {
390 s[row][col] += m[row] * f[col];
391 }
392 }
393 }
394 let trace = s[0][0] + s[1][1] + s[2][2];
395 let n_matrix = [
396 [
397 trace,
398 s[1][2] - s[2][1],
399 s[2][0] - s[0][2],
400 s[0][1] - s[1][0],
401 ],
402 [
403 s[1][2] - s[2][1],
404 s[0][0] - s[1][1] - s[2][2],
405 s[0][1] + s[1][0],
406 s[2][0] + s[0][2],
407 ],
408 [
409 s[2][0] - s[0][2],
410 s[0][1] + s[1][0],
411 -s[0][0] + s[1][1] - s[2][2],
412 s[1][2] + s[2][1],
413 ],
414 [
415 s[0][1] - s[1][0],
416 s[2][0] + s[0][2],
417 s[1][2] + s[2][1],
418 -s[0][0] - s[1][1] + s[2][2],
419 ],
420 ];
421 let q = largest_eigenvector_symmetric_4(&n_matrix).ok_or(
422 RegistrationError::DegenerateLandmarks("landmark fit did not converge"),
423 )?;
424 let (w, x, y, z) = (q[0], q[1], q[2], q[3]);
425 let rotation = [
426 w.mul_add(w, x * x) - y.mul_add(y, z * z),
427 2.0 * x.mul_add(y, -(w * z)),
428 2.0 * x.mul_add(z, w * y),
429 2.0 * x.mul_add(y, w * z),
430 w.mul_add(w, y * y) - x.mul_add(x, z * z),
431 2.0 * y.mul_add(z, -(w * x)),
432 2.0 * x.mul_add(z, -(w * y)),
433 2.0 * y.mul_add(z, w * x),
434 w.mul_add(w, z * z) - x.mul_add(x, y * y),
435 ];
436 let transform = RigidTransform {
437 rotation,
438 translation_mm: [0.0; 3],
439 };
440 let rotated_centroid = transform.apply(moving_centroid);
441 let transform = RigidTransform {
442 rotation,
443 translation_mm: [
444 fixed_centroid[0] - rotated_centroid[0],
445 fixed_centroid[1] - rotated_centroid[1],
446 fixed_centroid[2] - rotated_centroid[2],
447 ],
448 };
449 transform.validate()?;
450
451 let rms = (landmarks
452 .iter()
453 .map(|pair| {
454 let fitted = transform.apply(pair.moving_lps_mm);
455 (0..3)
456 .map(|axis| (fitted[axis] - pair.fixed_lps_mm[axis]).powi(2))
457 .sum::<f64>()
458 })
459 .sum::<f64>()
460 / n)
461 .sqrt();
462 Ok((transform, rms))
463}
464
465fn largest_eigenvector_symmetric_4(n: &[[f64; 4]; 4]) -> Option<[f64; 4]> {
469 let mut a = *n;
470 let mut v = [[0.0; 4]; 4];
471 for (i, row) in v.iter_mut().enumerate() {
472 row[i] = 1.0;
473 }
474 let scale: f64 = a
475 .iter()
476 .flat_map(|row| row.iter())
477 .map(|v| v.abs())
478 .fold(0.0, f64::max)
479 .max(1.0);
480 for _sweep in 0..64 {
481 let mut max_off = 0.0;
484 let (mut p, mut q) = (0, 1);
485 for (i, row) in a.iter().enumerate() {
486 for (j, value) in row.iter().enumerate().skip(i + 1) {
487 if value.abs() > max_off {
488 max_off = value.abs();
489 p = i;
490 q = j;
491 }
492 }
493 }
494 if max_off <= 1.0e-14 * scale {
495 break;
496 }
497 let app = a[p][p];
498 let aqq = a[q][q];
499 let apq = a[p][q];
500 let theta = 0.5 * (2.0 * apq).atan2(aqq - app);
503 let (c, s) = (theta.cos(), theta.sin());
504 for row in a.iter_mut() {
505 let (akp, akq) = (row[p], row[q]);
506 row[p] = c * akp - s * akq;
507 row[q] = s * akp + c * akq;
508 }
509 let (before, after) = a.split_at_mut(q);
510 let (row_p, row_q) = (&mut before[p], &mut after[0]);
511 for (apk, aqk) in row_p.iter_mut().zip(row_q.iter_mut()) {
512 let (x, y) = (*apk, *aqk);
513 *apk = c * x - s * y;
514 *aqk = s * x + c * y;
515 }
516 for row in v.iter_mut() {
517 let (vkp, vkq) = (row[p], row[q]);
518 row[p] = c * vkp - s * vkq;
519 row[q] = s * vkp + c * vkq;
520 }
521 }
522 let (mut best, mut best_value) = (0, f64::NEG_INFINITY);
523 for (i, row) in a.iter().enumerate() {
524 if row[i] > best_value {
525 best_value = row[i];
526 best = i;
527 }
528 }
529 if !best_value.is_finite() {
530 return None;
531 }
532 let column = [v[0][best], v[1][best], v[2][best], v[3][best]];
533 let norm: f64 = column.iter().map(|x| x * x).sum::<f64>().sqrt();
534 if !matches!(norm.partial_cmp(&0.0), Some(std::cmp::Ordering::Greater)) {
535 return None;
536 }
537 let mut q = [
538 column[0] / norm,
539 column[1] / norm,
540 column[2] / norm,
541 column[3] / norm,
542 ];
543 if q[0] < 0.0 {
545 for component in &mut q {
546 *component = -*component;
547 }
548 }
549 Some(q)
550}
551
552pub fn landmark_registration(
554 id: impl Into<String>,
555 moving: Option<ContentReference>,
556 fixed: Option<ContentReference>,
557 landmarks: Vec<LandmarkPair>,
558 note: Option<String>,
559) -> Result<Registration, RegistrationError> {
560 let (transform, rms) = fit_landmark_transform(&landmarks)?;
561 let registration = Registration {
562 schema_version: REGISTRATION_SCHEMA.into(),
563 id: id.into(),
564 moving,
565 fixed,
566 transform,
567 method: RegistrationMethod::LandmarkLeastSquares,
568 landmarks: Some(landmarks),
569 rms_residual_mm: Some(rms),
570 note,
571 };
572 registration.validate()?;
573 Ok(registration)
574}
575
576pub fn declared_registration(
578 id: impl Into<String>,
579 moving: Option<ContentReference>,
580 fixed: Option<ContentReference>,
581 transform: RigidTransform,
582 note: Option<String>,
583) -> Result<Registration, RegistrationError> {
584 let registration = Registration {
585 schema_version: REGISTRATION_SCHEMA.into(),
586 id: id.into(),
587 moving,
588 fixed,
589 transform,
590 method: RegistrationMethod::Declared,
591 landmarks: None,
592 rms_residual_mm: None,
593 note,
594 };
595 registration.validate()?;
596 Ok(registration)
597}
598
599#[derive(Debug, Error)]
600pub enum RegistrationError {
601 #[error("unsupported registration schema {0:?}")]
602 UnsupportedSchema(String),
603 #[error("invalid registration: {0}")]
604 Invalid(String),
605 #[error("invalid rigid transform: {0}")]
606 InvalidTransform(String),
607 #[error("degenerate landmark set: {0}")]
608 DegenerateLandmarks(&'static str),
609 #[error("invalid geometry: {0}")]
610 InvalidGeometry(#[from] ValidationError),
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616
617 fn landmarks(points: &[[f64; 3]], transform: &RigidTransform) -> Vec<LandmarkPair> {
618 points
619 .iter()
620 .map(|p| LandmarkPair {
621 name: None,
622 moving_lps_mm: transform.apply(*p),
623 fixed_lps_mm: *p,
624 })
625 .collect()
626 }
627
628 fn phantom_points() -> Vec<[f64; 3]> {
629 vec![
630 [0.0, 0.0, 0.0],
631 [80.0, 0.0, 0.0],
632 [0.0, 90.0, 0.0],
633 [0.0, 0.0, 70.0],
634 [40.0, 30.0, 50.0],
635 [-20.0, 60.0, 10.0],
636 ]
637 }
638
639 fn known_transform() -> RigidTransform {
641 let (c, s) = (30.0_f64.to_radians().cos(), 30.0_f64.to_radians().sin());
642 RigidTransform {
643 rotation: [c, -s, 0.0, s, c, 0.0, 0.0, 0.0, 1.0],
644 translation_mm: [12.0, -7.5, 4.0],
645 }
646 }
647
648 #[test]
649 fn exact_landmarks_recover_the_transform() {
650 let known = known_transform();
651 let pairs = landmarks(&phantom_points(), &known);
653 let (fit, rms) = fit_landmark_transform(&pairs).unwrap();
654 let expected = known.inverse();
655 for i in 0..9 {
656 assert!((fit.rotation[i] - expected.rotation[i]).abs() < 1e-9);
657 }
658 for i in 0..3 {
659 assert!((fit.translation_mm[i] - expected.translation_mm[i]).abs() < 1e-9);
660 }
661 assert!(rms < 1e-9, "rms {rms}");
662 }
663
664 #[test]
665 fn arbitrary_rotation_and_translation_recover() {
666 let (c1, s1) = (0.35_f64.cos(), 0.35_f64.sin());
668 let (c2, s2) = (0.6_f64.cos(), 0.6_f64.sin());
669 let rz = RigidTransform {
670 rotation: [c1, -s1, 0.0, s1, c1, 0.0, 0.0, 0.0, 1.0],
671 translation_mm: [0.0; 3],
672 };
673 let ry = RigidTransform {
674 rotation: [c2, 0.0, s2, 0.0, 1.0, 0.0, -s2, 0.0, c2],
675 translation_mm: [0.0; 3],
676 };
677 let known = RigidTransform {
678 rotation: RigidTransform::compose(&rz, &ry).rotation,
679 translation_mm: [-30.0, 15.0, 22.5],
680 };
681 let pairs = landmarks(&phantom_points(), &known);
682 let (fit, _) = fit_landmark_transform(&pairs).unwrap();
683 let probe = [11.0, -23.0, 47.0];
684 let want = known.inverse().apply(probe);
685 let got = fit.apply(probe);
686 for axis in 0..3 {
687 assert!((got[axis] - want[axis]).abs() < 1e-8);
688 }
689 }
690
691 #[test]
692 fn degenerate_landmark_sets_are_rejected() {
693 let pairs = landmarks(&phantom_points()[..2], &known_transform());
694 assert!(matches!(
695 fit_landmark_transform(&pairs),
696 Err(RegistrationError::DegenerateLandmarks(_))
697 ));
698 let coincident = vec![
699 LandmarkPair {
700 name: None,
701 moving_lps_mm: [5.0, 5.0, 5.0],
702 fixed_lps_mm: [0.0, 0.0, 0.0],
703 },
704 LandmarkPair {
705 name: None,
706 moving_lps_mm: [5.0, 5.0, 5.0],
707 fixed_lps_mm: [1.0, 0.0, 0.0],
708 },
709 LandmarkPair {
710 name: None,
711 moving_lps_mm: [5.0, 5.0, 5.0],
712 fixed_lps_mm: [0.0, 1.0, 0.0],
713 },
714 ];
715 assert!(matches!(
716 fit_landmark_transform(&coincident),
717 Err(RegistrationError::DegenerateLandmarks(_))
718 ));
719 let collinear: Vec<LandmarkPair> = (0..4)
721 .map(|i| LandmarkPair {
722 name: None,
723 moving_lps_mm: [i as f64 * 10.0, 0.0, 0.0],
724 fixed_lps_mm: [i as f64 * 10.0, 0.0, 0.0],
725 })
726 .collect();
727 assert!(matches!(
728 fit_landmark_transform(&collinear),
729 Err(RegistrationError::DegenerateLandmarks(_))
730 ));
731 }
732
733 #[test]
734 fn transform_inverse_and_compose_round_trip() {
735 let t = known_transform();
736 let p = [33.0, -14.0, 25.0];
737 let round = t.inverse().apply(t.apply(p));
738 for axis in 0..3 {
739 assert!((round[axis] - p[axis]).abs() < 1e-9);
740 }
741 let composed = RigidTransform::compose(&t, &t.inverse());
742 for i in 0..9 {
743 let want = if i % 4 == 0 { 1.0 } else { 0.0 };
744 assert!((composed.rotation[i] - want).abs() < 1e-9);
745 }
746 assert!(composed.translation_mm.iter().all(|v| v.abs() < 1e-9));
747 }
748
749 #[test]
750 fn declared_transforms_must_be_rigid() {
751 let mut t = RigidTransform::identity();
752 t.rotation[0] = 2.0; assert!(t.validate().is_err());
754 let reflection = RigidTransform {
755 rotation: [-1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
756 translation_mm: [0.0; 3],
757 };
758 assert!(reflection.validate().is_err());
759 }
760
761 #[test]
762 fn geometry_transform_preserves_voxel_mapping() {
763 let geometry = GridGeometry {
766 shape: [4, 4, 4],
767 spacing_mm: [2.0, 3.0, 4.0],
768 origin_mm: [-10.0, -5.0, 0.0],
769 direction: [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
770 };
771 let t = known_transform();
772 let moved = t.apply_to_geometry(&geometry);
773 moved.voxel_count().unwrap(); let original = geometry.voxel_center_lps_mm([3, 2, 1]).unwrap();
775 let transformed = moved.voxel_center_lps_mm([3, 2, 1]).unwrap();
776 let want = t.apply(original);
777 for axis in 0..3 {
778 assert!((transformed[axis] - want[axis]).abs() < 1e-9);
779 }
780 }
781
782 #[test]
783 fn registration_documents_validate_method_evidence() {
784 let pairs = landmarks(&phantom_points(), &known_transform());
785 let registration = landmark_registration("reg.test", None, None, pairs, None).unwrap();
786 registration.validate().unwrap();
787 assert!(registration.rms_residual_mm.unwrap() < 1e-9);
788
789 let mut declared = declared_registration(
790 "reg.declared",
791 None,
792 None,
793 known_transform(),
794 Some("external TPS matrix".into()),
795 )
796 .unwrap();
797 declared.validate().unwrap();
798 declared.landmarks = Some(vec![]);
800 assert!(declared.validate().is_err());
801 }
802}