Skip to main content

kui_core/
geom.rs

1//! Plain geometry in logical pixels: [`Vec2`], [`Size`], [`Rect`] and
2//! [`Edges`].
3//!
4//! Every number here is a logical pixel, before the window's scale factor
5//! is applied; the display list and the renderer work in physical pixels.
6//! The types are `#[repr(C)]` and `Copy`, so they cross the FFI boundary
7//! unchanged.
8
9/// A point or offset in logical pixels.
10///
11/// ```rust
12/// use kui_core::Vec2;
13/// let p = Vec2::new(10.0, 4.0).plus(Vec2::new(2.0, 1.0));
14/// assert_eq!((p.x, p.y), (12.0, 5.0));
15/// ```
16#[repr(C)]
17#[derive(Clone, Copy, Debug, Default, PartialEq)]
18pub struct Vec2 {
19    pub x: f32,
20    pub y: f32,
21}
22
23impl Vec2 {
24    pub const ZERO: Vec2 = Vec2 { x: 0.0, y: 0.0 };
25
26    /// `{x, y}` — an offset as a readback spells it.
27    pub fn to_value(self) -> crate::value::Value {
28        use crate::value::Value;
29        Value::map([("x", Value::float(self.x)), ("y", Value::float(self.y))])
30    }
31
32    pub fn new(x: f32, y: f32) -> Self {
33        Self { x, y }
34    }
35
36    /// Component-wise sum.
37    pub fn plus(self, o: Vec2) -> Vec2 {
38        Vec2::new(self.x + o.x, self.y + o.y)
39    }
40
41    /// Component-wise difference.
42    pub fn minus(self, o: Vec2) -> Vec2 {
43        Vec2::new(self.x - o.x, self.y - o.y)
44    }
45
46    /// This displacement rounded to a whole number of physical pixels.
47    ///
48    /// Every offset a *subtree* is moved by goes through here — a `slide`,
49    /// an `enter`/`exit` offset, a scroll — because glyphs are placed at
50    /// whole physical pixels (one raster per glyph, `text::emit`) and their
51    /// box is not. A fractional displacement moves the two by different
52    /// amounts, so text wobbles ±0.5 px inside its own background for as
53    /// long as the motion lasts; a whole one moves them together. Where a
54    /// node sits when it is *still* is untouched: this rounds the offset,
55    /// not the position, so a card laid out at a fractional x stays there
56    /// and its text keeps the gap it had.
57    pub(crate) fn snapped(self, scale: f32) -> Self {
58        if scale <= 0.0 || !scale.is_finite() {
59            return self;
60        }
61        Self::new(
62            snap_px(self.x * scale) / scale,
63            snap_px(self.y * scale) / scale,
64        )
65    }
66}
67
68/// A physical coordinate put on the pixel grid — where a run of glyphs or a
69/// cell grid is placed, so one raster serves every frame.
70///
71/// `floor(v + 0.5)` and not `v.round()`, because this has to survive being
72/// *moved*: `round` breaks a .5 tie away from zero, so text sitting at
73/// exactly x.5 jumps a whole pixel the moment it crosses the origin, which
74/// is the wobble [`Vec2::snapped`] removes coming back at one line on the
75/// screen. This one obeys `snap_px(v + k) == snap_px(v) + k` for every
76/// whole `k`, which is the property that makes a snapped displacement move
77/// a box and its text by the same amount.
78///
79/// The bias is what makes that property survive floating point. At 150%
80/// every other whole logical pixel *is* a half physical one, so exact ties
81/// are ordinary here, not a corner — and a snapped displacement reaches
82/// this through a divide by the scale and a multiply back, which lands a
83/// microscopic hair either side of the tie and picks a different pixel each
84/// way. A thousandth of a pixel is three orders above that noise and three
85/// below anything a placement could show. (Past ~2^16 physical pixels the
86/// float spacing overtakes it again; that is well off any screen.)
87pub(crate) fn snap_px(v: f32) -> f32 {
88    const TIE: f32 = 1.0 / 1024.0;
89    (v + 0.5 + TIE).floor()
90}
91
92#[repr(C)]
93#[derive(Clone, Copy, Debug, Default, PartialEq)]
94pub struct Size {
95    pub w: f32,
96    pub h: f32,
97}
98
99impl Size {
100    pub const ZERO: Size = Size { w: 0.0, h: 0.0 };
101
102    pub fn new(w: f32, h: f32) -> Self {
103        Self { w, h }
104    }
105}
106
107#[repr(C)]
108#[derive(Clone, Copy, Debug, Default, PartialEq)]
109pub struct Rect {
110    pub x: f32,
111    pub y: f32,
112    pub w: f32,
113    pub h: f32,
114}
115
116impl Rect {
117    /// `{x, y, w, h}` — a rect as a readback spells it: a caret, a
118    /// scroller's box, a window's anchor.
119    pub fn to_value(self) -> crate::value::Value {
120        use crate::value::Value;
121        Value::map([
122            ("x", Value::float(self.x)),
123            ("y", Value::float(self.y)),
124            ("w", Value::float(self.w)),
125            ("h", Value::float(self.h)),
126        ])
127    }
128
129    pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
130        Self { x, y, w, h }
131    }
132
133    pub fn from_pos_size(pos: Vec2, size: Size) -> Self {
134        Self {
135            x: pos.x,
136            y: pos.y,
137            w: size.w,
138            h: size.h,
139        }
140    }
141
142    /// The point halfway across and halfway down.
143    pub fn center(&self) -> Vec2 {
144        Vec2 {
145            x: self.x + self.w / 2.0,
146            y: self.y + self.h / 2.0,
147        }
148    }
149
150    pub fn contains(&self, p: Vec2) -> bool {
151        p.x >= self.x && p.x < self.x + self.w && p.y >= self.y && p.y < self.y + self.h
152    }
153
154    pub fn scaled(&self, s: f32) -> Rect {
155        Rect {
156            x: self.x * s,
157            y: self.y * s,
158            w: self.w * s,
159            h: self.h * s,
160        }
161    }
162
163    /// This physical rect with each edge snapped to a whole pixel
164    /// ([`snap_px`]) on its own, so two rects that share an edge land it
165    /// on the same pixel line: `pixelSnap` boxes, and a text's
166    /// backgrounds (`text::emit`).
167    pub(crate) fn on_pixels(&self) -> Rect {
168        let (x0, y0) = (snap_px(self.x), snap_px(self.y));
169        let (x1, y1) = (snap_px(self.x + self.w), snap_px(self.y + self.h));
170        Rect::new(x0, y0, x1 - x0, y1 - y0)
171    }
172
173    /// The smallest rect holding both.
174    pub fn union(&self, other: &Rect) -> Rect {
175        let x = self.x.min(other.x);
176        let y = self.y.min(other.y);
177        let r = (self.x + self.w).max(other.x + other.w);
178        let b = (self.y + self.h).max(other.y + other.h);
179        Rect::new(x, y, r - x, b - y)
180    }
181
182    pub fn intersect(&self, other: &Rect) -> Rect {
183        let x = self.x.max(other.x);
184        let y = self.y.max(other.y);
185        let r = (self.x + self.w).min(other.x + other.w);
186        let b = (self.y + self.h).min(other.y + other.h);
187        Rect {
188            x,
189            y,
190            w: (r - x).max(0.0),
191            h: (b - y).max(0.0),
192        }
193    }
194}
195
196/// A similarity transform — a turn and a uniform scale about the origin,
197/// then a move: `p' = R(angle) · scale · p + t`, y down, so a positive
198/// angle turns clockwise on screen. What a node's `rotate`, `scale` and
199/// `pivot` compose to (ADR 0043), carried by the clip entry its quads
200/// name (`display::Clip::transform`) in the same units as the entry's
201/// rect.
202///
203/// `#[repr(C)]`: four floats, which is how `KuiClip` and the Node
204/// `clips()` buffer read it.
205///
206/// ```rust
207/// use kui_core::{Rect, Transform, Vec2};
208/// // A quarter turn about the centre of a 100 × 50 box at (10, 10).
209/// let t = Transform::about(Vec2::new(60.0, 35.0), 0.25, 1.0);
210/// let p = t.apply(Vec2::new(10.0, 10.0));
211/// assert!((p.x - 85.0).abs() < 1e-4 && (p.y - (-15.0)).abs() < 1e-4);
212/// let back = t.unapply(p);
213/// assert!((back.x - 10.0).abs() < 1e-4 && (back.y - 10.0).abs() < 1e-4);
214/// // Its bounding box is the box turned: 50 wide, 100 tall, same centre.
215/// let b = t.bounds(Rect::new(10.0, 10.0, 100.0, 50.0));
216/// assert!((b.w - 50.0).abs() < 1e-3 && (b.h - 100.0).abs() < 1e-3);
217/// ```
218#[repr(C)]
219#[derive(Clone, Copy, Debug, PartialEq)]
220pub struct Transform {
221    /// Radians, clockwise with y down.
222    pub angle: f32,
223    /// The uniform factor; 1 for none.
224    pub scale: f32,
225    /// The move after the turn and the scale.
226    pub tx: f32,
227    pub ty: f32,
228}
229
230impl Default for Transform {
231    fn default() -> Self {
232        Self::IDENTITY
233    }
234}
235
236impl Transform {
237    /// No turn, no scale, no move.
238    pub const IDENTITY: Transform = Transform {
239        angle: 0.0,
240        scale: 1.0,
241        tx: 0.0,
242        ty: 0.0,
243    };
244
245    /// A turn of `turns` (clockwise, y down) and a scale of `scale` about
246    /// `pivot`, which stays where it is.
247    pub fn about(pivot: Vec2, turns: f32, scale: f32) -> Self {
248        let angle = turns * std::f32::consts::TAU;
249        let (s, c) = angle.sin_cos();
250        // t = pivot − R·s·pivot
251        let rx = (pivot.x * c - pivot.y * s) * scale;
252        let ry = (pivot.x * s + pivot.y * c) * scale;
253        Transform {
254            angle,
255            scale,
256            tx: pivot.x - rx,
257            ty: pivot.y - ry,
258        }
259    }
260
261    pub fn is_identity(&self) -> bool {
262        self.angle == 0.0 && self.scale == 1.0 && self.tx == 0.0 && self.ty == 0.0
263    }
264
265    /// `p` through this transform.
266    pub fn apply(&self, p: Vec2) -> Vec2 {
267        let (s, c) = self.angle.sin_cos();
268        let x = p.x * self.scale;
269        let y = p.y * self.scale;
270        Vec2 {
271            x: x * c - y * s + self.tx,
272            y: x * s + y * c + self.ty,
273        }
274    }
275
276    /// The point that maps to `p`: the inverse. A scale of zero has no
277    /// inverse; the answer is then NaN, which no rect contains, so nothing
278    /// is hit, as nothing is drawn.
279    pub fn unapply(&self, p: Vec2) -> Vec2 {
280        if self.scale == 0.0 {
281            return Vec2::new(f32::NAN, f32::NAN);
282        }
283        let (s, c) = (-self.angle).sin_cos();
284        let x = p.x - self.tx;
285        let y = p.y - self.ty;
286        Vec2 {
287            x: (x * c - y * s) / self.scale,
288            y: (x * s + y * c) / self.scale,
289        }
290    }
291
292    /// This transform, then `outer`: the composition a nested turn is
293    /// (`inner.then(outer)` maps a point as `outer.apply(inner.apply(p))`).
294    pub fn then(&self, outer: &Transform) -> Transform {
295        let t = outer.apply(Vec2::new(self.tx, self.ty));
296        Transform {
297            angle: self.angle + outer.angle,
298            scale: self.scale * outer.scale,
299            tx: t.x,
300            ty: t.y,
301        }
302    }
303
304    /// Logical to physical pixels: the move scales, the turn and the
305    /// factor do not.
306    pub fn scaled(&self, s: f32) -> Transform {
307        Transform {
308            angle: self.angle,
309            scale: self.scale,
310            tx: self.tx * s,
311            ty: self.ty * s,
312        }
313    }
314
315    /// The smallest axis-aligned rect holding `r` put through this
316    /// transform: what an access rect and a cull read.
317    pub fn bounds(&self, r: Rect) -> Rect {
318        let corners = [
319            self.apply(Vec2::new(r.x, r.y)),
320            self.apply(Vec2::new(r.x + r.w, r.y)),
321            self.apply(Vec2::new(r.x + r.w, r.y + r.h)),
322            self.apply(Vec2::new(r.x, r.y + r.h)),
323        ];
324        let mut x0 = f32::INFINITY;
325        let mut y0 = f32::INFINITY;
326        let mut x1 = f32::NEG_INFINITY;
327        let mut y1 = f32::NEG_INFINITY;
328        for c in corners {
329            x0 = x0.min(c.x);
330            y0 = y0.min(c.y);
331            x1 = x1.max(c.x);
332            y1 = y1.max(c.y);
333        }
334        Rect::new(x0, y0, x1 - x0, y1 - y0)
335    }
336
337    /// The bounding box of `r` pulled back through this transform: the
338    /// rect in this transform's source space that covers everything of
339    /// `r` in its target space — how a clip from outside a turn is
340    /// approximated inside it (ADR 0043, decision 4).
341    pub fn unbounds(&self, r: Rect) -> Rect {
342        if self.scale == 0.0 {
343            return Rect::new(0.0, 0.0, 0.0, 0.0);
344        }
345        let inv = Transform {
346            angle: -self.angle,
347            scale: 1.0 / self.scale,
348            tx: 0.0,
349            ty: 0.0,
350        };
351        let o = inv.apply(Vec2::new(-self.tx, -self.ty));
352        Transform {
353            tx: o.x,
354            ty: o.y,
355            ..inv
356        }
357        .bounds(r)
358    }
359
360    /// The four lanes a tween carries for the slot: angle in turns, the
361    /// scale, and two spare. A turn or a scale that is not a finite number
362    /// is none, so a NaN from a binding never reaches a tween it would hold
363    /// for good.
364    pub(crate) fn lanes(turns: f32, scale: f32) -> [f32; 4] {
365        [finite_or(turns, 0.0), finite_or(scale, 1.0), 0.0, 0.0]
366    }
367}
368
369/// Per-side lengths: padding, borders.
370#[repr(C)]
371#[derive(Clone, Copy, Debug, Default, PartialEq)]
372pub struct Edges {
373    pub l: f32,
374    pub r: f32,
375    pub t: f32,
376    pub b: f32,
377}
378
379impl Edges {
380    pub fn all(v: f32) -> Self {
381        Self {
382            l: v,
383            r: v,
384            t: v,
385            b: v,
386        }
387    }
388
389    pub fn xy(x: f32, y: f32) -> Self {
390        Self {
391            l: x,
392            r: x,
393            t: y,
394            b: y,
395        }
396    }
397
398    /// Total horizontal extent.
399    pub fn x(&self) -> f32 {
400        self.l + self.r
401    }
402
403    /// Total vertical extent.
404    pub fn y(&self) -> f32 {
405        self.t + self.b
406    }
407}
408
409/// `v`, or `none` when `v` is NaN or infinite: a turn or a scale from a
410/// binding's raw field.
411#[inline]
412pub(crate) fn finite_or(v: f32, none: f32) -> f32 {
413    if v.is_finite() { v } else { none }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn a_whole_pixel_shift_moves_a_snapped_coordinate_by_exactly_that() {
422        // The property `Vec2::snapped` relies on: box and text move
423        // together only if shifting by a whole pixel shifts the snapped
424        // coordinate by the same whole pixel — at a tie, across zero, and
425        // through the float noise a logical round trip leaves behind.
426        for v in [0.0f32, 0.25, 0.5, 10.5, -0.5, -26.5, 31.5, 7.3, -118.5] {
427            for k in [-200.0f32, -1.0, 0.0, 1.0, 3.0, 141.0] {
428                assert_eq!(
429                    snap_px(v + k),
430                    snap_px(v) + k,
431                    "snap_px({v}) shifted by {k}"
432                );
433            }
434        }
435    }
436
437    #[test]
438    fn a_snapped_displacement_is_whole_physical_pixels() {
439        for scale in [1.0f32, 1.25, 1.5, 2.0, 3.0] {
440            for d in [0.0f32, 0.1, -0.4, 12.34, -99.9] {
441                let s = Vec2::new(d, -d).snapped(scale);
442                for v in [s.x, s.y] {
443                    let px = v * scale;
444                    assert!((px - px.round()).abs() < 1e-3, "{v} at {scale} is {px} px");
445                }
446            }
447        }
448        // A scale that cannot be divided by is left alone rather than
449        // turning a position into a NaN.
450        assert_eq!(Vec2::new(1.5, 2.5).snapped(0.0), Vec2::new(1.5, 2.5));
451    }
452}