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