use std::collections::{HashMap, HashSet};
use crate::cluster::ClusterId;
use crate::field::{Field, NodeId, NodeKind, Vec2};
#[derive(Clone, Copy, Debug)]
pub struct ClusterPolicy {
pub enabled: bool,
pub distance_px: f32,
pub dwell_ms: u64,
pub min_members: usize,
pub include_anchored: bool,
pub include_active: bool,
}
impl Default for ClusterPolicy {
fn default() -> Self {
Self {
enabled: true,
distance_px: 220.0,
dwell_ms: 1_500,
min_members: 2,
include_anchored: false,
include_active: false,
}
}
}
#[derive(Clone, Debug, Default)]
pub struct ClusterFormationState {
near_since: HashMap<(NodeId, NodeId), u64>,
}
fn footprint_gap2(a_pos: Vec2, a_size: Vec2, b_pos: Vec2, b_size: Vec2) -> f32 {
let dx = (a_pos.x - b_pos.x).abs() - (a_size.x.abs() * 0.5 + b_size.x.abs() * 0.5);
let dy = (a_pos.y - b_pos.y).abs() - (a_size.y.abs() * 0.5 + b_size.y.abs() * 0.5);
let gx = dx.max(0.0);
let gy = dy.max(0.0);
gx * gx + gy * gy
}
fn ordered_pair(a: NodeId, b: NodeId) -> (NodeId, NodeId) {
if a.as_u64() <= b.as_u64() {
(a, b)
} else {
(b, a)
}
}
pub fn tick_cluster_formation(
field: &mut Field,
now_ms: u64,
policy: ClusterPolicy,
state: &mut ClusterFormationState,
) -> Vec<ClusterId> {
if !policy.enabled {
state.near_since.clear();
return Vec::new();
}
let mut already_clustered: HashSet<NodeId> = HashSet::new();
for c in field.clusters_iter() {
for &m in c.members() {
already_clustered.insert(m);
}
if let Some(core) = c.core {
already_clustered.insert(core);
}
}
let mut candidates: Vec<NodeId> = field
.nodes()
.keys()
.copied()
.filter(|&id| field.participates_in_field_view(id))
.filter(|&id| field.is_visible(id))
.filter(|&id| !already_clustered.contains(&id))
.filter(|&id| field.node(id).is_some_and(|n| n.kind == NodeKind::Surface))
.filter(|&id| {
if policy.include_active {
true
} else {
field.node(id).is_some_and(|n| {
n.state != crate::field::NodeState::Active
&& n.state != crate::field::NodeState::Core
})
}
})
.filter(|&id| {
if policy.include_anchored {
true
} else {
field.node(id).is_some_and(|n| !n.pinned)
}
})
.collect();
candidates.sort_by_key(|id| id.as_u64());
let thr2 = policy.distance_px * policy.distance_px;
let mut near_now: HashSet<(NodeId, NodeId)> = HashSet::new();
for i in 0..candidates.len() {
for j in (i + 1)..candidates.len() {
let a = candidates[i];
let b = candidates[j];
let (pa, sa, pb, sb) = match (field.node(a), field.node(b)) {
(Some(na), Some(nb)) => (na.pos, na.footprint, nb.pos, nb.footprint),
_ => continue,
};
if footprint_gap2(pa, sa, pb, sb) <= thr2 {
let key = ordered_pair(a, b);
near_now.insert(key);
state.near_since.entry(key).or_insert(now_ms);
}
}
}
state.near_since.retain(|k, _| near_now.contains(k));
let mut adj: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
for (&(a, b), &since) in state.near_since.iter() {
if now_ms.saturating_sub(since) >= policy.dwell_ms {
adj.entry(a).or_default().push(b);
adj.entry(b).or_default().push(a);
}
}
let mut seen: HashSet<NodeId> = HashSet::new();
let mut created: Vec<ClusterId> = Vec::new();
for &start in &candidates {
if seen.contains(&start) {
continue;
}
if !adj.contains_key(&start) {
seen.insert(start);
continue;
}
let mut stack = vec![start];
let mut comp: Vec<NodeId> = Vec::new();
seen.insert(start);
while let Some(x) = stack.pop() {
comp.push(x);
if let Some(neis) = adj.get(&x) {
for &y in neis {
if !seen.contains(&y) {
seen.insert(y);
stack.push(y);
}
}
}
}
if comp.len() >= policy.min_members {
if let Ok(cid) = field.create_cluster(comp.clone()) {
created.push(cid);
let comp_set: HashSet<NodeId> = comp.into_iter().collect();
state
.near_since
.retain(|&(a, b), _| !comp_set.contains(&a) && !comp_set.contains(&b));
}
}
}
created
}
#[cfg(test)]
mod tests {
use super::*;
use crate::field::Vec2;
#[test]
fn forms_cluster_after_dwell() {
let mut f = Field::new();
let a = f.spawn_surface("A", Vec2 { x: 0.0, y: 0.0 }, Vec2 { x: 10.0, y: 10.0 });
let b = f.spawn_surface("B", Vec2 { x: 50.0, y: 0.0 }, Vec2 { x: 10.0, y: 10.0 });
let mut st = ClusterFormationState::default();
let policy = ClusterPolicy {
enabled: true,
distance_px: 100.0,
dwell_ms: 1_000,
min_members: 2,
include_anchored: false,
include_active: true,
};
let c0 = tick_cluster_formation(&mut f, 0, policy, &mut st);
assert!(c0.is_empty());
let c1 = tick_cluster_formation(&mut f, 999, policy, &mut st);
assert!(c1.is_empty());
let c2 = tick_cluster_formation(&mut f, 1000, policy, &mut st);
assert_eq!(c2.len(), 1);
let cid = c2[0];
let cl = f.cluster(cid).unwrap();
assert!(cl.contains(a));
assert!(cl.contains(b));
}
#[test]
fn disabled_clears_state_and_does_nothing() {
let mut f = Field::new();
let _a = f.spawn_surface("A", Vec2 { x: 0.0, y: 0.0 }, Vec2 { x: 10.0, y: 10.0 });
let _b = f.spawn_surface("B", Vec2 { x: 50.0, y: 0.0 }, Vec2 { x: 10.0, y: 10.0 });
let mut st = ClusterFormationState::default();
st.near_since.insert((NodeId::new(1), NodeId::new(2)), 0);
let policy = ClusterPolicy {
enabled: false,
..Default::default()
};
let out = tick_cluster_formation(&mut f, 1234, policy, &mut st);
assert!(out.is_empty());
assert!(st.near_since.is_empty());
}
#[test]
fn moving_apart_resets_dwell_timer() {
let mut f = Field::new();
let a = f.spawn_surface("A", Vec2 { x: 0.0, y: 0.0 }, Vec2 { x: 10.0, y: 10.0 });
let b = f.spawn_surface("B", Vec2 { x: 50.0, y: 0.0 }, Vec2 { x: 10.0, y: 10.0 });
let mut st = ClusterFormationState::default();
let policy = ClusterPolicy {
enabled: true,
distance_px: 100.0,
dwell_ms: 1_000,
min_members: 2,
include_anchored: false,
include_active: true,
};
assert!(tick_cluster_formation(&mut f, 0, policy, &mut st).is_empty());
assert!(f.carry(
b,
Vec2 {
x: 10_000.0,
y: 0.0
}
));
assert!(tick_cluster_formation(&mut f, 500, policy, &mut st).is_empty());
assert!(f.carry(b, Vec2 { x: 50.0, y: 0.0 }));
assert!(tick_cluster_formation(&mut f, 800, policy, &mut st).is_empty());
assert!(tick_cluster_formation(&mut f, 1700, policy, &mut st).is_empty());
let out = tick_cluster_formation(&mut f, 1800, policy, &mut st);
assert_eq!(out.len(), 1);
let cid = out[0];
let cl = f.cluster(cid).unwrap();
assert!(cl.contains(a));
assert!(cl.contains(b));
}
}