Add multi-output nodes with struct returns destructured by #[node_macro::destructure]

A #[node_macro::node] function returning a struct tagged with
field is a named output connector (title-cased from the field name,
renamed with #[name("...")], described by its doc comment). By default
the node has a hidden primary output carrying the whole struct with the
fields as secondary outputs; marking at most one field #[primary] makes
that field the primary output instead.

The macro generates one hidden extractor proto node per field plus a
registration keyed by the struct's TypeId. The Graphene preprocessor
recognizes nodes returning a registered struct and substitutes them, in
the transient runtime copy of the network only, with a generated network
exporting each field through its extractor. The destructuring machinery
therefore never appears when drilling into a node, in copied clipboard
content, or in saved documents. When a Memoize implementation is
registered for the struct type, the struct is computed once and shared
across all outputs rather than re-evaluated per output.

The editor derives output counts, names, and types for such nodes from
the registry. The old hand-authored "Split Vec2" and "Split Channels"
wrapper-network definitions are replaced by multi-output split_vec2 and
split_channels proto nodes, with document migrations that keep existing
wires valid since the output indices are unchanged.

The "Position on Path" and "Tangent on Path" nodes are combined into a
single multi-output "Evaluate Path" node whose primary output is the
position and whose secondary output is the tangent angle. A migration
converts old instances, forwarding the shared inputs and remapping the
tangent nodes' downstream connections to the new tangent output index.

The now-redundant "Extract XY" node is removed (its role is subsumed by
Split Vec2's destructuring), and "Extract Channel" becomes a plain helper
used by Split Channels rather than a standalone node.
This commit is contained in:
Keavon Chambers
2026-07-07 03:14:51 -07:00
parent 3d7e85c054
commit 0fb36b68a4
17 changed files with 923 additions and 327 deletions

View File

@@ -14,3 +14,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 }

View File

@@ -232,7 +232,27 @@ impl Preprocessor {
})
.collect();
if generated_nodes == 0 && !memoize && !inject_scope {
// Nodes returning a `#[node_macro::destructure]` struct are multi-output: they always need a substitution
// so their generated network can export each struct field through a hidden extractor node
let destructure = destructure_metadata_for_type(&first_node_io.return_value);
// A multi-output node is otherwise evaluated once per connected output, so when a Memoize implementation
// is registered for its struct type, wrap the struct in one so all the extractors share a single evaluation.
// Rows are matched by element type name, since the executor registry's structural rows carry no element TypeId.
let memoize_row_for_struct = |ty: &Type| {
let element_type = match ty.nested_type() {
Type::Item(inner) | Type::List(inner) => inner.nested_type(),
other => other,
};
let Type::Concrete(descriptor) = element_type else { return false };
destructure.as_ref().is_some_and(|metadata| descriptor.name == metadata.struct_name)
};
let memoize = *memoize
|| into_node_registry
.get(&graphene_core::memo::memoize::IDENTIFIER)
.is_some_and(|implementations| implementations.keys().any(|node_io| memoize_row_for_struct(&node_io.return_value)));
if generated_nodes == 0 && !memoize && !inject_scope && destructure.is_none() {
continue;
}
@@ -249,7 +269,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 +283,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.as_ref().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 +391,172 @@ pub enum PreprocessorError {
ResourceNotFound(ResourceId),
}
#[cfg(test)]
mod destructure_tests {
use super::*;
use core_types::list::Item;
use glam::DVec2;
use graph_craft::graphene_compiler::Compiler;
use interpreted_executor::dynamic_executor::DynamicExecutor;
/// Test-only multi-output struct with a `#[primary]` field, exercising the primary-output layout and the
/// unmemoized path (no Memoize implementation is registered for this struct type).
#[node_macro::destructure]
#[derive(Debug, Clone, Copy, dyn_any::DynAny)]
pub struct SumProduct {
/// The sum of the two inputs.
#[primary]
sum: f64,
/// The product of the two inputs.
product: f64,
}
#[node_macro::node(category(""))]
fn sum_product(_: impl core_types::Ctx, a: Item<f64>, b: Item<f64>) -> Item<SumProduct> {
let (a, b) = (a.into_element(), b.into_element());
Item::new_from_element(SumProduct { sum: a + b, product: a * b })
}
/// 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<_>>(), 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<_>>(), 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.));
}
}
impl std::fmt::Display for PreprocessorError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {