diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 9b400255f4..5eb1e76c76 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -324,6 +324,7 @@ pub(crate) fn property_from_type( Type::Fn(_, out) => return property_from_type(node_id, index, out, number_options, unit, display_decimal_places, step, context), Type::Future(out) => return property_from_type(node_id, index, out, number_options, unit, display_decimal_places, step, context), Type::Ref(inner) => return property_from_type(node_id, index, inner, number_options, unit, display_decimal_places, step, context), + Type::Record(inner) => return property_from_type(node_id, index, inner, number_options, unit, display_decimal_places, step, context), }; extra_widgets.push(widgets); diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 30d30f5507..af5b4d7ad3 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -390,6 +390,7 @@ macro_rules! tagged_value { match input { Type::Generic(_) => None, Type::Ref(_) => None, + Type::Record(_) => None, Type::Concrete(concrete_type) => { let name = concrete_type.name.as_ref(); // TODO: Add default implementations for types such as TaggedValue::Subpaths, and use the defaults here and in document_node_types @@ -651,6 +652,7 @@ impl TaggedValue { match ty { Type::Generic(_) => None, Type::Ref(_) => None, + Type::Record(_) => None, Type::Concrete(concrete_type) => { let ty = concrete_type.id?; use std::any::TypeId; diff --git a/node-graph/graph-craft/src/proto.rs b/node-graph/graph-craft/src/proto.rs index 1f1938e163..2d922e1b1f 100644 --- a/node-graph/graph-craft/src/proto.rs +++ b/node-graph/graph-craft/src/proto.rs @@ -902,6 +902,8 @@ fn valid_type(from: &Type, to: &Type) -> bool { (Type::Fn(in1, out1), Type::Fn(in2, out2)) => valid_type(out2, out1) && valid_type(in1, in2), // A lend edge is substitutable exactly when the lent values are. (Type::Ref(in1), Type::Ref(in2)) => valid_type(in1, in2), + // A record edge is substitutable exactly when the elements are. + (Type::Record(in1), Type::Record(in2)) => valid_type(in1, in2), // If either the proposed input or the allowed input are generic, we allow the substitution (meaning this is a valid subtype). // TODO: Add proper generic counting which is not based on the name (Type::Generic(_), _) | (_, Type::Generic(_)) => true, @@ -925,6 +927,8 @@ fn ref_adapter(proposed: &Type, wanted: &Type) -> Option { match (proposed_output.as_ref(), wanted_output.as_ref()) { (Type::Ref(inner), wanted_output @ Type::Concrete(_)) if valid_type(inner, wanted_output) => Some(ProtoNodeIdentifier::new("graphene_core::debug::CloneNode")), (proposed_output @ Type::Concrete(_), Type::Ref(inner)) if valid_type(proposed_output, inner) => Some(ProtoNodeIdentifier::new("graphene_core::memo::LendNode")), + (Type::Record(inner), wanted_output @ Type::Concrete(_)) if valid_type(inner, wanted_output) => Some(ProtoNodeIdentifier::new("core_types::record::RecordExtractNode")), + (proposed_output @ Type::Concrete(_), Type::Record(inner)) if valid_type(proposed_output, inner) => Some(ProtoNodeIdentifier::new("core_types::record::RecordLiftNode")), _ => None, } } @@ -980,10 +984,23 @@ fn collect_generics(types: &NodeIOTypes) -> Vec> { /// Checks if a generic type can be substituted with a concrete type and returns the concrete type fn check_generic(types: &NodeIOTypes, input: &Type, parameters: &[Type], generic: &str) -> Result { + fn record_element(ty: Option<&Type>) -> Option<&Type> { + match ty { + Some(Type::Record(inner)) => Some(inner.as_ref()), + _ => None, + } + } let inputs = [(Some(&types.call_argument), Some(input))] .into_iter() .chain(types.inputs.iter().map(|x| x.fn_input()).zip(parameters.iter().map(|x| x.fn_input()))) - .chain(types.inputs.iter().map(|x| x.fn_output()).zip(parameters.iter().map(|x| x.fn_output()))); + .chain(types.inputs.iter().map(|x| x.fn_output()).zip(parameters.iter().map(|x| x.fn_output()))) + .chain( + types + .inputs + .iter() + .map(|x| record_element(x.fn_output())) + .zip(parameters.iter().map(|x| record_element(x.fn_output()))), + ); let concrete_inputs = inputs.filter(|(ni, _)| matches!(ni, Some(Type::Generic(input)) if generic == input)); let mut outputs = concrete_inputs.flat_map(|(_, out)| out); let out_ty = outputs diff --git a/node-graph/interpreted-executor/src/dynamic_executor.rs b/node-graph/interpreted-executor/src/dynamic_executor.rs index 38505df10e..3f8b7c6639 100644 --- a/node-graph/interpreted-executor/src/dynamic_executor.rs +++ b/node-graph/interpreted-executor/src/dynamic_executor.rs @@ -181,6 +181,7 @@ where return Err("Output node not found in executor".into()); }; let mut arena = self.arena.lock().unwrap_or_else(PoisonError::into_inner); + core_types::record::stack::reserve(self.tree.stack_need()); let result = eval_root(&mut arena, &self.runtime, &input, |ctx| match TaggedValue::from_edge(handle.duplicate(), ctx) { Ok(poll) => poll.map(Ok), Err(error) => GPoll::Final(Err(error)), @@ -469,6 +470,14 @@ impl BorrowTree { pub fn source_map(&self) -> &HashMap { &self.source_map } + + /// The record-stack bound of an evaluation: the sum over all node frames. + pub fn stack_need(&self) -> usize { + self.nodes + .values() + .map(|(handle, _)| handle.layout().map_or(0, |layout| layout.size.next_multiple_of(8))) + .sum() + } } #[cfg(test)] @@ -596,6 +605,40 @@ mod test { assert!(matches!(result, Some(GPoll::Final(_))), "the palette must evaluate through the spliced lend, got {result:?}"); } + #[test] + fn a_record_wire_types_wires_and_evaluates_through_the_registry() { + let network = ProtoNetwork { + inputs: vec![], + output: NodeId(2), + nodes: vec![ + (NodeId(0), ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(7.).into()), vec![])), + (NodeId(1), proto_node("core_types::record::RecordLiftNode", vec![NodeId(0)])), + (NodeId(2), proto_node("core_types::record::RecordExtractNode", vec![NodeId(1)])), + ], + }; + + let executor = DynamicExecutor::new(network).unwrap(); + let lift = executor.tree().get(NodeId(1)).unwrap(); + assert_eq!(lift.ty(), &core_types::registry::record_edge_type::()); + assert!(lift.layout().is_some()); + assert_eq!((&executor).execute(()).unwrap(), GPoll::Final(TaggedValue::F64(7.))); + } + + #[test] + fn a_lift_adapter_is_spliced_between_a_plain_producer_and_a_record_consumer() { + let network = ProtoNetwork { + inputs: vec![], + output: NodeId(1), + nodes: vec![ + (NodeId(0), ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(7.).into()), vec![])), + (NodeId(1), proto_node("core_types::record::RecordExtractNode", vec![NodeId(0)])), + ], + }; + + let executor = DynamicExecutor::new(network).unwrap(); + assert_eq!((&executor).execute(()).unwrap(), GPoll::Final(TaggedValue::F64(7.))); + } + #[test] fn a_clone_out_adapter_is_spliced_between_a_lending_producer_and_an_owned_consumer() { let network = ProtoNetwork { diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 6d0b71a941..fe44c8f92f 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -20,7 +20,7 @@ use graphene_std::transform::Footprint; use graphene_std::uuid::NodeId; use graphene_std::vector::Vector; use graphene_std::{Artboard, Context, Graphic, ProtoNodeIdentifier, SourceId, concrete, fn_type}; -use node_registry_macros::{async_node, clone_node, convert_node, frame_memo_node, into_node, lend_node}; +use node_registry_macros::{async_node, clone_node, convert_node, frame_memo_node, into_node, lend_node, record_extract_node, record_lift_node}; use std::collections::HashMap; #[cfg(feature = "gpu")] use wgpu_executor::WgpuExecutorHandle; @@ -374,6 +374,8 @@ fn node_registry() -> HashMap> { #[cfg(target_family = "wasm")] frame_memo_node!(CanvasHandle), lend_node!(f64), + record_lift_node!(f64), + record_extract_node!(f64), clone_node!(f64), frame_memo_node!(f64), lend_node!(f32), @@ -769,6 +771,44 @@ mod node_registry_macros { }; } + macro_rules! record_lift_node { + ($type:ty) => { + ( + ProtoNodeIdentifier::new("core_types::record::RecordLiftNode"), + RegistryEntry { + io: NodeIOTypes::new(concrete!(Context), core_types::registry::record_type::<$type>(), vec![fn_type!(Context, $type)]), + constructor: |inputs| { + if inputs.len() != 1 { + return Err(ConstructionError::Arity { expected: 1, got: inputs.len() }); + } + let mut inputs = inputs.into_iter(); + let node = core_types::record::RecordLift::<$type, _>::wire(inputs.next().unwrap().downcast::<$type>()?); + Ok(EdgeHandle::new_record::<$type>(std::sync::Arc::new(node) as std::sync::Arc)) + }, + }, + ) + }; + } + + macro_rules! record_extract_node { + ($type:ty) => { + ( + ProtoNodeIdentifier::new("core_types::record::RecordExtractNode"), + RegistryEntry { + io: NodeIOTypes::new(concrete!(Context), concrete!($type), vec![core_types::registry::record_edge_type::<$type>()]), + constructor: |inputs| { + if inputs.len() != 1 { + return Err(ConstructionError::Arity { expected: 1, got: inputs.len() }); + } + let mut inputs = inputs.into_iter(); + let node = core_types::record::RecordExtract::<$type, _>::wire(inputs.next().unwrap().downcast_record::<$type>()?); + Ok(EdgeHandle::new(std::sync::Arc::new(node) as std::sync::Arc>)) + }, + }, + ) + }; + } + macro_rules! clone_node { ($type:ty) => { ( @@ -813,4 +853,6 @@ mod node_registry_macros { pub(crate) use frame_memo_node; pub(crate) use into_node; pub(crate) use lend_node; + pub(crate) use record_extract_node; + pub(crate) use record_lift_node; } diff --git a/node-graph/libraries/core-types/src/record.rs b/node-graph/libraries/core-types/src/record.rs index 82e68c3a11..f9114cda97 100644 --- a/node-graph/libraries/core-types/src/record.rs +++ b/node-graph/libraries/core-types/src/record.rs @@ -352,6 +352,79 @@ impl RecordSource { } } +/// Lifts a plain producer onto a record wire: the element lands at offset 0 +/// of a fresh element-only record. `Copy` elements only until droppable +/// elements ride records. +pub struct RecordLift { + edge: N, + layout: Layout, + frame_bytes: usize, + _marker: std::marker::PhantomData El>, +} + +impl RecordLift { + pub fn wire(edge: N) -> Self { + let layout = Layout::default().with_writes(0, (size_of::(), align_of::()), &[]); + let frame_bytes = layout.size.next_multiple_of(8); + Self { + edge, + layout, + frame_bytes, + _marker: std::marker::PhantomData, + } + } +} + +impl<'e, C, El, N> Node for RecordLift +where + C: crate::context::ExtractArena, + El: Copy + 'static, + N: Node, +{ + type Output = RecordValue<'e>; + + fn eval(&self, input: &C) -> GPoll> { + let dst = stack::push(self.frame_bytes); + let value = self.edge.eval(input).map(|element| { + unsafe { write_field(dst, 0, element) }; + RecordValue::from_rec(unsafe { Rec::new(dst.cast_const()) }) + }); + stack::pop(dst); + value + } + + fn layout(&self) -> Option<&Layout> { + Some(&self.layout) + } +} + +/// Extracts the element from a record wire for a plain consumer. +pub struct RecordExtract { + edge: N, + _marker: std::marker::PhantomData El>, +} + +impl RecordExtract { + pub fn wire(edge: N) -> Self { + Self { + edge, + _marker: std::marker::PhantomData, + } + } +} + +impl<'e, C, El, N> Node for RecordExtract +where + El: Copy + 'static, + N: Node>, +{ + type Output = El; + + fn eval(&self, input: &C) -> GPoll { + self.edge.eval(input).map(|value| unsafe { value.rec().element::() }) + } +} + impl<'e, C, N> Node for RecordSource where N: Node>, diff --git a/node-graph/libraries/core-types/src/registry.rs b/node-graph/libraries/core-types/src/registry.rs index bfc0db53f1..4c97fb92b0 100644 --- a/node-graph/libraries/core-types/src/registry.rs +++ b/node-graph/libraries/core-types/src/registry.rs @@ -79,6 +79,12 @@ pub type ErasedLendNode = dyn for<'c> Node, Output = &'c T> + #[cfg(target_family = "wasm")] pub type ErasedLendNode = dyn for<'c> Node, Output = &'c T>; +/// Element-independent by erasure; the wire's `Type::Record(El)` keeps element reads proven at wiring. +#[cfg(not(target_family = "wasm"))] +pub type ErasedRecordNode = dyn for<'c> Node, Output = crate::record::RecordValue<'c>> + Send + Sync; +#[cfg(target_family = "wasm")] +pub type ErasedRecordNode = dyn for<'c> Node, Output = crate::record::RecordValue<'c>>; + #[cfg(not(target_family = "wasm"))] type DynEdge = dyn std::any::Any + Send + Sync; #[cfg(target_family = "wasm")] @@ -96,6 +102,22 @@ pub fn lend_edge_type() -> Type { Type::Fn(Box::new(concrete!(Context)), Box::new(ref_type::())) } +pub fn record_type() -> Type { + Type::Record(Box::new(concrete!(T))) +} + +pub fn record_edge_type() -> Type { + Type::Fn(Box::new(concrete!(Context)), Box::new(record_type::())) +} + +/// The record edge type of a token row, generic over the element. +pub fn generic_record_edge_type(name: &'static str) -> Type { + Type::Fn( + Box::new(concrete!(Context)), + Box::new(Type::Record(Box::new(Type::Generic(std::borrow::Cow::Borrowed(name))))), + ) +} + pub fn cache_key(ctx: &C) -> u64 { let mut hasher = graphene_hash::FxHasher64::new(); ctx.cache_hash(&mut hasher); @@ -106,6 +128,7 @@ pub fn cache_key(ctx: &C) -> u64 { pub enum ConstructionError { Arity { expected: usize, got: usize }, Type { expected: Box, found: Box }, + MissingLayout, } pub struct SharedEdge { @@ -198,6 +221,10 @@ impl EdgeHandle { Self::new_erased(node, lend_edge_type::()) } + pub fn new_record(node: std::sync::Arc) -> Self { + Self::new_erased(node, record_edge_type::()) + } + pub fn new_erased(node: std::sync::Arc, ty: Type) -> Self where N: ?Sized + 'static + for<'c> Node>, @@ -242,6 +269,10 @@ impl EdgeHandle { self.downcast_erased(lend_edge_type::()) } + pub fn downcast_record(self) -> Result, ConstructionError> { + self.downcast_erased(record_edge_type::()) + } + pub fn downcast_erased(self, expected: Type) -> Result, ConstructionError> { let found = self.ty; self.node.downcast::>().map(|edge| *edge).map_err(|_| ConstructionError::Type { diff --git a/node-graph/libraries/core-types/src/types.rs b/node-graph/libraries/core-types/src/types.rs index 97644818aa..e1558de29d 100644 --- a/node-graph/libraries/core-types/src/types.rs +++ b/node-graph/libraries/core-types/src/types.rs @@ -236,6 +236,8 @@ pub enum Type { /// Represents a future which promises to return the inner type. Future(Box), Ref(Box), + /// A packed record wire over the element type; the layout stays node-resident metadata. + Record(Box), } impl Default for Type { @@ -310,6 +312,7 @@ impl Type { Self::Fn(_, _) => None, Self::Future(_) => None, Self::Ref(_) => None, + Self::Record(_) => None, } } @@ -320,6 +323,7 @@ impl Type { Self::Fn(_, _) => None, Self::Future(_) => None, Self::Ref(_) => None, + Self::Record(_) => None, } } @@ -330,6 +334,7 @@ impl Type { Self::Fn(_, output) => output.nested_type(), Self::Future(output) => output.nested_type(), Self::Ref(inner) => inner.nested_type(), + Self::Record(inner) => inner.nested_type(), } } @@ -343,6 +348,7 @@ impl Type { Self::Fn(_, output) => output.replace_nested(f), Self::Future(output) => output.replace_nested(f), Self::Ref(inner) => inner.replace_nested(f), + Self::Record(inner) => inner.replace_nested(f), } } @@ -353,6 +359,7 @@ impl Type { Type::Fn(call_arg, return_value) => format!("{} called with {}", return_value.identifier_name(), call_arg.identifier_name()), Type::Future(ty) => ty.identifier_name(), Type::Ref(ty) => ty.identifier_name(), + Type::Record(ty) => ty.identifier_name(), } } } @@ -448,6 +455,7 @@ impl std::fmt::Display for Type { Type::Fn(_, return_value) => write!(f, "{return_value}"), Type::Future(ty) => write!(f, "{ty}"), Type::Ref(ty) => write!(f, "{ty}"), + Type::Record(ty) => write!(f, "{ty}"), } } } diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs index 85ee74ae5d..446dc9657b 100644 --- a/node-graph/node-macro/src/codegen.rs +++ b/node-graph/node-macro/src/codegen.rs @@ -1731,7 +1731,7 @@ fn entries_tokens(parsed: &ParsedNodeFn, struct_name: &Ident, data_field_generic return quote!(); } if has_record_io(parsed) { - return quote!(); + return record_entries_tokens(parsed, struct_name, regular_fields); } let Some(rows) = implementation_rows(parsed, regular_fields) else { return quote!(); @@ -1815,6 +1815,104 @@ fn entries_tokens(parsed: &ParsedNodeFn, struct_name: &Ident, data_field_generic } } +fn record_entries_tokens(parsed: &ParsedNodeFn, struct_name: &Ident, regular_fields: &[&ParsedField]) -> TokenStream2 { + let Some(shape) = record_shape(parsed) else { + return quote!(); + }; + let carrier_in_fields = !shape.skips_carrier(); + let values_concrete = regular_fields.iter().skip(carrier_in_fields as usize).all(|field| match &field.ty { + ParsedFieldType::Regular(RegularParsedField { ty, lend, .. }) => !contains_open_generic(parsed, ty) && (lend.is_some() || !type_disqualifies(ty)), + _ => false, + }); + if !values_concrete { + return quote!(); + } + + let fn_name = &parsed.fn_name; + let entries_name = format_ident!("{}_entries", fn_name); + let arity = regular_fields.len(); + let names: Vec<&Ident> = regular_fields.iter().map(|field| &field.pat_ident.ident).collect(); + + let input_types = regular_fields.iter().enumerate().map(|(index, field)| { + let ParsedFieldType::Regular(RegularParsedField { ty, lend, .. }) = &field.ty else { + unreachable!("record nodes take no lazy inputs") + }; + if carrier_in_fields && index == 0 { + return match &shape.carrier { + RecordCarrier::Token(token) => { + let name = token.to_string(); + quote!(gcore::registry::generic_record_edge_type(#name)) + } + RecordCarrier::Read(carrier_ty) => quote!(gcore::registry::record_edge_type::<#carrier_ty>()), + RecordCarrier::None => unreachable!(), + }; + } + match lend.is_some() { + true => quote!(gcore::registry::lend_edge_type::<#ty>()), + false => quote!(gcore::registry::edge_type::<#ty>()), + } + }); + let downcasts = regular_fields.iter().enumerate().map(|(index, field)| { + let name = &field.pat_ident.ident; + let ParsedFieldType::Regular(RegularParsedField { ty, lend, .. }) = &field.ty else { + unreachable!("record nodes take no lazy inputs") + }; + if carrier_in_fields && index == 0 { + return quote! { + let __carrier_handle = inputs.next().unwrap(); + let __carrier_ty = __carrier_handle.ty().clone(); + let Some(__carrier_layout) = __carrier_handle.layout().cloned() else { + return Err(gcore::registry::ConstructionError::MissingLayout); + }; + let #name = __carrier_handle.downcast_erased::(__carrier_ty.clone())?; + }; + } + match lend.is_some() { + true => quote!(let #name = inputs.next().unwrap().downcast_lend::<#ty>()?;), + false => quote!(let #name = inputs.next().unwrap().downcast::<#ty>()?;), + } + }); + let wire_layout_arg = carrier_in_fields.then(|| quote!(&__carrier_layout,)).into_iter(); + let (io_output, construct_output) = match (&shape.carrier, &shape.element_write) { + (RecordCarrier::Token(token), _) => { + let name = token.to_string(); + ( + quote!(gcore::Type::Record(Box::new(gcore::Type::Generic(::std::borrow::Cow::Borrowed(#name))))), + quote!(Ok(gcore::registry::EdgeHandle::new_erased( + ::std::sync::Arc::new(__node) as ::std::sync::Arc, + __carrier_ty, + ))), + ) + } + (_, Some(element)) => ( + quote!(gcore::registry::record_type::<#element>()), + quote!(Ok(gcore::registry::EdgeHandle::new_record::<#element>(::std::sync::Arc::new(__node)))), + ), + (_, None) => unreachable!("non-token record nodes write an element"), + }; + + quote! { + pub fn #entries_name() -> ::std::vec::Vec { + vec![gcore::registry::RegistryEntry { + io: gcore::registry::NodeIOTypes::new( + gcore::concrete!(gcore::context::ContextImpl<'static>), + #io_output, + vec![#(#input_types),*], + ), + constructor: |inputs| { + if inputs.len() != #arity { + return Err(gcore::registry::ConstructionError::Arity { expected: #arity, got: inputs.len() }); + } + let mut inputs = inputs.into_iter(); + #(#downcasts)* + let __node = #struct_name::wire(#(#names,)* #(#wire_layout_arg)*); + #construct_output + }, + }] + } + } +} + fn implementation_rows(parsed: &ParsedNodeFn, regular_fields: &[&ParsedField]) -> Option>> { let ctx_ident = context_param(parsed).map(|ctx| ctx.ident.clone()); let open_generics: Vec<&Ident> = parsed