Skip to main content

halley_core/
world.rs

1use std::collections::HashMap;
2
3use crate::field::{Field, NodeId, Vec2};
4use crate::viewport::Viewport;
5
6#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
7pub struct SpaceId(u64);
8
9impl SpaceId {
10    pub fn new(raw: u64) -> Self {
11        Self(raw)
12    }
13    pub fn as_u64(self) -> u64 {
14        self.0
15    }
16}
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
19pub enum PortalDir {
20    N,
21    E,
22    S,
23    W,
24}
25
26impl PortalDir {
27    pub fn opposite(self) -> Self {
28        match self {
29            PortalDir::N => PortalDir::S,
30            PortalDir::E => PortalDir::W,
31            PortalDir::S => PortalDir::N,
32            PortalDir::W => PortalDir::E,
33        }
34    }
35}
36
37/// Multiple independent infinite Fields (one per monitor space),
38/// plus optional adjacency (portals) between them.
39pub struct World {
40    spaces: HashMap<SpaceId, Field>,
41    neighbors: HashMap<(SpaceId, PortalDir), SpaceId>,
42}
43
44impl World {
45    pub fn new() -> Self {
46        Self {
47            spaces: HashMap::new(),
48            neighbors: HashMap::new(),
49        }
50    }
51
52    pub fn add_space(&mut self, id: SpaceId, field: Field) {
53        self.spaces.insert(id, field);
54    }
55
56    pub fn space(&self, id: SpaceId) -> Option<&Field> {
57        self.spaces.get(&id)
58    }
59
60    pub fn space_mut(&mut self, id: SpaceId) -> Option<&mut Field> {
61        self.spaces.get_mut(&id)
62    }
63
64    /// Define a portal edge from `a` in direction `dir` to `b`.
65    /// You typically set both directions (a->b and b->a).
66    pub fn set_neighbor(&mut self, a: SpaceId, dir: PortalDir, b: SpaceId) {
67        self.neighbors.insert((a, dir), b);
68    }
69
70    pub fn neighbor(&self, a: SpaceId, dir: PortalDir) -> Option<SpaceId> {
71        self.neighbors.get(&(a, dir)).copied()
72    }
73
74    /// Move a single node from one space to its neighbor space through `dir`.
75    /// The compositor should call this ONLY when the "transfer modifier" is held.
76    pub fn transfer_node(
77        &mut self,
78        from_space: SpaceId,
79        node: NodeId,
80        dir: PortalDir,
81        from_vp: &Viewport,
82        to_vp: &Viewport,
83    ) -> bool {
84        let to_space = match self.neighbor(from_space, dir) {
85            Some(s) => s,
86            None => return false,
87        };
88
89        let (pos, node_data) = {
90            let from = match self.space_mut(from_space) {
91                Some(f) => f,
92                None => return false,
93            };
94
95            let n = match from.node(node) {
96                Some(n) => n,
97                None => return false,
98            };
99
100            // Movement constraint: pinned nodes can't be transferred.
101            if n.pinned {
102                return false;
103            }
104
105            // compute new position first (pure)
106            let new_pos = map_across_portal(from_vp, to_vp, dir, n.pos);
107
108            // remove node payload
109            let removed = match from.remove(node) {
110                Some(x) => x,
111                None => return false,
112            };
113
114            (new_pos, removed)
115        };
116
117        // insert into target field, preserving NodeId
118        let to = match self.space_mut(to_space) {
119            Some(f) => f,
120            None => return false,
121        };
122
123        let mut insert = node_data;
124        insert.pos = pos;
125        to.insert_existing(insert);
126
127        true
128    }
129
130    /// Move a cluster by its core handle across spaces.
131    /// This moves the core + members as a unit by rehoming all involved nodes.
132    pub fn transfer_cluster_by_core(
133        &mut self,
134        from_space: SpaceId,
135        core: NodeId,
136        dir: PortalDir,
137        from_vp: &Viewport,
138        to_vp: &Viewport,
139    ) -> bool {
140        let to_space = match self.neighbor(from_space, dir) {
141            Some(s) => s,
142            None => return false,
143        };
144
145        // -------- PRE-FLIGHT (NO MUTATION) --------
146        let (cid, members, core_pos) = {
147            let from = match self.space(from_space) {
148                Some(f) => f,
149                None => return false,
150            };
151
152            let cid = match from.cluster_id_for_core_public(core) {
153                Some(cid) => cid,
154                None => return false,
155            };
156
157            let cluster = match from.cluster(cid) {
158                Some(c) => c,
159                None => return false,
160            };
161
162            let core_node = match from.node(core) {
163                Some(n) => n,
164                None => return false,
165            };
166
167            // Movement constraint checks: pinned nodes block transfer.
168            if core_node.pinned {
169                return false;
170            }
171
172            for m in cluster.members() {
173                match from.node(*m) {
174                    Some(n) if !n.pinned => {}
175                    _ => return false,
176                }
177            }
178
179            (cid, cluster.members().to_vec(), core_node.pos)
180        };
181
182        // Compute mapping delta
183        let mapped = map_across_portal(from_vp, to_vp, dir, core_pos);
184        let delta = Vec2 {
185            x: mapped.x - core_pos.x,
186            y: mapped.y - core_pos.y,
187        };
188
189        // -------- REMOVE FROM SOURCE (atomic intent) --------
190        let (cluster_obj, core_payload, member_payloads) = {
191            let from = match self.space_mut(from_space) {
192                Some(f) => f,
193                None => return false,
194            };
195
196            let cluster_obj = match from.remove_cluster(cid) {
197                Some(c) => c,
198                None => return false,
199            };
200
201            let core_node = match from.remove(core) {
202                Some(n) => n,
203                None => {
204                    from.insert_cluster(cluster_obj);
205                    return false;
206                }
207            };
208
209            let mut members_out = Vec::with_capacity(members.len());
210            for m in &members {
211                match from.remove(*m) {
212                    Some(n) => members_out.push(n),
213                    None => {
214                        // rollback
215                        from.insert_existing(core_node);
216                        from.insert_cluster(cluster_obj);
217                        return false;
218                    }
219                }
220            }
221
222            (cluster_obj, core_node, members_out)
223        };
224
225        // -------- INSERT INTO TARGET --------
226        let to = match self.space_mut(to_space) {
227            Some(f) => f,
228            None => return false,
229        };
230
231        // Insert cluster record first
232        to.insert_cluster(cluster_obj);
233
234        // Insert core
235        let mut core_insert = core_payload;
236        core_insert.pos = mapped;
237        to.insert_existing(core_insert);
238
239        // Insert members
240        for mut mp in member_payloads {
241            mp.pos.x += delta.x;
242            mp.pos.y += delta.y;
243            to.insert_existing(mp);
244        }
245
246        true
247    }
248}
249
250/// Edge-preserving mapping between monitor spaces.
251///
252/// Rule:
253/// - If crossing E: new x = left edge + epsilon, preserve y offset from viewport center.
254/// - If crossing W: new x = right edge - epsilon, preserve y offset.
255/// - If crossing N: new y = top edge + epsilon in y-down screen terms.
256/// - If crossing S: new y = bottom edge - epsilon in y-down screen terms.
257pub fn map_across_portal(from_vp: &Viewport, to_vp: &Viewport, dir: PortalDir, pos: Vec2) -> Vec2 {
258    let to = to_vp.rect();
259    let eps = 1.0;
260
261    let rel = Vec2 {
262        x: pos.x - from_vp.center.x,
263        y: pos.y - from_vp.center.y,
264    };
265
266    match dir {
267        PortalDir::E => Vec2 {
268            x: to.min.x + eps,
269            y: to_vp.center.y + rel.y,
270        },
271        PortalDir::W => Vec2 {
272            x: to.max.x - eps,
273            y: to_vp.center.y + rel.y,
274        },
275        PortalDir::N => Vec2 {
276            x: to_vp.center.x + rel.x,
277            y: to.min.y + eps,
278        },
279        PortalDir::S => Vec2 {
280            x: to_vp.center.x + rel.x,
281            y: to.max.y - eps,
282        },
283    }
284}
285
286impl Default for World {
287    fn default() -> Self {
288        Self::new()
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crate::field::Vec2;
296
297    #[test]
298    fn map_preserves_tangent_offset_east() {
299        let from_vp = Viewport::new(Vec2 { x: 0.0, y: 0.0 }, Vec2 { x: 100.0, y: 100.0 });
300        let to_vp = Viewport::new(
301            Vec2 {
302                x: 1000.0,
303                y: 500.0,
304            },
305            Vec2 { x: 100.0, y: 100.0 },
306        );
307
308        let pos = Vec2 { x: 49.0, y: 10.0 }; // near east edge of from
309        let mapped = map_across_portal(&from_vp, &to_vp, PortalDir::E, pos);
310
311        // x placed near left edge of to
312        assert!(mapped.x > to_vp.rect().min.x);
313        // y keeps rel offset from center
314        assert_eq!(mapped.y, to_vp.center.y + (pos.y - from_vp.center.y));
315    }
316
317    #[test]
318    fn transfer_node_moves_between_spaces() {
319        let mut w = World::new();
320        let mut fa = Field::new();
321        let fb = Field::new();
322
323        let a = SpaceId::new(1);
324        let b = SpaceId::new(2);
325
326        let n = fa.spawn_surface("A", Vec2 { x: 10.0, y: 0.0 }, Vec2 { x: 10.0, y: 10.0 });
327
328        w.add_space(a, fa);
329        w.add_space(b, fb);
330
331        w.set_neighbor(a, PortalDir::E, b);
332        w.set_neighbor(b, PortalDir::W, a);
333
334        let from_vp = Viewport::new(Vec2 { x: 0.0, y: 0.0 }, Vec2 { x: 100.0, y: 100.0 });
335        let to_vp = Viewport::new(Vec2 { x: 1000.0, y: 0.0 }, Vec2 { x: 100.0, y: 100.0 });
336
337        assert!(w.transfer_node(a, n, PortalDir::E, &from_vp, &to_vp));
338
339        assert!(w.space(a).unwrap().node(n).is_none());
340        assert!(w.space(b).unwrap().node(n).is_some());
341    }
342
343    #[test]
344    fn transfer_cluster_moves_cluster_record() {
345        let mut w = World::new();
346
347        let mut fa = Field::new();
348        let fb = Field::new();
349
350        let a = SpaceId::new(1);
351        let b = SpaceId::new(2);
352
353        let n1 = fa.spawn_surface("A", Vec2 { x: 0.0, y: 0.0 }, Vec2 { x: 10.0, y: 10.0 });
354        let n2 = fa.spawn_surface("B", Vec2 { x: 10.0, y: 0.0 }, Vec2 { x: 10.0, y: 10.0 });
355
356        let cid = fa.create_cluster(vec![n1, n2]).unwrap();
357        let core = fa.collapse_cluster(cid).unwrap();
358
359        w.add_space(a, fa);
360        w.add_space(b, fb);
361
362        w.set_neighbor(a, PortalDir::E, b);
363        w.set_neighbor(b, PortalDir::W, a);
364
365        let from_vp = Viewport::new(Vec2 { x: 0.0, y: 0.0 }, Vec2 { x: 100.0, y: 100.0 });
366        let to_vp = Viewport::new(Vec2 { x: 1000.0, y: 0.0 }, Vec2 { x: 100.0, y: 100.0 });
367
368        assert!(w.transfer_cluster_by_core(a, core, PortalDir::E, &from_vp, &to_vp));
369
370        // Cluster should no longer exist in space A
371        assert!(w.space(a).unwrap().cluster(cid).is_none());
372
373        // Cluster should now exist in space B
374        assert!(w.space(b).unwrap().cluster(cid).is_some());
375
376        // Core lookup should work
377        let dest = w.space(b).unwrap();
378        assert_eq!(dest.cluster_id_for_core_public(core), Some(cid));
379    }
380}