blockworx_paint/vantage.rs
1//! Where the camera stands, and the transform it puts the world under.
2
3use blockworx_geom::{Pos2, Rect, Vec2, WorldPx};
4
5use crate::{Factor, Font, Zoom};
6
7/// A move of the camera a host read off its pointer, in screen space: a drag
8/// pan, a wheel notch, a pinch. Manual control, which is why the session
9/// abandons any framing still easing when one arrives.
10#[derive(Clone, Copy, PartialEq, Debug, serde::Serialize, serde::Deserialize)]
11pub enum Move {
12 Pan(Vec2),
13 /// Scale the zoom by `factor`, leaving the world under `anchor` where it
14 /// is — the "zoom about the cursor" every zoom input wants.
15 Zoom {
16 factor: Factor,
17 anchor: Pos2,
18 },
19}
20
21/// The whole of what a `view`-kind undo entry restores.
22///
23/// The two numbers the view holds, not a world rect — a rect would have to be
24/// re-framed against a viewport that may have resized since, which would land
25/// the camera somewhere the user never was.
26///
27/// The transform is here rather than in a backend because two backends read
28/// it: the on-screen painter converts every draw through it, and a recording
29/// canvas has to place its marks where the painter would have.
30#[derive(Clone, Copy, PartialEq, Debug, serde::Serialize, serde::Deserialize)]
31pub struct Vantage {
32 pub zoom: Zoom,
33 pub translation: Vec2,
34}
35
36impl Vantage {
37 /// The camera at rest: unity zoom, no pan.
38 pub const fn resting() -> Self {
39 Self {
40 zoom: Zoom::unity(),
41 translation: Vec2::ZERO,
42 }
43 }
44
45 /// `origin` is the screen corner the viewport was laid out at.
46 pub fn world_to_screen(self, origin: Pos2, world: Pos2) -> Pos2 {
47 origin + self.translation + world.to_vec2() * self.zoom.get()
48 }
49
50 pub fn screen_to_world(self, origin: Pos2, screen: Pos2) -> Pos2 {
51 ((screen - origin - self.translation) / self.zoom.get()).to_pos2()
52 }
53
54 pub fn remap_rect(self, origin: Pos2, world: Rect) -> Rect {
55 Rect::from_min_max(
56 self.world_to_screen(origin, world.min),
57 self.world_to_screen(origin, world.max),
58 )
59 }
60
61 /// World-space length → screen-space length. The single place a world
62 /// magnitude (radius, rounding, stroke width, wrap width) loses its unit.
63 pub fn remap_len(self, len: WorldPx) -> f32 {
64 len.get() * self.zoom.get()
65 }
66
67 /// The same face at the size it is drawn on screen.
68 pub fn remap_font(self, font: &Font) -> Font {
69 Font::new(font.size * self.zoom.get(), font.family.clone())
70 }
71
72 /// The world rect `viewport` shows from here.
73 pub fn visible(self, viewport: Rect) -> Rect {
74 Rect::from_min_max(
75 self.screen_to_world(viewport.min, viewport.min),
76 self.screen_to_world(viewport.min, viewport.max),
77 )
78 }
79
80 /// The vantage that frames `content` centred in `within`, a screen region
81 /// inside the viewport whose corner is `viewport_min`. `within` is not
82 /// necessarily the whole viewport: floating chrome may leave less of it
83 /// clear, and framing against the raw rect lands the model under the
84 /// chrome. Padding is the caller's — it pads `content` before framing it.
85 pub fn framing(within: Rect, viewport_min: Pos2, content: Rect, max_zoom: Zoom) -> Self {
86 let zoom = Zoom::framing(within.size(), content.size(), max_zoom);
87 Self {
88 zoom,
89 translation: (within.center() - viewport_min) - content.center().to_vec2() * zoom.get(),
90 }
91 }
92
93 /// The same camera zoomed by `factor` about `anchor` (screen space, in a
94 /// viewport cornered at `origin`), leaving the world under `anchor` where
95 /// it was. Lands inside [`Zoom::WORKED_MIN`]`..=`[`Zoom::WORKED_MAX`] — a
96 /// user working the camera stays in the band, where a framing may use the
97 /// whole range.
98 #[must_use]
99 pub fn zoomed_about(self, origin: Pos2, anchor: Pos2, factor: Factor) -> Self {
100 let zoom =
101 Zoom::new(self.zoom.get() * factor.get()).clamp(Zoom::WORKED_MIN, Zoom::WORKED_MAX);
102 let from_corner = anchor - origin;
103 let world_offset = from_corner - self.translation;
104 Self {
105 zoom,
106 translation: from_corner - world_offset * (zoom.get() / self.zoom.get()),
107 }
108 }
109}