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"); + } +}