Skip to main content

xgpui_canvas/
lib.rs

1//! BETA spatial viewport: f64 camera, retained scene culling, snapping and
2//! procedural grids. Expect significant API changes.
3//!
4//! Screen coordinates are viewport-local logical pixels.
5//! World coordinates stay in f64; convert to f32 only after projection.
6
7#[cfg(feature = "gpui")]
8mod viewport;
9#[cfg(feature = "gpui")]
10pub use viewport::{Canvas, GridOptions, GridPainter, GridStyle};
11mod input;
12pub use input::PanGesture;
13mod overview;
14mod scene;
15mod snapping;
16mod spatial;
17pub use overview::{Overview, OverviewCell};
18pub use scene::{LevelOfDetail, Scene, SceneItem, VisibleItem};
19pub use snapping::{AlignmentGuide, Axis, SnapResult};
20
21#[derive(Clone, Copy, Debug, Default, PartialEq)]
22pub struct Point {
23    pub x: f64,
24    pub y: f64,
25}
26
27impl Point {
28    pub const fn new(x: f64, y: f64) -> Self {
29        Self { x, y }
30    }
31}
32
33#[derive(Clone, Copy, Debug)]
34pub struct WorldBounds {
35    pub min: Point,
36    pub max: Point,
37}
38
39impl WorldBounds {
40    pub fn translated(self, delta: Point) -> Self {
41        Self {
42            min: Point::new(self.min.x + delta.x, self.min.y + delta.y),
43            max: Point::new(self.max.x + delta.x, self.max.y + delta.y),
44        }
45    }
46    pub fn is_valid(self) -> bool {
47        [self.min.x, self.min.y, self.max.x, self.max.y]
48            .iter()
49            .all(|v| v.is_finite())
50            && self.min.x <= self.max.x
51            && self.min.y <= self.max.y
52    }
53    pub fn contains(self, point: Point) -> bool {
54        point.x >= self.min.x
55            && point.x <= self.max.x
56            && point.y >= self.min.y
57            && point.y <= self.max.y
58    }
59    pub fn intersects(self, other: Self) -> bool {
60        self.min.x <= other.max.x
61            && self.max.x >= other.min.x
62            && self.min.y <= other.max.y
63            && self.max.y >= other.min.y
64    }
65}
66
67/// Translation and uniform scale; rotation is intentionally deferred.
68#[derive(Clone, Copy, Debug)]
69pub struct Camera2D {
70    center: Point,
71    zoom: f64,
72}
73
74impl Default for Camera2D {
75    fn default() -> Self {
76        Self {
77            center: Point::default(),
78            zoom: 1.0,
79        }
80    }
81}
82
83impl Camera2D {
84    /// Recenter without changing zoom. Invalid coordinates leave the camera unchanged.
85    pub fn center_on(&mut self, world: Point) -> bool {
86        if !world.x.is_finite() || !world.y.is_finite() {
87            return false;
88        }
89        self.center = world;
90        true
91    }
92    pub fn zoom(self) -> f64 {
93        self.zoom
94    }
95    pub fn world_to_screen(self, world: Point, viewport: Point) -> Point {
96        Point::new(
97            (world.x - self.center.x) * self.zoom + viewport.x / 2.,
98            (world.y - self.center.y) * self.zoom + viewport.y / 2.,
99        )
100    }
101    pub fn screen_to_world(self, screen: Point, viewport: Point) -> Point {
102        Point::new(
103            self.center.x + (screen.x - viewport.x / 2.) / self.zoom,
104            self.center.y + (screen.y - viewport.y / 2.) / self.zoom,
105        )
106    }
107    pub fn pan_by(&mut self, delta: Point) {
108        if delta.x.is_finite() && delta.y.is_finite() {
109            self.center.x -= delta.x / self.zoom;
110            self.center.y -= delta.y / self.zoom;
111        }
112    }
113    pub fn zoom_at(&mut self, anchor: Point, viewport: Point, factor: f64) {
114        self.zoom_at_with_limits(anchor, viewport, factor, 0.01, 64.);
115    }
116    /// Pointer-anchored zoom with application-selected finite, positive limits.
117    pub fn zoom_at_with_limits(
118        &mut self,
119        anchor: Point,
120        viewport: Point,
121        factor: f64,
122        min: f64,
123        max: f64,
124    ) {
125        if !min.is_finite() || !max.is_finite() || min <= 0. || max < min {
126            return;
127        }
128        if !factor.is_finite() || factor <= 0. || !anchor.x.is_finite() || !anchor.y.is_finite() {
129            return;
130        }
131        let before = self.screen_to_world(anchor, viewport);
132        self.zoom = (self.zoom * factor).clamp(min, max);
133        let after = self.screen_to_world(anchor, viewport);
134        self.center.x += before.x - after.x;
135        self.center.y += before.y - after.y;
136    }
137    pub fn visible_world_bounds(self, viewport: Point) -> WorldBounds {
138        WorldBounds {
139            min: self.screen_to_world(Point::default(), viewport),
140            max: self.screen_to_world(viewport, viewport),
141        }
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    fn close(a: Point, b: Point) {
149        assert!(
150            (a.x - b.x).abs() < 1e-5 && (a.y - b.y).abs() < 1e-5,
151            "{a:?} != {b:?}"
152        );
153    }
154    #[test]
155    fn extended_zoom_preserves_anchor_and_rejects_invalid_limits() {
156        let mut camera = Camera2D::default();
157        let viewport = Point::new(1000., 700.);
158        let anchor = Point::new(317., 291.);
159        let expected = camera.screen_to_world(anchor, viewport);
160        camera.zoom_at_with_limits(anchor, viewport, 1e12, 0.01, 1e10);
161        assert_eq!(camera.zoom(), 1e10);
162        close(camera.screen_to_world(anchor, viewport), expected);
163        camera.zoom_at_with_limits(anchor, viewport, 0.5, 2., 1.);
164        assert_eq!(camera.zoom(), 1e10);
165        camera.zoom_at(anchor, viewport, 1.);
166        assert_eq!(camera.zoom(), 64.);
167    }
168    #[test]
169    fn projection_and_anchor_survive_zoom_limits_and_large_world() {
170        let mut camera = Camera2D::default();
171        let viewport = Point::new(1000., 700.);
172        camera.pan_by(Point::new(1e10, -1e10));
173        let anchor = Point::new(137., 492.);
174        let world = camera.screen_to_world(anchor, viewport);
175        for factor in [2., 1e8, 1e-12, 3.] {
176            camera.zoom_at(anchor, viewport, factor);
177            close(camera.screen_to_world(anchor, viewport), world);
178            close(camera.world_to_screen(world, viewport), anchor);
179        }
180    }
181    #[test]
182    fn culling_includes_edges_and_pan_moves_content() {
183        let mut camera = Camera2D::default();
184        let viewport = Point::new(100., 100.);
185        camera.pan_by(Point::new(10., 20.));
186        close(
187            camera.world_to_screen(Point::default(), viewport),
188            Point::new(60., 70.),
189        );
190        let visible = camera.visible_world_bounds(viewport);
191        assert!(visible.intersects(WorldBounds {
192            min: visible.max,
193            max: visible.max
194        }));
195        assert!(!visible.intersects(WorldBounds {
196            min: Point::new(1000., 1000.),
197            max: Point::new(1100., 1100.)
198        }));
199    }
200    #[test]
201    fn invalid_input_does_not_poison_camera() {
202        let mut camera = Camera2D::default();
203        camera.zoom_at(Point::default(), Point::new(100., 100.), f64::NAN);
204        camera.pan_by(Point::new(f64::INFINITY, 0.));
205        assert_eq!(camera.zoom(), 1.);
206        assert_eq!(camera.center, Point::default());
207    }
208
209    #[test]
210    fn resize_preserves_world_center_and_updates_culling_bounds() {
211        let mut camera = Camera2D::default();
212        camera.pan_by(Point::new(123., -456.));
213        let center = camera.screen_to_world(Point::new(500., 350.), Point::new(1000., 700.));
214        for viewport in [
215            Point::new(800., 600.),
216            Point::new(1600., 1200.),
217            Point::default(),
218        ] {
219            close(
220                camera.screen_to_world(Point::new(viewport.x / 2., viewport.y / 2.), viewport),
221                center,
222            );
223            let bounds = camera.visible_world_bounds(viewport);
224            close(
225                camera.world_to_screen(bounds.min, viewport),
226                Point::default(),
227            );
228            close(camera.world_to_screen(bounds.max, viewport), viewport);
229        }
230    }
231}