1use std::collections::{HashMap, HashSet};
2
3use crate::cluster::ClusterId;
4use crate::field::{Field, NodeId, NodeKind, Vec2};
5
6#[derive(Clone, Copy, Debug)]
13pub struct ClusterPolicy {
14 pub enabled: bool,
15
16 pub distance_px: f32,
18
19 pub dwell_ms: u64,
21
22 pub min_members: usize,
24
25 pub include_anchored: bool,
27
28 pub include_active: bool,
31}
32
33impl Default for ClusterPolicy {
34 fn default() -> Self {
35 Self {
36 enabled: true,
37 distance_px: 220.0,
38 dwell_ms: 1_500,
39 min_members: 2,
40 include_anchored: false,
41 include_active: false,
42 }
43 }
44}
45
46#[derive(Clone, Debug, Default)]
49pub struct ClusterFormationState {
50 near_since: HashMap<(NodeId, NodeId), u64>,
53}
54
55fn footprint_gap2(a_pos: Vec2, a_size: Vec2, b_pos: Vec2, b_size: Vec2) -> f32 {
58 let dx = (a_pos.x - b_pos.x).abs() - (a_size.x.abs() * 0.5 + b_size.x.abs() * 0.5);
59 let dy = (a_pos.y - b_pos.y).abs() - (a_size.y.abs() * 0.5 + b_size.y.abs() * 0.5);
60 let gx = dx.max(0.0);
61 let gy = dy.max(0.0);
62 gx * gx + gy * gy
63}
64
65fn ordered_pair(a: NodeId, b: NodeId) -> (NodeId, NodeId) {
66 if a.as_u64() <= b.as_u64() {
67 (a, b)
68 } else {
69 (b, a)
70 }
71}
72
73pub fn tick_cluster_formation(
77 field: &mut Field,
78 now_ms: u64,
79 policy: ClusterPolicy,
80 state: &mut ClusterFormationState,
81) -> Vec<ClusterId> {
82 if !policy.enabled {
83 state.near_since.clear();
84 return Vec::new();
85 }
86
87 let mut already_clustered: HashSet<NodeId> = HashSet::new();
89 for c in field.clusters_iter() {
90 for &m in c.members() {
91 already_clustered.insert(m);
92 }
93 if let Some(core) = c.core {
94 already_clustered.insert(core);
95 }
96 }
97
98 let mut candidates: Vec<NodeId> = field
100 .nodes()
101 .keys()
102 .copied()
103 .filter(|&id| field.participates_in_field_view(id))
104 .filter(|&id| field.is_visible(id))
105 .filter(|&id| !already_clustered.contains(&id))
106 .filter(|&id| field.node(id).is_some_and(|n| n.kind == NodeKind::Surface))
107 .filter(|&id| {
108 if policy.include_active {
109 true
110 } else {
111 field.node(id).is_some_and(|n| {
112 n.state != crate::field::NodeState::Active
113 && n.state != crate::field::NodeState::Core
114 })
115 }
116 })
117 .filter(|&id| {
118 if policy.include_anchored {
119 true
120 } else {
121 field.node(id).is_some_and(|n| !n.pinned)
122 }
123 })
124 .collect();
125
126 candidates.sort_by_key(|id| id.as_u64());
128
129 let thr2 = policy.distance_px * policy.distance_px;
130
131 let mut near_now: HashSet<(NodeId, NodeId)> = HashSet::new();
133
134 for i in 0..candidates.len() {
136 for j in (i + 1)..candidates.len() {
137 let a = candidates[i];
138 let b = candidates[j];
139
140 let (pa, sa, pb, sb) = match (field.node(a), field.node(b)) {
141 (Some(na), Some(nb)) => (na.pos, na.footprint, nb.pos, nb.footprint),
142 _ => continue,
143 };
144
145 if footprint_gap2(pa, sa, pb, sb) <= thr2 {
146 let key = ordered_pair(a, b);
147 near_now.insert(key);
148 state.near_since.entry(key).or_insert(now_ms);
149 }
150 }
151 }
152
153 state.near_since.retain(|k, _| near_now.contains(k));
155
156 let mut adj: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
158 for (&(a, b), &since) in state.near_since.iter() {
159 if now_ms.saturating_sub(since) >= policy.dwell_ms {
160 adj.entry(a).or_default().push(b);
161 adj.entry(b).or_default().push(a);
162 }
163 }
164
165 let mut seen: HashSet<NodeId> = HashSet::new();
167 let mut created: Vec<ClusterId> = Vec::new();
168
169 for &start in &candidates {
170 if seen.contains(&start) {
171 continue;
172 }
173 if !adj.contains_key(&start) {
174 seen.insert(start);
175 continue;
176 }
177
178 let mut stack = vec![start];
180 let mut comp: Vec<NodeId> = Vec::new();
181 seen.insert(start);
182
183 while let Some(x) = stack.pop() {
184 comp.push(x);
185 if let Some(neis) = adj.get(&x) {
186 for &y in neis {
187 if !seen.contains(&y) {
188 seen.insert(y);
189 stack.push(y);
190 }
191 }
192 }
193 }
194
195 if comp.len() >= policy.min_members {
197 if let Ok(cid) = field.create_cluster(comp.clone()) {
199 created.push(cid);
200
201 let comp_set: HashSet<NodeId> = comp.into_iter().collect();
203 state
204 .near_since
205 .retain(|&(a, b), _| !comp_set.contains(&a) && !comp_set.contains(&b));
206 }
207 }
208 }
209
210 created
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use crate::field::Vec2;
217
218 #[test]
219 fn forms_cluster_after_dwell() {
220 let mut f = Field::new();
221 let a = f.spawn_surface("A", Vec2 { x: 0.0, y: 0.0 }, Vec2 { x: 10.0, y: 10.0 });
222 let b = f.spawn_surface("B", Vec2 { x: 50.0, y: 0.0 }, Vec2 { x: 10.0, y: 10.0 });
223
224 let mut st = ClusterFormationState::default();
225 let policy = ClusterPolicy {
226 enabled: true,
227 distance_px: 100.0,
228 dwell_ms: 1_000,
229 min_members: 2,
230 include_anchored: false,
231 include_active: true,
232 };
233
234 let c0 = tick_cluster_formation(&mut f, 0, policy, &mut st);
236 assert!(c0.is_empty());
237
238 let c1 = tick_cluster_formation(&mut f, 999, policy, &mut st);
240 assert!(c1.is_empty());
241
242 let c2 = tick_cluster_formation(&mut f, 1000, policy, &mut st);
244 assert_eq!(c2.len(), 1);
245
246 let cid = c2[0];
248 let cl = f.cluster(cid).unwrap();
249 assert!(cl.contains(a));
250 assert!(cl.contains(b));
251 }
252
253 #[test]
254 fn disabled_clears_state_and_does_nothing() {
255 let mut f = Field::new();
256 let _a = f.spawn_surface("A", Vec2 { x: 0.0, y: 0.0 }, Vec2 { x: 10.0, y: 10.0 });
257 let _b = f.spawn_surface("B", Vec2 { x: 50.0, y: 0.0 }, Vec2 { x: 10.0, y: 10.0 });
258
259 let mut st = ClusterFormationState::default();
260 st.near_since.insert((NodeId::new(1), NodeId::new(2)), 0);
261
262 let policy = ClusterPolicy {
263 enabled: false,
264 ..Default::default()
265 };
266 let out = tick_cluster_formation(&mut f, 1234, policy, &mut st);
267 assert!(out.is_empty());
268 assert!(st.near_since.is_empty());
269 }
270
271 #[test]
272 fn moving_apart_resets_dwell_timer() {
273 let mut f = Field::new();
274 let a = f.spawn_surface("A", Vec2 { x: 0.0, y: 0.0 }, Vec2 { x: 10.0, y: 10.0 });
275 let b = f.spawn_surface("B", Vec2 { x: 50.0, y: 0.0 }, Vec2 { x: 10.0, y: 10.0 });
276
277 let mut st = ClusterFormationState::default();
278 let policy = ClusterPolicy {
279 enabled: true,
280 distance_px: 100.0,
281 dwell_ms: 1_000,
282 min_members: 2,
283 include_anchored: false,
284 include_active: true,
285 };
286
287 assert!(tick_cluster_formation(&mut f, 0, policy, &mut st).is_empty());
289
290 assert!(f.carry(
292 b,
293 Vec2 {
294 x: 10_000.0,
295 y: 0.0
296 }
297 ));
298 assert!(tick_cluster_formation(&mut f, 500, policy, &mut st).is_empty());
299
300 assert!(f.carry(b, Vec2 { x: 50.0, y: 0.0 }));
302 assert!(tick_cluster_formation(&mut f, 800, policy, &mut st).is_empty());
303
304 assert!(tick_cluster_formation(&mut f, 1700, policy, &mut st).is_empty());
306
307 let out = tick_cluster_formation(&mut f, 1800, policy, &mut st);
309 assert_eq!(out.len(), 1);
310 let cid = out[0];
311 let cl = f.cluster(cid).unwrap();
312 assert!(cl.contains(a));
313 assert!(cl.contains(b));
314 }
315}