Skip to main content

blockworx_kernel/
camera.rs

1//! The camera the session owns: where the diagram is shown from, how a
2//! framing glides into place, and what the host's own moves — a pan, a
3//! wheel notch, a pinch — do to it.
4//!
5//! The host reads its pointer and reports a [`Move`]; the session applies it,
6//! eases a framing a frame at a time on its own clock, and answers the
7//! vantage back through the view. Nothing here knows what the viewport is
8//! drawn with.
9
10use 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/// Whether the view still owes the drawing a fit-to-content framing.
18#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
19pub enum Refit {
20    #[default]
21    Idle,
22    Owed,
23}
24
25/// Whether the user worked the camera themselves this frame — a wheel, a
26/// pinch, a drag pan, a zoom step. The undo stack names an entry by the move
27/// that made it, and a fit is not a pan.
28#[derive(Clone, Copy, PartialEq, Eq, Debug)]
29pub enum CameraWork {
30    Worked,
31    Idle,
32}
33
34/// Whether a framing eases into place or jumps there.
35#[derive(Clone, Copy, PartialEq, Eq, Debug)]
36pub enum Glide {
37    Eased,
38    /// A history step snaps: the undo stack pins the camera the step ended
39    /// at, and one still easing toward its target would leave the pin and the
40    /// view disagreeing for every frame of the ease.
41    Snap,
42}
43
44/// Largest zoom a *framing* will apply — the navigator focusing one block, or a
45/// double-click fitting the level's contents. Small content would otherwise
46/// balloon to fill the view, which reads as a jump rather than a fit. Explicit
47/// camera rects are not capped: a caller asking for a ten-cell window means
48/// it.
49const FRAME_MAX_ZOOM: f32 = 2.0;
50
51/// The gutter a double-click fit leaves around the content, in grid cells, so
52/// the outermost blocks don't sit against the viewport edge.
53const FIT_BORDER_CELLS: f32 = 4.0;
54
55/// The slack the camera framings leave around their rect, as a fraction of the
56/// content's size per side — a quarter of the content overall.
57const CAMERA_SLACK: f32 = 0.125;
58
59/// Exponential approach rate for an animated view change; higher is snappier.
60const VIEW_ANIMATION_RATE: f32 = 16.0;
61
62/// The longest a frame counts for when a framing eases: a frame that stalled
63/// moves the camera as far as a tenth of a second would, not as far as the
64/// stall.
65const LONGEST_EASED_FRAME: Duration = Duration::from_millis(100);
66
67/// How far a span reaching from `lo` to `hi` must move along one axis to sit
68/// inside `visible_lo..visible_hi`. Zero when it already does; assumes it
69/// fits, so it never has to choose which end to leave outside.
70fn 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/// The session's camera: where it stands, where it is easing to, the screen
81/// it is shown on and the part of that the chrome leaves clear.
82#[derive(Clone, Copy, Debug)]
83pub(crate) struct Camera {
84    pub(crate) vantage: Vantage,
85    /// The screen rect the canvas is laid out into.
86    pub(crate) viewport: Rect,
87    /// The part of the viewport the floating chrome leaves clear, or
88    /// [`Rect::NOTHING`] where nothing has measured any.
89    pub(crate) safe: Rect,
90    /// Where a framing is easing to, while one is.
91    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    /// Where a framing centres and sizes itself: the region the chrome left,
107    /// or the whole viewport where no chrome has reported any.
108    fn framing_rect(&self) -> Rect {
109        if self.safe.is_positive() {
110            self.safe
111        } else {
112            self.viewport
113        }
114    }
115
116    /// The world rect the viewport currently shows.
117    pub(crate) fn visible(&self) -> Rect {
118        self.vantage.visible(self.viewport)
119    }
120
121    /// Stand exactly here. Any framing still easing is abandoned: what is
122    /// asked for *is* the framing now, and letting the old one finish would
123    /// walk the camera off it.
124    pub(crate) fn stand_at(&mut self, vantage: Vantage) {
125        self.vantage = vantage;
126        self.target = None;
127    }
128
129    /// Frame `content` to fill the view, snapping immediately. Used for an
130    /// authored camera rect and for layer changes: the block path changed
131    /// underneath, so animating across unrelated content is jarring rather
132    /// than helpful.
133    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    /// Fit the level's whole contents in view — the double-click gesture. Unlike
142    /// an authored camera rect this leaves a fixed `FIT_BORDER_CELLS` gutter
143    /// (so nothing sits against the edge) and is capped at `FRAME_MAX_ZOOM`
144    /// (so a nearly empty level doesn't leap to a huge magnification).
145    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    /// Frame a single subject — a block picked from the navigator, the region
154    /// an undo just changed — capping the zoom so a small one doesn't blow up
155    /// to fill the whole view.
156    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    /// Bring `content` into view with the gentlest move that reaches it:
165    /// nothing at all when it is already visible, otherwise a pan — the zoom
166    /// is only touched for content that cannot fit at this one. What a paste
167    /// lands is content the viewer has never seen, so the alternative to this
168    /// is content that arrives off-screen and reads as having not arrived.
169    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        // Not the viewport itself: the chrome floats over it, and framing
200        // against the raw rect lands the model under the tool cluster.
201        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    /// Advance a framing in progress by one frame that took `dt`, easing
213    /// toward the target with a frame-rate-independent exponential approach.
214    /// Answers whether it is still under way — a host that draws on demand
215    /// asks for another frame while it is.
216    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    /// What the host's pointer did to the camera. Manual control, so it
239    /// cancels a framing in progress and names the undo entry.
240    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    /// One keyboard zoom step, about `anchor` (the pointer) or the viewport
258    /// center when the pointer is elsewhere.
259    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    /// The screen rect the canvas is laid out in — told whenever it changes.
280    pub fn set_viewport(&mut self, viewport: Rect) {
281        self.camera.viewport = viewport;
282    }
283
284    /// The part of the viewport the floating chrome leaves clear, measured by
285    /// whoever draws the chrome. Every framing centres in this rather than in
286    /// the raw viewport, so nothing lands under a pill.
287    pub fn set_safe_region(&mut self, safe: Rect) {
288        self.camera.safe = safe;
289    }
290
291    /// The world rect the viewport currently shows.
292    pub fn visible_world(&self) -> Rect {
293        self.camera.visible()
294    }
295
296    /// A screen position under the camera — where a tool carried off the
297    /// cluster was dropped.
298    pub fn screen_to_world(&self, screen: Pos2) -> Pos2 {
299        self.vantage()
300            .screen_to_world(self.camera.viewport.min, screen)
301    }
302
303    /// What the host's pointer did to the camera this frame, applied in the
304    /// order it happened.
305    pub fn moves(&mut self, moves: &[Move]) {
306        for &moved in moves {
307            self.camera.moved(moved);
308        }
309    }
310
311    /// Put the camera exactly here — what a history step restores.
312    pub fn stand_at(&mut self, vantage: Vantage) {
313        self.camera.stand_at(vantage);
314    }
315
316    /// Frame this world rect, snapping — an authored camera rect.
317    pub fn fit_to_rect(&mut self, content: Rect) {
318        self.camera.fit_to_rect(content);
319    }
320
321    /// Frame the level's contents, the way the owed fit is taken once the
322    /// diagram has been measured.
323    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    /// One 60 Hz frame.
345    const FRAME: Duration = Duration::from_micros(16_667);
346    use blockworx_geom::pos2;
347
348    /// A camera whose viewport has been laid out — what every framing and
349    /// zoom below is measured against.
350    fn viewing(viewport: Rect) -> Camera {
351        Camera {
352            viewport,
353            ..Camera::resting()
354        }
355    }
356
357    /// Zooming about a point leaves the world under it where it was — that is
358    /// what "zoom about the cursor" means, and what the wheel, pinch, and the
359    /// keyboard steps all share.
360    #[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        // Out again returns to where it started (the step factors are inverses).
383        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    /// With the pointer off the canvas the step falls back to the viewport
397    /// center, so a keyboard zoom always has an anchor.
398    #[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        // A pointer outside the canvas is ignored, like no pointer at all.
405        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    /// The three answers [`Camera::bring_into_view`] gives, in the order it
410    /// prefers them: nothing at all, a pan, and — only for content that
411    /// cannot fit at this zoom — a reframing.
412    #[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    /// A double-click fit keeps a [`FIT_BORDER_CELLS`] gutter around the
464    /// content and never magnifies past [`FRAME_MAX_ZOOM`].
465    #[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        // Wide content: the fit is bound by the content, not the cap, so the
469        // gutter is what decides where its edges land.
470        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        // A nearly empty level would fit at a huge zoom; the cap holds it.
486        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        // A tiny block would fit at a huge zoom; focus_on caps it.
496        assert_eq!(Zoom::framing(viewport, tiny, focus_cap), focus_cap);
497        // fit_to_rect's higher cap lets the same block zoom in further.
498        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    /// An eased framing glides toward its target one frame at a time and
512    /// lands on it exactly; a pan mid-flight abandons it.
513    #[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}