From 20eb5ccb8ad1112c65e54a035d5d9bc26970c030 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 15 Jul 2026 23:41:46 -0700 Subject: [PATCH] New nodes: Sum, Average, Minimum, Maximum, Any, All (#4344) Co-authored-by: Dennis Kobert --- Cargo.lock | 6 + .../document/document_message_handler.rs | 6 +- .../node_graph/document_node_definitions.rs | 10 +- .../messages/portfolio/document_migration.rs | 23 +- .../tool/tool_messages/artboard_tool.rs | 2 +- .../libraries/core-types/src/record/mod.rs | 2 +- .../core-types/src/record/testkit.rs | 327 ++++++++ .../libraries/core-types/src/registry.rs | 8 + node-graph/nodes/gcore/Cargo.toml | 8 + node-graph/nodes/gcore/src/context.rs | 1 + node-graph/nodes/gcore/src/lib.rs | 1 + node-graph/nodes/gcore/src/list.rs | 725 ++++++++++++++++++ node-graph/nodes/gcore/src/record.rs | 447 +---------- node-graph/nodes/graphic/Cargo.toml | 4 + node-graph/nodes/graphic/src/graphic.rs | 146 +--- node-graph/nodes/graphic/src/record.rs | 62 +- node-graph/nodes/gstd/src/lib.rs | 6 + node-graph/nodes/gstd/src/text.rs | 13 +- node-graph/nodes/math/src/lib.rs | 39 + node-graph/nodes/repeat/src/repeat_nodes.rs | 2 +- 20 files changed, 1174 insertions(+), 664 deletions(-) create mode 100644 node-graph/nodes/gcore/src/list.rs diff --git a/Cargo.lock b/Cargo.lock index 2021bdb780..74d9633c56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2095,12 +2095,17 @@ dependencies = [ "dyn-any", "glam", "graphene-hash", + "graphic-nodes", "graphic-types", "log", + "math-nodes", "node-macro", + "rand", "raster-types", + "repeat-nodes", "serde", "tsify", + "vector-types", "wasm-bindgen", ] @@ -2180,6 +2185,7 @@ dependencies = [ "core-types", "dyn-any", "glam", + "graphene-core", "graphic-types", "node-macro", "raster-types", diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index b1f65e9437..1c2abd7293 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -2758,7 +2758,7 @@ impl DocumentMessageHandler { } let solidify_stroke_definition = document_node_definitions::resolve_proto_node_type(graphene_std::vector::solidify_stroke::IDENTIFIER).expect("Solidify Stroke node should exist"); - let item_at_index_definition = document_node_definitions::resolve_proto_node_type(graphene_std::graphic::item_at_index::IDENTIFIER).expect("Item at Index node should exist"); + let item_at_index_definition = document_node_definitions::resolve_proto_node_type(graphene_std::list::item_at_index::IDENTIFIER).expect("Item at Index node should exist"); let mut resulting_layers: Vec = Vec::new(); @@ -4339,13 +4339,13 @@ mod document_message_handler_tests { // A base that wrongly carried a phantom element would therefore show up as a recorded row, which this catches. // The `news` guard below is what keeps both assertions honest, since a wrong `Output` type empties every record. let base_lengths: Vec = instrumented - .grab_all_input_as::, graphene_std::list::List>(&editor.runtime) + .grab_all_input_as::, graphene_std::list::List>(&editor.runtime) .map(|base| base.len()) .collect(); assert!(base_lengths.iter().all(|&len| len == 0), "Every stack base should be empty, found lengths {base_lengths:?}"); let news: Vec> = instrumented - .grab_all_input_as::, graphene_std::list::List>(&editor.runtime) + .grab_all_input_as::, graphene_std::list::List>(&editor.runtime) .collect(); assert!(!news.is_empty(), "Instrumentation should have recorded at least one stacked element list"); let phantom_count = news diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index 54d72f9ab8..82d0f89415 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -189,7 +189,7 @@ fn document_node_definitions() -> HashMap HashMap))), 0), NodeInput::node(NodeId(3), 0), ], - implementation: DocumentNodeImplementation::ProtoNode(graphic::extend::IDENTIFIER), + implementation: DocumentNodeImplementation::ProtoNode(list::extend::IDENTIFIER), ..Default::default() }, // Content coercion into a graphic level, evaluated within the artboard's footprint @@ -836,7 +836,7 @@ fn document_node_definitions() -> HashMap HashMap, preserving each item's start/end/name attributes DocumentNode { inputs: vec![NodeInput::node(NodeId(0), 0), NodeInput::value(TaggedValue::F64(0.), false)], - implementation: DocumentNodeImplementation::ProtoNode(graphic::remove_at_index::IDENTIFIER), + implementation: DocumentNodeImplementation::ProtoNode(list::remove_at_index::IDENTIFIER), ..Default::default() }, ] diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 2409deeecb..41044d966c 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -104,7 +104,7 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ "graphene_core::transform_nodes::FreezeRealTimeNode", "graphene_core::vector::SubpathSegmentLengthsNode", "core_types::vector::SubpathSegmentLengthsNode", - // The deleted debug Option trio degrades to a passthrough of its single input (audit resolution 8) + // The deleted debug Option trio degrades to a passthrough of its single input "graphene_core::ops::SizeOfNode", "graphene_core::debug::SizeOfNode", "graphene_core::ops::SomeNode", @@ -139,8 +139,12 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ ], }, NodeReplacement { - node: graphene_std::graphic::extend::IDENTIFIER, - aliases: &["graphene_core::graphic::graphic::ExtendNode", "graphene_core::graphic::ExtendNode"], + node: graphene_std::list::extend::IDENTIFIER, + aliases: &[ + "graphene_core::graphic::graphic::ExtendNode", + "graphene_core::graphic::ExtendNode", + "graphic_nodes::graphic::ExtendNode", + ], }, NodeReplacement { node: graphene_std::graphic::flatten_graphic::IDENTIFIER, @@ -155,18 +159,19 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ aliases: &["graphene_core::graphic::FlattenVectorNode", "graphene_core::graphic_element::FlattenVectorNode"], }, NodeReplacement { - node: graphene_std::graphic::item_at_index::IDENTIFIER, + node: graphene_std::list::item_at_index::IDENTIFIER, aliases: &[ "graphene_core::graphic_element::IndexNode", "graphene_core::graphic::IndexNode", "graphene_core::graphic::IndexElementsNode", "graphic_nodes::graphic::IndexElementsNode", "graphic_nodes::graphic::ExtractElementNode", + "graphic_nodes::graphic::ItemAtIndexNode", ], }, NodeReplacement { - node: graphene_std::graphic::remove_at_index::IDENTIFIER, - aliases: &["graphic_nodes::graphic::OmitElementNode"], + node: graphene_std::list::remove_at_index::IDENTIFIER, + aliases: &["graphic_nodes::graphic::OmitElementNode", "graphic_nodes::graphic::RemoveAtIndexNode"], }, // The legacy layer extend no longer exists as a node; the aliases still land on its identifier so the // subgraph rebuild below recognizes and replaces the networks that carried it. @@ -850,8 +855,8 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ }, // The string map folded into the general Map, and its reader into the vararg readers. NodeReplacement { - node: graphene_std::graphic::map::IDENTIFIER, - aliases: &["graphene_core::vector::InstanceMapNode", "text_nodes::MapStringNode"], + node: graphene_std::list::map::IDENTIFIER, + aliases: &["graphene_core::vector::InstanceMapNode", "text_nodes::MapStringNode", "graphic_nodes::graphic::MapNode"], }, NodeReplacement { node: graphene_std::context::read_position::IDENTIFIER, @@ -2800,7 +2805,7 @@ fn migrate_removed_catalog_definitions(node_id: &NodeId, node: &DocumentNode, ne } } - // The removed Attach Attribute node (merged into Write Attribute per audit resolution 6) degrades to a passthrough of its + // The removed Attach Attribute node degrades to a passthrough of its // content: its eager whole-list source input cannot be mechanically rewired as Write Attribute's lazy per-item value producer. if let Some(DefinitionIdentifier::ProtoNode(identifier)) = document.network_interface.reference(node_id, network_path) && identifier.as_str().ends_with("::AttachAttributeNode") diff --git a/editor/src/messages/tool/tool_messages/artboard_tool.rs b/editor/src/messages/tool/tool_messages/artboard_tool.rs index bc063ac28e..1e315d704d 100644 --- a/editor/src/messages/tool/tool_messages/artboard_tool.rs +++ b/editor/src/messages/tool/tool_messages/artboard_tool.rs @@ -582,7 +582,7 @@ mod test_artboard { Err(e) => panic!("Failed to evaluate graph: {e}"), }; let mut artboards = List::new(); - for list in instrumented.grab_all_input_level::, Artboard>(&editor.runtime) { + for list in instrumented.grab_all_input_level::, Artboard>(&editor.runtime) { for index in 0..list.len() { if let Some(item) = list.clone_item(index) { artboards.push(item); diff --git a/node-graph/libraries/core-types/src/record/mod.rs b/node-graph/libraries/core-types/src/record/mod.rs index 254237617a..39a3f5cd0a 100644 --- a/node-graph/libraries/core-types/src/record/mod.rs +++ b/node-graph/libraries/core-types/src/record/mod.rs @@ -33,4 +33,4 @@ pub use promote::{Promotion, assert_promoted, register_element_promote, register pub use route::{RecordSource, SourcePlan}; pub use run::{Group, GroupItem, RunBuilder, RunColumn, RunView, run_to_owned_list}; pub use serve::{FrameClaim, MaterializedSpan, Served, SlotRun, serve_input}; -pub use testkit::{LiftedSource, ServedRecord, capture, test_frames}; +pub use testkit::{LiftedSource, ServedRecord, capture, fixtures as test_fixtures, test_frames}; diff --git a/node-graph/libraries/core-types/src/record/testkit.rs b/node-graph/libraries/core-types/src/record/testkit.rs index 43c0af7cf0..8eeba90637 100644 --- a/node-graph/libraries/core-types/src/record/testkit.rs +++ b/node-graph/libraries/core-types/src/record/testkit.rs @@ -180,3 +180,330 @@ mod tests { assert_eq!(served.attr::(), DAffine2::from_translation(DVec2::new(3., 4.))); } } + +/// The node-test fixtures: hand-wired record sources and layout installers, +/// so a node crate's tests drive a generated node without the compiler pass. +pub mod fixtures { + use super::super::frames::Frames; + use super::super::layout::{FieldWrite, Layout, LayoutMeta, RecordLayout, element_write}; + use super::super::serve::{FrameClaim, Served}; + use super::test_frames; + use crate::SourceId; + use crate::arena::Arena; + use crate::attribute::{Attribute, Opacity, Transform}; + use crate::context::{ContextImpl, EvalScope, ExtractArena, ExtractIndex, ExtractIndices}; + use crate::gpoll::{Extent, GPoll}; + use crate::node::Node; + use crate::value::ValueSource; + use glam::DAffine2; + + /// A one-record source with fixed `f64` fields, optionally served partial. + pub struct RecordSourceNode { + pub layout: Layout, + pub element: E, + pub fields: Vec<(&'static str, f64)>, + pub partial: bool, + } + + impl Node for RecordSourceNode { + fn serve<'e, 'l>(&self, input: &C, slot: FrameClaim<'e, 'l>) -> GPoll> + where + C: ExtractArena, + { + let mut frame = slot; + let arena = ExtractArena::arena(input); + if frame.element(self.element, arena).is_none() { + return GPoll::arena_exhausted(); + } + for (name, field) in &self.fields { + write_field_at(&mut frame, &self.layout, name, 0, *field); + } + // SAFETY: the writes above complete the record of this layout. + let served = unsafe { frame.finish_served() }; + match self.partial { + true => GPoll::Partial(served), + false => GPoll::Final(served), + } + } + + fn layout(&self) -> &Layout { + &self.layout + } + } + + /// A level of `f64` lanes, each optionally carrying one fixed field. + pub struct LeveledSourceNode { + pub layout: Layout, + pub elements: Vec, + pub field: Option<(&'static str, f64)>, + } + + impl Node for LeveledSourceNode { + fn serve<'e, 'l>(&self, input: &C, slot: FrameClaim<'e, 'l>) -> GPoll> + where + C: ExtractArena, + { + let element = self.elements[input.innermost_index() as usize % self.elements.len()]; + let mut frame = slot; + let arena = ExtractArena::arena(input); + if frame.element(element, arena).is_none() { + return GPoll::arena_exhausted(); + } + if let Some((name, value)) = self.field { + write_field_at(&mut frame, &self.layout, name, 0, value); + } + // SAFETY: the writes above complete the record of this layout. + GPoll::Final(unsafe { frame.finish_served() }) + } + + fn extent_at<'x>(&self, _input: &C, _level: u8, _frames: &Frames<'x>) -> GPoll + where + C: ExtractArena, + { + GPoll::Final(Extent::Exactly(self.elements.len())) + } + + fn layout(&self) -> &Layout { + &self.layout + } + } + + /// A level of `f64` lanes each carrying a transform. + pub struct LeveledTransformSource { + pub layout: Layout, + pub rows: Vec<(f64, DAffine2)>, + } + + impl Node for LeveledTransformSource { + fn serve<'e, 'l>(&self, input: &C, slot: FrameClaim<'e, 'l>) -> GPoll> + where + C: ExtractArena, + { + let (element, transform) = self.rows[input.innermost_index() as usize % self.rows.len()]; + let mut frame = slot; + let arena = ExtractArena::arena(input); + if frame.element(element, arena).is_none() { + return GPoll::arena_exhausted(); + } + write_attr_at::(&mut frame, &self.layout, transform); + // SAFETY: the writes above complete the record of this layout. + GPoll::Final(unsafe { frame.finish_served() }) + } + + fn extent_at<'x>(&self, _input: &C, _level: u8, _frames: &Frames<'x>) -> GPoll + where + C: ExtractArena, + { + GPoll::Final(Extent::Exactly(self.rows.len())) + } + + fn layout(&self) -> &Layout { + &self.layout + } + } + + /// Serves lanes carrying both a Transform no gather kernel declares and an + /// Opacity one does, so a carried column can be told apart from a written one. + pub struct LeveledCarriedSource { + pub layout: Layout, + pub rows: Vec<(f64, DAffine2, f64)>, + } + + impl Node for LeveledCarriedSource { + fn serve<'e, 'l>(&self, input: &C, slot: FrameClaim<'e, 'l>) -> GPoll> + where + C: ExtractArena, + { + let (element, transform, opacity) = self.rows[input.innermost_index() as usize % self.rows.len()]; + let mut frame = slot; + let arena = ExtractArena::arena(input); + if frame.element(element, arena).is_none() { + return GPoll::arena_exhausted(); + } + write_attr_at::(&mut frame, &self.layout, transform); + write_attr_at::(&mut frame, &self.layout, opacity); + // SAFETY: the writes above complete the record of this layout. + GPoll::Final(unsafe { frame.finish_served() }) + } + + fn extent_at<'x>(&self, _input: &C, _level: u8, _frames: &Frames<'x>) -> GPoll + where + C: ExtractArena, + { + GPoll::Final(Extent::Exactly(self.rows.len())) + } + + fn layout(&self) -> &Layout { + &self.layout + } + } + + /// A leveled source that keeps its count to itself: the extent is a lower + /// bound and lanes past the data answer the past-end signal. + pub struct DrainSourceNode { + pub layout: Layout, + pub count: usize, + } + + impl Node for DrainSourceNode { + fn serve<'e, 'l>(&self, input: &C, slot: FrameClaim<'e, 'l>) -> GPoll> + where + C: ExtractArena, + { + let lane = input.innermost_index(); + if lane >= self.count as u64 { + return GPoll::past_end(); + } + let mut frame = slot; + let arena = ExtractArena::arena(input); + if frame.element(lane as f64, arena).is_none() { + return GPoll::arena_exhausted(); + } + // SAFETY: the writes above complete the record of this layout. + GPoll::Final(unsafe { frame.finish_served() }) + } + + fn extent_at<'x>(&self, _input: &C, _level: u8, _frames: &Frames<'x>) -> GPoll + where + C: ExtractArena, + { + GPoll::Final(Extent::AtLeast(0)) + } + + fn layout(&self) -> &Layout { + &self.layout + } + } + + /// Depth-0 content varying per copy: serves the enclosing (pushed) level's + /// index, which sits one link above the content's own innermost lane. + pub struct IndexSourceNode { + pub layout: Layout, + } + + impl Node for IndexSourceNode { + fn serve<'e, 'l>(&self, input: &C, slot: FrameClaim<'e, 'l>) -> GPoll> + where + C: ExtractArena, + { + let element = input.try_index().and_then(|mut indices| indices.nth(1)).unwrap_or(0) as f64; + let mut frame = slot; + let arena = ExtractArena::arena(input); + if frame.element(element, arena).is_none() { + return GPoll::arena_exhausted(); + } + // SAFETY: the writes above complete the record of this layout. + GPoll::Final(unsafe { frame.finish_served() }) + } + + fn layout(&self) -> &Layout { + &self.layout + } + } + + /// Writes a field at the layout's resolved offset, the wiring-proven pairing + /// a generated node performs. + pub fn write_field_at(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::(), "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. + pub fn write_attr_at(frame: &mut FrameClaim<'_, '_>, layout: &Layout, value: A::Value<'static>) + where + A::Value<'static>: Copy + 'static, + { + write_field_at(frame, layout, A::NAME, 0, value); + } + + pub fn scope_fixture<'a>(generations: &'a [(SourceId, u64)], arena: &'a Arena) -> EvalScope<'a> { + EvalScope::new(Some(0.5), None, None, generations, arena) + } + + fn f64_fields(names: &[&'static str]) -> Vec { + names + .iter() + .map(|name| FieldWrite { + name, + level: 0, + size: 8, + align: 8, + type_id: std::any::TypeId::of::(), + read_erased: ::read_erased, + repark: None, + content_hash: None, + content_eq: None, + }) + .collect() + } + + /// A depth-0 `f64` record layout carrying the named `f64` fields. + pub fn f64_layout(names: &[&'static str]) -> Layout { + Layout::default().with_writes(0, element_write::(), &f64_fields(names)) + } + + /// A one-level `f64` layout carrying the named `f64` fields. + pub fn leveled_f64_layout(names: &[&'static str]) -> Layout { + Layout::default().with_writes(1, element_write::(), &f64_fields(names)) + } + + /// Frame space sized for the layouts, with a floor for small fixtures. + pub fn frames_for(layouts: &[&Layout]) -> Frames<'static> { + test_frames(layouts.iter().map(|layout| layout.frame_bytes()).sum::().max(1 << 12)) + } + + /// Installs the layout the compiler pass would resolve for `node` over the + /// given input layouts. The fixtures wire constants into every eager input, + /// which the pass records as lane-invariant. + pub fn install>>(mut node: N, meta: LayoutMeta, inputs: &[Option<&Layout>]) -> N { + let resolved = RecordLayout { + named_writes: Vec::new(), + named_reads: Vec::new(), + named_read_defaults: Vec::new(), + lane_invariant: u32::MAX, + ..meta.resolve(inputs) + }; + >>::set_layout(&mut node, resolved); + node + } + + /// Installs a flipped node's output layout directly. + pub fn install_flip>>(mut node: N, layout: &Layout) -> N { + let bundle = 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, + }; + >>::set_layout(&mut node, bundle); + node + } + + /// A constant as a value source with its layout. + pub fn lifted_value(value: T) -> (ValueSource, Layout) + where + T::Static: Clone + Send + Sync, + { + let lift = ValueSource::new(value); + let layout = Node::::layout(&lift).clone(); + (lift, layout) + } + + pub fn bare_source(layout: &Layout, element: f64) -> RecordSourceNode { + RecordSourceNode { + layout: layout.clone(), + element, + fields: vec![], + partial: false, + } + } +} diff --git a/node-graph/libraries/core-types/src/registry.rs b/node-graph/libraries/core-types/src/registry.rs index d8c7610245..bcd268954a 100644 --- a/node-graph/libraries/core-types/src/registry.rs +++ b/node-graph/libraries/core-types/src/registry.rs @@ -102,6 +102,14 @@ pub fn cache_key(ctx: &C) -> u64 { hasher.finish() } +/// The lane-normalized cache key and arena generation of one evaluation, so a +/// node can keep per-evaluation scratch across its lanes. +pub fn eval_key<'e, C: CacheHash + crate::context::InjectIndex + crate::context::ExtractArena + Copy>(ctx: &C) -> (u64, u64) { + let mut keyed = *ctx; + keyed.set_index(0); + (cache_key(&keyed), ctx.arena().generation()) +} + #[derive(Debug, PartialEq)] pub enum ConstructionError { Arity { expected: usize, got: usize }, diff --git a/node-graph/nodes/gcore/Cargo.toml b/node-graph/nodes/gcore/Cargo.toml index 1e22058ff7..ec976b73ba 100644 --- a/node-graph/nodes/gcore/Cargo.toml +++ b/node-graph/nodes/gcore/Cargo.toml @@ -23,14 +23,22 @@ core-types = { workspace = true } graphene-hash = { workspace = true } raster-types = { workspace = true } graphic-types = { workspace = true } +vector-types = { workspace = true } node-macro = { workspace = true } # Workspace dependencies dyn-any = { workspace = true } glam = { workspace = true } +rand = { workspace = true } log = { workspace = true } # Optional workspace dependencies serde = { workspace = true, optional = true } tsify = { workspace = true, optional = true } wasm-bindgen = { workspace = true, optional = true } + +[dev-dependencies] +# The level tests drive the production catalog's kernels +graphic-nodes = { workspace = true } +repeat-nodes = { workspace = true } +math-nodes = { workspace = true } diff --git a/node-graph/nodes/gcore/src/context.rs b/node-graph/nodes/gcore/src/context.rs index d4598fdd45..b6473a8376 100644 --- a/node-graph/nodes/gcore/src/context.rs +++ b/node-graph/nodes/gcore/src/context.rs @@ -53,6 +53,7 @@ vararg_readers! { read_color / read_color_extent / ReadColorNode: Color; read_gradient / read_gradient_extent / ReadGradientNode: Gradient; read_string / read_string_extent / ReadStringNode: String; + read_number / read_number_extent / ReadNumberNode: f64; } #[node_macro::node(category("Context"), path(core_types::vector))] diff --git a/node-graph/nodes/gcore/src/lib.rs b/node-graph/nodes/gcore/src/lib.rs index b391dc2e1c..f60a4c9793 100644 --- a/node-graph/nodes/gcore/src/lib.rs +++ b/node-graph/nodes/gcore/src/lib.rs @@ -3,6 +3,7 @@ pub mod context; pub mod context_modification; pub mod debug; pub mod extract_xy; +pub mod list; pub mod memo; pub mod ops; #[cfg(test)] diff --git a/node-graph/nodes/gcore/src/list.rs b/node-graph/nodes/gcore/src/list.rs new file mode 100644 index 0000000000..097a73cea7 --- /dev/null +++ b/node-graph/nodes/gcore/src/list.rs @@ -0,0 +1,725 @@ +//! The generic level kernels: the nodes that reorder, select, expand or +//! collapse a level regardless of its element type. + +use core_types::context::IndexLink; +use core_types::extent::{ExtentIn, LevelIn, ListIn, ValueIn}; +use core_types::gpoll::{Extent, GPoll, GraphError, Interrupt, Level}; +use core_types::list::List; +use core_types::node::Lane; +use core_types::registry::types::{SeedValue, SignedInteger}; +use core_types::uuid::NodeId; +use core_types::{CacheHash, Color, Ctx, DeriveCtx, ExtractIndex, InjectIndex, ModifyIndex}; +use glam::{DAffine2, DVec2}; +use graphic_types::vector_types::Gradient; +use graphic_types::{Artboard, Graphic, Vector}; +use rand::SeedableRng; +use rand::seq::SliceRandom; +use raster_types::{CPU, GPU, Raster}; +use std::cmp::Ordering; + +/// 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 { + 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( + ctx: impl Ctx + ModifyIndex + Copy, + /// The list of data. + list: impl Node, 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 { + 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 { + 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( + _: impl Ctx, + /// The `List` of data to extract from. + #[implementations(String, f64, NodeId, Color, Gradient, Vector, Raster, Graphic, Artboard)] + list: IList, + /// 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() +} + +/// A lane order computed once per evaluation, for the nodes whose lane +/// mapping needs the whole level (sort, shuffle). +#[derive(Debug, Default)] +pub struct LaneOrder { + key: u64, + generation: u64, + order: Vec, +} + +type LaneOrderCache = std::sync::Arc>>; + +/// The source lane of output `lane` under the order `build` produces, built +/// once per `(key, generation)`. +fn ordered_source(cache: &LaneOrderCache, (key, generation): (u64, u64), lane: usize, build: impl FnOnce() -> Vec) -> Option { + let mut cached = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if !matches!(cached.as_ref(), Some(entry) if entry.key == key && entry.generation == generation) { + *cached = Some(LaneOrder { key, generation, order: build() }); + } + cached.as_ref().and_then(|entry| entry.order.get(lane).copied()) +} + +/// The lanes the pattern keeps: a period's kept positions and how many lanes +/// there are in total, so a lane maps to its source in constant time. +fn kept_source(pattern: &[bool], lane: usize) -> usize { + if pattern.is_empty() { + return lane; + } + let kept: Vec = pattern.iter().enumerate().filter_map(|(position, keep)| keep.then_some(position)).collect(); + (lane / kept.len()) * pattern.len() + kept[lane % kept.len()] +} + +fn kept_count(pattern: &[bool], total: usize) -> usize { + if pattern.is_empty() { + return total; + } + let kept_per_period = pattern.iter().filter(|keep| **keep).count(); + let full_periods = total / pattern.len(); + let tail_kept = pattern[..total % pattern.len()].iter().filter(|keep| **keep).count(); + full_periods * kept_per_period + tail_kept +} + +/// Keeps chosen items from a list (those corresponding to `true` values) and discards the others (those corresponding to `false` values) based on the *Keep Pattern* bool list. A short pattern is repeated over the remainder of the filtered list, allowing a pattern like `[true, false]` to keep every other item starting from the first. An empty pattern keeps all items. +#[node_macro::node(category("General"), extent(filter_extent))] +fn filter( + ctx: impl Ctx + ExtractIndex + InjectIndex + Copy, + /// The list of data to filter. + #[implementations(String, bool, f32, f64, u32, u64, DVec2, DAffine2, Vector, Graphic, Raster, Raster, Color, Gradient, Artboard)] + list: IList, + /// The list of true and false values that determines which corresponding items are kept (`true`) and discarded (`false`). The pattern may repeat if it is shorter than the list of data. + keep_pattern: IList, +) -> Result>, Interrupt> { + let pattern: Vec = keep_pattern.iter().collect(); + let source = kept_source(&pattern, ctx.index() as usize); + if source >= list.len() { + return Err(GraphError::past_end().into()); + } + Ok(list.lane(source)) +} + +fn filter_extent(list: ListIn<'_, T>, keep_pattern: ListIn<'_, bool>, level: LevelIn) -> GPoll { + match level.top() { + true => list.get().zip(keep_pattern.get()).map(|(list, keep_pattern)| { + let pattern: Vec = keep_pattern.iter().collect(); + Extent::Exactly(kept_count(&pattern, list.len())) + }), + false => GPoll::Final(Extent::Exactly(1)), + } +} + +/// Reverses the order of the items in a list, so the last item comes first and the first comes last. +#[node_macro::node(category("General"), extent(same_count_extent))] +fn reverse( + ctx: impl Ctx + ExtractIndex + InjectIndex + Copy, + /// The list of data to reverse. + #[implementations(String, bool, f32, f64, u32, u64, DVec2, DAffine2, Vector, Graphic, Raster, Raster, Color, Gradient, Artboard)] + list: IList, +) -> Result>, Interrupt> { + let lane = ctx.index() as usize; + if lane >= list.len() { + return Err(GraphError::past_end().into()); + } + Ok(list.lane(list.len() - 1 - lane)) +} + +/// The level keeps the list's count; the lanes only change places. +fn same_count_extent(list: ListIn<'_, T>, level: LevelIn) -> GPoll { + match level.top() { + true => list.total(), + false => GPoll::Final(Extent::Exactly(1)), + } +} + +/// Shifts the items in a list by a number of positions. With wrapping, items pushed off one end reappear at the other. Otherwise they are dropped, shortening the list. +#[node_macro::node(category("General"), extent(shift_extent))] +fn shift( + ctx: impl Ctx + ExtractIndex + InjectIndex + Copy, + /// The list of data to shift. + #[implementations(String, bool, f32, f64, u32, u64, DVec2, DAffine2, Vector, Graphic, Raster, Raster, Color, Gradient, Artboard)] + list: IList, + /// How many positions to shift each item. Positive values shift items toward the start of the list, negative toward the end. + amount: SignedInteger, + /// Whether items shifted off one end wrap around to the other. When off, they are dropped and the list gets shorter. + #[default(true)] + wrap: bool, +) -> Result>, Interrupt> { + let lane = ctx.index() as i64; + let amount = amount as i64; + let len = list.len() as i64; + let source = match (wrap, len) { + (_, 0) => return Err(GraphError::past_end().into()), + (true, len) => (lane + amount).rem_euclid(len), + // Dropping from the front reads ahead; dropping from the back only shortens the level. + (false, _) => lane + amount.max(0), + }; + if source >= len { + return Err(GraphError::past_end().into()); + } + Ok(list.lane(source as usize)) +} + +fn shift_extent(list: ListIn<'_, T>, amount: ValueIn<'_, f64>, wrap: ValueIn<'_, bool>, level: LevelIn) -> GPoll { + match level.top() { + true => list.total().zip(amount.get()).zip(wrap.get()).map(|((total, amount), wrap)| match (total, wrap) { + (Extent::Exactly(count), false) => Extent::Exactly(count.saturating_sub(amount.abs() as usize)), + (total, _) => total, + }), + false => GPoll::Final(Extent::Exactly(1)), + } +} + +/// Randomly reorders the items in a list. The same seed always produces the same ordering. +#[node_macro::node(category("General"), extent(shuffle_extent))] +fn shuffle<'e, T: Clone + Send + Sync + CacheHash + 'static>( + ctx: impl Ctx + CacheHash + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy, + /// The list to have its items randomly reordered. + #[implementations(String, bool, f32, f64, u32, u64, DVec2, DAffine2, Vector, Graphic, Raster, Raster, Color, Gradient, Artboard)] + list: IList, + /// Seed to determine the unique variation of the random shuffle ordering. The same seed always produces the same ordering. + seed: SeedValue, + #[data] order: LaneOrderCache, +) -> Result>, Interrupt> { + let source = ordered_source(order, core_types::registry::eval_key(ctx), ctx.index() as usize, || { + let mut order: Vec = (0..list.len()).collect(); + order.shuffle(&mut rand::rngs::StdRng::seed_from_u64(seed.into())); + order + }); + source.map(|source| list.lane(source)).ok_or_else(|| GraphError::past_end().into()) +} + +fn shuffle_extent(list: ListIn<'_, T>, _seed: ValueIn<'_, SeedValue>, level: LevelIn) -> GPoll { + same_count_extent(list, level) +} + +/// Generates a list of evenly spaced numbers, starting at a value and progressing by a step (which may be positive, negative, or zero) for a given count. +#[node_macro::node(category("General"), name("Number Sequence"), extent(number_sequence_extent))] +fn number_sequence( + ctx: impl Ctx + ExtractIndex + InjectIndex + Copy, + _primary: (), + /// The first number in the sequence. + start: f64, + /// The amount added to reach each successive number. + #[default(1.)] + step: f64, + /// How many numbers to generate. + #[default(10)] + count: u32, +) -> Result, Interrupt> { + let lane = ctx.index(); + if lane >= count as u64 { + return Err(GraphError::past_end().into()); + } + Ok(start + step * lane as f64) +} + +fn number_sequence_extent(_primary: ValueIn<'_, ()>, _start: ValueIn<'_, f64>, _step: ValueIn<'_, f64>, count: ValueIn<'_, u32>, level: LevelIn) -> GPoll { + match level.top() { + true => count.get().map(|count| Extent::Exactly(count as usize)), + false => GPoll::Final(Extent::Exactly(1)), + } +} + +/// Counts out the index of each item in a list (0, 1, 2, and so on), producing a list of numbers with one for each item. +#[node_macro::node(category("General"), extent(list_indices_extent))] +fn list_indices( + ctx: impl Ctx + ExtractIndex + InjectIndex + Copy, + /// The list whose items are counted. + #[implementations(String, bool, f32, f64, u32, u64, DVec2, DAffine2, Vector, Graphic, Raster, Raster, Color, Gradient, Artboard)] + list: IList, + /// The number that the count begins from for the first item. + start_index: SignedInteger, +) -> Result>, Interrupt> { + let lane = ctx.index() as usize; + if lane >= list.len() { + return Err(GraphError::past_end().into()); + } + Ok(list.lane(lane).map_element(start_index + lane as f64)) +} + +fn list_indices_extent(list: ListIn<'_, T>, _start_index: ValueIn<'_, f64>, level: LevelIn) -> GPoll { + same_count_extent(list, level) +} + +/// The half-open lane range a slice keeps: negative starts count from the +/// end, and an end at or below zero counts from the end too. +fn slice_bounds(total: usize, start: f64, end: f64) -> (usize, usize) { + let start = match start < 0. { + true => total.saturating_sub(start.abs() as usize), + false => (start as usize).min(total), + }; + let end = match end <= 0. { + true => total.saturating_sub(end.abs() as usize), + false => (end as usize).min(total), + }; + (start, end.max(start)) +} + +/// Extracts a portion of a list, starting at "Start" and ending before "End". +/// +/// Negative indices count from the end of the list. If the index of "Start" equals or exceeds "End", the result is an empty list. +#[node_macro::node(category("General"), extent(list_slice_extent))] +fn list_slice( + ctx: impl Ctx + ExtractIndex + InjectIndex + Copy, + /// The list of data to take a portion of. + #[implementations(String, bool, f32, f64, u32, u64, DVec2, DAffine2, Vector, Graphic, Raster, Raster, Color, Gradient, Artboard)] + list: IList, + /// The index of the first item in the portion. Negative indices count from the end of the list. + start: SignedInteger, + /// The index the portion ends before, which is not included. Zero or negative indices count from the end of the list. + end: SignedInteger, +) -> Result>, Interrupt> { + let (first, end) = slice_bounds(list.len(), start, end); + let source = first + ctx.index() as usize; + if source >= end { + return Err(GraphError::past_end().into()); + } + Ok(list.lane(source)) +} + +fn list_slice_extent(list: ListIn<'_, T>, start: ValueIn<'_, f64>, end: ValueIn<'_, f64>, level: LevelIn) -> GPoll { + match level.top() { + true => list.total().zip(start.get()).zip(end.get()).map(|((total, start), end)| match total { + Extent::Exactly(count) => { + let (first, end) = slice_bounds(count, start, end); + Extent::Exactly(end - first) + } + total => total, + }), + false => GPoll::Final(Extent::Exactly(1)), + } +} + +/// Pairwise ordering used by the Sort node for element values. Types without a natural +/// order compare as equal, so the stable sort leaves their items in their original relative positions. +pub trait ElementOrder { + fn element_order(&self, _other: &Self) -> Ordering { + Ordering::Equal + } +} + +macro_rules! element_order { + (ordered: $($ordered:ty),*; unordered: $($unordered:ty),*;) => { + $( + impl ElementOrder for $ordered { + fn element_order(&self, other: &Self) -> Ordering { + self.cmp(other) + } + } + )* + $(impl ElementOrder for $unordered {})* + }; +} + +element_order! { + ordered: String, bool, u32, u64; + unordered: DVec2, DAffine2, Vector, Graphic<'_>, Raster, Raster, Color, Gradient, Artboard<'_>; +} + +impl ElementOrder for f32 { + fn element_order(&self, other: &Self) -> Ordering { + self.total_cmp(other) + } +} + +impl ElementOrder for f64 { + fn element_order(&self, other: &Self) -> Ordering { + self.total_cmp(other) + } +} + +/// Reorders a list's items from smallest to largest, either by each item's own value or by a parallel list of sortable values in the *Sort Order* input. The sort is stable, so items with the same sort order retain their relative positions. +#[node_macro::node(category("General"), extent(sort_extent))] +fn sort<'e, T: ElementOrder + Clone + Send + Sync + CacheHash + 'static>( + ctx: impl Ctx + CacheHash + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy, + /// The list of data to reorder. + #[implementations(String, bool, f32, f64, u32, u64, DVec2, DAffine2, Vector, Graphic, Raster, Raster, Color, Gradient, Artboard)] + list: IList, + /// The optional list of orderable values, corresponding item-to-item with the input list, to sort by instead of the items' own values. + // The two-generic grid master authors here (f64/String/bool key lists) needs multi-generic implementations support our macro does not have; narrowed to f64 keys. + #[expose] + sort_order: IList, + /// Reverses the sorted list order, following descending order instead of ascending (numbers largest-to-smallest, strings Z-to-A, etc.). + reverse: bool, + #[data] order: LaneOrderCache, +) -> Result>, Interrupt> { + let source = ordered_source(order, core_types::registry::eval_key(ctx), ctx.index() as usize, || { + // Order by the parallel keys when provided (repeating the last if there are fewer keys than items), otherwise by the element values themselves + let keys: Vec = sort_order.iter().collect(); + let mut order: Vec = (0..list.len()).collect(); + order.sort_by(|&a, &b| { + let ordering = match keys.as_slice() { + [] => list.element_ref(a).element_order(list.element_ref(b)), + keys => keys[a.min(keys.len() - 1)].element_order(&keys[b.min(keys.len() - 1)]), + }; + if reverse { ordering.reverse() } else { ordering } + }); + order + }); + source.map(|source| list.lane(source)).ok_or_else(|| GraphError::past_end().into()) +} + +fn sort_extent(list: ListIn<'_, T>, _sort_order: ListIn<'_, f64>, _reverse: ValueIn<'_, bool>, level: LevelIn) -> GPoll { + same_count_extent(list, level) +} + +/// One content row as the vararg shape the readers expect: a single-item +/// legacy list carrying the row's element only, so the list's dyn-hash is a +/// complete cache key over the observables. +fn vararg_row(content: core_types::node::List<'_, Row>, row: usize) -> List { + List::new_from_element(content.element_ref(row).clone()) +} + +/// 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( + ctx: impl Ctx + DeriveCtx + ExtractIndex + InjectIndex + Copy, + #[implementations(Graphic, Vector, Raster, Color, Gradient, String)] content: IList, + mapped: impl Node, Output = IList>, +) -> Result, Interrupt> { + let mut remaining = ctx.index(); + for row in 0..content.len() { + let item = 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 = IndexLink { index: 0, outer: None }; + return mapped.eval(&scoped.ctx().push_level(&mut frame, row as u64, remaining)); + } + Err(GraphError::past_end().into()) +} + +/// 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( + ctx: impl Ctx + ExtractIndex + InjectIndex + Copy, + /// The input whose lanes appear at the start of the extended level. + base: impl Node, Output = T>, + /// The input whose lanes appear at the end of the extended level. + #[expose] + new: impl Node, Output = T>, +) -> Result { + 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 { + 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"), + } + } + }), + } +} + +pub use _map_mod::map_entries; + +#[cfg(test)] +mod tests { + use super::*; + use core_types::arena::Arena; + use core_types::context::ContextImpl; + use core_types::node::Node; + use core_types::record::test_fixtures::*; + use core_types::record::{Frames, Layout, capture}; + use core_types::value::{LeveledValueSource, ValueSource}; + + /// A level of `elements` as a record source with its layout. + fn level(elements: Vec) -> (LeveledValueSource, Layout) + where + T::Static: Clone + Send + Sync, + { + let source = LeveledValueSource::new(elements); + let layout = Node::::layout(&source).clone(); + (source, layout) + } + + /// The level's elements in lane order, read through the extent. + fn elements<'a, T: Clone + 'static, N: for<'c> Node>>(node: &N, ctx: &ContextImpl<'a>, frames: &Frames<'a>) -> Vec { + let GPoll::Final(Extent::Exactly(count)) = node.extent_at(ctx, 0, &frames.reborrow()) else { + panic!("expected an exact level"); + }; + let head = ctx.index_head(); + let mut elements = Vec::with_capacity(count); + for lane in 0..count { + let GPoll::Final(served) = capture(node, &ctx.promoted(&head, lane as u64), frames) else { + panic!("expected a final record at lane {lane}"); + }; + elements.push(served.element::()); + } + elements + } + + macro_rules! fixture { + ($arena:ident, $scope:ident, $ctx:ident) => { + let $arena = Arena::new(1 << 16).unwrap(); + let generations = []; + let $scope = scope_fixture(&generations, &$arena); + let $ctx = ContextImpl::root(&$scope); + }; + } + + fn sorted(items: Vec, keys: Vec, reverse: bool) -> Vec + where + T::Static: Clone + Send + Sync, + { + fixture!(arena, scope, ctx); + let (list, list_layout) = level(items); + let (keys, keys_layout) = level(keys); + let frames = frames_for(&[&list_layout, &keys_layout]); + let node = install( + SortNode::<_, _, _, T>::new(list, keys, ValueSource::new(reverse)), + sort_layout_meta(), + &[Some(&list_layout), Some(&keys_layout)], + ); + elements(&node, &ctx, &frames) + } + + #[test] + fn sorts_elements_by_their_natural_order() { + assert_eq!( + sorted(vec!["banana".to_string(), "apple".to_string(), "cherry".to_string()], vec![], false), + ["apple", "banana", "cherry"] + ); + } + + #[test] + fn sorts_elements_in_reverse() { + assert_eq!(sorted(vec![3., 1., 2.], vec![], true), [3., 2., 1.]); + } + + #[test] + fn sort_order_keys_override_element_order() { + assert_eq!( + sorted(vec!["apple".to_string(), "banana".to_string(), "cherry".to_string()], vec![2., 0., 1.], false), + ["banana", "cherry", "apple"] + ); + } + + #[test] + fn short_sort_order_repeats_its_last_key() { + assert_eq!(sorted(vec!["a".to_string(), "b".to_string(), "c".to_string()], vec![2., 1.], false), ["b", "c", "a"]); + } + + #[test] + fn long_sort_order_ignores_its_extra_keys() { + assert_eq!(sorted(vec![1., 2.], vec![3., 1., 0., 5.], false), [2., 1.]); + } + + #[test] + fn unsortable_elements_keep_their_original_order() { + let points = vec![DVec2::new(3., 3.), DVec2::new(1., 1.), DVec2::new(2., 2.)]; + assert_eq!(sorted(points.clone(), vec![], false), points); + } + + fn shifted(items: Vec, amount: f64, wrap: bool) -> Vec { + fixture!(arena, scope, ctx); + let (list, layout) = level(items); + let frames = frames_for(&[&layout]); + let node = install( + ShiftNode::<_, _, _, f64>::new(list, ValueSource::new(amount), ValueSource::new(wrap)), + shift_layout_meta(), + &[Some(&layout)], + ); + elements(&node, &ctx, &frames) + } + + #[test] + fn shift_wraps_items_around() { + assert_eq!(shifted(vec![1., 2., 3., 4.], 1., true), [2., 3., 4., 1.]); + assert_eq!(shifted(vec![1., 2., 3., 4.], -1., true), [4., 1., 2., 3.]); + } + + #[test] + fn shift_without_wrapping_drops_items() { + assert_eq!(shifted(vec![1., 2., 3., 4.], 1., false), [2., 3., 4.]); + assert_eq!(shifted(vec![1., 2., 3., 4.], -1., false), [1., 2., 3.]); + } + + #[test] + fn shuffle_is_deterministic_and_preserves_elements() { + let original = vec![1., 2., 3., 4., 5., 6., 7., 8.]; + let shuffled = |seed: u32| { + fixture!(arena, scope, ctx); + let (list, layout) = level(original.clone()); + let frames = frames_for(&[&layout]); + let node = install(ShuffleNode::<_, _, f64>::new(list, ValueSource::new(SeedValue::from(seed))), shuffle_layout_meta(), &[Some(&layout)]); + elements::(&node, &ctx, &frames) + }; + let first = shuffled(42); + assert_eq!(first, shuffled(42), "the same seed should always produce the same ordering"); + + let mut recovered = first; + recovered.sort_by(|a, b| a.total_cmp(b)); + assert_eq!(recovered, original, "shuffling should preserve all the elements"); + } + + #[test] + fn reverse_flips_the_lane_order() { + fixture!(arena, scope, ctx); + let (list, layout) = level(vec![1., 2., 3.]); + let frames = frames_for(&[&layout]); + let node = install(ReverseNode::<_, f64>::new(list), reverse_layout_meta(), &[Some(&layout)]); + assert_eq!(elements::(&node, &ctx, &frames), [3., 2., 1.]); + } + + fn filtered(items: Vec, pattern: Vec) -> Vec { + fixture!(arena, scope, ctx); + let (list, layout) = level(items); + let (pattern, pattern_layout) = level(pattern); + let frames = frames_for(&[&layout, &pattern_layout]); + let node = install(FilterNode::<_, _, f64>::new(list, pattern), filter_layout_meta(), &[Some(&layout), Some(&pattern_layout)]); + elements(&node, &ctx, &frames) + } + + #[test] + fn filter_tiles_the_keep_pattern_over_the_items() { + assert_eq!(filtered(vec![1., 2., 3., 4., 5.], vec![true, false]), [1., 3., 5.]); + assert_eq!(filtered(vec![1., 2., 3., 4., 5.], vec![false, true, true]), [2., 3., 5.]); + assert_eq!(filtered(vec![1., 2., 3.], vec![]), [1., 2., 3.], "an empty pattern keeps everything"); + assert!(filtered(vec![1., 2., 3.], vec![false]).is_empty()); + } + + #[test] + fn number_sequence_generates_evenly_spaced_numbers() { + fixture!(arena, scope, ctx); + let (unit, unit_layout) = lifted_value(()); + let (start, start_layout) = lifted_value(0.); + let (step, step_layout) = lifted_value(2.); + let (count, count_layout) = lifted_value(4_u32); + let frames = frames_for(&[&unit_layout]); + let node = install( + NumberSequenceNode::new(unit, start, step, count, &unit_layout, &start_layout, &step_layout, &count_layout), + number_sequence_layout_meta(), + &[Some(&unit_layout), Some(&start_layout), Some(&step_layout), Some(&count_layout)], + ); + assert_eq!(elements::(&node, &ctx, &frames), [0., 2., 4., 6.]); + } + + #[test] + fn list_indices_counts_each_item() { + let indices = |start: f64| { + fixture!(arena, scope, ctx); + let (list, layout) = level(vec!["a".to_string(), "b".to_string(), "c".to_string()]); + let frames = frames_for(&[&layout]); + let node = install(ListIndicesNode::<_, _, String>::new(list, ValueSource::new(start)), list_indices_layout_meta(), &[Some(&layout)]); + elements::(&node, &ctx, &frames) + }; + assert_eq!(indices(0.), [0., 1., 2.]); + assert_eq!(indices(1.), [1., 2., 3.]); + } + + fn sliced(items: Vec, start: f64, end: f64) -> Vec { + fixture!(arena, scope, ctx); + let (list, layout) = level(items); + let frames = frames_for(&[&layout]); + let node = install( + ListSliceNode::<_, _, _, f64>::new(list, ValueSource::new(start), ValueSource::new(end)), + list_slice_layout_meta(), + &[Some(&layout)], + ); + elements(&node, &ctx, &frames) + } + + #[test] + fn list_slice_takes_the_portion_between_start_and_end() { + assert_eq!(sliced(vec![1., 2., 3., 4., 5.], 1., 3.), [2., 3.]); + } + + #[test] + fn list_slice_resolves_negative_indices_from_the_end() { + assert_eq!(sliced(vec![1., 2., 3., 4., 5.], -2., 0.), [4., 5.], "an end of zero reaches through the end of the list"); + } + + #[test] + fn list_slice_yields_nothing_when_start_reaches_end() { + assert!(sliced(vec![1., 2., 3., 4., 5.], 3., 3.).is_empty()); + } +} + +#[cfg(test)] +mod registry_tests { + use super::*; + + /// A carried subject with implementations registers one row per element + /// type, each naming its concrete carrier rather than the erased generic. + #[test] + fn a_carried_subject_registers_concrete_rows() { + let entries = _sort_mod::sort_entries(); + assert_eq!(entries.len(), 15, "one row per implementation"); + assert_eq!(entries[0].io.inputs[0], core_types::registry::record_source_type::()); + assert_eq!(entries[3].io.inputs[0], core_types::registry::record_source_type::()); + assert_eq!(entries[3].io.return_value, core_types::registry::record_type::()); + } +} diff --git a/node-graph/nodes/gcore/src/record.rs b/node-graph/nodes/gcore/src/record.rs index 17de0aaab4..a497c16915 100644 --- a/node-graph/nodes/gcore/src/record.rs +++ b/node-graph/nodes/gcore/src/record.rs @@ -6,7 +6,7 @@ //! wiring is by hand until the compiler pass constructs layouts. use core_types::Ctx; -use core_types::attribute::{Attr, EditorLayerPath, Opacity, OwnedAttr, RemoveAttr, Transform}; +use core_types::attribute::{Attr, Opacity, OwnedAttr, RemoveAttr, Transform}; use core_types::context::{DeriveCtx, ExtractIndex, IndexLink, InjectIndex, ModifyIndex}; use core_types::extent::{ExtentIn, LevelIn, ListIn, ValueIn}; use core_types::gpoll::{ErrorKind, Extent, GPoll, GraphError, Interrupt, Level}; @@ -62,11 +62,6 @@ fn repeat_opacity(ctx: impl Ctx + ExtractIndex, element: f64, count: u32) -> ILi emit(element, Attr(ctx.index() as f64)) } -#[node_macro::node(category("Test"))] -fn sum(_: impl Ctx, items: IList) -> f64 { - items.into_iter().sum() -} - #[node_macro::node(category("Test"))] fn sum_nested(_: impl Ctx, items: IList>) -> f64 { items.into_iter().sum() @@ -80,39 +75,6 @@ fn repeat_opacity_extent(element: ExtentIn<'_>, count: ValueIn<'_, u32>, level: } } -/// Generic structure creator: evaluates the lazy content once per copy with the -/// copy's index pushed in, producing a rank level of `count` copies. -#[node_macro::node(category("Test"), extent(repeat_extent))] -fn repeat( - ctx: impl Ctx + DeriveCtx + ExtractIndex, - content: impl Node, Output = T>, - #[default(1)] - #[hard(1..)] - count: u32, - reverse: bool, -) -> Result, Interrupt> { - let inner = content.inner_extent(ctx)?; - let (copy, rest) = ctx.split_innermost(inner); - if copy >= count as u64 { - return Err(GraphError::past_end().into()); - } - let copy = match reverse { - true => count as u64 - 1 - copy, - false => copy, - }; - let mut frame = IndexLink { index: 0, outer: None }; - content.eval(&ctx.push_level(&mut frame, copy, rest)) -} - -/// The pushed level's extent is the copy count; inner levels forward to the -/// content, whose extent is taken uniform across copies (queried at copy 0). -fn repeat_extent(content: ExtentIn<'_>, count: ValueIn<'_, u32>, _reverse: ValueIn<'_, bool>, level: LevelIn) -> GPoll { - match level.pushed() { - true => count.get().map(|count| Extent::Exactly(count as usize)), - false => content.at(level), - } -} - /// Test-only lazy-carrier creator: each copy evaluates the content at its own /// index and re-scales the row's opacity by the copy number. #[node_macro::node(category("Test"), extent(repeat_faded_extent))] @@ -136,51 +98,6 @@ fn repeat_faded_extent(content: ExtentIn<'_>, count: ValueIn<'_, u32>, level: Le } } -/// Rank-model Extend: the output's top level is `base`'s lanes followed by -/// `new`'s, each side evaluated within its own index range. -#[node_macro::node(category("Test"), extent(extend_extent))] -fn extend(ctx: impl Ctx + ExtractIndex + InjectIndex + Copy, base: impl Node, Output = T>, new: impl Node, Output = T>) -> Result { - 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 defers to the other. -fn extend_extent(base: ExtentIn<'_>, new: ExtentIn<'_>, level: LevelIn) -> GPoll { - 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, new) if base == new => GPoll::Final(base), - _ => GPoll::error("extend inner extents differ"), - }), - } -} - -/// The layer-path stamp: writes the owning layer's document node path on each lane. -#[node_macro::node(category("Test"))] -fn stamp_layer_path<'e, T>(ctx: impl Ctx + ExtractArena<'e>, element: T, path: Vec) -> Result<(T, Attr<'e, EditorLayerPath>), Interrupt> { - let (parked, _) = ctx.arena().alloc(path).ok_or(GraphError { - kind: ErrorKind::ArenaExhausted, - trace: Vec::new(), - })?; - Ok((element, Attr(parked.as_slice()))) -} - /// 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 { @@ -191,36 +108,6 @@ fn resolve_index(index: f64, total: u64) -> Option { } } -/// Rank-model Omit Element: the top level shrinks by one; lanes at or past -/// the omitted index read one lane further. An out-of-range index passes the -/// level through unchanged. -#[node_macro::node(category("Test"), extent(omit_element_extent))] -fn omit_element(ctx: impl Ctx + ModifyIndex + Copy, content: impl Node, Output = T>, index: f64) -> Result { - let total = match content.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); - content.eval(&shifted) -} - -fn omit_element_extent(content: ExtentIn<'_>, index: ValueIn<'_, f64>, level: LevelIn) -> GPoll { - match level.top() { - true => index.get().zip(content.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 => content.at(level), - } -} - /// Rank-model Index Elements: a one-lane level holding the item at the index /// with its attributes, or an empty level when the index is out of range. #[node_macro::node(category("Test"), extent(index_elements_extent))] @@ -248,13 +135,6 @@ fn index_elements_extent(content: ExtentIn<'_>, index: ValueIn<'_, f64>, level: } } -/// Rank-model Extract Element: the bare element at the index, or the element -/// type's default when the index is out of range. -#[node_macro::node(category("Test"))] -fn extract_element(_: impl Ctx + InjectIndex + Copy, list: IList, index: f64) -> f64 { - resolve_index(index, list.len() as u64).map(|resolved| list.get(resolved as usize)).unwrap_or_default() -} - /// Rank-model Mirror kernel: the level holds the content's lanes followed by /// reflected copies (or the reflected copies alone), each reflected transform /// mirrored about the level's horizontal center. @@ -455,325 +335,19 @@ fn forward_record(_: impl Ctx, element: T) -> T { #[cfg(test)] mod tests { use super::*; + use crate::list::{ExtendNode, ItemAtIndexNode, RemoveAtIndexNode}; use core_types::SourceId; 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::gpoll::GPoll; use core_types::node::Node; + use core_types::record::test_fixtures::*; use core_types::record::{FrameClaim, Layout, LiftedSource, Rec, RecordSource, Served}; use core_types::value::ValueSource; - - struct RecordSourceNode { - layout: Layout, - element: E, - fields: Vec<(&'static str, f64)>, - partial: bool, - } - - impl Node for RecordSourceNode { - fn serve<'e, 'l>(&self, input: &C, slot: FrameClaim<'e, 'l>) -> GPoll> - where - C: ExtractArena, - { - let mut frame = slot; - let arena = ExtractArena::arena(input); - if frame.element(self.element, arena).is_none() { - return GPoll::arena_exhausted(); - } - for (name, field) in &self.fields { - write_field_at(&mut frame, &self.layout, name, 0, *field); - } - // SAFETY: the writes above complete the record of this layout. - let served = unsafe { frame.finish_served() }; - match self.partial { - true => GPoll::Partial(served), - false => GPoll::Final(served), - } - } - - fn layout(&self) -> &Layout { - &self.layout - } - } - - struct LeveledSourceNode { - layout: Layout, - elements: Vec, - field: Option<(&'static str, f64)>, - } - - impl Node for LeveledSourceNode { - fn serve<'e, 'l>(&self, input: &C, slot: FrameClaim<'e, 'l>) -> GPoll> - where - C: ExtractArena, - { - let element = self.elements[input.innermost_index() as usize % self.elements.len()]; - let mut frame = slot; - let arena = ExtractArena::arena(input); - if frame.element(element, arena).is_none() { - return GPoll::arena_exhausted(); - } - if let Some((name, value)) = self.field { - write_field_at(&mut frame, &self.layout, name, 0, value); - } - // SAFETY: the writes above complete the record of this layout. - GPoll::Final(unsafe { frame.finish_served() }) - } - - fn extent_at<'x>(&self, _input: &C, _level: u8, _frames: &core_types::record::Frames<'x>) -> GPoll - where - C: ExtractArena, - { - GPoll::Final(Extent::Exactly(self.elements.len())) - } - - fn layout(&self) -> &Layout { - &self.layout - } - } - - struct LeveledTransformSource { - layout: Layout, - rows: Vec<(f64, DAffine2)>, - } - - impl Node for LeveledTransformSource { - fn serve<'e, 'l>(&self, input: &C, slot: FrameClaim<'e, 'l>) -> GPoll> - where - C: ExtractArena, - { - let (element, transform) = self.rows[input.innermost_index() as usize % self.rows.len()]; - let mut frame = slot; - let arena = ExtractArena::arena(input); - if frame.element(element, arena).is_none() { - return GPoll::arena_exhausted(); - } - write_attr_at::(&mut frame, &self.layout, transform); - // SAFETY: the writes above complete the record of this layout. - GPoll::Final(unsafe { frame.finish_served() }) - } - - fn extent_at<'x>(&self, _input: &C, _level: u8, _frames: &core_types::record::Frames<'x>) -> GPoll - where - C: ExtractArena, - { - GPoll::Final(Extent::Exactly(self.rows.len())) - } - - fn layout(&self) -> &Layout { - &self.layout - } - } - - /// Serves lanes carrying both a Transform no gather kernel declares and an - /// Opacity one does, so a carried column can be told apart from a written one. - struct LeveledCarriedSource { - layout: Layout, - rows: Vec<(f64, DAffine2, f64)>, - } - - impl Node for LeveledCarriedSource { - fn serve<'e, 'l>(&self, input: &C, slot: FrameClaim<'e, 'l>) -> GPoll> - where - C: ExtractArena, - { - let (element, transform, opacity) = self.rows[input.innermost_index() as usize % self.rows.len()]; - let mut frame = slot; - let arena = ExtractArena::arena(input); - if frame.element(element, arena).is_none() { - return GPoll::arena_exhausted(); - } - write_attr_at::(&mut frame, &self.layout, transform); - write_attr_at::(&mut frame, &self.layout, opacity); - // SAFETY: the writes above complete the record of this layout. - GPoll::Final(unsafe { frame.finish_served() }) - } - - fn extent_at<'x>(&self, _input: &C, _level: u8, _frames: &core_types::record::Frames<'x>) -> GPoll - where - C: ExtractArena, - { - GPoll::Final(Extent::Exactly(self.rows.len())) - } - - fn layout(&self) -> &Layout { - &self.layout - } - } - - /// A leveled source that keeps its count to itself: the extent is a lower - /// bound and lanes past the data answer the past-end signal. - struct DrainSourceNode { - layout: Layout, - count: usize, - } - - impl Node for DrainSourceNode { - fn serve<'e, 'l>(&self, input: &C, slot: FrameClaim<'e, 'l>) -> GPoll> - where - C: ExtractArena, - { - let lane = input.innermost_index(); - if lane >= self.count as u64 { - return GPoll::past_end(); - } - let mut frame = slot; - let arena = ExtractArena::arena(input); - if frame.element(lane as f64, arena).is_none() { - return GPoll::arena_exhausted(); - } - // SAFETY: the writes above complete the record of this layout. - GPoll::Final(unsafe { frame.finish_served() }) - } - - fn extent_at<'x>(&self, _input: &C, _level: u8, _frames: &core_types::record::Frames<'x>) -> GPoll - where - C: ExtractArena, - { - GPoll::Final(Extent::AtLeast(0)) - } - - fn layout(&self) -> &Layout { - &self.layout - } - } - - struct IndexSourceNode { - layout: Layout, - } - - impl Node for IndexSourceNode { - fn serve<'e, 'l>(&self, input: &C, slot: FrameClaim<'e, 'l>) -> GPoll> - where - C: ExtractArena, - { - // Depth-0 content varying per copy: the enclosing (pushed) level's - // index sits one link above the content's own innermost lane. - let element = input.try_index().and_then(|mut indices| indices.nth(1)).unwrap_or(0) as f64; - let mut frame = slot; - let arena = ExtractArena::arena(input); - if frame.element(element, arena).is_none() { - return GPoll::arena_exhausted(); - } - // SAFETY: the writes above complete the record of this layout. - GPoll::Final(unsafe { frame.finish_served() }) - } - - fn layout(&self) -> &Layout { - &self.layout - } - } - - /// Writes a field at the layout's resolved offset, the wiring-proven pairing - /// a generated node performs. - fn write_field_at(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::(), "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(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 f64_layout(names: &[&'static str]) -> Layout { - let writes: Vec = names - .iter() - .map(|name| core_types::record::FieldWrite { - name, - level: 0, - size: 8, - align: 8, - type_id: std::any::TypeId::of::(), - read_erased: ::read_erased, - repark: None, - content_hash: None, - content_eq: None, - }) - .collect(); - Layout::default().with_writes(0, core_types::record::element_write::(), &writes) - } - - fn leveled_f64_layout(names: &[&'static str]) -> Layout { - let writes: Vec = names - .iter() - .map(|name| core_types::record::FieldWrite { - name, - level: 0, - size: 8, - align: 8, - type_id: std::any::TypeId::of::(), - read_erased: ::read_erased, - repark: None, - content_hash: None, - content_eq: None, - }) - .collect(); - Layout::default().with_writes(1, core_types::record::element_write::(), &writes) - } - - fn frames_for(layouts: &[&Layout]) -> core_types::record::Frames<'static> { - core_types::record::test_frames(layouts.iter().map(|layout| layout.frame_bytes()).sum::().max(1 << 12)) - } - - fn install>>(mut node: N, meta: core_types::record::LayoutMeta, inputs: &[Option<&Layout>]) -> N { - // The fixtures wire constants into every eager input, which the compiler - // pass records as lane-invariant. - let resolved = core_types::record::RecordLayout { - named_writes: Vec::new(), - named_reads: Vec::new(), - named_read_defaults: Vec::new(), - lane_invariant: u32::MAX, - ..meta.resolve(inputs) - }; - >>::set_layout(&mut node, resolved); - node - } - - fn install_flip>>(mut node: N, layout: &Layout) -> N { - let bundle = core_types::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, - }; - >>::set_layout(&mut node, bundle); - node - } - - fn lifted_value(value: T) -> (ValueSource, Layout) - where - T::Static: Clone + Send + Sync, - { - let lift = ValueSource::new(value); - let layout = Node::::layout(&lift).clone(); - (lift, layout) - } - - fn bare_source(layout: &Layout, element: f64) -> RecordSourceNode { - RecordSourceNode { - layout: layout.clone(), - element, - fields: vec![], - partial: false, - } - } + use graphic_nodes::graphic::{StampLayerPathNode, stamp_layer_path_layout_meta}; + use math_nodes::{SumNode, sum_layout_meta}; + use repeat_nodes::repeat_nodes::RepeatNode; #[test] fn creator_pushes_a_rank_level() { @@ -1405,7 +979,7 @@ mod tests { }; let (index_edge, index_layout) = lifted_value(index); install( - OmitElementNode::new(RecordSource::new(content, &layout, &layout), index_edge, &layout, &index_layout), + RemoveAtIndexNode::new(RecordSource::new(content, &layout, &layout), index_edge, &layout, &index_layout), meta(), &[Some(&layout)], ) @@ -1495,7 +1069,10 @@ mod tests { field: None, }; let (index_edge, index_layout) = lifted_value(index); - let node = install_flip(ExtractElementNode::new(RecordSource::new(content, &layout, &layout), index_edge, &layout, &index_layout), &out); + let node = install_flip( + ItemAtIndexNode::<_, _, f64>::new(RecordSource::new(content, &layout, &layout), index_edge, &layout, &index_layout), + &out, + ); let GPoll::Final(served) = core_types::record::capture(&node, &ctx, &frames) else { panic!("expected a final record"); }; diff --git a/node-graph/nodes/graphic/Cargo.toml b/node-graph/nodes/graphic/Cargo.toml index c67322948e..aaf4637149 100644 --- a/node-graph/nodes/graphic/Cargo.toml +++ b/node-graph/nodes/graphic/Cargo.toml @@ -17,3 +17,7 @@ dyn-any = { workspace = true } glam = { workspace = true } serde = { workspace = true } node-macro = { workspace = true } + +[dev-dependencies] +# The map tests drive the generic Map over graphic rows +graphene-core = { workspace = true } diff --git a/node-graph/nodes/graphic/src/graphic.rs b/node-graph/nodes/graphic/src/graphic.rs index a4369aad9c..81d6664174 100644 --- a/node-graph/nodes/graphic/src/graphic.rs +++ b/node-graph/nodes/graphic/src/graphic.rs @@ -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 { - 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( - ctx: impl Ctx + ModifyIndex + Copy, - /// The list of data. - list: impl Node, 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 { - 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 { - 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( - _: impl Ctx, - /// The `List` of data to extract from. - #[implementations(String, f64, NodeId, Color, Gradient, Vector, Raster, Graphic, Artboard)] - list: IList, - /// 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( - ctx: impl Ctx + DeriveCtx + ExtractIndex + InjectIndex + Copy, - #[implementations(Graphic, Vector, Raster, Color, Gradient, String)] content: IList, - mapped: impl Node, Output = IList>, -) -> Result, 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(legacy: &List, relative_to_bounds: ReferencePoint, offset: f64, angle: f64) -> Option @@ -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( - ctx: impl Ctx + ExtractIndex + InjectIndex + Copy, - /// The input whose lanes appear at the start of the extended level. - base: impl Node, Output = T>, - /// The input whose lanes appear at the end of the extended level. - #[expose] - new: impl Node, Output = T>, -) -> Result { - 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 { - 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. diff --git a/node-graph/nodes/graphic/src/record.rs b/node-graph/nodes/graphic/src/record.rs index 40958c111a..563cff3bb6 100644 --- a/node-graph/nodes/graphic/src/record.rs +++ b/node-graph/nodes/graphic/src/record.rs @@ -223,15 +223,16 @@ fn flatten_levels_extent(content: ExtentIn<'_>, level: LevelIn) -> GPoll #[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(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::(), "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(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>>(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) - }; - >>::set_layout(&mut node, resolved); - node - } - - fn install_flip>>(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, - }; - >>::set_layout(&mut node, bundle); - node - } - fn graphic_layout() -> Layout { Layout::default().with_writes(1, record::element_write_hashed::(), &[record::FieldWrite::of::(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 = entries.iter().map(|entry| entry.io.inputs[0].clone()).collect(); assert_eq!(content_types[0], core_types::registry::record_source_type::()); diff --git a/node-graph/nodes/gstd/src/lib.rs b/node-graph/nodes/gstd/src/lib.rs index 6fe946fc0e..e2e4fd02af 100644 --- a/node-graph/nodes/gstd/src/lib.rs +++ b/node-graph/nodes/gstd/src/lib.rs @@ -82,6 +82,12 @@ pub mod context { pub use graphene_core::context::*; } +// The list types beside the generic level kernels over them. +pub mod list { + pub use core_types::list::*; + pub use graphene_core::list::*; +} + // Re-export graphene_core modules for backward compatibility pub mod ops { pub use core_types::ops::*; diff --git a/node-graph/nodes/gstd/src/text.rs b/node-graph/nodes/gstd/src/text.rs index 93afa65beb..88602ba235 100644 --- a/node-graph/nodes/gstd/src/text.rs +++ b/node-graph/nodes/gstd/src/text.rs @@ -127,15 +127,6 @@ pub struct ShapedRows { type ShapedCache = std::sync::Arc>>; -/// The lane-normalized cache key and arena generation of one evaluation. -macro_rules! eval_key { - ($ctx:expr) => {{ - let mut keyed = *$ctx; - InjectIndex::set_index(&mut keyed, 0); - (core_types::registry::cache_key(&keyed), $ctx.arena().generation()) - }}; -} - /// The `lane`-th path over all the strings' shaped rows, carrying its /// string's columns with the composed transform overriding. `key` and /// `generation` scope the cache to one evaluation. @@ -186,7 +177,7 @@ fn text_to_vector<'e>( strings: IList, #[data] shaped: ShapedCache, ) -> Result, Attr<'e, TransformAttr>)>, Interrupt> { - shaped_lane(strings, ctx.index() as usize, eval_key!(ctx), shaped, false) + shaped_lane(strings, ctx.index() as usize, core_types::registry::eval_key(ctx), shaped, false) } fn text_to_vector_extent(strings: ListIn<'_, String>, level: LevelIn) -> GPoll { @@ -201,7 +192,7 @@ fn text_to_vector_glyphs<'e>( strings: IList, #[data] shaped: ShapedCache, ) -> Result, Attr<'e, TransformAttr>)>, Interrupt> { - shaped_lane(strings, ctx.index() as usize, eval_key!(ctx), shaped, true) + shaped_lane(strings, ctx.index() as usize, core_types::registry::eval_key(ctx), shaped, true) } fn text_to_vector_glyphs_extent(strings: ListIn<'_, String>, level: LevelIn) -> GPoll { diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index c31874a186..aedd16f293 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -639,6 +639,45 @@ fn binary_gcd + std::ops: a << shift } +/// Adds together all the numbers in the input list, producing their total. +#[node_macro::node(category("Math: Numeric"))] +pub fn sum(_: impl Ctx, values: IList) -> f64 { + values.iter().sum() +} + +/// Averages all the numbers in the input list. An empty list gives 0. +#[node_macro::node(category("Math: Numeric"))] +fn average(_: impl Ctx, values: IList) -> f64 { + let count = values.len(); + let average = if count == 0 { 0. } else { values.iter().sum::() / count as f64 }; + + average +} + +/// Gives the smallest number in the input list. An empty list gives 0. +#[node_macro::node(category("Math: Numeric"))] +fn minimum(_: impl Ctx, values: IList) -> f64 { + values.iter().reduce(f64::min).unwrap_or_default() +} + +/// Gives the largest number in the input list. An empty list gives 0. +#[node_macro::node(category("Math: Numeric"))] +fn maximum(_: impl Ctx, values: IList) -> f64 { + values.iter().reduce(f64::max).unwrap_or_default() +} + +/// Outputs true if at least one value in the input list is true. An empty list gives false. +#[node_macro::node(category("Math: Logic"))] +fn any(_: impl Ctx, values: IList) -> bool { + values.iter().any(|value| value) +} + +/// Outputs true only if every value in the input list is true. An empty list gives true. +#[node_macro::node(category("Math: Logic"))] +fn all(_: impl Ctx, values: IList) -> bool { + values.iter().all(|value| value) +} + /// The less-than operation (`<`) compares two values and returns true if the first value is less than the second, or false if it is not. /// If enabled with *Or Equal*, the less-than-or-equal operation (`<=`) is used instead. #[node_macro::node(category("Math: Logic"))] diff --git a/node-graph/nodes/repeat/src/repeat_nodes.rs b/node-graph/nodes/repeat/src/repeat_nodes.rs index 013e894f4c..dbbc24fc11 100644 --- a/node-graph/nodes/repeat/src/repeat_nodes.rs +++ b/node-graph/nodes/repeat/src/repeat_nodes.rs @@ -12,7 +12,7 @@ use graphic_types::Vector; /// producing a level of `count` copies. // Someday this node can have the option to generate infinitely instead of a fixed count (basically `std::iter::repeat`). #[node_macro::node(category("Repeat"), extent(repeat_extent))] -fn repeat( +pub fn repeat( ctx: impl Ctx + DeriveCtx + ExtractIndex, content: impl Node, Output = T>, #[default(1)]