Skip to main content

dotloom_render/
overlay.rs

1//! Grid and interaction overlays (built every frame; cheap and view-dependent).
2
3use dotloom_geometry::{FlattenTolerance, Point};
4use dotloom_scene::{MarkerKind, Overlay};
5use serde::{Deserialize, Serialize};
6
7use crate::color::{Theme, premul_bytes, with_alpha};
8use crate::tess::{Mesh, dash4, effective_tolerance};
9use crate::view::View;
10
11/// Grid settings (model units).
12#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
13#[serde(rename_all = "camelCase", default)]
14pub struct Grid {
15    /// Draw the grid.
16    pub visible: bool,
17    /// Minor spacing in model units.
18    pub spacing: f64,
19    /// Major line every N minor lines.
20    pub major_every: u32,
21    /// Smallest on-screen spacing in CSS pixels; finer grids step up by
22    /// `major_every`.
23    pub min_px: f64,
24}
25
26impl Default for Grid {
27    fn default() -> Self {
28        Self { visible: true, spacing: 10.0, major_every: 10, min_px: 8.0 }
29    }
30}
31
32/// Marker size in CSS pixels.
33pub const MARKER_PX: f64 = 10.0;
34
35/// Effective grid spacing (model units) for a view: steps up until lines are at
36/// least `min_px` apart. Returns `None` if the grid cannot be drawn.
37#[must_use]
38pub fn effective_spacing(grid: &Grid, view: &View) -> Option<f64> {
39    if !(grid.spacing.is_finite() && grid.spacing > 0.0) {
40        return None;
41    }
42    let step = f64::from(grid.major_every.max(2));
43    let mut s = grid.spacing;
44    for _ in 0..64 {
45        if s * view.scale >= grid.min_px.max(2.0) {
46            return Some(s);
47        }
48        s *= step;
49    }
50    None
51}
52
53/// Grid lines in device pixels (origin `[0, 0]`, identity transform).
54pub(crate) fn grid_mesh(grid: &Grid, view: &View, size: (u32, u32), theme: &Theme) -> Mesh {
55    let mut m = Mesh::at([0.0, 0.0]);
56    if !grid.visible {
57        return m;
58    }
59    let Some(sp) = effective_spacing(grid, view) else { return m };
60    let major = i64::from(grid.major_every.max(2));
61    let (w, h) = (f64::from(size.0), f64::from(size.1));
62    let s = view.device_scale();
63    let to_x = |x: f64| ((x - view.center[0]) * s + w * 0.5).floor() + 0.5;
64    let to_y = |y: f64| (h * 0.5 - (y - view.center[1]) * s).floor() + 0.5;
65    let half_w = w * 0.5 / s;
66    let half_h = h * 0.5 / s;
67    let (x0, x1) = ((view.center[0] - half_w) / sp, (view.center[0] + half_w) / sp);
68    let (y0, y1) = ((view.center[1] - half_h) / sp, (view.center[1] + half_h) / sp);
69    // Defensive bound: at most a few thousand lines.
70    if !(x0.is_finite() && x1.is_finite() && y0.is_finite() && y1.is_finite())
71        || (x1 - x0) > 4096.0
72        || (y1 - y0) > 4096.0
73    {
74        return m;
75    }
76    // Whether the coarser level is the true major grid (k * sp is a multiple of
77    // major spacing) — use exact integer math on the level index.
78    let base_ratio = (sp / grid.spacing).round();
79    let level_major = |k: i64| -> bool {
80        let n = (k as f64 * base_ratio).round() as i64;
81        n % major == 0
82    };
83    let mut lines: Vec<(f64, bool, u32)> = Vec::new();
84    for k in (x0.ceil() as i64)..=(x1.floor() as i64) {
85        let color = if k == 0 {
86            theme.grid_axis
87        } else if level_major(k) {
88            theme.grid_major
89        } else {
90            theme.grid_minor
91        };
92        lines.push((to_x(k as f64 * sp), true, color));
93    }
94    for k in (y0.ceil() as i64)..=(y1.floor() as i64) {
95        let color = if k == 0 {
96            theme.grid_axis
97        } else if level_major(k) {
98            theme.grid_major
99        } else {
100            theme.grid_minor
101        };
102        lines.push((to_y(k as f64 * sp), false, color));
103    }
104    // Minor lines first so majors and axes draw on top.
105    lines.sort_by_key(|(_, _, c)| u8::from(*c == theme.grid_major) + 2 * u8::from(*c == theme.grid_axis));
106    for (v, vertical, color) in lines {
107        let (a, b) =
108            if vertical { (Point::new(v, 0.0), Point::new(v, h)) } else { (Point::new(0.0, v), Point::new(w, v)) };
109        m.polyline(&[a, b], false, premul_bytes(color), 1.0, [0.0; 4]);
110    }
111    m
112}
113
114fn marker(m: &mut Mesh, kind: MarkerKind, c: [f64; 2], dpr: f64, theme: &Theme) {
115    let r = MARKER_PX * 0.5 * dpr;
116    let p = |dx: f64, dy: f64| Point::new(c[0] + dx * r, c[1] + dy * r);
117    let color = premul_bytes(theme.marker);
118    let w = 1.5;
119    match kind {
120        MarkerKind::Handle => {
121            let k = 0.7;
122            let pts = [p(-k, -k), p(k, -k), p(k, k), p(-k, k)];
123            m.fill_rings(&[pts.to_vec()], premul_bytes(theme.background));
124            m.polyline(&pts, true, premul_bytes(theme.selection), 1.5, [0.0; 4]);
125        }
126        MarkerKind::Endpoint => {
127            m.polyline(&[p(-1.0, -1.0), p(1.0, -1.0), p(1.0, 1.0), p(-1.0, 1.0)], true, color, w, [0.0; 4])
128        }
129        MarkerKind::Midpoint => m.polyline(&[p(0.0, -1.1), p(1.0, 0.8), p(-1.0, 0.8)], true, color, w, [0.0; 4]),
130        MarkerKind::Center => {
131            let pts: Vec<Point> = (0..24)
132                .map(|i| {
133                    let a = f64::from(i) / 24.0 * core::f64::consts::TAU;
134                    p(dotloom_geometry::math::cos(a), dotloom_geometry::math::sin(a))
135                })
136                .collect();
137            m.polyline(&pts, true, color, w, [0.0; 4]);
138        }
139        MarkerKind::Intersection => {
140            m.polyline(&[p(-1.0, -1.0), p(1.0, 1.0)], false, color, w + 0.5, [0.0; 4]);
141            m.polyline(&[p(-1.0, 1.0), p(1.0, -1.0)], false, color, w + 0.5, [0.0; 4]);
142        }
143        MarkerKind::Nearest => {
144            m.polyline(&[p(-1.0, -1.0), p(1.0, -1.0), p(-1.0, 1.0), p(1.0, 1.0)], true, color, w, [0.0; 4]);
145        }
146        MarkerKind::Grid => {
147            m.polyline(&[p(-1.0, 0.0), p(1.0, 0.0)], false, color, w, [0.0; 4]);
148            m.polyline(&[p(0.0, -1.0), p(0.0, 1.0)], false, color, w, [0.0; 4]);
149        }
150        MarkerKind::Anchor => {
151            m.polyline(&[p(0.0, -1.2), p(1.2, 0.0), p(0.0, 1.2), p(-1.2, 0.0)], true, color, w, [0.0; 4])
152        }
153    }
154}
155
156/// Screen-space overlay (device pixels): markers and the selection rectangle.
157pub(crate) fn screen_overlay(o: &Overlay, view: &View, size: (u32, u32), theme: &Theme) -> Mesh {
158    let mut m = Mesh::at([0.0, 0.0]);
159    let (w, h) = (f64::from(size.0), f64::from(size.1));
160    let s = view.device_scale();
161    let to_dev = |p: Point| [(p.x - view.center[0]) * s + w * 0.5, h * 0.5 - (p.y - view.center[1]) * s];
162    if let Some(r) = o.marquee
163        && !r.is_empty()
164        && r.min.is_finite()
165        && r.max.is_finite()
166    {
167        let a = to_dev(r.min);
168        let b = to_dev(r.max);
169        let pts = [Point::new(a[0], a[1]), Point::new(b[0], a[1]), Point::new(b[0], b[1]), Point::new(a[0], b[1])];
170        let base = if o.crossing { theme.marquee_crossing } else { theme.marquee_window };
171        m.fill_rings(&[pts.to_vec()], premul_bytes(with_alpha(base, 0.1)));
172        let dash = if o.crossing { dash4(&[5.0, 4.0]) } else { [0.0; 4] };
173        m.polyline(&pts, true, premul_bytes(base), 1.0, dash);
174    }
175    for mk in &o.markers {
176        if mk.at.is_finite() {
177            marker(&mut m, mk.kind, to_dev(mk.at), view.dpr, theme);
178        }
179    }
180    m
181}
182
183/// World-space overlay (construction guides and in-progress sketch shapes).
184pub(crate) fn world_overlay(o: &Overlay, view: &View, lod_tol: f64, theme: &Theme) -> Mesh {
185    let mut m = Mesh::at(view.center);
186    let guide = premul_bytes(theme.guide);
187    for g in &o.guides {
188        m.polyline(&[g.a, g.b], false, guide, 1.0, dash4(&[6.0, 4.0]));
189    }
190    let sketch = premul_bytes(theme.sketch);
191    for s in &o.sketch {
192        let tol = effective_tolerance(lod_tol, s.bbox());
193        for fp in s.flatten(FlattenTolerance(tol)) {
194            m.polyline(&fp.points, fp.closed, sketch, 1.5, dash4(&[4.0, 3.0]));
195        }
196    }
197    m
198}
199
200#[cfg(test)]
201mod tests {
202    use dotloom_geometry::{Aabb, Segment, Shape};
203    use dotloom_scene::Marker;
204
205    use super::*;
206
207    fn view(scale: f64) -> View {
208        View { center: [0.0, 0.0], scale, width: 400.0, height: 300.0, dpr: 2.0 }
209    }
210
211    #[test]
212    fn grid_steps_up_when_dense() {
213        let g = Grid::default();
214        assert_eq!(effective_spacing(&g, &view(1.0)), Some(10.0));
215        assert_eq!(effective_spacing(&g, &view(0.5)), Some(100.0));
216        assert_eq!(effective_spacing(&g, &view(0.01)), Some(1000.0));
217        let m = grid_mesh(&g, &view(1.0), (800, 600), &Theme::light());
218        // 400 css px / 10 = 40 vertical lines (+1), 30 horizontal (+1).
219        assert!((70..=74).contains(&m.lines.len()), "{}", m.lines.len());
220        // Lines are pixel-centered.
221        assert!(m.lines.iter().all(|l| (l.p0[0].fract() - 0.5).abs() < 1e-6 || (l.p0[1].fract() - 0.5).abs() < 1e-6));
222        let hidden = Grid { visible: false, ..g };
223        assert!(grid_mesh(&hidden, &view(1.0), (800, 600), &Theme::light()).lines.is_empty());
224        let bad = Grid { spacing: f64::NAN, ..g };
225        assert!(grid_mesh(&bad, &view(1.0), (800, 600), &Theme::light()).lines.is_empty());
226    }
227
228    #[test]
229    fn markers_have_constant_screen_size() {
230        let o = Overlay {
231            markers: vec![Marker { at: Point::new(10.0, 10.0), kind: MarkerKind::Endpoint }],
232            ..Overlay::default()
233        };
234        let a = screen_overlay(&o, &view(1.0), (800, 600), &Theme::light());
235        let b = screen_overlay(&o, &view(100.0), (800, 600), &Theme::light());
236        let span = |m: &Mesh| m.lines[0].p1[0] - m.lines[0].p0[0];
237        assert_eq!(span(&a), span(&b));
238        assert!((f64::from(span(&a)) - MARKER_PX * 2.0).abs() < 1e-3, "dpr 2 → 20 device px");
239    }
240
241    #[test]
242    fn marquee_and_world_overlay() {
243        let o = Overlay {
244            marquee: Some(Aabb::from_corners(Point::new(-10.0, -10.0), Point::new(10.0, 10.0))),
245            crossing: true,
246            guides: vec![Segment::new(Point::new(0.0, 0.0), Point::new(100.0, 0.0))],
247            sketch: vec![Shape::Line(Segment::new(Point::new(0.0, 0.0), Point::new(0.0, 50.0)))],
248            ..Overlay::default()
249        };
250        let s = screen_overlay(&o, &view(1.0), (800, 600), &Theme::light());
251        assert_eq!(s.lines.len(), 4);
252        assert!(s.lines[0].dash[0] > 0.0);
253        assert_eq!(s.fill_indices.len(), 6);
254        let w = world_overlay(&o, &view(1.0), 0.1, &Theme::light());
255        assert_eq!(w.lines.len(), 2);
256    }
257}