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    pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
51        let i = ((y * self.width + x) * 4) as usize;
52        [
53            self.pixels[i],
54            self.pixels[i + 1],
55            self.pixels[i + 2],
56            self.pixels[i + 3],
57        ]
58    }
59}
60
61/// A sub-rectangle of a sprite atlas: one named icon.
62#[derive(Debug, Clone, Default)]
63pub struct SpriteRect {
64    pub x: u32,
65    pub y: u32,
66    pub width: u32,
67    pub height: u32,
68    /// Device pixels per logical pixel the icon was authored at (a `@2x`
69    /// sprite has `pixel_ratio == 2.0`). Consumers divide by it to get the
70    /// icon's intended display size.
71    pub pixel_ratio: f32,
72    /// Nine-slice metadata for `icon-text-fit`: the `[from, to)` bands of
73    /// image columns (resp. rows) that absorb the stretch, and the part of
74    /// the image the text is fitted into. Empty / `None` means the whole
75    /// image stretches and the whole image is the content box.
76    pub stretch_x: Vec<[u32; 2]>,
77    pub stretch_y: Vec<[u32; 2]>,
78    pub content: Option<[u32; 4]>,
79}
80
81/// A decoded sprite sheet: one atlas image plus a name → sub-rect index.
82/// The runtime counterpart of a `sprite` source — the host builds it from
83/// the atlas PNG and the (inline or fetched) index, and the `icon` node
84/// crops named rects out of it.
85#[derive(Debug)]
86pub struct SpriteSheet {
87    pub atlas: RasterBuf,
88    pub icons: HashMap<String, SpriteRect>,
89}
90
91impl SpriteSheet {
92    /// Crop a named icon out of the atlas into a standalone `RasterBuf`.
93    /// Returns `None` if the name is unknown or its rect falls outside the
94    /// atlas bounds.
95    pub fn crop(&self, name: &str) -> Option<RasterBuf> {
96        let r = self.icons.get(name)?;
97        if r.width == 0
98            || r.height == 0
99            || r.x + r.width > self.atlas.width
100            || r.y + r.height > self.atlas.height
101        {
102            return None;
103        }
104        let mut out = RasterBuf::new(r.width, r.height);
105        let aw = self.atlas.width as usize;
106        for row in 0..r.height {
107            let src = (((r.y + row) as usize * aw) + r.x as usize) * 4;
108            let dst = (row as usize * r.width as usize) * 4;
109            let n = r.width as usize * 4;
110            out.pixels[dst..dst + n].copy_from_slice(&self.atlas.pixels[src..src + n]);
111        }
112        Some(out)
113    }
114}
115
116/// Type-erased value carried on `Features` and `Brush` ports. Concrete
117/// types are a convention between producer and consumer node impls;
118/// downcasts happen inside nodes. The DAG only checks the `PortKind`.
119pub type OpaqueValue = Arc<dyn Any + Send + Sync>;
120
121/// Per-pixel `f32` scalar grid flowing along `ScalarField` ports.
122///
123/// The general carrier for single-channel floating-point data —
124/// elevation, signed distance, scalar noise, slope angle, anything
125/// "one number per pixel". Layout is row-major, one `f32` per pixel.
126/// `width` / `height` MUST match the canvas's `padded_size()` so
127/// consumers can pair samples with the same geometry as their raster
128/// output.
129///
130/// `geo_scale` is populated when the values represent a quantity
131/// measured per real-world distance (e.g. elevation in metres at a
132/// particular latitude). Gradient-based consumers (`hillshade`,
133/// `slope`) read it to compute geographically faithful results.
134/// `None` means the field is unitless / in pixel space — fine for
135/// `color-ramp` style mapping but stylization-only
136/// for gradient ops.
137///
138/// Missing samples (e.g. ocean nodata in some DEMs) surface as
139/// `nodata`; consumers fall back to `0.0` or pass-through.
140#[derive(Debug, Clone)]
141pub struct ScalarField {
142    pub width: u32,
143    pub height: u32,
144    pub values: Arc<[f32]>,
145    pub nodata: Option<f32>,
146    pub geo_scale: Option<GeoScale>,
147}
148
149/// Geographic per-pixel scaling for a `ScalarField`. Filled by the
150/// producer from tile geometry and latitude (Web Mercator's scale is
151/// latitude-dependent), so consumers like `slope` don't need to
152/// re-derive tile geometry.
153#[derive(Debug, Clone, Copy)]
154pub struct GeoScale {
155    pub metres_per_pixel_x: f32,
156    pub metres_per_pixel_y: f32,
157}
158
159impl ScalarField {
160    pub fn sample(&self, x: u32, y: u32) -> f32 {
161        self.values[(y * self.width + x) as usize]
162    }
163
164    /// Real-world metres per pixel along X, or `1.0` when the field
165    /// has no geographic scaling. Lets gradient consumers stay
166    /// branch-free; the fallback is a no-op scaling that produces
167    /// pixel-space gradients — geographically inaccurate but useful
168    /// for stylization over non-DEM inputs.
169    pub fn metres_per_pixel_x(&self) -> f32 {
170        self.geo_scale.map(|g| g.metres_per_pixel_x).unwrap_or(1.0)
171    }
172
173    pub fn metres_per_pixel_y(&self) -> f32 {
174        self.geo_scale.map(|g| g.metres_per_pixel_y).unwrap_or(1.0)
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn sprite_crop_extracts_named_rect() {
184        // 4×2 atlas: left half red, right half green (premultiplied, opaque).
185        let mut atlas = RasterBuf::new(4, 2);
186        for y in 0..2 {
187            for x in 0..4 {
188                let i = ((y * 4 + x) * 4) as usize;
189                let c = if x < 2 {
190                    [255, 0, 0, 255]
191                } else {
192                    [0, 255, 0, 255]
193                };
194                atlas.pixels[i..i + 4].copy_from_slice(&c);
195            }
196        }
197        let mut icons = HashMap::new();
198        icons.insert(
199            "left".to_string(),
200            SpriteRect {
201                x: 0,
202                y: 0,
203                width: 2,
204                height: 2,
205                pixel_ratio: 1.0,
206                ..SpriteRect::default()
207            },
208        );
209        icons.insert(
210            "right".to_string(),
211            SpriteRect {
212                x: 2,
213                y: 0,
214                width: 2,
215                height: 2,
216                pixel_ratio: 1.0,
217                ..SpriteRect::default()
218            },
219        );
220        icons.insert(
221            "oob".to_string(),
222            SpriteRect {
223                x: 3,
224                y: 0,
225                width: 2,
226                height: 2,
227                pixel_ratio: 1.0,
228                ..SpriteRect::default()
229            },
230        );
231        let sheet = SpriteSheet { atlas, icons };
232
233        let right = sheet.crop("right").expect("named icon");
234        assert_eq!((right.width, right.height), (2, 2));
235        assert!(right.pixels.chunks_exact(4).all(|p| p == [0, 255, 0, 255]));
236
237        let left = sheet.crop("left").unwrap();
238        assert!(left.pixels.chunks_exact(4).all(|p| p == [255, 0, 0, 255]));
239
240        // Unknown name / out-of-bounds rect → None.
241        assert!(sheet.crop("missing").is_none());
242        assert!(sheet.crop("oob").is_none());
243    }
244}