Skip to main content

ezu_paint/
lib.rs

1//! Paint MVT features onto a raster canvas.
2//!
3//! Three painting primitives are exposed:
4//!
5//! - [`paint_polygons`] — `tiny-skia` solid fill + optional outline +
6//!   `libblur` gaussian blur. Fast path for large patches.
7//! - [`paint_polygons_dabs`] — `hokusai` scatter-dab fill with
8//!   world-deterministic jitter (seamless across tile boundaries).
9//! - [`paint_lines`] — `hokusai::Brush::stroke_to` along polylines.
10//!
11//! These are the building blocks for the graph nodes in [`nodes`];
12//! the host-side glue (PNG encoding, asset loading) lives in [`host`].
13//!
14//! All painting happens on a [`Canvas`] that optionally wraps a
15//! **padded** buffer (`tile_size + 2 * pad`). Paint operations work in
16//! the padded space; cropping happens at the host boundary.
17
18pub mod brush;
19pub mod builtin;
20pub mod dabs;
21pub mod render;
22pub mod strokes;
23
24pub use brush::BrushDefaults;
25pub use dabs::{paint_polygons_dabs, DabFillStyle};
26pub use hokusai::color::RgbaF32;
27pub use hokusai::Brush;
28#[cfg(feature = "parallel")]
29pub use strokes::paint_lines_parallel;
30pub use strokes::{paint_lines, LineStrokeStyle};
31
32use ezu_features::Polygon;
33use tiny_skia::{
34    Color, FillRule, Paint, PathBuilder, Pixmap, PixmapPaint, PremultipliedColorU8, Stroke,
35    Transform,
36};
37
38/// A raster canvas backed by a premultiplied RGBA `Pixmap`.
39///
40/// The canvas optionally has a padding ring around the tile area; all paint
41/// operations work in the padded coordinate space, and [`encode_png`] crops
42/// back down to the actual tile.
43pub mod host;
44pub mod nodes;
45
46pub struct Canvas {
47    pixmap: Pixmap,
48    tile_w: u32,
49    tile_h: u32,
50    pad: u32,
51}
52
53impl Canvas {
54    /// Convenience: padded canvas with `pad = 0`.
55    /// Returns `None` if `tile_w == 0` or `tile_h == 0`, or if the
56    /// pixel buffer would overflow allocation.
57    pub fn new(tile_w: u32, tile_h: u32) -> Option<Self> {
58        Self::new_padded(tile_w, tile_h, 0)
59    }
60
61    /// Create a canvas whose internal buffer is `tile_w + 2*pad` × `tile_h + 2*pad`.
62    ///
63    /// Returns `None` if the resulting padded dimensions are zero or
64    /// would overflow allocation.
65    pub fn new_padded(tile_w: u32, tile_h: u32, pad: u32) -> Option<Self> {
66        let pw = tile_w.checked_add(2u32.checked_mul(pad)?)?;
67        let ph = tile_h.checked_add(2u32.checked_mul(pad)?)?;
68        let pixmap = Pixmap::new(pw, ph)?;
69        Some(Self {
70            pixmap,
71            tile_w,
72            tile_h,
73            pad,
74        })
75    }
76
77    /// Fill the entire (padded) canvas with a solid color, e.g. paper background.
78    pub fn fill(&mut self, color: Color) {
79        self.pixmap.fill(color);
80    }
81
82    pub fn pixmap(&self) -> &Pixmap {
83        &self.pixmap
84    }
85
86    pub fn pixmap_mut(&mut self) -> &mut Pixmap {
87        &mut self.pixmap
88    }
89
90    /// Width of the internal (padded) buffer. Use this when sizing layer
91    /// pixmaps, masks, or scatter grids.
92    pub fn width(&self) -> u32 {
93        self.tile_w + 2 * self.pad
94    }
95
96    /// Height of the internal (padded) buffer.
97    pub fn height(&self) -> u32 {
98        self.tile_h + 2 * self.pad
99    }
100
101    pub fn tile_width(&self) -> u32 {
102        self.tile_w
103    }
104
105    pub fn tile_height(&self) -> u32 {
106        self.tile_h
107    }
108
109    pub fn pad(&self) -> u32 {
110        self.pad
111    }
112
113    /// Consume the canvas and return its underlying `Pixmap`. Callers
114    /// can then call `Pixmap::take` to recover the raw `Vec<u8>` without
115    /// copying — paint nodes use this to hand a freshly-painted buffer
116    /// to the graph layer without an intermediate `to_vec`.
117    pub fn into_pixmap(self) -> Pixmap {
118        self.pixmap
119    }
120}
121
122/// Style for a watercolor polygon layer.
123#[derive(Debug, Clone)]
124pub struct WatercolorStyle {
125    pub fill: Color,
126    /// Optional darker outline color giving the "wet edge" feel.
127    pub edge: Option<Color>,
128    pub edge_width: f32,
129    /// Gaussian blur sigma applied to the layer before compositing.
130    pub blur_sigma: f32,
131}
132
133impl Default for WatercolorStyle {
134    fn default() -> Self {
135        Self {
136            fill: Color::from_rgba8(150, 180, 210, 180),
137            edge: Some(Color::from_rgba8(80, 110, 150, 220)),
138            edge_width: 1.5,
139            blur_sigma: 1.2,
140        }
141    }
142}
143
144/// Paint a collection of MVT polygons onto a fresh transparent layer, blur it,
145/// and composite it over `canvas` (source-over).
146///
147/// Coordinates are MVT tile-local (`[0, extent]`, y-down). The polygons are
148/// scaled to tile size and offset by the canvas's padding.
149pub fn paint_polygons(
150    canvas: &mut Canvas,
151    polygons: &[Polygon],
152    extent: u32,
153    style: &WatercolorStyle,
154) {
155    let w = canvas.width();
156    let h = canvas.height();
157    let mut layer = Pixmap::new(w, h).expect("non-zero layer");
158
159    let sx = canvas.tile_w as f32 / extent as f32;
160    let sy = canvas.tile_h as f32 / extent as f32;
161    let ox = canvas.pad as f32;
162    let oy = canvas.pad as f32;
163
164    let mut fill_paint = Paint::default();
165    fill_paint.set_color(style.fill);
166    fill_paint.anti_alias = true;
167
168    let mut edge_paint = Paint::default();
169    if let Some(edge) = style.edge {
170        edge_paint.set_color(edge);
171        edge_paint.anti_alias = true;
172    }
173
174    for poly in polygons {
175        let Some(path) = build_polygon_path(poly, sx, sy, ox, oy) else {
176            continue;
177        };
178        layer.fill_path(
179            &path,
180            &fill_paint,
181            FillRule::EvenOdd,
182            Transform::identity(),
183            None,
184        );
185        if style.edge.is_some() {
186            let stroke = Stroke {
187                width: style.edge_width,
188                ..Stroke::default()
189            };
190            layer.stroke_path(&path, &edge_paint, &stroke, Transform::identity(), None);
191        }
192    }
193
194    if style.blur_sigma > 0.0 {
195        blur_pixmap(&mut layer, style.blur_sigma);
196    }
197
198    canvas.pixmap.draw_pixmap(
199        0,
200        0,
201        layer.as_ref(),
202        &PixmapPaint::default(),
203        Transform::identity(),
204        None,
205    );
206}
207
208pub(crate) fn build_polygon_path(
209    poly: &Polygon,
210    sx: f32,
211    sy: f32,
212    ox: f32,
213    oy: f32,
214) -> Option<tiny_skia::Path> {
215    let mut pb = PathBuilder::new();
216    push_ring(&mut pb, &poly.exterior, sx, sy, ox, oy)?;
217    for hole in &poly.holes {
218        push_ring(&mut pb, hole, sx, sy, ox, oy)?;
219    }
220    pb.finish()
221}
222
223fn push_ring(
224    pb: &mut PathBuilder,
225    ring: &[(i32, i32)],
226    sx: f32,
227    sy: f32,
228    ox: f32,
229    oy: f32,
230) -> Option<()> {
231    if ring.len() < 3 {
232        return None;
233    }
234    let (x0, y0) = ring[0];
235    pb.move_to(x0 as f32 * sx + ox, y0 as f32 * sy + oy);
236    for &(x, y) in &ring[1..] {
237        pb.line_to(x as f32 * sx + ox, y as f32 * sy + oy);
238    }
239    pb.close();
240    Some(())
241}
242
243/// In-place gaussian blur on a tiny-skia `Pixmap` using `libblur`.
244fn blur_pixmap(pixmap: &mut Pixmap, sigma: f32) {
245    let w = pixmap.width() as usize;
246    let h = pixmap.height() as usize;
247    let mut rgba: Vec<u8> = Vec::with_capacity(w * h * 4);
248    for px in pixmap.pixels() {
249        let p = px.demultiply();
250        rgba.extend_from_slice(&[p.red(), p.green(), p.blue(), p.alpha()]);
251    }
252
253    let src_buf = rgba.clone();
254    let src = libblur::BlurImage::borrow(
255        &src_buf,
256        w as u32,
257        h as u32,
258        libblur::FastBlurChannels::Channels4,
259    );
260    let mut dst = libblur::BlurImageMut::borrow(
261        &mut rgba,
262        w as u32,
263        h as u32,
264        libblur::FastBlurChannels::Channels4,
265    );
266    if libblur::gaussian_blur(
267        &src,
268        &mut dst,
269        libblur::GaussianBlurParams::new_from_sigma(sigma as f64),
270        libblur::EdgeMode2D::new(libblur::EdgeMode::Clamp),
271        libblur::ThreadingPolicy::Single,
272        libblur::ConvolutionMode::Exact,
273    )
274    .is_err()
275    {
276        return;
277    }
278
279    let out = pixmap.pixels_mut();
280    for (i, dst) in out.iter_mut().enumerate() {
281        let r = rgba[i * 4];
282        let g = rgba[i * 4 + 1];
283        let b = rgba[i * 4 + 2];
284        let a = rgba[i * 4 + 3];
285        *dst = PremultipliedColorU8::from_rgba(mul(r, a), mul(g, a), mul(b, a), a).unwrap_or_else(
286            || {
287                // Fully-transparent black is always a valid premul color;
288                // this fallback only fires if `from_rgba` ever rejects
289                // its input (it doesn't today).
290                PremultipliedColorU8::from_rgba(0, 0, 0, 0)
291                    .expect("transparent black is a valid premul color")
292            },
293        );
294    }
295}
296
297#[inline]
298fn mul(c: u8, a: u8) -> u8 {
299    ((c as u16 * a as u16 + 127) / 255) as u8
300}
301
302#[derive(Debug, thiserror::Error)]
303pub enum PaintError {
304    #[error("png encode failed")]
305    PngEncode,
306    #[error("webp encode failed: {0}")]
307    WebpEncode(String),
308}