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
@@ -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;
@@ -1059,185 +1057,6 @@ fn document_node_definitions() -> HashMap<DefinitionIdentifier, DocumentNodeDefi
description: Cow::Borrowed("TODO"),
properties: None,
},
DocumentNodeDefinition {
identifier: "Split Channels",
category: "Raster: Channels",
node_template: NodeTemplate {
document_node: DocumentNode {
implementation: DocumentNodeImplementation::Network(NodeNetwork {
exports: vec![
NodeInput::value(TaggedValue::None, false),
NodeInput::node(NodeId(0), 0),
NodeInput::node(NodeId(1), 0),
NodeInput::node(NodeId(2), 0),
NodeInput::node(NodeId(3), 0),
],
nodes: [
DocumentNode {
inputs: vec![
NodeInput::import(list!(Raster<CPU>), 0),
NodeInput::value(TaggedValue::RedGreenBlueAlpha(RedGreenBlueAlpha::Red), false),
],
implementation: DocumentNodeImplementation::ProtoNode(raster_nodes::adjustments::extract_channel::IDENTIFIER),
call_argument: generic!(T),
..Default::default()
},
DocumentNode {
inputs: vec![
NodeInput::import(list!(Raster<CPU>), 0),
NodeInput::value(TaggedValue::RedGreenBlueAlpha(RedGreenBlueAlpha::Green), false),
],
implementation: DocumentNodeImplementation::ProtoNode(raster_nodes::adjustments::extract_channel::IDENTIFIER),
call_argument: generic!(T),
..Default::default()
},
DocumentNode {
inputs: vec![
NodeInput::import(list!(Raster<CPU>), 0),
NodeInput::value(TaggedValue::RedGreenBlueAlpha(RedGreenBlueAlpha::Blue), false),
],
implementation: DocumentNodeImplementation::ProtoNode(raster_nodes::adjustments::extract_channel::IDENTIFIER),
call_argument: generic!(T),
..Default::default()
},
DocumentNode {
inputs: vec![
NodeInput::import(list!(Raster<CPU>), 0),
NodeInput::value(TaggedValue::RedGreenBlueAlpha(RedGreenBlueAlpha::Alpha), false),
],
implementation: DocumentNodeImplementation::ProtoNode(raster_nodes::adjustments::extract_channel::IDENTIFIER),
call_argument: generic!(T),
..Default::default()
},
]
.into_iter()
.enumerate()
.map(|(id, node)| (NodeId(id as u64), node))
.collect(),
..Default::default()
}),
inputs: vec![NodeInput::type_default(list!(Raster<CPU>), true)],
..Default::default()
},
persistent_node_metadata: DocumentNodePersistentMetadata {
input_metadata: vec![("Image", "TODO").into()],
output_names: vec!["".to_string(), "Red".to_string(), "Green".to_string(), "Blue".to_string(), "Alpha".to_string()],
network_metadata: Some(NodeNetworkMetadata {
persistent_metadata: NodeNetworkPersistentMetadata {
node_metadata: [
DocumentNodeMetadata {
persistent_metadata: DocumentNodePersistentMetadata {
node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(0, 0)),
..Default::default()
},
..Default::default()
},
DocumentNodeMetadata {
persistent_metadata: DocumentNodePersistentMetadata {
node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(0, 2)),
..Default::default()
},
..Default::default()
},
DocumentNodeMetadata {
persistent_metadata: DocumentNodePersistentMetadata {
node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(0, 4)),
..Default::default()
},
..Default::default()
},
DocumentNodeMetadata {
persistent_metadata: DocumentNodePersistentMetadata {
node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(0, 6)),
..Default::default()
},
..Default::default()
},
]
.into_iter()
.enumerate()
.map(|(id, node)| (NodeId(id as u64), node))
.collect(),
..Default::default()
},
..Default::default()
}),
..Default::default()
},
},
description: Cow::Borrowed("TODO"),
properties: None,
},
DocumentNodeDefinition {
identifier: "Split Vec2",
category: "Math: Vec2",
node_template: NodeTemplate {
document_node: DocumentNode {
implementation: DocumentNodeImplementation::Network(NodeNetwork {
exports: vec![NodeInput::value(TaggedValue::None, false), NodeInput::node(NodeId(0), 0), NodeInput::node(NodeId(1), 0)],
nodes: [
DocumentNode {
inputs: vec![NodeInput::import(item!(DVec2), 0), NodeInput::value(TaggedValue::XY(XY::X), false)],
implementation: DocumentNodeImplementation::ProtoNode(extract_xy::extract_xy::IDENTIFIER),
call_argument: generic!(T),
..Default::default()
},
DocumentNode {
inputs: vec![NodeInput::import(item!(DVec2), 0), NodeInput::value(TaggedValue::XY(XY::Y), false)],
implementation: DocumentNodeImplementation::ProtoNode(extract_xy::extract_xy::IDENTIFIER),
call_argument: generic!(T),
..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)],
..Default::default()
},
persistent_node_metadata: DocumentNodePersistentMetadata {
input_metadata: vec![("Vec2", "TODO").into()],
output_names: vec!["".to_string(), "X".to_string(), "Y".to_string()],
network_metadata: Some(NodeNetworkMetadata {
persistent_metadata: NodeNetworkPersistentMetadata {
node_metadata: [
DocumentNodeMetadata {
persistent_metadata: DocumentNodePersistentMetadata {
node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(0, 0)),
..Default::default()
},
..Default::default()
},
DocumentNodeMetadata {
persistent_metadata: DocumentNodePersistentMetadata {
node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(0, 2)),
..Default::default()
},
..Default::default()
},
]
.into_iter()
.enumerate()
.map(|(id, node)| (NodeId(id as u64), node))
.collect(),
..Default::default()
},
..Default::default()
}),
..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: "",
@@ -28,6 +28,8 @@ pub(super) fn post_process_nodes(custom: Vec<DocumentNodeDefinition>) -> HashMap
// `interpreted_executor::node_registry::NODE_REGISTRY` via `async_node!`. We consult that extended registry as a
// fallback when deriving `call_argument` so it reflects the impls actually registered, which will usually be `Context`.
let extended_node_registry = &*interpreted_executor::node_registry::NODE_REGISTRY;
// Pre-initialize the multi-output node map here since its initializer locks NODE_REGISTRY, which would deadlock once we hold the lock below
let multi_output_nodes = &*MULTI_OUTPUT_NODES;
let node_registry = NODE_REGISTRY.lock().unwrap();
let empty_implementations: Vec<(NodeConstructor, NodeIOTypes)> = Vec::new();
let context_type = concrete!(Context);
@@ -67,6 +69,17 @@ pub(super) fn post_process_nodes(custom: Vec<DocumentNodeDefinition>) -> HashMap
};
let inputs = preprocessor::node_inputs(fields, first_node_io);
// A multi-output node (returning a `#[node_macro::destructure]` struct) names each output after its struct field,
// preceded by an unnamed entry for the hidden primary output unless one field is marked `#[primary]`
let output_names = multi_output_nodes
.get(id)
.map(|metadata| {
let hidden_primary_name = (!metadata.has_primary).then(String::new);
hidden_primary_name.into_iter().chain(metadata.fields.iter().map(|field| field.name.to_string())).collect()
})
.unwrap_or_default();
definitions_map.insert(
identifier,
DocumentNodeDefinition {
@@ -92,6 +105,7 @@ pub(super) fn post_process_nodes(custom: Vec<DocumentNodeDefinition>) -> HashMap
RegistryWidgetOverride::Custom(str) => InputMetadata::with_name_description_override(f.name, f.description, WidgetOverride::Custom(str.to_string())),
})
.collect(),
output_names,
locked: false,
..Default::default()
},
@@ -28,6 +28,7 @@ use graphene_std::ContextDependencies;
use graphene_std::Graphic;
use graphene_std::list::List;
use graphene_std::math::quad::Quad;
use graphene_std::registry::MULTI_OUTPUT_NODES;
use graphene_std::subpath::Subpath;
use graphene_std::transform::Footprint;
use graphene_std::vector::click_target::{ClickTarget, ClickTargetType, FreePoint};
@@ -365,7 +366,12 @@ impl NodeNetworkInterface {
return 0;
};
match &implementation {
DocumentNodeImplementation::ProtoNode(_) => 1,
// A multi-output proto node (returning a `#[node_macro::destructure]` struct) has one output per field,
// 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,
},
DocumentNodeImplementation::Network(nested_network) => nested_network.exports.len(),
DocumentNodeImplementation::Extract => 1,
}
@@ -1140,6 +1146,9 @@ impl NodeNetworkInterface {
pub fn hidden_primary_output(&self, node_id: &NodeId, network_path: &[NodeId]) -> bool {
match self.implementation(node_id, network_path) {
Some(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
Some(DocumentNodeImplementation::ProtoNode(identifier)) => MULTI_OUTPUT_NODES.get(identifier).is_some_and(|metadata| !metadata.has_primary),
_ => false,
}
}
@@ -2618,10 +2627,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 {
@@ -4791,6 +4797,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<String>, 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");
@@ -355,10 +355,37 @@ impl NodeNetworkInterface {
DocumentNodeImplementation::Network(_) => self.input_type(&InputConnector::Export(*output_index), &[network_path, &[*node_id]].concat()),
// The compiler removes passthrough nodes so they resolve no type of their own, but their output carries their primary input's type
DocumentNodeImplementation::ProtoNode(identifier) if *identifier == graphene_std::ops::passthrough::IDENTIFIER => self.input_type(&InputConnector::node(*node_id, 0), 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 element types recorded in the registry, and
// ride the struct's resolved rank since a framed multi-output node emits one list per field. 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 struct_is_rank_1 = self
.resolved_types
.types
.get(&[network_path, &[*node_id]].concat())
.is_some_and(|resolved_type| matches!(resolved_type.output.nested_type(), Type::List(_)));
let field_wire = if struct_is_rank_1 {
Type::List(Box::new(field.ty.clone()))
} else {
Type::Item(Box::new(field.ty.clone()))
};
TypeSource::Compiled(field_wire)
}
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!(())),
}
}
@@ -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();