use std::sync::Arc;
use std::sync::OnceLock;
use ezu_graph::{
schema_frag, BuiltNode, Connection, EvalCtx, EvalError, FactoryCtx, FactoryError, Node,
NodeFactory, PortKind, PortSpec, PortValue, RasterBuf,
};
use serde_json::Value;
use xxhash_rust::xxh3::Xxh3;
use crate::nodes::common::{
raster_or_sprite_output, unwrap_raster_or_sprite, wrap_raster_like, ACCEPTS_RASTER_OR_SPRITE,
};
use super::blend::div255;
struct StackNode {
ports: Vec<PortSpec>,
}
impl Node for StackNode {
fn op_name(&self) -> &'static str {
"stack"
}
fn inputs(&self) -> &[PortSpec] {
&self.ports
}
fn output(&self, input_kinds: &[Option<PortKind>]) -> PortKind {
raster_or_sprite_output(input_kinds)
}
fn eval(
&self,
_ctx: &EvalCtx<'_>,
inputs: &[Option<PortValue>],
) -> Result<PortValue, EvalError> {
let base_in = inputs
.first()
.and_then(Option::as_ref)
.ok_or_else(|| EvalError::MissingInput("layers[0]".into()))?;
let (base, kind) = unwrap_raster_or_sprite(base_in, "layers[0]")?;
let mut layers = Vec::with_capacity(inputs.len().saturating_sub(1));
for (li, slot) in inputs.iter().enumerate().skip(1) {
let over_in = slot
.as_ref()
.ok_or_else(|| EvalError::MissingInput(format!("layers[{li}]")))?;
layers.push(unwrap_raster_or_sprite(over_in, "layers")?.0);
}
match fold_over(&base, &layers)? {
Some(buf) => Ok(wrap_raster_like(Arc::new(buf), kind)),
None => Ok(base_in.clone()),
}
}
fn param_hash(&self, h: &mut Xxh3) {
h.update(b"stack");
h.update(&(self.ports.len() as u64).to_le_bytes());
}
}
fn fold_over(base: &RasterBuf, layers: &[Arc<RasterBuf>]) -> Result<Option<RasterBuf>, EvalError> {
let (w, h) = (base.width, base.height);
let mut acc: Option<RasterBuf> = None;
for over in layers {
if over.width != w || over.height != h {
return Err(EvalError::Other("stack: layer size mismatch".into()));
}
if over.is_blank() {
continue;
}
let dst = acc.get_or_insert_with(|| base.clone());
composite_over_inplace(dst, over);
}
Ok(acc)
}
fn composite_over_inplace(acc: &mut RasterBuf, over: &RasterBuf) {
for (d, s) in acc
.pixels
.as_chunks_mut::<4>()
.0
.iter_mut()
.zip(over.pixels.as_chunks::<4>().0)
{
let inv = 255 - s[3] as u32;
for c in 0..4 {
d[c] = (s[c] as u32 + div255(d[c] as u32 * inv)) as u8;
}
}
}
fn layer_port_name(ix: usize) -> &'static str {
static POOL: OnceLock<std::sync::Mutex<Vec<&'static str>>> = OnceLock::new();
let mut pool = POOL
.get_or_init(|| std::sync::Mutex::new(Vec::new()))
.lock()
.expect("stack port-name pool poisoned");
while pool.len() <= ix {
let name: &'static str = Box::leak(format!("layers[{}]", pool.len()).into_boxed_str());
pool.push(name);
}
pool[ix]
}
pub(super) struct StackFactory;
impl NodeFactory for StackFactory {
fn op_name(&self) -> &'static str {
"stack"
}
fn build(
&self,
fields: &serde_json::Map<String, Value>,
_ctx: &FactoryCtx<'_>,
) -> Result<BuiltNode, FactoryError> {
let arr = fields
.get("layers")
.ok_or_else(|| FactoryError::MissingField("layers".into()))?
.as_array()
.ok_or_else(|| FactoryError::BadField {
field: "layers".into(),
msg: "expected an array of `@node-ref` strings".into(),
})?;
if arr.is_empty() {
return Err(FactoryError::BadField {
field: "layers".into(),
msg: "needs at least one layer".into(),
});
}
let mut ports = Vec::with_capacity(arr.len());
let mut connections = Vec::with_capacity(arr.len());
for (ix, entry) in arr.iter().enumerate() {
let s = entry.as_str().ok_or_else(|| FactoryError::BadField {
field: "layers".into(),
msg: format!("entry {ix}: expected a `@node-ref` string"),
})?;
let id = match ezu_style::FieldRef::classify(s) {
ezu_style::FieldRef::Node(id) => id.to_string(),
_ => {
return Err(FactoryError::BadField {
field: "layers".into(),
msg: format!("entry {ix}: expected `@node-ref`, got `{s}`"),
})
}
};
let name = layer_port_name(ix);
ports.push(PortSpec {
name,
accepts: ACCEPTS_RASTER_OR_SPRITE,
optional: false,
});
connections.push(Connection {
port: name.into(),
src: id,
});
}
Ok(BuiltNode {
node: Box::new(StackNode { ports }),
connections,
})
}
fn schema(&self) -> Value {
serde_json::json!({
"description": "Composite an ordered list of raster `layers` bottom-to-top with plain source-over (equivalent to a chain of `blend` nodes with `mode: normal`, `composite: over`, `opacity: 1`), folded into one pass over a single accumulator. Fully-transparent layers are skipped. For per-layer opacity, blend modes, clipping, masks, or the eraser composite, use `blend`.",
"properties": {
"layers": {
"type": "array",
"minItems": 1,
"items": schema_frag::node_ref(),
"description": "Raster (or sprite) layers, bottom first. The output mirrors the bottom layer's kind."
}
},
"required": ["layers"],
})
}
}
ezu_graph::submit_node!(StackFactory);
#[cfg(test)]
mod tests {
use super::*;
use ezu_graph::{CanvasInfo, NoAssets, ParamValues, TileId};
struct Lcg(u64);
impl Lcg {
fn next(&mut self) -> u32 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1);
(self.0 >> 33) as u32
}
fn byte(&mut self) -> u8 {
(self.next() & 0xff) as u8
}
}
fn random_premul(w: u32, h: u32, seed: u64) -> RasterBuf {
let mut rng = Lcg(seed);
let mut buf = RasterBuf::new(w, h);
for px in buf.pixels.as_chunks_mut::<4>().0 {
let a = rng.byte();
px[0] = rng.byte().min(a);
px[1] = rng.byte().min(a);
px[2] = rng.byte().min(a);
px[3] = a;
}
buf
}
fn chain_reference(layers: &[RasterBuf]) -> RasterBuf {
let mut acc = layers[0].clone();
for over in &layers[1..] {
if over.is_blank() {
continue;
}
composite_over_inplace(&mut acc, over);
}
acc
}
fn fold(layers: &[RasterBuf]) -> RasterBuf {
let arcs: Vec<Arc<RasterBuf>> = layers[1..].iter().cloned().map(Arc::new).collect();
match fold_over(&layers[0], &arcs).unwrap() {
Some(buf) => buf,
None => layers[0].clone(),
}
}
#[test]
fn two_layers_match_blend_chain() {
let a = random_premul(31, 17, 0x1111);
let b = random_premul(31, 17, 0x2222);
assert_eq!(
fold(&[a.clone(), b.clone()]).pixels,
chain_reference(&[a, b]).pixels
);
}
#[test]
fn many_layers_match_blend_chain() {
let layers: Vec<RasterBuf> = (0..8)
.map(|i| random_premul(23, 29, 0xa000 + i as u64))
.collect();
assert_eq!(fold(&layers).pixels, chain_reference(&layers).pixels);
}
#[test]
fn blank_layers_are_skipped() {
let a = random_premul(19, 13, 0x3333);
let blank = RasterBuf::new(19, 13);
let b = random_premul(19, 13, 0x4444);
assert_eq!(
fold(&[a.clone(), blank.clone(), b.clone(), blank]).pixels,
chain_reference(&[a, b]).pixels
);
}
#[test]
fn blank_base_promotes_first_opaque_layer() {
let blank = RasterBuf::new(15, 15);
let b = random_premul(15, 15, 0x5555);
assert_eq!(fold(&[blank, b.clone()]).pixels, b.pixels);
}
#[test]
fn single_layer_is_returned_verbatim() {
let a = random_premul(11, 7, 0x6666);
let arcs: Vec<Arc<RasterBuf>> = Vec::new();
assert!(fold_over(&a, &arcs).unwrap().is_none());
}
#[test]
fn size_mismatch_errors() {
let a = random_premul(8, 8, 0x1);
let b = random_premul(9, 8, 0x2);
let arcs = vec![Arc::new(b)];
assert!(fold_over(&a, &arcs).is_err());
}
#[test]
fn sprite_kind_is_propagated() {
let ports: Vec<PortSpec> = (0..2)
.map(|ix| PortSpec {
name: layer_port_name(ix),
accepts: ACCEPTS_RASTER_OR_SPRITE,
optional: false,
})
.collect();
let node = StackNode { ports };
let a = random_premul(9, 9, 0x7777);
let b = random_premul(9, 9, 0x8888);
let inputs = vec![
Some(PortValue::Sprite(Arc::new(a))),
Some(PortValue::Sprite(Arc::new(b))),
];
let assets = NoAssets;
let params = ParamValues::new();
let ctx = EvalCtx {
tile: TileId { z: 0, x: 0, y: 0 },
canvas: CanvasInfo::square(9, 0),
assets: &assets,
params: ¶ms,
rng_seed: 0,
};
let out = node.eval(&ctx, &inputs).unwrap();
assert!(matches!(out, PortValue::Sprite(_)), "kind must mirror base");
}
}