New nodes: Sum, Average, Minimum, Maximum, Any, All (#4344)

Co-authored-by: Dennis Kobert <dennis@kobert.dev>
This commit is contained in:
Keavon Chambers
2026-09-15 14:35:27 +02:00
committed by Dennis Kobert
co-authored by Dennis Kobert
parent 04d6c0d5cf
commit 20eb5ccb8a
20 changed files with 1174 additions and 664 deletions
+5 -141
View File
@@ -2,17 +2,17 @@ use crate::record::Inherited;
use core_types::arena::Arena;
use core_types::attribute::{Attr, EditorLayerPath, Name0, Named, Opacity, OpacityFill, Transform as TransformAttr, WireValue};
use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::extent::{ExtentIn, LevelIn, ListIn, ValueIn};
use core_types::gpoll::{ErrorKind, Extent, GPoll, GraphError, Interrupt, Level};
use core_types::extent::{LevelIn, ListIn, ValueIn};
use core_types::gpoll::{ErrorKind, Extent, GPoll, GraphError, Interrupt};
use core_types::list::List;
use core_types::node::Lane;
use core_types::registry::types::{Angle, SignedInteger};
use core_types::registry::types::Angle;
use core_types::uuid::NodeId;
use core_types::{ATTR_EDITOR_LAYER_PATH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, CacheHash, Color, Ctx, DeriveCtx, ExtractIndex, InjectIndex, ModifyIndex};
use core_types::{ATTR_EDITOR_LAYER_PATH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, Color, Ctx, ExtractIndex, InjectIndex};
use glam::{DAffine2, DVec2};
use graphic_types::graphic::{Graphic, GraphicLevel, RowStep, TryFromGraphic, walk_vector_rows};
use graphic_types::markers::{EditorMergedLayers, Fill, Stroke as StrokeAttr};
use graphic_types::{ATTR_FILL, ATTR_STROKE, Artboard, Vector};
use graphic_types::{ATTR_FILL, ATTR_STROKE, Vector};
use raster_types::{CPU, GPU, Raster};
use vector_types::gradient::{GradientSpreadMethod, GradientType as GradientTypeValue};
use vector_types::{Gradient, GradientStop, ReferencePoint};
@@ -25,90 +25,6 @@ fn arena_exhausted() -> Interrupt {
.into()
}
/// Resolves a signed index over `total` lanes: negatives count from the end,
/// out of range resolves to nothing.
fn resolve_index(index: f64, total: u64) -> Option<u64> {
let index = index as i64;
match index < 0 {
true => total.checked_sub(index.unsigned_abs()),
false => ((index as u64) < total).then_some(index as u64),
}
}
/// Returns the list with the item at the specified index removed.
/// If no value exists at that index, the list is returned unchanged.
#[node_macro::node(category("General"), name("Remove at Index"), extent(omit_element_extent))]
pub fn remove_at_index<T>(
ctx: impl Ctx + ModifyIndex + Copy,
/// The list of data.
list: impl Node<Context<'_>, Output = T>,
/// The index of the item to remove, starting from 0 for the first item. Negative indices count backwards from the end of the list, starting from -1 for the last item.
index: SignedInteger,
) -> Result<T, Interrupt> {
let total = match list.extent(ctx, Level::Total) {
GPoll::Final(Extent::Exactly(count)) => count as u64,
GPoll::Pending => return Err(Interrupt::Pending),
_ => return Err(GraphError::new("omit over a non-exact extent").into()),
};
let lane = ctx.index();
let source = match resolve_index(index, total) {
Some(omitted) if lane >= omitted => lane + 1,
_ => lane,
};
let mut shifted = *ctx;
shifted.set_index(source);
list.eval(&shifted)
}
fn omit_element_extent(list: ExtentIn<'_>, index: ValueIn<'_, f64>, level: LevelIn) -> GPoll<Extent> {
match level.top() {
true => index.get().zip(list.at(level)).map(|(index, extent)| match extent {
Extent::Exactly(count) if resolve_index(index, count as u64).is_some() => Extent::Exactly(count - 1),
extent => extent,
}),
false => list.at(level),
}
}
/// Returns the bare element (without the item's attributes) at the specified index in a `List`.
/// Use this when downstream nodes want just the inner value rather than a `List` containing a single item.
/// If no value exists at that index, the element type's default is returned.
#[node_macro::node(category("General"), name("Item at Index"))]
pub fn item_at_index<T: Clone + Default + Send + Sync + CacheHash + 'static>(
_: impl Ctx,
/// The `List` of data to extract from.
#[implementations(String, f64, NodeId, Color, Gradient, Vector, Raster<CPU>, Graphic, Artboard)]
list: IList<T>,
/// The index of the item to retrieve, starting from 0 for the first item. Negative indices count backwards from the end of the list, starting from -1 for the last item.
index: SignedInteger,
) -> T {
resolve_index(index, list.len() as u64).map(|resolved| list.element_ref(resolved as usize).clone()).unwrap_or_default()
}
/// One subgraph invocation per content row, the row riding as a vararg, with
/// the subgraph's lanes concatenated into one flat level. The level reports a
/// lower bound; consumers drain to the past-end signal.
#[node_macro::node(category("General"))]
pub fn map<Row: Clone + Send + Sync + CacheHash + 'static, T>(
ctx: impl Ctx + DeriveCtx + ExtractIndex + InjectIndex + Copy,
#[implementations(Graphic, Vector, Raster<CPU>, Color, Gradient, String)] content: IList<Row>,
mapped: impl Node<Context<'_>, Output = IList<T>>,
) -> Result<IList<T>, Interrupt> {
let mut remaining = ctx.index();
for row in 0..content.len() {
let item = crate::record::vararg_row(content, row);
let scoped = ctx.push_vararg(&item);
let lanes = mapped.inner_extent_at(&scoped.ctx(), row as u64)?;
if remaining >= lanes {
remaining -= lanes;
continue;
}
let mut frame = core_types::context::IndexLink { index: 0, outer: None };
return mapped.eval(&scoped.ctx().push_level(&mut frame, row as u64, remaining));
}
Err(GraphError::past_end().into())
}
/// The reflection transform the mirror applies, or nothing when the content
/// has no rectangular bounds (the legacy passthrough case).
fn mirror_reflection<T>(legacy: &List<T>, relative_to_bounds: ReferencePoint, offset: f64, angle: f64) -> Option<DAffine2>
@@ -261,7 +177,6 @@ fn mirror_vector_extent(
}
}
pub use _map_mod::map_entries;
pub use _mirror_vector_mod::mirror_vector_entries;
/// `node_path` with its trailing entry dropped: the containing network's path, which is also a unique
@@ -344,57 +259,6 @@ attribute_reads! {
read_spread_method_attribute: GradientSpreadMethod => GradientSpreadMethod;
}
/// Joins two levels of the same type, the base's lanes followed by the new's.
#[node_macro::node(category("General"), extent(extend_extent))]
pub fn extend<T>(
ctx: impl Ctx + ExtractIndex + InjectIndex + Copy,
/// The input whose lanes appear at the start of the extended level.
base: impl Node<Context<'_>, Output = T>,
/// The input whose lanes appear at the end of the extended level.
#[expose]
new: impl Node<Context<'_>, Output = T>,
) -> Result<T, Interrupt> {
let split = match base.extent(ctx, Level::Total) {
GPoll::Final(Extent::Exactly(count)) => count as u64,
// A scalar side joins the concat as a single lane, per `Extent::sum`.
GPoll::Final(Extent::Free) => 1,
GPoll::Pending => return Err(Interrupt::Pending),
_ => return Err(GraphError::new("extend over a non-exact base extent").into()),
};
let lane = ctx.index();
match lane < split {
true => base.eval(ctx),
false => {
let mut shifted = *ctx;
shifted.set_index(lane - split);
new.eval(&shifted)
}
}
}
/// The top level sums both sides; inner levels must agree (rectangular), a
/// free side or a side with no top-level lanes defers to the other.
fn extend_extent(base: ExtentIn<'_>, new: ExtentIn<'_>, level: LevelIn) -> GPoll<Extent> {
match level.top() {
true => Extent::sum(base.at(level), new.at(level)),
false => base.at(level).zip(new.at(level)).and_then(|extents| match extents {
(Extent::Free, other) | (other, Extent::Free) => GPoll::Final(other),
(base_inner, new_inner) if base_inner == new_inner => GPoll::Final(base_inner),
(base_inner, new_inner) => {
let top = LevelIn {
level: level.depth - 1,
depth: level.depth,
};
match (base.at(top), new.at(top)) {
(GPoll::Final(Extent::Exactly(0)), _) => GPoll::Final(new_inner),
(_, GPoll::Final(Extent::Exactly(0))) => GPoll::Final(base_inner),
_ => GPoll::error("extend inner extents differ"),
}
}
}),
}
}
/// Nests the input graphical content in a wrapper graphic. This essentially "groups" the input.
/// The wrapped run keeps the level's element type, so the legacy boundary can
/// lower a wrapped vector level to the bare typed graphic the pre-flip wrap made.
+5 -57
View File
@@ -223,15 +223,16 @@ fn flatten_levels_extent(content: ExtentIn<'_>, level: LevelIn) -> GPoll<Extent>
#[cfg(test)]
mod tests {
use super::*;
use crate::graphic::{ColorsToGradientNode, FlattenColorNode, FlattenGraphicNode, MapNode, WrapGraphicNode, flatten_color_layout_meta, flatten_graphic_layout_meta, wrap_graphic_layout_meta};
use core_types::SourceId;
use crate::graphic::{ColorsToGradientNode, FlattenColorNode, FlattenGraphicNode, WrapGraphicNode, flatten_color_layout_meta, flatten_graphic_layout_meta, wrap_graphic_layout_meta};
use core_types::arena::Arena;
use core_types::attribute::Attribute as AttributeMarker;
use core_types::context::{ContextImpl, EvalScope, ExtractArena};
use core_types::context::{ContextImpl, ExtractArena};
use core_types::list::{Item, List};
use core_types::node::Node;
use core_types::record::test_fixtures::*;
use core_types::record::{self, FrameClaim, Layout, RecordSource, Served};
use core_types::value::ValueSource;
use graphene_core::list::{MapNode, map_entries};
struct GraphicSource {
layout: Layout,
@@ -265,59 +266,6 @@ mod tests {
&self.layout
}
}
/// Writes a field at the layout's resolved offset, the wiring-proven pairing
/// a generated node performs.
fn write_field_at<T: Copy + 'static>(frame: &mut FrameClaim<'_, '_>, layout: &Layout, name: &str, level: u8, value: T) {
let field = layout
.fields
.iter()
.find(|field| field.name == name && field.level == level)
.expect("the layout carries the written field");
assert_eq!(field.type_id, std::any::TypeId::of::<T>(), "the field was declared at this value type");
// SAFETY: the offset is this layout's own, at the field's declared type.
unsafe { frame.attr_at(field.offset, value) };
}
/// [`write_field_at`] for a census marker at level 0.
fn write_attr_at<A: core_types::attribute::Attribute>(frame: &mut FrameClaim<'_, '_>, layout: &Layout, value: A::Value<'static>)
where
A::Value<'static>: Copy + 'static,
{
write_field_at(frame, layout, A::NAME, 0, value);
}
fn scope_fixture<'a>(generations: &'a [(SourceId, u64)], arena: &'a Arena) -> EvalScope<'a> {
EvalScope::new(Some(0.5), None, None, generations, arena)
}
fn install<N: Node<ContextImpl<'static>>>(mut node: N, meta: record::LayoutMeta, inputs: &[Option<&Layout>]) -> N {
// The fixtures wire constants into every eager input, which the compiler
// pass records as lane-invariant.
let resolved = record::RecordLayout {
named_writes: Vec::new(),
named_reads: Vec::new(),
named_read_defaults: Vec::new(),
lane_invariant: u32::MAX,
..meta.resolve(inputs)
};
<N as Node<ContextImpl<'static>>>::set_layout(&mut node, resolved);
node
}
fn install_flip<N: Node<ContextImpl<'static>>>(mut node: N, layout: &Layout) -> N {
let bundle = record::RecordLayout {
named_writes: Vec::new(),
named_reads: Vec::new(),
named_read_defaults: Vec::new(),
frame_bytes: layout.frame_bytes(),
plan: Vec::new(),
layout: layout.clone(),
lane_invariant: u32::MAX,
};
<N as Node<ContextImpl<'static>>>::set_layout(&mut node, bundle);
node
}
fn graphic_layout() -> Layout {
Layout::default().with_writes(1, record::element_write_hashed::<Graphic>(), &[record::FieldWrite::of::<Transform>(0)])
}
@@ -568,7 +516,7 @@ mod tests {
#[test]
fn flat_map_registers_one_row_per_content_type() {
let entries = crate::graphic::map_entries();
let entries = map_entries();
assert_eq!(entries.len(), 6, "one registry row per content implementation");
let content_types: Vec<core_types::Type> = entries.iter().map(|entry| entry.io.inputs[0].clone()).collect();
assert_eq!(content_types[0], core_types::registry::record_source_type::<Graphic>());