Skip to main content

agg_gui/widgets/scene/
transform.rs

1//! Pan/zoom transform math for the [`Scene`](super::Scene) container.
2//!
3//! A [`SceneTransform`] is a translation + uniform positive scale that maps
4//! **scene coordinates** (the space child widgets are laid out in) to
5//! **screen-local coordinates** (the Scene widget's own bottom-left-origin
6//! Y-up pixel space).  Both spaces are Y-up — unlike egui, whose Scene is
7//! Y-down — so a positive uniform scale needs no axis flips: a scene point
8//! above another stays above it on screen.
9//!
10//! The mapping is `screen = zoom * scene + offset` (component-wise).  Keeping
11//! the transform as an explicit `(zoom, offset)` pair rather than a full
12//! affine matrix keeps the zoom-at-cursor and fit math trivial to reason
13//! about and to unit-test.
14
15use crate::geometry::{Point, Rect, Size};
16
17/// Translation + uniform scale mapping scene space to Scene-local screen space.
18#[derive(Clone, Copy, Debug, PartialEq)]
19pub struct SceneTransform {
20    /// Screen pixels per scene unit.  Always `> 0`.
21    pub zoom: f64,
22    /// Scene→screen translation, applied after scaling (Scene-local Y-up px).
23    pub offset: Point,
24}
25
26impl Default for SceneTransform {
27    fn default() -> Self {
28        Self::identity()
29    }
30}
31
32impl SceneTransform {
33    pub fn new(zoom: f64, offset: Point) -> Self {
34        Self { zoom, offset }
35    }
36
37    /// Zoom 1, no translation — scene coordinates equal screen coordinates.
38    pub fn identity() -> Self {
39        Self {
40            zoom: 1.0,
41            offset: Point::ORIGIN,
42        }
43    }
44
45    /// Map a scene-space point to Scene-local screen space.
46    pub fn scene_to_screen(&self, p: Point) -> Point {
47        Point::new(self.zoom * p.x + self.offset.x, self.zoom * p.y + self.offset.y)
48    }
49
50    /// This transform as a [`crate::TransAffine`] mapping scene (child) space to
51    /// Scene-local screen space, i.e. `screen = zoom * scene + offset`.
52    ///
53    /// This is what `Scene` returns from
54    /// [`Widget::child_transform`](crate::widget::Widget::child_transform) so
55    /// the framework's paint / hit-test / dispatch / inspector traversals all
56    /// map coordinates through the same pan/zoom the content is drawn under.
57    pub fn to_affine(&self) -> crate::TransAffine {
58        let mut m = crate::TransAffine::new_scaling_uniform(self.zoom);
59        m.translate(self.offset.x, self.offset.y);
60        m
61    }
62
63    /// Map a Scene-local screen point back to scene space.
64    pub fn screen_to_scene(&self, p: Point) -> Point {
65        let z = self.zoom.max(1e-12);
66        Point::new((p.x - self.offset.x) / z, (p.y - self.offset.y) / z)
67    }
68
69    /// The region of scene space currently visible in a container of `size`
70    /// (the Scene widget's bounds), expressed in scene coordinates.
71    ///
72    /// This is what the caller reads back as the "scene rect".
73    pub fn visible_scene_rect(&self, size: Size) -> Rect {
74        let bl = self.screen_to_scene(Point::ORIGIN);
75        let z = self.zoom.max(1e-12);
76        Rect::new(bl.x, bl.y, size.width / z, size.height / z)
77    }
78
79    /// Translate the view by a screen-space delta (drag-to-pan).  Content
80    /// follows the cursor: dragging right moves the scene content right.
81    pub fn pan(&mut self, delta_screen: Point) {
82        self.offset.x += delta_screen.x;
83        self.offset.y += delta_screen.y;
84    }
85
86    /// Change the zoom to `new_zoom` (clamped to `range`) while keeping the
87    /// scene point currently under `cursor_screen` fixed on screen.
88    ///
89    /// This is the invariant that makes wheel-zoom feel anchored to the
90    /// cursor rather than to the origin.
91    pub fn zoom_at(&mut self, cursor_screen: Point, new_zoom: f64, range: (f64, f64)) {
92        let target = clamp_zoom(new_zoom, range);
93        // Scene point under the cursor *before* the zoom change.
94        let scene_pt = self.screen_to_scene(cursor_screen);
95        self.zoom = target;
96        // Solve `cursor = target * scene_pt + offset` for the new offset so
97        // that same scene point still lands under the cursor.
98        self.offset = Point::new(
99            cursor_screen.x - target * scene_pt.x,
100            cursor_screen.y - target * scene_pt.y,
101        );
102    }
103
104    /// Build a transform that fits `content` (a rect in scene coords) fully
105    /// inside a `container` of the given size, centered, with a uniform scale
106    /// clamped to `range`.  Used for the initial view and for "reset view".
107    pub fn fit(content: Rect, container: Size, range: (f64, f64)) -> Self {
108        let cw = content.width.max(1e-9);
109        let ch = content.height.max(1e-9);
110        let raw = (container.width / cw).min(container.height / ch);
111        let zoom = clamp_zoom(raw, range);
112        // Center: map the content's center onto the container's center.
113        let cc = Point::new(container.width * 0.5, container.height * 0.5);
114        let sc = content.center();
115        let offset = Point::new(cc.x - zoom * sc.x, cc.y - zoom * sc.y);
116        Self { zoom, offset }
117    }
118}
119
120/// Clamp `z` into `(min, max)`, tolerating a caller that passes the pair
121/// reversed.
122fn clamp_zoom(z: f64, range: (f64, f64)) -> f64 {
123    let (lo, hi) = if range.0 <= range.1 {
124        (range.0, range.1)
125    } else {
126        (range.1, range.0)
127    };
128    z.clamp(lo, hi)
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    const RANGE: (f64, f64) = (0.1, 2.0);
136
137    fn approx(a: Point, b: Point) {
138        assert!(
139            (a.x - b.x).abs() < 1e-9 && (a.y - b.y).abs() < 1e-9,
140            "expected {:?} ≈ {:?}",
141            a,
142            b
143        );
144    }
145
146    #[test]
147    fn screen_scene_round_trip() {
148        let t = SceneTransform::new(1.5, Point::new(10.0, 20.0));
149        let p = Point::new(3.0, 4.0);
150        let screen = t.scene_to_screen(p);
151        approx(t.screen_to_scene(screen), p);
152    }
153
154    #[test]
155    fn zoom_at_cursor_keeps_scene_point_fixed() {
156        let mut t = SceneTransform::new(2.0, Point::new(5.0, 5.0));
157        let cursor = Point::new(100.0, 80.0);
158        let scene_before = t.screen_to_scene(cursor);
159        t.zoom_at(cursor, 0.5, RANGE);
160        let scene_after = t.screen_to_scene(cursor);
161        approx(scene_after, scene_before);
162        assert!((t.zoom - 0.5).abs() < 1e-12);
163    }
164
165    #[test]
166    fn zoom_is_clamped_to_range() {
167        let mut t = SceneTransform::identity();
168        t.zoom_at(Point::new(50.0, 50.0), 10.0, RANGE);
169        assert!((t.zoom - 2.0).abs() < 1e-12, "zoom should clamp to max");
170        t.zoom_at(Point::new(50.0, 50.0), 0.0001, RANGE);
171        assert!((t.zoom - 0.1).abs() < 1e-12, "zoom should clamp to min");
172    }
173
174    #[test]
175    fn pan_shifts_visible_rect_inversely() {
176        let mut t = SceneTransform::identity();
177        let size = Size::new(200.0, 150.0);
178        let before = t.visible_scene_rect(size);
179        // Pan content +30 in x, -10 in y (screen delta). At zoom 1 the visible
180        // scene rect's origin moves by the negative of that delta.
181        t.pan(Point::new(30.0, -10.0));
182        let after = t.visible_scene_rect(size);
183        assert!((after.x - (before.x - 30.0)).abs() < 1e-9);
184        assert!((after.y - (before.y + 10.0)).abs() < 1e-9);
185    }
186
187    #[test]
188    fn identity_visible_rect_matches_container() {
189        let t = SceneTransform::identity();
190        let r = t.visible_scene_rect(Size::new(200.0, 150.0));
191        assert_eq!(r, Rect::new(0.0, 0.0, 200.0, 150.0));
192    }
193
194    #[test]
195    fn fit_centers_content_in_container() {
196        let content = Rect::new(0.0, 0.0, 100.0, 50.0);
197        let container = Size::new(200.0, 200.0);
198        let t = SceneTransform::fit(content, container, RANGE);
199        // min(200/100, 200/50) = min(2, 4) = 2, within range.
200        assert!((t.zoom - 2.0).abs() < 1e-12);
201        // Content center should land at the container center.
202        approx(
203            t.scene_to_screen(content.center()),
204            Point::new(100.0, 100.0),
205        );
206    }
207
208    #[test]
209    fn to_affine_matches_scene_to_screen() {
210        // The affine handed to the framework must reproduce `scene_to_screen`
211        // exactly, and its inverse must reproduce `screen_to_scene` — that is
212        // the contract the hit-test / dispatch traversals rely on.
213        let t = SceneTransform::new(1.75, Point::new(12.0, -7.0));
214        let m = t.to_affine();
215        for p in [
216            Point::new(0.0, 0.0),
217            Point::new(3.0, 4.0),
218            Point::new(-10.0, 25.0),
219        ] {
220            let (mut x, mut y) = (p.x, p.y);
221            m.transform(&mut x, &mut y);
222            let expect = t.scene_to_screen(p);
223            approx(Point::new(x, y), expect);
224
225            let (mut ix, mut iy) = (expect.x, expect.y);
226            m.inverse_transform(&mut ix, &mut iy);
227            approx(Point::new(ix, iy), p);
228        }
229    }
230
231    #[test]
232    fn fit_scale_clamped_to_range() {
233        // A tiny content rect would need a huge scale to fill the container;
234        // the fit must clamp to the zoom range's max.
235        let content = Rect::new(0.0, 0.0, 1.0, 1.0);
236        let t = SceneTransform::fit(content, Size::new(1000.0, 1000.0), RANGE);
237        assert!((t.zoom - 2.0).abs() < 1e-12);
238    }
239}