use std::collections::{HashMap, HashSet, VecDeque};
use nalgebra::Point2;
use super::delaunay::Triangulation;
use crate::lattice::Coord;
#[derive(Clone, Copy, Debug)]
pub(super) struct HexAxisCache {
pub(super) angle_rad: [f32; 3],
pub(super) informative: [bool; 3],
}
impl HexAxisCache {
#[cfg(test)]
#[inline]
fn informative_count(&self) -> usize {
self.informative.iter().filter(|&&b| b).count()
}
}
pub(super) fn build_hex_axis_caches(
features: &[crate::feature::OrientedFeature<3>],
max_sigma_rad: f32,
) -> Vec<HexAxisCache> {
features
.iter()
.map(|f| HexAxisCache {
angle_rad: [
f.axes[0].angle_rad,
f.axes[1].angle_rad,
f.axes[2].angle_rad,
],
informative: [
super::axis::is_informative(&f.axes[0], max_sigma_rad),
super::axis::is_informative(&f.axes[1], max_sigma_rad),
super::axis::is_informative(&f.axes[2], max_sigma_rad),
],
})
.collect()
}
#[inline]
fn axis_diff(theta: f32, alpha: f32) -> f32 {
let pi = std::f32::consts::PI;
let half_pi = pi / 2.0;
let mut d = (theta - alpha) % pi;
if d < 0.0 {
d += pi;
}
if d > half_pi {
d = pi - d;
}
d
}
fn nearest_family(theta: f32, cache: &HexAxisCache, align_tol_rad: f32) -> Option<usize> {
let mut best: Option<(usize, f32)> = None;
for family in 0..3 {
if !cache.informative[family] {
continue;
}
let d = axis_diff(theta, cache.angle_rad[family]);
if !d.is_finite() {
continue;
}
match best {
None => best = Some((family, d)),
Some((_, bd)) if d < bd => best = Some((family, d)),
_ => {}
}
}
best.and_then(|(f, d)| (d < align_tol_rad).then_some(f))
}
#[derive(Clone, Copy, Debug)]
pub(super) struct Triangle {
pub(super) vertices: [usize; 3],
}
impl Triangle {
fn edges(&self) -> [(usize, usize); 3] {
let [a, b, c] = self.vertices;
[ordered(a, b), ordered(b, c), ordered(c, a)]
}
}
#[inline]
fn ordered(a: usize, b: usize) -> (usize, usize) {
if a < b {
(a, b)
} else {
(b, a)
}
}
pub(super) fn classify_hex_cells(
positions: &[Point2<f32>],
axes: &[HexAxisCache],
triangulation: &Triangulation,
align_tol_rad: f32,
) -> Vec<Triangle> {
let mut out = Vec::new();
for t in 0..triangulation.num_tri() {
let base = 3 * t;
let v = [
triangulation.triangles[base],
triangulation.triangles[base + 1],
triangulation.triangles[base + 2],
];
if v[0] == v[1] || v[1] == v[2] || v[2] == v[0] {
continue;
}
let mut families = [usize::MAX; 3];
let mut ok = true;
for (k, &(a, b)) in [(v[0], v[1]), (v[1], v[2]), (v[2], v[0])]
.iter()
.enumerate()
{
let pa = positions[a];
let pb = positions[b];
let theta = (pb.y - pa.y).atan2(pb.x - pa.x);
let fa = nearest_family(theta, &axes[a], align_tol_rad);
let fb = nearest_family(theta, &axes[b], align_tol_rad);
match (fa, fb) {
(Some(family_a), Some(family_b)) if family_a == family_b => {
families[k] = family_a;
}
_ => {
ok = false;
break;
}
}
}
if !ok {
continue;
}
if families[0] == families[1] || families[1] == families[2] || families[0] == families[2] {
continue;
}
out.push(Triangle { vertices: v });
}
out
}
#[derive(Clone, Debug, Default)]
pub(super) struct HexComponent {
pub(super) labelled: HashMap<Coord, usize>,
}
fn build_edge_index(triangles: &[Triangle]) -> HashMap<(usize, usize), Vec<(usize, usize)>> {
let mut idx: HashMap<(usize, usize), Vec<(usize, usize)>> = HashMap::new();
for (ti, t) in triangles.iter().enumerate() {
let [a, b, c] = t.vertices;
idx.entry(ordered(a, b)).or_default().push((ti, c));
idx.entry(ordered(b, c)).or_default().push((ti, a));
idx.entry(ordered(c, a)).or_default().push((ti, b));
}
idx
}
fn connected_components(
triangles: &[Triangle],
edge_index: &HashMap<(usize, usize), Vec<(usize, usize)>>,
) -> (Vec<u32>, u32) {
let mut comp_of = vec![u32::MAX; triangles.len()];
let mut next_comp: u32 = 0;
for start in 0..triangles.len() {
if comp_of[start] != u32::MAX {
continue;
}
let cid = next_comp;
next_comp += 1;
comp_of[start] = cid;
let mut q = VecDeque::new();
q.push_back(start);
while let Some(ti) = q.pop_front() {
for edge in triangles[ti].edges() {
if let Some(buddies) = edge_index.get(&edge) {
for &(tj, _) in buddies {
if tj != ti && comp_of[tj] == u32::MAX {
comp_of[tj] = cid;
q.push_back(tj);
}
}
}
}
}
}
(comp_of, next_comp)
}
fn seed_labels(t: &Triangle) -> HashMap<usize, Coord> {
let mut m = HashMap::new();
m.insert(t.vertices[0], Coord::new(0, 0));
m.insert(t.vertices[1], Coord::new(1, 0));
m.insert(t.vertices[2], Coord::new(0, 1));
m
}
fn rebase_to_origin(labelled: &mut HashMap<Coord, usize>) {
if labelled.is_empty() {
return;
}
let min_u = labelled.keys().map(|c| c.u).min().unwrap();
let min_v = labelled.keys().map(|c| c.v).min().unwrap();
if min_u == 0 && min_v == 0 {
return;
}
let rebased: HashMap<Coord, usize> = labelled
.drain()
.map(|(c, v)| (Coord::new(c.u - min_u, c.v - min_v), v))
.collect();
*labelled = rebased;
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(
level = "debug",
skip_all,
fields(num_triangles = triangles.len()),
)
)]
pub(super) fn label_components(
triangles: &[Triangle],
min_corners_per_component: usize,
) -> Vec<HexComponent> {
if triangles.is_empty() {
return Vec::new();
}
let edge_index = build_edge_index(triangles);
let (comp_of, n_comp) = connected_components(triangles, &edge_index);
let mut tris_by_comp: Vec<Vec<usize>> = vec![Vec::new(); n_comp as usize];
for (ti, &cid) in comp_of.iter().enumerate() {
tris_by_comp[cid as usize].push(ti);
}
let mut out = Vec::new();
for comp_tris in tris_by_comp {
let mut vertex_label: HashMap<usize, Coord> = seed_labels(&triangles[comp_tris[0]]);
let mut visited: HashSet<usize> = HashSet::new();
let mut conflicts = false;
let mut queue = VecDeque::new();
queue.push_back(comp_tris[0]);
visited.insert(comp_tris[0]);
while let Some(ti) = queue.pop_front() {
let t = &triangles[ti];
let [a, b, c] = t.vertices;
for &(e_lo, e_hi, apex) in &[(a, b, c), (b, c, a), (c, a, b)] {
let key = ordered(e_lo, e_hi);
let Some(buddies) = edge_index.get(&key) else {
continue;
};
let (Some(&la), Some(&lb), Some(&lc)) = (
vertex_label.get(&e_lo),
vertex_label.get(&e_hi),
vertex_label.get(&apex),
) else {
continue;
};
for &(tj, nbr_apex) in buddies {
if tj == ti {
continue;
}
let derived = Coord::new(la.u + lb.u - lc.u, la.v + lb.v - lc.v);
match vertex_label.get(&nbr_apex) {
Some(&existing) if existing != derived => {
conflicts = true;
}
Some(_) => {}
None => {
vertex_label.insert(nbr_apex, derived);
}
}
if visited.insert(tj) {
queue.push_back(tj);
}
}
}
}
if conflicts {
continue;
}
let mut labelled: HashMap<Coord, usize> = HashMap::new();
let mut ambiguous: HashSet<Coord> = HashSet::new();
for (&vtx, &lbl) in &vertex_label {
match labelled.entry(lbl) {
std::collections::hash_map::Entry::Vacant(e) => {
e.insert(vtx);
}
std::collections::hash_map::Entry::Occupied(e) => {
e.remove();
ambiguous.insert(lbl);
}
}
}
labelled.retain(|lbl, _| !ambiguous.contains(lbl));
if labelled.len() < min_corners_per_component {
continue;
}
rebase_to_origin(&mut labelled);
out.push(HexComponent { labelled });
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::feature::{LocalAxis, OrientedFeature, PointFeature};
fn hex_model(q: i32, r: i32) -> Point2<f32> {
let sqrt3_2 = 3.0_f32.sqrt() * 0.5;
Point2::new(q as f32 + 0.5 * r as f32, sqrt3_2 * r as f32)
}
fn hex_patch(radius: i32, s: f32) -> (Vec<OrientedFeature<3>>, Vec<(i32, i32)>) {
let third = std::f32::consts::PI / 3.0;
let mut feats = Vec::new();
let mut qr = Vec::new();
let mut idx = 0usize;
for q in -radius..=radius {
for r in (-radius).max(-q - radius)..=radius.min(-q + radius) {
let m = hex_model(q, r);
let p = PointFeature::new(idx, Point2::new(m.x * s + 100.0, m.y * s + 100.0));
let axes = [
LocalAxis::new(0.0, Some(0.02)),
LocalAxis::new(third, Some(0.02)),
LocalAxis::new(2.0 * third, Some(0.02)),
];
feats.push(OrientedFeature::<3>::new(p, axes));
qr.push((q, r));
idx += 1;
}
}
(feats, qr)
}
#[test]
fn classify_keeps_unit_cells_rejects_slivers() {
let (feats, _) = hex_patch(2, 30.0);
let positions: Vec<Point2<f32>> = feats.iter().map(|f| f.point.position).collect();
let caches = build_hex_axis_caches(&feats, 0.6);
let tri = super::super::delaunay::triangulate(&positions);
let cells = classify_hex_cells(&positions, &caches, &tri, 15.0_f32.to_radians());
assert!(cells.len() >= 12, "kept only {} hex cells", cells.len());
}
#[test]
fn walk_labels_perfect_patch_consistently() {
let (feats, qr) = hex_patch(3, 30.0);
let positions: Vec<Point2<f32>> = feats.iter().map(|f| f.point.position).collect();
let caches = build_hex_axis_caches(&feats, 0.6);
let tri = super::super::delaunay::triangulate(&positions);
let cells = classify_hex_cells(&positions, &caches, &tri, 15.0_f32.to_radians());
let comps = label_components(&cells, 4);
assert_eq!(comps.len(), 1, "expected one connected hex component");
let comp = &comps[0];
let truth: HashMap<usize, (i32, i32)> = qr
.iter()
.enumerate()
.map(|(idx, &(q, r))| (idx, (q, r)))
.collect();
let pairs: Vec<((i32, i32), (i32, i32))> = comp
.labelled
.iter()
.map(|(c, &idx)| ((c.u, c.v), truth[&idx]))
.collect();
assert!(pairs.len() >= 12, "recovered only {} nodes", pairs.len());
let found = crate::lattice::D6_TRANSFORMS.iter().any(|m| {
let (du0, dv0) = pairs[0].0;
let mapped0 = m.apply(Coord::new(du0, dv0));
let (tu0, tv0) = pairs[0].1;
let t = (tu0 - mapped0.u, tv0 - mapped0.v);
pairs.iter().all(|(d, truth_c)| {
let mapped = m.apply(Coord::new(d.0, d.1));
(mapped.u + t.0, mapped.v + t.1) == *truth_c
})
});
assert!(
found,
"hex labels are not a D6 automorphism of ground truth"
);
}
#[test]
fn cache_counts_informative_families() {
let (feats, _) = hex_patch(1, 30.0);
let caches = build_hex_axis_caches(&feats, 0.6);
assert!(caches.iter().all(|c| c.informative_count() == 3));
}
}