| 1 | //! Zoom/pan/fit/rotate/flip maths and their animation targets. |
| 2 | //! |
| 3 | //! Coordinates: the image is centred on the origin of *display space* (image pixels after |
| 4 | //! rotation and flip). `center` is the display-space point shown at the middle of the viewport. |
| 5 | //! A screen point is `viewport_centre + scale * (q - center)` for display-space point `q`. |
| 6 | |
| 7 | use std::f32::consts::FRAC_PI_2; |
| 8 | use std::time::{Duration, Instant}; |
| 9 | |
| 10 | use crate::anim::Tween; |
| 11 | |
| 12 | pub const MIN_SCALE: f32 = 0.01; |
| 13 | pub const MAX_SCALE: f32 = 64.0; |
| 14 | /// Magnification above which `filter = auto` switches to nearest. |
| 15 | pub const NEAREST_ABOVE: f32 = 3.0; |
| 16 | |
| 17 | #[derive( |
| 18 | Clone, |
| 19 | Copy, |
| 20 | Debug, |
| 21 | Default, |
| 22 | PartialEq, |
| 23 | Eq, |
| 24 | clap::ValueEnum, |
| 25 | serde::Deserialize, |
| 26 | serde::Serialize, |
| 27 | )] |
| 28 | #[serde(rename_all = "lowercase")] |
| 29 | pub enum FitMode { |
| 30 | /// Fit, but never enlarge. |
| 31 | #[default] |
| 32 | Down, |
| 33 | Fit, |
| 34 | Fill, |
| 35 | Width, |
| 36 | Height, |
| 37 | Original, |
| 38 | } |
| 39 | |
| 40 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 41 | pub enum Dir { |
| 42 | Left, |
| 43 | Right, |
| 44 | Up, |
| 45 | Down, |
| 46 | } |
| 47 | |
| 48 | impl Dir { |
| 49 | pub fn vector(self) -> [f32; 2] { |
| 50 | match self { |
| 51 | Dir::Left => [-1.0, 0.0], |
| 52 | Dir::Right => [1.0, 0.0], |
| 53 | Dir::Up => [0.0, -1.0], |
| 54 | Dir::Down => [0.0, 1.0], |
| 55 | } |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 60 | pub enum Axis { |
| 61 | Horizontal, |
| 62 | Vertical, |
| 63 | } |
| 64 | |
| 65 | /// Maps the unit quad onto the screen: `screen = m * uv + t`, in physical pixels. |
| 66 | #[derive(Clone, Copy, Debug, PartialEq)] |
| 67 | pub struct Affine { |
| 68 | pub m: [f32; 4], |
| 69 | pub t: [f32; 2], |
| 70 | } |
| 71 | |
| 72 | #[derive(Debug, Clone)] |
| 73 | pub struct View { |
| 74 | img: [f32; 2], |
| 75 | viewport: [f32; 2], |
| 76 | fit: Option<FitMode>, |
| 77 | // Targets. |
| 78 | scale: f32, |
| 79 | center: [f32; 2], |
| 80 | quarter: i32, |
| 81 | flip: [f32; 2], |
| 82 | // Displayed values: scale tweens in log space around a fixed screen anchor `d` |
| 83 | // (offset from the viewport centre), so `center = anchor - d / scale`. |
| 84 | log_s: Tween<f32>, |
| 85 | anchor: Tween<[f32; 2]>, |
| 86 | d: [f32; 2], |
| 87 | rot: Tween<f32>, |
| 88 | flip_t: Tween<[f32; 2]>, |
| 89 | } |
| 90 | |
| 91 | fn add(a: [f32; 2], b: [f32; 2]) -> [f32; 2] { |
| 92 | [a[0] + b[0], a[1] + b[1]] |
| 93 | } |
| 94 | |
| 95 | fn sub(a: [f32; 2], b: [f32; 2]) -> [f32; 2] { |
| 96 | [a[0] - b[0], a[1] - b[1]] |
| 97 | } |
| 98 | |
| 99 | fn mul(a: [f32; 2], s: f32) -> [f32; 2] { |
| 100 | [a[0] * s, a[1] * s] |
| 101 | } |
| 102 | |
| 103 | impl View { |
| 104 | pub fn new(now: Instant) -> Self { |
| 105 | Self { |
| 106 | img: [1.0, 1.0], |
| 107 | viewport: [1.0, 1.0], |
| 108 | fit: Some(FitMode::Down), |
| 109 | scale: 1.0, |
| 110 | center: [0.0, 0.0], |
| 111 | quarter: 0, |
| 112 | flip: [1.0, 1.0], |
| 113 | log_s: Tween::new(0.0, now), |
| 114 | anchor: Tween::new([0.0, 0.0], now), |
| 115 | d: [0.0, 0.0], |
| 116 | rot: Tween::new(0.0, now), |
| 117 | flip_t: Tween::new([1.0, 1.0], now), |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | /// Show a new image: rotation and flips reset, then `fit` or a fixed `zoom` applies. |
| 122 | pub fn reset(&mut self, img: [u32; 2], fit: FitMode, zoom: Option<f32>, now: Instant) { |
| 123 | self.img = [img[0].max(1) as f32, img[1].max(1) as f32]; |
| 124 | self.quarter = 0; |
| 125 | self.flip = [1.0, 1.0]; |
| 126 | self.rot.set(0.0); |
| 127 | self.flip_t.set([1.0, 1.0]); |
| 128 | match zoom { |
| 129 | Some(z) => { |
| 130 | self.fit = None; |
| 131 | self.scale = z.clamp(MIN_SCALE, MAX_SCALE); |
| 132 | } |
| 133 | None => { |
| 134 | self.fit = Some(fit); |
| 135 | self.scale = self.fit_scale(fit); |
| 136 | } |
| 137 | } |
| 138 | self.center = self.clamp_center([0.0, 0.0], self.scale); |
| 139 | self.jump(); |
| 140 | let _ = now; |
| 141 | } |
| 142 | |
| 143 | /// The image area changed; refit if a fit mode is active, without animating. |
| 144 | pub fn set_viewport(&mut self, w: f32, h: f32) { |
| 145 | self.viewport = [w.max(1.0), h.max(1.0)]; |
| 146 | if let Some(fit) = self.fit { |
| 147 | self.scale = self.fit_scale(fit); |
| 148 | } |
| 149 | self.center = self.clamp_center(self.center, self.scale); |
| 150 | self.jump(); |
| 151 | } |
| 152 | |
| 153 | pub fn viewport(&self) -> [f32; 2] { |
| 154 | self.viewport |
| 155 | } |
| 156 | |
| 157 | #[cfg(test)] |
| 158 | pub fn fit_mode(&self) -> Option<FitMode> { |
| 159 | self.fit |
| 160 | } |
| 161 | |
| 162 | /// Target scale (1.0 = one image pixel per physical pixel). |
| 163 | pub fn scale(&self) -> f32 { |
| 164 | self.scale |
| 165 | } |
| 166 | |
| 167 | pub fn display_scale(&self, now: Instant) -> f32 { |
| 168 | self.log_s.value(now).exp() |
| 169 | } |
| 170 | |
| 171 | pub fn is_animating(&self, now: Instant) -> bool { |
| 172 | self.log_s.is_active(now) |
| 173 | || self.anchor.is_active(now) |
| 174 | || self.rot.is_active(now) |
| 175 | || self.flip_t.is_active(now) |
| 176 | } |
| 177 | |
| 178 | pub fn set_fit(&mut self, fit: FitMode, now: Instant, dur: Duration) { |
| 179 | self.fit = Some(fit); |
| 180 | let s = self.fit_scale(fit); |
| 181 | let c = self.clamp_center(self.center, s); |
| 182 | self.animate_to(s, c, [0.0, 0.0], now, dur); |
| 183 | } |
| 184 | |
| 185 | /// Zoom by `factor` around `anchor` (viewport pixels), or the centre when `None`. |
| 186 | pub fn zoom_by(&mut self, factor: f32, anchor: Option<[f32; 2]>, now: Instant, dur: Duration) { |
| 187 | self.zoom_to(self.scale * factor, anchor, now, dur); |
| 188 | } |
| 189 | |
| 190 | pub fn zoom_to(&mut self, scale: f32, anchor: Option<[f32; 2]>, now: Instant, dur: Duration) { |
| 191 | let d = match anchor { |
| 192 | Some(a) => sub(a, mul(self.viewport, 0.5)), |
| 193 | None => [0.0, 0.0], |
| 194 | }; |
| 195 | let s1 = scale.clamp(MIN_SCALE, MAX_SCALE); |
| 196 | let p = add(self.center, mul(d, 1.0 / self.scale)); |
| 197 | let c1 = self.clamp_center(sub(p, mul(d, 1.0 / s1)), s1); |
| 198 | self.fit = None; |
| 199 | self.animate_to(s1, c1, d, now, dur); |
| 200 | } |
| 201 | |
| 202 | /// Move the image by a screen-space delta immediately (drag, held keys). |
| 203 | pub fn pan_pixels(&mut self, delta: [f32; 2]) { |
| 204 | let c1 = self.clamp_center(sub(self.center, mul(delta, 1.0 / self.scale)), self.scale); |
| 205 | let moved = sub(c1, self.center); |
| 206 | self.center = c1; |
| 207 | self.anchor.shift(moved); |
| 208 | } |
| 209 | |
| 210 | /// Pan by a fraction of the viewport, animated. |
| 211 | pub fn pan_step(&mut self, dir: Dir, frac: f32, now: Instant, dur: Duration) { |
| 212 | let v = dir.vector(); |
| 213 | let step = [ |
| 214 | v[0] * self.viewport[0] * frac, |
| 215 | v[1] * self.viewport[1] * frac, |
| 216 | ]; |
| 217 | let c1 = self.clamp_center(add(self.center, mul(step, 1.0 / self.scale)), self.scale); |
| 218 | self.animate_to(self.scale, c1, [0.0, 0.0], now, dur); |
| 219 | } |
| 220 | |
| 221 | pub fn pan_edge(&mut self, dir: Dir, now: Instant, dur: Duration) { |
| 222 | let far = mul(dir.vector(), 1e9); |
| 223 | let c1 = self.clamp_center(far, self.scale); |
| 224 | let c1 = match dir { |
| 225 | Dir::Left | Dir::Right => [c1[0], self.center[1]], |
| 226 | Dir::Up | Dir::Down => [self.center[0], c1[1]], |
| 227 | }; |
| 228 | self.animate_to(self.scale, c1, [0.0, 0.0], now, dur); |
| 229 | } |
| 230 | |
| 231 | pub fn recenter(&mut self, now: Instant, dur: Duration) { |
| 232 | let c1 = self.clamp_center([0.0, 0.0], self.scale); |
| 233 | self.animate_to(self.scale, c1, [0.0, 0.0], now, dur); |
| 234 | } |
| 235 | |
| 236 | /// Rotate clockwise on screen by `quarters` quarter turns (negative = counter-clockwise). |
| 237 | pub fn rotate(&mut self, quarters: i32, now: Instant, dur: Duration) { |
| 238 | // A single mirror reverses the visual sense of the rotation angle. |
| 239 | let sign = if self.flip[0] * self.flip[1] < 0.0 { |
| 240 | -1 |
| 241 | } else { |
| 242 | 1 |
| 243 | }; |
| 244 | self.quarter += sign * quarters; |
| 245 | let mut c = self.center; |
| 246 | for _ in 0..quarters.rem_euclid(4) { |
| 247 | c = [-c[1], c[0]]; |
| 248 | } |
| 249 | let s = match self.fit { |
| 250 | Some(fit) => self.fit_scale(fit), |
| 251 | None => self.scale, |
| 252 | }; |
| 253 | let c = self.clamp_center(c, s); |
| 254 | self.rot.retarget(self.quarter as f32 * FRAC_PI_2, now, dur); |
| 255 | self.animate_to(s, c, [0.0, 0.0], now, dur); |
| 256 | } |
| 257 | |
| 258 | pub fn flip(&mut self, axis: Axis, now: Instant, dur: Duration) { |
| 259 | let i = match axis { |
| 260 | Axis::Horizontal => 0, |
| 261 | Axis::Vertical => 1, |
| 262 | }; |
| 263 | self.flip[i] = -self.flip[i]; |
| 264 | let mut c = self.center; |
| 265 | c[i] = -c[i]; |
| 266 | self.flip_t.retarget(self.flip, now, dur); |
| 267 | self.animate_to(self.scale, c, [0.0, 0.0], now, dur); |
| 268 | } |
| 269 | |
| 270 | /// The transform for the frame at `now`. |
| 271 | pub fn transform(&self, now: Instant) -> Affine { |
| 272 | let s = self.display_scale(now); |
| 273 | let c = sub(self.anchor.value(now), mul(self.d, 1.0 / s)); |
| 274 | let (sin, cos) = self.rot.value(now).sin_cos(); |
| 275 | let f = self.flip_t.value(now); |
| 276 | // M = F · R(θ) · diag(img) · s |
| 277 | let m = [ |
| 278 | f[0] * cos * self.img[0] * s, |
| 279 | -f[0] * sin * self.img[1] * s, |
| 280 | f[1] * sin * self.img[0] * s, |
| 281 | f[1] * cos * self.img[1] * s, |
| 282 | ]; |
| 283 | let vc = mul(self.viewport, 0.5); |
| 284 | let t = [ |
| 285 | vc[0] - s * c[0] - 0.5 * (m[0] + m[1]), |
| 286 | vc[1] - s * c[1] - 0.5 * (m[2] + m[3]), |
| 287 | ]; |
| 288 | Affine { m, t } |
| 289 | } |
| 290 | |
| 291 | fn display_dims(&self) -> [f32; 2] { |
| 292 | if self.quarter.rem_euclid(2) == 1 { |
| 293 | [self.img[1], self.img[0]] |
| 294 | } else { |
| 295 | self.img |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | fn fit_scale(&self, fit: FitMode) -> f32 { |
| 300 | let [dw, dh] = self.display_dims(); |
| 301 | let [vw, vh] = self.viewport; |
| 302 | let fit_s = (vw / dw).min(vh / dh); |
| 303 | let s = match fit { |
| 304 | FitMode::Down => fit_s.min(1.0), |
| 305 | FitMode::Fit => fit_s, |
| 306 | FitMode::Fill => (vw / dw).max(vh / dh), |
| 307 | FitMode::Width => vw / dw, |
| 308 | FitMode::Height => vh / dh, |
| 309 | FitMode::Original => 1.0, |
| 310 | }; |
| 311 | s.clamp(MIN_SCALE, MAX_SCALE) |
| 312 | } |
| 313 | |
| 314 | /// Keep the image covering the viewport where it is larger, and centred where smaller. |
| 315 | fn clamp_center(&self, c: [f32; 2], s: f32) -> [f32; 2] { |
| 316 | let dims = self.display_dims(); |
| 317 | let mut out = c; |
| 318 | for i in 0..2 { |
| 319 | let extent = (dims[i] - self.viewport[i] / s) * 0.5; |
| 320 | out[i] = if extent <= 0.0 { |
| 321 | 0.0 |
| 322 | } else { |
| 323 | c[i].clamp(-extent, extent) |
| 324 | }; |
| 325 | } |
| 326 | out |
| 327 | } |
| 328 | |
| 329 | fn animate_to(&mut self, s1: f32, c1: [f32; 2], d: [f32; 2], now: Instant, dur: Duration) { |
| 330 | let s0 = self.display_scale(now); |
| 331 | let c0 = sub(self.anchor.value(now), mul(self.d, 1.0 / s0)); |
| 332 | let p0 = add(c0, mul(d, 1.0 / s0)); |
| 333 | let p1 = add(c1, mul(d, 1.0 / s1)); |
| 334 | self.log_s.animate(s0.ln(), s1.ln(), now, dur); |
| 335 | self.anchor.animate(p0, p1, now, dur); |
| 336 | self.d = d; |
| 337 | self.scale = s1; |
| 338 | self.center = c1; |
| 339 | } |
| 340 | |
| 341 | fn jump(&mut self) { |
| 342 | self.log_s.set(self.scale.ln()); |
| 343 | self.anchor.set(self.center); |
| 344 | self.d = [0.0, 0.0]; |
| 345 | self.rot.set(self.quarter as f32 * FRAC_PI_2); |
| 346 | self.flip_t.set(self.flip); |
| 347 | } |
| 348 | } |
| 349 | |
| 350 | #[cfg(test)] |
| 351 | mod tests { |
| 352 | use super::*; |
| 353 | |
| 354 | const D: Duration = Duration::from_millis(100); |
| 355 | |
| 356 | fn view(img: [u32; 2], vp: [f32; 2], fit: FitMode) -> (View, Instant) { |
| 357 | let now = Instant::now(); |
| 358 | let mut v = View::new(now); |
| 359 | v.set_viewport(vp[0], vp[1]); |
| 360 | v.reset(img, fit, None, now); |
| 361 | (v, now) |
| 362 | } |
| 363 | |
| 364 | fn apply(a: Affine, u: [f32; 2]) -> [f32; 2] { |
| 365 | [ |
| 366 | a.m[0] * u[0] + a.m[1] * u[1] + a.t[0], |
| 367 | a.m[2] * u[0] + a.m[3] * u[1] + a.t[1], |
| 368 | ] |
| 369 | } |
| 370 | |
| 371 | fn close(a: [f32; 2], b: [f32; 2]) -> bool { |
| 372 | (a[0] - b[0]).abs() < 0.01 && (a[1] - b[1]).abs() < 0.01 |
| 373 | } |
| 374 | |
| 375 | #[test] |
| 376 | fn fit_modes() { |
| 377 | let vp = [1000.0, 500.0]; |
| 378 | assert_eq!(view([2000, 2000], vp, FitMode::Fit).0.scale(), 0.25); |
| 379 | assert_eq!(view([2000, 2000], vp, FitMode::Fill).0.scale(), 0.5); |
| 380 | assert_eq!(view([2000, 2000], vp, FitMode::Width).0.scale(), 0.5); |
| 381 | assert_eq!(view([2000, 2000], vp, FitMode::Height).0.scale(), 0.25); |
| 382 | assert_eq!(view([100, 100], vp, FitMode::Down).0.scale(), 1.0); |
| 383 | assert_eq!(view([100, 100], vp, FitMode::Fit).0.scale(), 5.0); |
| 384 | assert_eq!(view([100, 100], vp, FitMode::Original).0.scale(), 1.0); |
| 385 | } |
| 386 | |
| 387 | #[test] |
| 388 | fn fitted_image_is_centred() { |
| 389 | let (v, now) = view([200, 100], [400.0, 400.0], FitMode::Fit); |
| 390 | let a = v.transform(now); |
| 391 | assert!(close(apply(a, [0.0, 0.0]), [0.0, 100.0])); |
| 392 | assert!(close(apply(a, [1.0, 1.0]), [400.0, 300.0])); |
| 393 | } |
| 394 | |
| 395 | #[test] |
| 396 | fn zoom_keeps_anchor_fixed_throughout() { |
| 397 | let (mut v, now) = view([4000, 4000], [1000.0, 1000.0], FitMode::Fit); |
| 398 | let cursor = [700.0, 300.0]; |
| 399 | let a0 = v.transform(now); |
| 400 | // Image uv under the cursor before zooming. |
| 401 | let uv = [ |
| 402 | (cursor[0] - a0.t[0]) / a0.m[0], |
| 403 | (cursor[1] - a0.t[1]) / a0.m[3], |
| 404 | ]; |
| 405 | v.zoom_by(2.0, Some(cursor), now, D); |
| 406 | for ms in [0, 20, 50, 80, 100, 150] { |
| 407 | let a = v.transform(now + Duration::from_millis(ms)); |
| 408 | assert!(close(apply(a, uv), cursor), "drifted at {ms} ms"); |
| 409 | } |
| 410 | assert!(v.fit_mode().is_none()); |
| 411 | } |
| 412 | |
| 413 | #[test] |
| 414 | fn rotation_swaps_fit_dimensions() { |
| 415 | let (mut v, now) = view([200, 100], [400.0, 400.0], FitMode::Fit); |
| 416 | assert_eq!(v.scale(), 2.0); |
| 417 | v.rotate(1, now, Duration::ZERO); |
| 418 | assert_eq!(v.scale(), 2.0); |
| 419 | let (mut v, now) = view([400, 100], [400.0, 200.0], FitMode::Fit); |
| 420 | assert_eq!(v.scale(), 1.0); |
| 421 | v.rotate(1, now, Duration::ZERO); |
| 422 | assert_eq!(v.scale(), 0.5); |
| 423 | // A clockwise turn puts the image's top-left corner at the top-right. |
| 424 | let a = v.transform(now); |
| 425 | assert!(close(apply(a, [0.0, 0.0]), [225.0, 0.0])); |
| 426 | } |
| 427 | |
| 428 | #[test] |
| 429 | fn pan_is_clamped_to_edges() { |
| 430 | let (mut v, now) = view([1000, 1000], [500.0, 500.0], FitMode::Original); |
| 431 | v.pan_pixels([10_000.0, 0.0]); |
| 432 | let a = v.transform(now); |
| 433 | // Dragging right as far as possible shows the left edge. |
| 434 | assert!(close(apply(a, [0.0, 0.0]), [0.0, -250.0])); |
| 435 | v.pan_edge(Dir::Right, now, Duration::ZERO); |
| 436 | let a = v.transform(now); |
| 437 | assert!(close(apply(a, [1.0, 1.0]), [500.0, 750.0])); |
| 438 | } |
| 439 | |
| 440 | #[test] |
| 441 | fn flip_mirrors_on_screen() { |
| 442 | let (mut v, now) = view([200, 100], [200.0, 100.0], FitMode::Fit); |
| 443 | v.flip(Axis::Horizontal, now, Duration::ZERO); |
| 444 | let a = v.transform(now); |
| 445 | assert!(close(apply(a, [0.0, 0.0]), [200.0, 0.0])); |
| 446 | } |
| 447 | } |