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}