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}