Skip to main content

vectis_crdt/
stroke.rs

1use crate::types::*;
2use crate::rga::StrokeId;
3use std::collections::HashMap;
4
5// ─── StrokePoint ─────────────────────────────────────────────────────────────
6
7/// A point in a stroke. Coordinates + pressure (for stylus).
8/// Layout: 3 × f32 = 12 bytes — cache-line friendly.
9#[derive(Debug, Clone, Copy)]
10pub struct StrokePoint {
11    pub x: f32,
12    pub y: f32,
13    /// 0.0–1.0; defaults to 1.0 for mouse/touch.
14    pub pressure: f32,
15}
16
17impl StrokePoint {
18    #[inline]
19    pub fn new(x: f32, y: f32, pressure: f32) -> Self {
20        Self { x, y, pressure }
21    }
22
23    #[inline]
24    pub fn basic(x: f32, y: f32) -> Self {
25        Self { x, y, pressure: 1.0 }
26    }
27}
28
29// ─── Aabb ─────────────────────────────────────────────────────────────────────
30
31/// Axis-Aligned Bounding Box. Computed once at stroke creation, updated after
32/// point simplification. Never serialized to wire (derived from points).
33///
34/// Coordinates are in canvas space, no padding — callers add `stroke_width/2`
35/// via [`Aabb::expanded`] before intersection tests.
36#[derive(Debug, Clone, Copy)]
37pub struct Aabb {
38    pub min_x: f32,
39    pub min_y: f32,
40    pub max_x: f32,
41    pub max_y: f32,
42}
43
44impl Aabb {
45    /// An AABB that intersects everything. Used for empty/degenerate strokes
46    /// so they are never wrongly culled.
47    pub const INFINITE: Aabb = Aabb {
48        min_x: f32::NEG_INFINITY,
49        min_y: f32::NEG_INFINITY,
50        max_x: f32::INFINITY,
51        max_y: f32::INFINITY,
52    };
53
54    /// Compute a tight AABB from a slice of points.
55    /// Returns `INFINITE` for empty slices (never cull).
56    pub fn from_points(points: &[StrokePoint]) -> Self {
57        if points.is_empty() {
58            return Self::INFINITE;
59        }
60        let mut min_x = f32::INFINITY;
61        let mut min_y = f32::INFINITY;
62        let mut max_x = f32::NEG_INFINITY;
63        let mut max_y = f32::NEG_INFINITY;
64        for p in points {
65            if p.x < min_x { min_x = p.x; }
66            if p.y < min_y { min_y = p.y; }
67            if p.x > max_x { max_x = p.x; }
68            if p.y > max_y { max_y = p.y; }
69        }
70        Aabb { min_x, min_y, max_x, max_y }
71    }
72
73    /// Returns true if `self` overlaps with `other`.
74    #[inline]
75    pub fn intersects(&self, other: &Aabb) -> bool {
76        self.min_x <= other.max_x
77            && self.max_x >= other.min_x
78            && self.min_y <= other.max_y
79            && self.max_y >= other.min_y
80    }
81
82    /// Expand uniformly by `padding` on all sides.
83    /// Use this to account for stroke width: `bounds.expanded(stroke_width / 2.0)`.
84    #[inline]
85    pub fn expanded(self, padding: f32) -> Aabb {
86        Aabb {
87            min_x: self.min_x - padding,
88            min_y: self.min_y - padding,
89            max_x: self.max_x + padding,
90            max_y: self.max_y + padding,
91        }
92    }
93
94    /// Transform this AABB by an affine [`Transform2D`].
95    /// Returns the new AABB enclosing all four transformed corners.
96    /// Used before culling when a stroke has a non-identity transform.
97    pub fn transform(&self, t: &Transform2D) -> Aabb {
98        let corners = [
99            (self.min_x, self.min_y),
100            (self.max_x, self.min_y),
101            (self.min_x, self.max_y),
102            (self.max_x, self.max_y),
103        ];
104        let mut min_x = f32::INFINITY;
105        let mut min_y = f32::INFINITY;
106        let mut max_x = f32::NEG_INFINITY;
107        let mut max_y = f32::NEG_INFINITY;
108        for (x, y) in corners {
109            let tx = t.a * x + t.c * y + t.tx;
110            let ty = t.b * x + t.d * y + t.ty;
111            if tx < min_x { min_x = tx; }
112            if ty < min_y { min_y = ty; }
113            if tx > max_x { max_x = tx; }
114            if ty > max_y { max_y = ty; }
115        }
116        Aabb { min_x, min_y, max_x, max_y }
117    }
118}
119
120// ─── RDP — Ramer-Douglas-Peucker ─────────────────────────────────────────────
121
122/// Perpendicular distance from point `p` to line segment `a`–`b`.
123#[inline]
124fn perp_dist(p: &StrokePoint, a: &StrokePoint, b: &StrokePoint) -> f32 {
125    let dx = b.x - a.x;
126    let dy = b.y - a.y;
127    let len_sq = dx * dx + dy * dy;
128    if len_sq < 1e-10 {
129        // Degenerate segment (a == b): fall back to point-to-point distance.
130        let ex = p.x - a.x;
131        let ey = p.y - a.y;
132        return (ex * ex + ey * ey).sqrt();
133    }
134    // ||(b−a) × (a−p)|| / ||b−a||
135    let cross = (dx * (a.y - p.y) - dy * (a.x - p.x)).abs();
136    cross / len_sq.sqrt()
137}
138
139/// Compute which point indices to keep using Ramer-Douglas-Peucker.
140/// Uses an **iterative (stack-based)** approach — no stack-overflow risk even
141/// for strokes with tens of thousands of points.
142///
143/// Always retains index `0` and `n - 1`.
144fn rdp_indices(points: &[StrokePoint], epsilon: f32) -> Vec<usize> {
145    let n = points.len();
146    if n <= 2 {
147        return (0..n).collect();
148    }
149
150    let mut keep = vec![false; n];
151    keep[0] = true;
152    keep[n - 1] = true;
153
154    // Stack of (start_inclusive, end_inclusive) ranges to process.
155    let mut stack: Vec<(usize, usize)> = Vec::with_capacity(64);
156    stack.push((0, n - 1));
157
158    while let Some((start, end)) = stack.pop() {
159        if end <= start + 1 {
160            continue;
161        }
162        let a = &points[start];
163        let b = &points[end];
164        let mut max_dist = 0.0f32;
165        let mut max_idx = start;
166
167        for (idx, p) in points.iter().enumerate().take(end).skip(start + 1) {
168            let d = perp_dist(p, a, b);
169            if d > max_dist {
170                max_dist = d;
171                max_idx = idx;
172            }
173        }
174
175        if max_dist > epsilon {
176            keep[max_idx] = true;
177            stack.push((start, max_idx));
178            stack.push((max_idx, end));
179        }
180        // else: all intermediate points within epsilon — drop them.
181    }
182
183    keep.iter()
184        .enumerate()
185        .filter_map(|(i, &k)| if k { Some(i) } else { None })
186        .collect()
187}
188
189// ─── ToolKind ────────────────────────────────────────────────────────────────
190
191/// Tool kind that generated the stroke.
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193#[repr(u8)]
194pub enum ToolKind {
195    Pen     = 0,
196    Eraser  = 1,
197    Marker  = 2,
198    Laser   = 3,
199    Shape   = 4,
200    Arrow   = 5,
201}
202
203impl ToolKind {
204    pub fn from_u8(v: u8) -> Self {
205        match v {
206            0 => Self::Pen,
207            1 => Self::Eraser,
208            2 => Self::Marker,
209            3 => Self::Laser,
210            4 => Self::Shape,
211            5 => Self::Arrow,
212            _ => Self::Pen,
213        }
214    }
215}
216
217// ─── StrokeData ───────────────────────────────────────────────────────────────
218
219/// Immutable stroke path data. Created once; if the user "edits" a stroke,
220/// the old one is deleted and a new one created (simplifies the CRDT model).
221///
222/// `bounds` is a tight AABB computed from `points` — **not serialized to wire**
223/// (recomputed on decode). Add `stroke_width / 2` via [`Aabb::expanded`] before
224/// viewport intersection tests.
225#[derive(Debug, Clone)]
226pub struct StrokeData {
227    /// Path points. Immutable after creation / simplification.
228    pub points: Box<[StrokePoint]>,
229    /// Tool type.
230    pub tool: ToolKind,
231    /// Tight AABB (no padding). Recomputed after simplification.
232    pub bounds: Aabb,
233}
234
235impl StrokeData {
236    /// Canonical constructor. Automatically computes `bounds` from `points`.
237    pub fn new(points: Box<[StrokePoint]>, tool: ToolKind) -> Self {
238        let bounds = Aabb::from_points(&points);
239        Self { points, tool, bounds }
240    }
241
242    /// Simplify points in-place using Ramer-Douglas-Peucker.
243    ///
244    /// `epsilon`: maximum allowed perpendicular deviation in canvas units.
245    /// Recommended values:
246    /// - `0.5` for high-DPI displays and fine-grained stylus input
247    /// - `1.0` for standard displays
248    ///
249    /// Returns the number of points removed. Recomputes `bounds` afterward.
250    /// No-op for strokes with ≤ 2 points.
251    ///
252    /// **Typical reduction**: a 500-point freehand stroke simplifies to
253    /// ~30–80 points (~88% reduction) with epsilon = 0.5, with no perceptible
254    /// visual difference at normal zoom levels.
255    pub fn simplify(&mut self, epsilon: f32) -> usize {
256        let original = self.points.len();
257        if original <= 2 {
258            return 0;
259        }
260        let indices = rdp_indices(&self.points, epsilon);
261        let kept = indices.len();
262        if kept == original {
263            return 0;
264        }
265        let new_points: Box<[StrokePoint]> = indices.iter().map(|&i| self.points[i]).collect();
266        self.bounds = Aabb::from_points(&new_points);
267        self.points = new_points;
268        original - kept
269    }
270}
271
272// ─── LwwRegister ─────────────────────────────────────────────────────────────
273
274/// LWW (Last-Write-Wins) register.
275/// `T` is the value; the timestamp determines the winner.
276#[derive(Debug, Clone)]
277pub struct LwwRegister<T: Clone> {
278    pub value: T,
279    pub timestamp: OpId,
280}
281
282impl<T: Clone> LwwRegister<T> {
283    pub fn new(value: T, timestamp: OpId) -> Self {
284        Self { value, timestamp }
285    }
286
287    /// Applies a new value only if `timestamp` is strictly greater.
288    /// Returns true if the value changed.
289    pub fn apply(&mut self, value: T, timestamp: OpId) -> bool {
290        if timestamp > self.timestamp {
291            self.value = value;
292            self.timestamp = timestamp;
293            true
294        } else {
295            false
296        }
297    }
298}
299
300// ─── Transform2D ─────────────────────────────────────────────────────────────
301
302/// Affine 2D transform: `[a, b, c, d, tx, ty]`.
303/// Equivalent to CSS `matrix()` / SVG `transform`.
304#[derive(Debug, Clone, Copy)]
305pub struct Transform2D {
306    pub a: f32,
307    pub b: f32,
308    pub c: f32,
309    pub d: f32,
310    pub tx: f32,
311    pub ty: f32,
312}
313
314impl Default for Transform2D {
315    fn default() -> Self {
316        // Identity matrix
317        Self { a: 1.0, b: 0.0, c: 0.0, d: 1.0, tx: 0.0, ty: 0.0 }
318    }
319}
320
321impl Transform2D {
322    /// Returns true if this is the identity transform.
323    #[inline]
324    pub fn is_identity(&self) -> bool {
325        self.a == 1.0
326            && self.b == 0.0
327            && self.c == 0.0
328            && self.d == 1.0
329            && self.tx == 0.0
330            && self.ty == 0.0
331    }
332}
333
334// ─── StrokeProperties ────────────────────────────────────────────────────────
335
336/// Mutable properties of a stroke. Each field is an independent LWW-Register,
337/// enabling granular concurrent merges without conflict.
338///
339/// Example: User A changes color while User B changes opacity → both changes
340/// are preserved.
341#[derive(Debug, Clone)]
342pub struct StrokeProperties {
343    /// RGBA packed as `0xRRGGBBAA`.
344    pub color: LwwRegister<u32>,
345    pub stroke_width: LwwRegister<f32>,
346    /// 0.0–1.0
347    pub opacity: LwwRegister<f32>,
348    pub transform: LwwRegister<Transform2D>,
349}
350
351impl StrokeProperties {
352    pub fn new(color: u32, stroke_width: f32, opacity: f32, id: OpId) -> Self {
353        Self {
354            color: LwwRegister::new(color, id),
355            stroke_width: LwwRegister::new(stroke_width, id),
356            opacity: LwwRegister::new(opacity, id),
357            transform: LwwRegister::new(Transform2D::default(), id),
358        }
359    }
360}
361
362// ─── StrokeStore ─────────────────────────────────────────────────────────────
363
364/// Stroke store: `StrokeId → (immutable data, mutable properties)`.
365/// Separate from `RgaArray` to keep the ordering structure compact.
366pub struct StrokeStore {
367    /// `pub(crate)` — direct HashMap access bypasses all CRDT invariants.
368    /// Use the provided methods instead.
369    pub(crate) strokes: HashMap<StrokeId, (StrokeData, StrokeProperties)>,
370}
371
372impl StrokeStore {
373    pub fn new() -> Self {
374        Self { strokes: HashMap::new() }
375    }
376
377    pub fn insert(&mut self, id: StrokeId, data: StrokeData, props: StrokeProperties) {
378        self.strokes.insert(id, (data, props));
379    }
380
381    pub fn get(&self, id: &StrokeId) -> Option<(&StrokeData, &StrokeProperties)> {
382        self.strokes.get(id).map(|(d, p)| (d, p))
383    }
384
385    pub fn get_mut(&mut self, id: &StrokeId) -> Option<(&StrokeData, &mut StrokeProperties)> {
386        self.strokes.get_mut(id).map(|(d, p)| (&*d, p))
387    }
388
389    pub fn get_data_mut(&mut self, id: &StrokeId) -> Option<&mut StrokeData> {
390        self.strokes.get_mut(id).map(|(d, _)| d)
391    }
392
393    pub fn remove(&mut self, id: &StrokeId) -> Option<(StrokeData, StrokeProperties)> {
394        self.strokes.remove(id)
395    }
396
397    pub fn contains(&self, id: &StrokeId) -> bool {
398        self.strokes.contains_key(id)
399    }
400}
401
402impl Default for StrokeStore {
403    fn default() -> Self {
404        Self::new()
405    }
406}
407
408// ─── Tests ───────────────────────────────────────────────────────────────────
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn lww_register_apply() {
416        let id_a = OpId { lamport: LamportTs(1), actor: ActorId(1) };
417        let id_b = OpId { lamport: LamportTs(2), actor: ActorId(1) };
418        let id_c = OpId { lamport: LamportTs(1), actor: ActorId(1) };
419
420        let mut reg = LwwRegister::new(10u32, id_a);
421        assert!(reg.apply(20, id_b));
422        assert_eq!(reg.value, 20);
423        assert!(!reg.apply(5, id_c));
424        assert_eq!(reg.value, 20);
425    }
426
427    #[test]
428    fn transform2d_default_is_identity() {
429        let t = Transform2D::default();
430        assert!(t.is_identity());
431        let t2 = Transform2D { a: 1.0, b: 0.0, c: 0.0, d: 1.0, tx: 1.0, ty: 0.0 };
432        assert!(!t2.is_identity());
433    }
434
435    #[test]
436    fn aabb_from_points() {
437        let pts = vec![
438            StrokePoint::basic(0.0, 0.0),
439            StrokePoint::basic(10.0, 5.0),
440            StrokePoint::basic(-2.0, 8.0),
441        ];
442        let b = Aabb::from_points(&pts);
443        assert_eq!(b.min_x, -2.0);
444        assert_eq!(b.min_y, 0.0);
445        assert_eq!(b.max_x, 10.0);
446        assert_eq!(b.max_y, 8.0);
447    }
448
449    #[test]
450    fn aabb_intersects() {
451        let a = Aabb { min_x: 0.0, min_y: 0.0, max_x: 10.0, max_y: 10.0 };
452        let b = Aabb { min_x: 5.0, min_y: 5.0, max_x: 15.0, max_y: 15.0 };
453        let c = Aabb { min_x: 20.0, min_y: 20.0, max_x: 30.0, max_y: 30.0 };
454        assert!(a.intersects(&b));
455        assert!(b.intersects(&a));
456        assert!(!a.intersects(&c));
457    }
458
459    #[test]
460    fn aabb_expanded() {
461        let a = Aabb { min_x: 0.0, min_y: 0.0, max_x: 10.0, max_y: 10.0 };
462        let e = a.expanded(2.0);
463        assert_eq!(e.min_x, -2.0);
464        assert_eq!(e.max_x, 12.0);
465    }
466
467    #[test]
468    fn aabb_transform_identity_leaves_bounds_unchanged() {
469        let a = Aabb { min_x: 1.0, min_y: 2.0, max_x: 5.0, max_y: 6.0 };
470        let t = Transform2D::default();
471        let b = a.transform(&t);
472        assert!((b.min_x - 1.0).abs() < 1e-5);
473        assert!((b.min_y - 2.0).abs() < 1e-5);
474        assert!((b.max_x - 5.0).abs() < 1e-5);
475        assert!((b.max_y - 6.0).abs() < 1e-5);
476    }
477
478    #[test]
479    fn aabb_transform_translation() {
480        let a = Aabb { min_x: 0.0, min_y: 0.0, max_x: 10.0, max_y: 10.0 };
481        let t = Transform2D { a: 1.0, b: 0.0, c: 0.0, d: 1.0, tx: 5.0, ty: -3.0 };
482        let b = a.transform(&t);
483        assert!((b.min_x - 5.0).abs() < 1e-5);
484        assert!((b.min_y - (-3.0)).abs() < 1e-5);
485        assert!((b.max_x - 15.0).abs() < 1e-5);
486        assert!((b.max_y - 7.0).abs() < 1e-5);
487    }
488
489    #[test]
490    fn rdp_straight_line_simplified_to_endpoints() {
491        // 100 collinear points — RDP should keep only 2 (first and last).
492        let pts: Box<[StrokePoint]> = (0..100)
493            .map(|i| StrokePoint::basic(i as f32, i as f32))
494            .collect();
495        let mut data = StrokeData::new(pts, ToolKind::Pen);
496        let removed = data.simplify(0.5);
497        assert_eq!(data.points.len(), 2, "straight line → 2 points");
498        assert_eq!(removed, 98);
499    }
500
501    #[test]
502    fn rdp_curve_keeps_shape() {
503        // Sine wave — should keep many points to represent the curve.
504        let pts: Box<[StrokePoint]> = (0..=360)
505            .map(|i| {
506                let t = (i as f32).to_radians();
507                StrokePoint::basic(i as f32, t.sin() * 100.0)
508            })
509            .collect();
510        let n = pts.len();
511        let mut data = StrokeData::new(pts, ToolKind::Pen);
512        let removed = data.simplify(1.0);
513        assert!(data.points.len() < n / 2, "should reduce significantly");
514        assert!(data.points.len() > 2, "should retain shape");
515        assert_eq!(data.points.len() + removed, n);
516    }
517
518    #[test]
519    fn rdp_two_points_unchanged() {
520        let pts: Box<[StrokePoint]> = vec![
521            StrokePoint::basic(0.0, 0.0),
522            StrokePoint::basic(10.0, 10.0),
523        ].into();
524        let mut data = StrokeData::new(pts, ToolKind::Pen);
525        assert_eq!(data.simplify(0.5), 0);
526        assert_eq!(data.points.len(), 2);
527    }
528
529    #[test]
530    fn stroke_data_bounds_computed_on_new() {
531        let pts: Box<[StrokePoint]> = vec![
532            StrokePoint::basic(3.0, 7.0),
533            StrokePoint::basic(9.0, 2.0),
534        ].into();
535        let data = StrokeData::new(pts, ToolKind::Pen);
536        assert_eq!(data.bounds.min_x, 3.0);
537        assert_eq!(data.bounds.min_y, 2.0);
538        assert_eq!(data.bounds.max_x, 9.0);
539        assert_eq!(data.bounds.max_y, 7.0);
540    }
541
542    #[test]
543    fn simplify_recomputes_bounds() {
544        // Diagonal line — simplify drops intermediate points but endpoints remain.
545        let pts: Box<[StrokePoint]> = (0..=10)
546            .map(|i| StrokePoint::basic(i as f32, i as f32))
547            .collect();
548        let mut data = StrokeData::new(pts, ToolKind::Pen);
549        let removed = data.simplify(0.1);
550        assert!(removed > 0);
551        // Endpoints kept → full extent preserved
552        assert!((data.bounds.min_x - 0.0).abs() < 1e-5);
553        assert!((data.bounds.max_x - 10.0).abs() < 1e-5);
554    }
555}