Skip to main content

a7n_core/
layout.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3//! Validated desk canvas and last-writer-wins layout merge (PROTOCOL.md 7).
4
5pub use a7n_proto::v1::Edge;
6use a7n_proto::{MachineId, ProtoError, limits, v1};
7
8#[cfg(test)]
9mod tests;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct Placement {
13    pub id: MachineId,
14    pub x: i32,
15    pub y: i32,
16    pub width: u32,
17    pub height: u32,
18    pub blocked: u16,
19}
20
21const SEGMENTS_PER_EDGE: u16 = 3;
22const BLOCKED_MASK: u32 = (1 << (4 * SEGMENTS_PER_EDGE)) - 1;
23
24/// Map a clamped fractional edge offset to its third.
25#[must_use]
26pub fn third_at(along: f32) -> u8 {
27    let along = along.clamp(0.0, 1.0);
28    if along < 1.0 / f32::from(SEGMENTS_PER_EDGE) {
29        0
30    } else if along < 2.0 / f32::from(SEGMENTS_PER_EDGE) {
31        1
32    } else {
33        2
34    }
35}
36
37impl Placement {
38    #[must_use]
39    pub fn is_blocked(&self, edge: Edge, third: u8) -> bool {
40        self.blocked & Self::segment_mask(edge, third) != 0
41    }
42
43    pub fn toggle_blocked(&mut self, edge: Edge, third: u8) {
44        self.blocked ^= Self::segment_mask(edge, third);
45    }
46
47    fn segment_mask(edge: Edge, third: u8) -> u16 {
48        if u16::from(third) >= SEGMENTS_PER_EDGE {
49            return 0;
50        }
51        let index = match edge {
52            Edge::Left => 0,
53            Edge::Top => 1,
54            Edge::Right => 2,
55            Edge::Bottom => 3,
56            Edge::Unspecified => return 0,
57        };
58        1 << (index * SEGMENTS_PER_EDGE + u16::from(third))
59    }
60
61    fn blocks(&self, edge: Edge, along: f32) -> bool {
62        self.is_blocked(edge, third_at(along))
63    }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Corner {
68    TopLeft,
69    TopRight,
70    BottomLeft,
71    BottomRight,
72}
73
74#[derive(Debug, thiserror::Error)]
75pub enum LayoutError {
76    #[error(transparent)]
77    InvalidId(#[from] ProtoError),
78    #[error("layout exceeds the machine limit")]
79    TooManyMachines,
80    #[error("machine rectangle has zero size")]
81    ZeroSize,
82    #[error("layout contains a duplicate machine id")]
83    DuplicateId,
84    #[error("machine rectangle exceeds the canvas coordinate range")]
85    CoordinateOverflow,
86    #[error("machine rectangles overlap")]
87    Overlap,
88    #[error("layout version is exhausted")]
89    VersionExhausted,
90}
91
92/// Validate all placements before storing or sharing a canvas.
93///
94/// # Errors
95/// Rejects excessive, duplicate, empty, overflowing or overlapping rectangles.
96pub fn validate(placements: &[Placement]) -> Result<(), LayoutError> {
97    if placements.len() > limits::LAYOUT_MACHINES_MAX {
98        return Err(LayoutError::TooManyMachines);
99    }
100    for (index, p) in placements.iter().enumerate() {
101        if p.width == 0 || p.height == 0 {
102            return Err(LayoutError::ZeroSize);
103        }
104        if right(p) > i64::from(i32::MAX) || bottom(p) > i64::from(i32::MAX) {
105            return Err(LayoutError::CoordinateOverflow);
106        }
107        for other in &placements[..index] {
108            if p.id == other.id {
109                return Err(LayoutError::DuplicateId);
110            }
111            if i64::from(p.x) < right(other)
112                && i64::from(other.x) < right(p)
113                && i64::from(p.y) < bottom(other)
114                && i64::from(other.y) < bottom(p)
115            {
116                return Err(LayoutError::Overlap);
117            }
118        }
119    }
120    Ok(())
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct Layout {
125    version: u64,
126    by: MachineId,
127    placements: Vec<Placement>,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum Merge {
132    Unchanged,
133    Replaced { lost_own_edit: bool },
134}
135
136impl Layout {
137    #[must_use]
138    pub fn empty(own_id: MachineId) -> Self {
139        Self {
140            version: 0,
141            by: own_id,
142            placements: Vec::new(),
143        }
144    }
145
146    #[must_use]
147    pub fn version(&self) -> u64 {
148        self.version
149    }
150
151    #[must_use]
152    pub fn by(&self) -> MachineId {
153        self.by
154    }
155
156    #[must_use]
157    pub fn placements(&self) -> &[Placement] {
158        &self.placements
159    }
160
161    /// One-based machine number, ordered top to bottom, then left to right.
162    /// Ineligible placements keep their numbers but cannot be selected.
163    #[must_use]
164    pub fn nth(&self, n: u8, eligible: impl Fn(MachineId) -> bool) -> Option<MachineId> {
165        let index = usize::from(n.checked_sub(1)?);
166        let mut placements: Vec<_> = self.placements.iter().collect();
167        placements.sort_unstable_by_key(|p| (p.y, p.x));
168        placements
169            .get(index)
170            .filter(|p| eligible(p.id))
171            .map(|p| p.id)
172    }
173
174    /// # Errors
175    /// Rejects invalid placements and an exhausted version counter.
176    pub fn edit(&self, placements: Vec<Placement>, own_id: MachineId) -> Result<Self, LayoutError> {
177        validate(&placements)?;
178        let version = self
179            .version
180            .checked_add(1)
181            .ok_or(LayoutError::VersionExhausted)?;
182        Ok(Self {
183            version,
184            by: own_id,
185            placements,
186        })
187    }
188
189    pub fn merge(&mut self, incoming: Self, own_id: MachineId) -> Merge {
190        if (incoming.version, incoming.by) <= (self.version, self.by) {
191            return Merge::Unchanged;
192        }
193        let lost_own_edit = self.by == own_id && self.version == incoming.version;
194        *self = incoming;
195        Merge::Replaced { lost_own_edit }
196    }
197
198    #[must_use]
199    pub fn to_proto(&self) -> v1::Layout {
200        v1::Layout {
201            version: self.version,
202            by: self.by.as_bytes().to_vec(),
203            machines: self
204                .placements
205                .iter()
206                .map(|p| v1::MachineRect {
207                    id: p.id.as_bytes().to_vec(),
208                    x: p.x,
209                    y: p.y,
210                    width: p.width,
211                    height: p.height,
212                    blocked: u32::from(p.blocked),
213                })
214                .collect(),
215        }
216    }
217
218    /// Map an exit to an eligible touching machine's opposite edge and fractional offset.
219    #[must_use]
220    #[allow(clippy::cast_possible_truncation)] // Landing uses f32 on the wire; geometry uses f64.
221    pub fn exit(
222        &self,
223        from: MachineId,
224        edge: Edge,
225        along: f32,
226        eligible: impl Fn(MachineId) -> bool,
227    ) -> Option<(MachineId, v1::Landing)> {
228        if along.is_nan() {
229            return None;
230        }
231        let source = self.placements.iter().find(|p| p.id == from)?;
232        if source.blocks(edge, along) {
233            return None;
234        }
235        let horizontal = matches!(edge, Edge::Top | Edge::Bottom);
236        let (start, size) = if horizontal {
237            (source.x, source.width)
238        } else {
239            (source.y, source.height)
240        };
241        let point = f64::from(start) + f64::from(size) * f64::from(along.clamp(0.0, 1.0));
242        for target in &self.placements {
243            if target.id == from || !eligible(target.id) || !touches(source, target, edge) {
244                continue;
245            }
246            let (start, size) = if horizontal {
247                (target.x, target.width)
248            } else {
249                (target.y, target.height)
250            };
251            let offset = (point - f64::from(start)) / f64::from(size);
252            if (0.0..1.0).contains(&offset) {
253                return Some((
254                    target.id,
255                    v1::Landing {
256                        edge: opposite(edge) as i32,
257                        offset: offset as f32,
258                        ..v1::Landing::default()
259                    },
260                ));
261            }
262        }
263        None
264    }
265
266    /// Map a diagonal push to a neighbour touching only at the given corner.
267    /// Call after `exit` so a shared edge takes precedence.
268    #[must_use]
269    pub fn exit_corner(
270        &self,
271        from: MachineId,
272        edge: Edge,
273        corner: Corner,
274        eligible: impl Fn(MachineId) -> bool,
275    ) -> Option<(MachineId, v1::Landing)> {
276        let source = self.placements.iter().find(|p| p.id == from)?;
277        let at_right = matches!(corner, Corner::TopRight | Corner::BottomRight);
278        let at_bottom = matches!(corner, Corner::BottomLeft | Corner::BottomRight);
279        let at_end = match edge {
280            Edge::Left if !at_right => at_bottom,
281            Edge::Right if at_right => at_bottom,
282            Edge::Top if !at_bottom => at_right,
283            Edge::Bottom if at_bottom => at_right,
284            _ => return None,
285        };
286        if source.blocks(edge, if at_end { 1.0 } else { 0.0 }) {
287            return None;
288        }
289        let target = self.placements.iter().find(|target| {
290            target.id != from
291                && eligible(target.id)
292                && if at_right {
293                    right(source) == i64::from(target.x)
294                } else {
295                    i64::from(source.x) == right(target)
296                }
297                && if at_bottom {
298                    bottom(source) == i64::from(target.y)
299                } else {
300                    i64::from(source.y) == bottom(target)
301                }
302        })?;
303        Some((
304            target.id,
305            v1::Landing {
306                edge: opposite(edge) as i32,
307                offset: if at_end { 0.0 } else { 1.0 },
308                ..v1::Landing::default()
309            },
310        ))
311    }
312}
313
314impl TryFrom<&v1::Layout> for Layout {
315    type Error = LayoutError;
316
317    fn try_from(value: &v1::Layout) -> Result<Self, Self::Error> {
318        if value.machines.len() > limits::LAYOUT_MACHINES_MAX {
319            return Err(LayoutError::TooManyMachines);
320        }
321        if value.version > limits::COUNTER_MAX {
322            return Err(ProtoError::CounterTooLarge.into());
323        }
324        let by = MachineId::try_from(value.by.as_slice())?;
325        let placements = value
326            .machines
327            .iter()
328            .map(|p| {
329                Ok(Placement {
330                    id: MachineId::try_from(p.id.as_slice())?,
331                    x: p.x,
332                    y: p.y,
333                    width: p.width,
334                    height: p.height,
335                    blocked: u16::try_from(p.blocked & BLOCKED_MASK)
336                        .expect("masked segments fit u16"),
337                })
338            })
339            .collect::<Result<Vec<_>, LayoutError>>()?;
340        validate(&placements)?;
341        Ok(Self {
342            version: value.version,
343            by,
344            placements,
345        })
346    }
347}
348
349#[derive(Debug, Clone, Copy, PartialEq, Eq)]
350pub struct SharedEdge {
351    pub from: MachineId,
352    pub to: MachineId,
353    pub edge: Edge,
354}
355
356#[must_use]
357pub fn shared_edges(placements: &[Placement]) -> Vec<SharedEdge> {
358    let mut edges = Vec::new();
359    for from in placements {
360        for to in placements {
361            if from.id == to.id {
362                continue;
363            }
364            for edge in [Edge::Left, Edge::Right, Edge::Top, Edge::Bottom] {
365                let overlap = if matches!(edge, Edge::Left | Edge::Right) {
366                    i64::from(from.y).max(i64::from(to.y)) < bottom(from).min(bottom(to))
367                } else {
368                    i64::from(from.x).max(i64::from(to.x)) < right(from).min(right(to))
369                };
370                if overlap && touches(from, to, edge) {
371                    edges.push(SharedEdge {
372                        from: from.id,
373                        to: to.id,
374                        edge,
375                    });
376                }
377            }
378        }
379    }
380    edges
381}
382
383fn right(p: &Placement) -> i64 {
384    i64::from(p.x) + i64::from(p.width)
385}
386fn bottom(p: &Placement) -> i64 {
387    i64::from(p.y) + i64::from(p.height)
388}
389
390fn touches(from: &Placement, to: &Placement, edge: Edge) -> bool {
391    match edge {
392        Edge::Left => i64::from(from.x) == right(to),
393        Edge::Right => right(from) == i64::from(to.x),
394        Edge::Top => i64::from(from.y) == bottom(to),
395        Edge::Bottom => bottom(from) == i64::from(to.y),
396        Edge::Unspecified => false,
397    }
398}
399
400fn opposite(edge: Edge) -> Edge {
401    match edge {
402        Edge::Left => Edge::Right,
403        Edge::Right => Edge::Left,
404        Edge::Top => Edge::Bottom,
405        Edge::Bottom => Edge::Top,
406        Edge::Unspecified => Edge::Unspecified,
407    }
408}