Skip to main content

blockworx/doc_ng/
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
9/// The document extent, in grid cells: two of them sum far inside `i32`,
10/// so no rect accessor can overflow a value a decoder accepted. Enforced
11/// at the decode boundary, where "bounded by the document extent" stops
12/// being true because the payload came from a stranger. Refused, never
13/// clamped — clamping relocates geometry instead of reporting a bad
14/// payload. Authoring must respect it too, or it writes commits it cannot
15/// read back.
16pub const GRID_LIMIT: i32 = 1 << 20;
17
18#[derive(Debug, thiserror::Error, PartialEq, Eq)]
19pub enum OutOfExtent {
20    #[error("coordinate {0} is outside the document extent (±{GRID_LIMIT})")]
21    Coordinate(i32),
22    #[error("extent {0} exceeds the document extent ({GRID_LIMIT})")]
23    Size(u32),
24    #[error("fixed-point value {0} is outside the screen range (±{limit})", limit = FracVal::LIMIT)]
25    Frac(i64),
26}
27
28/// Mirrors [`GridPoint`] so `try_from` has an unchecked type to decode
29/// into first — serde's price for a validating decode.
30#[derive(Deserialize)]
31struct RawGridPoint {
32    x: i32,
33    y: i32,
34}
35
36#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
37#[serde(try_from = "RawGridPoint")]
38pub struct GridPoint {
39    pub x: i32,
40    pub y: i32,
41}
42
43impl TryFrom<RawGridPoint> for GridPoint {
44    type Error = OutOfExtent;
45    fn try_from(raw: RawGridPoint) -> Result<Self, Self::Error> {
46        for coordinate in [raw.x, raw.y] {
47            if !(-GRID_LIMIT..=GRID_LIMIT).contains(&coordinate) {
48                return Err(OutOfExtent::Coordinate(coordinate));
49            }
50        }
51        Ok(Self { x: raw.x, y: raw.y })
52    }
53}
54
55#[derive(Deserialize)]
56struct RawGridSize {
57    w: u32,
58    h: u32,
59}
60
61#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
62#[serde(try_from = "RawGridSize")]
63pub struct GridSize {
64    pub w: u32,
65    pub h: u32,
66}
67
68impl TryFrom<RawGridSize> for GridSize {
69    type Error = OutOfExtent;
70    fn try_from(raw: RawGridSize) -> Result<Self, Self::Error> {
71        for extent in [raw.w, raw.h] {
72            if extent > GRID_LIMIT.unsigned_abs() {
73                return Err(OutOfExtent::Size(extent));
74            }
75        }
76        Ok(Self { w: raw.w, h: raw.h })
77    }
78}
79
80/// Atomic wherever it appears — one register holds the whole rect.
81#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
82pub struct GridRect {
83    pub top_left: GridPoint,
84    pub size: GridSize,
85}
86
87/// The lock rides inside the atomic waypoints value: a polyline with its
88/// locks is one author's coherent intent.
89#[derive(Clone, Copy, PartialEq, Eq, Default, Debug, Serialize, Deserialize)]
90pub struct Waypoint {
91    pub pos: GridPoint,
92    pub locked: bool,
93}
94
95/// An `f32` quantized to units of 2⁻²⁴ (truncated toward zero), stored as
96/// `i64`. NOTE: `f32 -> FracVal` on NaN saturates to 0 (Rust `as` cast).
97/// Reject NaN at command construction (`debug_assert`) rather than relying on
98/// saturation.
99#[derive(
100    Default, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
101)]
102#[serde(try_from = "i64")]
103pub struct FracVal(i64);
104
105impl FracVal {
106    /// ±2²⁴ in screen coordinates; sums of two stay exact in `i64`. Same
107    /// refuse-don't-clamp rule as [`GRID_LIMIT`].
108    pub const LIMIT: i64 = 1 << 48;
109}
110
111impl TryFrom<i64> for FracVal {
112    type Error = OutOfExtent;
113    fn try_from(raw: i64) -> Result<Self, Self::Error> {
114        if (-Self::LIMIT..=Self::LIMIT).contains(&raw) {
115            Ok(Self(raw))
116        } else {
117            Err(OutOfExtent::Frac(raw))
118        }
119    }
120}
121
122impl From<f32> for FracVal {
123    fn from(val: f32) -> FracVal {
124        debug_assert!(!val.is_nan(), "NaN must not enter the document");
125        Self((val as f64 * 2.0f64.powi(24)) as i64)
126    }
127}
128impl From<FracVal> for f32 {
129    fn from(val: FracVal) -> f32 {
130        ((val.0 as f64) / 2.0f64.powi(24)) as f32
131    }
132}
133
134#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
135pub struct ScreenPoint {
136    pub x: FracVal,
137    pub y: FracVal,
138}
139
140#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
141pub struct ScreenSize {
142    pub w: FracVal,
143    pub h: FracVal,
144}
145
146/// Atomic (same reasoning as `GridRect`). Default = empty rect (meaningful zero).
147#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq, Default)]
148pub struct ScreenRect {
149    pub top_left: ScreenPoint,
150    pub size: ScreenSize,
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use proptest::prelude::*;
157
158    /// One value, one encoding: the two float zeros collapse to one
159    /// `FracVal`, so the sign of a zero can never reach a change hash.
160    #[test]
161    fn both_float_zeros_quantize_identically() {
162        assert_eq!(FracVal::from(0.0f32), FracVal::from(-0.0f32));
163    }
164
165    #[test]
166    #[should_panic(expected = "NaN must not enter the document")]
167    fn nan_is_refused_at_the_boundary() {
168        let _ = FracVal::from(f32::NAN);
169    }
170
171    /// Any f32 of magnitude ≥ 0.5 is an exact multiple of 2⁻²⁴, so typical
172    /// document values (pixel offsets, arc lengths) survive bit-exactly.
173    #[test]
174    fn representable_values_round_trip_exactly() {
175        for v in [0.0f32, 1.0, -1.0, 0.5, -3.25, 1024.75, 65_536.125] {
176            assert_eq!(f32::from(FracVal::from(v)), v);
177        }
178    }
179
180    proptest! {
181        #[test]
182        fn quantization_error_is_within_one_step(v in -1.0e6f32..1.0e6) {
183            let back = f32::from(FracVal::from(v));
184            prop_assert!((f64::from(back) - f64::from(v)).abs() <= 2.0f64.powi(-24));
185        }
186
187        /// Truncation is monotone, so `FracVal`'s derived order agrees with
188        /// f32 order (values within one step may collapse to equal).
189        #[test]
190        fn ordering_is_preserved(a in -1.0e6f32..1.0e6, b in -1.0e6f32..1.0e6) {
191            let (fa, fb) = (FracVal::from(a), FracVal::from(b));
192            if a <= b {
193                prop_assert!(fa <= fb);
194            } else {
195                prop_assert!(fa >= fb);
196            }
197        }
198    }
199}