use kiddo::{KdTree, SquaredEuclidean};
use nalgebra::{Point2, Vector2};
use crate::circular_stats::{angular_dist_pi, wrap_pi};
use crate::topological::AxisHint;
use super::{Seed, SeedOutput};
pub trait SeedQuadValidator {
fn position(&self, idx: usize) -> Point2<f32>;
fn axes(&self, idx: usize) -> [AxisHint; 2];
fn a_candidates(&self) -> Vec<usize>;
fn bc_candidates(&self) -> Vec<usize>;
fn edge_ok(&self, _from: usize, _to: usize, _axis_tol_rad: f32) -> bool {
true
}
fn has_midpoint_violation(&self, _seed: Seed, _cell_size: f32) -> bool {
false
}
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug)]
pub struct SeedQuadParams {
pub axis_tol_rad: f32,
pub edge_ratio_tol: f32,
pub close_tol_rel: f32,
pub k_bc: usize,
pub top_per_axis: usize,
}
impl Default for SeedQuadParams {
fn default() -> Self {
Self {
axis_tol_rad: 15.0_f32.to_radians(),
edge_ratio_tol: 0.30,
close_tol_rel: 0.30,
k_bc: 32,
top_per_axis: 6,
}
}
}
impl SeedQuadParams {
pub fn new(axis_tol_rad: f32, edge_ratio_tol: f32, close_tol_rel: f32) -> Self {
Self {
axis_tol_rad,
edge_ratio_tol,
close_tol_rel,
..Self::default()
}
}
}
pub fn find_quad<V: SeedQuadValidator>(
validator: &V,
params: &SeedQuadParams,
) -> Option<SeedOutput> {
let a_indices = validator.a_candidates();
let bc_indices = validator.bc_candidates();
if a_indices.is_empty() || bc_indices.is_empty() {
return None;
}
let min_ratio = 1.0 - params.edge_ratio_tol;
let max_ratio = 1.0 + params.edge_ratio_tol;
let ratio_floor = min_ratio / max_ratio;
let mut a_tree: KdTree<f32, 2> = KdTree::new();
for (slot, &idx) in a_indices.iter().enumerate() {
let p = validator.position(idx);
a_tree.add(&[p.x, p.y], slot as u64);
}
let mut bc_tree: KdTree<f32, 2> = KdTree::new();
for (slot, &idx) in bc_indices.iter().enumerate() {
let p = validator.position(idx);
bc_tree.add(&[p.x, p.y], slot as u64);
}
for &a_idx in &a_indices {
let a_pos = validator.position(a_idx);
let a_axes = validator.axes(a_idx);
let a_axis0 = wrap_pi(a_axes[0].angle);
let a_axis1 = wrap_pi(a_axes[1].angle);
let mut neighbors: Vec<(usize, f32, Vector2<f32>)> = bc_tree
.nearest_n::<SquaredEuclidean>(&[a_pos.x, a_pos.y], params.k_bc)
.into_iter()
.map(|nn| {
let slot = nn.item as usize;
let idx = bc_indices[slot];
let p = validator.position(idx);
let off = Vector2::new(p.x - a_pos.x, p.y - a_pos.y);
(idx, nn.distance.sqrt(), off)
})
.filter(|(_, d, _)| d.is_finite() && *d > 1e-3)
.collect();
neighbors.sort_by(|a, b| a.1.total_cmp(&b.1));
if neighbors.len() < 2 {
continue;
}
let mut b_cands: Vec<(usize, f32, Vector2<f32>)> = Vec::new();
let mut c_cands: Vec<(usize, f32, Vector2<f32>)> = Vec::new();
for (idx, dist, off) in &neighbors {
let ang = wrap_pi(off.y.atan2(off.x));
let d0 = angular_dist_pi(ang, a_axis0);
let d1 = angular_dist_pi(ang, a_axis1);
if d0 <= params.axis_tol_rad && d0 < d1 {
b_cands.push((*idx, *dist, *off));
} else if d1 <= params.axis_tol_rad && d1 < d0 {
c_cands.push((*idx, *dist, *off));
}
}
if b_cands.is_empty() || c_cands.is_empty() {
continue;
}
for (b_idx, b_dist, b_off) in b_cands.iter().take(params.top_per_axis) {
for (c_idx, c_dist, c_off) in c_cands.iter().take(params.top_per_axis) {
if b_idx == c_idx {
continue;
}
let ab = *b_dist;
let ac = *c_dist;
if ab.min(ac) / ab.max(ac) < ratio_floor {
continue;
}
let pred = a_pos + b_off + c_off;
let avg_edge = (ab + ac) * 0.5;
let close_px_sq = (params.close_tol_rel * avg_edge).powi(2);
let mut best: Option<(usize, f32)> = None;
for nn in a_tree
.within_unsorted::<SquaredEuclidean>(&[pred.x, pred.y], close_px_sq)
.into_iter()
{
let slot = nn.item as usize;
let d_idx = a_indices[slot];
if d_idx == a_idx {
continue;
}
let d = nn.distance.sqrt();
if best.map(|b| d < b.1).unwrap_or(true) {
best = Some((d_idx, d));
}
}
let Some((d_idx, _gap)) = best else { continue };
let bd = (validator.position(d_idx) - validator.position(*b_idx)).norm();
let cd = (validator.position(d_idx) - validator.position(*c_idx)).norm();
let all = [ab, ac, bd, cd];
let emin = all.iter().copied().fold(f32::INFINITY, f32::min);
let emax = all.iter().copied().fold(0.0_f32, f32::max);
if emax <= 0.0 || emin / emax < ratio_floor {
continue;
}
if !validator.edge_ok(a_idx, *b_idx, params.axis_tol_rad)
|| !validator.edge_ok(a_idx, *c_idx, params.axis_tol_rad)
|| !validator.edge_ok(*b_idx, d_idx, params.axis_tol_rad)
|| !validator.edge_ok(*c_idx, d_idx, params.axis_tol_rad)
{
continue;
}
let cell_size = (ab + ac + bd + cd) * 0.25;
let seed = Seed {
a: a_idx,
b: *b_idx,
c: *c_idx,
d: d_idx,
};
if validator.has_midpoint_violation(seed, cell_size) {
continue;
}
return Some(SeedOutput { seed, cell_size });
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
struct ToyValidator<'a> {
positions: &'a [Point2<f32>],
axes: &'a [[AxisHint; 2]],
is_a: Vec<bool>,
}
impl<'a> SeedQuadValidator for ToyValidator<'a> {
fn position(&self, idx: usize) -> Point2<f32> {
self.positions[idx]
}
fn axes(&self, idx: usize) -> [AxisHint; 2] {
self.axes[idx]
}
fn a_candidates(&self) -> Vec<usize> {
(0..self.is_a.len()).filter(|&i| self.is_a[i]).collect()
}
fn bc_candidates(&self) -> Vec<usize> {
(0..self.is_a.len()).filter(|&i| !self.is_a[i]).collect()
}
}
fn checkerboard(rows: i32, cols: i32, s: f32) -> ToyValidator<'static> {
let n = (rows * cols) as usize;
let mut positions = Vec::with_capacity(n);
let mut axes = Vec::with_capacity(n);
let mut is_a = Vec::with_capacity(n);
for j in 0..rows {
for i in 0..cols {
positions.push(Point2::new(i as f32 * s + 50.0, j as f32 * s + 50.0));
axes.push([
AxisHint::from_angle(0.0_f32),
AxisHint::from_angle(std::f32::consts::FRAC_PI_2),
]);
is_a.push((i + j).rem_euclid(2) == 0);
}
}
ToyValidator {
positions: Box::leak(positions.into_boxed_slice()),
axes: Box::leak(axes.into_boxed_slice()),
is_a,
}
}
#[test]
fn finds_quad_on_clean_grid() {
let v = checkerboard(5, 5, 20.0);
let out = find_quad(&v, &SeedQuadParams::default()).expect("seed");
assert!((out.cell_size - 20.0).abs() < 0.5);
assert!(v.is_a[out.seed.a]);
assert!(!v.is_a[out.seed.b]);
assert!(!v.is_a[out.seed.c]);
assert!(v.is_a[out.seed.d]);
}
#[test]
fn returns_none_when_one_class_is_empty() {
let axes_arr: &[[AxisHint; 2]] = &[
[
AxisHint::from_angle(0.0),
AxisHint::from_angle(std::f32::consts::FRAC_PI_2),
],
[
AxisHint::from_angle(0.0),
AxisHint::from_angle(std::f32::consts::FRAC_PI_2),
],
];
let v = ToyValidator {
positions: &[Point2::new(0.0, 0.0), Point2::new(10.0, 0.0)],
axes: axes_arr,
is_a: vec![true, true],
};
assert!(find_quad(&v, &SeedQuadParams::default()).is_none());
}
#[test]
fn rejects_seeds_that_violate_edge_ok() {
struct AlwaysReject<'a>(&'a ToyValidator<'a>);
impl<'a> SeedQuadValidator for AlwaysReject<'a> {
fn position(&self, idx: usize) -> Point2<f32> {
self.0.position(idx)
}
fn axes(&self, idx: usize) -> [AxisHint; 2] {
self.0.axes(idx)
}
fn a_candidates(&self) -> Vec<usize> {
self.0.a_candidates()
}
fn bc_candidates(&self) -> Vec<usize> {
self.0.bc_candidates()
}
fn edge_ok(&self, _: usize, _: usize, _: f32) -> bool {
false
}
}
let inner = checkerboard(5, 5, 20.0);
let outer = AlwaysReject(&inner);
assert!(find_quad(&outer, &SeedQuadParams::default()).is_none());
}
#[test]
fn rejects_seeds_with_midpoint_violation() {
struct MidpointViolator<'a>(&'a ToyValidator<'a>);
impl<'a> SeedQuadValidator for MidpointViolator<'a> {
fn position(&self, idx: usize) -> Point2<f32> {
self.0.position(idx)
}
fn axes(&self, idx: usize) -> [AxisHint; 2] {
self.0.axes(idx)
}
fn a_candidates(&self) -> Vec<usize> {
self.0.a_candidates()
}
fn bc_candidates(&self) -> Vec<usize> {
self.0.bc_candidates()
}
fn has_midpoint_violation(&self, _: Seed, _: f32) -> bool {
true
}
}
let inner = checkerboard(5, 5, 20.0);
let outer = MidpointViolator(&inner);
assert!(find_quad(&outer, &SeedQuadParams::default()).is_none());
}
}