From 592b2fbe32ed4abe9e5a4026781c00eff22676b8 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Wed, 5 Aug 2026 19:25:17 +0000 Subject: [PATCH] Turn context modification into a generic record routing node and park droppable elements as arena references --- .../src/dynamic_executor.rs | 59 +++++ .../interpreted-executor/src/node_registry.rs | 137 +++++++++-- node-graph/libraries/core-types/src/record.rs | 145 ++++++++++++ node-graph/node-macro/src/codegen.rs | 222 ++++++++++++++++-- .../nodes/gcore/src/context_modification.rs | 39 +-- node-graph/nodes/gcore/src/record.rs | 127 +++++++++- 6 files changed, 646 insertions(+), 83 deletions(-) diff --git a/node-graph/interpreted-executor/src/dynamic_executor.rs b/node-graph/interpreted-executor/src/dynamic_executor.rs index 15e40585d0..ef57e79894 100644 --- a/node-graph/interpreted-executor/src/dynamic_executor.rs +++ b/node-graph/interpreted-executor/src/dynamic_executor.rs @@ -654,6 +654,65 @@ mod test { assert!(fields.is_empty(), "an element-only record has no attribute fields"); } + fn modification_value() -> ProtoNode { + let modification = core_types::ContextModification::from_sources(core_types::context::ContextFeatures::all(), &[]); + ProtoNode::value(ConstructionArgs::Value(TaggedValue::ContextModification(modification).into()), vec![]) + } + + #[test] + fn a_context_modification_row_wires_over_a_plain_value_wire() { + let network = ProtoNetwork { + inputs: vec![], + output: NodeId(3), + nodes: vec![ + (NodeId(0), ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(7.).into()), vec![])), + (NodeId(1), modification_value()), + (NodeId(2), proto_node("graphene_core::context_modification::ContextModificationNode", vec![NodeId(0), NodeId(1)])), + (NodeId(3), proto_node("core_types::record::RecordExtractNode", vec![NodeId(2)])), + ], + }; + + let executor = DynamicExecutor::new(network).unwrap(); + assert_eq!((&executor).execute(()).unwrap(), GPoll::Final(TaggedValue::F64(7.))); + } + + #[test] + fn a_context_modification_over_a_wire_without_a_lift_row_reports_at_typing() { + let network = ProtoNetwork { + inputs: vec![], + output: NodeId(3), + nodes: vec![ + (NodeId(0), ProtoNode::value(ConstructionArgs::Value(TaggedValue::BrushStrokes(vec![]).into()), vec![])), + (NodeId(1), modification_value()), + (NodeId(2), proto_node("graphene_core::context_modification::ContextModificationNode", vec![NodeId(0), NodeId(1)])), + (NodeId(3), proto_node("core_types::record::RecordExtractNode", vec![NodeId(2)])), + ], + }; + + let result = DynamicExecutor::new(network); + let error = format!("{:?}", result.err()); + assert!(!error.contains("MissingLayout"), "an absent lift row must be a typing error, not a construction failure: {error}"); + } + + #[test] + fn nested_context_modifications_forward_the_layout() { + let network = ProtoNetwork { + inputs: vec![], + output: NodeId(5), + nodes: vec![ + (NodeId(0), ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(7.).into()), vec![])), + (NodeId(1), modification_value()), + (NodeId(2), proto_node("graphene_core::context_modification::ContextModificationNode", vec![NodeId(0), NodeId(1)])), + (NodeId(3), modification_value()), + (NodeId(4), proto_node("graphene_core::context_modification::ContextModificationNode", vec![NodeId(2), NodeId(3)])), + (NodeId(5), proto_node("core_types::record::RecordExtractNode", vec![NodeId(4)])), + ], + }; + + let executor = DynamicExecutor::new(network).unwrap(); + 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 { diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index e13c73c31d..402c82b6e6 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -186,25 +186,6 @@ fn node_registry() -> HashMap> { async_node!(graphene_core::memo::MonitorNode<_, _>, input: Context, fn_params: [Context => graphene_std::text::TextAlign]), async_node!(graphene_core::memo::MonitorNode<_, _>, input: Context, fn_params: [Context => graphene_std::transform::ScaleType]), async_node!(graphene_core::memo::MonitorNode<_, _>, input: Context, fn_params: [Context => graphene_std::vector::misc::InterpolationDistribution]), - // Context nullification - #[cfg(feature = "gpu")] - async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => std::sync::Arc, Context => graphene_std::ContextModification]), - async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => RuntimeHandle, Context => graphene_std::ContextModification]), - async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => SourceId, Context => graphene_std::ContextModification]), - async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => RenderIntermediate, Context => graphene_std::ContextModification]), - async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => RenderOutput, Context => graphene_std::ContextModification]), - async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => AttributeDyn, Context => graphene_std::ContextModification]), - async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => AttributeValueDyn, Context => graphene_std::ContextModification]), - async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => ListDyn, Context => graphene_std::ContextModification]), - async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => graphene_std::vector::style::GradientType, Context => graphene_std::ContextModification]), - async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => graphene_std::vector::style::GradientSpreadMethod, Context => graphene_std::ContextModification]), - async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => Option, Context => graphene_std::ContextModification]), - #[cfg(target_family = "wasm")] - async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => CanvasHandle, Context => graphene_std::ContextModification]), - async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => std::sync::Arc, Context => graphene_std::ContextModification]), - async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => wgpu_executor::WgpuExecutorHandle, Context => graphene_std::ContextModification]), - async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => Option, Context => graphene_std::ContextModification]), - async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => wgpu_executor::WgpuPipelineCache, Context => graphene_std::ContextModification]), // ========== // MEMO NODES // ========== @@ -376,6 +357,90 @@ fn node_registry() -> HashMap> { lend_node!(f64), record_lift_node!(f64), record_extract_node!(f64), + record_lift_node!(()), + record_extract_node!(()), + record_lift_node!(bool), + record_extract_node!(bool), + record_lift_node!(u32), + record_extract_node!(u32), + record_lift_node!(u64), + record_extract_node!(u64), + record_lift_node!(f32), + record_extract_node!(f32), + record_lift_node!(DVec2), + record_extract_node!(DVec2), + record_lift_node!(IVec2), + record_extract_node!(IVec2), + record_lift_node!(DAffine2), + record_extract_node!(DAffine2), + record_lift_node!(Option), + record_extract_node!(Option), + record_lift_node!(Footprint), + record_extract_node!(Footprint), + record_lift_node!(SourceId), + record_extract_node!(SourceId), + record_lift_node!(BlendMode), + record_extract_node!(BlendMode), + record_lift_node!(graphene_std::vector::style::GradientType), + record_extract_node!(graphene_std::vector::style::GradientType), + record_lift_node!(graphene_std::vector::style::GradientSpreadMethod), + record_extract_node!(graphene_std::vector::style::GradientSpreadMethod), + record_lift_node!(ref String), + record_extract_node!(clone String), + record_lift_node!(ref List), + record_extract_node!(clone List), + record_lift_node!(ref List), + record_extract_node!(clone List), + record_lift_node!(ref List), + record_extract_node!(clone List), + record_lift_node!(ref List), + record_extract_node!(clone List), + record_lift_node!(ref List), + record_extract_node!(clone List), + record_lift_node!(ref List), + record_extract_node!(clone List), + record_lift_node!(ref List>), + record_extract_node!(clone List>), + #[cfg(feature = "gpu")] + record_lift_node!(ref List>), + #[cfg(feature = "gpu")] + record_extract_node!(clone List>), + record_lift_node!(ref List), + record_extract_node!(clone List), + record_lift_node!(ref List), + record_extract_node!(clone List), + record_lift_node!(ref List), + record_extract_node!(clone List), + record_lift_node!(ref AttributeDyn), + record_extract_node!(clone AttributeDyn), + record_lift_node!(ref AttributeValueDyn), + record_extract_node!(clone AttributeValueDyn), + record_lift_node!(ref ListDyn), + record_extract_node!(clone ListDyn), + record_lift_node!(ref std::sync::Arc), + record_extract_node!(clone std::sync::Arc), + record_lift_node!(ref RuntimeHandle), + record_extract_node!(clone RuntimeHandle), + record_lift_node!(ref RenderIntermediate), + record_extract_node!(clone RenderIntermediate), + record_lift_node!(ref RenderOutput), + record_extract_node!(clone RenderOutput), + #[cfg(target_family = "wasm")] + record_lift_node!(ref CanvasHandle), + #[cfg(target_family = "wasm")] + record_extract_node!(clone CanvasHandle), + #[cfg(feature = "gpu")] + record_lift_node!(ref WgpuExecutorHandle), + #[cfg(feature = "gpu")] + record_extract_node!(clone WgpuExecutorHandle), + #[cfg(feature = "gpu")] + record_lift_node!(ref Option), + #[cfg(feature = "gpu")] + record_extract_node!(clone Option), + #[cfg(feature = "gpu")] + record_lift_node!(ref wgpu_executor::WgpuPipelineCache), + #[cfg(feature = "gpu")] + record_extract_node!(clone wgpu_executor::WgpuPipelineCache), ( ProtoNodeIdentifier::new("graphene_core::memo::MonitorNode"), RegistryEntry { @@ -812,6 +877,22 @@ mod node_registry_macros { }, ) }; + (ref $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::RecordLiftRef::<$type, _>::new(inputs.next().unwrap().downcast::<$type>()?); + Ok(EdgeHandle::new_record::<$type>(std::sync::Arc::new(node) as std::sync::Arc)) + }, + }, + ) + }; } macro_rules! record_extract_node { @@ -833,6 +914,24 @@ mod node_registry_macros { }, ) }; + (clone $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 edge = inputs.next().unwrap(); + let layout = edge.layout().ok_or(ConstructionError::MissingLayout)?.clone(); + let node = core_types::record::RecordExtractClone::<$type, _>::new(edge.downcast_record::<$type>()?, &layout); + Ok(EdgeHandle::new(std::sync::Arc::new(node) as std::sync::Arc>)) + }, + }, + ) + }; } macro_rules! clone_node { diff --git a/node-graph/libraries/core-types/src/record.rs b/node-graph/libraries/core-types/src/record.rs index 0543f7433a..7a4bdaac8b 100644 --- a/node-graph/libraries/core-types/src/record.rs +++ b/node-graph/libraries/core-types/src/record.rs @@ -251,6 +251,64 @@ impl<'e> RecordValue<'e> { _lifetime: std::marker::PhantomData, } } + + /// Rebinds the eval lifetime; validity stays stack and arena discipline, + /// which derived scopes share with their parent evaluation. + fn rebind<'a>(self) -> RecordValue<'a> { + RecordValue { + ptr: self.ptr, + _extra: self._extra, + _lifetime: std::marker::PhantomData, + } + } +} + +/// A record edge evaluable at a derived context, yielding the record at that +/// context's lifetime. The lifetime is a trait parameter because a bound like +/// `for<'d> Node, Output = RecordValue<'d>>` is rejected: in a +/// higher-ranked bound the lifetime must appear in a constrained input +/// position, and both the `Derived` projection and the `Output` binding are +/// unconstrained ones. +pub trait DerivedRecordEdge<'derived, C> { + fn eval_derived(&self, cell: &crate::node::StatusCell, input_index: usize, ctx: &C) -> Result, crate::gpoll::Interrupt>; +} + +impl<'derived, C, N> DerivedRecordEdge<'derived, C> for N +where + N: Node>, +{ + fn eval_derived(&self, cell: &crate::node::StatusCell, input_index: usize, ctx: &C) -> Result, crate::gpoll::Interrupt> { + cell.eval_input(input_index, self, ctx) + } +} + +/// The lazy record input handed to a kernel that evaluates its edges under +/// derived contexts: evaluating rebinds the record to the kernel's routing +/// lifetime, so the value escapes the derivation scope. +#[derive(Clone, Copy)] +pub struct RecordLazyInput<'a, 'e, N> { + node: &'a N, + cell: &'a crate::node::StatusCell, + input_index: usize, + _lifetime: std::marker::PhantomData RecordValue<'e>>, +} + +impl<'a, 'e, N> RecordLazyInput<'a, 'e, N> { + pub fn new(node: &'a N, cell: &'a crate::node::StatusCell, input_index: usize) -> Self { + Self { + node, + cell, + input_index, + _lifetime: std::marker::PhantomData, + } + } + + pub fn eval<'d, C>(&self, ctx: &C) -> Result, crate::gpoll::Interrupt> + where + N: DerivedRecordEdge<'d, C>, + { + Ok(self.node.eval_derived(self.cell, self.input_index, ctx)?.rebind()) + } } /// The per-thread record stack: every record evaluation claims its activation @@ -574,6 +632,93 @@ where } } +/// Lifts a droppable producer onto a record wire: the owned element parks in +/// the arena and the record's element is the parked reference, the same rule +/// reference-valued attributes follow. +pub struct RecordLiftRef { + edge: N, + layout: Layout, + _marker: std::marker::PhantomData El>, +} + +impl RecordLiftRef { + pub fn new(edge: N) -> Self { + Self { + edge, + layout: Layout::default().with_writes(0, (size_of::<&El>(), align_of::<&El>()), &[]), + _marker: std::marker::PhantomData, + } + } +} + +impl<'e, C, El, N> Node for RecordLiftRef +where + C: crate::context::ExtractArena, + El: Send + Sync + 'static, + N: Node, +{ + type Output = RecordValue<'e>; + + fn eval(&self, input: &C) -> GPoll> { + let park = |element: El| { + let (parked, _) = input.arena().alloc(element)?; + let mut value = RecordValue::zeroed(); + unsafe { write_field::<&El>(value.as_mut_ptr(), 0, parked) }; + Some(value) + }; + let exhausted = || { + GPoll::Error(Box::new(crate::gpoll::GraphError { + kind: crate::gpoll::ErrorKind::ArenaExhausted, + trace: Vec::new(), + })) + }; + match self.edge.eval(input) { + GPoll::Final(element) => park(element).map_or_else(exhausted, GPoll::Final), + GPoll::Partial(element) => park(element).map_or_else(exhausted, GPoll::Partial), + GPoll::Fallback(boxed) => { + let (element, error) = *boxed; + park(element).map_or_else(exhausted, |value| GPoll::Fallback(Box::new((value, error)))) + } + GPoll::Pending => GPoll::Pending, + GPoll::Error(error) => GPoll::Error(error), + } + } + + fn layout(&self) -> Option<&Layout> { + Some(&self.layout) + } +} + +/// Extracts a parked-reference element from a record wire by cloning it out +/// for a plain consumer. +pub struct RecordExtractClone { + edge: N, + layout: Layout, + _marker: std::marker::PhantomData El>, +} + +impl RecordExtractClone { + pub fn new(edge: N, layout: &Layout) -> Self { + Self { + edge, + layout: layout.clone(), + _marker: std::marker::PhantomData, + } + } +} + +impl<'e, C, El, N> Node for RecordExtractClone +where + El: Clone + 'static, + N: Node>, +{ + type Output = El; + + fn eval(&self, input: &C) -> GPoll { + self.edge.eval(input).map(|value| unsafe { self.layout.rec(&value).element::<&El>() }.clone()) + } +} + /// Extracts the element from a record wire for a plain consumer. pub struct RecordExtract { edge: N, diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs index beadaca187..05ace45664 100644 --- a/node-graph/node-macro/src/codegen.rs +++ b/node-graph/node-macro/src/codegen.rs @@ -40,6 +40,7 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let (data_fields, regular_fields): (Vec<_>, Vec<_>) = fields.iter().partition(|f| f.is_data_field); let record = record_shape(parsed); + let routing = routing_io(parsed); let record_skips_carrier = record.as_ref().is_some_and(|shape| shape.skips_carrier()); // Record nodes with a `_: ()` primary input have no carrier edge; the unit // field stays visible in the metadata but claims no struct field. @@ -137,6 +138,7 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn })); state } + None if routing.is_some() => vec![quote!(pub(super) __layout: gcore::record::Layout)], None => Vec::new(), }; @@ -258,7 +260,7 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let all_field_inits = data_inits.chain(regular_inits).chain(slot_init); // Data fields may not implement Copy, PartialEq, etc., so only derive Debug and Clone - let struct_derives = if record.is_some() { + let struct_derives = if record.is_some() || routing.is_some() { quote!(#[derive(Debug, Clone)]) } else if data_fields.is_empty() && !async_source { quote!(#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]) @@ -285,15 +287,18 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn }; // Record nodes construct through the generated `wire` fn, which resolves // offsets from the carrier layout; `new` cannot fill that state. + let routing_layout_param = routing.is_some().then(|| quote!(__layout: &gcore::record::Layout,)).into_iter(); + let routing_layout_init = routing.is_some().then(|| quote!(__layout: __layout.clone(),)).into_iter(); let new_impl = match record.is_none() { true => quote! { #[automatically_derived] impl<'n, #(#struct_generic_params,)*> #struct_name<#(#struct_type_params,)*> { #[allow(clippy::too_many_arguments)] - pub fn new(#(#new_args,)*) -> Self { + pub fn new(#(#new_args,)* #(#routing_layout_param)*) -> Self { Self { #(#all_field_inits,)* + #(#routing_layout_init)* } } } @@ -712,6 +717,7 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn _ => false, }) }); + let derive_routing = derives && routing.is_some(); let ctx_generic = match ctx_bounds.is_empty() { true => quote!(#ctx_ident), @@ -721,7 +727,15 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn 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 generics: Vec = parsed + .fn_generics + .iter() + .filter(|param| match param { + GenericParam::Type(type_param) => !derive_routing || Some(&type_param.ident) != routing.as_ref().map(|routing| &routing.generic), + _ => true, + }) + .map(&generic_tokens) + .collect(); let mut impl_generics: Vec = parsed .fn_generics .iter() @@ -744,6 +758,9 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn if routing.is_some() || record.is_some() { impl_generics.insert(0, quote!('__record)); } + if derive_routing { + generics.insert(0, quote!('__record)); + } let fn_name = &parsed.fn_name; let mod_name = format_ident!("_{}_mod", parsed.mod_name); @@ -760,6 +777,21 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let (data_fields, regular_fields): (Vec<_>, Vec<_>) = parsed.fields.iter().partition(|field| field.is_data_field); let regular_fields: Vec<_> = regular_fields.into_iter().skip(skips_carrier as usize).collect(); + if derive_routing { + for (index, field) in regular_fields.iter().enumerate() { + let source_ty = match &field.ty { + ParsedFieldType::Node(NodeParsedField { output_type, .. }) => output_type, + ParsedFieldType::Regular(RegularParsedField { ty, .. }) => ty, + }; + if matches!((&routing, source_ty), (Some(routing), Type::Path(path)) if path.path.get_ident() == Some(&routing.generic)) { + let source_generic = format_ident!("__Source{index}"); + generics.push(quote! { + #source_generic: for<'__derived> #core_types::record::DerivedRecordEdge<'__derived, #core_types::context::Derived<'__derived, #ctx_ident>> + }); + } + } + } + let data_field_generic_idents: Vec = parsed .fn_generics .iter() @@ -792,6 +824,8 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn false => quote!(#core_types::node::Node<#ctx_ident, Output = #output_type>), }; + let routing_source = |ty: &Type| matches!((&routing, ty), (Some(routing), Type::Path(path)) if path.path.get_ident() == Some(&routing.generic)); + let attr_kernel_params = parsed.attribute_reads.iter().map(|read| { let pat = &read.pat_ident; let marker = &read.marker; @@ -799,12 +833,17 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn }); let kernel_params = regular_fields .iter() - .filter(|field| !injected_name(&field.pat_ident.ident)) - .map(|field| { + .enumerate() + .filter(|(_, field)| !injected_name(&field.pat_ident.ident)) + .map(|(index, 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 derive_routing && routing_source(output_type) => { + let source_generic = format_ident!("__Source{index}"); + quote!(#pat: #core_types::record::RecordLazyInput<'_, '__record, #source_generic>) + } ParsedFieldType::Node(NodeParsedField { output_type, .. }) if raw_lazy => { let bound = lazy_bound(output_type); quote!(#pat: &impl #bound) @@ -817,7 +856,6 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn }) .chain(attr_kernel_params); - 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(_), .. }) => { @@ -831,10 +869,13 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn 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, .. }) if routing_source(output_type) => match derives { + true => quote!(#node_generic: for<'__derived> #core_types::record::DerivedRecordEdge<'__derived, #core_types::context::Derived<'__derived, #ctx_ident>>), + false => { + 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) @@ -898,6 +939,9 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn Err(interrupt) => return interrupt.into(), }; }, + ParsedFieldType::Node(NodeParsedField { output_type, .. }) if derive_routing && routing_source(output_type) => quote! { + let #name = #core_types::record::RecordLazyInput::new(&self.#name, &__cell, #index); + }, ParsedFieldType::Node(_) if raw_lazy => quote!(), ParsedFieldType::Node(_) => quote! { let #name = #core_types::node::LazyInput::new(&self.#name, &__cell, #index); @@ -994,7 +1038,14 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn // lifetime explicitly and pass through untouched. let kernel_output = record.as_ref().and_then(|_| inject_attr_lifetimes(&parsed.output_type)); let attr_lifetime = kernel_output.is_some().then(|| quote!('__attr,)); - let kernel_output = kernel_output.map(|ty| quote!(#ty)).unwrap_or_else(|| quote!(#output_type)); + let kernel_output = match derive_routing { + true => { + let generic = &routing.as_ref().expect("derive routing implies routing").generic; + let ty = substitute_routing_record(&parsed.output_type, generic, core_types); + quote!(#ty) + } + false => kernel_output.map(|ty| quote!(#ty)).unwrap_or_else(|| quote!(#output_type)), + }; let kernel = match async_fn { false => quote! { #[allow(clippy::too_many_arguments)] @@ -1229,16 +1280,19 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn Some(shape) if shape.skips_carrier() => { vec![quote!(#ctx_ident: #core_types::context::ExtractArena)] } + None if derive_routing => { + vec![quote!(#ctx_ident: #core_types::context::ExtractArena)] + } _ => Vec::new(), }; - let record_layout_impl = match &record { - Some(_) => quote! { + let record_layout_impl = match record.is_some() || routing.is_some() { + true => quote! { fn layout(&self) -> Option<&#core_types::record::Layout> { Some(&self.__layout) } }, - None => quote!(), + false => quote!(), }; let entries = entries_tokens(parsed, &struct_name, &data_field_generic_idents, ®ular_fields); @@ -1412,6 +1466,36 @@ pub(crate) fn has_record_io(parsed: &ParsedNodeFn) -> bool { !parsed.attribute_reads.is_empty() || record_writes(&slot_value_type(&parsed.output_type)).is_some() } +/// Replaces the routing generic in a derive-routing kernel's return type with +/// the routing record value, since the kernel's edges rebind to '__record. +fn substitute_routing_record(output: &Type, generic: &Ident, core_types: &TokenStream2) -> Type { + struct Subst<'a> { + generic: &'a Ident, + replacement: Type, + } + + impl VisitMut for Subst<'_> { + fn visit_type_mut(&mut self, ty: &mut Type) { + if let Type::Path(path) = ty + && path.qself.is_none() + && path.path.get_ident() == Some(self.generic) + { + *ty = self.replacement.clone(); + return; + } + syn::visit_mut::visit_type_mut(self, ty); + } + } + + let mut ty = output.clone(); + let mut subst = Subst { + generic, + replacement: syn::parse_quote!(#core_types::record::RecordValue<'__record>), + }; + subst.visit_type_mut(&mut ty); + ty +} + fn inject_attr_lifetimes(output: &Type) -> Option { struct Injector { changed: bool, @@ -1734,6 +1818,9 @@ fn entries_tokens(parsed: &ParsedNodeFn, struct_name: &Ident, data_field_generic if has_record_io(parsed) { return record_entries_tokens(parsed, struct_name, regular_fields); } + if routing_io(parsed).is_some() { + return routing_entries_tokens(parsed, struct_name, regular_fields); + } let Some(rows) = implementation_rows(parsed, regular_fields) else { return quote!(); }; @@ -1816,6 +1903,113 @@ fn entries_tokens(parsed: &ParsedNodeFn, struct_name: &Ident, data_field_generic } } +/// The registry row of a routing node: one instance covers every element, +/// sources claim generic record edges, and the constructor wraps each source +/// in its union translation and stores the union as the node's layout. +fn routing_entries_tokens(parsed: &ParsedNodeFn, struct_name: &Ident, regular_fields: &[&ParsedField]) -> TokenStream2 { + let Some(routing) = routing_io(parsed) else { + return quote!(); + }; + let is_source = |field: &ParsedField| { + let ty = match &field.ty { + ParsedFieldType::Node(NodeParsedField { output_type, .. }) => output_type, + ParsedFieldType::Regular(RegularParsedField { ty, .. }) => ty, + }; + matches!(ty, Type::Path(path) if path.path.get_ident() == Some(&routing.generic)) + }; + let values_concrete = regular_fields.iter().filter(|field| !is_source(field)).all(|field| { + let (ty, lend) = match &field.ty { + ParsedFieldType::Regular(RegularParsedField { ty, lend, .. }) => (ty, lend.is_some()), + ParsedFieldType::Node(NodeParsedField { output_type, .. }) => (output_type, false), + }; + !contains_open_generic(parsed, ty) && (lend || !type_disqualifies(ty)) + }); + 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 token_name = routing.generic.to_string(); + + let input_types = regular_fields.iter().map(|field| { + if is_source(field) { + return quote!(gcore::registry::generic_record_edge_type(#token_name)); + } + match &field.ty { + ParsedFieldType::Regular(RegularParsedField { ty, lend: Some(_), .. }) => quote!(gcore::registry::lend_edge_type::<#ty>()), + ParsedFieldType::Regular(RegularParsedField { ty, .. }) => quote!(gcore::registry::edge_type::<#ty>()), + ParsedFieldType::Node(NodeParsedField { output_type, .. }) => quote!(gcore::registry::edge_type::<#output_type>()), + } + }); + let source_layouts: Vec = regular_fields + .iter() + .enumerate() + .filter(|(_, field)| is_source(field)) + .map(|(index, _)| format_ident!("__layout_{index}")) + .collect(); + let downcasts = regular_fields.iter().enumerate().map(|(index, field)| { + let name = &field.pat_ident.ident; + if is_source(field) { + let layout = format_ident!("__layout_{index}"); + let handle = format_ident!("__handle_{index}"); + let ty = format_ident!("__ty_{index}"); + return quote! { + let #handle = inputs.next().unwrap(); + let #ty = #handle.ty().clone(); + let Some(#layout) = #handle.layout().cloned() else { + return Err(gcore::registry::ConstructionError::MissingLayout); + }; + let #name = #handle.downcast_erased::(#ty.clone())?; + }; + } + match &field.ty { + ParsedFieldType::Regular(RegularParsedField { ty, lend: Some(_), .. }) => quote!(let #name = inputs.next().unwrap().downcast_lend::<#ty>()?;), + ParsedFieldType::Regular(RegularParsedField { ty, .. }) => quote!(let #name = inputs.next().unwrap().downcast::<#ty>()?;), + ParsedFieldType::Node(NodeParsedField { output_type, .. }) => quote!(let #name = inputs.next().unwrap().downcast::<#output_type>()?;), + } + }); + let source_wraps = regular_fields.iter().enumerate().filter(|(_, field)| is_source(field)).map(|(index, field)| { + let name = &field.pat_ident.ident; + let layout = format_ident!("__layout_{index}"); + quote!(let #name = gcore::record::RecordSource::new(#name, &#layout, &__union);) + }); + let first_source_ty = regular_fields + .iter() + .enumerate() + .find(|(_, field)| is_source(field)) + .map(|(index, _)| format_ident!("__ty_{index}")) + .expect("routing nodes have a source"); + + quote! { + pub fn #entries_name() -> ::std::vec::Vec { + vec![gcore::registry::RegistryEntry { + io: gcore::registry::NodeIOTypes::new( + gcore::concrete!(gcore::context::ContextImpl<'static>), + gcore::Type::Record(Box::new(gcore::Type::Generic(::std::borrow::Cow::Borrowed(#token_name)))), + 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 __union = gcore::record::Layout::union(&[#(&#source_layouts),*]); + #(#source_wraps)* + let __node = #struct_name::new(#(#names,)* &__union); + Ok(gcore::registry::EdgeHandle::new_erased( + ::std::sync::Arc::new(__node) as ::std::sync::Arc, + #first_source_ty, + )) + }, + }] + } + } +} + fn record_entries_tokens(parsed: &ParsedNodeFn, struct_name: &Ident, regular_fields: &[&ParsedField]) -> TokenStream2 { let Some(shape) = record_shape(parsed) else { return quote!(); diff --git a/node-graph/nodes/gcore/src/context_modification.rs b/node-graph/nodes/gcore/src/context_modification.rs index f9338886b3..027ce82abd 100644 --- a/node-graph/nodes/gcore/src/context_modification.rs +++ b/node-graph/nodes/gcore/src/context_modification.rs @@ -1,14 +1,5 @@ -use core::f64; -use core_types::Color; use core_types::context::{Context, ContextModification, Ctx, DeriveCtx}; -use core_types::gpoll::GPoll; -use core_types::list::{AttributeDyn, AttributeValueDyn, List, ListDyn}; -use core_types::transform::Footprint; -use core_types::uuid::NodeId; -use glam::{DAffine2, DVec2}; -use graphic_types::vector_types::GradientStops; -use graphic_types::{Artboard, Graphic, Vector}; -use raster_types::{CPU, GPU, Raster}; +use core_types::gpoll::Interrupt; /// Filters out what should be unused components of the context based on the specified requirements. /// This node is inserted by the compiler to "zero out" unused context components. @@ -16,36 +7,10 @@ use raster_types::{CPU, GPU, Raster}; fn context_modification( ctx: impl Ctx + DeriveCtx, /// The data to pass through, evaluated with the stripped down context. - #[implementations( - Context -> (), - Context -> bool, - Context -> u32, - Context -> u64, - Context -> f32, - Context -> f64, - Context -> String, - Context -> DAffine2, - Context -> Footprint, - Context -> DVec2, - Context -> List, - Context -> List, - Context -> List, - Context -> List, - Context -> List, - Context -> List, - Context -> List>, - Context -> List>, - Context -> List, - Context -> List, - Context -> List, - Context -> AttributeDyn, - Context -> AttributeValueDyn, - Context -> ListDyn, - )] value: impl Node, Output = T>, /// The parts of the context to keep when evaluating the input value. All other parts are nullified. modification: ContextModification, -) -> GPoll { +) -> Result { let scope = ctx.scope().nullified(modification.features, Some(modification.sources())); value.eval(&ctx.nullified(modification.features, &scope)) } diff --git a/node-graph/nodes/gcore/src/record.rs b/node-graph/nodes/gcore/src/record.rs index f0c2cfd0cc..a9606c5a8d 100644 --- a/node-graph/nodes/gcore/src/record.rs +++ b/node-graph/nodes/gcore/src/record.rs @@ -501,6 +501,7 @@ mod tests { ValueNode(second), RecordSource::new(f64_record_source(&layout_a, 1., vec![(layout_a.offset_of("opacity", 0).unwrap(), 0.5)]), &layout_a, &union), RecordSource::new(f64_record_source(&layout_b, 3., vec![(layout_b.offset_of("length", 0).unwrap(), 3.)]), &layout_b, &union), + &union, ) }; @@ -537,6 +538,7 @@ mod tests { ValueNode(false), RecordSource::new(f64_record_source(&layout_a, 1., vec![(layout_a.offset_of("opacity", 0).unwrap(), 0.5)]), &layout_a, &union), RecordSource::new(f64_record_source(&layout_b, 3., vec![(layout_b.offset_of("length", 0).unwrap(), 3.)]), &layout_b, &union), + &union, ); let GPoll::Final(value) = chain.eval(&ctx) else { @@ -548,6 +550,102 @@ mod tests { assert_eq!(unsafe { rec.read::(union.offset_of("length", 0).unwrap()) }, 0.); } + struct RealTimeProbe { + layout: Layout, + } + + impl<'e> Node> for RealTimeProbe { + type Output = RecordValue<'e>; + + fn eval(&self, input: &ContextImpl<'e>) -> GPoll> { + assert!(self.layout.is_inline()); + let mut value = RecordValue::zeroed(); + let element: f64 = match core_types::context::ExtractRealTime::try_real_time(input) { + Some(_) => 1., + None => 0., + }; + unsafe { value.as_mut_ptr().cast::().write(element) }; + GPoll::Final(value) + } + } + + #[test] + fn context_modification_nullifies_for_the_inner_record_edge() { + use core_types::context::{ContextFeatures, ContextModification}; + + let arena = Arena::new(1024).unwrap(); + let generations = []; + let scope = scope_fixture(&generations, &arena); + let ctx = ContextImpl::root(&scope); + + let layout = f64_layout(&[]); + reserve_for(&[&layout]); + + let probed = |features: ContextFeatures| { + let node = crate::context_modification::ContextModificationNode::new( + RealTimeProbe { layout: layout.clone() }, + ValueNode(ContextModification::from_sources(features, &[])), + &layout, + ); + assert_eq!(Node::::layout(&node), Some(&layout)); + let GPoll::Final(value) = node.eval(&ctx) else { + panic!("expected a final record"); + }; + unsafe { layout.rec(&value).element::() } + }; + + assert_eq!(probed(ContextFeatures::all()), 1., "kept features stay readable under the modification"); + assert_eq!(probed(ContextFeatures::empty()), 0., "nullified features read as absent for the inner edge"); + } + + #[test] + fn context_modification_forwards_record_partiality() { + use core_types::context::{ContextFeatures, ContextModification}; + + let arena = Arena::new(1024).unwrap(); + let generations = []; + let scope = scope_fixture(&generations, &arena); + let ctx = ContextImpl::root(&scope); + + let layout = f64_layout(&["opacity"]); + reserve_for(&[&layout]); + + let node = crate::context_modification::ContextModificationNode::new( + RecordSourceNode { + layout: layout.clone(), + element: 4., + fields: vec![(layout.offset_of("opacity", 0).unwrap(), 0.25)], + partial: true, + }, + ValueNode(ContextModification::from_sources(ContextFeatures::all(), &[])), + &layout, + ); + + let GPoll::Partial(value) = node.eval(&ctx) else { + panic!("expected a partial record"); + }; + let rec = layout.rec(&value); + assert_eq!(unsafe { rec.element::() }, 4.); + assert_eq!(unsafe { rec.read::(layout.offset_of("opacity", 0).unwrap()) }, 0.25); + } + + #[test] + fn droppable_elements_park_and_clone_out() { + let arena = Arena::new(1024).unwrap(); + let generations = []; + let scope = scope_fixture(&generations, &arena); + let ctx = ContextImpl::root(&scope); + + let lift = core_types::record::RecordLiftRef::::new(ValueNode(String::from("parked"))); + let layout = Node::::layout(&lift).unwrap().clone(); + let chain = core_types::record::RecordExtractClone::::new(lift, &layout); + + let GPoll::Final(text) = chain.eval(&ctx) else { + panic!("expected a final value"); + }; + assert_eq!(text, "parked"); + } + #[test] fn inline_records_survive_sibling_evaluations_by_value() { let arena = Arena::new(1024).unwrap(); @@ -565,6 +663,7 @@ mod tests { ValueNode(false), RecordSource::new(f64_record_source(&layout_a, 1., vec![(layout_a.offset_of("opacity", 0).unwrap(), 0.5)]), &layout_a, &union), RecordSource::new(f64_record_source(&layout_b, 3., vec![]), &layout_b, &union), + &union, ); let GPoll::Final(value) = chain.eval(&ctx) else { @@ -587,11 +686,10 @@ mod tests { let base = stack::push(0); stack::pop(base); - let chain = ForwardRecordNode::new(RecordSource::new( - f64_record_source(&layout, 4., vec![(layout.offset_of("opacity", 0).unwrap(), 0.25)]), + let chain = ForwardRecordNode::new( + RecordSource::new(f64_record_source(&layout, 4., vec![(layout.offset_of("opacity", 0).unwrap(), 0.25)]), &layout, &layout.clone()), &layout, - &layout.clone(), - )); + ); let GPoll::Final(value) = chain.eval(&ctx) else { panic!("expected a final record"); @@ -612,16 +710,19 @@ mod tests { let layout = f64_layout(&["opacity"]); reserve_for(&[&layout]); - let chain = ForwardRecordNode::new(RecordSource::new( - RecordSourceNode { - layout: layout.clone(), - element: 4., - fields: vec![], - partial: true, - }, + let chain = ForwardRecordNode::new( + RecordSource::new( + RecordSourceNode { + layout: layout.clone(), + element: 4., + fields: vec![], + partial: true, + }, + &layout, + &layout.clone(), + ), &layout, - &layout.clone(), - )); + ); let GPoll::Partial(value) = chain.eval(&ctx) else { panic!("expected a partial record");