Skip to main content

ezu_graph/
buf.rs

1//! Concrete buffer types flowing along `Raster` edges.
2//!
3//! These are deliberately small and dependency-free so node
4//! implementations from different crates can produce / consume them
5//! without a shared dependency on `tiny-skia` or `hokusai`. Nodes
6//! that wrap those engines do conversions at their boundaries.
7
8use std::any::Any;
9use std::collections::HashMap;
10use std::sync::Arc;
11
12/// RGBA8 raster, sRGB color space, premultiplied alpha. Layout is
13/// row-major, four bytes per pixel `[R, G, B, A]`.
14#[derive(Debug, Clone)]
15pub struct RasterBuf {
16    pub width: u32,
17    pub height: u32,
18    pub pixels: Vec<u8>,
19}
20
21impl RasterBuf {
22    pub fn new(width: u32, height: u32) -> Self {
23        Self {
24            width,
25            height,
26            pixels: vec![0; (width * height * 4) as usize],
27        }
28    }
29
30    pub fn filled(width: u32, height: u32, rgba: [u8; 4]) -> Self {
31        let mut s = Self::new(width, height);
32        for px in s.pixels.chunks_exact_mut(4) {
33            px.copy_from_slice(&rgba);
34        }
35        s
36    }
37
38    /// Whether every byte is zero — i.e. fully transparent everywhere.
39    /// For premultiplied RGBA this means no coverage and no color at all
40    /// (a valid premultiplied pixel with `a == 0` also has `rgb == 0`).
41    /// Scans in `u128`-wide chunks, so a canvas-sized buffer costs only
42    /// tens of microseconds.
43    pub fn is_blank(&self) -> bool {
44        // SAFETY: `align_to` only reinterprets the byte slice; `u128` has
45        // no invalid bit patterns, so every reading is a valid value.
46        let (head, mid, tail) = unsafe { self.pixels.align_to::<u128>() };
47        head.iter().all(|&b| b == 0) && mid.iter().all(|&w| w == 0) && tail.iter().all(|&b| b == 0)
48    }
49
50    /// A shared all-zero raster of the given size.
51    ///
52    /// A style with dozens of layers produces dozens of fully transparent
53    /// rasters on any tile that lacks those features — every one of them
54    /// identical, and each otherwise costing a full padded canvas. Handing
55    /// out one interned buffer per size collapses them into a single
56    /// allocation. The pool keeps at most one buffer per distinct canvas
57    /// size, which in practice means one.
58    ///
59    /// Callers must treat the result as immutable; `RasterBuf` is `Clone`,
60    /// so any consumer needing to write copies first.
61    pub fn blank_shared(width: u32, height: u32) -> Arc<RasterBuf> {
62        let mut pool = blank_pool().lock().unwrap_or_else(|e| e.into_inner());
63        Arc::clone(
64            pool.entry((width, height))
65                .or_insert_with(|| Arc::new(RasterBuf::new(width, height))),
66        )
67    }
68
69    /// Whether `buf` *is* the interned blank for its size — i.e. holding
70    /// it costs nothing, because every other holder points at the same
71    /// allocation. Memory accounting uses this to avoid charging one
72    /// buffer's worth of bytes to each of its dozens of holders.
73    pub fn is_interned_blank(buf: &Arc<RasterBuf>) -> bool {
74        let pool = blank_pool().lock().unwrap_or_else(|e| e.into_inner());
75        pool.get(&(buf.width, buf.height))
76            .is_some_and(|shared| Arc::ptr_eq(shared, buf))
77    }
78
79    pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
80        let i = ((y * self.width + x) * 4) as usize;
81        [
82            self.pixels[i],
83            self.pixels[i + 1],
84            self.pixels[i + 2],
85            self.pixels[i + 3],
86        ]
87    }
88}
89
90/// Interned all-zero rasters, keyed by `(width, height)`.
91type BlankPool = std::sync::Mutex<HashMap<(u32, u32), Arc<RasterBuf>>>;
92
93fn blank_pool() -> &'static BlankPool {
94    static POOL: std::sync::OnceLock<BlankPool> = std::sync::OnceLock::new();
95    POOL.get_or_init(|| std::sync::Mutex::new(HashMap::new()))
96}
97
98/// A sub-rectangle of a sprite atlas: one named icon.
99#[derive(Debug, Clone, Default)]
100pub struct SpriteRect {
101    pub x: u32,
102    pub y: u32,
103    pub width: u32,
104    pub height: u32,
105    /// Device pixels per logical pixel the icon was authored at (a `@2x`
106    /// sprite has `pixel_ratio == 2.0`). Consumers divide by it to get the
107    /// icon's intended display size.
108    pub pixel_ratio: f32,
109    /// Nine-slice metadata for `icon-text-fit`: the `[from, to)` bands of
110    /// image columns (resp. rows) that absorb the stretch, and the part of
111    /// the image the text is fitted into. Empty / `None` means the whole
112    /// image stretches and the whole image is the content box.
113    pub stretch_x: Vec<[u32; 2]>,
114    pub stretch_y: Vec<[u32; 2]>,
115    pub content: Option<[u32; 4]>,
116}
117
118/// A decoded sprite sheet: one atlas image plus a name → sub-rect index.
119/// The runtime counterpart of a `sprite` source — the host builds it from
120/// the atlas PNG and the (inline or fetched) index, and the `icon` node
121/// crops named rects out of it.
122#[derive(Debug)]
123pub struct SpriteSheet {
124    pub atlas: RasterBuf,
125    pub icons: HashMap<String, SpriteRect>,
126}
127
128impl SpriteSheet {
129    /// Crop a named icon out of the atlas into a standalone `RasterBuf`.
130    /// Returns `None` if the name is unknown or its rect falls outside the
131    /// atlas bounds.
132    pub fn crop(&self, name: &str) -> Option<RasterBuf> {
133        let r = self.icons.get(name)?;
134        if r.width == 0
135            || r.height == 0
136            || r.x + r.width > self.atlas.width
137            || r.y + r.height > self.atlas.height
138        {
139            return None;
140        }
141        let mut out = RasterBuf::new(r.width, r.height);
142        let aw = self.atlas.width as usize;
143        for row in 0..r.height {
144            let src = (((r.y + row) as usize * aw) + r.x as usize) * 4;
145            let dst = (row as usize * r.width as usize) * 4;
146            let n = r.width as usize * 4;
147            out.pixels[dst..dst + n].copy_from_slice(&self.atlas.pixels[src..src + n]);
148        }
149        Some(out)
150    }
151}
152
153/// Type-erased value carried on `Features` and `Brush` ports. Concrete
154/// types are a convention between producer and consumer node impls;
155/// downcasts happen inside nodes. The DAG only checks the `PortKind`.
156pub type OpaqueValue = Arc<dyn Any + Send + Sync>;
157
158/// Per-pixel `f32` scalar grid flowing along `ScalarField` ports.
159///
160/// The general carrier for single-channel floating-point data —
161/// elevation, signed distance, scalar noise, slope angle, anything
162/// "one number per pixel". Layout is row-major, one `f32` per pixel.
163/// `width` / `height` MUST match the canvas's `padded_size()` so
164/// consumers can pair samples with the same geometry as their raster
165/// output.
166///
167/// `geo_scale` is populated when the values represent a quantity
168/// measured per real-world distance (e.g. elevation in metres at a
169/// particular latitude). Gradient-based consumers (`hillshade`,
170/// `slope`) read it to compute geographically faithful results.
171/// `None` means the field is unitless / in pixel space — fine for
172/// `color-ramp` style mapping but stylization-only
173/// for gradient ops.
174///
175/// Missing samples (e.g. ocean nodata in some DEMs) surface as
176/// `nodata`; consumers fall back to `0.0` or pass-through.
177#[derive(Debug, Clone)]
178pub struct ScalarField {
179    pub width: u32,
180    pub height: u32,
181    pub values: Arc<[f32]>,
182    pub nodata: Option<f32>,
183    pub geo_scale: Option<GeoScale>,
184}
185
186/// Geographic per-pixel scaling for a `ScalarField`. Filled by the
187/// producer from tile geometry and latitude (Web Mercator's scale is
188/// latitude-dependent), so consumers like `slope` don't need to
189/// re-derive tile geometry.
190#[derive(Debug, Clone, Copy)]
191pub struct GeoScale {
192    pub metres_per_pixel_x: f32,
193    pub metres_per_pixel_y: f32,
194}
195
196impl ScalarField {
197    pub fn sample(&self, x: u32, y: u32) -> f32 {
198        self.values[(y * self.width + x) as usize]
199    }
200
201    /// Real-world metres per pixel along X, or `1.0` when the field
202    /// has no geographic scaling. Lets gradient consumers stay
203    /// branch-free; the fallback is a no-op scaling that produces
204    /// pixel-space gradients — geographically inaccurate but useful
205    /// for stylization over non-DEM inputs.
206    pub fn metres_per_pixel_x(&self) -> f32 {
207        self.geo_scale.map(|g| g.metres_per_pixel_x).unwrap_or(1.0)
208    }
209
210    pub fn metres_per_pixel_y(&self) -> f32 {
211        self.geo_scale.map(|g| g.metres_per_pixel_y).unwrap_or(1.0)
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn sprite_crop_extracts_named_rect() {
221        // 4×2 atlas: left half red, right half green (premultiplied, opaque).
222        let mut atlas = RasterBuf::new(4, 2);
223        for y in 0..2 {
224            for x in 0..4 {
225                let i = ((y * 4 + x) * 4) as usize;
226                let c = if x < 2 {
227                    [255, 0, 0, 255]
228                } else {
229                    [0, 255, 0, 255]
230                };
231                atlas.pixels[i..i + 4].copy_from_slice(&c);
232            }
233        }
234        let mut icons = HashMap::new();
235        icons.insert(
236            "left".to_string(),
237            SpriteRect {
238                x: 0,
239                y: 0,
240                width: 2,
241                height: 2,
242                pixel_ratio: 1.0,
243                ..SpriteRect::default()
244            },
245        );
246        icons.insert(
247            "right".to_string(),
248            SpriteRect {
249                x: 2,
250                y: 0,
251                width: 2,
252                height: 2,
253                pixel_ratio: 1.0,
254                ..SpriteRect::default()
255            },
256        );
257        icons.insert(
258            "oob".to_string(),
259            SpriteRect {
260                x: 3,
261                y: 0,
262                width: 2,
263                height: 2,
264                pixel_ratio: 1.0,
265                ..SpriteRect::default()
266            },
267        );
268        let sheet = SpriteSheet { atlas, icons };
269
270        let right = sheet.crop("right").expect("named icon");
271        assert_eq!((right.width, right.height), (2, 2));
272        assert!(right.pixels.chunks_exact(4).all(|p| p == [0, 255, 0, 255]));
273
274        let left = sheet.crop("left").unwrap();
275        assert!(left.pixels.chunks_exact(4).all(|p| p == [255, 0, 0, 255]));
276
277        // Unknown name / out-of-bounds rect → None.
278        assert!(sheet.crop("missing").is_none());
279        assert!(sheet.crop("oob").is_none());
280    }
281}