From 20b476dd202a2f97c30906136faaf385c206a31a Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 7 Jul 2026 03:14:51 -0700 Subject: [PATCH] Add multi-output nodes with struct returns destructured by #[node_macro::destructure] --- Cargo.lock | 5 + .../node_graph/document_node_definitions.rs | 112 +----- .../document_node_derive.rs | 13 + .../utility_types/network_interface.rs | 1 + .../utility_types/network_interface/caches.rs | 5 +- .../network_interface/mutations.rs | 14 + .../network_interface/resolved_types.rs | 30 +- .../utility_types/network_interface/view.rs | 9 + .../messages/portfolio/document_migration.rs | 126 ++++-- .../interpreted-executor/src/node_registry.rs | 14 + .../libraries/core-types/src/registry.rs | 68 ++++ node-graph/node-macro/src/codegen.rs | 62 ++- node-graph/node-macro/src/destructure.rs | 368 ++++++++++++++++++ node-graph/node-macro/src/lib.rs | 56 +++ node-graph/node-macro/src/parsing.rs | 49 ++- node-graph/node-macro/src/validation.rs | 25 +- node-graph/nodes/gcore/src/extract_xy.rs | 40 +- node-graph/nodes/raster/src/adjustments.rs | 45 ++- node-graph/nodes/repeat/src/repeat_nodes.rs | 21 +- node-graph/nodes/vector/src/vector_nodes.rs | 95 ++--- node-graph/preprocessor/Cargo.toml | 8 + node-graph/preprocessor/src/lib.rs | 314 ++++++++++++++- 22 files changed, 1204 insertions(+), 276 deletions(-) create mode 100644 node-graph/node-macro/src/destructure.rs diff --git a/Cargo.lock b/Cargo.lock index 07b29735a75..eb1edcd70b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4367,10 +4367,15 @@ checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" name = "preprocessor" version = "0.0.0" dependencies = [ + "core-types", + "dyn-any", + "futures", + "glam", "graph-craft", "graphene-std", "interpreted-executor", "log", + "node-macro", ] [[package]] diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index ad9d4bc201d..257b13b5546 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -15,9 +15,7 @@ use graph_craft::ProtoNodeIdentifier; use graph_craft::document::value::*; use graph_craft::document::*; use graph_craft::{concrete, list}; -use graphene_std::extract_xy::XY; -use graphene_std::raster::{CellularDistanceFunction, CellularReturnType, Color, DomainWarpType, FractalType, NoiseType, RedGreenBlueAlpha}; -use graphene_std::raster_types::{CPU, Raster}; +use graphene_std::raster::{CellularDistanceFunction, CellularReturnType, Color, DomainWarpType, FractalType, NoiseType}; #[allow(unused_imports)] use graphene_std::transform::Footprint; use graphene_std::vector::Vector; @@ -663,114 +661,6 @@ fn document_node_definitions() -> HashMap), 0), - NodeInput::value(TaggedValue::RedGreenBlueAlpha(RedGreenBlueAlpha::Red), false), - ], - implementation: NodeTemplateImplementation::ProtoNode(raster_nodes::adjustments::extract_channel::IDENTIFIER), - call_argument: generic!(T), - node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(0, 0)), - ..Default::default() - }, - NodeTemplate { - inputs: vec![ - NodeInput::import(list!(Raster), 0), - NodeInput::value(TaggedValue::RedGreenBlueAlpha(RedGreenBlueAlpha::Green), false), - ], - implementation: NodeTemplateImplementation::ProtoNode(raster_nodes::adjustments::extract_channel::IDENTIFIER), - call_argument: generic!(T), - node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(0, 2)), - ..Default::default() - }, - NodeTemplate { - inputs: vec![ - NodeInput::import(list!(Raster), 0), - NodeInput::value(TaggedValue::RedGreenBlueAlpha(RedGreenBlueAlpha::Blue), false), - ], - implementation: NodeTemplateImplementation::ProtoNode(raster_nodes::adjustments::extract_channel::IDENTIFIER), - call_argument: generic!(T), - node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(0, 4)), - ..Default::default() - }, - NodeTemplate { - inputs: vec![ - NodeInput::import(list!(Raster), 0), - NodeInput::value(TaggedValue::RedGreenBlueAlpha(RedGreenBlueAlpha::Alpha), false), - ], - implementation: NodeTemplateImplementation::ProtoNode(raster_nodes::adjustments::extract_channel::IDENTIFIER), - call_argument: generic!(T), - node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(0, 6)), - ..Default::default() - }, - ] - .into_iter() - .enumerate() - .map(|(id, node)| (NodeId(id as u64), node)) - .collect(), - ..Default::default() - }), - inputs: vec![NodeInput::type_default(list!(Raster), true)], - input_metadata: vec![("Image", "TODO").into()], - output_names: vec!["".to_string(), "Red".to_string(), "Green".to_string(), "Blue".to_string(), "Alpha".to_string()], - ..Default::default() - }, - description: Cow::Borrowed("TODO"), - properties: None, - }, - DocumentNodeDefinition { - identifier: "Split Vec2", - category: "Math: Vec2", - node_template: NodeTemplate { - implementation: NodeTemplateImplementation::Network(NodeNetworkTemplate { - exports: vec![NodeInput::value(TaggedValue::None, false), NodeInput::node(NodeId(0), 0), NodeInput::node(NodeId(1), 0)], - nodes: [ - NodeTemplate { - inputs: vec![NodeInput::import(item!(DVec2), 0), NodeInput::value(TaggedValue::XY(XY::X), false)], - implementation: NodeTemplateImplementation::ProtoNode(extract_xy::extract_xy::IDENTIFIER), - call_argument: generic!(T), - node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(0, 0)), - ..Default::default() - }, - NodeTemplate { - inputs: vec![NodeInput::import(item!(DVec2), 0), NodeInput::value(TaggedValue::XY(XY::Y), false)], - implementation: NodeTemplateImplementation::ProtoNode(extract_xy::extract_xy::IDENTIFIER), - call_argument: generic!(T), - node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(0, 2)), - ..Default::default() - }, - ] - .into_iter() - .enumerate() - .map(|(id, node)| (NodeId(id as u64), node)) - .collect(), - ..Default::default() - }), - inputs: vec![NodeInput::value(TaggedValue::DVec2(DVec2::ZERO), true)], - input_metadata: vec![("Vec2", "TODO").into()], - output_names: vec!["".to_string(), "X".to_string(), "Y".to_string()], - ..Default::default() - }, - description: Cow::Borrowed( - "Decomposes the X and Y components of a vec2.\n\ - \n\ - The inverse of this node is **Combine Vec2**, which composes a vec2 from its X and Y components.", - ), - properties: None, - }, DocumentNodeDefinition { identifier: "Extract", category: "", diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions/document_node_derive.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions/document_node_derive.rs index f8a96b0c8c7..e851b5810ca 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions/document_node_derive.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions/document_node_derive.rs @@ -42,6 +42,7 @@ pub(super) fn post_process_nodes(custom: Vec) -> HashMap description, properties, context_features, + output_fields, .. } = metadata; @@ -66,6 +67,17 @@ pub(super) fn post_process_nodes(custom: Vec) -> HashMap }; let inputs = preprocessor::node_inputs(fields, first_node_io); + + // A multi-output node (declared `destructure_output`) names each output after a field of its returned struct, + // preceded by an unnamed entry for the hidden primary output unless one field is marked `#[primary]` + let output_names = output_fields + .as_ref() + .map(|destructure| { + let hidden_primary_name = (!destructure.has_primary).then(String::new); + hidden_primary_name.into_iter().chain(destructure.fields.iter().map(|field| field.name.to_string())).collect() + }) + .unwrap_or_default(); + definitions_map.insert( identifier, DocumentNodeDefinition { @@ -85,6 +97,7 @@ pub(super) fn post_process_nodes(custom: Vec) -> HashMap RegistryWidgetOverride::Custom(str) => InputMetadata::with_name_description_override(f.name, f.description, WidgetOverride::Custom(str.to_string())), }) .collect(), + output_names, ..Default::default() }, category, diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs index 50a11fb0340..c11df9162f7 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -43,6 +43,7 @@ use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, No use graphene_std::Appearance; use graphene_std::ContextDependencies; use graphene_std::math::quad::Quad; +use graphene_std::registry::MULTI_OUTPUT_NODES; use graphene_std::transform::Footprint; use graphene_std::vector::click_target::{ClickTarget, ClickTargetType, FreePoint}; use graphene_std::vector::{Vector, VectorModificationType}; diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/caches.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/caches.rs index 6a96ec85bb9..0a6ca72ebb8 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/caches.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/caches.rs @@ -984,10 +984,7 @@ impl NodeNetworkInterface { } } - let number_of_outputs = match &document_node.implementation { - DocumentNodeImplementation::Network(network) => network.exports.len(), - _ => 1, - }; + let number_of_outputs = self.number_of_outputs(node_id, network_path); // If the node has a hidden primary output, do not display the first output let start_index = if self.hidden_primary_output(node_id, network_path) { 1 } else { 0 }; for output_index in start_index..number_of_outputs { diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/mutations.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/mutations.rs index 1b2b873985f..e6e4e6b249c 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/mutations.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/mutations.rs @@ -1340,6 +1340,20 @@ impl NodeNetworkInterface { self.unload_node_click_targets(node_id, network_path); } + /// Replaces the full list of output port names for a node. Used by document migrations that turn a single-output node + /// into a multi-output one, since the port labels are otherwise unnamed and fall back to the type name. + pub fn set_output_names(&mut self, node_id: &NodeId, output_names: Vec, network_path: &[NodeId]) { + let Some(node_metadata) = self.node_metadata_mut(node_id, network_path) else { + log::error!("Could not get node {node_id} in set_output_names"); + return; + }; + if node_metadata.persistent_metadata.output_names == output_names { + return; + } + node_metadata.persistent_metadata.output_names = output_names; + self.transaction_modified(); + } + pub fn set_import_export_name(&mut self, mut name: String, index: ImportOrExport, network_path: &[NodeId]) { let Some(encapsulating_node) = self.encapsulating_node_metadata_mut(network_path) else { log::error!("Could not get encapsulating network in set_import_export_name"); diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/resolved_types.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/resolved_types.rs index e9fb310e9a6..3e86696c7f5 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/resolved_types.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/resolved_types.rs @@ -395,10 +395,32 @@ impl NodeNetworkInterface { DocumentNodeImplementation::ProtoNode(identifier) if *identifier == graphene_std::ops::passthrough::IDENTIFIER => { self.input_type(&InputConnector::primary_input(*node_id), network_path) } - DocumentNodeImplementation::ProtoNode(_) => match self.resolved_types.types.get(&[network_path, &[*node_id]].concat()) { - Some(resolved_type) => TypeSource::Compiled(resolved_type.output.clone()), - None => TypeSource::Unknown, - }, + DocumentNodeImplementation::ProtoNode(identifier) => { + // The field outputs of a multi-output proto node have their wire types recorded in the registry. When the + // node resolved to its mapped variant it returns the struct's rank-lifted twin, whose fields are all lists. + // Without a `#[primary]` field, output 0 is the hidden struct output, which falls through to the compiled types below. + if let Some(metadata) = graphene_std::registry::MULTI_OUTPUT_NODES.get(identifier) { + let field_index = if metadata.has_primary { Some(*output_index) } else { output_index.checked_sub(1) }; + if let Some(field_index) = field_index { + return match metadata.fields.get(field_index) { + Some(field) => { + let is_mapped = self + .resolved_types + .types + .get(&[network_path, &[*node_id]].concat()) + .is_some_and(|resolved_type| *resolved_type.output.nested_type() == metadata.mapped_type); + TypeSource::Compiled(if is_mapped { field.mapped_ty.clone() } else { field.ty.clone() }) + } + None => TypeSource::Error("Output index out of range for proto node"), + }; + } + } + + match self.resolved_types.types.get(&[network_path, &[*node_id]].concat()) { + Some(resolved_type) => TypeSource::Compiled(resolved_type.output.clone()), + None => TypeSource::Unknown, + } + } DocumentNodeImplementation::Extract => TypeSource::Compiled(concrete!(())), } } diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/view.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/view.rs index 6c99aca5314..542e6e3de53 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface/view.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/view.rs @@ -228,6 +228,12 @@ impl<'a, 'p> NetworkView<'a, 'p> { pub fn number_of_outputs(&self, node_id: &NodeId) -> Result { Ok(match self.implementation(node_id)? { DocumentNodeImplementation::Network(nested_network) => nested_network.exports.len(), + // A multi-output proto node (declared `destructure_output`) has one output per field of the struct it returns, + // preceded by a hidden primary output carrying the struct itself unless one field is marked `#[primary]` + DocumentNodeImplementation::ProtoNode(identifier) => match MULTI_OUTPUT_NODES.get(identifier) { + Some(metadata) => metadata.fields.len() + if metadata.has_primary { 0 } else { 1 }, + None => 1, + }, _ => 1, }) } @@ -248,6 +254,9 @@ impl<'a, 'p> NetworkView<'a, 'p> { pub fn hidden_primary_output(&self, node_id: &NodeId) -> Result { Ok(match self.implementation(node_id)? { DocumentNodeImplementation::Network(network) => network.exports.first().is_none_or(|input| !input.is_exposed()), + // A multi-output proto node's primary output carries the whole struct, hidden so only the destructured field + // outputs are shown, unless a field marked `#[primary]` takes its place as the primary output + DocumentNodeImplementation::ProtoNode(identifier) => MULTI_OUTPUT_NODES.get(identifier).is_some_and(|metadata| !metadata.has_primary), _ => false, }) } diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index a2d105c40e5..20e9227d32a 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -82,10 +82,6 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ node: graphene_std::animation::animation_time::IDENTIFIER, aliases: &["graphene_core::animation::AnimationTimeNode"], }, - NodeReplacement { - node: graphene_std::extract_xy::extract_xy::IDENTIFIER, - aliases: &["graphene_core::ops::ExtractXyNode"], - }, NodeReplacement { node: graphene_std::ops::passthrough::IDENTIFIER, aliases: &[ @@ -524,14 +520,6 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ node: graphene_std::raster_nodes::std_nodes::extend_image_to_bounds::IDENTIFIER, aliases: &["graphene_raster_nodes::std_nodes::ExtendImageToBoundsNode", "graphene_std::raster::ExtendImageToBoundsNode"], }, - NodeReplacement { - node: graphene_std::raster_nodes::adjustments::extract_channel::IDENTIFIER, - aliases: &[ - "graphene_raster_nodes::adjustments::ExtractChannelNode", - "graphene_core::raster::adjustments::ExtractChannelNode", - "graphene_core::raster::ExtractChannelNode", - ], - }, NodeReplacement { node: graphene_std::raster_nodes::adjustments::gamma_correction::IDENTIFIER, aliases: &["graphene_raster_nodes::adjustments::GammaCorrectionNode", "graphene_core::raster::adjustments::GammaCorrectionNode"], @@ -934,10 +922,6 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ node: graphene_std::vector::scatter_points::IDENTIFIER, aliases: &["graphene_core::vector::PoissonDiskPointsNode", "core_types::vector::PoissonDiskPointsNode"], }, - NodeReplacement { - node: graphene_std::vector::position_on_path::IDENTIFIER, - aliases: &["graphene_core::vector::PositionOnPathNode"], - }, NodeReplacement { node: graphene_std::vector::round_corners::IDENTIFIER, aliases: &["graphene_core::vector::RoundCornersNode"], @@ -970,10 +954,6 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ node: graphene_std::vector::stroke::IDENTIFIER, aliases: &["graphene_core::vector::StrokeNode"], }, - NodeReplacement { - node: graphene_std::vector::tangent_on_path::IDENTIFIER, - aliases: &["graphene_core::vector::TangentOnPathNode"], - }, NodeReplacement { node: graphene_std::vector::as_vector::IDENTIFIER, aliases: &[ @@ -1264,6 +1244,96 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_ } } + // The "Split Vec2" and "Split Channels" wrapper networks were replaced with the multi-output `split_vec2` and `split_channels` proto nodes. + // Convert old instances to the proto node, forwarding the single input. Output indices are unchanged: the hidden primary output stays at + // index 0 (now carrying the destructured struct) and the field outputs keep their old indices, so downstream wires stay connected. + // Pre-pass for the same reason as the Brush, Transform, and Image migrations above: replacing the outer network impl orphans its child paths. + let split_wrapper_replacements = [ + ("Split Vec2", graphene_std::extract_xy::split_vec_2::IDENTIFIER), + ("Split Channels", graphene_std::raster_nodes::adjustments::split_channels::IDENTIFIER), + ]; + for (old_reference, new_identifier) in split_wrapper_replacements { + let split_nodes: Vec<(NodeId, Vec)> = document + .network_interface + .document_network() + .recursive_nodes() + .filter_map(|(node_id, _, path)| (document.network_interface.reference(node_id, &path) == Some(DefinitionIdentifier::Network(old_reference.into()))).then_some((*node_id, path))) + .collect(); + for (node_id, network_path) in &split_nodes { + // Pre-load `outward_wires` so the chain-break check inside `set_input` resolves the original upstream→node wire from cache + // rather than triggering a fresh rebuild from the (already-mutated) post-`replace_inputs` state, which would orphan wires. + let _ = document.network_interface.outward_wires(network_path); + let new_reference = DefinitionIdentifier::ProtoNode(new_identifier.clone()); + let Some(definition) = resolve_document_node_type(&new_reference) else { continue }; + let mut node_template = definition.default_node_template(); + document.network_interface.replace_implementation(node_id, network_path, &mut node_template); + let Some(old_inputs) = document.network_interface.replace_inputs(node_id, network_path, &mut node_template) else { + continue; + }; + if let Some(input) = old_inputs.first() { + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 0), input.clone(), network_path); + } + } + } + + // The "Position on Path" and "Tangent on Path" nodes were combined into a single multi-output "Evaluate Path" node whose primary + // output (index 0) is the position and whose secondary output (index 1) is the tangent angle. Convert old instances to the new node, + // forwarding the shared inputs. Tangent instances additionally have their downstream connections remapped from output 0 to output 1, + // and default their radians input to true to match the old "Tangent on Path" behavior where radians was the only option. + const POSITION_ON_PATH: &str = "graphene_core::vector::PositionOnPathNode"; + const TANGENT_ON_PATH: &str = "graphene_core::vector::TangentOnPathNode"; + let evaluate_path_nodes: Vec<(NodeId, Vec, bool)> = document + .network_interface + .document_network() + .recursive_nodes() + .filter_map(|(node_id, node, path)| { + let DocumentNodeImplementation::ProtoNode(identifier) = &node.implementation else { return None }; + match identifier.as_str() { + POSITION_ON_PATH => Some((*node_id, path, false)), + TANGENT_ON_PATH => Some((*node_id, path, true)), + _ => None, + } + }) + .collect(); + for (node_id, network_path, is_tangent) in &evaluate_path_nodes { + // Capture the old output's downstream connections before mutating, so a tangent node's wires can be remapped to output index 1 + let _ = document.network_interface.outward_wires(network_path); + let downstream_from_output = document + .network_interface + .outward_wires(network_path) + .and_then(|outward_wires| outward_wires.get(&OutputConnector::node(*node_id, 0))) + .cloned() + .unwrap_or_default(); + + let new_reference = DefinitionIdentifier::ProtoNode(graphene_std::vector::evaluate_path::IDENTIFIER); + let Some(definition) = resolve_document_node_type(&new_reference) else { continue }; + let mut node_template = definition.default_node_template(); + let output_names = definition.node_template.output_names.clone(); + + document.network_interface.replace_implementation(node_id, network_path, &mut node_template); + let Some(old_inputs) = document.network_interface.replace_inputs(node_id, network_path, &mut node_template) else { + continue; + }; + // The old single-output nodes have no output names, so set them to match the new multi-output node's "Position" and "Tangent" ports + document.network_interface.set_output_names(node_id, output_names, network_path); + + // Forward the shared inputs: content, progression, reverse, and parameterized distance + for (index, input) in old_inputs.iter().take(4).enumerate() { + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index), input.clone(), network_path); + } + + if *is_tangent { + // Forward the radians input if the tangent node already had it, otherwise default it to true to preserve the old behavior + let radians = old_inputs.get(4).cloned().unwrap_or_else(|| NodeInput::value(TaggedValue::Bool(true), false)); + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 4), radians, network_path); + + // Remap the tangent node's downstream connections from the old single output to the new tangent output at index 1 + for input_connector in &downstream_from_output { + document.network_interface.set_input(input_connector, NodeInput::node(*node_id, 1), network_path); + } + } + } + // Record which old text nodes are chain-positioned now, before `migrate_node`'s staged input-count migrations run, since those set // the upstream chain to absolute; the split below re-chains exactly the nodes that were originally part of a layer chain. let text_nodes_in_chain: std::collections::HashSet = document @@ -2036,22 +2106,6 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], .set_input(&InputConnector::node_at_index(*node_id, 1), NodeInput::value(TaggedValue::Bool(true), false), network_path); } - // Upgrade the 'Tangent on Path' node to include a boolean input for whether the output should be in radians, which was previously the only option but is now not the default - if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::tangent_on_path::IDENTIFIER) && inputs_count == 4 { - let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); - document.network_interface.replace_implementation(node_id, network_path, &mut node_template); - - let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; - - document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 0), old_inputs[0].clone(), network_path); - document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 1), old_inputs[1].clone(), network_path); - document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 2), old_inputs[2].clone(), network_path); - document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 3), old_inputs[3].clone(), network_path); - document - .network_interface - .set_input(&InputConnector::node_at_index(*node_id, 4), NodeInput::value(TaggedValue::Bool(true), false), network_path); - } - // Upgrade the Modulo node to include a boolean input for whether the output should be always positive, which was previously not an option if reference == DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::modulo::IDENTIFIER) && inputs_count == 2 { let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 0736d0a9167..6c87b6edf65 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -152,6 +152,20 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => Item<&wgpu_executor::WgpuExecutor>]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item>]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item]), + // Destructure structs of multi-output nodes and their rank-lifted twins, memoized so the struct is computed once rather than once per output (see the Graphene preprocessor) + async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => graphene_std::extract_xy::Vec2Components]), + async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => graphene_std::extract_xy::Vec2ComponentsList]), + async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => graphene_std::raster_nodes::adjustments::ImageChannels]), + async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => graphene_std::raster_nodes::adjustments::ImageChannelsList]), + async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => graphene_std::vector::PathEvaluation]), + async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => graphene_std::vector::PathEvaluationList]), + // Monitor rows for the hidden struct primary output of multi-output nodes, so inspecting one resolves + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::extract_xy::Vec2Components]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::extract_xy::Vec2ComponentsList]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::raster_nodes::adjustments::ImageChannels]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::raster_nodes::adjustments::ImageChannelsList]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::vector::PathEvaluation]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::vector::PathEvaluationList]), ]; // The per-connector input adapter, registered per element type: an `Item` or `List` wire passes through unchanged. // The `name` arm registers an `Into`-based whole-wire shift under the given identifier, serving the `ListDyn` erasure rows. diff --git a/node-graph/libraries/core-types/src/registry.rs b/node-graph/libraries/core-types/src/registry.rs index 7539a698a48..591416d7f2b 100644 --- a/node-graph/libraries/core-types/src/registry.rs +++ b/node-graph/libraries/core-types/src/registry.rs @@ -18,6 +18,8 @@ pub struct NodeMetadata { pub context_features: Vec, pub memoize: bool, pub inject_scope: bool, + /// The output connectors of a `destructure_output` node, taken from the [`Destructure`] struct it returns. + pub output_fields: Option, } // Translation struct between macro and definition @@ -59,12 +61,78 @@ pub enum RegistryValueSource { Scope(&'static str), } +/// Metadata for a `#[derive(node_macro::Destructure)]` struct, describing how its fields are broken out into individual node connectors. +/// Produced by [`Destructure::metadata`] and stored in [`NodeMetadata::output_fields`] for each node declared `destructure_output`. +/// +/// Currently used for node outputs: such a node becomes a multi-output node whose outputs are the struct's fields. +/// The same metadata is intended to eventually also drive destructured inputs, where a single struct parameter expands into one input connector per field. +#[derive(Clone, Debug)] +pub struct DestructureMetadata { + /// The fields in output-connector order. When `has_primary` is true the first entry is the field marked `#[primary]`, + /// exposed as the node's primary output at index 0 with the remaining fields following it. Otherwise a hidden primary + /// output carrying the whole struct occupies index 0 and the fields are the secondary outputs at indices 1 and up. + pub fields: Vec, + pub has_primary: bool, + /// The type of the struct's rank-lifted twin ([`Destructure::Mapped`]), which the node's mapped variant returns when framed over a list. + pub mapped_type: Type, +} + +// Translation struct between macro and definition +#[derive(Clone, Debug)] +pub struct DestructureFieldMetadata { + pub name: &'static str, + pub description: &'static str, + /// The generated proto node that extracts this field from the struct or from its rank-lifted twin. + pub extractor: ProtoNodeIdentifier, + /// The field's wire type as declared on the struct, `Item` or `List`. + pub ty: Type, + /// The field's wire type on the rank-lifted twin, always `List`. + pub mapped_ty: Type, +} + +/// A struct of wires returned by a multi-output node, implemented by `#[derive(node_macro::Destructure)]`. +/// +/// Each field is an `Item` or `List` wire that becomes one output connector. The struct itself never travels on a wire: +/// the Graphene preprocessor expands the node into the derive's generated extractor nodes, one per field. +pub trait Destructure: Sized { + /// The rank-lifted twin returned by the node's mapped variant when it is framed over a list: each `Item` field + /// becomes `List` and each `List` field stays `List`, flat-mapped per the rank-2 rule. + type Mapped; + + fn metadata() -> DestructureMetadata; + + /// An empty twin with each field's list sized for the given number of frame slots. + fn mapped_with_capacity(capacity: usize) -> Self::Mapped; + + /// Appends this struct's fields to the twin as one frame slot, pushing each `Item` field and extending with each `List` field. + fn push_into(self, mapped: &mut Self::Mapped); +} + +/// Moves one field out of a [`Destructure`] struct or its rank-lifted twin, by the field's output-connector index. +/// The generated extractor nodes are written against this trait so one node serves both rank forms. +pub trait DestructureField { + type Wire; + + fn field(self) -> Self::Wire; +} + type NodeRegistry = LazyLock>>>; pub static NODE_REGISTRY: NodeRegistry = LazyLock::new(|| Mutex::new(HashMap::new())); pub static NODE_METADATA: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); +/// All multi-output proto nodes (those declared `destructure_output`), keyed by their identifier. +/// Snapshotted on first access, which must happen after startup registration of the node metadata completes. +pub static MULTI_OUTPUT_NODES: LazyLock> = LazyLock::new(|| { + NODE_METADATA + .lock() + .unwrap() + .iter() + .filter_map(|(identifier, metadata)| metadata.output_fields.clone().map(|output_fields| (identifier.clone(), output_fields))) + .collect() +}); + #[cfg(not(target_family = "wasm"))] pub type DynFuture<'n, T> = Pin + 'n + Send>>; #[cfg(target_family = "wasm")] diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs index 6485d058c05..528a732ee00 100644 --- a/node-graph/node-macro/src/codegen.rs +++ b/node-graph/node-macro/src/codegen.rs @@ -7,7 +7,7 @@ use syn::punctuated::Punctuated; use syn::spanned::Spanned; use syn::token::Comma; use syn::{Error, Expr, ExprPath, Ident, PatIdent, Token, WhereClause, WherePredicate, parse_quote}; -static NODE_ID: AtomicU64 = AtomicU64::new(0); +pub(crate) static NODE_ID: AtomicU64 = AtomicU64::new(0); pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn) -> syn::Result { let ParsedNodeFn { @@ -510,6 +510,37 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn #serialize_impl }; + // The output a framed variant collects into: an expander kernel (returning `List`) flat-maps under the frame per the + // rank-2 force-flatten rule, a map kernel pushes one item per slot, and a `destructure_output` kernel pushes each slot's + // struct of wires into the struct's rank-lifted twin. Returns the output type, its empty initial value sized for + // `__frame_length`, the per-slot collect statement, and the value returned for an empty frame. + let framed_output = || -> (TokenStream2, TokenStream2, TokenStream2, TokenStream2) { + if attributes.destructure_output { + let destructure = quote!(<#output_type as #core_types::registry::Destructure>); + return ( + quote!(#destructure::Mapped), + quote!(#destructure::mapped_with_capacity(__frame_length)), + quote!(#core_types::registry::Destructure::push_into(__result, &mut __output);), + quote!(#destructure::mapped_with_capacity(0)), + ); + } + + match parsed.output_element.as_ref() { + Some(element_ty) => ( + quote!(#core_types::list::List<#element_ty>), + quote!(#core_types::list::List::with_capacity(__frame_length)), + quote!(__output.push(__result);), + quote!(#core_types::list::List::new()), + ), + None => ( + quote!(#output_type), + quote!(#core_types::list::List::new()), + quote!(__output.extend(__result);), + quote!(#core_types::list::List::new()), + ), + } + }; + // The mapped variant zips every ranked connector by frame slot (longest-list, last-element repeats), broadcasting bare and environment parameters by clone let mapped_eval_impl = mapped_variant.then(|| { let ranked_names: Vec<_> = regular_fields @@ -540,15 +571,7 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn false => quote!(__input.clone()), }; - // An expander kernel (returning `List`) flat-maps under the frame per the rank-2 force-flatten rule; a map kernel pushes one item per slot - let (mapped_output_type, initial_output, collect_result) = match parsed.output_element.as_ref() { - Some(element_ty) => ( - quote!(#core_types::list::List<#element_ty>), - quote!(#core_types::list::List::with_capacity(__frame_length)), - quote!(__output.push(__result);), - ), - None => (quote!(#output_type), quote!(#core_types::list::List::new()), quote!(__output.extend(__result);)), - }; + let (mapped_output_type, initial_output, collect_result, empty_output) = framed_output(); quote! { type Output = #core_types::registry::DynFuture<'n, #mapped_output_type>; @@ -560,7 +583,7 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let __frame_length = [#(#ranked_names.len()),*].into_iter().max().unwrap_or(0); if [#(#ranked_names.len()),*].into_iter().any(|length| length == 0) { - return #core_types::list::List::new(); + return #empty_output; } let mut __output = #initial_output; @@ -608,19 +631,12 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn }) .collect(); - let (list_content_output_type, initial_output, collect_result) = match parsed.output_element.as_ref() { - Some(element_ty) => ( - quote!(#core_types::list::List<#element_ty>), - quote!(#core_types::list::List::with_capacity(__frame_length)), - quote!(__output.push(__result);), - ), - None => (quote!(#output_type), quote!(#core_types::list::List::new()), quote!(__output.extend(__result);)), - }; + let (list_content_output_type, initial_output, collect_result, empty_output) = framed_output(); let empty_param_check = (!ranked_names.is_empty()).then(|| { quote! { if [#(#ranked_names.len()),*].into_iter().any(|__length| __length == 0) { - return #core_types::list::List::new(); + return #empty_output; } } }); @@ -671,6 +687,11 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let properties = &attributes.properties_string.as_ref().map(|value| quote!(Some(#value))).unwrap_or(quote!(None)); let memoize_flag = attributes.memoize; let inject_scope_flag = attributes.inject_scope; + // A `destructure_output` node records the fields of its returned struct of wires as its output connectors + let output_fields = match attributes.destructure_output { + true => quote!(Some(<#output_type as gcore::registry::Destructure>::metadata())), + false => quote!(None), + }; let cfg = crate::shader_nodes::modify_cfg(attributes); let node_input_accessor = generate_node_input_references(parsed, &field_idents, core_types, &identifier, &cfg); @@ -864,6 +885,7 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn context_features: vec![#(ContextFeature::#context_features,)*], memoize: #memoize_flag, inject_scope: #inject_scope_flag, + output_fields: #output_fields, fields: vec![ #( FieldMetadata { diff --git a/node-graph/node-macro/src/destructure.rs b/node-graph/node-macro/src/destructure.rs new file mode 100644 index 00000000000..f0c6219604d --- /dev/null +++ b/node-graph/node-macro/src/destructure.rs @@ -0,0 +1,368 @@ +use crate::crate_ident::CrateIdent; +use crate::parsing::{new_destructure_extractor_fn, peel_item, peel_list}; +use convert_case::{Case, Casing}; +use proc_macro2::TokenStream as TokenStream2; +use quote::{format_ident, quote}; +use syn::{AttrStyle, Attribute, Data, DeriveInput, Error, Expr, Fields, Ident, Lit, LitStr, Meta, Type, Visibility}; + +/// One field of a `#[derive(Destructure)]` struct, parsed from the struct definition. +struct DestructureField { + ident: Ident, + vis: Visibility, + /// The declared wire type, `Item` or `List`. + ty: Type, + /// The element type `T` carried by the wire. + element: Type, + /// Whether the wire is a whole `List` rather than a rank-0 `Item` cell. + is_list: bool, + /// The connector label shown in the UI: the `#[name("...")]` override, or the field name converted to title case. + display_name: String, + /// Tooltip text collected from the field's doc comments. + description: String, + /// The field's doc attributes, re-emitted onto the twin's field and the generated extractor node function. + doc_attrs: Vec, +} + +pub fn derive_destructure_impl(item: TokenStream2) -> syn::Result { + let input = syn::parse2::(item)?; + + let Data::Struct(data_struct) = &input.data else { + return Err(Error::new(input.ident.span(), "`Destructure` can only be derived for a struct")); + }; + if !input.generics.params.is_empty() || input.generics.where_clause.is_some() { + return Err(Error::new_spanned( + &input.generics, + "A `Destructure` struct cannot have generic parameters or a where clause, since each field must have a concrete wire type", + )); + } + let Fields::Named(named_fields) = &data_struct.fields else { + return Err(Error::new_spanned(&data_struct.fields, "A `Destructure` struct must have named fields, one per connector")); + }; + if named_fields.named.is_empty() { + return Err(Error::new_spanned(named_fields, "A `Destructure` struct must have at least one field")); + } + + // Collect each field's connector metadata from its wire type, doc comments, and the `#[name(...)]` and `#[primary]` helper attributes + let mut fields = Vec::new(); + let mut primary_field_index = None; + for (field_index, field) in named_fields.named.iter().enumerate() { + let ident = field.ident.clone().expect("Named fields always have an identifier"); + + let (element, is_list) = match (peel_item(&field.ty), peel_list(&field.ty)) { + (Some(element), _) => (element, false), + (None, Some(element)) => (element, true), + (None, None) => { + return Err(Error::new_spanned( + &field.ty, + format!("The field `{ident}` must be a wire type: `Item` for one cell or `List` for a whole list"), + )); + } + }; + + if let Some(primary_attr) = field.attrs.iter().find(|field_attr| field_attr.path().is_ident("primary")) { + if !matches!(primary_attr.meta, Meta::Path(_)) { + return Err(Error::new_spanned(primary_attr, "Expected a bare `#[primary]` with no arguments")); + } + if primary_field_index.is_some() { + return Err(Error::new_spanned(primary_attr, "At most one field may be marked `#[primary]`")); + } + primary_field_index = Some(field_index); + } + + let display_name = match field.attrs.iter().find(|field_attr| field_attr.path().is_ident("name")) { + Some(name_attr) => { + let name_literal: LitStr = name_attr + .parse_args() + .map_err(|e| Error::new_spanned(name_attr, format!("Expected `#[name(\"...\")]` with a string literal: {e}")))?; + name_literal.value() + } + None => ident.to_string().to_case(Case::Title), + }; + + let doc_attrs: Vec = field.attrs.iter().filter(|field_attr| field_attr.path().is_ident("doc")).cloned().collect(); + let description = doc_attrs + .iter() + .filter_map(|doc_attr| { + if doc_attr.style != AttrStyle::Outer { + return None; + } + let Meta::NameValue(name_value) = &doc_attr.meta else { return None }; + let Expr::Lit(expr_lit) = &name_value.value else { return None }; + let Lit::Str(text) = &expr_lit.lit else { return None }; + Some(text.value().trim().to_string()) + }) + .collect::>() + .join("\n"); + + fields.push(DestructureField { + ident, + vis: field.vis.clone(), + ty: field.ty.clone(), + element, + is_list, + display_name, + description, + doc_attrs, + }); + } + + // Registration lists the fields in output-connector order, so a `#[primary]` field moves to the front where it + // becomes the node's primary output in place of the hidden output that otherwise carries the whole struct + let has_primary = primary_field_index.is_some(); + if let Some(primary_field_index) = primary_field_index { + let primary_field = fields.remove(primary_field_index); + fields.insert(0, primary_field); + } + + let crate_ident = CrateIdent::default(); + let gcore = crate_ident.gcore()?; + let struct_ident = &input.ident; + let struct_vis = &input.vis; + let struct_snake_name = struct_ident.to_string().to_case(Case::Snake); + let mapped_ident = format_ident!("{struct_ident}List"); + + // The rank-lifted twin returned by the node's mapped variant, lifting every field to a whole-list wire + let mapped_field_defs = fields.iter().map(|field| { + let DestructureField { ident, vis, element, doc_attrs, .. } = field; + quote! { + #(#doc_attrs)* + #vis #ident: #gcore::list::List<#element> + } + }); + let mapped_struct = quote! { + #[doc(hidden)] + #[derive(Debug, Clone, dyn_any::DynAny)] + #struct_vis struct #mapped_ident { + #(#mapped_field_defs,)* + } + }; + + // Generate a hidden extractor node per field, written against `DestructureField` so its single identifier registers one + // row taking the struct and one taking the twin, and give both forms the field accessor it relies on + let mut field_accessors = Vec::new(); + let mut extractor_nodes = Vec::new(); + let mut extractor_modules = Vec::new(); + for (index, field) in fields.iter().enumerate() { + let DestructureField { + ident: field_ident, + ty: field_ty, + element, + doc_attrs, + .. + } = field; + + field_accessors.push(quote! { + #[automatically_derived] + impl #gcore::registry::DestructureField<#index> for #struct_ident { + type Wire = #field_ty; + + fn field(self) -> Self::Wire { + self.#field_ident + } + } + + #[automatically_derived] + impl #gcore::registry::DestructureField<#index> for #mapped_ident { + type Wire = #gcore::list::List<#element>; + + fn field(self) -> Self::Wire { + self.#field_ident + } + } + }); + + let extractor_fn_name = format_ident!("{struct_snake_name}_{field_ident}"); + extractor_modules.push(extractor_fn_name.clone()); + + // An empty category keeps the extractor out of the editor's node catalog + let extractor_display_name = format!("{struct_ident} {}", field.display_name); + let node_attr = quote!(category(""), name(#extractor_display_name)); + let node_fn = quote! { + #(#doc_attrs)* + fn #extractor_fn_name>(_: impl #gcore::Ctx, #[implementations(#struct_ident, #mapped_ident)] source: S) -> S::Wire { + source.field() + } + }; + extractor_nodes.push(new_destructure_extractor_fn(node_attr, node_fn)?); + } + + // The metadata the node macro records on each `destructure_output` node returning this struct, and the twin collection + // its mapped variant performs per frame slot: an `Item` field pushes into its list and a `List` field extends it + let field_names = fields.iter().map(|field| field.display_name.as_str()).collect::>(); + let field_descriptions = fields.iter().map(|field| field.description.as_str()).collect::>(); + let field_types = fields.iter().map(|field| { + let element = &field.element; + match field.is_list { + true => quote!(#gcore::list!(#element)), + false => quote!(#gcore::item!(#element)), + } + }); + let mapped_field_types = fields.iter().map(|field| { + let element = &field.element; + quote!(#gcore::list!(#element)) + }); + let mapped_field_inits = fields.iter().map(|field| { + let ident = &field.ident; + quote!(#ident: #gcore::list::List::with_capacity(capacity)) + }); + let push_fields = fields.iter().map(|field| { + let ident = &field.ident; + match field.is_list { + true => quote!(mapped.#ident.extend(self.#ident);), + false => quote!(mapped.#ident.push(self.#ident);), + } + }); + + let destructure_impl = quote! { + #[automatically_derived] + impl #gcore::registry::Destructure for #struct_ident { + type Mapped = #mapped_ident; + + fn metadata() -> #gcore::registry::DestructureMetadata { + #gcore::registry::DestructureMetadata { + fields: vec![ + #( + #gcore::registry::DestructureFieldMetadata { + name: #field_names, + description: #field_descriptions, + extractor: #extractor_modules::IDENTIFIER, + ty: #field_types, + mapped_ty: #mapped_field_types, + }, + )* + ], + has_primary: #has_primary, + mapped_type: #gcore::concrete!(#mapped_ident), + } + } + + fn mapped_with_capacity(capacity: usize) -> Self::Mapped { + #mapped_ident { + #(#mapped_field_inits,)* + } + } + + fn push_into(self, mapped: &mut Self::Mapped) { + #(#push_fields)* + } + } + }; + + Ok(quote! { + #mapped_struct + + #destructure_impl + + #(#field_accessors)* + + #(#extractor_nodes)* + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn expect_error(item: TokenStream2, message_fragment: &str) { + let error = derive_destructure_impl(item).expect_err("Expected the derive to reject this input"); + let message = error.to_string(); + assert!(message.contains(message_fragment), "Expected error containing `{message_fragment}`, got `{message}`"); + } + + #[test] + fn rejects_non_structs() { + expect_error( + quote!( + enum Test { + Variant, + } + ), + "can only be derived for a struct", + ); + } + + #[test] + fn rejects_tuple_structs() { + expect_error( + quote!( + struct Test(Item, Item); + ), + "must have named fields", + ); + } + + #[test] + fn rejects_generic_structs() { + expect_error( + quote!( + struct Test { + x: Item, + } + ), + "cannot have generic parameters", + ); + } + + #[test] + fn rejects_empty_structs() { + expect_error( + quote!( + struct Test {} + ), + "at least one field", + ); + } + + #[test] + fn rejects_unranked_fields() { + expect_error( + quote!( + struct Test { + x: f64, + } + ), + "must be a wire type", + ); + } + + #[test] + fn rejects_multiple_primary_fields() { + expect_error( + quote!( + struct Test { + #[primary] + x: Item, + #[primary] + y: Item, + } + ), + "At most one field", + ); + } + + #[test] + fn rejects_primary_attribute_with_arguments() { + expect_error( + quote!( + struct Test { + #[primary(true)] + x: Item, + } + ), + "bare `#[primary]`", + ); + } + + #[test] + fn rejects_malformed_name_attribute() { + expect_error( + quote!( + struct Test { + #[name(42)] + x: Item, + } + ), + "string literal", + ); + } +} diff --git a/node-graph/node-macro/src/lib.rs b/node-graph/node-macro/src/lib.rs index 57521221365..25e300ab36d 100644 --- a/node-graph/node-macro/src/lib.rs +++ b/node-graph/node-macro/src/lib.rs @@ -6,6 +6,7 @@ mod buffer_struct; mod codegen; mod crate_ident; mod derive_choice_type; +mod destructure; mod parsing; mod shader_nodes; mod validation; @@ -18,6 +19,61 @@ pub fn node(attr: TokenStream, item: TokenStream) -> TokenStream { parsing::new_node_fn(attr.into(), item.into()).unwrap_or_else(|err| err.to_compile_error()).into() } +/// Derives `Destructure` for a struct of wires, whose fields are the output connectors of a multi-output node. +/// +/// A `#[node_macro::node(..., destructure_output)]` function returns such a struct directly (not wrapped in `Item` or `List`) +/// and becomes a multi-output node: each field is exposed as a named output connector in the graph UI. Every field must be +/// an `Item` or `List` wire, so each output carries its own rank and attributes. The destructuring itself is performed +/// by hidden extractor nodes which this derive generates, one per field. Those extractor nodes exist only in the transient +/// runtime network produced by the Graphene preprocessor; they are never shown in the graph UI, saved to documents, or +/// serialized when copying nodes. +/// +/// Output names default to the field name converted to title case. Use `#[name("...")]` on a field to override that +/// when the automatic conversion doesn't format correctly. Doc comments on fields are recorded as connector descriptions. +/// +/// By default the node has no primary output: a hidden primary output carries the whole struct and the fields appear as +/// secondary outputs. Marking at most one field with `#[primary]` makes that field the node's primary output instead. +/// +/// The derive also generates the struct's rank-lifted twin, named with a `List` suffix, in which every field is a `List`. +/// The node's mapped variant, used when it is framed over a list, collects one twin from the per-slot structs: an `Item` +/// field pushes into its list and a `List` field extends it, flat-mapping per the rank-2 rule. The extractors accept +/// both forms. +/// +/// The struct is computed once and shared across all outputs when a Memoize implementation is registered for it and its +/// twin (see the `MemoizeNode` entries in `interpreted-executor`'s node registry); otherwise the node re-evaluates per +/// connected output. +/// +/// The struct must have named fields with concrete (non-generic) wire types, and derive `dyn_any::DynAny`, `Clone`, and +/// `Debug`, which the twin derives too. +/// +/// The same metadata is planned to eventually drive destructured *inputs*, where a single struct parameter of a node +/// function expands into one input connector per field, grouped in the Properties panel. +/// +/// ```ignore +/// #[derive(Debug, Clone, dyn_any::DynAny, node_macro::Destructure)] +/// pub struct Vec2Components { +/// /// The X component of the vec2. +/// x: Item, +/// /// The Y component of the vec2. +/// y: Item, +/// } +/// +/// #[node_macro::node(name("Split Vec2"), category("Math: Vec2"), destructure_output)] +/// fn split_vec2(_: impl Ctx, vec2: Item) -> Vec2Components { +/// let (vec2, attributes) = vec2.into_parts(); +/// +/// Vec2Components { +/// x: Item::from_parts(vec2.x, attributes.clone()), +/// y: Item::from_parts(vec2.y, attributes), +/// } +/// } +/// ``` +#[proc_macro_error] +#[proc_macro_derive(Destructure, attributes(name, primary))] +pub fn derive_destructure(input_item: TokenStream) -> TokenStream { + destructure::derive_destructure_impl(input_item.into()).unwrap_or_else(|err| err.to_compile_error()).into() +} + /// Generate meta-information for an enum. /// /// `#[widget(F)]` on a type indicates the type of widget to use to display/edit the type, currently `Radio` and `Dropdown` are supported. diff --git a/node-graph/node-macro/src/parsing.rs b/node-graph/node-macro/src/parsing.rs index c74992c75b9..1c332df0688 100644 --- a/node-graph/node-macro/src/parsing.rs +++ b/node-graph/node-macro/src/parsing.rs @@ -58,6 +58,10 @@ pub(crate) struct NodeFnAttributes { pub(crate) memoize: bool, /// Whether this node provides a scope pub(crate) inject_scope: bool, + /// Whether the node returns a `Destructure` struct of wires whose fields become its output connectors + pub(crate) destructure_output: bool, + /// Set on the extractor nodes the `Destructure` derive generates, whose input is the struct of wires rather than a ranked wire; never parsed from source + pub(crate) destructure_extractor: bool, } #[derive(Clone, Debug, Default)] @@ -401,6 +405,7 @@ impl Parse for NodeFnAttributes { let mut serialize = None; let mut memoize = false; let mut inject_scope = false; + let mut destructure_output = false; let content = input; // let content; @@ -543,13 +548,25 @@ impl Parse for NodeFnAttributes { } inject_scope = true; } + // Declares the node as multi-output: it returns a struct deriving `node_macro::Destructure` directly, and each of + // the struct's `Item`/`List` fields becomes one output connector (see the derive's documentation). + // + // Example usage: + // #[node_macro::node(..., destructure_output, ...)] + "destructure_output" => { + let path = meta.require_path_only()?; + if destructure_output { + return Err(Error::new_spanned(path, "Multiple 'destructure_output' attributes are not allowed")); + } + destructure_output = true; + } _ => { return Err(Error::new_spanned( meta, indoc!( r#" Unsupported attribute in `node`. - Supported attributes are 'category', 'name', 'path', 'skip_impl', 'properties', 'cfg', 'shader_node', 'serialize', 'memoize', and 'inject_scope'. + Supported attributes are 'category', 'name', 'path', 'skip_impl', 'properties', 'cfg', 'shader_node', 'serialize', 'memoize', 'inject_scope', and 'destructure_output'. Example usage: #[node_macro::node(..., name("Test Node"), ...)] "# @@ -583,6 +600,8 @@ impl Parse for NodeFnAttributes { serialize, memoize, inject_scope, + destructure_output, + destructure_extractor: false, }) } } @@ -1023,8 +1042,20 @@ fn extract_attribute<'a>(attrs: &'a [Attribute], name: &str) -> Option<&'a Attri // Modify the new_node_fn function to use the code generation pub fn new_node_fn(attr: TokenStream2, item: TokenStream2) -> syn::Result { + let parsed_node = parse_node_fn(attr, item).map_err(|e| Error::new(e.span(), format!("Failed to parse node function:\n{e}")))?; + generate_parsed_node_fn(parsed_node) +} + +/// Builds one of the `Destructure` derive's extractor nodes, whose input is the struct of wires itself rather than a +/// ranked wire and is therefore exempt from the ranked-input validation. +pub(crate) fn new_destructure_extractor_fn(attr: TokenStream2, item: TokenStream2) -> syn::Result { + let mut parsed_node = parse_node_fn(attr, item).map_err(|e| Error::new(e.span(), format!("Failed to parse node function:\n{e}")))?; + parsed_node.attributes.destructure_extractor = true; + generate_parsed_node_fn(parsed_node) +} + +fn generate_parsed_node_fn(mut parsed_node: ParsedNodeFn) -> syn::Result { let crate_ident = CrateIdent::default(); - let mut parsed_node = parse_node_fn(attr, item.clone()).map_err(|e| Error::new(e.span(), format!("Failed to parse node function:\n{e}")))?; parsed_node.replace_impl_trait_in_input(); crate::validation::validate_node_fn(&parsed_node).map_err(|e| Error::new(e.span(), format!("Validation error:\n{e}")))?; generate_node_code(&crate_ident, &parsed_node).map_err(|e| Error::new(e.span(), format!("Failed to generate node code:\n{e}"))) @@ -1183,6 +1214,8 @@ mod tests { serialize: None, memoize: false, inject_scope: false, + destructure_output: false, + destructure_extractor: false, }, fn_name: Ident::new("add", Span::call_site()), struct_name: Ident::new("Add", Span::call_site()), @@ -1254,6 +1287,8 @@ mod tests { serialize: None, memoize: false, inject_scope: false, + destructure_output: false, + destructure_extractor: false, }, fn_name: Ident::new("transform", Span::call_site()), struct_name: Ident::new("Transform", Span::call_site()), @@ -1340,6 +1375,8 @@ mod tests { serialize: None, memoize: false, inject_scope: false, + destructure_output: false, + destructure_extractor: false, }, fn_name: Ident::new("circle", Span::call_site()), struct_name: Ident::new("Circle", Span::call_site()), @@ -1407,6 +1444,8 @@ mod tests { serialize: None, memoize: false, inject_scope: false, + destructure_output: false, + destructure_extractor: false, }, fn_name: Ident::new("levels", Span::call_site()), struct_name: Ident::new("Levels", Span::call_site()), @@ -1486,6 +1525,8 @@ mod tests { serialize: None, memoize: false, inject_scope: false, + destructure_output: false, + destructure_extractor: false, }, fn_name: Ident::new("add", Span::call_site()), struct_name: Ident::new("Add", Span::call_site()), @@ -1568,6 +1609,8 @@ mod tests { serialize: None, memoize: false, inject_scope: false, + destructure_output: false, + destructure_extractor: false, }, fn_name: Ident::new("load_image", Span::call_site()), struct_name: Ident::new("LoadImage", Span::call_site()), @@ -1635,6 +1678,8 @@ mod tests { serialize: None, memoize: false, inject_scope: false, + destructure_output: false, + destructure_extractor: false, }, fn_name: Ident::new("custom_node", Span::call_site()), struct_name: Ident::new("CustomNode", Span::call_site()), diff --git a/node-graph/node-macro/src/validation.rs b/node-graph/node-macro/src/validation.rs index bb2c313bdab..83ff9223dcd 100644 --- a/node-graph/node-macro/src/validation.rs +++ b/node-graph/node-macro/src/validation.rs @@ -14,6 +14,7 @@ pub fn validate_node_fn(parsed: &ParsedNodeFn) -> syn::Result<()> { validate_no_item_parameters, validate_element_wise, validate_ranked_inputs, + validate_destructure_output, ]; for validator in validators { @@ -107,10 +108,27 @@ fn validate_element_wise(parsed: &ParsedNodeFn) { ); } - if !outer_wrapper_is(&parsed.output_type, "Item") && !outer_wrapper_is(&parsed.output_type, "List") { + if !parsed.attributes.destructure_output && !outer_wrapper_is(&parsed.output_type, "Item") && !outer_wrapper_is(&parsed.output_type, "List") { emit_error!( parsed.output_type.span(), - "An element-wise node (declared by its `Item` primary input) must return `Item`, or `List` for an expander" + "An element-wise node (declared by its `Item` primary input) must return `Item`, `List` for an expander, or a `Destructure` struct when declared `destructure_output`" + ); + } +} + +/// A `destructure_output` node returns its `Destructure` struct directly, since the struct's `Item`/`List` fields carry +/// the wire types, so the return type itself must not be ranked, generic, or unit. +fn validate_destructure_output(parsed: &ParsedNodeFn) { + if !parsed.attributes.destructure_output { + return; + } + + let output_type = &parsed.output_type; + let ranked = outer_wrapper_is(output_type, "Item") || outer_wrapper_is(output_type, "List") || outer_wrapper_is(output_type, "ListDyn"); + if ranked || is_unit_type(output_type) || contains_generic_param(output_type, &parsed.fn_generics) { + emit_error!( + output_type.span(), + "A `destructure_output` node must return its `Destructure` struct directly, since the struct's `Item`/`List` fields carry the wire types" ); } } @@ -126,7 +144,8 @@ fn validate_ranked_inputs(parsed: &ParsedNodeFn) { fn ranked_input_violations(parsed: &ParsedNodeFn) -> Vec<(proc_macro2::Span, String)> { let mut violations = Vec::new(); - if parsed.attributes.skip_impl { + // A `Destructure` derive's extractor takes the struct of wires itself, which is never a ranked wire + if parsed.attributes.skip_impl || parsed.attributes.destructure_extractor { return violations; } diff --git a/node-graph/nodes/gcore/src/extract_xy.rs b/node-graph/nodes/gcore/src/extract_xy.rs index 8fbdffbc8fb..af1fa094291 100644 --- a/node-graph/nodes/gcore/src/extract_xy.rs +++ b/node-graph/nodes/gcore/src/extract_xy.rs @@ -1,23 +1,7 @@ use core_types::list::Item; use core_types::{CacheHash, Ctx}; use dyn_any::DynAny; -use glam::{DVec2, IVec2, UVec2}; - -/// Obtains the X or Y component of a vec2. -/// -/// The inverse of this node is **Combine Vec2**, which composes a vec2 from its X and Y components. -#[node_macro::node(name("Extract XY"), category("Math: Vec2"))] -fn extract_xy>(_: impl Ctx, #[implementations(DVec2, IVec2, UVec2)] vector: Item, axis: Item) -> Item { - let vector = vector.into_element(); - let axis = axis.into_element(); - - let result = match axis { - XY::X => vector.into().x, - XY::Y => vector.into().y, - }; - - Item::new_from_element(result) -} +use glam::DVec2; /// The X or Y component of a vec2. #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] @@ -29,3 +13,25 @@ pub enum XY { X, Y, } + +/// The X and Y components of a vec2, split into separate node outputs. +#[derive(Debug, Clone, PartialEq, DynAny, node_macro::Destructure)] +pub struct Vec2Components { + /// The X component of the vec2. + pub x: Item, + /// The Y component of the vec2. + pub y: Item, +} + +/// Decomposes the X and Y components of a vec2. +/// +/// The inverse of this node is **Combine Vec2**, which composes a vec2 from its X and Y components. +#[node_macro::node(name("Split Vec2"), category("Math: Vec2"), destructure_output)] +fn split_vec2(_: impl Ctx, #[name("Vec2")] vec2: Item) -> Vec2Components { + let (vec2, attributes) = vec2.into_parts(); + + Vec2Components { + x: Item::from_parts(vec2.x, attributes.clone()), + y: Item::from_parts(vec2.y, attributes), + } +} diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index 20f697ef50c..8cbab3ae943 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -139,18 +139,10 @@ fn gamma_correction>( input } -#[node_macro::node(category("Raster: Channels"), shader_node(PerPixelAdjust))] -fn extract_channel>( - _: impl Ctx, - #[implementations(Raster, Color, Gradient)] - #[gpu_image] - input: Item, - channel: Item, -) -> Item { - let mut input = input; - let channel = channel.into_element(); - - input.element_mut().adjust(|color| { +/// Extracts one color channel as a grayscale image. Used internally by the `split_channels` node. +#[cfg(feature = "std")] +fn extract_channel>(mut input: T, channel: RedGreenBlueAlpha) -> T { + input.adjust(|color| { let extracted_value = match channel { RedGreenBlueAlpha::Red => color.r(), RedGreenBlueAlpha::Green => color.g(), @@ -162,6 +154,35 @@ fn extract_channel>( input } +/// The red, green, blue, and alpha channels of an image, split into separate node outputs. +#[cfg(feature = "std")] +#[derive(Debug, Clone, dyn_any::DynAny, node_macro::Destructure)] +pub struct ImageChannels { + /// The red channel of the image, as a grayscale image. + pub red: Item>, + /// The green channel of the image, as a grayscale image. + pub green: Item>, + /// The blue channel of the image, as a grayscale image. + pub blue: Item>, + /// The alpha channel of the image, as a grayscale image. + pub alpha: Item>, +} + +/// Separates an image into its red, green, blue, and alpha channels, each provided as a grayscale image. +#[cfg(feature = "std")] +#[node_macro::node(name("Split Channels"), category("Raster: Channels"), destructure_output)] +fn split_channels(_: impl Ctx, image: Item>) -> ImageChannels { + let (image, attributes) = image.into_parts(); + + // Each channel image keeps the source image's attributes, such as its transform + ImageChannels { + red: Item::from_parts(extract_channel(image.clone(), RedGreenBlueAlpha::Red), attributes.clone()), + green: Item::from_parts(extract_channel(image.clone(), RedGreenBlueAlpha::Green), attributes.clone()), + blue: Item::from_parts(extract_channel(image.clone(), RedGreenBlueAlpha::Blue), attributes.clone()), + alpha: Item::from_parts(extract_channel(image, RedGreenBlueAlpha::Alpha), attributes), + } +} + #[node_macro::node(category("Raster: Channels"), shader_node(PerPixelAdjust))] fn make_opaque>( _: impl Ctx, diff --git a/node-graph/nodes/repeat/src/repeat_nodes.rs b/node-graph/nodes/repeat/src/repeat_nodes.rs index 6169fdb4fe1..5ba31a4563c 100644 --- a/node-graph/nodes/repeat/src/repeat_nodes.rs +++ b/node-graph/nodes/repeat/src/repeat_nodes.rs @@ -238,7 +238,6 @@ mod test { use core_types::transform::Footprint; use glam::DVec2; use graphene_core::ReadPositionNode; - use graphene_core::extract_xy::{ExtractXyNode, XY}; use graphic_types::Vector; use kurbo::Shape; use kurbo::{BezPath, DEFAULT_ACCURACY, Rect}; @@ -277,15 +276,27 @@ mod test { } } + /// Test helper that extracts the Y component of an upstream node's `Item` output. + #[derive(Clone)] + struct ExtractYNode(Position); + + impl<'i, I: Ctx, Position> Node<'i, I> for ExtractYNode + where + Position: Node<'i, I, Output = Pin> + 'i + Send>>>, + { + type Output = Pin> + 'i + Send>>; + fn eval(&'i self, input: I) -> Self::Output { + let position = self.0.eval(input); + Box::pin(async move { Item::new_from_element(position.await.element().y) }) + } + } + #[tokio::test] async fn repeat_on_points_test() { let context = OwnedContextImpl::default().into_context(); let rect = RectangleNode::new( FutureWrapperNode(()), - ExtractXyNode::new( - ReadPositionNode::new(FutureWrapperNode(()), FutureWrapperNode(Item::new_from_element(0_u32))), - FutureWrapperNode(Item::new_from_element(XY::Y)), - ), + ExtractYNode(ReadPositionNode::new(FutureWrapperNode(()), FutureWrapperNode(Item::new_from_element(0_u32)))), FutureWrapperNode(Item::new_from_element(2_f64)), FutureWrapperNode(Item::new_from_element(BoxCorners::default())), FutureWrapperNode(Item::new_from_element(false)), diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 912ba87b254..069554b5001 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -2138,48 +2138,21 @@ async fn cut_segments(_: impl Ctx, #[implementati }) } -/// Determines the position of a point on the path, given by its progression from 0 to 1 along the path. -/// -/// If multiple subpaths make up the path, the whole number part of the progression value selects the subpath and the decimal part determines the position along it. -#[node_macro::node(name("Position on Path"), category("Vector: Measure"), path(graphene_core::vector))] -async fn position_on_path( - _: impl Ctx, - /// The path to traverse. - content: Item, - /// The factor from the start to the end of the path, 0–1 for one subpath, 1–2 for a second subpath, and so on. - progression: Item, - /// Swap the direction of the path. - reverse: Item, - /// Traverse the path using each segment's Bézier curve parameterization instead of the Euclidean distance. Faster to compute but doesn't respect actual distances. - parameterized_distance: Item, -) -> Item { - let (progression, reverse, parameterized_distance) = (progression.into_element(), reverse.into_element(), parameterized_distance.into_element()); - let euclidian = !parameterized_distance; - - let transform: DAffine2 = content.attribute_cloned_or_default(ATTR_TRANSFORM); - let mut bezpaths: Vec<_> = content.element().stroke_bezpath_iter().map(|bezpath| (bezpath, transform)).collect(); - let bezpath_count = bezpaths.len() as f64; - let progression = progression.clamp(0., bezpath_count); - let progression = if reverse { bezpath_count - progression } else { progression }; - let index = if progression >= bezpath_count { (bezpath_count - 1.) as usize } else { progression as usize }; - - let position = bezpaths.get_mut(index).map_or(DVec2::ZERO, |(bezpath, transform)| { - let t = if progression == bezpath_count { 1. } else { progression.fract() }; - let t = if euclidian { TValue::Euclidean(t) } else { TValue::Parametric(t) }; - - bezpath.apply_affine(Affine::new(transform.to_cols_array())); - - point_to_dvec2(evaluate_bezpath(bezpath, t, None)) - }); - - Item::new_from_element(position) +/// The position and tangent angle at a point along a path, split into separate node outputs. +#[derive(Debug, Clone, PartialEq, dyn_any::DynAny, node_macro::Destructure)] +pub struct PathEvaluation { + /// The position of the point on the path. + #[primary] + position: Item, + /// The angle of the tangent at the point on the path. + tangent: Item, } -/// Determines the angle of the tangent at a point on the path, given by its progression from 0 to 1 along the path. +/// Determines the position and tangent angle at a point on the path, given by its progression from 0 to 1 along the path. /// /// If multiple subpaths make up the path, the whole number part of the progression value selects the subpath and the decimal part determines the position along it. -#[node_macro::node(name("Tangent on Path"), category("Vector: Measure"), path(graphene_core::vector))] -async fn tangent_on_path( +#[node_macro::node(category("Vector: Measure"), path(graphene_core::vector), destructure_output)] +async fn evaluate_path( _: impl Ctx, /// The path to traverse. content: Item, @@ -2189,9 +2162,9 @@ async fn tangent_on_path( reverse: Item, /// Traverse the path using each segment's Bézier curve parameterization instead of the Euclidean distance. Faster to compute but doesn't respect actual distances. parameterized_distance: Item, - /// Whether the resulting angle should be given in as radians instead of degrees. + /// Whether the resulting tangent angle should be given in radians instead of degrees. radians: Item, -) -> Item { +) -> PathEvaluation { let (progression, reverse, parameterized_distance, radians) = (progression.into_element(), reverse.into_element(), parameterized_distance.into_element(), radians.into_element()); let euclidian = !parameterized_distance; @@ -2202,25 +2175,37 @@ async fn tangent_on_path( let progression = if reverse { bezpath_count - progression } else { progression }; let index = if progression >= bezpath_count { (bezpath_count - 1.) as usize } else { progression as usize }; - let angle = bezpaths.get_mut(index).map_or(0., |(bezpath, transform)| { - let t = if progression == bezpath_count { 1. } else { progression.fract() }; - let t_value = |t: f64| if euclidian { TValue::Euclidean(t) } else { TValue::Parametric(t) }; + let Some((bezpath, transform)) = bezpaths.get_mut(index) else { + return PathEvaluation { + position: Item::new_from_element(DVec2::ZERO), + tangent: Item::new_from_element(0.), + }; + }; + + let t = if progression == bezpath_count { 1. } else { progression.fract() }; + let t_value = |t: f64| if euclidian { TValue::Euclidean(t) } else { TValue::Parametric(t) }; - bezpath.apply_affine(Affine::new(transform.to_cols_array())); + // Apply the transform once so both the position and tangent are computed on the transformed path + bezpath.apply_affine(Affine::new(transform.to_cols_array())); - let mut tangent = point_to_dvec2(tangent_on_bezpath(bezpath, t_value(t), None)); - if tangent == DVec2::ZERO { - let t = t + if t > 0.5 { -0.001 } else { 0.001 }; - tangent = point_to_dvec2(tangent_on_bezpath(bezpath, t_value(t), None)); - } - if tangent == DVec2::ZERO { - return 0.; - } + let position = point_to_dvec2(evaluate_bezpath(bezpath, t_value(t), None)); + let mut tangent = point_to_dvec2(tangent_on_bezpath(bezpath, t_value(t), None)); + if tangent == DVec2::ZERO { + let t = t + if t > 0.5 { -0.001 } else { 0.001 }; + tangent = point_to_dvec2(tangent_on_bezpath(bezpath, t_value(t), None)); + } + let angle = if tangent == DVec2::ZERO { + 0. + } else { -tangent.angle_to(if reverse { -DVec2::X } else { DVec2::X }) - }); + }; + let tangent = if radians { angle } else { angle.to_degrees() }; - Item::new_from_element(if radians { angle } else { angle.to_degrees() }) + PathEvaluation { + position: Item::new_from_element(position), + tangent: Item::new_from_element(tangent), + } } #[node_macro::node(category("Vector: Modifier"), path(core_types::vector), memoize)] @@ -2750,7 +2735,7 @@ async fn morph( if paths.is_empty() { default_polyline() } else { paths } }; - // Select which subpath to use based on the integer part of progression (like the 'Position on Path' node) + // Select which subpath to use based on the integer part of progression (like the 'Evaluate Path' node) let progression = progression.max(0.); let subpath_count = control_bezpaths.len() as f64; let progression = if reverse { subpath_count - progression } else { progression }; diff --git a/node-graph/preprocessor/Cargo.toml b/node-graph/preprocessor/Cargo.toml index a24d03f885c..3e94c67394a 100644 --- a/node-graph/preprocessor/Cargo.toml +++ b/node-graph/preprocessor/Cargo.toml @@ -16,3 +16,11 @@ log = { workspace = true } graphene-std = { workspace = true, features = ["gpu"] } graph-craft = { workspace = true } interpreted-executor = { workspace = true } + +[dev-dependencies] +# Workspace dependencies +core-types = { workspace = true } +dyn-any = { workspace = true } +futures = { workspace = true } +glam = { workspace = true } +node-macro = { workspace = true } diff --git a/node-graph/preprocessor/src/lib.rs b/node-graph/preprocessor/src/lib.rs index 0889a529e3d..aced462ff7c 100644 --- a/node-graph/preprocessor/src/lib.rs +++ b/node-graph/preprocessor/src/lib.rs @@ -119,7 +119,13 @@ impl Preprocessor { for (id, metadata) in core_types::registry::NODE_METADATA.lock().unwrap().iter() { let id = id.clone(); - let NodeMetadata { fields, memoize, inject_scope, .. } = metadata; + let NodeMetadata { + fields, + memoize, + inject_scope, + output_fields, + .. + } = metadata; let Some(implementations) = node_registry.get(&id) else { continue }; let valid_call_args: HashSet<_> = implementations.iter().map(|(_, node_io)| node_io.call_argument.clone()).collect(); let first_node_io = implementations.first().map(|(_, node_io)| node_io).unwrap_or(const { &NodeIOTypes::empty() }); @@ -232,7 +238,24 @@ impl Preprocessor { }) .collect(); - if generated_nodes == 0 && !memoize && !inject_scope { + // A `destructure_output` node is multi-output: it always needs a substitution so its generated network can export + // each field of its returned struct of wires through a hidden extractor node + let destructure = output_fields.as_ref(); + + // A multi-output node is otherwise evaluated once per connected output, so when a Memoize implementation is registered + // for its struct (in either rank form), wrap the struct in one so all the extractors share a single evaluation + let memoize = *memoize + || destructure.is_some_and(|destructure| { + let struct_type = first_node_io.return_value.nested_type(); + into_node_registry.get(&graphene_core::memo::memoize::IDENTIFIER).is_some_and(|implementations| { + implementations.keys().any(|node_io| { + let memoized_type = node_io.return_value.nested_type(); + memoized_type == struct_type || *memoized_type == destructure.mapped_type + }) + }) + }); + + if generated_nodes == 0 && !memoize && !inject_scope && destructure.is_none() { continue; } @@ -249,7 +272,7 @@ impl Preprocessor { nodes.insert(NodeId(input_count as u64), document_node); // If memoize is requested, append a Memoize node after the main node and redirect the export through it - let export_node_id = if *memoize { + let export_node_id = if memoize { let memoize_node_id = NodeId(input_count as u64 + 1); let memoize_node = DocumentNode { inputs: vec![NodeInput::node(NodeId(input_count as u64), 0)], @@ -263,14 +286,35 @@ impl Preprocessor { NodeId(input_count as u64) }; + // A multi-output node exports each struct field through that field's generated extractor node. When one + // field is marked `#[primary]` its extractor becomes export 0; otherwise export 0 carries the struct + // itself, which stays hidden in the UI as the node's primary output + let mut exports = Vec::new(); + if destructure.is_none_or(|destructure| !destructure.has_primary) { + exports.push(NodeInput::Node { + node_id: export_node_id, + output_index: 0, + }); + } + if let Some(destructure) = destructure { + for (field_index, field) in destructure.fields.iter().enumerate() { + let extractor_node_id = NodeId(export_node_id.0 + 1 + field_index as u64); + let extractor_node = DocumentNode { + inputs: vec![NodeInput::node(export_node_id, 0)], + implementation: DocumentNodeImplementation::ProtoNode(field.extractor.clone()), + visible: true, + ..Default::default() + }; + nodes.insert(extractor_node_id, extractor_node); + exports.push(NodeInput::node(extractor_node_id, 0)); + } + } + let node = DocumentNode { inputs, call_argument: input_type.clone(), implementation: DocumentNodeImplementation::Network(NodeNetwork { - exports: vec![NodeInput::Node { - node_id: export_node_id, - output_index: 0, - }], + exports, nodes, scope_injections: Default::default(), generated: true, @@ -350,6 +394,262 @@ pub enum PreprocessorError { ResourceNotFound(ResourceId), } +#[cfg(test)] +mod destructure_tests { + use super::*; + use core_types::list::{Item, List}; + use core_types::registry::Destructure; + use glam::DVec2; + use graph_craft::graphene_compiler::Compiler; + use interpreted_executor::dynamic_executor::DynamicExecutor; + + /// Test-only struct of wires with a `#[primary]` field, exercising the primary-output layout and the + /// unmemoized path (no Memoize implementation is registered for this struct). + #[derive(Debug, Clone, dyn_any::DynAny, node_macro::Destructure)] + pub struct SumProduct { + /// The sum of the two inputs. + #[primary] + sum: Item, + /// The product of the two inputs. + product: Item, + } + + #[node_macro::node(category(""), destructure_output)] + fn sum_product(_: impl core_types::Ctx, a: Item, b: Item) -> SumProduct { + let (a, b) = (a.into_element(), b.into_element()); + + SumProduct { + sum: Item::new_from_element(a + b), + product: Item::new_from_element(a * b), + } + } + + /// Test-only struct of mixed ranks returned by a whole-list node, which maps its input list to one output and reduces + /// it to another from a single evaluation. + #[derive(Debug, Clone, dyn_any::DynAny, node_macro::Destructure)] + pub struct DoubledAndCount { + /// Each input value doubled. + doubled: List, + /// How many values were given. + count: Item, + } + + #[node_macro::node(category(""), destructure_output)] + fn doubled_and_count(_: impl core_types::Ctx, values: List) -> DoubledAndCount { + let count = Item::new_from_element(values.len() as f64); + let doubled = values + .into_iter() + .map(|value| { + let (value, attributes) = value.into_parts(); + Item::from_parts(value * 2., attributes) + }) + .collect(); + + DoubledAndCount { doubled, count } + } + + /// A network where the outputs of the given multi-output node feed an Add node. + /// Includes a stub "editor-api" scope injection, which preprocessing requires and `wrap_network_in_scope` normally provides. + fn multi_output_into_add_network(node: DocumentNode, added_output_indices: [usize; 2]) -> NodeNetwork { + NodeNetwork { + exports: vec![NodeInput::node(NodeId(1), 0)], + nodes: [ + (NodeId(0), node), + ( + NodeId(1), + DocumentNode { + inputs: vec![NodeInput::node(NodeId(0), added_output_indices[0]), NodeInput::node(NodeId(0), added_output_indices[1])], + implementation: DocumentNodeImplementation::ProtoNode(graphene_std::math_nodes::add::IDENTIFIER), + ..Default::default() + }, + ), + ( + NodeId(2), + DocumentNode { + inputs: vec![NodeInput::value(TaggedValue::EditorApi(std::sync::Arc::default()), false)], + implementation: DocumentNodeImplementation::ProtoNode(ops::passthrough::IDENTIFIER), + ..Default::default() + }, + ), + ] + .into_iter() + .collect(), + scope_injections: [("editor-api".to_string(), (NodeId(2), concrete!(&graph_craft::application_io::PlatformEditorApi)))] + .into_iter() + .collect(), + ..Default::default() + } + } + + /// A network where a multi-output Split Vec2 node's X and Y outputs (indices 1 and 2, after the hidden primary) feed an Add node. + fn split_vec2_network() -> NodeNetwork { + let split_vec2 = DocumentNode { + inputs: vec![NodeInput::value(TaggedValue::DVec2(DVec2::new(3., 5.)), false)], + implementation: DocumentNodeImplementation::ProtoNode(graphene_std::extract_xy::split_vec_2::IDENTIFIER), + ..Default::default() + }; + multi_output_into_add_network(split_vec2, [1, 2]) + } + + fn assert_execution_result(network: NodeNetwork, expected: TaggedValue) { + let proto_network = Compiler {}.compile_single(network).expect("Compilation should succeed"); + let executor = futures::executor::block_on(DynamicExecutor::new(proto_network)).expect("The executor should type check and build"); + + let context: core_types::Context = None; + let result = futures::executor::block_on(executor.tree().eval_tagged_value(executor.output(), context)).expect("Execution should succeed"); + assert_eq!(result, expected); + } + + #[test] + fn multi_output_node_expands_into_generated_destructure_network() { + let split_vec2_identifier = graphene_std::extract_xy::split_vec_2::IDENTIFIER; + let destructure = registry::MULTI_OUTPUT_NODES + .get(&split_vec2_identifier) + .expect("Split Vec2 should be registered as a multi-output node"); + assert_eq!(destructure.fields.iter().map(|field| field.name).collect::>(), vec!["X", "Y"]); + assert!(!destructure.has_primary); + + let mut network = split_vec2_network(); + Preprocessor::new().preprocess(&mut network, &|_| None).expect("Preprocessing should succeed"); + + // The multi-output node is substituted with a transient generated network: the struct as the hidden primary export, + // followed by one export per field, each pulled out of the struct by that field's extractor node + let node = network.nodes.get(&NodeId(0)).unwrap(); + let DocumentNodeImplementation::Network(generated) = &node.implementation else { + panic!("The multi-output node should be substituted with a generated network") + }; + assert!(generated.generated, "The substituted network must be marked as generated so it stays out of node paths"); + assert_eq!(generated.exports.len(), 1 + destructure.fields.len()); + + // A Memoize implementation is registered for Vec2Components, so the struct is computed once and shared through it + let Some(NodeInput::Node { node_id: struct_source_id, .. }) = generated.exports.first() else { + panic!("Export 0 should come from a node") + }; + let struct_source = generated.nodes.get(struct_source_id).unwrap(); + assert_eq!(struct_source.implementation, DocumentNodeImplementation::ProtoNode(graphene_core::memo::memoize::IDENTIFIER)); + + let Some(NodeInput::Node { node_id: main_node_id, .. }) = struct_source.inputs.first() else { + panic!("The Memoize node should pull from the struct-producing node") + }; + let main_node = generated.nodes.get(main_node_id).unwrap(); + assert_eq!(main_node.implementation, DocumentNodeImplementation::ProtoNode(split_vec2_identifier)); + + for (field, export) in destructure.fields.iter().zip(&generated.exports[1..]) { + let NodeInput::Node { node_id: extractor_id, .. } = export else { + panic!("Each field export should come from an extractor node") + }; + let extractor = generated.nodes.get(extractor_id).unwrap(); + assert_eq!(extractor.implementation, DocumentNodeImplementation::ProtoNode(field.extractor.clone())); + assert_eq!(extractor.inputs, vec![NodeInput::node(*struct_source_id, 0)], "Each extractor should share the memoized struct"); + } + } + + #[test] + fn multi_output_node_compiles_and_executes() { + let mut network = split_vec2_network(); + Preprocessor::new().preprocess(&mut network, &|_| None).expect("Preprocessing should succeed"); + + // X + Y of (3, 5) should be 8 + assert_execution_result(network, TaggedValue::F64(8.)); + } + + #[test] + fn primary_field_becomes_the_primary_output() { + let identifier = sum_product::IDENTIFIER; + let destructure = registry::MULTI_OUTPUT_NODES.get(&identifier).expect("Sum Product should be registered as a multi-output node"); + assert!(destructure.has_primary); + assert_eq!(destructure.fields.iter().map(|field| field.name).collect::>(), vec!["Sum", "Product"]); + + let node = DocumentNode { + inputs: vec![NodeInput::value(TaggedValue::F64(3.), false), NodeInput::value(TaggedValue::F64(5.), false)], + implementation: DocumentNodeImplementation::ProtoNode(identifier), + ..Default::default() + }; + let mut network = multi_output_into_add_network(node, [0, 1]); + Preprocessor::new().preprocess(&mut network, &|_| None).expect("Preprocessing should succeed"); + + // With a `#[primary]` field there is no hidden struct export: one export per field, with the primary field first + let node = network.nodes.get(&NodeId(0)).unwrap(); + let DocumentNodeImplementation::Network(generated) = &node.implementation else { + panic!("The multi-output node should be substituted with a generated network") + }; + assert_eq!(generated.exports.len(), destructure.fields.len()); + for (field, export) in destructure.fields.iter().zip(&generated.exports) { + let NodeInput::Node { node_id: extractor_id, .. } = export else { + panic!("Each field export should come from an extractor node") + }; + let extractor = generated.nodes.get(extractor_id).unwrap(); + assert_eq!(extractor.implementation, DocumentNodeImplementation::ProtoNode(field.extractor.clone())); + } + + // Sum + product of (3, 5) should be 8 + 15 = 23 + assert_execution_result(network, TaggedValue::F64(23.)); + } + + #[test] + fn framed_multi_output_node_lifts_each_field_into_the_twin() { + let destructure = registry::MULTI_OUTPUT_NODES + .get(&sum_product::IDENTIFIER) + .expect("Sum Product should be registered as a multi-output node"); + assert_eq!(destructure.mapped_type, concrete!(SumProductList)); + assert!(destructure.fields.iter().all(|field| field.ty == core_types::item!(f64) && field.mapped_ty == core_types::list!(f64))); + + // The node registers a mapped variant returning the twin alongside its single-item form + let node_registry = core_types::registry::NODE_REGISTRY.lock().unwrap(); + let rows = node_registry.get(&sum_product::IDENTIFIER).expect("Sum Product should have registered implementations"); + assert!(rows.iter().any(|(_, node_io)| *node_io.return_value.nested_type() == concrete!(SumProduct))); + assert!(rows.iter().any(|(_, node_io)| *node_io.return_value.nested_type() == destructure.mapped_type)); + drop(node_registry); + + // A list on one connector frames the node, so the sums [11, 12, 13] and products [10, 20, 30] add to [21, 32, 43] + let node = DocumentNode { + inputs: vec![NodeInput::value(TaggedValue::F64Array(vec![1., 2., 3.]), false), NodeInput::value(TaggedValue::F64(10.), false)], + implementation: DocumentNodeImplementation::ProtoNode(sum_product::IDENTIFIER), + ..Default::default() + }; + let mut network = multi_output_into_add_network(node, [0, 1]); + Preprocessor::new().preprocess(&mut network, &|_| None).expect("Preprocessing should succeed"); + assert_execution_result(network, TaggedValue::F64Array(vec![21., 32., 43.])); + } + + #[test] + fn whole_list_multi_output_node_returns_fields_of_mixed_rank() { + let destructure = registry::MULTI_OUTPUT_NODES + .get(&doubled_and_count::IDENTIFIER) + .expect("Doubled And Count should be registered as a multi-output node"); + assert_eq!( + destructure.fields.iter().map(|field| field.ty.clone()).collect::>(), + vec![core_types::list!(f64), core_types::item!(f64)] + ); + + // The doubled list [2, 4, 6] and the count 3 add to [5, 7, 9] + let node = DocumentNode { + inputs: vec![NodeInput::value(TaggedValue::F64Array(vec![1., 2., 3.]), false)], + implementation: DocumentNodeImplementation::ProtoNode(doubled_and_count::IDENTIFIER), + ..Default::default() + }; + let mut network = multi_output_into_add_network(node, [1, 2]); + Preprocessor::new().preprocess(&mut network, &|_| None).expect("Preprocessing should succeed"); + assert_execution_result(network, TaggedValue::F64Array(vec![5., 7., 9.])); + } + + #[test] + fn pushing_into_the_twin_lifts_item_fields_and_flattens_list_fields() { + let mut mapped = DoubledAndCount::mapped_with_capacity(2); + for count in [1., 2.] { + let doubled = [count, count].into_iter().map(Item::new_from_element).collect(); + DoubledAndCount { + doubled, + count: Item::new_from_element(count), + } + .push_into(&mut mapped); + } + + assert_eq!(mapped.doubled.iter_element_values().copied().collect::>(), vec![1., 1., 2., 2.]); + assert_eq!(mapped.count.iter_element_values().copied().collect::>(), vec![1., 2.]); + } +} + impl std::fmt::Display for PreprocessorError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self {