Skip to main content

blockworx_paint/
ground.rs

1//! What lies under the diagram: the canvas fill and the grid over it.
2//!
3//! One rule, read by every backend. The grid is not in the display list — it
4//! is drawn from the camera, so it stays sharp at any zoom and costs nothing
5//! to record — which would otherwise leave each backend to decide for itself
6//! where a line falls and how heavy it is.
7
8use blockworx_geom::{Pos2, Rangef, Rect, grid::GRID_SIZE, pos2};
9
10use crate::{Color, Vantage, Zoom};
11
12/// Screen-space spacing below which the minor grid lines are dropped, as they
13/// read as a wash of colour rather than a grid.
14const MIN_MINOR_SPACING: f32 = 5.0;
15
16/// Every fourth grid line is a major one.
17const MAJOR_STEP: i32 = 4;
18
19/// The two raw colours a backend paints the ground with. The app resolves
20/// these from its theme, so a backend never needs to know about roles.
21#[derive(Clone, Copy, PartialEq, Eq, Debug)]
22pub struct Ground {
23    pub background: Color,
24    pub grid: Color,
25}
26
27/// The two weights the grid is drawn in.
28#[derive(Clone, Copy, PartialEq, Eq, Debug)]
29pub enum GridLine {
30    Major,
31    Minor,
32}
33
34impl GridLine {
35    fn at(index: i32) -> Self {
36        if index % MAJOR_STEP == 0 {
37            Self::Major
38        } else {
39            Self::Minor
40        }
41    }
42
43    /// The stroke width this weight is drawn at, in screen pixels.
44    #[must_use]
45    pub fn width(self) -> f32 {
46        match self {
47            Self::Major => 1.0,
48            Self::Minor => 0.5,
49        }
50    }
51}
52
53/// Screen x of every grid line crossing `viewport` vertically, each with its
54/// weight.
55pub fn verticals(vantage: Vantage, viewport: Rect) -> impl Iterator<Item = (f32, GridLine)> {
56    let world = vantage.visible(viewport);
57    along(vantage.zoom, world.x_range())
58        .map(move |(x, line)| (screen(vantage, viewport.min, pos2(x, 0.0)).x, line))
59}
60
61/// Screen y of every grid line crossing `viewport` horizontally, each with its
62/// weight.
63pub fn horizontals(vantage: Vantage, viewport: Rect) -> impl Iterator<Item = (f32, GridLine)> {
64    let world = vantage.visible(viewport);
65    along(vantage.zoom, world.y_range())
66        .map(move |(y, line)| (screen(vantage, viewport.min, pos2(0.0, y)).y, line))
67}
68
69fn screen(vantage: Vantage, origin: Pos2, world: Pos2) -> Pos2 {
70    vantage.world_to_screen(origin, world)
71}
72
73/// World coordinates of the grid lines crossing `span` on one axis, each with
74/// its weight. Minor lines are skipped once they crowd together on screen.
75fn along(zoom: Zoom, span: Rangef) -> impl Iterator<Item = (f32, GridLine)> {
76    let draw_minor = GRID_SIZE * zoom.get() >= MIN_MINOR_SPACING;
77    ((span.min / GRID_SIZE).floor() as i32..=(span.max / GRID_SIZE).ceil() as i32)
78        .map(|i| (i as f32 * GRID_SIZE, GridLine::at(i)))
79        .filter(move |&(_, line)| line == GridLine::Major || draw_minor)
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use blockworx_geom::{Vec2, vec2};
86
87    fn viewport() -> Rect {
88        Rect::from_min_size(pos2(0.0, 0.0), Vec2::new(800.0, 600.0))
89    }
90
91    #[test]
92    fn a_resting_camera_rules_the_viewport_every_grid_step() {
93        let lines: Vec<_> = verticals(Vantage::resting(), viewport()).collect();
94        let step = GRID_SIZE;
95        assert!(
96            lines.len() > 2,
97            "precondition: the viewport spans several steps"
98        );
99        for pair in lines.windows(2) {
100            assert!(
101                (pair[1].0 - pair[0].0 - step).abs() < 1e-3,
102                "lines stand one grid step apart: {pair:?}"
103            );
104        }
105        assert!(
106            lines.iter().any(|&(x, _)| (x - 0.0).abs() < 1e-3),
107            "the origin is ruled: {lines:?}"
108        );
109    }
110
111    #[test]
112    fn every_fourth_line_is_a_major_one() {
113        let lines: Vec<_> = horizontals(Vantage::resting(), viewport()).collect();
114        let major_at = |y: f32| (y / GRID_SIZE).round() as i32 % MAJOR_STEP == 0;
115        assert!(
116            lines.iter().any(|&(_, line)| line == GridLine::Minor),
117            "precondition: the minor lines are drawn at unity zoom"
118        );
119        for &(y, line) in &lines {
120            assert_eq!(
121                line == GridLine::Major,
122                major_at(y),
123                "the weight at {y} does not follow the step"
124            );
125        }
126        assert!(GridLine::Major.width() > GridLine::Minor.width());
127    }
128
129    #[test]
130    fn the_minor_lines_drop_out_once_they_crowd() {
131        let zoomed_out = Vantage {
132            zoom: Zoom::new(MIN_MINOR_SPACING / GRID_SIZE * 0.9),
133            translation: Vec2::ZERO,
134        };
135        assert!(
136            GRID_SIZE * zoomed_out.zoom.get() < MIN_MINOR_SPACING,
137            "precondition: the steps are closer than the floor"
138        );
139        assert!(
140            verticals(zoomed_out, viewport()).all(|(_, line)| line == GridLine::Major),
141            "a crowded grid keeps only its major lines"
142        );
143        assert!(
144            verticals(zoomed_out, viewport()).count() > 1,
145            "and still rules the viewport"
146        );
147    }
148
149    /// The rule is stated in screen space, so panning slides it and zooming
150    /// spreads it — a backend draws the numbers it is given and nothing else.
151    #[test]
152    fn the_lines_follow_the_camera() {
153        let by = 7.0;
154        let panned = Vantage {
155            zoom: Zoom::unity(),
156            translation: vec2(by, 0.0),
157        };
158        let origin_line = |vantage| {
159            verticals(vantage, viewport())
160                .map(|(x, _)| x)
161                .find(|x| (x - by).abs() < 1e-3)
162        };
163        assert!(
164            origin_line(Vantage::resting()).is_none(),
165            "precondition: the resting camera rules nothing at {by}"
166        );
167        assert!(
168            origin_line(panned).is_some(),
169            "a pan of {by} px puts the world origin's line there"
170        );
171
172        let zoomed = Vantage {
173            zoom: Zoom::new(2.0),
174            translation: Vec2::ZERO,
175        };
176        let spacing = |vantage| {
177            let lines: Vec<f32> = verticals(vantage, viewport()).map(|(x, _)| x).collect();
178            lines[1] - lines[0]
179        };
180        assert!(
181            (spacing(zoomed) - 2.0 * spacing(Vantage::resting())).abs() < 1e-3,
182            "doubling the zoom doubles the spacing"
183        );
184    }
185}