1use core::time::Duration;
11
12use blockworx_geom::{Pos2, Rect, Vec2, grid::GRID_SIZE};
13use blockworx_paint::{Factor, Move, Vantage, Zoom, ZoomStep};
14
15use crate::session::Session;
16
17#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
19pub enum Refit {
20 #[default]
21 Idle,
22 Owed,
23}
24
25#[derive(Clone, Copy, PartialEq, Eq, Debug)]
29pub enum CameraWork {
30 Worked,
31 Idle,
32}
33
34#[derive(Clone, Copy, PartialEq, Eq, Debug)]
36pub enum Glide {
37 Eased,
38 Snap,
42}
43
44const FRAME_MAX_ZOOM: f32 = 2.0;
50
51const FIT_BORDER_CELLS: f32 = 4.0;
54
55const CAMERA_SLACK: f32 = 0.125;
58
59const VIEW_ANIMATION_RATE: f32 = 16.0;
61
62const LONGEST_EASED_FRAME: Duration = Duration::from_millis(100);
66
67fn overshoot(lo: f32, hi: f32, visible_lo: f32, visible_hi: f32) -> f32 {
71 if lo < visible_lo {
72 lo - visible_lo
73 } else if hi > visible_hi {
74 hi - visible_hi
75 } else {
76 0.0
77 }
78}
79
80#[derive(Clone, Copy, Debug)]
83pub(crate) struct Camera {
84 pub(crate) vantage: Vantage,
85 pub(crate) viewport: Rect,
87 pub(crate) safe: Rect,
90 target: Option<Vantage>,
92 pub(crate) worked: CameraWork,
93}
94
95impl Camera {
96 pub(crate) fn resting() -> Self {
97 Self {
98 vantage: Vantage::resting(),
99 viewport: Rect::ZERO,
100 safe: Rect::NOTHING,
101 target: None,
102 worked: CameraWork::Idle,
103 }
104 }
105
106 fn framing_rect(&self) -> Rect {
109 if self.safe.is_positive() {
110 self.safe
111 } else {
112 self.viewport
113 }
114 }
115
116 pub(crate) fn visible(&self) -> Rect {
118 self.vantage.visible(self.viewport)
119 }
120
121 pub(crate) fn stand_at(&mut self, vantage: Vantage) {
125 self.vantage = vantage;
126 self.target = None;
127 }
128
129 pub(crate) fn fit_to_rect(&mut self, content: Rect) {
134 self.frame_rect(
135 content.expand2(content.size() * CAMERA_SLACK),
136 Zoom::max_value(),
137 Glide::Snap,
138 );
139 }
140
141 pub(crate) fn fit_content(&mut self, content: Rect) {
146 self.frame_rect(
147 content.expand(FIT_BORDER_CELLS * GRID_SIZE),
148 Zoom::new(FRAME_MAX_ZOOM),
149 Glide::Snap,
150 );
151 }
152
153 pub(crate) fn focus_on(&mut self, content: Rect, glide: Glide) {
157 self.frame_rect(
158 content.expand2(content.size() * CAMERA_SLACK),
159 Zoom::new(FRAME_MAX_ZOOM),
160 glide,
161 );
162 }
163
164 pub(crate) fn bring_into_view(&mut self, content: Rect, glide: Glide) {
170 if !content.is_positive() || !self.viewport.is_positive() {
171 return;
172 }
173 let visible = self.visible();
174 if visible.contains_rect(content) {
175 return;
176 }
177 let fits = |r: Rect| r.width() <= visible.width() && r.height() <= visible.height();
178 let gutter = content.expand(FIT_BORDER_CELLS * GRID_SIZE);
179 let target = if fits(gutter) { gutter } else { content };
180 if !fits(target) {
181 self.focus_on(content, glide);
182 return;
183 }
184 let shift = Vec2::new(
185 overshoot(target.min.x, target.max.x, visible.min.x, visible.max.x),
186 overshoot(target.min.y, target.max.y, visible.min.y, visible.max.y),
187 );
188 let panned = Vantage {
189 zoom: self.vantage.zoom,
190 translation: self.vantage.translation - shift * self.vantage.zoom.get(),
191 };
192 self.glide_to(panned, glide);
193 }
194
195 fn frame_rect(&mut self, content: Rect, max_zoom: Zoom, glide: Glide) {
196 if !content.is_positive() || !self.viewport.is_positive() {
197 return;
198 }
199 let framed = Vantage::framing(self.framing_rect(), self.viewport.min, content, max_zoom);
202 self.glide_to(framed, glide);
203 }
204
205 fn glide_to(&mut self, vantage: Vantage, glide: Glide) {
206 match glide {
207 Glide::Eased => self.target = Some(vantage),
208 Glide::Snap => self.stand_at(vantage),
209 }
210 }
211
212 pub(crate) fn ease(&mut self, dt: Duration) -> bool {
217 let Some(target) = self.target else {
218 return false;
219 };
220 let dt = dt.min(LONGEST_EASED_FRAME).as_secs_f32();
221 let t = 1.0 - (-VIEW_ANIMATION_RATE * dt).exp();
222 let zoom = self.vantage.zoom.get() + (target.zoom.get() - self.vantage.zoom.get()) * t;
223 let translation =
224 self.vantage.translation + (target.translation - self.vantage.translation) * t;
225 self.vantage = Vantage {
226 zoom: Zoom::new(zoom),
227 translation,
228 };
229 if (zoom - target.zoom.get()).abs() < 1e-3
230 && (translation - target.translation).length() < 0.5
231 {
232 self.stand_at(target);
233 return false;
234 }
235 true
236 }
237
238 pub(crate) fn moved(&mut self, moved: Move) {
241 match moved {
242 Move::Pan(delta) => {
243 self.target = None;
244 self.vantage.translation += delta;
245 self.worked = CameraWork::Worked;
246 }
247 Move::Zoom { factor, anchor } => self.zoom_about(factor, anchor),
248 }
249 }
250
251 fn zoom_about(&mut self, factor: Factor, anchor: Pos2) {
252 self.target = None;
253 self.worked = CameraWork::Worked;
254 self.vantage = self.vantage.zoomed_about(self.viewport.min, anchor, factor);
255 }
256
257 pub(crate) fn zoom_step(&mut self, step: ZoomStep, anchor: Option<Pos2>) {
260 if !self.viewport.is_positive() {
261 return;
262 }
263 let anchor = anchor
264 .filter(|p| self.viewport.contains(*p))
265 .unwrap_or_else(|| self.viewport.center());
266 self.zoom_about(step.factor(), anchor);
267 }
268}
269
270impl Session {
271 pub fn vantage(&self) -> Vantage {
272 self.camera.vantage
273 }
274
275 pub fn viewport(&self) -> Rect {
276 self.camera.viewport
277 }
278
279 pub fn set_viewport(&mut self, viewport: Rect) {
281 self.camera.viewport = viewport;
282 }
283
284 pub fn set_safe_region(&mut self, safe: Rect) {
288 self.camera.safe = safe;
289 }
290
291 pub fn visible_world(&self) -> Rect {
293 self.camera.visible()
294 }
295
296 pub fn screen_to_world(&self, screen: Pos2) -> Pos2 {
299 self.vantage()
300 .screen_to_world(self.camera.viewport.min, screen)
301 }
302
303 pub fn moves(&mut self, moves: &[Move]) {
306 for &moved in moves {
307 self.camera.moved(moved);
308 }
309 }
310
311 pub fn stand_at(&mut self, vantage: Vantage) {
313 self.camera.stand_at(vantage);
314 }
315
316 pub fn fit_to_rect(&mut self, content: Rect) {
318 self.camera.fit_to_rect(content);
319 }
320
321 pub fn fit_content(&mut self, content: Rect) {
324 self.camera.fit_content(content);
325 }
326
327 pub(crate) fn focus_on(&mut self, content: Rect, glide: Glide) {
328 self.camera.focus_on(content, glide);
329 }
330
331 pub(crate) fn bring_into_view(&mut self, content: Rect, glide: Glide) {
332 self.camera.bring_into_view(content, glide);
333 }
334
335 pub(crate) fn zoom_step(&mut self, step: ZoomStep, anchor: Option<Pos2>) {
336 self.camera.zoom_step(step, anchor);
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 const FRAME: Duration = Duration::from_micros(16_667);
346 use blockworx_geom::pos2;
347
348 fn viewing(viewport: Rect) -> Camera {
351 Camera {
352 viewport,
353 ..Camera::resting()
354 }
355 }
356
357 #[test]
361 fn a_zoom_step_holds_the_world_under_its_anchor() {
362 let mut camera = viewing(Rect::from_min_size(
363 pos2(30.0, 20.0),
364 Vec2::new(800.0, 600.0),
365 ));
366 camera.vantage.translation = Vec2::new(17.0, -9.0);
367 let anchor = pos2(240.0, 380.0);
368 let origin = camera.viewport.min;
369 let before = camera.vantage.screen_to_world(origin, anchor);
370
371 camera.zoom_step(ZoomStep::In, Some(anchor));
372 assert!(
373 camera.vantage.zoom > Zoom::unity(),
374 "zooming in should raise the zoom"
375 );
376 let after = camera.vantage.screen_to_world(origin, anchor);
377 assert!(
378 before.distance(after) < 0.01,
379 "the world under the cursor moved: {before:?} -> {after:?}"
380 );
381
382 camera.zoom_step(ZoomStep::Out, Some(anchor));
384 assert!(
385 (camera.vantage.zoom.get() - 1.0).abs() < 1e-5,
386 "{:?}",
387 camera.vantage.zoom
388 );
389 assert_eq!(
390 camera.worked,
391 CameraWork::Worked,
392 "a step is the user's move"
393 );
394 }
395
396 #[test]
399 fn a_zoom_step_without_a_pointer_uses_the_viewport_center() {
400 let mut camera = viewing(Rect::from_min_size(pos2(0.0, 0.0), Vec2::new(800.0, 600.0)));
401 let origin = camera.viewport.min;
402 let center = camera.viewport.center();
403 let before = camera.vantage.screen_to_world(origin, center);
404 camera.zoom_step(ZoomStep::In, Some(pos2(-50.0, -50.0)));
406 assert!(before.distance(camera.vantage.screen_to_world(origin, center)) < 0.01);
407 }
408
409 #[test]
413 fn bringing_content_into_view_moves_as_little_as_it_can() {
414 let settled = |camera: &mut Camera| {
415 if let Some(target) = camera.target.take() {
416 camera.vantage = target;
417 }
418 camera.visible()
419 };
420 let mut camera = viewing(Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)));
421 let visible = camera.visible();
422 assert!(
423 visible.width() > 0.0 && visible.height() > 0.0,
424 "precondition: the view shows something to be outside of",
425 );
426
427 let inside = Rect::from_min_size(pos2(100.0, 100.0), Vec2::splat(50.0));
428 assert!(visible.contains_rect(inside), "precondition: already shown");
429 camera.bring_into_view(inside, Glide::Eased);
430 assert!(
431 camera.target.is_none(),
432 "content already in view moved the camera",
433 );
434
435 let beside = Rect::from_min_size(pos2(900.0, 100.0), Vec2::splat(50.0));
436 assert!(
437 !visible.contains_rect(beside) && beside.width() < visible.width(),
438 "precondition: outside the view, and small enough to pan to",
439 );
440 let zoom_before = camera.vantage.zoom;
441 camera.bring_into_view(beside, Glide::Eased);
442 assert_eq!(
443 camera.target.expect("the camera moves").zoom,
444 zoom_before,
445 "a pan must not touch the zoom",
446 );
447 assert!(settled(&mut camera).contains_rect(beside));
448
449 let mut camera = viewing(Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)));
450 let huge = Rect::from_min_size(pos2(400.0, 0.0), Vec2::new(2000.0, 300.0));
451 assert!(
452 huge.width() > camera.visible().width(),
453 "precondition: no pan can hold this at the current zoom",
454 );
455 camera.bring_into_view(huge, Glide::Eased);
456 assert!(
457 camera.target.expect("the camera moves").zoom < camera.vantage.zoom,
458 "content too wide for the view must be zoomed out to",
459 );
460 assert!(settled(&mut camera).contains_rect(huge));
461 }
462
463 #[test]
466 fn a_content_fit_leaves_a_border_and_caps_the_zoom() {
467 let mut camera = viewing(Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)));
468 let content = Rect::from_min_size(pos2(100.0, 100.0), Vec2::new(1600.0, 400.0));
471 camera.fit_content(content);
472 let zoom = camera.vantage.zoom.get();
473 assert!(zoom < FRAME_MAX_ZOOM, "the cap should not bind here");
474 let origin = camera.viewport.min;
475 let left = camera.vantage.world_to_screen(origin, content.min).x;
476 let right = camera.vantage.world_to_screen(origin, content.max).x;
477 let gutter = FIT_BORDER_CELLS * GRID_SIZE * zoom;
478 assert!(
479 left - camera.viewport.left() >= gutter - 0.5,
480 "content starts {left} with only {} of gutter",
481 left - camera.viewport.left()
482 );
483 assert!(camera.viewport.right() - right >= gutter - 0.5);
484
485 camera.fit_content(Rect::from_min_size(pos2(0.0, 0.0), Vec2::new(10.0, 10.0)));
487 assert_eq!(camera.vantage.zoom, Zoom::new(FRAME_MAX_ZOOM));
488 }
489
490 #[test]
491 fn focus_zoom_caps_small_content_but_fit_zooms_in_further() {
492 let viewport = Vec2::new(800.0, 600.0);
493 let tiny = Vec2::new(10.0, 10.0);
494 let focus_cap = Zoom::new(FRAME_MAX_ZOOM);
495 assert_eq!(Zoom::framing(viewport, tiny, focus_cap), focus_cap);
497 assert!(Zoom::framing(viewport, tiny, Zoom::max_value()) > focus_cap);
499 }
500
501 #[test]
502 fn frame_zoom_clamps_huge_content_to_the_floor() {
503 let viewport = Vec2::new(800.0, 600.0);
504 let huge = Vec2::new(100_000.0, 100_000.0);
505 assert_eq!(
506 Zoom::framing(viewport, huge, Zoom::max_value()),
507 Zoom::min_value()
508 );
509 }
510
511 #[test]
514 fn an_eased_framing_lands_and_a_pan_abandons_it() {
515 let mut camera = viewing(Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0)));
516 let subject = Rect::from_min_size(pos2(2000.0, 2000.0), Vec2::new(100.0, 100.0));
517 camera.focus_on(subject, Glide::Eased);
518 let target = camera.target.expect("an eased framing has a target");
519 assert!(camera.ease(FRAME), "one frame does not reach it");
520 assert_ne!(camera.vantage, target);
521 let mut frames = 1;
522 while camera.ease(FRAME) {
523 frames += 1;
524 assert!(frames < 600, "the framing never lands");
525 }
526 assert_eq!(camera.vantage, target, "the landing is exact");
527
528 camera.focus_on(
529 Rect::from_min_size(Pos2::ZERO, Vec2::splat(10.0)),
530 Glide::Eased,
531 );
532 camera.moved(Move::Pan(Vec2::new(5.0, 0.0)));
533 assert!(
534 !camera.ease(FRAME),
535 "the pan should have abandoned the framing"
536 );
537 assert_eq!(camera.worked, CameraWork::Worked);
538 }
539}