1use crate::geometry::{Point, Rect, Size};
16
17#[derive(Clone, Copy, Debug, PartialEq)]
19pub struct SceneTransform {
20 pub zoom: f64,
22 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 pub fn identity() -> Self {
39 Self {
40 zoom: 1.0,
41 offset: Point::ORIGIN,
42 }
43 }
44
45 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 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 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 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 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 pub fn zoom_at(&mut self, cursor_screen: Point, new_zoom: f64, range: (f64, f64)) {
95 let target = clamp_zoom(new_zoom, range);
96 let scene_pt = self.screen_to_scene(cursor_screen);
98 self.zoom = target;
99 self.offset = Point::new(
102 cursor_screen.x - target * scene_pt.x,
103 cursor_screen.y - target * scene_pt.y,
104 );
105 }
106
107 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 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
123fn 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 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 assert!((t.zoom - 2.0).abs() < 1e-12);
204 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 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 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}