1use kiddo::{KdTree, SquaredEuclidean};
67use nalgebra::Point2;
68
69use crate::feature::{LocalAxis, OrientedFeature, PointFeature};
70
71const K_AXIS_NEIGHBOURS: usize = 4;
75
76const K_HEX_NEIGHBOURS: usize = 6;
80
81const MIN_CHORD_PX: f32 = 1e-3;
84
85const GLOBAL_BINS: usize = 90;
87
88const MODE_MIN_SEPARATION: f32 = 0.349_065_85; const REFINE_ITERS: usize = 4;
95
96pub fn synthesize_oriented2(features: &[PointFeature]) -> Vec<OrientedFeature<2>> {
104 let positions: Vec<Point2<f32>> = features.iter().map(|f| f.position).collect();
105 let n = positions.len();
106
107 if n < 3 {
108 return features
111 .iter()
112 .map(|f| OrientedFeature::<2>::new(*f, ordered_axes(0.0, std::f32::consts::FRAC_PI_2)))
113 .collect();
114 }
115
116 let mut tree: KdTree<f32, 2> = KdTree::new();
117 for (i, p) in positions.iter().enumerate() {
118 tree.add(&[p.x, p.y], i as u64);
119 }
120
121 let per_corner: Vec<Vec<(f32, f32)>> = (0..n)
126 .map(|i| nearest_folded_chords(&tree, &positions, i))
127 .collect();
128
129 let (g0, g1) = global_two_modes(&per_corner);
131
132 features
133 .iter()
134 .enumerate()
135 .map(|(i, feat)| {
136 let (a0, a1) = refine_axes(&per_corner[i], g0, g1);
137 OrientedFeature::<2>::new(*feat, ordered_axes(a0, a1))
138 })
139 .collect()
140}
141
142pub fn synthesize_oriented2_from_oriented1(
165 features: &[OrientedFeature<1>],
166) -> Vec<OrientedFeature<2>> {
167 let positions: Vec<Point2<f32>> = features.iter().map(|f| f.point.position).collect();
168 let n = positions.len();
169
170 if n < 3 {
171 return features
175 .iter()
176 .map(|f| {
177 let a0 = fold_pi(f.axes[0].angle_rad);
178 OrientedFeature::<2>::new(
179 f.point,
180 ordered_axes(a0, fold_pi(a0 + std::f32::consts::FRAC_PI_2)),
181 )
182 })
183 .collect();
184 }
185
186 let mut tree: KdTree<f32, 2> = KdTree::new();
187 for (i, p) in positions.iter().enumerate() {
188 tree.add(&[p.x, p.y], i as u64);
189 }
190
191 let per_corner: Vec<Vec<(f32, f32)>> = (0..n)
192 .map(|i| nearest_folded_chords(&tree, &positions, i))
193 .collect();
194
195 let (g0, g1) = global_two_modes(&per_corner);
198
199 features
200 .iter()
201 .enumerate()
202 .map(|(i, feat)| {
203 let known = fold_pi(feat.axes[0].angle_rad);
204 let seed1 = if dist_pi(g0, known) >= dist_pi(g1, known) {
207 g0
208 } else {
209 g1
210 };
211 let second = refine_second_axis(&per_corner[i], known, seed1);
212 OrientedFeature::<2>::new(feat.point, ordered_axes(known, second))
213 })
214 .collect()
215}
216
217pub fn synthesize_oriented3(features: &[PointFeature]) -> Vec<OrientedFeature<3>> {
245 let positions: Vec<Point2<f32>> = features.iter().map(|f| f.position).collect();
246 let n = positions.len();
247
248 if n < 4 {
249 let third = std::f32::consts::PI / 3.0;
253 return features
254 .iter()
255 .map(|f| OrientedFeature::<3>::new(*f, ordered_axes3([0.0, third, 2.0 * third])))
256 .collect();
257 }
258
259 let mut tree: KdTree<f32, 2> = KdTree::new();
260 for (i, p) in positions.iter().enumerate() {
261 tree.add(&[p.x, p.y], i as u64);
262 }
263
264 let per_corner: Vec<Vec<(f32, f32)>> = (0..n)
265 .map(|i| nearest_folded_chords_k(&tree, &positions, i, K_HEX_NEIGHBOURS))
266 .collect();
267
268 let globals = global_k_modes::<3>(&per_corner);
269
270 features
271 .iter()
272 .enumerate()
273 .map(|(i, feat)| {
274 let axes = refine_axes_k::<3>(&per_corner[i], globals);
275 OrientedFeature::<3>::new(*feat, ordered_axes3(axes))
276 })
277 .collect()
278}
279
280fn refine_second_axis(folded: &[(f32, f32)], known: f32, seed1: f32) -> f32 {
286 if folded.is_empty() {
287 return seed1;
288 }
289 let mut c1 = seed1;
290 for _ in 0..REFINE_ITERS {
291 let mut acc1 = UndirectedMean::default();
292 for &(a, w) in folded {
293 if dist_pi(a, c1) < dist_pi(a, known) {
296 acc1.push(a, w);
297 }
298 }
299 c1 = acc1.mean().unwrap_or(c1);
300 }
301 c1
302}
303
304fn nearest_folded_chords(
307 tree: &KdTree<f32, 2>,
308 positions: &[Point2<f32>],
309 i: usize,
310) -> Vec<(f32, f32)> {
311 nearest_folded_chords_k(tree, positions, i, K_AXIS_NEIGHBOURS)
312}
313
314fn nearest_folded_chords_k(
320 tree: &KdTree<f32, 2>,
321 positions: &[Point2<f32>],
322 i: usize,
323 k: usize,
324) -> Vec<(f32, f32)> {
325 let p = positions[i];
326 let hits = tree.nearest_n::<SquaredEuclidean>(&[p.x, p.y], k + 1);
328
329 let mut out = Vec::with_capacity(k);
330 for nn in hits {
331 let j = nn.item as usize;
332 if j == i {
333 continue;
334 }
335 let q = positions[j];
336 let dx = q.x - p.x;
337 let dy = q.y - p.y;
338 let d = (dx * dx + dy * dy).sqrt();
339 if d <= MIN_CHORD_PX {
340 continue;
341 }
342 out.push((fold_pi(dy.atan2(dx)), 1.0 / d));
343 }
344 out
345}
346
347fn global_two_modes(per_corner: &[Vec<(f32, f32)>]) -> (f32, f32) {
355 let bin_w = std::f32::consts::PI / GLOBAL_BINS as f32;
356 let mut hist = [0.0_f32; GLOBAL_BINS];
357 let mut total = 0usize;
358 for chords in per_corner {
359 for &(a, w) in chords {
360 let mut b = (a / bin_w) as usize;
361 if b >= GLOBAL_BINS {
362 b = GLOBAL_BINS - 1;
363 }
364 hist[b] += w;
365 total += 1;
366 }
367 }
368 if total == 0 {
369 return (0.0, std::f32::consts::FRAC_PI_2);
370 }
371
372 let smoothed = smooth_circular(&hist);
373 let g0_bin = argmax(&smoothed);
374 let g0 = (g0_bin as f32 + 0.5) * bin_w;
375
376 let suppress = (MODE_MIN_SEPARATION / bin_w).ceil() as i32;
378 let mut best_bin = None;
379 let mut best_val = 0.0_f32;
380 for (b, &v) in smoothed.iter().enumerate() {
381 let circ = circular_bin_distance(b as i32, g0_bin as i32, GLOBAL_BINS as i32);
382 if circ <= suppress {
383 continue;
384 }
385 if v > best_val {
386 best_val = v;
387 best_bin = Some(b);
388 }
389 }
390 let g1 = match best_bin {
391 Some(b) if best_val > 0.0 => (b as f32 + 0.5) * bin_w,
392 _ => fold_pi(g0 + std::f32::consts::FRAC_PI_2),
396 };
397 (g0, g1)
398}
399
400fn global_k_modes<const K: usize>(per_corner: &[Vec<(f32, f32)>]) -> [f32; K] {
410 let pi = std::f32::consts::PI;
411 let bin_w = pi / GLOBAL_BINS as f32;
412 let mut hist = [0.0_f32; GLOBAL_BINS];
413 let mut total = 0usize;
414 for chords in per_corner {
415 for &(a, w) in chords {
416 let mut b = (a / bin_w) as usize;
417 if b >= GLOBAL_BINS {
418 b = GLOBAL_BINS - 1;
419 }
420 hist[b] += w;
421 total += 1;
422 }
423 }
424 let mut out = [0.0_f32; K];
426 for (k, slot) in out.iter_mut().enumerate() {
427 *slot = fold_pi(k as f32 * pi / K as f32);
428 }
429 if total == 0 {
430 return out;
431 }
432
433 let smoothed = smooth_circular(&hist);
434 let suppress = (MODE_MIN_SEPARATION / bin_w).ceil() as i32;
435 let mut chosen_bins: Vec<i32> = Vec::with_capacity(K);
436 for slot in 0..K {
437 let mut best_bin: Option<usize> = None;
438 let mut best_val = 0.0_f32;
439 for (b, &v) in smoothed.iter().enumerate() {
440 if chosen_bins
442 .iter()
443 .any(|&c| circular_bin_distance(b as i32, c, GLOBAL_BINS as i32) <= suppress)
444 {
445 continue;
446 }
447 if v > best_val {
448 best_val = v;
449 best_bin = Some(b);
450 }
451 }
452 match best_bin {
453 Some(b) if best_val > 0.0 => {
454 chosen_bins.push(b as i32);
455 out[slot] = (b as f32 + 0.5) * bin_w;
456 }
457 _ => {
460 let base = out[0];
461 out[slot] = fold_pi(base + slot as f32 * pi / K as f32);
462 }
463 }
464 }
465 out.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
466 out
467}
468
469fn refine_axes_k<const K: usize>(folded: &[(f32, f32)], seeds: [f32; K]) -> [f32; K] {
474 let mut centers = seeds;
475 if folded.is_empty() {
476 return centers;
477 }
478 for _ in 0..REFINE_ITERS {
479 let mut acc = [UndirectedMean::default(); K];
480 for &(a, w) in folded {
481 let mut best = 0usize;
484 let mut best_d = dist_pi(a, centers[0]);
485 for (k, &c) in centers.iter().enumerate().skip(1) {
486 let d = dist_pi(a, c);
487 if d < best_d {
488 best_d = d;
489 best = k;
490 }
491 }
492 acc[best].push(a, w);
493 }
494 for (k, a) in acc.iter().enumerate() {
495 centers[k] = a.mean().unwrap_or(centers[k]);
496 }
497 }
498 centers
499}
500
501fn ordered_axes3(mut a: [f32; 3]) -> [LocalAxis; 3] {
504 a.sort_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal));
505 [
506 LocalAxis::new(a[0], None),
507 LocalAxis::new(a[1], None),
508 LocalAxis::new(a[2], None),
509 ]
510}
511
512fn refine_axes(folded: &[(f32, f32)], g0: f32, g1: f32) -> (f32, f32) {
516 if folded.is_empty() {
517 return (g0, g1);
518 }
519 let (mut c0, mut c1) = (g0, g1);
520 for _ in 0..REFINE_ITERS {
521 let mut acc0 = UndirectedMean::default();
522 let mut acc1 = UndirectedMean::default();
523 for &(a, w) in folded {
524 if dist_pi(a, c0) <= dist_pi(a, c1) {
525 acc0.push(a, w);
526 } else {
527 acc1.push(a, w);
528 }
529 }
530 c0 = acc0.mean().unwrap_or(c0);
532 c1 = acc1.mean().unwrap_or(c1);
533 }
534 (c0, c1)
535}
536
537fn ordered_axes(a: f32, b: f32) -> [LocalAxis; 2] {
541 let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
542 [LocalAxis::new(lo, None), LocalAxis::new(hi, None)]
543}
544
545#[derive(Clone, Copy, Default)]
548struct UndirectedMean {
549 sum_cos: f32,
550 sum_sin: f32,
551 count: usize,
552}
553
554impl UndirectedMean {
555 fn push(&mut self, theta: f32, weight: f32) {
556 self.sum_cos += weight * (2.0 * theta).cos();
557 self.sum_sin += weight * (2.0 * theta).sin();
558 self.count += 1;
559 }
560
561 fn mean(&self) -> Option<f32> {
562 if self.count == 0 || self.sum_cos.hypot(self.sum_sin) < 1e-6 {
563 return None;
564 }
565 Some(fold_pi(0.5 * self.sum_sin.atan2(self.sum_cos)))
566 }
567}
568
569#[inline]
571fn dist_pi(a: f32, b: f32) -> f32 {
572 let pi = std::f32::consts::PI;
573 let d = (a - b).abs() % pi;
574 d.min(pi - d)
575}
576
577#[inline]
579fn fold_pi(theta: f32) -> f32 {
580 let pi = std::f32::consts::PI;
581 let mut t = theta % pi;
582 if t < 0.0 {
583 t += pi;
584 }
585 if t >= pi {
586 t -= pi;
587 }
588 t
589}
590
591fn smooth_circular(hist: &[f32; GLOBAL_BINS]) -> [f32; GLOBAL_BINS] {
593 let mut out = [0.0_f32; GLOBAL_BINS];
594 let n = GLOBAL_BINS as i32;
595 for (i, slot) in out.iter_mut().enumerate() {
596 let mut s = 0.0_f32;
597 for d in -2..=2 {
598 let idx = ((i as i32 + d) % n + n) % n;
599 s += hist[idx as usize];
600 }
601 *slot = s;
602 }
603 out
604}
605
606fn argmax(v: &[f32; GLOBAL_BINS]) -> usize {
607 let mut best = 0usize;
608 let mut best_val = v[0];
609 for (i, &x) in v.iter().enumerate() {
610 if x > best_val {
611 best_val = x;
612 best = i;
613 }
614 }
615 best
616}
617
618#[inline]
620fn circular_bin_distance(a: i32, b: i32, n: i32) -> i32 {
621 let d = (a - b).abs() % n;
622 d.min(n - d)
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628 use nalgebra::Matrix3;
629 use std::collections::HashMap;
630
631 fn grid_features(rows: i32, cols: i32, s: f32) -> Vec<PointFeature> {
632 let mut out = Vec::new();
633 let mut idx = 0usize;
634 for j in 0..rows {
635 for i in 0..cols {
636 out.push(PointFeature::new(
637 idx,
638 Point2::new(i as f32 * s + 40.0, j as f32 * s + 40.0),
639 ));
640 idx += 1;
641 }
642 }
643 out
644 }
645
646 fn assert_axes_match(axes: [LocalAxis; 2], exp_a: f32, exp_b: f32, tol_deg: f32) {
649 let tol = tol_deg.to_radians();
650 let direct = dist_pi(axes[0].angle_rad, exp_a).max(dist_pi(axes[1].angle_rad, exp_b));
652 let swapped = dist_pi(axes[0].angle_rad, exp_b).max(dist_pi(axes[1].angle_rad, exp_a));
653 let err = direct.min(swapped);
654 assert!(
655 err < tol,
656 "axes {:?},{:?} don't match expected {exp_a},{exp_b} (err {err})",
657 axes[0].angle_rad,
658 axes[1].angle_rad
659 );
660 }
661
662 #[test]
663 fn axis_aligned_grid_recovers_horizontal_vertical() {
664 let feats = grid_features(6, 6, 25.0);
665 let oriented = synthesize_oriented2(&feats);
666 assert_axes_match(oriented[14].axes, 0.0, std::f32::consts::FRAC_PI_2, 4.0);
668 }
669
670 #[test]
671 fn rotated_grid_tracks_orientation() {
672 for deg in [10.0_f32, 30.0, 47.0, 80.0] {
673 let theta = deg.to_radians();
674 let (c, s) = (theta.cos(), theta.sin());
675 let feats: Vec<PointFeature> = grid_features(6, 6, 25.0)
676 .iter()
677 .map(|f| {
678 let (x, y) = (f.position.x, f.position.y);
679 PointFeature::new(f.source_index, Point2::new(c * x - s * y, s * x + c * y))
680 })
681 .collect();
682 let oriented = synthesize_oriented2(&feats);
683 assert_axes_match(
685 oriented[14].axes,
686 fold_pi(theta),
687 fold_pi(theta + std::f32::consts::FRAC_PI_2),
688 6.0,
689 );
690 }
691 }
692
693 #[test]
694 fn perspective_grid_axes_are_non_orthogonal_and_correct() {
695 let h = Matrix3::new(
699 1.0, 0.20, 0.0, 0.0, 1.0, 0.0, 0.0015, 0.0009, 1.0,
702 );
703 let project = |gx: f32, gy: f32| -> Point2<f32> {
704 let v = h * nalgebra::Vector3::new(gx, gy, 1.0);
705 Point2::new(v.x / v.z, v.y / v.z)
706 };
707
708 let rows = 9;
709 let cols = 9;
710 let s = 30.0_f32;
711 let mut feats = Vec::new();
712 let mut idx = 0usize;
713 for j in 0..rows {
714 for i in 0..cols {
715 let p = project(i as f32 * s + 40.0, j as f32 * s + 40.0);
716 feats.push(PointFeature::new(idx, p));
717 idx += 1;
718 }
719 }
720 let oriented = synthesize_oriented2(&feats);
721
722 let mut saw_non_orthogonal = false;
723 for j in 2..rows - 2 {
725 for i in 2..cols - 2 {
726 let flat = (j * cols + i) as usize;
727 let here = feats[flat].position;
728 let pu = feats[(j * cols + (i + 1)) as usize].position;
729 let pv = feats[((j + 1) * cols + i) as usize].position;
730 let exp_u = fold_pi((pu.y - here.y).atan2(pu.x - here.x));
731 let exp_v = fold_pi((pv.y - here.y).atan2(pv.x - here.x));
732 assert_axes_match(oriented[flat].axes, exp_u, exp_v, 6.0);
733 if dist_pi(exp_u, exp_v) < 80.0_f32.to_radians() {
734 saw_non_orthogonal = true;
735 }
736 }
737 }
738 assert!(
741 saw_non_orthogonal,
742 "test homography too weak to exercise non-orthogonal axes"
743 );
744 }
745
746 #[test]
747 fn preserves_source_index_and_position() {
748 let feats = grid_features(3, 3, 20.0);
749 let oriented = synthesize_oriented2(&feats);
750 for (f, o) in feats.iter().zip(&oriented) {
751 assert_eq!(o.point.source_index, f.source_index);
752 assert_eq!(o.point.position, f.position);
753 }
754 }
755
756 #[test]
757 fn handles_degenerate_inputs() {
758 assert!(synthesize_oriented2(&[]).is_empty());
759 let one = vec![PointFeature::new(0, Point2::new(1.0, 2.0))];
760 let got = synthesize_oriented2(&one);
761 assert_eq!(got.len(), 1);
762 assert!(got[0].axes[0].angle_rad.is_finite() && got[0].axes[1].angle_rad.is_finite());
763 }
764
765 fn perspective_grid_with_u_axis(
772 rows: i32,
773 cols: i32,
774 s: f32,
775 h: &Matrix3<f32>,
776 ) -> (Vec<Point2<f32>>, Vec<(i32, i32)>) {
777 let mut pts = Vec::new();
778 let mut ij = Vec::new();
779 for j in 0..rows {
780 for i in 0..cols {
781 let v = h * nalgebra::Vector3::new(i as f32 * s + 40.0, j as f32 * s + 40.0, 1.0);
782 pts.push(Point2::new(v.x / v.z, v.y / v.z));
783 ij.push((i, j));
784 }
785 }
786 (pts, ij)
787 }
788
789 #[test]
790 fn oriented1_anchors_supplied_axis_and_recovers_second() {
791 let h = Matrix3::new(
793 1.0, 0.16, 0.0, 0.05, 1.0, 0.0, 0.0012, 0.0008, 1.0,
796 );
797 let (rows, cols, s) = (9, 9, 30.0_f32);
798 let (pts, ij) = perspective_grid_with_u_axis(rows, cols, s, &h);
799 let cols_us = cols as usize;
800
801 let mut rng = 0x9E3779B9u32;
804 let mut next = || {
805 rng ^= rng << 13;
806 rng ^= rng >> 17;
807 rng ^= rng << 5;
808 (rng as f32 / u32::MAX as f32) - 0.5
809 };
810 let o1: Vec<OrientedFeature<1>> = ij
811 .iter()
812 .enumerate()
813 .map(|(flat, &(i, j))| {
814 let here = pts[flat];
816 let u_nb = if i + 1 < cols {
817 pts[(j as usize) * cols_us + (i as usize + 1)]
818 } else {
819 pts[(j as usize) * cols_us + (i as usize - 1)]
820 };
821 let true_u = fold_pi((u_nb.y - here.y).atan2(u_nb.x - here.x));
822 let noisy = true_u + 3.0_f32.to_radians() * next();
823 OrientedFeature::<1>::new(
824 PointFeature::new(flat, here),
825 [LocalAxis::new(noisy, None)],
826 )
827 })
828 .collect();
829
830 let o2 = synthesize_oriented2_from_oriented1(&o1);
831 assert_eq!(o2.len(), o1.len());
832
833 for j in 1..rows - 1 {
837 for i in 1..cols - 1 {
838 let flat = (j as usize) * cols_us + i as usize;
839 let here = pts[flat];
840 let pu = pts[(j as usize) * cols_us + (i as usize + 1)];
841 let pv = pts[((j + 1) as usize) * cols_us + i as usize];
842 let exp_u = fold_pi((pu.y - here.y).atan2(pu.x - here.x));
843 let exp_v = fold_pi((pv.y - here.y).atan2(pv.x - here.x));
844 assert_axes_match(o2[flat].axes, exp_u, exp_v, 6.0);
845 let d0 = dist_pi(o2[flat].axes[0].angle_rad, exp_u);
848 let d1 = dist_pi(o2[flat].axes[1].angle_rad, exp_u);
849 assert!(
850 d0.min(d1) < 4.0_f32.to_radians(),
851 "supplied +u axis not anchored at ({i},{j})"
852 );
853 }
854 }
855 }
856
857 #[test]
858 fn oriented1_matches_oriented2_path_on_clean_grid() {
859 let feats = grid_features(7, 7, 25.0);
863 let o1: Vec<OrientedFeature<1>> = feats
864 .iter()
865 .map(|f| OrientedFeature::<1>::new(*f, [LocalAxis::new(0.0, None)]))
866 .collect();
867 let from_o1 = synthesize_oriented2_from_oriented1(&o1);
868 let from_pos = synthesize_oriented2(&feats);
869 assert_axes_match(from_o1[24].axes, 0.0, std::f32::consts::FRAC_PI_2, 4.0);
871 assert_axes_match(from_pos[24].axes, 0.0, std::f32::consts::FRAC_PI_2, 4.0);
872 }
873
874 #[test]
875 fn oriented1_handles_degenerate_inputs() {
876 assert!(synthesize_oriented2_from_oriented1(&[]).is_empty());
877 let one = vec![OrientedFeature::<1>::new(
878 PointFeature::new(0, Point2::new(1.0, 2.0)),
879 [LocalAxis::new(0.3, None)],
880 )];
881 let got = synthesize_oriented2_from_oriented1(&one);
882 assert_eq!(got.len(), 1);
883 let d = dist_pi(got[0].axes[0].angle_rad, fold_pi(0.3))
885 .min(dist_pi(got[0].axes[1].angle_rad, fold_pi(0.3)));
886 assert!(d < 1e-4);
887 }
888
889 #[test]
890 fn oriented1_preserves_source_index_and_position() {
891 let feats = grid_features(3, 3, 20.0);
892 let o1: Vec<OrientedFeature<1>> = feats
893 .iter()
894 .map(|f| OrientedFeature::<1>::new(*f, [LocalAxis::new(0.1, None)]))
895 .collect();
896 let got = synthesize_oriented2_from_oriented1(&o1);
897 for (f, o) in feats.iter().zip(&got) {
898 assert_eq!(o.point.source_index, f.source_index);
899 assert_eq!(o.point.position, f.position);
900 }
901 }
902
903 fn hex_model(q: i32, r: i32) -> Point2<f32> {
908 let sqrt3_2 = 3.0_f32.sqrt() * 0.5;
909 Point2::new(q as f32 + 0.5 * r as f32, sqrt3_2 * r as f32)
910 }
911
912 fn hex_features(radius: i32, s: f32, h: &Matrix3<f32>) -> (Vec<PointFeature>, Vec<(i32, i32)>) {
915 let mut feats = Vec::new();
916 let mut qr = Vec::new();
917 let mut idx = 0usize;
918 for q in -radius..=radius {
919 for r in (-radius).max(-q - radius)..=radius.min(-q + radius) {
920 let m = hex_model(q, r);
921 let v = h * nalgebra::Vector3::new(m.x * s + 200.0, m.y * s + 200.0, 1.0);
922 feats.push(PointFeature::new(idx, Point2::new(v.x / v.z, v.y / v.z)));
923 qr.push((q, r));
924 idx += 1;
925 }
926 }
927 (feats, qr)
928 }
929
930 fn assert_axes3_match(axes: [LocalAxis; 3], exp: [f32; 3], tol_deg: f32) {
933 let tol = tol_deg.to_radians();
934 let got = [axes[0].angle_rad, axes[1].angle_rad, axes[2].angle_rad];
935 const PERMS: [[usize; 3]; 6] = [
937 [0, 1, 2],
938 [0, 2, 1],
939 [1, 0, 2],
940 [1, 2, 0],
941 [2, 0, 1],
942 [2, 1, 0],
943 ];
944 let best_max = PERMS
945 .iter()
946 .map(|p| {
947 (0..3)
948 .map(|k| dist_pi(got[k], exp[p[k]]))
949 .fold(0.0_f32, f32::max)
950 })
951 .fold(f32::INFINITY, f32::min);
952 assert!(
953 best_max < tol,
954 "axes {got:?} don't match expected {exp:?} (max err {best_max})"
955 );
956 }
957
958 #[test]
959 fn hex_axis_aligned_recovers_three_directions() {
960 let h = Matrix3::identity();
963 let (feats, qr) = hex_features(3, 26.0, &h);
964 let third = std::f32::consts::PI / 3.0;
965 let centre = qr.iter().position(|&c| c == (0, 0)).unwrap();
967 assert_axes3_match(feats_axes3(&feats)[centre], [0.0, third, 2.0 * third], 5.0);
968 }
969
970 #[test]
971 fn hex_perspective_axes_track_local_directions() {
972 let h = Matrix3::new(
979 1.0, 0.12, 0.0, 0.03, 1.0, 0.0, 0.0006, 0.0004, 1.0,
982 );
983 let (feats, qr) = hex_features(4, 24.0, &h);
984 let index: HashMap<(i32, i32), usize> =
985 qr.iter().enumerate().map(|(i, &c)| (c, i)).collect();
986 let oriented = synthesize_oriented3(&feats);
987
988 let mut checked = 0usize;
989 for (flat, &(q, r)) in qr.iter().enumerate() {
990 let line_dir = |a: (i32, i32), b: (i32, i32)| -> Option<f32> {
993 let pa = feats[*index.get(&a)?].position;
994 let pb = feats[*index.get(&b)?].position;
995 Some(fold_pi((pb.y - pa.y).atan2(pb.x - pa.x)))
996 };
997 let (Some(dq), Some(dr), Some(ds)) = (
998 line_dir((q - 1, r), (q + 1, r)),
999 line_dir((q, r - 1), (q, r + 1)),
1000 line_dir((q + 1, r - 1), (q - 1, r + 1)),
1001 ) else {
1002 continue;
1003 };
1004 assert_axes3_match(oriented[flat].axes, [dq, dr, ds], 8.0);
1005 checked += 1;
1006 }
1007 assert!(checked >= 4, "too few interior nodes checked: {checked}");
1008 }
1009
1010 #[test]
1011 fn hex_axes_survive_position_noise() {
1012 let h = Matrix3::new(
1015 1.0, 0.10, 0.0, 0.03, 1.0, 0.0, 0.0005, 0.0004, 1.0,
1018 );
1019 let (mut feats, qr) = hex_features(4, 24.0, &h);
1020 let index: HashMap<(i32, i32), usize> =
1021 qr.iter().enumerate().map(|(i, &c)| (c, i)).collect();
1022 let mut rng = 0x1234_5678u32;
1024 let mut next = || {
1025 rng ^= rng << 13;
1026 rng ^= rng >> 17;
1027 rng ^= rng << 5;
1028 (rng as f32 / u32::MAX as f32) - 0.5
1029 };
1030 for f in feats.iter_mut() {
1031 f.position.x += 1.2 * next();
1032 f.position.y += 1.2 * next();
1033 }
1034 let oriented = synthesize_oriented3(&feats);
1035 let mut checked = 0usize;
1036 for (flat, &(q, r)) in qr.iter().enumerate() {
1037 let line_dir = |a: (i32, i32), b: (i32, i32)| -> Option<f32> {
1038 let pa = feats[*index.get(&a)?].position;
1039 let pb = feats[*index.get(&b)?].position;
1040 Some(fold_pi((pb.y - pa.y).atan2(pb.x - pa.x)))
1041 };
1042 let (Some(dq), Some(dr), Some(ds)) = (
1043 line_dir((q - 1, r), (q + 1, r)),
1044 line_dir((q, r - 1), (q, r + 1)),
1045 line_dir((q + 1, r - 1), (q - 1, r + 1)),
1046 ) else {
1047 continue;
1048 };
1049 assert_axes3_match(oriented[flat].axes, [dq, dr, ds], 12.0);
1050 checked += 1;
1051 }
1052 assert!(checked >= 4, "too few interior nodes checked: {checked}");
1053 }
1054
1055 #[test]
1056 fn hex_preserves_source_index_and_position() {
1057 let h = Matrix3::identity();
1058 let (feats, _) = hex_features(2, 20.0, &h);
1059 let oriented = synthesize_oriented3(&feats);
1060 assert_eq!(oriented.len(), feats.len());
1061 for (f, o) in feats.iter().zip(&oriented) {
1062 assert_eq!(o.point.source_index, f.source_index);
1063 assert_eq!(o.point.position, f.position);
1064 }
1065 }
1066
1067 #[test]
1068 fn hex_handles_degenerate_inputs() {
1069 assert!(synthesize_oriented3(&[]).is_empty());
1070 let one = vec![PointFeature::new(0, Point2::new(1.0, 2.0))];
1071 let got = synthesize_oriented3(&one);
1072 assert_eq!(got.len(), 1);
1073 assert!(got[0].axes.iter().all(|a| a.angle_rad.is_finite()));
1074 }
1075
1076 fn feats_axes3(feats: &[PointFeature]) -> Vec<[LocalAxis; 3]> {
1078 synthesize_oriented3(feats)
1079 .into_iter()
1080 .map(|o| o.axes)
1081 .collect()
1082 }
1083}