diff --git a/node-graph/libraries/core-types/src/attribute.rs b/node-graph/libraries/core-types/src/attribute.rs
new file mode 100644
index 0000000000..2328060032
--- /dev/null
+++ b/node-graph/libraries/core-types/src/attribute.rs
@@ -0,0 +1,227 @@
+//! Attribute markers and their census. A marker declares an attribute name
+//! once, fixing its value type and its name-specific default; the census
+//! collects every declaration so name resolution, defaults, and diagnostics
+//! run at graph compile time. One name belongs to one marker, so a name can
+//! never mean two different types.
+
+use crate::list::AnyAttributeValue;
+use glam::{DAffine2, DVec2};
+use std::any::TypeId;
+use std::collections::HashMap;
+use std::collections::hash_map::Entry;
+use std::ops::Deref;
+use std::sync::{LazyLock, Mutex};
+
+/// Declares an attribute name: one marker per name, fixing the value type and
+/// the name-specific default. Declare markers through the [`attribute!`]
+/// macro, which also registers them into the [`ATTRIBUTE_REGISTRY`].
+pub trait Attribute: 'static {
+ /// The name as it appears in documents and diagnostics.
+ const NAME: &'static str;
+ /// The value type every read and write of this name shares.
+ type Value: AnyAttributeValue + Clone + Default + std::fmt::Debug;
+ /// The name-specific default, filled where an item lacks the attribute.
+ fn default() -> Self::Value {
+ Self::Value::default()
+ }
+}
+
+/// A kernel-facing attribute value. A parameter `Attr` is a read of `A`
+/// (yielding the declared default where the attribute is absent upstream), an
+/// `Attr` in the return tuple is a write, and the same marker on both
+/// sides is a modify.
+pub struct Attr(pub A::Value);
+
+impl Deref for Attr {
+ type Target = A::Value;
+
+ fn deref(&self) -> &A::Value {
+ &self.0
+ }
+}
+
+impl Clone for Attr {
+ fn clone(&self) -> Self {
+ Attr(self.0.clone())
+ }
+}
+
+impl std::fmt::Debug for Attr {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_tuple(A::NAME).field(&self.0).finish()
+ }
+}
+
+/// A census row: what is known about one declared attribute name.
+#[derive(Clone, Copy, Debug)]
+pub struct AttributeInfo {
+ pub name: &'static str,
+ pub value_type: TypeId,
+ pub value_type_name: &'static str,
+ pub default: fn() -> Box,
+ pub size: usize,
+ pub align: usize,
+ /// Whether the value is eligible for packed-record fields: no drop glue,
+ /// so its bytes copy freely. Droppable values stay on the erased path
+ /// until the per-type clone/drop glue lands.
+ pub packable: bool,
+ /// Writes the declared default's bytes into a `size`-long slice.
+ /// Meaningful only when `packable`.
+ pub write_default_bytes: fn(&mut [u8]),
+}
+
+fn write_default_bytes(out: &mut [u8]) {
+ assert!(!std::mem::needs_drop::(), "default bytes exist only for packable values");
+ assert_eq!(out.len(), size_of::());
+ let value = A::default();
+ unsafe { std::ptr::copy_nonoverlapping((&raw const value).cast::(), out.as_mut_ptr(), size_of::()) };
+}
+
+/// All declared attribute names, keyed by name.
+pub static ATTRIBUTE_REGISTRY: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new()));
+
+/// Registers `A` into the census. Called by the [`attribute!`] expansion at
+/// startup (ctor natively, a `__node_registry_attribute_*` export on wasm).
+/// Re-registration at the same value type is idempotent; a second marker
+/// claiming the name at a different value type panics.
+pub fn register() {
+ let info = AttributeInfo {
+ name: A::NAME,
+ value_type: TypeId::of::(),
+ value_type_name: std::any::type_name::(),
+ default: || Box::new(A::default()),
+ size: size_of::(),
+ align: align_of::(),
+ packable: !std::mem::needs_drop::(),
+ write_default_bytes: write_default_bytes::,
+ };
+ let conflict = match ATTRIBUTE_REGISTRY.lock().unwrap().entry(A::NAME) {
+ Entry::Vacant(vacant) => {
+ vacant.insert(info);
+ None
+ }
+ Entry::Occupied(occupied) => (occupied.get().value_type != info.value_type).then(|| occupied.get().value_type_name),
+ };
+ if let Some(existing) = conflict {
+ panic!("attribute `{}` is declared at two value types: {existing} and {}", A::NAME, info.value_type_name);
+ }
+}
+
+/// Looks up a declared name.
+pub fn info(name: &str) -> Option {
+ ATTRIBUTE_REGISTRY.lock().unwrap().get(name).copied()
+}
+
+/// The name-specific default for `name`, if the name is declared.
+pub fn default_value(name: &str) -> Option> {
+ info(name).map(|info| (info.default)())
+}
+
+/// Declares attribute markers: for each entry, the marker struct, its
+/// [`Attribute`] impl, and the census registration.
+///
+/// ```
+/// core_types::attribute! {
+/// /// How visible the content is.
+/// pub Opacity("opacity"): f64 = 1.;
+/// /// The item's transformation.
+/// pub Transform("transform"): glam::DAffine2;
+/// }
+/// ```
+///
+/// The trailing `= expr` is the name-specific default; without it the value
+/// type's `Default` applies.
+#[macro_export]
+macro_rules! attribute {
+ ($($(#[$meta:meta])* $vis:vis $marker:ident($name:literal): $value:ty $(= $default:expr)?;)+) => {
+ $(
+ $(#[$meta])*
+ $vis struct $marker;
+
+ impl $crate::attribute::Attribute for $marker {
+ const NAME: &'static str = $name;
+ type Value = $value;
+ $(
+ fn default() -> $value {
+ $default
+ }
+ )?
+ }
+
+ const _: () = {
+ #[cfg(not(target_family = "wasm"))]
+ #[$crate::ctor::ctor]
+ fn register() {
+ $crate::attribute::register::<$marker>();
+ }
+
+ #[cfg(target_family = "wasm")]
+ #[unsafe(export_name = concat!("__node_registry_attribute_", stringify!($marker)))]
+ extern "C" fn register() {
+ $crate::attribute::register::<$marker>();
+ }
+ };
+ )+
+ };
+}
+
+attribute! {
+ /// Item's `DAffine2` transformation, composed multiplicatively through nested groups.
+ pub Transform("transform"): DAffine2;
+ /// Item's `BlendMode`, controlling how it composites with content beneath it.
+ pub BlendMode("blend_mode"): crate::blending::BlendMode;
+ /// Item's opacity multiplier, composed multiplicatively through nested groups.
+ /// Affects content clipped to the item.
+ pub Opacity("opacity"): f64 = 1.;
+ /// Item's fill opacity multiplier. Like opacity but does not affect content clipped to the item.
+ pub OpacityFill("opacity_fill"): f64 = 1.;
+ /// Whether an item inherits the alpha of the content beneath it (clipping mask).
+ pub ClippingMask("clipping_mask"): bool;
+ /// Artboard's top-left corner in document coordinates.
+ pub Location("location"): DVec2;
+ /// A regex named-capture-group's name, or empty for unnamed groups.
+ pub Name("name"): String;
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn census_carries_declared_names() {
+ let row = info("opacity").unwrap();
+ assert_eq!(row.value_type, TypeId::of::());
+ assert_eq!(info("transform").unwrap().value_type, TypeId::of::());
+ assert!(info("never_declared").is_none());
+ }
+
+ #[test]
+ fn name_specific_default_overrides_the_type_default() {
+ assert_eq!(::default(), 1.);
+ assert_eq!(::default(), String::new());
+ }
+
+ #[test]
+ fn erased_default_downcasts_to_the_declared_type() {
+ let value = default_value("opacity_fill").unwrap();
+ assert_eq!(*value.as_any().downcast_ref::().unwrap(), 1.);
+ }
+
+ #[test]
+ fn reregistration_at_the_same_type_is_idempotent() {
+ register::();
+ register::();
+ assert_eq!(info("opacity").unwrap().value_type, TypeId::of::());
+ }
+
+ #[test]
+ #[should_panic(expected = "two value types")]
+ fn a_second_marker_at_a_different_type_panics() {
+ struct Conflict;
+ impl Attribute for Conflict {
+ const NAME: &'static str = "opacity";
+ type Value = bool;
+ }
+ register::();
+ }
+}
diff --git a/node-graph/libraries/core-types/src/context.rs b/node-graph/libraries/core-types/src/context.rs
index b122f673d1..e3247be208 100644
--- a/node-graph/libraries/core-types/src/context.rs
+++ b/node-graph/libraries/core-types/src/context.rs
@@ -624,6 +624,7 @@ pub struct EvalScope<'a> {
pointer_position: Option,
generations: &'a [(SourceId, u64)],
arena: &'a Arena,
+ frame: Option<&'a crate::record::Frame>,
hash: u64,
}
@@ -635,12 +636,19 @@ impl<'a> EvalScope<'a> {
pointer_position,
generations,
arena,
+ frame: None,
hash: 0,
};
scope.hash = scope.compute_hash(|_| true);
scope
}
+ /// Attaches the record frame. Operational like the arena: not part of the
+ /// scope hash.
+ pub fn with_frame(&self, frame: &'a crate::record::Frame) -> EvalScope<'a> {
+ EvalScope { frame: Some(frame), ..*self }
+ }
+
pub fn with_real_time(&self, real_time: Option) -> EvalScope<'a> {
let mut scope = EvalScope { real_time, ..*self };
scope.hash = scope.compute_hash(|_| true);
@@ -700,6 +708,23 @@ impl<'a> EvalScope<'a> {
pub fn arena(&self) -> &'a Arena {
self.arena
}
+
+ pub fn frame(&self) -> Option<&'a crate::record::Frame> {
+ self.frame
+ }
+}
+
+/// Read access to the record frame, the operational sibling of
+/// [`ExtractArena`]. `None` on contexts whose scope carries no frame (a
+/// graph without record edges allocates none).
+pub trait ExtractFrame<'e> {
+ fn frame(&self) -> Option<&'e crate::record::Frame>;
+}
+
+impl<'a> ExtractFrame<'a> for ContextImpl<'a> {
+ fn frame(&self) -> Option<&'a crate::record::Frame> {
+ self.scope.frame()
+ }
}
pub trait ExtractArena {
diff --git a/node-graph/libraries/core-types/src/lib.rs b/node-graph/libraries/core-types/src/lib.rs
index 5906c6ab65..ff702baa5a 100644
--- a/node-graph/libraries/core-types/src/lib.rs
+++ b/node-graph/libraries/core-types/src/lib.rs
@@ -1,6 +1,7 @@
extern crate log;
pub mod arena;
+pub mod attribute;
pub mod bounds;
pub mod consts;
pub mod context;
@@ -12,6 +13,7 @@ pub mod memo;
pub mod misc;
pub mod node;
pub mod ops;
+pub mod record;
pub mod registry;
pub mod render_complexity;
pub mod runtime;
diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs
index b0d5888115..e52edcded3 100644
--- a/node-graph/libraries/core-types/src/list.rs
+++ b/node-graph/libraries/core-types/src/list.rs
@@ -954,6 +954,15 @@ impl List {
}
}
+ /// Creates a list from element values with no attributes.
+ pub fn from_element_values(element: Vec) -> Self {
+ let len = element.len();
+ Self {
+ element,
+ attributes: Attributes::with_len(len),
+ }
+ }
+
/// Creates a list containing a single item from the given [`Item`], preserving its attributes.
pub fn new_from_item(item: Item) -> Self {
let mut attributes = Attributes::new();
@@ -1000,6 +1009,11 @@ impl List {
self.element.iter()
}
+ /// Consumes the list, returning its element values and dropping its attributes.
+ pub fn into_element_values(self) -> Vec {
+ self.element
+ }
+
/// Returns an iterator over mutable references to all element values.
pub fn iter_element_values_mut(&mut self) -> std::slice::IterMut<'_, T> {
self.element.iter_mut()
@@ -1087,6 +1101,20 @@ impl List {
}
}
+ /// Removes and returns the attribute column for the given key, if present.
+ pub fn take_attribute_dyn(&mut self, key: &str) -> Option {
+ let position = self.attributes.attributes.iter().position(|(k, _)| k == key)?;
+ Some(AttributeDyn(self.attributes.attributes.remove(position).1))
+ }
+
+ /// Replaces (or adds) an attribute, taking ownership of the column, whose length must equal this list's item count.
+ pub fn insert_attribute_dyn(&mut self, key: impl Into, column: AttributeDyn) {
+ assert_eq!(column.len(), self.element.len(), "attribute column length must match the list's item count");
+ let key = key.into();
+ self.attributes.attributes.retain(|(k, _)| k != &key);
+ self.attributes.attributes.push((key, column.0));
+ }
+
/// Removes the entire attribute for the given key, if present.
pub fn remove_attribute(&mut self, key: &str) {
self.attributes.remove_attribute(key);
diff --git a/node-graph/libraries/core-types/src/record.rs b/node-graph/libraries/core-types/src/record.rs
new file mode 100644
index 0000000000..bcb07acbaf
--- /dev/null
+++ b/node-graph/libraries/core-types/src/record.rs
@@ -0,0 +1,390 @@
+//! The packed-record tier at rank 0. A record is the element at offset 0
+//! plus one field per written attribute; its [`Layout`] is computed at
+//! wiring from the upstream write set and never serialized. Records live as
+//! per-lane views in a per-worker [`Frame`] whose slots are assigned at
+//! wiring, and kernels route them as opaque [`RecordValue`]s that carry
+//! their provenance. Only generated or wiring code touches offsets, so a
+//! safe kernel cannot misalign a field.
+
+use crate::attribute;
+use crate::gpoll::GPoll;
+use crate::node::Node;
+use std::cell::UnsafeCell;
+
+/// One field of a [`Layout`]: a (name, level) key resolved to an offset.
+/// Levels are numbered innermost-out; only level 0 exists at rank 0.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct FieldDesc {
+ pub name: &'static str,
+ pub level: u8,
+ pub offset: usize,
+ pub size: usize,
+ pub align: usize,
+}
+
+/// A record layout: the element at offset 0, then the written attributes in
+/// canonical order (descending alignment, then size, then name, then level).
+/// Layouts are derived data, a pure function of the upstream write set.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct Layout {
+ pub depth: u8,
+ pub element_size: usize,
+ pub element_align: usize,
+ pub fields: Vec,
+ pub size: usize,
+ pub align: usize,
+}
+
+impl Layout {
+ pub fn offset_of(&self, name: &str, level: u8) -> Option {
+ self.fields.iter().find(|field| field.name == name && field.level == level).map(|field| field.offset)
+ }
+
+ /// The union of this layout's fields and `writes` over an element of
+ /// (size, align) at `depth`, in canonical order. A (name, level) written
+ /// at a different size is a type conflict and panics; the census keeps
+ /// declared names to one type, so this only fires on wiring bugs.
+ pub fn with_writes(&self, depth: u8, element: (usize, usize), writes: &[(&'static str, u8, usize, usize)]) -> Layout {
+ let mut merged: Vec<(&'static str, u8, usize, usize)> = self.fields.iter().map(|field| (field.name, field.level, field.size, field.align)).collect();
+ for &(name, level, size, align) in writes {
+ match merged.iter().find(|(n, l, ..)| *n == name && *l == level) {
+ Some(&(.., existing_size, _)) => assert_eq!(existing_size, size, "attribute `{name}` written at two different sizes"),
+ None => merged.push((name, level, size, align)),
+ }
+ }
+ merged.sort_by(|a, b| b.3.cmp(&a.3).then(b.2.cmp(&a.2)).then(a.0.cmp(b.0)).then(a.1.cmp(&b.1)));
+ let (element_size, element_align) = element;
+ let mut offset = element_size;
+ let mut align = element_align.max(1);
+ let fields = merged
+ .into_iter()
+ .map(|(name, level, size, field_align)| {
+ offset = offset.next_multiple_of(field_align.max(1));
+ align = align.max(field_align);
+ let desc = FieldDesc {
+ name,
+ level,
+ offset,
+ size,
+ align: field_align,
+ };
+ offset += size;
+ desc
+ })
+ .collect();
+ Layout {
+ depth,
+ element_size,
+ element_align,
+ fields,
+ size: offset,
+ align,
+ }
+ }
+
+ /// The union of several layouts over the same element and depth.
+ pub fn union(layouts: &[&Layout]) -> Layout {
+ let first = layouts.first().expect("a union needs at least one layout");
+ let mut union = Layout::default().with_writes(first.depth, (first.element_size, first.element_align), &[]);
+ for layout in layouts {
+ assert_eq!(union.element_size, layout.element_size, "union layouts must share the element size");
+ assert_eq!(union.element_align, layout.element_align, "union layouts must share the element alignment");
+ assert_eq!(union.depth, layout.depth, "union layouts must share the depth");
+ let writes: Vec<(&'static str, u8, usize, usize)> = layout.fields.iter().map(|field| (field.name, field.level, field.size, field.align)).collect();
+ union = union.with_writes(union.depth, (union.element_size, union.element_align), &writes);
+ }
+ union
+ }
+}
+
+/// A view of one record: a pointer whose layout is proven at wiring.
+#[derive(Clone, Copy, Debug)]
+pub struct Rec(*const u8);
+
+impl Rec {
+ /// # Safety
+ /// `ptr` must point to a live record of the layout the consumer resolved
+ /// at wiring, valid until the owning slot is next written.
+ pub unsafe fn new(ptr: *const u8) -> Self {
+ Rec(ptr)
+ }
+
+ /// # Safety
+ /// `offset` must be a field offset of the record's layout and `T` the
+ /// field's type; both are proven at wiring. The record's base is aligned
+ /// to its layout, so field reads are aligned.
+ pub unsafe fn read(self, offset: usize) -> T {
+ unsafe { self.0.add(offset).cast::().read() }
+ }
+
+ /// # Safety
+ /// `T` must be the record's element type; the element sits at offset 0.
+ pub unsafe fn element(self) -> T {
+ unsafe { self.read(0) }
+ }
+
+ pub fn ptr(self) -> *const u8 {
+ self.0
+ }
+}
+
+/// An opaque record value: element and attributes traveling as one unit.
+/// Lazy record inputs yield one per evaluation, kernels route them as
+/// ordinary values, and the returned value's record is the node's output, so
+/// provenance rides the value itself. The eval lifetime keeps it out of node
+/// state; the field is private, so it is unforgeable and uninspectable.
+#[derive(Clone, Copy, Debug)]
+pub struct RecordValue<'e>(Rec, std::marker::PhantomData<&'e ()>);
+
+impl<'e> RecordValue<'e> {
+ #[doc(hidden)]
+ pub fn from_rec(rec: Rec) -> Self {
+ RecordValue(rec, std::marker::PhantomData)
+ }
+
+ #[doc(hidden)]
+ pub fn rec(self) -> Rec {
+ self.0
+ }
+}
+
+/// Assigns frame slots at wiring: a bump allocator over slot sizes, aligned
+/// to at most 8 (record layouts never exceed word alignment).
+#[derive(Debug, Default)]
+pub struct FrameLayout {
+ size: usize,
+}
+
+impl FrameLayout {
+ pub fn slot(&mut self, layout: &Layout) -> usize {
+ assert!(layout.align <= 8, "record layouts align to at most 8");
+ self.size = self.size.next_multiple_of(layout.align.max(1));
+ let offset = self.size;
+ self.size += layout.size;
+ offset
+ }
+
+ pub fn size(&self) -> usize {
+ self.size
+ }
+}
+
+/// The per-worker record frame: every record-producing slot lives at a
+/// wiring-assigned offset. Slots are overwritten per lane and never touch
+/// the arena.
+pub struct Frame {
+ words: Box<[UnsafeCell]>,
+}
+
+// SAFETY: a frame belongs to one worker; slot writes happen only inside that
+// worker's evaluation, and wiring assigns disjoint offsets per slot.
+unsafe impl Send for Frame {}
+unsafe impl Sync for Frame {}
+
+impl Frame {
+ pub fn new(size: usize) -> Self {
+ Self {
+ words: (0..size.div_ceil(8).max(1)).map(|_| UnsafeCell::new(0)).collect(),
+ }
+ }
+
+ /// # Safety
+ /// `offset` must be a slot offset assigned by [`FrameLayout`] for this
+ /// frame, and the caller must be the slot's owning node evaluation.
+ pub unsafe fn slot(&self, offset: usize) -> *mut u8 {
+ debug_assert!(offset <= self.words.len() * 8);
+ unsafe { self.words.as_ptr().cast::().cast_mut().add(offset) }
+ }
+}
+
+impl std::fmt::Debug for Frame {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("Frame").field("bytes", &(self.words.len() * 8)).finish()
+ }
+}
+
+/// Field-by-field carry from `from`'s layout into `to`'s, computed at
+/// wiring. The element copy is included when `carry_element` holds, which is
+/// exactly when the node does not write a concrete element itself.
+pub fn copy_plan(from: &Layout, to: &Layout, carry_element: bool) -> Vec<(usize, usize, usize)> {
+ let mut plan = Vec::new();
+ if carry_element {
+ assert_eq!(from.element_size, to.element_size, "a carried element must keep its size");
+ if from.element_size > 0 {
+ plan.push((0, 0, from.element_size));
+ }
+ }
+ for field in &from.fields {
+ let target = to.offset_of(field.name, field.level).expect("carried field missing from the output layout");
+ plan.push((field.offset, target, field.size));
+ }
+ plan
+}
+
+/// # Safety
+/// `src` must be a record of the plan's source layout and `dst` a buffer of
+/// the plan's target layout; both are proven at wiring.
+pub unsafe fn apply_plan(src: Rec, dst: *mut u8, plan: &[(usize, usize, usize)]) {
+ for &(from, to, size) in plan {
+ unsafe { std::ptr::copy_nonoverlapping(src.ptr().add(from), dst.add(to), size) };
+ }
+}
+
+/// The default bytes for a union field the source does not carry: the census
+/// default for declared names, zeroes otherwise.
+fn default_fill_bytes(name: &str, size: usize) -> Box<[u8]> {
+ let mut bytes = vec![0u8; size].into_boxed_slice();
+ if let Some(info) = attribute::info(name)
+ && info.packable
+ && info.size == size
+ {
+ (info.write_default_bytes)(&mut bytes);
+ }
+ bytes
+}
+
+/// A routing source's wiring-resolved translation: field moves into the
+/// consumer's frame buffer plus census default fill for union fields the
+/// source lacks. Absent when the source's layout already equals the union,
+/// in which case the record pointer forwards untouched.
+#[derive(Debug)]
+pub struct SourcePlan {
+ moves: Vec<(usize, usize, usize)>,
+ fills: Vec<(usize, Box<[u8]>)>,
+ slot: usize,
+}
+
+impl SourcePlan {
+ pub fn new(source: &Layout, union: &Layout, slot: usize) -> Option {
+ if source == union {
+ return None;
+ }
+ let moves = copy_plan(source, union, true);
+ let fills = union
+ .fields
+ .iter()
+ .filter(|field| source.offset_of(field.name, field.level).is_none())
+ .map(|field| (field.offset, default_fill_bytes(field.name, field.size)))
+ .collect();
+ Some(SourcePlan { moves, fills, slot })
+ }
+
+ /// # Safety
+ /// `src` must be a record of this plan's source layout and `frame` the
+ /// frame whose slot was assigned to this plan at wiring.
+ pub unsafe fn translate(&self, src: Rec, frame: &Frame) -> Rec {
+ unsafe {
+ let dst = frame.slot(self.slot);
+ apply_plan(src, dst, &self.moves);
+ for (offset, bytes) in &self.fills {
+ std::ptr::copy_nonoverlapping(bytes.as_ptr(), dst.add(*offset), bytes.len());
+ }
+ Rec::new(dst)
+ }
+ }
+}
+
+/// A routing input's claimed edge plus its wiring-resolved [`SourcePlan`].
+/// Evaluating it yields the source's record translated to the union layout
+/// (or forwarded untouched when the layouts already agree), so the kernel
+/// holds and returns record values without ever seeing the representation.
+pub struct RecordSource {
+ edge: N,
+ plan: Option,
+}
+
+impl RecordSource {
+ pub fn wire(edge: N, source: &Layout, union: &Layout, slot: usize) -> Self {
+ Self {
+ edge,
+ plan: SourcePlan::new(source, union, slot),
+ }
+ }
+}
+
+impl<'e, C, N> Node for RecordSource
+where
+ C: crate::context::ExtractFrame<'e>,
+ N: Node>,
+{
+ type Output = RecordValue<'e>;
+
+ fn eval(&self, input: &C) -> GPoll> {
+ let value = self.edge.eval(input);
+ match &self.plan {
+ None => value,
+ Some(plan) => {
+ let Some(frame) = crate::context::ExtractFrame::frame(input) else {
+ return GPoll::error("record frame missing");
+ };
+ value.map(|value| RecordValue::from_rec(unsafe { plan.translate(value.rec(), frame) }))
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn f64_field(name: &'static str) -> (&'static str, u8, usize, usize) {
+ (name, 0, 8, 8)
+ }
+
+ #[test]
+ fn canonical_order_and_offsets() {
+ let layout = Layout::default().with_writes(0, (8, 8), &[("tint", 0, 4, 4), f64_field("opacity"), ("flag", 0, 1, 1)]);
+ assert_eq!(layout.offset_of("opacity", 0), Some(8));
+ assert_eq!(layout.offset_of("tint", 0), Some(16));
+ assert_eq!(layout.offset_of("flag", 0), Some(20));
+ assert_eq!(layout.size, 21);
+ assert_eq!(layout.align, 8);
+ }
+
+ #[test]
+ #[should_panic(expected = "two different sizes")]
+ fn size_conflicts_panic() {
+ let layout = Layout::default().with_writes(0, (8, 8), &[f64_field("opacity")]);
+ layout.with_writes(0, (8, 8), &[("opacity", 0, 4, 4)]);
+ }
+
+ #[test]
+ fn union_is_order_independent() {
+ let a = Layout::default().with_writes(0, (8, 8), &[f64_field("opacity")]);
+ let b = Layout::default().with_writes(0, (8, 8), &[f64_field("length")]);
+ assert_eq!(Layout::union(&[&a, &b]), Layout::union(&[&b, &a]));
+ assert!(Layout::union(&[&a, &b]).offset_of("length", 0).is_some());
+ }
+
+ #[test]
+ fn frame_slots_bump_aligned() {
+ let flag = Layout::default().with_writes(0, (1, 1), &[]);
+ let wide = Layout::default().with_writes(0, (8, 8), &[f64_field("opacity")]);
+ let mut frame = FrameLayout::default();
+ assert_eq!(frame.slot(&flag), 0);
+ assert_eq!(frame.slot(&wide), 8);
+ assert_eq!(frame.size(), 24);
+ }
+
+ #[test]
+ fn translation_moves_fields_and_fills_census_defaults() {
+ let source = Layout::default().with_writes(0, (8, 8), &[f64_field("length")]);
+ let union = Layout::union(&[&source, &Layout::default().with_writes(0, (8, 8), &[f64_field("opacity")])]);
+ let mut frame_layout = FrameLayout::default();
+ let slot = frame_layout.slot(&union);
+ let frame = Frame::new(frame_layout.size());
+
+ let plan = SourcePlan::new(&source, &union, slot).unwrap();
+ let record = [5f64, 7f64];
+ let translated = unsafe { plan.translate(Rec::new(record.as_ptr().cast()), &frame) };
+ assert_eq!(unsafe { translated.element::() }, 5.);
+ assert_eq!(unsafe { translated.read::(union.offset_of("length", 0).unwrap()) }, 7.);
+ assert_eq!(unsafe { translated.read::(union.offset_of("opacity", 0).unwrap()) }, 1.);
+ }
+
+ #[test]
+ fn identity_layouts_forward() {
+ let layout = Layout::default().with_writes(0, (8, 8), &[f64_field("opacity")]);
+ assert!(SourcePlan::new(&layout, &layout.clone(), 0).is_none());
+ }
+}
diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs
index 31ca1ef13d..d287a2c3bd 100644
--- a/node-graph/node-macro/src/codegen.rs
+++ b/node-graph/node-macro/src/codegen.rs
@@ -605,6 +605,14 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn
top_level: quote!(),
});
}
+ let record = record_shape(parsed);
+ if record.is_none() && has_record_io(parsed) {
+ return Ok(NodeImplTokens {
+ in_mod: quote!(),
+ top_level: quote!(),
+ });
+ }
+ let routing = routing_io(parsed);
let snapshot_ctx = async_fn && matches!(&parsed.input.ty, Type::Path(path) if path.path.segments.last().is_some_and(|segment| segment.ident == "CtxSnapshot"));
let mut ctx_bounds: Vec = match ctx_param {
@@ -665,26 +673,44 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn
true => quote!(#ctx_ident),
false => quote!(#ctx_ident: #(#ctx_bounds)+*),
};
- let mut generics: Vec = parsed
+ let generic_tokens = |param: &GenericParam| match param {
+ GenericParam::Type(type_param) if Some(&type_param.ident) == ctx_param.map(|ctx_param| &ctx_param.ident) => ctx_generic.clone(),
+ param => quote!(#param),
+ };
+ let mut generics: Vec = parsed.fn_generics.iter().map(&generic_tokens).collect();
+ let mut impl_generics: Vec = parsed
.fn_generics
.iter()
- .map(|param| match param {
- GenericParam::Type(type_param) if Some(&type_param.ident) == ctx_param.map(|ctx_param| &ctx_param.ident) => ctx_generic.clone(),
- param => quote!(#param),
+ .filter(|param| match (param, &routing) {
+ (GenericParam::Type(type_param), Some(routing)) => type_param.ident != routing.generic,
+ _ => true,
})
+ .map(&generic_tokens)
.collect();
if ctx_param.is_none() {
- generics.push(ctx_generic);
+ generics.push(ctx_generic.clone());
+ impl_generics.push(ctx_generic);
}
if let Some(lifetime) = &introduced_lend_lifetime {
generics.insert(0, quote!(#lifetime));
+ impl_generics.insert(0, quote!(#lifetime));
+ }
+ if routing.is_some() {
+ impl_generics.insert(0, quote!('__record));
}
let fn_name = &parsed.fn_name;
let mod_name = format_ident!("_{}_mod", parsed.mod_name);
let struct_name = format_ident!("{}Node", parsed.struct_name);
let output_type = &parsed.output_type;
- let trait_output = slot_value_type(&parsed.output_type);
+ let trait_output = match (&record, &routing) {
+ (Some(shape), _) => {
+ let element_out = &shape.element_out;
+ syn::parse_quote!(#core_types::list::List<#element_out>)
+ }
+ (None, Some(_)) => syn::parse_quote!(#core_types::record::RecordValue<'__record>),
+ (None, None) => slot_value_type(&parsed.output_type),
+ };
let raw_lazy = matches!(kernel_kind(&parsed.output_type), KernelKind::Poll(_));
let injected_name = |ident: &Ident| async_source && (ident == "_runtime" || ident == "_source");
let where_predicates: Vec = parsed.where_clause.iter().flat_map(|clause| clause.predicates.iter()).map(|predicate| quote!(#predicate)).collect();
@@ -723,28 +749,49 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn
false => quote!(#core_types::node::Node<#ctx_ident, Output = #output_type>),
};
- let kernel_params = regular_fields.iter().filter(|field| !injected_name(&field.pat_ident.ident)).map(|field| {
- let pat = &field.pat_ident;
- match &field.ty {
- ParsedFieldType::Regular(RegularParsedField { ty, lend: Some(_), .. }) => quote!(#pat: ty),
- ParsedFieldType::Regular(RegularParsedField { ty, .. }) => quote!(#pat: #ty),
- ParsedFieldType::Node(NodeParsedField { output_type, .. }) if raw_lazy => {
- let bound = lazy_bound(output_type);
- quote!(#pat: &impl #bound)
- }
- ParsedFieldType::Node(NodeParsedField { output_type, .. }) => {
- let bound = lazy_bound(output_type);
- quote!(#pat: #core_types::node::LazyInput<'_, impl #bound>)
- }
- }
+ let attr_kernel_params = parsed.attribute_reads.iter().map(|read| {
+ let pat = &read.pat_ident;
+ let marker = &read.marker;
+ quote!(#pat: #core_types::attribute::Attr<#marker>)
});
+ let kernel_params = regular_fields
+ .iter()
+ .filter(|field| !injected_name(&field.pat_ident.ident))
+ .map(|field| {
+ let pat = &field.pat_ident;
+ match &field.ty {
+ ParsedFieldType::Regular(RegularParsedField { ty, lend: Some(_), .. }) => quote!(#pat: ty),
+ ParsedFieldType::Regular(RegularParsedField { ty, .. }) => quote!(#pat: #ty),
+ ParsedFieldType::Node(NodeParsedField { output_type, .. }) if raw_lazy => {
+ let bound = lazy_bound(output_type);
+ quote!(#pat: &impl #bound)
+ }
+ ParsedFieldType::Node(NodeParsedField { output_type, .. }) => {
+ let bound = lazy_bound(output_type);
+ quote!(#pat: #core_types::node::LazyInput<'_, impl #bound>)
+ }
+ }
+ })
+ .chain(attr_kernel_params);
- let node_bounds = regular_fields.iter().zip(&node_generics).map(|(field, node_generic)| match &field.ty {
+ let routing_source = |ty: &Type| matches!((&routing, ty), (Some(routing), Type::Path(path)) if path.path.get_ident() == Some(&routing.generic));
+ let record_value_ty: Type = syn::parse_quote!(#core_types::record::RecordValue<'__record>);
+ let node_bounds = regular_fields.iter().enumerate().zip(&node_generics).map(|((index, field), node_generic)| match &field.ty {
ParsedFieldType::Regular(RegularParsedField { ty, lend: Some(_), .. }) => {
let lifetime = lend_lifetime.as_ref().expect("lend fields imply the lend lifetime");
quote!(#node_generic: #core_types::node::Node<#ctx_ident, Output = lifetime #ty>)
}
+ ParsedFieldType::Regular(RegularParsedField { ty, .. }) if record.is_some() && index == 0 => {
+ quote!(#node_generic: #core_types::node::Node<#ctx_ident, Output = #core_types::list::List<#ty>>)
+ }
+ ParsedFieldType::Regular(RegularParsedField { ty, .. }) if routing_source(ty) => {
+ quote!(#node_generic: #core_types::node::Node<#ctx_ident, Output = #record_value_ty>)
+ }
ParsedFieldType::Regular(RegularParsedField { ty, .. }) => quote!(#node_generic: #core_types::node::Node<#ctx_ident, Output = #ty>),
+ ParsedFieldType::Node(NodeParsedField { output_type, .. }) if routing_source(output_type) => {
+ let bound = lazy_bound(&record_value_ty);
+ quote!(#node_generic: #bound)
+ }
ParsedFieldType::Node(NodeParsedField { output_type, .. }) => {
let bound = lazy_bound(output_type);
quote!(#node_generic: #bound)
@@ -801,6 +848,12 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn
let eval_values = regular_fields.iter().enumerate().map(|(index, field)| {
let name = &field.pat_ident.ident;
match &field.ty {
+ ParsedFieldType::Regular(_) if record.is_some() && index == 0 => quote! {
+ let mut __record_list = match __cell.eval_input(#index, &self.#name, __input) {
+ Ok(value) => value,
+ Err(interrupt) => return interrupt.into(),
+ };
+ },
ParsedFieldType::Regular(_) => quote! {
let #name = match __cell.eval_input(#index, &self.#name, __input) {
Ok(value) => value,
@@ -994,8 +1047,109 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn
#fallback
}
};
+ let record_tail = record.as_ref().map(|shape| {
+ let record_call_args = regular_fields.iter().enumerate().map(|(index, field)| {
+ let name = &field.pat_ident.ident;
+ match (index, &field.ty) {
+ (0, _) => quote!(__element),
+ (_, ParsedFieldType::Regular(RegularParsedField { lend: Some(_), .. })) => quote!(#name),
+ _ => quote!(#name.clone()),
+ }
+ });
+ let attr_call_args = parsed.attribute_reads.iter().map(|read| {
+ let pat = &read.pat_ident.ident;
+ quote!(#pat)
+ });
+ let record_kernel_call = quote!(self::#fn_name(__input #(, &self.#data_names)* #(, #record_call_args)* #(, #attr_call_args)*));
+ let read_columns = parsed.attribute_reads.iter().enumerate().map(|(index, read)| {
+ let marker = &read.marker;
+ let column = format_ident!("__read_{index}");
+ quote! {
+ let #column = __record_list
+ .iter_attribute_values::<<#marker as #core_types::attribute::Attribute>::Value>(<#marker as #core_types::attribute::Attribute>::NAME)
+ .map(|__values| __values.cloned().collect::<::std::vec::Vec<_>>());
+ }
+ });
+ let read_bindings = parsed.attribute_reads.iter().enumerate().map(|(index, read)| {
+ let pat = &read.pat_ident;
+ let marker = &read.marker;
+ let column = format_ident!("__read_{index}");
+ quote! {
+ let #pat = #core_types::attribute::Attr::<#marker>(match column {
+ Some(__values) => __values[__index].clone(),
+ None => <#marker as #core_types::attribute::Attribute>::default(),
+ });
+ }
+ });
+ let write_markers = &shape.write_markers;
+ let write_columns: Vec = (0..write_markers.len()).map(|index| format_ident!("__write_{index}")).collect();
+ let write_column_decls = write_markers.iter().zip(&write_columns).map(|(marker, column)| {
+ quote! {
+ let mut #column: ::std::vec::Vec<<#marker as #core_types::attribute::Attribute>::Value> = ::std::vec::Vec::with_capacity(__len);
+ }
+ });
+ let write_pats: Vec = (0..write_markers.len()).map(|index| format_ident!("__written_{index}")).collect();
+ let destructure = match write_markers.is_empty() {
+ true => quote!(let __element_out = __kernel_value;),
+ false => quote!(let (__element_out #(, #core_types::attribute::Attr(#write_pats))*) = __kernel_value;),
+ };
+ let write_pushes = write_columns.iter().zip(&write_pats).map(|(column, pat)| quote!(#column.push(#pat);));
+ let written_names = write_markers.iter().map(|marker| quote!(<#marker as #core_types::attribute::Attribute>::NAME));
+ let write_inserts = write_markers.iter().zip(&write_columns).map(|(marker, column)| {
+ quote! {
+ __out.insert_attribute_dyn(
+ <#marker as #core_types::attribute::Attribute>::NAME,
+ #core_types::list::AttributeDyn(::std::boxed::Box::new(#core_types::list::Attribute(#column))),
+ );
+ }
+ });
+ let kernel_value = match shape.dialect {
+ RecordDialect::Plain => quote!(#record_kernel_call),
+ RecordDialect::Interrupt => quote! {
+ match #record_kernel_call {
+ Ok(__value) => __value,
+ Err(__interrupt) => return __interrupt.into(),
+ }
+ },
+ };
+ let element_out = &shape.element_out;
+ quote! {
+ let __len = __record_list.len();
+ #(#read_columns)*
+ let __written_names: &[&str] = &[#(#written_names),*];
+ let __carried_keys: ::std::vec::Vec<::std::string::String> = __record_list
+ .attribute_keys()
+ .filter(|__key| !__written_names.contains(__key))
+ .map(::std::string::String::from)
+ .collect();
+ let mut __carried: ::std::vec::Vec<(::std::string::String, #core_types::list::AttributeDyn)> = ::std::vec::Vec::with_capacity(__carried_keys.len());
+ for __key in __carried_keys {
+ if let Some(__column) = __record_list.take_attribute_dyn(&__key) {
+ __carried.push((__key, __column));
+ }
+ }
+ #(#write_column_decls)*
+ let mut __out_elements: ::std::vec::Vec<#element_out> = ::std::vec::Vec::with_capacity(__len);
+ for (__index, __element) in __record_list.into_element_values().into_iter().enumerate() {
+ #(#read_bindings)*
+ let __kernel_value = #kernel_value;
+ #destructure
+ __out_elements.push(__element_out);
+ #(#write_pushes)*
+ }
+ let mut __out = #core_types::list::List::from_element_values(__out_elements);
+ for (__key, __column) in __carried {
+ __out.insert_attribute_dyn(__key, __column);
+ }
+ #(#write_inserts)*
+ __cell.finish(__out)
+ }
+ });
let eval_tail = match (async_fn, future_kernel) {
- (false, false) => lift,
+ (false, false) => match record_tail {
+ Some(tail) => tail,
+ None => lift,
+ },
(true, _) => {
let kernel_value_names: Vec<&Ident> = kernel_fields.iter().map(|field| &field.pat_ident.ident).collect();
let snapshot_binding = snapshot_ctx.then(|| quote!(let __snapshot = #core_types::context::CtxSnapshot::capture(__input);)).into_iter();
@@ -1044,18 +1198,32 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn
}
};
+ let record_bounds: Vec = match &record {
+ Some(_) => regular_fields
+ .iter()
+ .skip(1)
+ .filter_map(|field| match &field.ty {
+ ParsedFieldType::Regular(RegularParsedField { lend: Some(_), .. }) => None,
+ ParsedFieldType::Regular(RegularParsedField { ty, .. }) => Some(quote!(#ty: Clone)),
+ _ => None,
+ })
+ .collect(),
+ None => Vec::new(),
+ };
+
let entries = entries_tokens(parsed, &struct_name, &data_field_generic_idents, ®ular_fields);
let cfg = crate::shader_nodes::modify_cfg(&parsed.attributes);
let top_level = quote! {
#cfg
#[automatically_derived]
- impl<#(#generics,)* #(#node_generics,)*> #core_types::node::Node<#ctx_ident> for #mod_name::#struct_name<#(#struct_type_params,)*>
+ impl<#(#impl_generics,)* #(#node_generics,)*> #core_types::node::Node<#ctx_ident> for #mod_name::#struct_name<#(#struct_type_params,)*>
where
#(#node_bounds,)*
#(#lend_outlives,)*
#(#clampable_bounds,)*
#(#async_bounds,)*
+ #(#record_bounds,)*
#(#where_predicates,)*
{
type Output = #trait_output;
@@ -1085,6 +1253,140 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn
})
}
+pub(crate) enum RecordDialect {
+ Plain,
+ Interrupt,
+}
+
+/// The record io of a node fn: the output element type and the written
+/// markers. Present exactly when the signature declares attribute reads or
+/// writes in a shape the driver supports (the carrier is the first field);
+/// malformed record io is reported by validation and generates no node impl.
+pub(crate) struct RecordShape {
+ pub(crate) element_out: Type,
+ pub(crate) write_markers: Vec,
+ pub(crate) dialect: RecordDialect,
+}
+
+pub(crate) fn has_record_io(parsed: &ParsedNodeFn) -> bool {
+ !parsed.attribute_reads.is_empty() || record_writes(&slot_value_type(&parsed.output_type)).is_some()
+}
+
+pub(crate) fn record_shape(parsed: &ParsedNodeFn) -> Option {
+ let (value, dialect) = match kernel_kind(&parsed.output_type) {
+ KernelKind::Plain => (parsed.output_type.clone(), RecordDialect::Plain),
+ KernelKind::Interrupt(inner) => (inner, RecordDialect::Interrupt),
+ _ => return None,
+ };
+ let writes = record_writes(&value);
+ if parsed.attribute_reads.is_empty() && writes.is_none() {
+ return None;
+ }
+ if parsed.is_async {
+ return None;
+ }
+ let carrier = parsed.fields.first()?;
+ if carrier.is_data_field {
+ return None;
+ }
+ let ParsedFieldType::Regular(RegularParsedField { ty, lend: None, .. }) = &carrier.ty else {
+ return None;
+ };
+ if matches!(ty, Type::Tuple(tuple) if tuple.elems.is_empty()) {
+ return None;
+ }
+ if parsed.fields.iter().any(|field| matches!(field.ty, ParsedFieldType::Node(_))) {
+ return None;
+ }
+ let (element_out, write_markers) = match writes {
+ Some(RecordWrites { element, markers }) => (element, markers),
+ None => (value, Vec::new()),
+ };
+ Some(RecordShape {
+ element_out,
+ write_markers,
+ dialect,
+ })
+}
+
+pub(crate) fn is_poll_kernel(output: &Type) -> bool {
+ matches!(kernel_kind(output), KernelKind::Poll(_))
+}
+
+/// A routing family: an unbounded generic shared by lazy inputs (and
+/// optionally the first parameter) and returned whole, instantiated at
+/// `RecordValue` so opaque records flow through the kernel. Detected only
+/// when the family's fields carry no implementations lists, so the existing
+/// per-type row spelling keeps its meaning.
+pub(crate) struct RoutingIo {
+ pub(crate) generic: Ident,
+}
+
+pub(crate) fn routing_io(parsed: &ParsedNodeFn) -> Option {
+ if has_record_io(parsed) || parsed.is_async {
+ return None;
+ }
+ if !matches!(kernel_kind(&parsed.output_type), KernelKind::Plain | KernelKind::Interrupt(_)) {
+ return None;
+ }
+ let value = slot_value_type(&parsed.output_type);
+ let Type::Path(path) = &value else { return None };
+ let ident = path.path.get_ident()?.clone();
+ let ctx_ident = context_param(parsed).map(|ctx| ctx.ident.clone());
+ parsed
+ .fn_generics
+ .iter()
+ .find(|param| matches!(param, GenericParam::Type(type_param) if type_param.ident == ident && type_param.bounds.is_empty() && Some(&type_param.ident) != ctx_ident.as_ref()))?;
+ if let Some(where_clause) = &parsed.where_clause
+ && tokens_contain_ident(where_clause.to_token_stream(), &ident)
+ {
+ return None;
+ }
+ let mut sources = 0;
+ for (index, field) in parsed.fields.iter().enumerate() {
+ match &field.ty {
+ ParsedFieldType::Node(NodeParsedField {
+ output_type,
+ input_type,
+ implementations,
+ }) => {
+ if bare_ident(output_type) == Some(&ident) {
+ if !implementations.is_empty() || type_contains_ident(input_type, &ident) {
+ return None;
+ }
+ sources += 1;
+ } else if type_contains_ident(output_type, &ident) || type_contains_ident(input_type, &ident) {
+ return None;
+ }
+ }
+ ParsedFieldType::Regular(RegularParsedField { ty, implementations, lend, .. }) => {
+ if bare_ident(ty) == Some(&ident) {
+ if index != 0 || field.is_data_field || !implementations.is_empty() || lend.is_some() {
+ return None;
+ }
+ sources += 1;
+ } else if type_contains_ident(ty, &ident) {
+ return None;
+ }
+ }
+ }
+ }
+ (sources > 0).then(|| RoutingIo { generic: ident })
+}
+
+fn bare_ident(ty: &Type) -> Option<&Ident> {
+ let Type::Path(path) = ty else { return None };
+ path.path.get_ident()
+}
+
+fn tokens_contain_ident(tokens: TokenStream2, ident: &Ident) -> bool {
+ tokens.into_iter().any(|token| match token {
+ proc_macro2::TokenTree::Ident(candidate) => &candidate == ident,
+ proc_macro2::TokenTree::Group(group) => tokens_contain_ident(group.stream(), ident),
+ _ => false,
+ })
+}
+
pub(crate) fn slot_value_type(output: &Type) -> Type {
match kernel_kind(output) {
KernelKind::Plain => output.clone(),
@@ -1252,14 +1554,17 @@ fn entries_tokens(parsed: &ParsedNodeFn, struct_name: &Ident, data_field_generic
.map(|field| matches!(&field.ty, ParsedFieldType::Regular(RegularParsedField { lend: Some(_), .. })))
.collect();
+ let record_carrier = record_shape(parsed).is_some();
let entries = rows.iter().map(|row| {
- let input_types = row.iter().zip(&lend_flags).map(|(ty, lend)| match lend {
- true => quote!(gcore::registry::lend_edge_type::<#ty>()),
- false => quote!(gcore::registry::edge_type::<#ty>()),
+ let input_types = row.iter().enumerate().zip(&lend_flags).map(|((index, ty), lend)| match (lend, record_carrier && index == 0) {
+ (true, _) => quote!(gcore::registry::lend_edge_type::<#ty>()),
+ (false, true) => quote!(gcore::registry::edge_type::>()),
+ (false, false) => quote!(gcore::registry::edge_type::<#ty>()),
});
- let edge_types = row.iter().zip(&lend_flags).map(|(ty, lend)| match lend {
- true => quote!(gcore::registry::SharedEdge>),
- false => quote!(gcore::registry::SharedEdge>),
+ let edge_types = row.iter().enumerate().zip(&lend_flags).map(|((index, ty), lend)| match (lend, record_carrier && index == 0) {
+ (true, _) => quote!(gcore::registry::SharedEdge>),
+ (false, true) => quote!(gcore::registry::SharedEdge>>),
+ (false, false) => quote!(gcore::registry::SharedEdge>),
});
let output = quote!(<#struct_name<#(#edge_types),*> as gcore::node::Node>>::Output);
let (io_output, construct) = match &ref_output_inner {
@@ -1272,9 +1577,10 @@ fn entries_tokens(parsed: &ParsedNodeFn, struct_name: &Ident, data_field_generic
quote!(Ok(gcore::registry::EdgeHandle::new(::std::sync::Arc::new(#struct_name::new(#(#names),*)) as ::std::sync::Arc>))),
),
};
- let downcasts = names.iter().zip(row.iter()).zip(&lend_flags).map(|((name, ty), lend)| match lend {
- true => quote!(let #name = inputs.next().unwrap().downcast_lend::<#ty>()?;),
- false => quote!(let #name = inputs.next().unwrap().downcast::<#ty>()?;),
+ let downcasts = names.iter().zip(row.iter().enumerate()).zip(&lend_flags).map(|((name, (index, ty)), lend)| match (lend, record_carrier && index == 0) {
+ (true, _) => quote!(let #name = inputs.next().unwrap().downcast_lend::<#ty>()?;),
+ (false, true) => quote!(let #name = inputs.next().unwrap().downcast::>()?;),
+ (false, false) => quote!(let #name = inputs.next().unwrap().downcast::<#ty>()?;),
});
quote! {
gcore::registry::RegistryEntry {
diff --git a/node-graph/node-macro/src/parsing.rs b/node-graph/node-macro/src/parsing.rs
index cf3dffc3d0..6d2e15d2fe 100644
--- a/node-graph/node-macro/src/parsing.rs
+++ b/node-graph/node-macro/src/parsing.rs
@@ -7,8 +7,8 @@ use syn::punctuated::Punctuated;
use syn::spanned::Spanned;
use syn::token::{Comma, RArrow};
use syn::{
- AttrStyle, Attribute, Error, Expr, FnArg, GenericParam, Ident, ItemFn, Lit, LitFloat, LitInt, LitStr, Meta, Pat, PatIdent, PatType, Path, ReturnType, TraitBound, Type, TypeImplTrait, TypeParam,
- TypeParamBound, Visibility, WhereClause, parse_quote,
+ AttrStyle, Attribute, Error, Expr, FnArg, GenericArgument, GenericParam, Ident, ItemFn, Lit, LitFloat, LitInt, LitStr, Meta, Pat, PatIdent, PatType, Path, PathArguments, ReturnType,
+ TraitBound, Type, TypeImplTrait, TypeParam, TypeParamBound, Visibility, WhereClause, parse_quote,
};
use crate::codegen::generate_node_code;
@@ -35,10 +35,59 @@ pub(crate) struct ParsedNodeFn {
pub(crate) output_type: Type,
pub(crate) is_async: bool,
pub(crate) fields: Vec,
+ pub(crate) attribute_reads: Vec,
pub(crate) body: TokenStream2,
pub(crate) description: String,
}
+/// An `Attr` parameter: a declared attribute read on the carrier's
+/// items, not a wired input.
+#[derive(Clone, Debug)]
+pub(crate) struct AttributeRead {
+ pub(crate) pat_ident: PatIdent,
+ pub(crate) marker: Type,
+}
+
+/// The write half of a record kernel's return: the element type in the first
+/// tuple slot and the attribute markers written after it. `None` unless the
+/// value is a well-formed write tuple (a non-`Attr` element first, then only
+/// `Attr` slots, at least one).
+pub(crate) struct RecordWrites {
+ pub(crate) element: Type,
+ pub(crate) markers: Vec,
+}
+
+pub(crate) fn record_writes(value: &Type) -> Option {
+ let Type::Tuple(tuple) = value else { return None };
+ let mut slots = tuple.elems.iter();
+ let element = slots.next()?;
+ if attr_marker(element).is_some() {
+ return None;
+ }
+ let markers: Option> = slots.map(attr_marker).collect();
+ let markers = markers?;
+ (!markers.is_empty()).then(|| RecordWrites {
+ element: element.clone(),
+ markers,
+ })
+}
+
+/// Returns the marker type of an `Attr` type, if `ty` is one.
+pub(crate) fn attr_marker(ty: &Type) -> Option {
+ let Type::Path(path) = ty else { return None };
+ let segment = path.path.segments.last()?;
+ if segment.ident != "Attr" {
+ return None;
+ }
+ let PathArguments::AngleBracketed(args) = &segment.arguments else { return None };
+ let mut types = args.args.iter().filter_map(|argument| match argument {
+ GenericArgument::Type(ty) => Some(ty),
+ _ => None,
+ });
+ let marker = types.next()?;
+ types.next().is_none().then(|| marker.clone())
+}
+
#[derive(Debug, Default, Clone)]
pub(crate) struct NodeFnAttributes {
pub(crate) category: Option,
@@ -577,7 +626,7 @@ fn parse_node_fn(attr: TokenStream2, item: TokenStream2) -> syn::Result syn::Result) -> syn::Result<(Input, Vec)> {
+fn parse_inputs(inputs: &Punctuated) -> syn::Result<(Input, Vec, Vec)> {
let mut fields = Vec::new();
+ let mut attribute_reads = Vec::new();
let mut input = None;
for (index, arg) in inputs.iter().enumerate() {
@@ -653,8 +704,18 @@ fn parse_inputs(inputs: &Punctuated) -> syn::Result<(Input, Vec) -> syn::Result<(Input, Vec>),
is_async: false,
+ attribute_reads: vec![],
fields: vec![ParsedField {
pat_ident: pat_ident("shadows"),
name: None,
@@ -1539,6 +1604,7 @@ mod tests {
},
output_type: parse_quote!(f64),
is_async: false,
+ attribute_reads: vec![],
fields: vec![ParsedField {
pat_ident: pat_ident("b"),
name: None,
@@ -1625,6 +1691,7 @@ mod tests {
},
output_type: parse_quote!(List>),
is_async: true,
+ attribute_reads: vec![],
fields: vec![ParsedField {
pat_ident: pat_ident("path"),
name: None,
@@ -1696,6 +1763,7 @@ mod tests {
},
output_type: parse_quote!(i32),
is_async: false,
+ attribute_reads: vec![],
fields: vec![],
body: TokenStream2::new(),
description: String::new(),
diff --git a/node-graph/node-macro/src/shader_nodes/per_pixel_adjust.rs b/node-graph/node-macro/src/shader_nodes/per_pixel_adjust.rs
index f004d162e6..180dbae75f 100644
--- a/node-graph/node-macro/src/shader_nodes/per_pixel_adjust.rs
+++ b/node-graph/node-macro/src/shader_nodes/per_pixel_adjust.rs
@@ -317,6 +317,7 @@ impl PerPixelAdjustCodegen<'_> {
output_type: raster_gpu,
is_async: false,
fields,
+ attribute_reads: Vec::new(),
body,
description: self.parsed.description.clone(),
};
diff --git a/node-graph/node-macro/src/validation.rs b/node-graph/node-macro/src/validation.rs
index d9ea23c382..8e76d09995 100644
--- a/node-graph/node-macro/src/validation.rs
+++ b/node-graph/node-macro/src/validation.rs
@@ -1,6 +1,6 @@
-use crate::parsing::{Implementation, NodeParsedField, ParsedField, ParsedFieldType, ParsedNodeFn, RegularParsedField};
+use crate::parsing::{Implementation, NodeParsedField, ParsedField, ParsedFieldType, ParsedNodeFn, RegularParsedField, attr_marker, record_writes};
use proc_macro_error2::emit_error;
-use quote::quote;
+use quote::{ToTokens, quote};
use syn::spanned::Spanned;
use syn::{GenericParam, Type};
@@ -13,6 +13,7 @@ pub fn validate_node_fn(parsed: &ParsedNodeFn) -> syn::Result<()> {
validate_range_slider_bounds,
validate_async_source,
validate_lend_fields,
+ validate_record_io,
];
for validator in validators {
@@ -22,6 +23,74 @@ pub fn validate_node_fn(parsed: &ParsedNodeFn) -> syn::Result<()> {
Ok(())
}
+fn validate_record_io(parsed: &ParsedNodeFn) {
+ let value = crate::codegen::slot_value_type(&parsed.output_type);
+ if let Type::Tuple(tuple) = &value {
+ let has_attr_slot = tuple.elems.iter().any(|slot| attr_marker(slot).is_some());
+ if has_attr_slot && record_writes(&value).is_none() {
+ emit_error!(
+ parsed.output_type.span(),
+ "a record return tuple is the element first, then only `Attr<..>` writes"
+ );
+ }
+ } else if attr_marker(&value).is_some() {
+ emit_error!(parsed.output_type.span(), "an `Attr<..>` write needs an element in the first tuple slot, e.g. `(T, Attr<..>)`");
+ }
+
+ let writes = record_writes(&value);
+ if parsed.attribute_reads.is_empty() && writes.is_none() {
+ return;
+ }
+
+ if parsed.is_async || crate::codegen::is_source_kernel(&parsed.output_type) {
+ emit_error!(parsed.output_type.span(), "attribute io is not supported on async source kernels");
+ }
+ if crate::codegen::is_poll_kernel(&parsed.output_type) {
+ emit_error!(parsed.output_type.span(), "attribute io needs a plain or `Result<_, Interrupt>` kernel, not a `GPoll` one");
+ }
+
+ match parsed.fields.first() {
+ None => emit_error!(
+ parsed.fn_name.span(),
+ "attribute io needs a value carrier as the first parameter after the context"
+ ),
+ Some(carrier) => {
+ let valid = !carrier.is_data_field
+ && matches!(&carrier.ty, ParsedFieldType::Regular(RegularParsedField { ty, lend: None, .. }) if !matches!(ty, Type::Tuple(tuple) if tuple.elems.is_empty()));
+ if !valid {
+ emit_error!(
+ carrier.pat_ident.span(),
+ "attribute io needs a value carrier as the first parameter after the context: an owned element type, not `()`, `#[data]`, `&T`, or `impl Node`"
+ );
+ }
+ }
+ }
+ for field in parsed.fields.iter().skip(1) {
+ if matches!(field.ty, ParsedFieldType::Node(_)) {
+ emit_error!(field.pat_ident.span(), "record nodes take no lazy inputs yet");
+ }
+ }
+
+ let mut seen_reads: Vec = Vec::new();
+ for read in &parsed.attribute_reads {
+ let marker = read.marker.to_token_stream().to_string();
+ if seen_reads.contains(&marker) {
+ emit_error!(read.pat_ident.span(), "attribute `{}` is read twice", marker);
+ }
+ seen_reads.push(marker);
+ }
+ if let Some(writes) = &writes {
+ let mut seen_writes: Vec = Vec::new();
+ for marker in &writes.markers {
+ let written = marker.to_token_stream().to_string();
+ if seen_writes.contains(&written) {
+ emit_error!(parsed.output_type.span(), "attribute `{}` is written twice", written);
+ }
+ seen_writes.push(written);
+ }
+ }
+}
+
fn validate_async_source(parsed: &ParsedNodeFn) {
let snapshot_ctx = matches!(&parsed.input.ty, Type::Path(path) if path.path.segments.last().is_some_and(|segment| segment.ident == "CtxSnapshot"));
let future_kernel = crate::codegen::is_source_kernel(&parsed.output_type);
@@ -206,6 +275,8 @@ fn validate_primary_input_expose(parsed: &ParsedNodeFn) {
fn validate_implementations_for_generics(parsed: &ParsedNodeFn) {
let has_skip_impl = parsed.attributes.skip_impl;
+ let routing = crate::codegen::routing_io(parsed);
+ let routing_source = |ty: &Type| matches!((&routing, ty), (Some(routing), Type::Path(path)) if path.path.get_ident() == Some(&routing.generic));
if !has_skip_impl && !parsed.fn_generics.is_empty() {
for field in &parsed.fields {
@@ -217,6 +288,9 @@ fn validate_implementations_for_generics(parsed: &ParsedNodeFn) {
let pat_ident = &field.pat_ident;
match &field.ty {
ParsedFieldType::Regular(RegularParsedField { ty, implementations, .. }) => {
+ if routing_source(ty) {
+ continue;
+ }
if contains_generic_param(ty, &parsed.fn_generics) && implementations.is_empty() {
emit_error!(
ty.span(),
@@ -234,6 +308,9 @@ fn validate_implementations_for_generics(parsed: &ParsedNodeFn) {
implementations,
..
}) => {
+ if routing_source(output_type) {
+ continue;
+ }
if (contains_generic_param(input_type, &parsed.fn_generics) || contains_generic_param(output_type, &parsed.fn_generics)) && implementations.is_empty() {
emit_error!(
pat_ident.span(),
diff --git a/node-graph/nodes/gcore/src/lib.rs b/node-graph/nodes/gcore/src/lib.rs
index 5f74dd3cd2..b391dc2e1c 100644
--- a/node-graph/nodes/gcore/src/lib.rs
+++ b/node-graph/nodes/gcore/src/lib.rs
@@ -5,6 +5,8 @@ pub mod debug;
pub mod extract_xy;
pub mod memo;
pub mod ops;
+#[cfg(test)]
+mod record;
// Re-export all nodes
pub use animation::*;
diff --git a/node-graph/nodes/gcore/src/record.rs b/node-graph/nodes/gcore/src/record.rs
new file mode 100644
index 0000000000..7d9dbcb4c0
--- /dev/null
+++ b/node-graph/nodes/gcore/src/record.rs
@@ -0,0 +1,453 @@
+//! Pilot record nodes exercising the macro's attribute io over materialized
+//! lists: `Attr` reads, tuple writes, column carry, and the census
+//! defaults. These are the flat-wave law tests; the node forms are the
+//! production authoring surface, the list driver behind them is interim.
+
+use core_types::attribute::{Attr, Opacity};
+use core_types::gpoll::{GraphError, Interrupt};
+use core_types::{Context, Ctx};
+
+core_types::attribute! {
+ /// Test-only measured length of an element.
+ pub Length("length"): f64;
+}
+
+#[node_macro::node(category("Test"))]
+fn multiply_opacity(_: impl Ctx, element: f64, factor: f64, opacity: Attr) -> (f64, Attr) {
+ (element, Attr(*opacity * factor))
+}
+
+#[node_macro::node(category("Test"))]
+fn measure(_: impl Ctx, element: f64) -> (f64, Attr) {
+ (element, Attr(element.abs()))
+}
+
+#[node_macro::node(category("Test"))]
+fn shade(_: impl Ctx, element: f64, opacity: Attr) -> f64 {
+ element * *opacity
+}
+
+#[node_macro::node(category("Test"))]
+fn checked_multiply_opacity(_: impl Ctx, element: f64, factor: f64, opacity: Attr) -> Result<(f64, Attr), Interrupt> {
+ if factor < 0. {
+ return Err(GraphError::new("negative factor").into());
+ }
+ Ok((element, Attr(*opacity * factor)))
+}
+
+#[node_macro::node(category("Test"))]
+fn scale(_: impl Ctx, element: f64, factor: &f64, opacity: Attr) -> (f64, Attr) {
+ (element * *factor, Attr(*opacity))
+}
+
+#[node_macro::node(category("Test"))]
+fn pick(ctx: impl Ctx, take_second: bool, first: impl Node, Output = T>, second: impl Node, Output = T>) -> Result {
+ if take_second { second.eval(ctx) } else { first.eval(ctx) }
+}
+
+#[node_macro::node(category("Test"))]
+fn hold_first(ctx: impl Ctx, take_second: bool, first: impl Node, Output = T>, second: impl Node, Output = T>) -> Result {
+ let held = first.eval(ctx)?;
+ let alt = second.eval(ctx)?;
+ Ok(if take_second { alt } else { held })
+}
+
+#[node_macro::node(category("Test"))]
+fn forward_record(_: impl Ctx, element: T) -> T {
+ element
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use core_types::SourceId;
+ use core_types::arena::Arena;
+ use core_types::attribute::Attribute as AttributeMarker;
+ use core_types::context::{ContextImpl, EvalScope};
+ use core_types::gpoll::GPoll;
+ use core_types::list::List;
+ use core_types::node::Node;
+
+ struct ValueNode(T);
+
+ impl Node for ValueNode {
+ type Output = T;
+
+ fn eval(&self, _input: &Input) -> GPoll {
+ GPoll::Final(self.0.clone())
+ }
+ }
+
+ struct PartialListNode(List);
+
+ impl Node for PartialListNode {
+ type Output = List;
+
+ fn eval(&self, _input: &Input) -> GPoll> {
+ GPoll::Partial(self.0.clone())
+ }
+ }
+
+ fn scope_fixture<'a>(generations: &'a [(SourceId, u64)], arena: &'a Arena) -> EvalScope<'a> {
+ EvalScope::new(Some(0.5), None, None, generations, arena)
+ }
+
+ fn elements(list: &List) -> Vec {
+ list.iter_element_values().copied().collect()
+ }
+
+ fn column(list: &List, key: &str) -> Vec {
+ list.iter_attribute_values::(key).unwrap().copied().collect()
+ }
+
+ #[test]
+ fn defaults_then_modify_then_stack() {
+ let arena = Arena::new(1024).unwrap();
+ let generations = [];
+ let scope = scope_fixture(&generations, &arena);
+ let ctx = ContextImpl::root(&scope);
+
+ let chain = MultiplyOpacityNode::new(
+ MultiplyOpacityNode::new(ValueNode(List::from_element_values(vec![1., 2.])), ValueNode(0.5)),
+ ValueNode(0.5),
+ );
+ let GPoll::Final(list) = chain.eval(&ctx) else {
+ panic!("expected a final list");
+ };
+ assert_eq!(elements(&list), vec![1., 2.]);
+ assert_eq!(column(&list, Opacity::NAME), vec![0.25, 0.25]);
+ }
+
+ #[test]
+ fn tuple_write_element_and_attribute() {
+ let arena = Arena::new(1024).unwrap();
+ let generations = [];
+ let scope = scope_fixture(&generations, &arena);
+ let ctx = ContextImpl::root(&scope);
+
+ let chain = MeasureNode::new(ValueNode(List::from_element_values(vec![-2., 3.])));
+ let GPoll::Final(list) = chain.eval(&ctx) else {
+ panic!("expected a final list");
+ };
+ assert_eq!(elements(&list), vec![-2., 3.]);
+ assert_eq!(column(&list, Length::NAME), vec![2., 3.]);
+ }
+
+ #[test]
+ fn elementwise_write_carries_unrelated_columns() {
+ let arena = Arena::new(1024).unwrap();
+ let generations = [];
+ let scope = scope_fixture(&generations, &arena);
+ let ctx = ContextImpl::root(&scope);
+
+ let chain = MeasureNode::new(MultiplyOpacityNode::new(ValueNode(List::from_element_values(vec![-2., 3.])), ValueNode(0.5)));
+ let GPoll::Final(list) = chain.eval(&ctx) else {
+ panic!("expected a final list");
+ };
+ assert_eq!(column(&list, Opacity::NAME), vec![0.5, 0.5]);
+ assert_eq!(column(&list, Length::NAME), vec![2., 3.]);
+ }
+
+ #[test]
+ fn reads_only_kernel_reads_the_declared_default_and_carries() {
+ let arena = Arena::new(1024).unwrap();
+ let generations = [];
+ let scope = scope_fixture(&generations, &arena);
+ let ctx = ContextImpl::root(&scope);
+
+ let bare = ShadeNode::new(ValueNode(List::from_element_values(vec![4., 6.])));
+ let GPoll::Final(list) = bare.eval(&ctx) else {
+ panic!("expected a final list");
+ };
+ assert_eq!(elements(&list), vec![4., 6.]);
+
+ let shaded = ShadeNode::new(MultiplyOpacityNode::new(ValueNode(List::from_element_values(vec![4., 6.])), ValueNode(0.5)));
+ let GPoll::Final(list) = shaded.eval(&ctx) else {
+ panic!("expected a final list");
+ };
+ assert_eq!(elements(&list), vec![2., 3.]);
+ assert_eq!(column(&list, Opacity::NAME), vec![0.5, 0.5]);
+ }
+
+ #[test]
+ fn partial_carrier_downgrades_the_output() {
+ let arena = Arena::new(1024).unwrap();
+ let generations = [];
+ let scope = scope_fixture(&generations, &arena);
+ let ctx = ContextImpl::root(&scope);
+
+ let chain = MultiplyOpacityNode::new(PartialListNode(List::from_element_values(vec![1.])), ValueNode(0.5));
+ let GPoll::Partial(list) = chain.eval(&ctx) else {
+ panic!("expected a partial list");
+ };
+ assert_eq!(column(&list, Opacity::NAME), vec![0.5]);
+ }
+
+ use core_types::context::ExtractFrame;
+ use core_types::record::{Frame, FrameLayout, Layout, Rec, RecordSource, RecordValue};
+
+ struct RecordSourceNode {
+ slot: usize,
+ element: f64,
+ fields: Vec<(usize, f64)>,
+ partial: bool,
+ }
+
+ impl<'e> Node> for RecordSourceNode {
+ type Output = RecordValue<'e>;
+
+ fn eval(&self, input: &ContextImpl<'e>) -> GPoll> {
+ let frame = ExtractFrame::frame(input).unwrap();
+ let value = unsafe {
+ let dst = frame.slot(self.slot);
+ dst.cast::().write(self.element);
+ for (offset, value) in &self.fields {
+ dst.add(*offset).cast::().write(*value);
+ }
+ RecordValue::from_rec(Rec::new(dst))
+ };
+ match self.partial {
+ true => GPoll::Partial(value),
+ false => GPoll::Final(value),
+ }
+ }
+ }
+
+ fn f64_layout(names: &[&'static str]) -> Layout {
+ let writes: Vec<(&'static str, u8, usize, usize)> = names.iter().map(|name| (*name, 0, 8, 8)).collect();
+ Layout::default().with_writes(0, (8, 8), &writes)
+ }
+
+ static FACTOR: f64 = 3.;
+
+ struct StaticLendNode(&'static f64);
+
+ impl<'e> Node> for StaticLendNode {
+ type Output = &'e f64;
+
+ fn eval(&self, _input: &ContextImpl<'e>) -> GPoll<&'e f64> {
+ GPoll::Final(self.0)
+ }
+ }
+
+ #[test]
+ fn lend_value_params_wire_into_record_kernels() {
+ let arena = Arena::new(1024).unwrap();
+ let generations = [];
+ let scope = scope_fixture(&generations, &arena);
+ let ctx = ContextImpl::root(&scope);
+
+ let chain = ScaleNode::new(
+ MultiplyOpacityNode::new(ValueNode(List::from_element_values(vec![1., 2.])), ValueNode(0.5)),
+ StaticLendNode(&FACTOR),
+ );
+ let GPoll::Final(list) = chain.eval(&ctx) else {
+ panic!("expected a final list");
+ };
+ assert_eq!(elements(&list), vec![3., 6.]);
+ assert_eq!(column(&list, Opacity::NAME), vec![0.5, 0.5]);
+ }
+
+ #[test]
+ fn routing_unions_branch_layouts_and_fills_census_defaults() {
+ let arena = Arena::new(1024).unwrap();
+ let generations = [];
+
+ let layout_a = f64_layout(&["opacity"]);
+ let layout_b = f64_layout(&["length"]);
+ let union = Layout::union(&[&layout_a, &layout_b]);
+
+ let mut frame_layout = FrameLayout::default();
+ let slot_a = frame_layout.slot(&layout_a);
+ let slot_b = frame_layout.slot(&layout_b);
+ let translate_a = frame_layout.slot(&union);
+ let translate_b = frame_layout.slot(&union);
+ let frame = Frame::new(frame_layout.size());
+
+ let scope = scope_fixture(&generations, &arena).with_frame(&frame);
+ let ctx = ContextImpl::root(&scope);
+
+ let taken = |second: bool| {
+ PickNode::new(
+ ValueNode(second),
+ RecordSource::wire(
+ RecordSourceNode {
+ slot: slot_a,
+ element: 1.,
+ fields: vec![(layout_a.offset_of("opacity", 0).unwrap(), 0.5)],
+ partial: false,
+ },
+ &layout_a,
+ &union,
+ translate_a,
+ ),
+ RecordSource::wire(
+ RecordSourceNode {
+ slot: slot_b,
+ element: 3.,
+ fields: vec![(layout_b.offset_of("length", 0).unwrap(), 3.)],
+ partial: false,
+ },
+ &layout_b,
+ &union,
+ translate_b,
+ ),
+ )
+ };
+
+ let GPoll::Final(value) = taken(false).eval(&ctx) else {
+ panic!("expected a final record");
+ };
+ let rec = value.rec();
+ assert_eq!(unsafe { rec.element::() }, 1.);
+ assert_eq!(unsafe { rec.read::(union.offset_of("opacity", 0).unwrap()) }, 0.5);
+ assert_eq!(unsafe { rec.read::(union.offset_of("length", 0).unwrap()) }, 0.);
+
+ let GPoll::Final(value) = taken(true).eval(&ctx) else {
+ panic!("expected a final record");
+ };
+ let rec = value.rec();
+ assert_eq!(unsafe { rec.element::() }, 3.);
+ assert_eq!(unsafe { rec.read::(union.offset_of("opacity", 0).unwrap()) }, 1.);
+ assert_eq!(unsafe { rec.read::(union.offset_of("length", 0).unwrap()) }, 3.);
+ }
+
+ #[test]
+ fn routing_provenance_survives_later_evaluations() {
+ let arena = Arena::new(1024).unwrap();
+ let generations = [];
+
+ let layout_a = f64_layout(&["opacity"]);
+ let layout_b = f64_layout(&["length"]);
+ let union = Layout::union(&[&layout_a, &layout_b]);
+
+ let mut frame_layout = FrameLayout::default();
+ let slot_a = frame_layout.slot(&layout_a);
+ let slot_b = frame_layout.slot(&layout_b);
+ let translate_a = frame_layout.slot(&union);
+ let translate_b = frame_layout.slot(&union);
+ let frame = Frame::new(frame_layout.size());
+
+ let scope = scope_fixture(&generations, &arena).with_frame(&frame);
+ let ctx = ContextImpl::root(&scope);
+
+ let chain = HoldFirstNode::new(
+ ValueNode(false),
+ RecordSource::wire(
+ RecordSourceNode {
+ slot: slot_a,
+ element: 1.,
+ fields: vec![(layout_a.offset_of("opacity", 0).unwrap(), 0.5)],
+ partial: false,
+ },
+ &layout_a,
+ &union,
+ translate_a,
+ ),
+ RecordSource::wire(
+ RecordSourceNode {
+ slot: slot_b,
+ element: 3.,
+ fields: vec![(layout_b.offset_of("length", 0).unwrap(), 3.)],
+ partial: false,
+ },
+ &layout_b,
+ &union,
+ translate_b,
+ ),
+ );
+
+ let GPoll::Final(value) = chain.eval(&ctx) else {
+ panic!("expected a final record");
+ };
+ let rec = value.rec();
+ assert_eq!(unsafe { rec.element::() }, 1.);
+ assert_eq!(unsafe { rec.read::(union.offset_of("opacity", 0).unwrap()) }, 0.5);
+ assert_eq!(unsafe { rec.read::(union.offset_of("length", 0).unwrap()) }, 0.);
+ }
+
+ #[test]
+ fn identity_layouts_forward_the_record_pointer() {
+ let arena = Arena::new(1024).unwrap();
+ let generations = [];
+
+ let layout = f64_layout(&["opacity"]);
+ let mut frame_layout = FrameLayout::default();
+ let slot = frame_layout.slot(&layout);
+ let frame = Frame::new(frame_layout.size());
+
+ let scope = scope_fixture(&generations, &arena).with_frame(&frame);
+ let ctx = ContextImpl::root(&scope);
+
+ let chain = ForwardRecordNode::new(RecordSource::wire(
+ RecordSourceNode {
+ slot,
+ element: 4.,
+ fields: vec![(layout.offset_of("opacity", 0).unwrap(), 0.25)],
+ partial: false,
+ },
+ &layout,
+ &layout.clone(),
+ 0,
+ ));
+
+ let GPoll::Final(value) = chain.eval(&ctx) else {
+ panic!("expected a final record");
+ };
+ let rec = value.rec();
+ assert_eq!(rec.ptr(), unsafe { frame.slot(slot) }.cast_const());
+ assert_eq!(unsafe { rec.element::() }, 4.);
+ assert_eq!(unsafe { rec.read::(layout.offset_of("opacity", 0).unwrap()) }, 0.25);
+ }
+
+ #[test]
+ fn partial_routing_sources_downgrade_the_output() {
+ let arena = Arena::new(1024).unwrap();
+ let generations = [];
+
+ let layout = f64_layout(&["opacity"]);
+ let mut frame_layout = FrameLayout::default();
+ let slot = frame_layout.slot(&layout);
+ let frame = Frame::new(frame_layout.size());
+
+ let scope = scope_fixture(&generations, &arena).with_frame(&frame);
+ let ctx = ContextImpl::root(&scope);
+
+ let chain = ForwardRecordNode::new(RecordSource::wire(
+ RecordSourceNode {
+ slot,
+ element: 4.,
+ fields: vec![],
+ partial: true,
+ },
+ &layout,
+ &layout.clone(),
+ 0,
+ ));
+
+ let GPoll::Partial(value) = chain.eval(&ctx) else {
+ panic!("expected a partial record");
+ };
+ assert_eq!(unsafe { value.rec().element::() }, 4.);
+ }
+
+ #[test]
+ fn interrupt_kernel_errors_stop_the_eval() {
+ let arena = Arena::new(1024).unwrap();
+ let generations = [];
+ let scope = scope_fixture(&generations, &arena);
+ let ctx = ContextImpl::root(&scope);
+
+ let ok = CheckedMultiplyOpacityNode::new(ValueNode(List::from_element_values(vec![1.])), ValueNode(0.5));
+ let GPoll::Final(list) = ok.eval(&ctx) else {
+ panic!("expected a final list");
+ };
+ assert_eq!(column(&list, Opacity::NAME), vec![0.5]);
+
+ let failing = CheckedMultiplyOpacityNode::new(ValueNode(List::from_element_values(vec![1.])), ValueNode(-1.));
+ let GPoll::Error(error) = failing.eval(&ctx) else {
+ panic!("expected an error");
+ };
+ assert!(error.kind == "negative factor");
+ }
+}