Skip to main content

blockworx_doc/
geometry.rs

1//! Geometry value types for the document model.
2//!
3//! No floats anywhere: coordinates are integers (`GridPoint`) or fixed-point
4//! (`FracVal`), so every value has exactly one encoding and the bytes the
5//! change hash covers are deterministic.
6
7use serde::{Deserialize, Serialize};
8
9use crate::values::PinSide;
10
11/// The document extent, in grid cells: two of them sum far inside `i32`,
12/// so no rect accessor can overflow a value a decoder accepted. Enforced
13/// at the decode boundary, where "bounded by the document extent" stops
14/// being true because the payload came from a stranger. Refused, never
15/// clamped — clamping relocates geometry instead of reporting a bad
16/// payload. Authoring must respect it too, or it writes commits it cannot
17/// read back.
18pub const GRID_LIMIT: i32 = 1 << 20;
19
20#[derive(Debug, thiserror::Error, PartialEq, Eq)]
21pub enum OutOfExtent {
22    #[error("coordinate {0} is outside the document extent (±{GRID_LIMIT})")]
23    Coordinate(i32),
24    #[error("extent {0} exceeds the document extent ({GRID_LIMIT})")]
25    Size(u32),
26    #[error("pin slot {0} is outside the document extent ({GRID_LIMIT})")]
27    Slot(u32),
28    #[error("fixed-point value {0} is outside the screen range (±{limit})", limit = FracVal::LIMIT)]
29    Frac(i64),
30}
31
32/// Mirrors [`GridPoint`] so `try_from` has an unchecked type to decode
33/// into first — serde's price for a validating decode.
34#[derive(Deserialize)]
35struct RawGridPoint {
36    #[serde(default)]
37    x: i32,
38    #[serde(default)]
39    y: i32,
40}
41
42#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
43#[serde(try_from = "RawGridPoint")]
44pub struct GridPoint {
45    #[serde(default, skip_serializing_if = "crate::entity::is_default")]
46    pub x: i32,
47    #[serde(default, skip_serializing_if = "crate::entity::is_default")]
48    pub y: i32,
49}
50
51impl TryFrom<RawGridPoint> for GridPoint {
52    type Error = OutOfExtent;
53    fn try_from(raw: RawGridPoint) -> Result<Self, Self::Error> {
54        for coordinate in [raw.x, raw.y] {
55            if !(-GRID_LIMIT..=GRID_LIMIT).contains(&coordinate) {
56                return Err(OutOfExtent::Coordinate(coordinate));
57            }
58        }
59        Ok(Self { x: raw.x, y: raw.y })
60    }
61}
62
63#[derive(Deserialize)]
64struct RawGridSize {
65    #[serde(default)]
66    w: u32,
67    #[serde(default)]
68    h: u32,
69}
70
71#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
72#[serde(try_from = "RawGridSize")]
73pub struct GridSize {
74    #[serde(default, skip_serializing_if = "crate::entity::is_default")]
75    pub w: u32,
76    #[serde(default, skip_serializing_if = "crate::entity::is_default")]
77    pub h: u32,
78}
79
80impl TryFrom<RawGridSize> for GridSize {
81    type Error = OutOfExtent;
82    fn try_from(raw: RawGridSize) -> Result<Self, Self::Error> {
83        for extent in [raw.w, raw.h] {
84            if extent > GRID_LIMIT.unsigned_abs() {
85                return Err(OutOfExtent::Size(extent));
86            }
87        }
88        Ok(Self { w: raw.w, h: raw.h })
89    }
90}
91
92/// A grid-space delta. Deliberately not serializable: deltas never cross
93/// the wire — commits carry the absolute values a delta produced.
94#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash)]
95pub struct GridVec {
96    pub dx: i32,
97    pub dy: i32,
98}
99
100impl GridVec {
101    pub const ZERO: GridVec = GridVec { dx: 0, dy: 0 };
102
103    pub const fn new(dx: i32, dy: i32) -> Self {
104        Self { dx, dy }
105    }
106}
107
108impl std::ops::Add<GridVec> for GridPoint {
109    type Output = GridPoint;
110    fn add(self, rhs: GridVec) -> GridPoint {
111        GridPoint {
112            x: self.x + rhs.dx,
113            y: self.y + rhs.dy,
114        }
115    }
116}
117
118impl std::ops::Sub<GridPoint> for GridPoint {
119    type Output = GridVec;
120    fn sub(self, rhs: GridPoint) -> GridVec {
121        GridVec::new(self.x - rhs.x, self.y - rhs.y)
122    }
123}
124
125/// Atomic wherever it appears — one register holds the whole rect.
126#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
127pub struct GridRect {
128    #[serde(default, skip_serializing_if = "crate::entity::is_default")]
129    pub top_left: GridPoint,
130    #[serde(default, skip_serializing_if = "crate::entity::is_default")]
131    pub size: GridSize,
132}
133
134/// `contains` is closed on every edge while `intersects` is open on the
135/// right and bottom — a point on a shared edge is inside both rects, yet
136/// edge-adjacent rects do not overlap. Hit-testing rests on the
137/// disagreement, so re-deriving either would move it.
138impl GridRect {
139    pub fn from_two_pos(a: GridPoint, b: GridPoint) -> Self {
140        let min_x = a.x.min(b.x);
141        let min_y = a.y.min(b.y);
142        Self {
143            top_left: GridPoint { x: min_x, y: min_y },
144            size: GridSize {
145                w: a.x.abs_diff(b.x),
146                h: a.y.abs_diff(b.y),
147            },
148        }
149    }
150
151    pub fn max(self) -> GridPoint {
152        GridPoint {
153            x: self.right(),
154            y: self.bottom(),
155        }
156    }
157
158    pub fn left(self) -> i32 {
159        self.top_left.x
160    }
161
162    pub fn top(self) -> i32 {
163        self.top_left.y
164    }
165
166    pub fn right(self) -> i32 {
167        self.top_left.x + self.size.w as i32
168    }
169
170    pub fn bottom(self) -> i32 {
171        self.top_left.y + self.size.h as i32
172    }
173
174    pub fn contains(self, p: GridPoint) -> bool {
175        p.x >= self.left() && p.x <= self.right() && p.y >= self.top() && p.y <= self.bottom()
176    }
177
178    pub fn intersects(self, other: GridRect) -> bool {
179        self.left() < other.right()
180            && other.left() < self.right()
181            && self.top() < other.bottom()
182            && other.top() < self.bottom()
183    }
184
185    /// The cells two rects share, or `None` where they share none. Open on
186    /// the right and the bottom, as [`Self::intersects`] is.
187    #[must_use]
188    pub fn intersection(self, other: GridRect) -> Option<Self> {
189        if !self.intersects(other) {
190            return None;
191        }
192        let left = self.left().max(other.left());
193        let top = self.top().max(other.top());
194        let right = self.right().min(other.right());
195        let bottom = self.bottom().min(other.bottom());
196        Some(Self {
197            top_left: GridPoint { x: left, y: top },
198            size: GridSize {
199                w: (right - left).unsigned_abs(),
200                h: (bottom - top).unsigned_abs(),
201            },
202        })
203    }
204
205    #[must_use]
206    pub fn translate(self, delta: GridVec) -> Self {
207        Self {
208            top_left: self.top_left + delta,
209            size: self.size,
210        }
211    }
212}
213
214#[derive(Deserialize)]
215struct RawPinSlot {
216    #[serde(default)]
217    side: PinSide,
218    #[serde(default)]
219    offset: u32,
220}
221
222/// Where a pin sits on its block-as-child: an edge and a slot index
223/// (slots, not cells — the pitch is the editor's concern). Atomic: a
224/// slot is one author's intent, and split registers could merge to an
225/// edge one write chose and an offset another did.
226#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
227#[serde(try_from = "RawPinSlot")]
228pub struct PinSlot {
229    #[serde(default, skip_serializing_if = "crate::entity::is_default")]
230    pub side: PinSide,
231    #[serde(default, skip_serializing_if = "crate::entity::is_default")]
232    pub offset: u32,
233}
234
235impl TryFrom<RawPinSlot> for PinSlot {
236    type Error = OutOfExtent;
237    fn try_from(raw: RawPinSlot) -> Result<Self, Self::Error> {
238        if raw.offset > GRID_LIMIT.unsigned_abs() {
239            return Err(OutOfExtent::Slot(raw.offset));
240        }
241        Ok(Self {
242            side: raw.side,
243            offset: raw.offset,
244        })
245    }
246}
247
248/// The lock rides inside the atomic waypoints value: a polyline with its
249/// locks is one author's coherent intent.
250#[derive(Clone, Copy, PartialEq, Eq, Default, Debug, Serialize, Deserialize)]
251pub struct Waypoint {
252    #[serde(default, skip_serializing_if = "crate::entity::is_default")]
253    pub pos: GridPoint,
254    #[serde(default, skip_serializing_if = "crate::entity::is_default")]
255    pub locked: bool,
256}
257
258/// An `f32` quantized to units of 2⁻²⁴ (truncated toward zero), stored as
259/// `i64`. NOTE: `f32 -> FracVal` on NaN saturates to 0 (Rust `as` cast).
260/// Reject NaN at command construction (`debug_assert`) rather than relying on
261/// saturation.
262#[derive(
263    Default, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
264)]
265#[serde(try_from = "i64")]
266pub struct FracVal(i64);
267
268impl FracVal {
269    /// ±2²⁴ in screen coordinates; sums of two stay exact in `i64`. Same
270    /// refuse-don't-clamp rule as [`GRID_LIMIT`].
271    pub const LIMIT: i64 = 1 << 48;
272}
273
274impl TryFrom<i64> for FracVal {
275    type Error = OutOfExtent;
276    fn try_from(raw: i64) -> Result<Self, Self::Error> {
277        if (-Self::LIMIT..=Self::LIMIT).contains(&raw) {
278            Ok(Self(raw))
279        } else {
280            Err(OutOfExtent::Frac(raw))
281        }
282    }
283}
284
285impl From<f32> for FracVal {
286    fn from(val: f32) -> FracVal {
287        debug_assert!(!val.is_nan(), "NaN must not enter the document");
288        Self((val as f64 * 2.0f64.powi(24)) as i64)
289    }
290}
291impl From<FracVal> for f32 {
292    fn from(val: FracVal) -> f32 {
293        ((val.0 as f64) / 2.0f64.powi(24)) as f32
294    }
295}
296
297#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
298pub struct ScreenPoint {
299    #[serde(default, skip_serializing_if = "crate::entity::is_default")]
300    pub x: FracVal,
301    #[serde(default, skip_serializing_if = "crate::entity::is_default")]
302    pub y: FracVal,
303}
304
305#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
306pub struct ScreenSize {
307    #[serde(default, skip_serializing_if = "crate::entity::is_default")]
308    pub w: FracVal,
309    #[serde(default, skip_serializing_if = "crate::entity::is_default")]
310    pub h: FracVal,
311}
312
313/// Atomic (same reasoning as `GridRect`). Default = empty rect (meaningful zero).
314#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Default)]
315pub struct ScreenRect {
316    #[serde(default, skip_serializing_if = "crate::entity::is_default")]
317    pub top_left: ScreenPoint,
318    #[serde(default, skip_serializing_if = "crate::entity::is_default")]
319    pub size: ScreenSize,
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use proptest::prelude::*;
326
327    /// Edge-adjacent rects do not intersect (open on the right and
328    /// bottom); overlapping ones do.
329    #[test]
330    fn intersects_is_open_on_the_right() {
331        let rect = |x, y, w, h| GridRect {
332            top_left: GridPoint { x, y },
333            size: GridSize { w, h },
334        };
335        let a = rect(0, 0, 2, 2);
336        assert!(!a.intersects(rect(2, 0, 2, 2)), "touching edges are apart");
337        assert!(a.intersects(rect(1, 0, 2, 2)));
338        assert!(!a.intersects(rect(0, 2, 2, 2)), "touching edges are apart");
339        assert!(a.intersects(rect(0, 1, 2, 2)));
340    }
341
342    /// `contains` is closed on every edge — a point on the boundary is
343    /// inside — which deliberately disagrees with `intersects` at the
344    /// shared edge, which is what hit-testing rests on.
345    #[test]
346    fn contains_is_closed_on_every_edge() {
347        let rect = GridRect {
348            top_left: GridPoint { x: 0, y: 0 },
349            size: GridSize { w: 2, h: 2 },
350        };
351        for corner in [
352            GridPoint { x: 0, y: 0 },
353            GridPoint { x: 2, y: 2 },
354            GridPoint { x: 0, y: 2 },
355            GridPoint { x: 2, y: 0 },
356        ] {
357            assert!(rect.contains(corner), "{corner:?} is on the boundary");
358        }
359        assert!(!rect.contains(GridPoint { x: 3, y: 0 }));
360    }
361
362    #[test]
363    fn from_two_pos_normalizes_any_corner_pair() {
364        let a = GridPoint { x: 5, y: -1 };
365        let b = GridPoint { x: 2, y: 3 };
366        let expected = GridRect {
367            top_left: GridPoint { x: 2, y: -1 },
368            size: GridSize { w: 3, h: 4 },
369        };
370        assert_eq!(GridRect::from_two_pos(a, b), expected);
371        assert_eq!(GridRect::from_two_pos(b, a), expected);
372        assert_eq!(expected.max(), GridPoint { x: 5, y: 3 });
373    }
374
375    #[test]
376    fn translation_moves_the_corner_and_keeps_the_size() {
377        let rect = GridRect {
378            top_left: GridPoint { x: 1, y: 1 },
379            size: GridSize { w: 2, h: 3 },
380        };
381        let moved = rect.translate(GridVec::new(3, -2));
382        assert_eq!(moved.top_left, GridPoint { x: 4, y: -1 });
383        assert_eq!(moved.size, rect.size);
384        assert_eq!(moved.top_left - rect.top_left, GridVec::new(3, -2));
385    }
386
387    /// One value, one encoding: the two float zeros collapse to one
388    /// `FracVal`, so the sign of a zero can never reach a change hash.
389    #[test]
390    fn both_float_zeros_quantize_identically() {
391        assert_eq!(FracVal::from(0.0f32), FracVal::from(-0.0f32));
392    }
393
394    #[test]
395    #[should_panic(expected = "NaN must not enter the document")]
396    fn nan_is_refused_at_the_boundary() {
397        let _ = FracVal::from(f32::NAN);
398    }
399
400    /// Any f32 of magnitude ≥ 0.5 is an exact multiple of 2⁻²⁴, so typical
401    /// document values (pixel offsets, arc lengths) survive bit-exactly.
402    #[test]
403    fn representable_values_round_trip_exactly() {
404        for v in [0.0f32, 1.0, -1.0, 0.5, -3.25, 1024.75, 65_536.125] {
405            assert_eq!(f32::from(FracVal::from(v)), v);
406        }
407    }
408
409    proptest! {
410        #[test]
411        fn quantization_error_is_within_one_step(v in -1.0e6f32..1.0e6) {
412            let back = f32::from(FracVal::from(v));
413            prop_assert!((f64::from(back) - f64::from(v)).abs() <= 2.0f64.powi(-24));
414        }
415
416        /// Truncation is monotone, so `FracVal`'s derived order agrees with
417        /// f32 order (values within one step may collapse to equal).
418        #[test]
419        fn ordering_is_preserved(a in -1.0e6f32..1.0e6, b in -1.0e6f32..1.0e6) {
420            let (fa, fb) = (FracVal::from(a), FracVal::from(b));
421            if a <= b {
422                prop_assert!(fa <= fb);
423            } else {
424                prop_assert!(fa >= fb);
425            }
426        }
427    }
428}