Skip to main content

projective_grid/
orient.rs

1//! Local lattice-orientation synthesis from point positions alone.
2//!
3//! Both square strategies consume [`OrientedFeature<2>`] — each corner carries
4//! two local grid directions. When the caller has no per-corner orientation
5//! (`Evidence::Positions`: a dot grid, a circle grid, or a chessboard whose
6//! corners carry no axis estimate), this module recovers those two directions
7//! geometrically so the topological machinery runs
8//! unchanged.
9//!
10//! # The perspective problem
11//!
12//! The grid is viewed in perspective. The two grid directions are therefore
13//! **not** orthogonal in the image, and the angle between them **varies across
14//! the image** (the grid-line families converge toward two vanishing points).
15//! Any method that assumes a fixed 90° between the axes, or a single global
16//! orientation, is wrong. The estimate must be *local* and must not constrain
17//! the inter-axis angle.
18//!
19//! # What is perspective-invariant
20//!
21//! A projective map preserves straight lines, so three collinear grid points
22//! `(i−1, j), (i, j), (i+1, j)` stay collinear in the image. Hence a corner's
23//! `+u` and `−u` neighbour chords are **exactly antipodal** (180° apart) even
24//! under arbitrary perspective. Folding chord orientation **modulo π**
25//! therefore collapses each axis neighbour-pair onto a single direction
26//! *exactly* — with no orthogonality assumption. The two grid directions show
27//! up as two distinct clusters in `[0, π)`, separated by whatever angle the
28//! local perspective dictates.
29//!
30//! A second fact makes the neighbour set reliable: for a grid cell the axis
31//! step is shorter than the diagonal step (`√(a² + b²) > max(a, b)`), and mild
32//! perspective scales both by roughly the same local factor, so a corner's
33//! **four nearest neighbours are its four axis neighbours** (`±u`, `±v`). The
34//! estimate uses those.
35//!
36//! # Algorithm
37//!
38//! 1. Per corner: fold the chord angles to its `k` nearest neighbours into
39//!    `[0, π)`. Generically these are two antipodal pairs collapsing to two
40//!    directions.
41//! 2. Pool every corner's folded nearest-edge angles into a **global
42//!    distribution** and pick its two dominant modes `(g0, g1)`. This is a
43//!    robust, image-wide prior — used only to *seed* the per-corner estimate
44//!    and as a fallback, never as the answer.
45//! 3. Per corner: run an undirected (mod-π) 2-means over the corner's folded
46//!    chords, seeded at `(g0, g1)`. The two resulting centers are the corner's
47//!    two local grid directions — **not** constrained to be orthogonal, so they
48//!    track the local perspective. An empty cluster falls back to its global
49//!    seed.
50//!
51//! # Precision contract
52//!
53//! A corner whose synthesized axes are wrong is rejected downstream by the
54//! seed / attach geometry gates (axis-alignment, edge-length, residual) — it
55//! becomes a *missing* corner, never a *mislabelled* one. That is the correct
56//! trade for the workspace precision contract: a missing corner is acceptable,
57//! a wrong `(i, j)` label is not.
58//!
59//! # Undirected circular statistics
60//!
61//! Axis angles are undirected (period π): `θ` and `θ + π` are the same
62//! direction. Every mean here accumulates `(cos 2θ, sin 2θ)` and halves the
63//! `atan2` result; raw `(cos θ, sin θ)` accumulation would break at the 0/π
64//! seam.
65
66use kiddo::{KdTree, SquaredEuclidean};
67use nalgebra::Point2;
68
69use crate::feature::{LocalAxis, OrientedFeature, PointFeature};
70
71/// Nearest neighbours used to estimate one corner's local grid directions.
72/// Four is the principled count: an interior grid corner's four nearest
73/// neighbours are exactly its axis neighbours (diagonals stay farther).
74const K_AXIS_NEIGHBOURS: usize = 4;
75
76/// Nearest neighbours used to estimate one hex corner's three local grid
77/// directions. Six is the principled count: an interior hex node's six nearest
78/// neighbours are exactly its six axial neighbours (`±` of the three axes).
79const K_HEX_NEIGHBOURS: usize = 6;
80
81/// Minimum chord length (pixels) for a neighbour edge to inform an estimate;
82/// guards against coincident / duplicate points.
83const MIN_CHORD_PX: f32 = 1e-3;
84
85/// Histogram bins over `[0, π)` for the global mode search (2° per bin).
86const GLOBAL_BINS: usize = 90;
87
88/// Minimum separation (radians) required between the two global modes, and the
89/// half-width suppressed around the first mode before searching for the second.
90const MODE_MIN_SEPARATION: f32 = 0.349_065_85; // 20°
91
92/// 2-means iterations per corner. The clusters are tiny (≈4 points) so this
93/// converges immediately; a small fixed budget keeps it allocation-light.
94const REFINE_ITERS: usize = 4;
95
96/// Synthesize two local lattice axes for every point feature from neighbour
97/// geometry, returning oriented-2 features that carry the same `source_index`
98/// and position plus the recovered axes. The two axes are **not** constrained
99/// to be orthogonal — they track the local projected grid directions.
100///
101/// The result is consumed by the topological assembler ([`crate::topological`])
102/// exactly like caller-supplied oriented features.
103pub 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        // Not enough points to recover two directions; emit a benign default.
109        // Such inputs cannot form a grid and are dropped downstream anyway.
110        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    // Per-corner folded chord angles to the k nearest neighbours, each
122    // carrying an inverse-distance weight so a corner's closer (true axis)
123    // neighbours dominate over a farther diagonal that sneaks into the k-set
124    // at a grid boundary.
125    let per_corner: Vec<Vec<(f32, f32)>> = (0..n)
126        .map(|i| nearest_folded_chords(&tree, &positions, i))
127        .collect();
128
129    // Global two-mode prior over the pooled nearest-edge orientations.
130    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
142/// Synthesize the *second* local lattice axis for every single-axis feature,
143/// keeping the caller-supplied axis as the first.
144///
145/// `Evidence::Oriented1` carries one trusted direction per corner (e.g. a
146/// detector that recovers a single dominant edge orientation but not the
147/// orthogonal one). This recovers the missing direction from neighbour
148/// geometry — the same perspective-invariant fold-mod-π / undirected-2-means
149/// machinery as [`synthesize_oriented2`] — but anchors one cluster at the
150/// supplied axis instead of seeding both from the global modes. The supplied
151/// axis is trusted as evidence and is *not* moved; only the second cluster is
152/// recovered from the chords that fall closer to it than to the supplied axis.
153///
154/// The result is consumed by the topological assembler ([`crate::topological`])
155/// exactly like caller-supplied [`OrientedFeature<2>`] — the wiring in
156/// [`crate::detect`] funnels `Oriented1` through this synthesis and then runs
157/// the chosen square strategy, mirroring the `Positions` path.
158///
159/// # Precision contract
160///
161/// Identical to [`synthesize_oriented2`]: a corner whose recovered second axis
162/// is wrong is rejected by the downstream geometry gates and becomes a
163/// *missing* corner, never a *mislabelled* one.
164pub 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        // Too few points to recover a second direction from neighbours. Keep
172        // the supplied axis and seed the second orthogonally; such inputs
173        // cannot form a grid and are dropped downstream regardless.
174        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    // Global two-mode prior, used only to seed the *second* cluster when a
196    // corner's chords don't clearly split — never as the answer.
197    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            // Seed the free cluster at whichever global mode is farther from
205            // the known axis (the likely "other" grid direction).
206            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
217/// Synthesize three local lattice axes for every point feature from neighbour
218/// geometry — the **hex** analogue of [`synthesize_oriented2`].
219///
220/// A hexagonal point lattice has three axis families. Viewed in perspective the
221/// three projected directions are neither 60° apart nor fixed across the image,
222/// but the same perspective-invariant fact holds per axis: three collinear hex
223/// nodes `(q−1, r), (q, r), (q+1, r)` stay collinear, so a node's `+`/`−`
224/// neighbour chords along each axis are antipodal and collapse (mod π) onto one
225/// direction. Folding the six nearest-neighbour chords into `[0, π)` therefore
226/// yields three clusters — the three local grid directions.
227///
228/// # Algorithm (k = 3 generalization of [`synthesize_oriented2`])
229///
230/// 1. Per corner: fold the chord angles to its six nearest neighbours into
231///    `[0, π)`.
232/// 2. Pool every corner's folded chords into a global distribution and pick its
233///    three dominant modes — a robust image-wide prior used only to *seed* the
234///    per-corner estimate.
235/// 3. Per corner: run an undirected (mod-π) 3-means seeded at the global modes.
236///    The three centers are the corner's three local grid directions, with no
237///    inter-axis-angle constraint so they track the local perspective.
238///
239/// # Precision contract
240///
241/// Identical to [`synthesize_oriented2`]: a corner whose synthesized axes are
242/// wrong is rejected by the downstream geometry gates and becomes a *missing*
243/// corner, never a *mislabelled* one.
244pub 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        // Too few points to recover three directions from neighbours. Emit a
250        // benign 0°/60°/120° default; such inputs cannot form a hex grid and
251        // are dropped downstream regardless.
252        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
280/// Recover the second grid direction with the first cluster *pinned* at the
281/// supplied `known` axis. Chords closer (mod π) to `known` are assigned to the
282/// anchored cluster and ignored for the mean; chords closer to the free
283/// cluster refine it via the undirected `(cos 2θ, sin 2θ)` mean. An empty free
284/// cluster keeps its global seed.
285fn 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            // Assign to the free cluster only when it is the closer center;
294            // the anchored `known` cluster absorbs the rest but stays fixed.
295            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
304/// Folded (`[0, π)`) chord angles from corner `i` to its `K_AXIS_NEIGHBOURS`
305/// nearest neighbours, each paired with an inverse-distance weight.
306fn 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
314/// Folded (`[0, π)`) chord angles from corner `i` to its `k` nearest
315/// neighbours, each paired with an inverse-distance weight. The `k = 4`
316/// specialization backs [`synthesize_oriented2`]; `k = 6` backs
317/// [`synthesize_oriented3`] (an interior hex node's six nearest neighbours are
318/// its six axial neighbours).
319fn 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    // `+ 1` because the nearest hit is the query point itself.
327    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
347/// Find the two dominant grid-edge orientations across the whole image from a
348/// smoothed circular (mod-π) histogram of every corner's nearest-edge angles.
349///
350/// Returns `(g0, g1)` with `g1` at least [`MODE_MIN_SEPARATION`] from `g0`.
351/// Falls back to an orthogonal pair only when the data exposes a single
352/// direction — and only as a *seed* for the per-corner refinement, which still
353/// adapts to the true local angle.
354fn 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    // Suppress a window around g0 (mod π) and find the next peak.
377    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        // Only one direction is globally visible. Seed the second axis
393        // orthogonally; per-corner refinement still recovers the true local
394        // (non-orthogonal) angle wherever the data supports it.
395        _ => fold_pi(g0 + std::f32::consts::FRAC_PI_2),
396    };
397    (g0, g1)
398}
399
400/// Find the `K` dominant grid-edge orientations across the whole image from a
401/// smoothed circular (mod-π) histogram of every corner's nearest-edge angles.
402///
403/// Generalizes [`global_two_modes`] to `K` modes via greedy non-maximum
404/// suppression: pick the global peak, suppress a [`MODE_MIN_SEPARATION`] window
405/// around it, repeat. Returns `K` centers sorted ascending in `[0, π)`. When
406/// fewer than `K` distinct peaks exist, the remaining slots are filled by
407/// spreading the found modes uniformly — they only *seed* the per-corner
408/// refinement, which still adapts to the true local angle.
409fn 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    // Uniform fallback when there is no data.
425    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            // Skip bins too close to any already-chosen mode.
441            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            // No further distinct peak: spread the remaining slots uniformly
458            // off the first found mode so every slot stays defined.
459            _ => {
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
469/// Undirected (mod-π) `K`-means over a corner's folded chord angles, seeded at
470/// the global modes. Returns the `K` cluster centers — the corner's `K` local
471/// grid directions, unconstrained in their separation. An empty cluster keeps
472/// its global seed so every slot stays defined.
473fn 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            // Assign to the nearest center under the undirected (mod-π) metric;
482            // ties go to the lowest index for determinism.
483            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
501/// Order three directions ascending into `[0, π)` and wrap into the
502/// `OrientedFeature<3>` axis array.
503fn 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
512/// Undirected (mod-π) 2-means over a corner's folded chord angles, seeded at
513/// the global modes. Returns the two cluster centers — the corner's two local
514/// grid directions, unconstrained in their separation.
515fn 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        // An empty cluster keeps its global seed so the slot stays defined.
531        c0 = acc0.mean().unwrap_or(c0);
532        c1 = acc1.mean().unwrap_or(c1);
533    }
534    (c0, c1)
535}
536
537/// Order two directions into the workspace axis convention:
538/// `axes[0] ∈ [0, π)`, `axes[1] ∈ (axes[0], axes[0] + π)`. Since both inputs
539/// are folded to `[0, π)`, this is an ascending sort.
540fn 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/// Weighted undirected circular-mean accumulator over `[0, π)` via
546/// `(Σ w cos 2θ, Σ w sin 2θ)`.
547#[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/// Smallest undirected angular distance modulo π, in `[0, π/2]`.
570#[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/// Fold an angle into `[0, π)`.
578#[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
591/// Circular box-smooth (mod the histogram length) with a ±2-bin window.
592fn 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/// Circular distance between two bin indices over `n` bins.
619#[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    /// Both synthesized axes must align (undirected) with the two expected
647    /// directions — in either slot order, and with NO orthogonality assumption.
648    fn assert_axes_match(axes: [LocalAxis; 2], exp_a: f32, exp_b: f32, tol_deg: f32) {
649        let tol = tol_deg.to_radians();
650        // axes[0] matches one expected, axes[1] the other (some assignment).
651        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        // Interior corner (2, 2) -> flat index 2*6 + 2 = 14.
667        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            // Pure rotation keeps axes orthogonal: expected theta and theta+90.
684            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        // Project a canonical grid through a homography with a real
696        // perspective term, then check each interior corner's recovered axes
697        // match the LOCAL projected grid directions — which are NOT 90° apart.
698        let h = Matrix3::new(
699            1.0, 0.20, 0.0, //
700            0.0, 1.0, 0.0, //
701            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        // Check several interior corners.
724        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        // The whole point: the perspective view really does bend the inter-axis
739        // angle away from 90° somewhere in the interior.
740        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    // --- Oriented1 → Oriented2 synthesis -------------------------------
766
767    /// Project a `rows × cols` grid through `h`; return the features and the
768    /// ground-truth per-feature LOCAL `+u` axis (the direction to the `(i+1,j)`
769    /// neighbour, folded to `[0, π)`), so each Oriented1 input carries one true
770    /// axis. Interior corners only carry a defined neighbour for both axes.
771    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        // Random-ish homography with a genuine perspective term.
792        let h = Matrix3::new(
793            1.0, 0.16, 0.0, //
794            0.05, 1.0, 0.0, //
795            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        // Build Oriented1 features: supply the TRUE local +u direction per
802        // corner, plus a small angular noise. Position-only otherwise.
803        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                // Use the +u neighbour where it exists, else the -u neighbour.
815                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 interior corners, the recovered axes must match the LOCAL
834        // projected grid directions (both +u and +v), and the supplied axis
835        // must survive as one of the two (within the injected noise band).
836        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                // The supplied (noisy) axis is preserved (anchored): one of the
846                // two output axes equals exp_u within the noise band.
847                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        // On a clean axis-aligned grid the from-Oriented1 synthesis should
860        // produce the same two directions as the from-Positions synthesis:
861        // both recover (0, π/2) at every interior corner.
862        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        // Interior corner (3,3) -> flat 3*7+3 = 24.
870        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        // Supplied axis preserved as one of the two.
884        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    // --- Positions → Oriented3 (hex) synthesis -------------------------
904
905    /// Axial hex node `(q, r)` model position with unit nearest-neighbour
906    /// spacing: `(q + r/2, sqrt(3)/2 · r)`.
907    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    /// Build a roughly-circular patch of hex nodes (axial radius `radius`),
913    /// projected through `h`. Returns the point features plus their `(q, r)`.
914    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    /// Check the three synthesized axes match the three expected directions in
931    /// some assignment (undirected, no inter-axis-angle constraint).
932    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        // Optimal (min-max) assignment over all 3! permutations of expected.
936        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        // Identity homography: the three undirected hex directions are 0°, 60°,
961        // 120° regardless of node — recovered at every interior node.
962        let h = Matrix3::identity();
963        let (feats, qr) = hex_features(3, 26.0, &h);
964        let third = std::f32::consts::PI / 3.0;
965        // Pick a deep-interior node (the centre (0,0)).
966        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        // Genuine perspective term: the three projected hex directions bend
973        // across the patch. The recovered axis along a family is the undirected
974        // mean of the `+` and `−` chords; under perspective those two are
975        // antipodal through the node (collinearity) so the fold-mod-π mean
976        // recovers the chord line. We therefore compare each recovered axis to
977        // the line through the node's two opposite neighbours along that axis.
978        let h = Matrix3::new(
979            1.0, 0.12, 0.0, //
980            0.03, 1.0, 0.0, //
981            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            // Need BOTH opposite neighbours along each of the three axes so the
991            // expected direction is the through-line (matching the mod-π mean).
992            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        // Add small isotropic position noise; the synthesized axes should stay
1013        // within a wider band of the local projected directions.
1014        let h = Matrix3::new(
1015            1.0, 0.10, 0.0, //
1016            0.03, 1.0, 0.0, //
1017            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        // Deterministic xorshift noise, ±0.6 px.
1023        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    /// Helper: run `synthesize_oriented3` and collect just the axis arrays.
1077    fn feats_axes3(feats: &[PointFeature]) -> Vec<[LocalAxis; 3]> {
1078        synthesize_oriented3(feats)
1079            .into_iter()
1080            .map(|o| o.axes)
1081            .collect()
1082    }
1083}