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(self.zoom * p.x + self.offset.x, self.zoom * p.y + self.offset.y)
48 }
49
50 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 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 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 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 pub fn zoom_at(&mut self, cursor_screen: Point, new_zoom: f64, range: (f64, f64)) {
92 let target = clamp_zoom(new_zoom, range);
93 let scene_pt = self.screen_to_scene(cursor_screen);
95 self.zoom = target;
96 self.offset = Point::new(
99 cursor_screen.x - target * scene_pt.x,
100 cursor_screen.y - target * scene_pt.y,
101 );
102 }
103
104 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 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
120fn 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 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 assert!((t.zoom - 2.0).abs() < 1e-12);
201 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 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 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}