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-23 00:23:02 -07:00
parent 3d7e85c054
commit 0fb36b68a4
17 changed files with 923 additions and 327 deletions
@@ -93,10 +93,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: &[
@@ -519,14 +515,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"],
@@ -928,10 +916,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"],
@@ -964,10 +948,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: &[
@@ -1272,6 +1252,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<NodeId>)> = 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(*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<NodeId>, 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.persistent_node_metadata.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(*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(*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<NodeId> = document
@@ -1993,22 +2063,6 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
.set_input(&InputConnector::node(*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(*node_id, 0), old_inputs[0].clone(), network_path);
document.network_interface.set_input(&InputConnector::node(*node_id, 1), old_inputs[1].clone(), network_path);
document.network_interface.set_input(&InputConnector::node(*node_id, 2), old_inputs[2].clone(), network_path);
document.network_interface.set_input(&InputConnector::node(*node_id, 3), old_inputs[3].clone(), network_path);
document
.network_interface
.set_input(&InputConnector::node(*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();