diff --git a/Cargo.lock b/Cargo.lock index 7dd43e5fc0d..a2412eb4320 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3441,7 +3441,9 @@ name = "math-nodes" version = "0.0.0" dependencies = [ "core-types", + "dyn-any", "glam 0.33.10", + "graphene-core", "graphic-types", "log", "math-parser", @@ -4414,6 +4416,7 @@ dependencies = [ "core-types", "dyn-any", "futures", + "glam 0.33.10", "graph-craft", "graphene-std", "interpreted-executor", @@ -4636,6 +4639,7 @@ dependencies = [ "fastnoise-lite", "futures", "glam 0.33.10", + "graphene-core", "graphene-hash", "graphene-resource", "image", diff --git a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs index f2f126c01ee..3d7bf646747 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs @@ -11,13 +11,12 @@ use graphene_std::animation::RealTimeMode; use graphene_std::blending::BlendMode; use graphene_std::color::SRGBA8; use graphene_std::core_types::misc::format_f64; -use graphene_std::extract_xy::XY; use graphene_std::gradient::Gradient; use graphene_std::list::{Item, List, NodeIdPath}; use graphene_std::math::float_noise::round_away_float_noise; use graphene_std::memo::IORecord; use graphene_std::raster::{ - AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DesaturateMethod, DomainWarpType, FractalType, HueSaturationRange, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, + AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DesaturateMethod, DomainWarpType, FractalType, HueSaturationRange, NoiseType, RedGreenBlue, RelativeAbsolute, SelectiveColorChoice, TonalRange, }; use graphene_std::raster_types::{CPU, GPU, Raster}; @@ -238,13 +237,11 @@ fn generate_layout(introspected_data: &Arc, List, List, - List, List, List, List, List, List, - List, List, List, List, @@ -294,13 +291,11 @@ fn generate_layout(introspected_data: &Arc, Item, Item, - Item, Item, Item, Item, Item, Item, - Item, Item, Item, Item, @@ -1034,13 +1029,11 @@ impl_table_item_layout_for_choice_enum!( TextDenomination, DesaturateMethod, RedGreenBlue, - RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice, TonalRange, AdjustmentChannel, HueSaturationRange, - XY, ScaleType, CentroidType, BooleanOperation, @@ -1251,13 +1244,11 @@ macro_rules! known_item_types { TextDenomination, DesaturateMethod, RedGreenBlue, - RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice, TonalRange, AdjustmentChannel, HueSaturationRange, - XY, ScaleType, ReferencePoint, CentroidType, 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 29bbd4cdaeb..3a44648dae9 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; @@ -676,114 +674,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/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 5d75242623c..575e336d0d1 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -21,10 +21,9 @@ use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, No use graph_craft::{Type, concrete, item}; use graphene_std::animation::RealTimeMode; use graphene_std::color::SRGBA8; -use graphene_std::extract_xy::XY; use graphene_std::raster::{ - AdjustmentChannel, BlendMode, CellularDistanceFunction, CellularReturnType, Color, DesaturateMethod, DomainWarpType, FractalType, HueSaturationRange, NoiseType, RedGreenBlue, RedGreenBlueAlpha, - RelativeAbsolute, SelectiveColorChoice, TonalRange, + AdjustmentChannel, BlendMode, CellularDistanceFunction, CellularReturnType, Color, DesaturateMethod, DomainWarpType, FractalType, HueSaturationRange, NoiseType, RedGreenBlue, RelativeAbsolute, + SelectiveColorChoice, TonalRange, }; use graphene_std::raster_types::{CPU, GPU, Image, Raster}; use graphene_std::text::{Font, TextAlign}; @@ -349,8 +348,6 @@ pub(crate) fn property_from_type(node_id: NodeId, index: usize, ty: &Type, optio Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), - Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), - Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), 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 5253d4ffe3e..665f522f26a 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 @@ -985,6 +985,16 @@ impl NodeNetworkInterface { self.invalidate_node_appearance(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(mut node) = self.node_mut(NodeLocator::new(*node_id, network_path)) else { + log::error!("Could not get node {node_id} in set_output_names"); + return; + }; + node.set_output_names(output_names); + } + pub fn set_import_export_name(&mut self, name: String, index: ImportOrExport, network_path: &[NodeId]) { let Some((encapsulating_node_id, encapsulating_network_path)) = network_path.split_last() else { log::error!("Could not get encapsulating network in set_import_export_name"); diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index d403250a798..6c18ae1f7e0 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -14,6 +14,8 @@ use graph_craft::{Type, concrete, item, list}; use graphene_std::Color; use graphene_std::ParameterRef; use graphene_std::ProtoNodeIdentifier; +use graphene_std::extract_xy::XY; +use graphene_std::raster::RedGreenBlueAlpha; use graphene_std::text::{TextAlign, TypesettingConfig}; use graphene_std::transform::ScaleType; use graphene_std::uuid::NodeId; @@ -53,6 +55,13 @@ fn into_group_aliases() -> impl Iterator { .flat_map(|replacement| replacement.aliases) } +/// Clears a migrated node's stored display name if it was only the replaced node's default name, so it shows its new node's name instead. +fn reset_default_display_name(document: &mut DocumentMessageHandler, node_id: &NodeId, network_path: &[NodeId], old_default_name: &str) { + if document.network_interface.display_name(node_id, network_path) == old_default_name { + document.network_interface.set_display_name(node_id, String::new(), network_path); + } +} + const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ // ================================ // blending @@ -82,10 +91,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: &[ @@ -525,14 +530,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"], @@ -935,10 +932,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"], @@ -971,10 +964,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: &[ @@ -1385,6 +1374,183 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_ } } + // The "Split Vec2", "Split Vector2", and "Split Channels" wrapper networks became the multi-output `split_vec2` and `split_channels` proto nodes. + // A wrapper saved before these gained a hidden primary output had each field one output lower, so its wires shift up by one. + // 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::math_nodes::split_vec_2::IDENTIFIER), + ("Split Vector2", graphene_std::math_nodes::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 { + let old_output_count = document.network_interface.number_of_outputs(node_id, network_path); + let output_shift = usize::from(!document.network_interface.hidden_primary_output(node_id, network_path)); + + // 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 downstream_by_output: Vec> = match document.network_interface.outward_wires(network_path) { + Some(outward_wires) if output_shift > 0 => (0..old_output_count) + .map(|output_index| outward_wires.get(&OutputConnector::node(*node_id, output_index)).cloned().unwrap_or_default()) + .collect(), + _ => Vec::new(), + }; + + 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(); + 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; + }; + document.network_interface.set_output_names(node_id, output_names, network_path); + reset_default_display_name(document, node_id, network_path, old_reference); + + if let Some(input) = old_inputs.first() { + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 0), input.clone(), network_path); + } + + for (old_output_index, downstream) in downstream_by_output.iter().enumerate() { + for input_connector in downstream { + document + .network_interface + .set_input(input_connector, NodeInput::node(*node_id, old_output_index + output_shift), network_path); + } + } + } + } + + // The standalone "Extract XY" and "Extract Channel" nodes were removed in favor of the multi-output "Split Vec2" and "Split Channels" nodes. + // Convert each to its replacement, moving its downstream wires to the field output for the axis or channel it extracted. + const EXTRACT_XY: [&str; 2] = ["graphene_core::extract_xy::ExtractXyNode", "graphene_core::ops::ExtractXyNode"]; + const EXTRACT_CHANNEL: [&str; 4] = [ + "raster_nodes::adjustments::ExtractChannelNode", + "graphene_raster_nodes::adjustments::ExtractChannelNode", + "graphene_core::raster::adjustments::ExtractChannelNode", + "graphene_core::raster::ExtractChannelNode", + ]; + let extract_nodes: Vec<(NodeId, Vec, ProtoNodeIdentifier, &str)> = document + .network_interface + .document_network() + .recursive_nodes() + .filter_map(|(node_id, node, path)| { + let DocumentNodeImplementation::ProtoNode(protonode_id) = &node.implementation else { return None }; + let name = protonode_id.as_str().split('<').next().unwrap_or_default(); + if EXTRACT_XY.contains(&name) { + Some((*node_id, path, graphene_std::math_nodes::split_vec_2::IDENTIFIER, "Extract XY")) + } else if EXTRACT_CHANNEL.contains(&name) { + Some((*node_id, path, graphene_std::raster_nodes::adjustments::split_channels::IDENTIFIER, "Extract Channel")) + } else { + None + } + }) + .collect(); + for (node_id, network_path, new_identifier, old_default_name) in &extract_nodes { + // Capture the old output's downstream connections before mutating, so they can be moved to the extracted field's output + 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 Some(definition) = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(new_identifier.clone())) 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; + }; + document.network_interface.set_output_names(node_id, output_names, network_path); + reset_default_display_name(document, node_id, network_path, old_default_name); + + if let Some(input) = old_inputs.first() { + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 0), input.clone(), network_path); + } + + // The field outputs follow the hidden primary output, in X, Y or red, green, blue, alpha order + let output_index = match old_inputs.get(1).and_then(|input| input.as_value()) { + Some(TaggedValue::XY(XY::X) | TaggedValue::RedGreenBlueAlpha(RedGreenBlueAlpha::Red)) => 1, + Some(TaggedValue::XY(XY::Y) | TaggedValue::RedGreenBlueAlpha(RedGreenBlueAlpha::Green)) => 2, + Some(TaggedValue::RedGreenBlueAlpha(RedGreenBlueAlpha::Blue)) => 3, + Some(TaggedValue::RedGreenBlueAlpha(RedGreenBlueAlpha::Alpha)) => 4, + _ => { + log::warn!("Old \"{old_default_name}\" node {node_id} has no fixed axis or channel to migrate, so its wires use the first field output"); + 1 + } + }; + for input_connector in &downstream_from_output { + document.network_interface.set_input(input_connector, NodeInput::node(*node_id, output_index), network_path); + } + } + + // "Position on Path" and "Tangent on Path" were merged into the multi-output "Evaluate Path" node, with position at output 0 and tangent at output 1. + // Tangent instances have their downstream wires moved to output 1, and default the radians input to true since radians was their 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 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); + reset_default_display_name(document, node_id, network_path, if *is_tangent { "Tangent on Path" } else { "Position on 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 @@ -2244,22 +2410,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/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index b7ec57fbbf8..ef56b798164 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -554,10 +554,8 @@ tagged_value! { #[serde(alias = "LuminanceCalculation")] DesaturateMethod(raster_nodes::adjustments::DesaturateMethod), QRCodeErrorCorrectionLevel(vector_nodes::generator_nodes::QRCodeErrorCorrectionLevel), - XY(graphene_core::extract_xy::XY), StringCapitalization(text_nodes::StringCapitalization), RedGreenBlue(raster_nodes::adjustments::RedGreenBlue), - RedGreenBlueAlpha(raster_nodes::adjustments::RedGreenBlueAlpha), RealTimeMode(graphene_core::animation::RealTimeMode), NoiseType(raster_nodes::adjustments::NoiseType), FractalType(raster_nodes::adjustments::FractalType), @@ -599,6 +597,8 @@ tagged_value! { PaintOrder(vector::style::PaintOrder), // TODO: Eventually remove this document upgrade code #[serde(alias = "Fill")] LegacyFill(graphic_types::migrations::legacy::LegacyFill), // TODO: Eventually remove this document upgrade code + XY(graphene_core::extract_xy::XY), // TODO: Eventually remove this document upgrade code + RedGreenBlueAlpha(raster_nodes::adjustments::RedGreenBlueAlpha), // TODO: Eventually remove this document upgrade code } impl TaggedValue { diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index f6e7e4b4ba4..7a23e20dd74 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -8,7 +8,6 @@ use graphene_animation::AnimationCurve; use graphene_std::animation::RealTimeMode; use graphene_std::any::DynAnyNode; use graphene_std::brush::Stroke; -use graphene_std::extract_xy::XY; use graphene_std::gradient::Gradient; use graphene_std::list::{AttributeValueDyn, Bundle, Item, List, ListDyn, NodeIdPath}; #[cfg(target_family = "wasm")] @@ -345,14 +344,12 @@ fn node_registry() -> HashMap, axis: Item) -> Item { - let vector = vector.into_element(); - let axis = axis.into_element(); - - let result = match axis { - XY::X => vector.x, - XY::Y => vector.y, - }; - - Item::new_from_element(result) -} +// Kept only to deserialize the axis input of old Extract XY nodes for document migration /// The X or Y component of a vec2. -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, CacheHash, DynAny, node_macro::ChoiceType)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, CacheHash, DynAny)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[widget(Radio)] pub enum XY { #[default] X, diff --git a/node-graph/nodes/math/Cargo.toml b/node-graph/nodes/math/Cargo.toml index 70a839d8dac..d7f41cc21be 100644 --- a/node-graph/nodes/math/Cargo.toml +++ b/node-graph/nodes/math/Cargo.toml @@ -16,11 +16,13 @@ workspace = true [dependencies] core-types = { workspace = true } +graphene-core = { workspace = true } node-macro = { workspace = true } graphic-types = { workspace = true } vector-types = { workspace = true } # Workspace dependencies +dyn-any = { workspace = true } glam = { workspace = true } rand = { workspace = true } math-parser = { workspace = true } diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index 447bb7665c9..57365125b28 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -1754,6 +1754,28 @@ fn combine_vec2( Item::new_from_element(DVec2::new(*x.element(), *y.element())) } +/// The X and Y components of a vec2, split into separate node outputs. +#[derive(Debug, Clone, PartialEq, dyn_any::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(category("Math: Vec2"), name("Split Vec2"))] +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), + } +} + /// The dot product operation (`·`) calculates the degree of similarity of a vec2 pair based on their angles and lengths. /// /// Calculated as `‖a‖‖b‖cos(θ)`, it represents the product of their lengths (`‖a‖‖b‖`) scaled by the alignment of their directions (`cos(θ)`). diff --git a/node-graph/nodes/raster/Cargo.toml b/node-graph/nodes/raster/Cargo.toml index b7e8f7f6e7f..dc489867d30 100644 --- a/node-graph/nodes/raster/Cargo.toml +++ b/node-graph/nodes/raster/Cargo.toml @@ -21,6 +21,7 @@ shader-nodes = ["std", "dep:raster-nodes-shaders", "dep:wgpu-executor"] std = [ "serde", "dep:core-types", + "dep:graphene-core", "dep:dyn-any", "dep:graphene-resource", "dep:graphene-hash", @@ -50,6 +51,7 @@ node-macro = { workspace = true } # Local std dependencies dyn-any = { workspace = true, optional = true } core-types = { workspace = true, optional = true } +graphene-core = { workspace = true, optional = true } graphene-resource = { workspace = true, optional = true } graphene-hash = { workspace = true, optional = true } raster-types = { workspace = true, optional = true } diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index 0ba42d9baae..5b08b16f36b 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -22,7 +22,7 @@ use num_enum::{FromPrimitive, IntoPrimitive}; #[cfg(not(feature = "std"))] use num_traits::float::Float; #[cfg(feature = "std")] -use raster_types::{CPU, Raster}; +use raster_types::{CPU, Image, Raster}; #[cfg(feature = "std")] use vector_types::Gradient; @@ -138,27 +138,47 @@ 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(); +/// 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"))] +fn split_channels(_: impl Ctx, image: Item>) -> ImageChannels { + let (image, attributes) = image.into_parts(); + let (width, height) = (image.width, image.height); + + // O(4 × pixels), since all four channels are written even when only some outputs are connected + let mut channels: [Vec; 4] = core::array::from_fn(|_| Vec::with_capacity(image.data.len())); + for color in &image.data { + for (channel, value) in channels.iter_mut().zip([color.r(), color.g(), color.b(), color.a()]) { + channel.push(Color::from_rgbaf32_unchecked(value, value, value, 1.)); + } + } - input.element_mut().adjust(|color| { - let extracted_value = match channel { - RedGreenBlueAlpha::Red => color.r(), - RedGreenBlueAlpha::Green => color.g(), - RedGreenBlueAlpha::Blue => color.b(), - RedGreenBlueAlpha::Alpha => color.a(), + // Each channel image keeps the source image's attributes, such as its transform + let [red, green, blue, alpha] = channels.map(|data| { + let channel_image = Image { + width, + height, + data, + base64_string: None, }; - color.map_rgb(|_| extracted_value).with_alpha(1.) + Item::from_parts(Raster::new_cpu(channel_image), attributes.clone()) }); - input + + ImageChannels { red, green, blue, alpha } } #[node_macro::node(category("Raster: Channels"), shader_node(PerPixelAdjust))] @@ -1551,12 +1571,10 @@ pub enum RedGreenBlue { Blue, } -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +// Kept only to deserialize the channel input of old Extract Channel nodes for document migration #[cfg_attr(feature = "std", derive(dyn_any::DynAny))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, bytemuck::NoUninit, BufferStruct, FromPrimitive, IntoPrimitive)] -#[widget(Radio)] -#[repr(u32)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] pub enum RedGreenBlueAlpha { #[default] Red, diff --git a/node-graph/nodes/repeat/src/repeat_nodes.rs b/node-graph/nodes/repeat/src/repeat_nodes.rs index 370f2059eb7..c918de96cc6 100644 --- a/node-graph/nodes/repeat/src/repeat_nodes.rs +++ b/node-graph/nodes/repeat/src/repeat_nodes.rs @@ -236,7 +236,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}; @@ -275,15 +274,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_i64))), - FutureWrapperNode(Item::new_from_element(XY::Y)), - ), + ExtractYNode(ReadPositionNode::new(FutureWrapperNode(()), FutureWrapperNode(Item::new_from_element(0_i64)))), 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/Cargo.toml b/node-graph/nodes/vector/Cargo.toml index 415d81e5b59..1815974dfb1 100644 --- a/node-graph/nodes/vector/Cargo.toml +++ b/node-graph/nodes/vector/Cargo.toml @@ -22,6 +22,7 @@ wasm = ["core-types/wasm", "tsify", "wasm-bindgen"] [dependencies] # Local dependencies core-types = { workspace = true } +graphene-core = { workspace = true } graphene-hash = { workspace = true } vector-types = { workspace = true } graphic-types = { workspace = true } @@ -44,6 +45,5 @@ tsify = { workspace = true, optional = true } wasm-bindgen = { workspace = true, optional = true } [dev-dependencies] -graphene-core = { workspace = true } tokio = { workspace = true, features = ["macros", "rt"] } futures = { workspace = true } diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 0f13886b921..eb7327ec902 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -2173,49 +2173,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] - 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] + pub position: Item, + /// The angle of the tangent at the point on the path. + pub 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))] +async fn evaluate_path( _: impl Ctx, /// The path to traverse. content: Item, @@ -2226,9 +2198,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; @@ -2239,21 +2211,33 @@ 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.), + }; + }; - bezpath.apply_affine(Affine::new(transform.to_cols_array())); + 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 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 reverse { -DVec2::X } else { DVec2::X }.try_angle_to(tangent).unwrap_or(0.) - }); + // 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())); - Item::new_from_element(if radians { angle } else { angle.to_degrees() }) + 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 reverse { -DVec2::X } else { DVec2::X }.try_angle_to(tangent).unwrap_or(0.); + let tangent = 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)] @@ -2785,7 +2769,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 52b0742e181..e6670be6351 100644 --- a/node-graph/preprocessor/Cargo.toml +++ b/node-graph/preprocessor/Cargo.toml @@ -28,4 +28,5 @@ interpreted-executor = { workspace = true } 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 fcb35157084..ab03de2483e 100644 --- a/node-graph/preprocessor/src/lib.rs +++ b/node-graph/preprocessor/src/lib.rs @@ -414,6 +414,7 @@ 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; @@ -493,6 +494,16 @@ mod destructure_tests { } } + /// 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::math_nodes::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"); @@ -502,6 +513,59 @@ mod destructure_tests { assert_eq!(result, expected); } + #[test] + fn multi_output_node_expands_into_generated_destructure_network() { + let split_vec2_identifier = graphene_std::math_nodes::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.hidden_primary_output()); + + 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(), destructure.number_of_outputs()); + + // The struct is computed once and shared through a Memoize node + 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::Number(8.)); + } + #[test] fn primary_field_becomes_the_primary_output() { let identifier = sum_product::IDENTIFIER;