Skip to main content

ravenrs_graphics/
vector.rs

1//! Vector shapes: curves and polygons that PDF and SVG write as they are, and
2//! that the screen and spreadsheets get as a picture.
3
4use ravenrs_core::{Color, ImageData, Point, Rect};
5
6/// One step of a shape's outline, in the page's coordinates.
7#[derive(Clone, Copy, Debug, PartialEq)]
8#[non_exhaustive]
9pub enum PathSegment {
10    /// Starts an outline here.
11    Move(Point),
12    /// A straight line to this point.
13    Line(Point),
14    /// A curve with two control points, to the last one.
15    Cubic(Point, Point, Point),
16    /// Back to where the outline started.
17    Close,
18}
19
20/// A filled and/or outlined shape.
21#[derive(Clone, Debug, PartialEq)]
22pub struct Shape {
23    /// Its outline.
24    pub path: Vec<PathSegment>,
25    /// Fill color.
26    pub fill: Option<Color>,
27    /// Outline width and color.
28    pub stroke: Option<(f32, Color)>,
29}
30
31/// What a picture is made of, for [`rasterize_layers`]: [`Shape`]s, plus what only
32/// a picture can show.
33#[derive(Clone, Debug, PartialEq)]
34#[non_exhaustive]
35pub enum Layer {
36    /// A filled and/or outlined shape.
37    Shape(Shape),
38    /// An outline filled from one color at the `top` to another at the `bottom`.
39    Gradient {
40        /// Its outline.
41        path: Vec<PathSegment>,
42        /// Where the first color is whole, and that color.
43        top: (f32, Color),
44        /// Where the last color is whole, and that color.
45        bottom: (f32, Color),
46    },
47    /// An outline drawn in dashes.
48    Dashed {
49        /// Its outline.
50        path: Vec<PathSegment>,
51        /// How wide the line is.
52        width: f32,
53        /// How long each dash and each gap is.
54        dash: f32,
55        /// Its color.
56        color: Color,
57    },
58    /// Another layer, seen only inside a rectangle. Outputs that only write shapes
59    /// write it whole.
60    Clipped {
61        /// Where it shows.
62        rect: Rect,
63        /// What shows.
64        layer: Box<Layer>,
65    },
66}
67
68/// The box around an outline: its curves stay inside the box of their control points.
69fn outline_bounds(path: &[PathSegment]) -> Option<(f32, f32, f32, f32)> {
70    let mut found: Option<(f32, f32, f32, f32)> = None;
71    let mut grow = |p: &Point| {
72        let (x0, y0, x1, y1) = found.unwrap_or((p.x, p.y, p.x, p.y));
73        found = Some((x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y)));
74    };
75    for segment in path {
76        match segment {
77            PathSegment::Move(p) | PathSegment::Line(p) => grow(p),
78            PathSegment::Cubic(a, b, c) => {
79                grow(a);
80                grow(b);
81                grow(c);
82            }
83            PathSegment::Close => {}
84        }
85    }
86    found
87}
88
89fn inflated(bounds: (f32, f32, f32, f32), by: f32) -> Rect {
90    let (x0, y0, x1, y1) = bounds;
91    Rect::new(x0 - by, y0 - by, x1 - x0 + by * 2.0, y1 - y0 + by * 2.0)
92}
93
94impl Layer {
95    /// A box it stays inside of, once drawn, or `None` if it draws nothing.
96    pub fn bounds(&self) -> Option<Rect> {
97        match self {
98            Self::Shape(shape) => {
99                let reach = shape.stroke.map_or(0.0, |(width, _)| width / 2.0);
100                outline_bounds(&shape.path).map(|b| inflated(b, reach + 1.0))
101            }
102            Self::Gradient { path, .. } => outline_bounds(path).map(|b| inflated(b, 1.0)),
103            Self::Dashed { path, width, .. } => outline_bounds(path).map(|b| inflated(b, width / 2.0 + 1.0)),
104            Self::Clipped { rect, layer } => {
105                let inner = layer.bounds()?;
106                let (x0, y0) = (rect.x().max(inner.x()), rect.y().max(inner.y()));
107                let (x1, y1) = (rect.max_x().min(inner.max_x()), rect.max_y().min(inner.max_y()));
108                (x1 > x0 && y1 > y0).then(|| Rect::new(x0, y0, x1 - x0, y1 - y0))
109            }
110        }
111    }
112
113    /// The closest a plain [`Shape`] gets, for outputs that only write shapes.
114    pub fn to_shape(&self) -> Shape {
115        match self {
116            Self::Shape(shape) => shape.clone(),
117            Self::Gradient { path, top, bottom } => {
118                let (a, b) = (top.1, bottom.1);
119                let middle =
120                    Color::rgba((a.r + b.r) / 2.0, (a.g + b.g) / 2.0, (a.b + b.b) / 2.0, (a.a + b.a) / 2.0);
121                Shape::filled(path.clone(), middle)
122            }
123            Self::Dashed { path, width, color, .. } => {
124                Shape { path: path.clone(), fill: None, stroke: Some((*width, *color)) }
125            }
126            Self::Clipped { layer, .. } => layer.to_shape(),
127        }
128    }
129}
130
131/// A quarter circle as a Bézier curve: the handle's length for radius 1.
132const KAPPA: f32 = 0.552_284_8;
133
134impl Shape {
135    /// The same shape moved by an offset.
136    pub fn translated(&self, by: Point) -> Self {
137        let moved = |p: &Point| Point::new(p.x + by.x, p.y + by.y);
138        let path = self
139            .path
140            .iter()
141            .map(|segment| match segment {
142                PathSegment::Move(p) => PathSegment::Move(moved(p)),
143                PathSegment::Line(p) => PathSegment::Line(moved(p)),
144                PathSegment::Cubic(a, b, c) => PathSegment::Cubic(moved(a), moved(b), moved(c)),
145                PathSegment::Close => PathSegment::Close,
146            })
147            .collect();
148        Self { path, fill: self.fill, stroke: self.stroke }
149    }
150
151    /// A shape filled with `fill`, without an outline.
152    pub fn filled(path: Vec<PathSegment>, fill: Color) -> Self {
153        Self { path, fill: Some(fill), stroke: None }
154    }
155}
156
157/// The outline of a rectangle, with rounded corners when `radius` is above zero.
158pub fn rectangle(rect: Rect, radius: f32) -> Vec<PathSegment> {
159    let (x0, x1, y0, y1) = (rect.x(), rect.max_x(), rect.y(), rect.max_y());
160    let mut path = Vec::with_capacity(5);
161    append_rectangle(&mut path, x0, y0, x1, y1, radius);
162    path
163}
164
165/// Appends a rectangle to an outline, for many rectangles in one shape.
166pub fn append_rectangle(path: &mut Vec<PathSegment>, x0: f32, y0: f32, x1: f32, y1: f32, radius: f32) {
167    let r = radius.clamp(0.0, (x1 - x0).min(y1 - y0) / 2.0);
168    let at = Point::new;
169    if r <= 0.0 {
170        path.extend([
171            PathSegment::Move(at(x0, y0)),
172            PathSegment::Line(at(x1, y0)),
173            PathSegment::Line(at(x1, y1)),
174            PathSegment::Line(at(x0, y1)),
175            PathSegment::Close,
176        ]);
177        return;
178    }
179    let k = r * (1.0 - KAPPA);
180    path.extend([
181        PathSegment::Move(at(x0 + r, y0)),
182        PathSegment::Line(at(x1 - r, y0)),
183        PathSegment::Cubic(at(x1 - k, y0), at(x1, y0 + k), at(x1, y0 + r)),
184        PathSegment::Line(at(x1, y1 - r)),
185        PathSegment::Cubic(at(x1, y1 - k), at(x1 - k, y1), at(x1 - r, y1)),
186        PathSegment::Line(at(x0 + r, y1)),
187        PathSegment::Cubic(at(x0 + k, y1), at(x0, y1 - k), at(x0, y1 - r)),
188        PathSegment::Line(at(x0, y0 + r)),
189        PathSegment::Cubic(at(x0, y0 + k), at(x0 + k, y0), at(x0 + r, y0)),
190        PathSegment::Close,
191    ]);
192}
193
194/// The outline of an ellipse inscribed in `rect`.
195pub fn ellipse(rect: Rect) -> Vec<PathSegment> {
196    let center = rect.center();
197    let (cx, cy) = (center.x, center.y);
198    let (rx, ry) = (rect.width() / 2.0, rect.height() / 2.0);
199    let (kx, ky) = (rx * KAPPA, ry * KAPPA);
200    let at = Point::new;
201    vec![
202        PathSegment::Move(at(cx + rx, cy)),
203        PathSegment::Cubic(at(cx + rx, cy + ky), at(cx + kx, cy + ry), at(cx, cy + ry)),
204        PathSegment::Cubic(at(cx - kx, cy + ry), at(cx - rx, cy + ky), at(cx - rx, cy)),
205        PathSegment::Cubic(at(cx - rx, cy - ky), at(cx - kx, cy - ry), at(cx, cy - ry)),
206        PathSegment::Cubic(at(cx + kx, cy - ry), at(cx + rx, cy - ky), at(cx + rx, cy)),
207        PathSegment::Close,
208    ]
209}
210
211/// The most pixels a side of a rasterized drawing can have.
212const MAX_SIDE: f32 = 4096.0;
213
214/// Draws shapes into a picture of `rect` at `scale` pixels per point, smoothed.
215pub fn rasterize(shapes: &[Shape], rect: Rect, scale: f32) -> Option<ImageData> {
216    rasterize_all(shapes.iter().map(LayerRef::Shape), rect, scale)
217}
218
219/// Draws layers into a picture of `rect` at `scale` pixels per point, smoothed.
220pub fn rasterize_layers(layers: &[Layer], rect: Rect, scale: f32) -> Option<ImageData> {
221    rasterize_all(layers.iter().map(LayerRef::Layer), rect, scale)
222}
223
224enum LayerRef<'a> {
225    Shape(&'a Shape),
226    Layer(&'a Layer),
227}
228
229fn draw_shape(
230    pixmap: &mut tiny_skia::Pixmap,
231    shape: &Shape,
232    transform: tiny_skia::Transform,
233    mask: Option<&tiny_skia::Mask>,
234) {
235    let Some(path) = build_path(&shape.path) else { return };
236    if let Some(fill) = shape.fill {
237        let paint = paint_of(fill);
238        pixmap.fill_path(&path, &paint, tiny_skia::FillRule::Winding, transform, mask);
239    }
240    if let Some((width, color)) = shape.stroke {
241        let paint = paint_of(color);
242        pixmap.stroke_path(&path, &paint, &round_stroke(width), transform, mask);
243    }
244}
245
246fn round_stroke(width: f32) -> tiny_skia::Stroke {
247    tiny_skia::Stroke {
248        width,
249        line_join: tiny_skia::LineJoin::Round,
250        line_cap: tiny_skia::LineCap::Round,
251        ..tiny_skia::Stroke::default()
252    }
253}
254
255fn skia_color(color: Color) -> tiny_skia::Color {
256    tiny_skia::Color::from_rgba(
257        color.r.clamp(0.0, 1.0),
258        color.g.clamp(0.0, 1.0),
259        color.b.clamp(0.0, 1.0),
260        color.a.clamp(0.0, 1.0),
261    )
262    .unwrap_or(tiny_skia::Color::TRANSPARENT)
263}
264
265fn draw_layer(
266    pixmap: &mut tiny_skia::Pixmap,
267    layer: &Layer,
268    transform: tiny_skia::Transform,
269    mask: Option<&tiny_skia::Mask>,
270) {
271    match layer {
272        Layer::Shape(shape) => draw_shape(pixmap, shape, transform, mask),
273        Layer::Clipped { rect, layer } => {
274            let Some(mut own) = tiny_skia::Mask::new(pixmap.width(), pixmap.height()) else { return };
275            let Some(outline) = build_path(&rectangle(*rect, 0.0)) else { return };
276            own.fill_path(&outline, tiny_skia::FillRule::Winding, true, transform);
277            draw_layer(pixmap, layer, transform, Some(&own));
278        }
279        Layer::Gradient { path, top, bottom } => {
280            let Some(path) = build_path(path) else { return };
281            let stops = vec![
282                tiny_skia::GradientStop::new(0.0, skia_color(top.1)),
283                tiny_skia::GradientStop::new(1.0, skia_color(bottom.1)),
284            ];
285            let Some(shader) = tiny_skia::LinearGradient::new(
286                tiny_skia::Point::from_xy(0.0, top.0),
287                tiny_skia::Point::from_xy(0.0, bottom.0),
288                stops,
289                tiny_skia::SpreadMode::Pad,
290                tiny_skia::Transform::identity(),
291            ) else {
292                return;
293            };
294            let paint = tiny_skia::Paint { anti_alias: true, shader, ..tiny_skia::Paint::default() };
295            pixmap.fill_path(&path, &paint, tiny_skia::FillRule::Winding, transform, mask);
296        }
297        Layer::Dashed { path, width, dash, color } => {
298            let Some(path) = build_path(path) else { return };
299            let paint = paint_of(*color);
300            let stroke = tiny_skia::Stroke {
301                dash: tiny_skia::StrokeDash::new(vec![*dash, *dash], 0.0),
302                ..round_stroke(*width)
303            };
304            pixmap.stroke_path(&path, &paint, &stroke, transform, mask);
305        }
306    }
307}
308
309fn rasterize_all<'a>(
310    layers: impl Iterator<Item = LayerRef<'a>>,
311    rect: Rect,
312    scale: f32,
313) -> Option<ImageData> {
314    let scale = scale.min(MAX_SIDE / rect.width().max(1.0)).min(MAX_SIDE / rect.height().max(1.0));
315    let width = (rect.width() * scale).ceil().max(1.0) as u32;
316    let height = (rect.height() * scale).ceil().max(1.0) as u32;
317    let mut pixmap = tiny_skia::Pixmap::new(width, height)?;
318    let transform = tiny_skia::Transform::from_translate(-rect.x(), -rect.y()).post_scale(scale, scale);
319    for layer in layers {
320        match layer {
321            LayerRef::Shape(shape) => draw_shape(&mut pixmap, shape, transform, None),
322            LayerRef::Layer(layer) => draw_layer(&mut pixmap, layer, transform, None),
323        }
324    }
325    // The pixmap is premultiplied; images hold straight alpha.
326    let mut pixels = pixmap.take();
327    for pixel in pixels.as_chunks_mut::<4>().0 {
328        let alpha = pixel[3];
329        if alpha != 0 && alpha != 255 {
330            for channel in &mut pixel[..3] {
331                *channel = (u16::from(*channel) * 255 / u16::from(alpha)).min(255) as u8;
332            }
333        }
334    }
335    ImageData::from_rgba8(width, height, pixels)
336}
337
338fn paint_of(color: Color) -> tiny_skia::Paint<'static> {
339    let mut paint = tiny_skia::Paint { anti_alias: true, ..tiny_skia::Paint::default() };
340    paint.set_color_rgba8(
341        (color.r.clamp(0.0, 1.0) * 255.0).round() as u8,
342        (color.g.clamp(0.0, 1.0) * 255.0).round() as u8,
343        (color.b.clamp(0.0, 1.0) * 255.0).round() as u8,
344        (color.a.clamp(0.0, 1.0) * 255.0).round() as u8,
345    );
346    paint
347}
348
349fn build_path(segments: &[PathSegment]) -> Option<tiny_skia::Path> {
350    let mut builder = tiny_skia::PathBuilder::new();
351    for segment in segments {
352        match segment {
353            PathSegment::Move(p) => builder.move_to(p.x, p.y),
354            PathSegment::Line(p) => builder.line_to(p.x, p.y),
355            PathSegment::Cubic(a, b, c) => builder.cubic_to(a.x, a.y, b.x, b.y, c.x, c.y),
356            PathSegment::Close => builder.close(),
357        }
358    }
359    builder.finish()
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    const RED: Color = Color::rgba(1.0, 0.0, 0.0, 1.0);
367
368    fn square() -> Shape {
369        Shape {
370            path: vec![
371                PathSegment::Move(Point::new(10.0, 10.0)),
372                PathSegment::Line(Point::new(20.0, 10.0)),
373                PathSegment::Line(Point::new(20.0, 20.0)),
374                PathSegment::Line(Point::new(10.0, 20.0)),
375                PathSegment::Close,
376            ],
377            fill: Some(RED),
378            stroke: None,
379        }
380    }
381
382    fn pixel(image: &ImageData, x: u32, y: u32) -> [u8; 4] {
383        let at = ((y * image.width() + x) * 4) as usize;
384        let p = &image.pixels()[at..at + 4];
385        [p[0], p[1], p[2], p[3]]
386    }
387
388    #[test]
389    fn shapes_land_where_the_page_says_at_the_scale() {
390        let image = rasterize(&[square()], Rect::new(10.0, 10.0, 20.0, 20.0), 2.0).expect("an image");
391        assert_eq!((image.width(), image.height()), (40, 40));
392        assert_eq!(pixel(&image, 10, 10), [255, 0, 0, 255], "inside");
393        assert_eq!(pixel(&image, 30, 30)[3], 0, "outside");
394    }
395
396    #[test]
397    fn translucent_colors_come_out_straight() {
398        let mut shape = square();
399        shape.fill = Some(Color::rgba(1.0, 0.0, 0.0, 0.5));
400        let image = rasterize(&[shape], Rect::new(10.0, 10.0, 20.0, 20.0), 1.0).expect("an image");
401        let [r, _, _, a] = pixel(&image, 5, 5);
402        assert!(r >= 254, "{r}");
403        assert!((126..=129).contains(&a), "{a}");
404    }
405
406    #[test]
407    fn a_huge_scale_is_held_back() {
408        let image = rasterize(&[square()], Rect::new(0.0, 0.0, 800.0, 800.0), 100.0).expect("an image");
409        assert!(image.width() <= 4096);
410    }
411
412    #[test]
413    fn a_gradient_goes_from_its_top_color_to_its_bottom_color() {
414        let layer = Layer::Gradient {
415            path: rectangle(Rect::new(0.0, 0.0, 10.0, 100.0), 0.0),
416            top: (0.0, Color::rgba(1.0, 0.0, 0.0, 0.8)),
417            bottom: (100.0, Color::rgba(1.0, 0.0, 0.0, 0.1)),
418        };
419        let image = rasterize_layers(&[layer], Rect::new(0.0, 0.0, 10.0, 100.0), 1.0).expect("an image");
420        let (high, low) = (pixel(&image, 5, 2)[3], pixel(&image, 5, 97)[3]);
421        assert!(high > 190 && low < 40, "{high} and {low}");
422    }
423
424    #[test]
425    fn a_dashed_line_has_gaps() {
426        let layer = Layer::Dashed {
427            path: vec![PathSegment::Move(Point::new(0.0, 5.0)), PathSegment::Line(Point::new(40.0, 5.0))],
428            width: 2.0,
429            dash: 8.0,
430            color: RED,
431        };
432        let image = rasterize_layers(&[layer], Rect::new(0.0, 0.0, 40.0, 10.0), 1.0).expect("an image");
433        assert!(pixel(&image, 4, 5)[3] > 200, "a dash");
434        assert_eq!(pixel(&image, 12, 5)[3], 0, "a gap");
435    }
436
437    #[test]
438    fn a_clipped_layer_shows_only_inside_its_rectangle() {
439        let area = Rect::new(0.0, 0.0, 40.0, 40.0);
440        let layer = Layer::Clipped {
441            rect: Rect::new(0.0, 0.0, 40.0, 15.0),
442            layer: Box::new(Layer::Shape(Shape::filled(rectangle(area, 0.0), RED))),
443        };
444        let image = rasterize_layers(&[layer], area, 1.0).expect("an image");
445        assert_eq!(pixel(&image, 5, 5)[3], 255, "inside");
446        assert_eq!(pixel(&image, 5, 30)[3], 0, "outside");
447    }
448
449    #[test]
450    fn an_empty_shape_is_skipped() {
451        let empty = Shape { path: Vec::new(), fill: Some(RED), stroke: None };
452        assert!(rasterize(&[empty], Rect::new(0.0, 0.0, 10.0, 10.0), 1.0).is_some());
453    }
454}