Skip to main content

pixelcoords_core/
draw.rs

1//! CPU rasterizer: shapes, text, and label placement into a `u32` pixel
2//! buffer (`0x00RRGGBB`, softbuffer's format), plus the alpha mask applied
3//! to circle crops. Everything clips to the buffer — drawing partially or
4//! fully off-buffer is safe and silent.
5
6use crate::font;
7use crate::geometry::{Point, Rect, Shape, Size};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub struct Color {
11    pub r: u8,
12    pub g: u8,
13    pub b: u8,
14}
15
16impl Color {
17    pub const WHITE: Self = Self {
18        r: 255,
19        g: 255,
20        b: 255,
21    };
22
23    pub const fn to_0rgb(self) -> u32 {
24        ((self.r as u32) << 16) | ((self.g as u32) << 8) | (self.b as u32)
25    }
26}
27
28pub struct Canvas<'a> {
29    px: &'a mut [u32],
30    pub w: i32,
31    pub h: i32,
32}
33
34impl<'a> Canvas<'a> {
35    /// Wrap a pixel buffer. `px.len()` must equal `w * h`.
36    pub fn new(px: &'a mut [u32], w: i32, h: i32) -> Self {
37        assert!(w > 0 && h > 0, "canvas dimensions must be positive");
38        assert_eq!(
39            px.len(),
40            (w as usize) * (h as usize),
41            "buffer size mismatch"
42        );
43        Self { px, w, h }
44    }
45
46    fn set(&mut self, x: i32, y: i32, color: u32) {
47        if x >= 0 && y >= 0 && x < self.w && y < self.h {
48            self.px[(y as usize) * (self.w as usize) + (x as usize)] = color;
49        }
50    }
51
52    pub fn fill_rect(&mut self, rect: Rect, color: Color) {
53        let c = color.to_0rgb();
54        let x0 = rect.x.max(0);
55        let y0 = rect.y.max(0);
56        let x1 = (rect.x + rect.w).min(self.w);
57        let y1 = (rect.y + rect.h).min(self.h);
58        if x1 <= x0 || y1 <= y0 {
59            return;
60        }
61        for y in y0..y1 {
62            let row = (y as usize) * (self.w as usize);
63            self.px[row + x0 as usize..row + x1 as usize].fill(c);
64        }
65    }
66
67    /// Darken already-drawn pixels inside `rect` to `strength`/256 of
68    /// their brightness — a backdrop panel without an alpha channel. 0
69    /// blacks out, 256 leaves the pixels untouched.
70    pub fn dim_rect(&mut self, rect: Rect, strength: u32) {
71        let x0 = rect.x.max(0);
72        let y0 = rect.y.max(0);
73        let x1 = (rect.x + rect.w).min(self.w);
74        let y1 = (rect.y + rect.h).min(self.h);
75        for y in y0..y1 {
76            let row = (y as usize) * (self.w as usize);
77            for px in &mut self.px[row + x0 as usize..row + x1.max(x0) as usize] {
78                let r = (((*px >> 16) & 0xFF) * strength) >> 8;
79                let g = (((*px >> 8) & 0xFF) * strength) >> 8;
80                let b = ((*px & 0xFF) * strength) >> 8;
81                *px = (r << 16) | (g << 8) | b;
82            }
83        }
84    }
85
86    pub fn draw_rect_outline(&mut self, rect: Rect, color: Color, thickness: i32) {
87        if thickness <= 0 {
88            return;
89        }
90        let t = thickness.min(rect.w).min(rect.h);
91        self.fill_rect(Rect::new(rect.x, rect.y, rect.w, t), color);
92        self.fill_rect(Rect::new(rect.x, rect.y + rect.h - t, rect.w, t), color);
93        self.fill_rect(Rect::new(rect.x, rect.y, t, rect.h), color);
94        self.fill_rect(Rect::new(rect.x + rect.w - t, rect.y, t, rect.h), color);
95    }
96
97    pub fn fill_circle(&mut self, cx: i32, cy: i32, r: i32, color: Color) {
98        self.circle_band(cx, cy, 0, r, color);
99    }
100
101    pub fn draw_circle_outline(&mut self, cx: i32, cy: i32, r: i32, color: Color, thickness: i32) {
102        if thickness <= 0 {
103            return;
104        }
105        self.circle_band(cx, cy, (r - thickness).max(0), r, color);
106    }
107
108    /// Fill pixels whose distance from the center lies in (`inner`, `outer`]
109    /// — `inner = 0` fills the disc.
110    fn circle_band(&mut self, cx: i32, cy: i32, inner: i32, outer: i32, color: Color) {
111        if outer <= 0 {
112            return;
113        }
114        let c = color.to_0rgb();
115        let inner2 = i64::from(inner) * i64::from(inner);
116        let outer2 = i64::from(outer) * i64::from(outer);
117        for y in (cy - outer).max(0)..=(cy + outer).min(self.h - 1) {
118            for x in (cx - outer).max(0)..=(cx + outer).min(self.w - 1) {
119                let dx = i64::from(x - cx);
120                let dy = i64::from(y - cy);
121                let d2 = dx * dx + dy * dy;
122                if d2 <= outer2 && (inner == 0 || d2 > inner2) {
123                    self.set(x, y, c);
124                }
125            }
126        }
127    }
128
129    /// Draw `text` with the embedded font, top-left of the line box at
130    /// (`x`, `y`). `scale` multiplies the base text size — pass the
131    /// monitor's DPI scale so text is the same visual size on any display.
132    pub fn draw_text(&mut self, x: i32, y: i32, text: &str, color: Color, scale: i32) {
133        let advance = font::advance(scale);
134        let baseline = y + font::ascent(scale);
135        let mut pen_x = x;
136        for ch in text.chars() {
137            self.blend_glyph(pen_x, baseline, ch, color, scale);
138            pen_x += advance;
139        }
140    }
141
142    /// One antialiased glyph, pen at (`pen_x`, `baseline`).
143    fn blend_glyph(&mut self, pen_x: i32, baseline: i32, ch: char, color: Color, scale: i32) {
144        let (metrics, coverage) = font::rasterize(ch, scale);
145        let x0 = pen_x + metrics.xmin;
146        let y0 = baseline - metrics.height as i32 - metrics.ymin;
147        for row in 0..metrics.height {
148            for col in 0..metrics.width {
149                let alpha = coverage[row * metrics.width + col];
150                if alpha != 0 {
151                    self.blend(x0 + col as i32, y0 + row as i32, color, alpha);
152                }
153            }
154        }
155    }
156
157    /// Source-over blend of `color` at coverage `alpha` onto one pixel.
158    fn blend(&mut self, col: i32, row: i32, color: Color, alpha: u8) {
159        if col < 0 || row < 0 || col >= self.w || row >= self.h {
160            return;
161        }
162        let index = (row as usize) * (self.w as usize) + (col as usize);
163        let dst = self.px[index];
164        let cover = u32::from(alpha);
165        let keep = 255 - cover;
166        let red = (u32::from(color.r) * cover + ((dst >> 16) & 0xFF) * keep) / 255;
167        let green = (u32::from(color.g) * cover + ((dst >> 8) & 0xFF) * keep) / 255;
168        let blue = (u32::from(color.b) * cover + (dst & 0xFF) * keep) / 255;
169        self.px[index] = (red << 16) | (green << 8) | blue;
170    }
171
172    /// Whether offset (`dx`, `dy`) lies inside the origin-centered
173    /// ellipse with radii (`rx`, `ry`); non-positive radii cover nothing.
174    fn draw_ellipse_rotated(&mut self, shape: &Shape, deg: i32, color: Color, band: Option<i32>) {
175        let Shape::Ellipse { cx, cy, rx, ry } = *shape else {
176            return;
177        };
178        let (thickness, fill) = band.map_or((0, true), |t| (t, false));
179        let bb = shape.rotated_bbox(deg);
180        let c = color.to_0rgb();
181        let (irx, iry) = if fill {
182            (-1, -1)
183        } else {
184            ((rx - thickness).max(0), (ry - thickness).max(0))
185        };
186        for y in bb.y.max(0)..=bb.y.saturating_add(bb.h).min(self.h - 1) {
187            for x in bb.x.max(0)..=bb.x.saturating_add(bb.w).min(self.w - 1) {
188                let local =
189                    crate::geometry::rotate_point_about(Point::new(x, y), Point::new(cx, cy), -deg);
190                let (dx, dy) = (local.x - cx, local.y - cy);
191                if ellipse_covers(dx, dy, rx, ry) && !ellipse_covers(dx, dy, irx, iry) {
192                    self.set(x, y, c);
193                }
194            }
195        }
196    }
197
198    /// Draw a shape rotated `deg` about its bbox center. Unrotated shapes
199    /// and circles take the fast paths; triangles rotate their vertices and
200    /// reuse the triangle raster; rotated rects raster by inverse-rotating
201    /// each candidate pixel into the rect's local space.
202    pub fn draw_shape_rotated(
203        &mut self,
204        shape: &Shape,
205        deg: i32,
206        color: Color,
207        thickness: i32,
208        fill: bool,
209    ) {
210        let deg = crate::geometry::normalize_deg(deg);
211        if deg == 0 || matches!(shape, Shape::Circle { .. }) {
212            return self.draw_shape(shape, color, thickness, fill);
213        }
214        if matches!(shape, Shape::Triangle { .. } | Shape::Poly { .. }) {
215            return self.draw_shape(&shape.with_rotation_baked(deg), color, thickness, fill);
216        }
217        if matches!(shape, Shape::Ellipse { .. }) {
218            let band = (!fill).then_some(thickness);
219            return self.draw_ellipse_rotated(shape, deg, color, band);
220        }
221        let Shape::Rect(rect) = *shape else { return };
222        if !fill && thickness <= 0 {
223            return;
224        }
225        let c = color.to_0rgb();
226        let bb = shape.rotated_bbox(deg);
227        let band = f64::from(thickness);
228        let (x0, y0) = (f64::from(rect.x), f64::from(rect.y));
229        let (x1, y1) = (f64::from(rect.x + rect.w), f64::from(rect.y + rect.h));
230        // Sample pixel CENTERS against half-open local edges, pivoting on
231        // the exact f64 box center: a 180-degree rect then covers the same
232        // pixels as an unrotated one. Saturating loop bounds tolerate
233        // absurd deserialized coordinates.
234        let pivot_x = f64::from(rect.x) + f64::from(rect.w) / 2.0;
235        let pivot_y = f64::from(rect.y) + f64::from(rect.h) / 2.0;
236        let rad = f64::from(-deg).to_radians();
237        let (sin, cos) = rad.sin_cos();
238        for y in bb.y.max(0)..=bb.y.saturating_add(bb.h).min(self.h - 1) {
239            for x in bb.x.max(0)..=bb.x.saturating_add(bb.w).min(self.w - 1) {
240                let dx = f64::from(x) + 0.5 - pivot_x;
241                let dy = f64::from(y) + 0.5 - pivot_y;
242                let lx = pivot_x + dx * cos - dy * sin;
243                let ly = pivot_y + dx * sin + dy * cos;
244                if lx < x0 || lx >= x1 || ly < y0 || ly >= y1 {
245                    continue;
246                }
247                if fill {
248                    self.set(x, y, c);
249                    continue;
250                }
251                let edge_dist = (lx - x0).min(x1 - lx).min(ly - y0).min(y1 - ly);
252                if edge_dist < band {
253                    self.set(x, y, c);
254                }
255            }
256        }
257    }
258
259    /// Draw a selection shape as outline or fill.
260    pub fn draw_shape(&mut self, shape: &Shape, color: Color, thickness: i32, fill: bool) {
261        match *shape {
262            Shape::Rect(r) if fill => self.fill_rect(r, color),
263            Shape::Rect(r) => self.draw_rect_outline(r, color, thickness),
264            Shape::Circle { cx, cy, r } if fill => self.fill_circle(cx, cy, r, color),
265            Shape::Circle { cx, cy, r } => self.draw_circle_outline(cx, cy, r, color, thickness),
266            Shape::Ellipse { cx, cy, rx, ry } if fill => {
267                self.ellipse_band(cx, cy, rx, ry, 0, color);
268            }
269            Shape::Ellipse { cx, cy, rx, ry } => {
270                self.ellipse_band(cx, cy, rx, ry, thickness, color);
271            }
272            Shape::Triangle { .. } => self.draw_triangle(shape, color, thickness, fill),
273            Shape::Poly { ref points } if fill => self.fill_poly(points, color),
274            Shape::Poly { ref points } => self.draw_poly_outline(points, color, thickness),
275        }
276    }
277
278    /// Even-odd scanline fill — correct for concave and self-touching
279    /// freehand outlines, not just convex N-gons.
280    fn fill_poly(&mut self, points: &[Point], color: Color) {
281        if points.len() < 3 {
282            return;
283        }
284        let c = color.to_0rgb();
285        let y0 = points.iter().map(|p| p.y).min().unwrap_or(0).max(0);
286        let y1 = points
287            .iter()
288            .map(|p| p.y)
289            .max()
290            .unwrap_or(0)
291            .min(self.h - 1);
292        for y in y0..=y1 {
293            let spans = scanline_spans(points, y);
294            for span in spans.chunks(2) {
295                let [l, r] = span else { continue };
296                let xa = (l.ceil() as i32).max(0);
297                let xb = (r.floor() as i32).min(self.w - 1);
298                for x in xa..=xb {
299                    self.set(x, y, c);
300                }
301            }
302        }
303    }
304
305    /// Outline as stamped edge segments: cheap enough to redraw per mouse
306    /// move mid-drag, unlike a per-pixel distance test over the bbox.
307    fn draw_poly_outline(&mut self, points: &[Point], color: Color, thickness: i32) {
308        if points.len() < 2 || thickness <= 0 {
309            return;
310        }
311        let n = points.len();
312        for i in 0..n {
313            self.stamp_segment(points[i], points[(i + 1) % n], color, thickness);
314        }
315    }
316
317    /// A thick line as a run of small filled squares along the segment.
318    fn stamp_segment(&mut self, a: Point, b: Point, color: Color, thickness: i32) {
319        let steps = (b.x - a.x).abs().max((b.y - a.y).abs()).max(1);
320        let half = thickness / 2;
321        for i in 0..=steps {
322            let x = a.x + ((b.x - a.x) * i) / steps;
323            let y = a.y + ((b.y - a.y) * i) / steps;
324            self.fill_rect(
325                Rect::new(x - half, y - half, thickness.max(1), thickness.max(1)),
326                color,
327            );
328        }
329    }
330
331    /// Fill the region between the ellipse and, for `thickness > 0`, the
332    /// concentric ellipse `thickness` smaller on each radius — thickness 0
333    /// fills the whole interior. Normalized-distance test per pixel.
334    fn ellipse_band(&mut self, cx: i32, cy: i32, rx: i32, ry: i32, thickness: i32, color: Color) {
335        if rx <= 0 || ry <= 0 {
336            return;
337        }
338        let c = color.to_0rgb();
339        let (irx, iry) = if thickness <= 0 {
340            (-1, -1)
341        } else {
342            ((rx - thickness).max(0), (ry - thickness).max(0))
343        };
344        for y in (cy - ry).max(0)..=(cy + ry).min(self.h - 1) {
345            for x in (cx - rx).max(0)..=(cx + rx).min(self.w - 1) {
346                if ellipse_covers(x - cx, y - cy, rx, ry)
347                    && !ellipse_covers(x - cx, y - cy, irx, iry)
348                {
349                    self.set(x, y, c);
350                }
351            }
352        }
353    }
354
355    /// Triangle raster: fill is coverage; outline is the inward band of
356    /// covered pixels within `thickness` of the nearest edge.
357    fn draw_triangle(&mut self, shape: &Shape, color: Color, thickness: i32, fill: bool) {
358        let Shape::Triangle {
359            ax,
360            ay,
361            bx,
362            by,
363            cx,
364            cy,
365        } = *shape
366        else {
367            return;
368        };
369        if !fill && thickness <= 0 {
370            return;
371        }
372        let c = color.to_0rgb();
373        let bb = shape.bbox();
374        let band = f64::from(thickness);
375        for y in bb.y.max(0)..=bb.y.saturating_add(bb.h).min(self.h - 1) {
376            for x in bb.x.max(0)..=bb.x.saturating_add(bb.w).min(self.w - 1) {
377                if !shape.covers(x, y) {
378                    continue;
379                }
380                if fill {
381                    self.set(x, y, c);
382                    continue;
383                }
384                let d = seg_dist(x, y, ax, ay, bx, by)
385                    .min(seg_dist(x, y, bx, by, cx, cy))
386                    .min(seg_dist(x, y, cx, cy, ax, ay));
387                if d < band {
388                    self.set(x, y, c);
389                }
390            }
391        }
392    }
393}
394
395/// Squared-distance-free point-to-segment distance, for outline bands.
396fn seg_dist(px: i32, py: i32, x1: i32, y1: i32, x2: i32, y2: i32) -> f64 {
397    let (px, py) = (f64::from(px), f64::from(py));
398    let (x1, y1) = (f64::from(x1), f64::from(y1));
399    let (x2, y2) = (f64::from(x2), f64::from(y2));
400    let (dx, dy) = (x2 - x1, y2 - y1);
401    let len2 = dx * dx + dy * dy;
402    let t = if len2 == 0.0 {
403        0.0
404    } else {
405        (((px - x1) * dx + (py - y1) * dy) / len2).clamp(0.0, 1.0)
406    };
407    (px - (x1 + t * dx)).hypot(py - (y1 + t * dy))
408}
409
410/// The coordinate caption for a shape, e.g. `(120, 448) 300x88` or
411/// `(900, 300) r=64`. Triangles caption their bounding box.
412pub fn coord_text(shape: &Shape) -> String {
413    match *shape {
414        Shape::Rect(ref r) => format!("({}, {}) {}x{}", r.x, r.y, r.w, r.h),
415        Shape::Circle { cx, cy, r } => format!("({cx}, {cy}) r={r}"),
416        Shape::Ellipse { cx, cy, rx, ry } => format!("({cx}, {cy}) {rx}x{ry}"),
417        Shape::Triangle { .. } | Shape::Poly { .. } => {
418            let b = shape.bbox();
419            format!("({}, {}) {}x{}", b.x, b.y, b.w, b.h)
420        }
421    }
422}
423
424/// Where to place a caption of `text_len` glyphs (drawn at `scale`) near
425/// `bbox` so it stays readable at screen edges: above by default, flipping
426/// left/right/below as needed. Ported from the predecessor's smart
427/// placement.
428pub fn smart_text_position(bbox: Rect, bounds: Size, text_len: usize, scale: i32) -> Point {
429    let scale = scale.max(1);
430    let padding = 4 * scale;
431    let text_w = font::text_width(text_len, scale);
432    let text_h = font::line_height(scale);
433    let mut x = bbox.x;
434    let mut y = bbox.y - text_h - padding;
435    if x + text_w > bounds.w {
436        x = bbox.x - text_w - padding;
437    }
438    if x < 0 {
439        x = bbox.x + bbox.w + padding;
440    }
441    if y < 0 {
442        y = bbox.y + bbox.h + padding;
443    }
444    if y + text_h > bounds.h {
445        y = bbox.y - text_h - padding;
446        if y < 0 {
447            y = bbox.y + padding;
448        }
449    }
450    x = x.max(0);
451    y = y.max(0);
452    if x + text_w > bounds.w {
453        x = bounds.w - text_w;
454    }
455    if y + text_h > bounds.h {
456        y = bounds.h - text_h;
457    }
458    Point::new(x, y)
459}
460
461/// The sorted x positions where the polygon's edges cross the horizontal
462/// line through the center of pixel row `y`. Sampling mid-pixel sidesteps
463/// the vertex-exactly-on-scanline double-count.
464fn scanline_spans(points: &[Point], row: i32) -> Vec<f64> {
465    let mid = f64::from(row) + 0.5;
466    let count = points.len();
467    let mut crossings = Vec::new();
468    for i in 0..count {
469        let from = points[i];
470        let to = points[(i + 1) % count];
471        let (from_y, to_y) = (f64::from(from.y), f64::from(to.y));
472        if (from_y < mid) == (to_y < mid) {
473            continue;
474        }
475        let t = (mid - from_y) / (to_y - from_y);
476        crossings.push(f64::from(from.x) + t * f64::from(to.x - from.x));
477    }
478    crossings.sort_by(f64::total_cmp);
479    crossings
480}
481
482/// Whether offset (`dx`, `dy`) lies inside the origin-centered ellipse
483/// with radii (`rx`, `ry`); non-positive radii cover nothing.
484fn ellipse_covers(dx: i32, dy: i32, rx: i32, ry: i32) -> bool {
485    if rx <= 0 || ry <= 0 {
486        return false;
487    }
488    let (dx, dy) = (i128::from(dx), i128::from(dy));
489    let (rx, ry) = (i128::from(rx), i128::from(ry));
490    dx * dx * ry * ry + dy * dy * rx * rx <= rx * rx * ry * ry
491}
492
493/// Zero the alpha of every RGBA pixel that no shape covers — the primary
494/// cutout: selections stay visible in place, everything else goes
495/// transparent. Each `(shape, deg)` pairs a shape with its rotation.
496/// Coverage is rasterized per shape over its rotated bbox, so cost scales
497/// with selection area rather than selections × frame.
498pub fn apply_cutout_mask(rgba: &mut [u8], w: i32, h: i32, shapes: &[(Shape, i32)]) {
499    let covered = coverage(rgba.len(), w, h, shapes);
500    for (i, inside) in covered.iter().enumerate() {
501        if !inside {
502            rgba[i * 4 + 3] = 0;
503        }
504    }
505}
506
507/// The exact complement: zero the alpha of every pixel a shape covers —
508/// the inverse cutout punches the selections out and keeps the rest, so
509/// the pair reassembles the frame with no pixel in both.
510pub fn apply_inverse_cutout_mask(rgba: &mut [u8], w: i32, h: i32, shapes: &[(Shape, i32)]) {
511    let covered = coverage(rgba.len(), w, h, shapes);
512    for (i, inside) in covered.iter().enumerate() {
513        if *inside {
514            rgba[i * 4 + 3] = 0;
515        }
516    }
517}
518
519/// Which pixels any shape covers, rasterized per shape over its rotated
520/// bbox. `rgba_len` is asserted against the dimensions once, here, for
521/// both cutout appliers.
522fn coverage(rgba_len: usize, w: i32, h: i32, shapes: &[(Shape, i32)]) -> Vec<bool> {
523    assert_eq!(
524        rgba_len,
525        (w as usize) * (h as usize) * 4,
526        "RGBA buffer size mismatch"
527    );
528    let mut covered = vec![false; (w as usize) * (h as usize)];
529    for (shape, deg) in shapes {
530        mark_covered(&mut covered, w, h, shape, *deg);
531    }
532    covered
533}
534
535/// Mark the pixels `shape` (rotated `deg`) covers, clipped to the canvas.
536fn mark_covered(covered: &mut [bool], w: i32, h: i32, shape: &Shape, deg: i32) {
537    let bbox = shape.rotated_bbox(deg);
538    let x0 = bbox.x.max(0);
539    let y0 = bbox.y.max(0);
540    let x1 = bbox.x.saturating_add(bbox.w).min(w);
541    let y1 = bbox.y.saturating_add(bbox.h).min(h);
542    for y in y0..y1 {
543        for x in x0..x1 {
544            if shape.hit_test_rotated(deg, crate::geometry::Point::new(x, y)) {
545                covered[(y as usize) * (w as usize) + (x as usize)] = true;
546            }
547        }
548    }
549}
550
551/// Zero the alpha of every RGBA pixel the shape (rotated `deg` about its
552/// bbox center) does not cover. Used on non-rect crops so the outside of
553/// the shape is transparent; `shape` is in the crop image's own coordinate
554/// space.
555pub fn apply_alpha_mask_outside(rgba: &mut [u8], w: i32, h: i32, shape: &Shape, deg: i32) {
556    assert_eq!(
557        rgba.len(),
558        (w as usize) * (h as usize) * 4,
559        "RGBA buffer size mismatch"
560    );
561    for y in 0..h {
562        for x in 0..w {
563            if !shape.hit_test_rotated(deg, crate::geometry::Point::new(x, y)) {
564                rgba[((y as usize) * (w as usize) + (x as usize)) * 4 + 3] = 0;
565            }
566        }
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573
574    const W: i32 = 100;
575    const H: i32 = 60;
576    const RED: Color = Color { r: 255, g: 0, b: 0 };
577
578    fn canvas_buf() -> Vec<u32> {
579        vec![0u32; (W * H) as usize]
580    }
581
582    fn px(buf: &[u32], x: i32, y: i32) -> u32 {
583        buf[(y * W + x) as usize]
584    }
585
586    #[test]
587    fn rect_outline_sets_border_not_interior() {
588        let mut buf = canvas_buf();
589        let mut c = Canvas::new(&mut buf, W, H);
590        c.draw_rect_outline(Rect::new(10, 10, 20, 20), RED, 2);
591        let red = RED.to_0rgb();
592        assert_eq!(px(&buf, 10, 10), red);
593        assert_eq!(px(&buf, 29, 29), red);
594        assert_eq!(px(&buf, 11, 15), red); // inside 2px border
595        assert_eq!(px(&buf, 15, 15), 0); // interior untouched
596        assert_eq!(px(&buf, 9, 10), 0); // outside untouched
597    }
598
599    #[test]
600    fn fill_rect_clips_to_canvas() {
601        let mut buf = canvas_buf();
602        let mut c = Canvas::new(&mut buf, W, H);
603        c.fill_rect(Rect::new(-10, -10, 30, 30), RED);
604        assert_eq!(px(&buf, 0, 0), RED.to_0rgb());
605        assert_eq!(px(&buf, 19, 19), RED.to_0rgb());
606        assert_eq!(px(&buf, 20, 20), 0);
607    }
608
609    #[test]
610    fn fill_rect_fully_off_canvas_is_a_noop() {
611        let mut buf = canvas_buf();
612        let mut c = Canvas::new(&mut buf, W, H);
613        c.fill_rect(Rect::new(W + 10, 10, 20, 20), RED);
614        c.fill_rect(Rect::new(-50, -50, 20, 20), RED);
615        c.fill_rect(Rect::new(10, H + 5, 20, 20), RED);
616        assert!(buf.iter().all(|&p| p == 0));
617    }
618
619    #[test]
620    fn zero_thickness_draws_nothing() {
621        let mut buf = canvas_buf();
622        let mut c = Canvas::new(&mut buf, W, H);
623        c.draw_rect_outline(Rect::new(10, 10, 20, 20), RED, 0);
624        c.draw_circle_outline(50, 30, 10, RED, 0);
625        assert!(buf.iter().all(|&p| p == 0));
626    }
627
628    #[test]
629    fn ellipse_outline_is_a_band_and_fill_covers_the_interior() {
630        let mut buf = canvas_buf();
631        let mut c = Canvas::new(&mut buf, W, H);
632        let e = Shape::Ellipse {
633            cx: 50,
634            cy: 30,
635            rx: 30,
636            ry: 15,
637        };
638        c.draw_shape(&e, RED, 3, false);
639        let red = RED.to_0rgb();
640        assert_eq!(px(&buf, 79, 30), red, "on the rim");
641        assert_eq!(px(&buf, 50, 30), 0, "outline leaves the center empty");
642        assert_eq!(px(&buf, 79, 15), 0, "bbox corner outside");
643
644        let mut buf = canvas_buf();
645        let mut c = Canvas::new(&mut buf, W, H);
646        c.draw_shape(&e, RED, 3, true);
647        assert_eq!(px(&buf, 50, 30), red, "fill covers the center");
648    }
649
650    #[test]
651    fn rotated_ellipse_raster_follows_the_turn() {
652        let mut buf = canvas_buf();
653        let mut c = Canvas::new(&mut buf, W, H);
654        let e = Shape::Ellipse {
655            cx: 50,
656            cy: 30,
657            rx: 25,
658            ry: 6,
659        };
660        c.draw_shape_rotated(&e, 90, RED, 2, true);
661        let red = RED.to_0rgb();
662        assert_eq!(px(&buf, 50, 50), red, "stands tall after the turn");
663        assert_eq!(px(&buf, 70, 30), 0, "no longer lies flat");
664    }
665
666    #[test]
667    fn circle_outline_is_an_annulus() {
668        let mut buf = canvas_buf();
669        let mut c = Canvas::new(&mut buf, W, H);
670        c.draw_circle_outline(50, 30, 10, RED, 2);
671        let red = RED.to_0rgb();
672        assert_eq!(px(&buf, 60, 30), red); // on the radius
673        assert_eq!(px(&buf, 59, 30), red); // within the band
674        assert_eq!(px(&buf, 50, 30), 0); // center empty
675        assert_eq!(px(&buf, 62, 30), 0); // outside
676    }
677
678    #[test]
679    fn fill_circle_covers_center_and_clips() {
680        let mut buf = canvas_buf();
681        let mut c = Canvas::new(&mut buf, W, H);
682        c.fill_circle(0, 0, 10, RED); // 3/4 off-canvas
683        assert_eq!(px(&buf, 0, 0), RED.to_0rgb());
684        assert_eq!(px(&buf, 7, 7), RED.to_0rgb());
685        assert_eq!(px(&buf, 8, 8), 0);
686    }
687
688    #[test]
689    fn triangle_fill_covers_interior_not_bbox_corners() {
690        let mut buf = canvas_buf();
691        let mut c = Canvas::new(&mut buf, W, H);
692        let tri = Shape::Triangle {
693            ax: 50,
694            ay: 10,
695            bx: 10,
696            by: 50,
697            cx: 90,
698            cy: 50,
699        };
700        c.draw_shape(&tri, RED, 2, true);
701        let red = RED.to_0rgb();
702        assert_eq!(px(&buf, 50, 40), red); // interior
703        assert_eq!(px(&buf, 50, 11), red); // just below apex
704        assert_eq!(px(&buf, 12, 12), 0); // bbox top-left, outside triangle
705        assert_eq!(px(&buf, 88, 12), 0); // bbox top-right, outside triangle
706    }
707
708    #[test]
709    fn triangle_outline_is_a_band_not_a_fill() {
710        let mut buf = canvas_buf();
711        let mut c = Canvas::new(&mut buf, W, H);
712        let tri = Shape::Triangle {
713            ax: 50,
714            ay: 10,
715            bx: 10,
716            by: 50,
717            cx: 90,
718            cy: 50,
719        };
720        c.draw_shape(&tri, RED, 2, false);
721        let red = RED.to_0rgb();
722        assert_eq!(px(&buf, 50, 49), red); // on the base edge
723        assert_eq!(px(&buf, 50, 35), 0); // interior stays empty
724        assert_eq!(px(&buf, 12, 12), 0); // outside stays empty
725    }
726
727    #[test]
728    fn text_inks_inside_its_line_box_and_nowhere_else() {
729        let mut buf = canvas_buf();
730        let mut c = Canvas::new(&mut buf, W, H);
731        c.draw_text(10, 10, "I", RED, 1);
732        // Ink lands somewhere inside the one-glyph line box; nothing
733        // changes outside it (one pixel of slack for hinting offsets).
734        let box_x = 10 - 1..10 + font::advance(1) + 1;
735        let box_y = 10 - 1..10 + font::line_height(1) + 1;
736        let mut inked = false;
737        for y in 0..H {
738            for x in 0..W {
739                let p = px(&buf, x, y);
740                if box_x.contains(&x) && box_y.contains(&y) {
741                    inked |= p != 0;
742                    continue;
743                }
744                assert_eq!(p, 0, "stray ink at ({x},{y})");
745            }
746        }
747        assert!(inked, "the glyph drew nothing");
748    }
749
750    #[test]
751    fn text_off_canvas_is_safe() {
752        let mut buf = canvas_buf();
753        let mut c = Canvas::new(&mut buf, W, H);
754        c.draw_text(-5, -5, "EDGE", RED, 1);
755        c.draw_text(W - 3, H - 3, "EDGE", RED, 1);
756    }
757
758    #[test]
759    fn smart_position_defaults_above() {
760        let p = smart_text_position(Rect::new(200, 200, 100, 50), Size::new(1920, 1080), 10, 1);
761        assert_eq!(p, Point::new(200, 200 - font::line_height(1) - 4));
762    }
763
764    #[test]
765    fn smart_position_flips_below_at_top_edge() {
766        let p = smart_text_position(Rect::new(200, 2, 100, 50), Size::new(1920, 1080), 10, 1);
767        assert_eq!(p, Point::new(200, 2 + 50 + 4));
768    }
769
770    #[test]
771    fn smart_position_flips_left_at_right_edge() {
772        let bbox = Rect::new(1900, 200, 15, 50);
773        let p = smart_text_position(bbox, Size::new(1920, 1080), 10, 1);
774        let text_w = font::text_width(10, 1);
775        assert_eq!(p.x, 1900 - text_w - 4);
776    }
777
778    #[test]
779    fn smart_position_stays_on_screen_in_corners() {
780        let bounds = Size::new(1920, 1080);
781        let text_len = 20;
782        for bbox in [
783            Rect::new(0, 0, 50, 50),
784            Rect::new(1870, 0, 50, 50),
785            Rect::new(0, 1030, 50, 50),
786            Rect::new(1870, 1030, 50, 50),
787        ] {
788            let p = smart_text_position(bbox, bounds, text_len, 1);
789            assert!(p.x >= 0 && p.y >= 0, "{bbox:?} gave {p:?}");
790            assert!(
791                p.x + font::text_width(text_len, 1) <= bounds.w
792                    && p.y + font::line_height(1) <= bounds.h,
793                "{bbox:?} gave {p:?}"
794            );
795        }
796    }
797
798    #[test]
799    fn scaled_text_is_larger_and_reaches_full_ink() {
800        let mut buf = canvas_buf();
801        let mut c = Canvas::new(&mut buf, W, H);
802        c.draw_text(10, 10, "M", RED, 2);
803        // At scale 2 a dense glyph has fully-covered interior pixels, so
804        // the exact color appears; everything stays inside the line box.
805        let red = RED.to_0rgb();
806        let box_x = 10 - 1..10 + font::advance(2) + 1;
807        let box_y = 10 - 1..10 + font::line_height(2) + 1;
808        let mut solid = false;
809        for y in 0..H {
810            for x in 0..W {
811                let p = px(&buf, x, y);
812                if box_x.contains(&x) && box_y.contains(&y) {
813                    solid |= p == red;
814                    continue;
815                }
816                assert_eq!(p, 0, "stray ink at ({x},{y})");
817            }
818        }
819        assert!(solid, "no fully-covered pixel in 'M' at scale 2");
820    }
821
822    #[test]
823    fn scaled_smart_position_scales_offsets() {
824        let p = smart_text_position(Rect::new(200, 200, 100, 50), Size::new(1920, 1080), 10, 2);
825        assert_eq!(p, Point::new(200, 200 - font::line_height(2) - 8));
826    }
827
828    #[test]
829    fn coord_text_formats() {
830        assert_eq!(
831            coord_text(&Shape::Rect(Rect::new(1, 2, 3, 4))),
832            "(1, 2) 3x4"
833        );
834        assert_eq!(
835            coord_text(&Shape::Circle { cx: 9, cy: 8, r: 7 }),
836            "(9, 8) r=7"
837        );
838    }
839
840    #[test]
841    fn rotated_rect_raster_follows_the_turn() {
842        let mut buf = canvas_buf();
843        let mut c = Canvas::new(&mut buf, W, H);
844        // Wide flat rect centered at (50, 30), turned 90 deg: it becomes
845        // tall and thin.
846        let s = Shape::Rect(Rect::new(20, 25, 60, 10));
847        c.draw_shape_rotated(&s, 90, RED, 2, true);
848        let red = RED.to_0rgb();
849        assert_eq!(px(&buf, 50, 5), red); // above center: inside turned
850        assert_eq!(px(&buf, 50, 55), red); // below center: inside turned
851        assert_eq!(px(&buf, 75, 30), 0); // right of center: outside now
852        // Rotation 0 matches plain drawing.
853        let mut buf2 = canvas_buf();
854        let mut c2 = Canvas::new(&mut buf2, W, H);
855        c2.draw_shape_rotated(&s, 0, RED, 2, true);
856        let mut buf3 = canvas_buf();
857        let mut c3 = Canvas::new(&mut buf3, W, H);
858        c3.draw_shape(&s, RED, 2, true);
859        assert_eq!(buf2, buf3);
860    }
861
862    #[test]
863    fn rect_covers_same_pixel_count_at_0_and_180_degrees() {
864        let s = Shape::Rect(Rect::new(20, 20, 11, 7)); // odd dims on purpose
865        let mut plain = canvas_buf();
866        Canvas::new(&mut plain, W, H).draw_shape(&s, RED, 2, true);
867        let mut turned = canvas_buf();
868        Canvas::new(&mut turned, W, H).draw_shape_rotated(&s, 180, RED, 2, true);
869        let count = |buf: &[u32]| buf.iter().filter(|&&p| p != 0).count();
870        assert_eq!(count(&plain), 77);
871        assert_eq!(count(&turned), 77, "180-degree raster must match 0-degree");
872    }
873
874    #[test]
875    fn dim_rect_scales_brightness_and_clips_to_the_canvas() {
876        let mut buf = vec![0x00FF_8040u32; (W * H) as usize];
877        let mut c = Canvas::new(&mut buf, W, H);
878        // Half brightness inside; a rect hanging off the canvas clips.
879        c.dim_rect(Rect::new(-10, -10, 20, 20), 128);
880        assert_eq!(buf[0], 0x007F_4020, "channels each halve");
881        assert_eq!(
882            buf[(10 * W + 10) as usize],
883            0x00FF_8040,
884            "outside the rect untouched"
885        );
886
887        let mut buf = vec![0x00FF_FFFFu32; (W * H) as usize];
888        Canvas::new(&mut buf, W, H).dim_rect(Rect::new(0, 0, 2, 1), 0);
889        assert_eq!(buf[0], 0, "strength 0 blacks out");
890
891        let mut buf = vec![0x0012_3456u32; (W * H) as usize];
892        Canvas::new(&mut buf, W, H).dim_rect(Rect::new(0, 0, 1, 1), 256);
893        assert_eq!(buf[0], 0x0012_3456, "strength 256 leaves pixels alone");
894        // A fully off-canvas rect is a no-op, not a panic.
895        Canvas::new(&mut buf, W, H).dim_rect(Rect::new(-50, -50, 10, 10), 64);
896    }
897
898    #[test]
899    fn cutout_keeps_every_shape_in_place_and_clears_the_rest() {
900        let w = 60;
901        let h = 40;
902        let mut rgba = vec![200u8; (w * h * 4) as usize];
903        let shapes = [
904            (Shape::Rect(Rect::new(5, 5, 10, 10)), 0),
905            (
906                Shape::Circle {
907                    cx: 40,
908                    cy: 20,
909                    r: 6,
910                },
911                0,
912            ),
913        ];
914        apply_cutout_mask(&mut rgba, w, h, &shapes);
915        let pixel = |x: i32, y: i32| {
916            let i = ((y * w + x) * 4) as usize;
917            (rgba[i], rgba[i + 3])
918        };
919        // Inside either shape: color and alpha untouched.
920        assert_eq!(pixel(10, 10), (200, 200));
921        assert_eq!(pixel(40, 20), (200, 200));
922        // Between and outside: transparent, color bytes untouched.
923        assert_eq!(pixel(25, 10), (200, 0));
924        assert_eq!(pixel(0, 39), (200, 0));
925        // The circle's bbox corner is outside the circle itself.
926        assert_eq!(pixel(35, 15), (200, 0));
927    }
928
929    #[test]
930    fn cutout_of_a_rotated_rect_follows_the_turn() {
931        let w = 30;
932        let h = 30;
933        let mut rgba = vec![255u8; (w * h * 4) as usize];
934        // Flat rect centered at (15, 15), turned 90: vertical strip kept.
935        apply_cutout_mask(
936            &mut rgba,
937            w,
938            h,
939            &[(Shape::Rect(Rect::new(3, 12, 24, 6)), 90)],
940        );
941        let alpha = |x: i32, y: i32| rgba[((y * w + x) * 4 + 3) as usize];
942        assert_eq!(alpha(15, 5), 255);
943        assert_eq!(alpha(5, 15), 0);
944    }
945
946    #[test]
947    fn cutout_clips_offscreen_shapes_instead_of_panicking() {
948        let w = 20;
949        let h = 20;
950        let mut rgba = vec![255u8; (w * h * 4) as usize];
951        // Hangs off the top-left; only the on-canvas part is kept.
952        apply_cutout_mask(
953            &mut rgba,
954            w,
955            h,
956            &[(Shape::Rect(Rect::new(-10, -10, 15, 15)), 0)],
957        );
958        let alpha = |x: i32, y: i32| rgba[((y * w + x) * 4 + 3) as usize];
959        assert_eq!(alpha(2, 2), 255);
960        assert_eq!(alpha(10, 10), 0);
961    }
962
963    #[test]
964    fn cutout_with_no_shapes_clears_everything() {
965        let mut rgba = vec![255u8; 4 * 4];
966        apply_cutout_mask(&mut rgba, 2, 2, &[]);
967        assert!(rgba.chunks(4).all(|p| p[3] == 0));
968    }
969
970    #[test]
971    fn inverse_cutout_is_the_exact_complement() {
972        let w = 60;
973        let h = 40;
974        let shapes = [
975            (Shape::Rect(Rect::new(5, 5, 10, 10)), 0),
976            (
977                Shape::Circle {
978                    cx: 40,
979                    cy: 20,
980                    r: 6,
981                },
982                45,
983            ),
984        ];
985        let mut primary = vec![255u8; (w * h * 4) as usize];
986        let mut inverse = vec![255u8; (w * h * 4) as usize];
987        apply_cutout_mask(&mut primary, w, h, &shapes);
988        apply_inverse_cutout_mask(&mut inverse, w, h, &shapes);
989        // Every pixel is transparent in exactly one of the pair — together
990        // they reassemble the frame.
991        for i in 0..(w * h) as usize {
992            let (p, v) = (primary[i * 4 + 3], inverse[i * 4 + 3]);
993            assert_eq!(p ^ v, 255, "pixel {i}: primary {p}, inverse {v}");
994        }
995        // Spot-check orientation: inside a shape the inverse is the
996        // transparent one.
997        let idx = ((10 * w + 10) * 4 + 3) as usize;
998        assert_eq!(primary[idx], 255);
999        assert_eq!(inverse[idx], 0);
1000    }
1001
1002    #[test]
1003    fn rotated_alpha_mask_follows_the_turn() {
1004        let w = 30;
1005        let h = 30;
1006        let mut rgba = vec![255u8; (w * h * 4) as usize];
1007        // Flat rect centered at (15,15), turned 90: vertical strip opaque.
1008        let s = Shape::Rect(Rect::new(3, 12, 24, 6));
1009        apply_alpha_mask_outside(&mut rgba, w, h, &s, 90);
1010        let alpha = |x: i32, y: i32| rgba[((y * w + x) * 4 + 3) as usize];
1011        assert_eq!(alpha(15, 5), 255); // vertical strip kept
1012        assert_eq!(alpha(5, 15), 0); // horizontal extent cleared
1013    }
1014
1015    #[test]
1016    fn alpha_mask_zeroes_outside_circle_only() {
1017        let w = 20;
1018        let h = 20;
1019        let mut rgba = vec![255u8; (w * h * 4) as usize];
1020        apply_alpha_mask_outside(
1021            &mut rgba,
1022            w,
1023            h,
1024            &Shape::Circle {
1025                cx: 10,
1026                cy: 10,
1027                r: 8,
1028            },
1029            0,
1030        );
1031        let alpha = |x: i32, y: i32| rgba[((y * w + x) * 4 + 3) as usize];
1032        assert_eq!(alpha(10, 10), 255); // center kept
1033        assert_eq!(alpha(10, 2), 255); // on the radius kept
1034        assert_eq!(alpha(0, 0), 0); // corner cleared
1035        assert_eq!(rgba[0], 255); // color channels untouched
1036    }
1037
1038    #[test]
1039    fn a_rotated_rect_fills_its_interior() {
1040        let mut buf = canvas_buf();
1041        let mut c = Canvas::new(&mut buf, W, H);
1042        c.draw_shape_rotated(&Shape::Rect(Rect::new(30, 15, 40, 30)), 30, RED, 2, true);
1043        // The centre of a rotated rect is inside it at any angle.
1044        assert_eq!(px(&buf, 50, 30), RED.to_0rgb());
1045    }
1046
1047    #[test]
1048    fn a_rotated_rect_outline_is_a_band_not_a_fill() {
1049        let mut buf = canvas_buf();
1050        let mut c = Canvas::new(&mut buf, W, H);
1051        c.draw_shape_rotated(&Shape::Rect(Rect::new(30, 15, 40, 30)), 30, RED, 2, false);
1052        assert_eq!(px(&buf, 50, 30), 0, "centre stays empty for an outline");
1053        assert!(
1054            buf.iter().any(|&p| p == RED.to_0rgb()),
1055            "something was drawn"
1056        );
1057    }
1058
1059    #[test]
1060    fn a_rotated_triangle_bakes_its_rotation() {
1061        let mut buf = canvas_buf();
1062        let mut c = Canvas::new(&mut buf, W, H);
1063        let tri = Shape::Triangle {
1064            ax: 50,
1065            ay: 10,
1066            bx: 20,
1067            by: 50,
1068            cx: 80,
1069            cy: 50,
1070        };
1071        c.draw_shape_rotated(&tri, 90, RED, 1, true);
1072        assert!(buf.iter().any(|&p| p == RED.to_0rgb()));
1073    }
1074
1075    #[test]
1076    fn draw_shape_fills_or_outlines_each_kind() {
1077        for fill in [true, false] {
1078            for shape in [
1079                Shape::Rect(Rect::new(10, 10, 20, 20)),
1080                Shape::Circle {
1081                    cx: 50,
1082                    cy: 30,
1083                    r: 12,
1084                },
1085                Shape::Triangle {
1086                    ax: 60,
1087                    ay: 10,
1088                    bx: 45,
1089                    by: 40,
1090                    cx: 75,
1091                    cy: 40,
1092                },
1093            ] {
1094                let mut buf = canvas_buf();
1095                let mut c = Canvas::new(&mut buf, W, H);
1096                c.draw_shape(&shape, RED, 2, fill);
1097                assert!(
1098                    buf.iter().any(|&p| p == RED.to_0rgb()),
1099                    "{shape:?} fill={fill} drew nothing"
1100                );
1101            }
1102        }
1103    }
1104
1105    #[test]
1106    fn a_caption_flips_inside_the_canvas_at_every_edge() {
1107        let bounds = Size::new(W, H);
1108        let len = 6;
1109        // Each corner drives a different branch of the placement cascade;
1110        // whichever it picks must land inside the canvas.
1111        for bbox in [
1112            Rect::new(0, 0, 20, 20),         // no room above
1113            Rect::new(W - 10, 0, 20, 20),    // no room right
1114            Rect::new(0, H - 10, 20, 20),    // no room below
1115            Rect::new(W - 5, H - 5, 20, 20), // no room anywhere
1116        ] {
1117            let p = smart_text_position(bbox, bounds, len, 1);
1118            assert!(p.x >= 0 && p.y >= 0, "{bbox:?} -> {p:?}");
1119            assert!(p.x + font::text_width(len, 1) <= W, "{bbox:?} -> {p:?}");
1120            assert!(p.y + font::line_height(1) <= H, "{bbox:?} -> {p:?}");
1121        }
1122    }
1123
1124    #[test]
1125    fn a_caption_wider_than_the_canvas_starts_off_the_left_edge() {
1126        // Documented, not desired: nothing can fit, so the placement pins
1127        // the right edge and the head of the text clips away. Drawing
1128        // clips per pixel, so this is safe rather than fatal.
1129        let len = 40;
1130        assert!(font::text_width(len, 1) > W);
1131        let p = smart_text_position(Rect::new(0, 0, 20, 20), Size::new(W, H), len, 1);
1132        assert!(p.x < 0, "{p:?}");
1133    }
1134}