use crate::legibility::ScreenGrid;
use egui::{Pos2, Rect, Vec2};
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum PickShape {
Radius(f32),
Box(Vec2),
}
impl Default for PickShape {
fn default() -> Self {
Self::Radius(8.0)
}
}
pub type LayerId = u8;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct PickId(pub u32);
pub const MAX_FEATURE: u32 = (1 << 24) - 1;
impl PickId {
#[must_use]
pub fn new(layer: LayerId, feature: u32) -> Self {
if layer == 0 || feature > MAX_FEATURE {
return Self::NOTHING;
}
Self(((layer as u32) << 24) | feature)
}
pub const NOTHING: PickId = PickId(0);
#[must_use]
pub fn is_nothing(self) -> bool {
self.0 == 0
}
#[must_use]
pub fn layer(self) -> LayerId {
(self.0 >> 24) as u8
}
#[must_use]
pub fn feature(self) -> u32 {
self.0 & MAX_FEATURE
}
#[must_use]
pub fn to_rgba(self) -> [u8; 4] {
self.0.to_le_bytes()
}
#[must_use]
pub fn from_rgba(b: [u8; 4]) -> Self {
Self(u32::from_le_bytes(b))
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PickHit {
pub id: PickId,
pub dist: f32,
pub candidates: usize,
}
#[derive(Debug, Default)]
pub struct PickIndex {
layers: Vec<PickLayer>,
key: u64,
}
#[derive(Debug)]
struct PickLayer {
layer: LayerId,
grid: ScreenGrid,
anchors: Vec<Pos2>,
features: Vec<u32>,
shape: PickShape,
}
impl PickIndex {
#[must_use]
pub fn key(&self) -> u64 {
self.key
}
#[must_use]
pub fn is_current(&self, key: u64) -> bool {
!self.layers.is_empty() && self.key == key
}
pub fn begin(&mut self, key: u64) {
self.layers.clear();
self.key = key;
}
pub fn push_layer(&mut self, layer: LayerId, anchors: &[Pos2], features: Vec<u32>, shape: PickShape) {
debug_assert_eq!(anchors.len(), features.len(), "anchors and features must be index-aligned");
self.layers.push(PickLayer {
layer,
grid: ScreenGrid::build(anchors),
anchors: anchors.to_vec(),
features,
shape,
});
}
#[must_use]
pub fn pick(&self, probe: Pos2) -> PickHit {
let mut best = PickHit::default();
let mut found = false;
for l in &self.layers {
let hit = match l.shape {
PickShape::Radius(r) => l.grid.nearest(&l.anchors, probe, r.max(1.0)).map(|h| {
best.candidates += h.candidates;
(h.index, h.dist)
}),
PickShape::Box(half) => {
let mut cands: Vec<u32> = Vec::new();
l.grid.query_rect(Rect::from_center_size(probe, half * 2.0), &mut cands);
best.candidates += cands.len();
cands.sort_unstable();
cands
.into_iter()
.find(|&i| {
let c = l.anchors[i as usize];
(probe.x - c.x).abs() <= half.x && (probe.y - c.y).abs() <= half.y
})
.map(|i| (i, (l.anchors[i as usize] - probe).length()))
}
};
let Some((index, dist)) = hit else { continue };
if !found || dist <= best.dist {
found = true;
let feat = l.features.get(index as usize).copied().unwrap_or(u32::MAX);
best.id = PickId::new(l.layer, feat);
best.dist = dist;
}
}
best
}
#[must_use]
pub fn layer_count(&self) -> usize {
self.layers.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn id_round_trips_through_rgba_and_fields() {
let id = PickId::new(3, 1_234_567);
assert_eq!(id.layer(), 3);
assert_eq!(id.feature(), 1_234_567);
assert_eq!(PickId::from_rgba(id.to_rgba()), id);
assert!(!id.is_nothing());
}
#[test]
fn out_of_range_encodes_as_nothing_never_as_an_alias() {
assert!(PickId::new(0, 5).is_nothing(), "layer 0 is reserved");
assert!(PickId::new(1, MAX_FEATURE + 1).is_nothing(), "a 25-bit feature must not alias");
assert_ne!(PickId::new(1, 0), PickId::NOTHING, "a real feature must not read as a miss");
}
#[test]
fn pick_returns_the_nearest_anchor_and_reports_its_work() {
let anchors: Vec<Pos2> = (0..1000).map(|i| Pos2::new((i % 50) as f32 * 10.0, (i / 50) as f32 * 10.0)).collect();
let mut idx = PickIndex::default();
idx.begin(1);
idx.push_layer(1, &anchors, (0..1000).collect(), PickShape::Radius(8.0));
let hit = idx.pick(Pos2::new(101.0, 60.0));
assert_eq!(hit.id.layer(), 1);
assert_eq!(hit.id.feature(), 310, "picked the wrong anchor");
assert!(hit.dist <= 2.0);
assert!(hit.candidates < 1000, "tested {} of 1000 — that is a linear scan", hit.candidates);
}
#[test]
fn a_click_on_empty_space_is_nothing() {
let anchors = vec![Pos2::new(0.0, 0.0)];
let mut idx = PickIndex::default();
idx.begin(1);
idx.push_layer(1, &anchors, vec![7], PickShape::Radius(4.0));
assert!(idx.pick(Pos2::new(500.0, 500.0)).id.is_nothing());
}
#[test]
fn the_upper_layer_wins_a_contested_pixel() {
let a = vec![Pos2::new(10.0, 10.0)];
let mut idx = PickIndex::default();
idx.begin(1);
idx.push_layer(1, &a, vec![100], PickShape::Radius(12.0)); idx.push_layer(2, &a, vec![200], PickShape::Radius(12.0)); let hit = idx.pick(Pos2::new(10.0, 10.0));
assert_eq!((hit.id.layer(), hit.id.feature()), (2, 200));
}
#[test]
fn box_shape_matches_the_linear_scan_over_overlapping_chips() {
let half = Vec2::new(92.0, 14.0);
let anchors: Vec<Pos2> = (0..300).map(|i| Pos2::new((i % 20) as f32 * 40.0, (i / 20) as f32 * 18.0)).collect();
let mut idx = PickIndex::default();
idx.begin(1);
idx.push_layer(1, &anchors, (0..300).collect(), PickShape::Box(half));
let reference = |p: Pos2| -> Option<u32> {
anchors.iter().position(|c| (p.x - c.x).abs() <= half.x && (p.y - c.y).abs() <= half.y).map(|i| i as u32)
};
let mut hits = 0;
for gx in 0..30 {
for gy in 0..20 {
let p = Pos2::new(gx as f32 * 27.0 - 40.0, gy as f32 * 15.0 - 20.0);
let got = idx.pick(p);
let want = reference(p);
match want {
Some(w) => {
assert_eq!(got.id.feature(), w, "probe {p:?}: index picked {:?}, scan picked {w}", got.id.feature());
hits += 1;
}
None => assert!(got.id.is_nothing(), "probe {p:?}: index invented a hit"),
}
}
}
assert!(hits > 100, "only {hits} probes landed on a chip — the sweep proves nothing");
}
}