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
Generated
+5
View File
@@ -4467,10 +4467,15 @@ checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
name = "preprocessor" name = "preprocessor"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"core-types",
"dyn-any",
"futures",
"glam",
"graph-craft", "graph-craft",
"graphene-std", "graphene-std",
"interpreted-executor", "interpreted-executor",
"log", "log",
"node-macro",
] ]
[[package]] [[package]]
@@ -15,9 +15,7 @@ use graph_craft::ProtoNodeIdentifier;
use graph_craft::document::value::*; use graph_craft::document::value::*;
use graph_craft::document::*; use graph_craft::document::*;
use graph_craft::{concrete, list}; use graph_craft::{concrete, list};
use graphene_std::extract_xy::XY; use graphene_std::raster::{CellularDistanceFunction, CellularReturnType, Color, DomainWarpType, FractalType, NoiseType};
use graphene_std::raster::{CellularDistanceFunction, CellularReturnType, Color, DomainWarpType, FractalType, NoiseType, RedGreenBlueAlpha};
use graphene_std::raster_types::{CPU, Raster};
#[allow(unused_imports)] #[allow(unused_imports)]
use graphene_std::transform::Footprint; use graphene_std::transform::Footprint;
use graphene_std::vector::Vector; use graphene_std::vector::Vector;
@@ -1059,185 +1057,6 @@ fn document_node_definitions() -> HashMap<DefinitionIdentifier, DocumentNodeDefi
description: Cow::Borrowed("TODO"), description: Cow::Borrowed("TODO"),
properties: None, 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 { DocumentNodeDefinition {
identifier: "Extract", identifier: "Extract",
category: "", 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 // `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`. // 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; 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 node_registry = NODE_REGISTRY.lock().unwrap();
let empty_implementations: Vec<(NodeConstructor, NodeIOTypes)> = Vec::new(); let empty_implementations: Vec<(NodeConstructor, NodeIOTypes)> = Vec::new();
let context_type = concrete!(Context); 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); 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( definitions_map.insert(
identifier, identifier,
DocumentNodeDefinition { 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())), RegistryWidgetOverride::Custom(str) => InputMetadata::with_name_description_override(f.name, f.description, WidgetOverride::Custom(str.to_string())),
}) })
.collect(), .collect(),
output_names,
locked: false, locked: false,
..Default::default() ..Default::default()
}, },
@@ -28,6 +28,7 @@ use graphene_std::ContextDependencies;
use graphene_std::Graphic; use graphene_std::Graphic;
use graphene_std::list::List; use graphene_std::list::List;
use graphene_std::math::quad::Quad; use graphene_std::math::quad::Quad;
use graphene_std::registry::MULTI_OUTPUT_NODES;
use graphene_std::subpath::Subpath; use graphene_std::subpath::Subpath;
use graphene_std::transform::Footprint; use graphene_std::transform::Footprint;
use graphene_std::vector::click_target::{ClickTarget, ClickTargetType, FreePoint}; use graphene_std::vector::click_target::{ClickTarget, ClickTargetType, FreePoint};
@@ -365,7 +366,12 @@ impl NodeNetworkInterface {
return 0; return 0;
}; };
match &implementation { 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::Network(nested_network) => nested_network.exports.len(),
DocumentNodeImplementation::Extract => 1, DocumentNodeImplementation::Extract => 1,
} }
@@ -1140,6 +1146,9 @@ impl NodeNetworkInterface {
pub fn hidden_primary_output(&self, node_id: &NodeId, network_path: &[NodeId]) -> bool { pub fn hidden_primary_output(&self, node_id: &NodeId, network_path: &[NodeId]) -> bool {
match self.implementation(node_id, network_path) { match self.implementation(node_id, network_path) {
Some(DocumentNodeImplementation::Network(network)) => network.exports.first().is_none_or(|input| !input.is_exposed()), 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, _ => false,
} }
} }
@@ -2618,10 +2627,7 @@ impl NodeNetworkInterface {
} }
} }
let number_of_outputs = match &document_node.implementation { let number_of_outputs = self.number_of_outputs(node_id, network_path);
DocumentNodeImplementation::Network(network) => network.exports.len(),
_ => 1,
};
// If the node has a hidden primary output, do not display the first output // 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 }; let start_index = if self.hidden_primary_output(node_id, network_path) { 1 } else { 0 };
for output_index in start_index..number_of_outputs { for output_index in start_index..number_of_outputs {
@@ -4791,6 +4797,20 @@ impl NodeNetworkInterface {
self.unload_node_click_targets(node_id, network_path); 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]) { 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 { let Some(encapsulating_node) = self.encapsulating_node_metadata_mut(network_path) else {
log::error!("Could not get encapsulating network in set_import_export_name"); 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()), 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 // 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(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()) { DocumentNodeImplementation::ProtoNode(identifier) => {
Some(resolved_type) => TypeSource::Compiled(resolved_type.output.clone()), // The field outputs of a multi-output proto node have their element types recorded in the registry, and
None => TypeSource::Unknown, // 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!(())), DocumentNodeImplementation::Extract => TypeSource::Compiled(concrete!(())),
} }
} }
@@ -93,10 +93,6 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[
node: graphene_std::animation::animation_time::IDENTIFIER, node: graphene_std::animation::animation_time::IDENTIFIER,
aliases: &["graphene_core::animation::AnimationTimeNode"], aliases: &["graphene_core::animation::AnimationTimeNode"],
}, },
NodeReplacement {
node: graphene_std::extract_xy::extract_xy::IDENTIFIER,
aliases: &["graphene_core::ops::ExtractXyNode"],
},
NodeReplacement { NodeReplacement {
node: graphene_std::ops::passthrough::IDENTIFIER, node: graphene_std::ops::passthrough::IDENTIFIER,
aliases: &[ aliases: &[
@@ -519,14 +515,6 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[
node: graphene_std::raster_nodes::std_nodes::extend_image_to_bounds::IDENTIFIER, node: graphene_std::raster_nodes::std_nodes::extend_image_to_bounds::IDENTIFIER,
aliases: &["graphene_raster_nodes::std_nodes::ExtendImageToBoundsNode", "graphene_std::raster::ExtendImageToBoundsNode"], 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 { NodeReplacement {
node: graphene_std::raster_nodes::adjustments::gamma_correction::IDENTIFIER, node: graphene_std::raster_nodes::adjustments::gamma_correction::IDENTIFIER,
aliases: &["graphene_raster_nodes::adjustments::GammaCorrectionNode", "graphene_core::raster::adjustments::GammaCorrectionNode"], 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, node: graphene_std::vector::scatter_points::IDENTIFIER,
aliases: &["graphene_core::vector::PoissonDiskPointsNode", "core_types::vector::PoissonDiskPointsNode"], aliases: &["graphene_core::vector::PoissonDiskPointsNode", "core_types::vector::PoissonDiskPointsNode"],
}, },
NodeReplacement {
node: graphene_std::vector::position_on_path::IDENTIFIER,
aliases: &["graphene_core::vector::PositionOnPathNode"],
},
NodeReplacement { NodeReplacement {
node: graphene_std::vector::round_corners::IDENTIFIER, node: graphene_std::vector::round_corners::IDENTIFIER,
aliases: &["graphene_core::vector::RoundCornersNode"], aliases: &["graphene_core::vector::RoundCornersNode"],
@@ -964,10 +948,6 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[
node: graphene_std::vector::stroke::IDENTIFIER, node: graphene_std::vector::stroke::IDENTIFIER,
aliases: &["graphene_core::vector::StrokeNode"], aliases: &["graphene_core::vector::StrokeNode"],
}, },
NodeReplacement {
node: graphene_std::vector::tangent_on_path::IDENTIFIER,
aliases: &["graphene_core::vector::TangentOnPathNode"],
},
NodeReplacement { NodeReplacement {
node: graphene_std::vector::as_vector::IDENTIFIER, node: graphene_std::vector::as_vector::IDENTIFIER,
aliases: &[ 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 // 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. // 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 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); .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 // 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 { 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(); let mut node_template = resolve_document_node_type(&reference)?.default_node_template();
@@ -143,6 +143,20 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<&wgpu_executor::WgpuExecutor>]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<&wgpu_executor::WgpuExecutor>]),
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<Option<&wgpu_executor::WgpuExecutor>>]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<Option<&wgpu_executor::WgpuExecutor>>]),
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<wgpu_executor::WgpuPipelineCache>]), async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<wgpu_executor::WgpuPipelineCache>]),
// Destructure structs of multi-output nodes, memoized so the struct is computed once rather than once per output (see the Graphene preprocessor)
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<graphene_std::extract_xy::Vec2Components>]),
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List<graphene_std::extract_xy::Vec2Components>]),
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<graphene_std::raster_nodes::adjustments::ImageChannels>]),
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List<graphene_std::raster_nodes::adjustments::ImageChannels>]),
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<graphene_std::vector::PathEvaluation>]),
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List<graphene_std::vector::PathEvaluation>]),
// Monitor rows for the hidden struct primary output of multi-output nodes, so inspecting one resolves
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item<graphene_std::extract_xy::Vec2Components>]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List<graphene_std::extract_xy::Vec2Components>]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item<graphene_std::raster_nodes::adjustments::ImageChannels>]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List<graphene_std::raster_nodes::adjustments::ImageChannels>]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Item<graphene_std::vector::PathEvaluation>]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => List<graphene_std::vector::PathEvaluation>]),
]; ];
// The per-connector input adapter, registered per element type: an `Item` or `List` wire passes through unchanged. // The per-connector input adapter, registered per element type: an `Item` or `List` wire passes through unchanged.
// The `name` arm registers an `Into`-based whole-wire shift under the given identifier, serving the `ListDyn` erasure rows. // The `name` arm registers an `Into`-based whole-wire shift under the given identifier, serving the `ListDyn` erasure rows.
@@ -1,6 +1,7 @@
use crate::{ContextFeature, Node, NodeIO, NodeIOTypes, ProtoNodeIdentifier, Type, WasmNotSend}; use crate::{ContextFeature, Node, NodeIO, NodeIOTypes, ProtoNodeIdentifier, Type, WasmNotSend};
use dyn_any::{DynAny, StaticType}; use dyn_any::{DynAny, StaticType};
pub use no_std_types::registry::types; pub use no_std_types::registry::types;
use std::any::TypeId;
use std::collections::HashMap; use std::collections::HashMap;
use std::marker::PhantomData; use std::marker::PhantomData;
use std::ops::Deref; use std::ops::Deref;
@@ -57,12 +58,66 @@ pub enum RegistryValueSource {
Scope(&'static str), Scope(&'static str),
} }
/// Metadata for a struct tagged with `#[node_macro::destructure]`, describing how its fields are broken out into individual node connectors.
/// Registered by the macro into [`DESTRUCTURE_METADATA`], keyed by the [`TypeId`] of the struct and of its `Item`/`List` wire forms.
///
/// Currently used for node outputs: a node function returning such a struct becomes a multi-output node whose outputs are the struct's fields.
/// The same registration is intended to eventually also drive destructured inputs, where a single struct parameter expands into one input connector per field.
#[derive(Clone, Debug)]
pub struct DestructureMetadata {
/// The fields in output-connector order. When `has_primary` is true the first entry is the field marked `#[primary]`,
/// exposed as the node's primary output at index 0 with the remaining fields following it. Otherwise a hidden primary
/// output carrying the whole struct occupies index 0 and the fields are the secondary outputs at indices 1 and up.
pub fields: Vec<DestructureFieldMetadata>,
pub has_primary: bool,
/// The struct's canonical type name from [`std::any::type_name`], used to match registry rows whose element descriptors carry no [`TypeId`].
pub struct_name: &'static str,
}
// Translation struct between macro and definition
#[derive(Clone, Debug)]
pub struct DestructureFieldMetadata {
pub name: &'static str,
pub description: &'static str,
/// The generated proto node that extracts this field from the struct value.
pub extractor: ProtoNodeIdentifier,
/// The concrete type of the field.
pub ty: Type,
}
type NodeRegistry = LazyLock<Mutex<HashMap<ProtoNodeIdentifier, Vec<(NodeConstructor, NodeIOTypes)>>>>; type NodeRegistry = LazyLock<Mutex<HashMap<ProtoNodeIdentifier, Vec<(NodeConstructor, NodeIOTypes)>>>>;
pub static NODE_REGISTRY: NodeRegistry = LazyLock::new(|| Mutex::new(HashMap::new())); pub static NODE_REGISTRY: NodeRegistry = LazyLock::new(|| Mutex::new(HashMap::new()));
pub static NODE_METADATA: LazyLock<Mutex<HashMap<ProtoNodeIdentifier, NodeMetadata>>> = LazyLock::new(|| Mutex::new(HashMap::new())); pub static NODE_METADATA: LazyLock<Mutex<HashMap<ProtoNodeIdentifier, NodeMetadata>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
pub static DESTRUCTURE_METADATA: LazyLock<Mutex<HashMap<TypeId, DestructureMetadata>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
/// Looks up the [`DestructureMetadata`] registered for a node's return type, if that type is a `#[node_macro::destructure]` struct.
/// Accepts the type as stored in [`NodeIOTypes::return_value`], unwrapping any `Future` wrapper and the `Item`/`List` rank around the concrete element type.
pub fn destructure_metadata_for_type(return_type: &Type) -> Option<DestructureMetadata> {
let element_type = match return_type.nested_type() {
Type::Item(inner) | Type::List(inner) => inner.nested_type(),
other => other,
};
let Type::Concrete(descriptor) = element_type else { return None };
let type_id = descriptor.id?;
DESTRUCTURE_METADATA.lock().unwrap().get(&type_id).cloned()
}
/// All multi-output proto nodes (those whose return type is a `#[node_macro::destructure]` struct), keyed by their identifier.
/// Snapshotted on first access, which must happen after startup registration of the node and destructure registries completes.
pub static MULTI_OUTPUT_NODES: LazyLock<HashMap<ProtoNodeIdentifier, DestructureMetadata>> = LazyLock::new(|| {
let node_registry = NODE_REGISTRY.lock().unwrap();
node_registry
.iter()
.filter_map(|(identifier, implementations)| {
let (_, node_io) = implementations.first()?;
destructure_metadata_for_type(&node_io.return_value).map(|metadata| (identifier.clone(), metadata))
})
.collect()
});
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
pub type DynFuture<'n, T> = Pin<Box<dyn Future<Output = T> + 'n + Send>>; pub type DynFuture<'n, T> = Pin<Box<dyn Future<Output = T> + 'n + Send>>;
#[cfg(target_family = "wasm")] #[cfg(target_family = "wasm")]
+1 -1
View File
@@ -7,7 +7,7 @@ use syn::punctuated::Punctuated;
use syn::spanned::Spanned; use syn::spanned::Spanned;
use syn::token::Comma; use syn::token::Comma;
use syn::{Error, Ident, PatIdent, Token, WhereClause, WherePredicate, parse_quote}; use syn::{Error, Ident, PatIdent, Token, WhereClause, WherePredicate, parse_quote};
static NODE_ID: AtomicU64 = AtomicU64::new(0); pub(crate) static NODE_ID: AtomicU64 = AtomicU64::new(0);
pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn) -> syn::Result<TokenStream2> { pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn) -> syn::Result<TokenStream2> {
let ParsedNodeFn { let ParsedNodeFn {
+313
View File
@@ -0,0 +1,313 @@
use crate::crate_ident::CrateIdent;
use convert_case::{Case, Casing};
use proc_macro2::TokenStream as TokenStream2;
use quote::{format_ident, quote};
use syn::spanned::Spanned;
use syn::{AttrStyle, Attribute, Error, Expr, Fields, Ident, ItemStruct, Lit, LitStr, Meta, Type};
/// One field of a `#[node_macro::destructure]` struct, parsed from the struct definition.
struct DestructureField {
ident: Ident,
ty: Type,
/// The connector label shown in the UI: the `#[name("...")]` override, or the field name converted to title case.
display_name: String,
/// Tooltip text collected from the field's doc comments.
description: String,
/// The field's doc attributes, re-emitted onto the generated extractor node function.
doc_attrs: Vec<Attribute>,
}
pub fn destructure_impl(attr: TokenStream2, item: TokenStream2) -> syn::Result<TokenStream2> {
if !attr.is_empty() {
return Err(Error::new(attr.span(), "The `destructure` attribute takes no arguments"));
}
let mut item_struct = syn::parse2::<ItemStruct>(item).map_err(|e| Error::new(e.span(), format!("`destructure` must be applied to a struct: {e}")))?;
if !item_struct.generics.params.is_empty() || item_struct.generics.where_clause.is_some() {
return Err(Error::new_spanned(
&item_struct.generics,
"A `destructure` struct cannot have generic parameters or a where clause, since each field must have a concrete type",
));
}
let Fields::Named(named_fields) = &mut item_struct.fields else {
return Err(Error::new_spanned(&item_struct.fields, "A `destructure` struct must have named fields, one per connector"));
};
if named_fields.named.is_empty() {
return Err(Error::new_spanned(named_fields, "A `destructure` struct must have at least one field"));
}
// Collect each field's connector metadata, stripping the `#[name(...)]` and `#[primary]` helper attributes from the emitted struct
let mut fields = Vec::new();
let mut primary_field_index = None;
for (field_index, field) in named_fields.named.iter_mut().enumerate() {
let ident = field.ident.clone().expect("Named fields always have an identifier");
if let Some(position) = field.attrs.iter().position(|field_attr| field_attr.path().is_ident("primary")) {
let primary_attr = field.attrs.remove(position);
if !matches!(primary_attr.meta, Meta::Path(_)) {
return Err(Error::new_spanned(&primary_attr, "Expected a bare `#[primary]` with no arguments"));
}
if primary_field_index.is_some() {
return Err(Error::new_spanned(&primary_attr, "At most one field may be marked `#[primary]`"));
}
primary_field_index = Some(field_index);
}
let mut display_name = None;
if let Some(position) = field.attrs.iter().position(|field_attr| field_attr.path().is_ident("name")) {
let name_attr = field.attrs.remove(position);
let name_literal: LitStr = name_attr
.parse_args()
.map_err(|e| Error::new_spanned(&name_attr, format!("Expected `#[name(\"...\")]` with a string literal: {e}")))?;
display_name = Some(name_literal.value());
}
let display_name = display_name.unwrap_or_else(|| ident.to_string().to_case(Case::Title));
let doc_attrs: Vec<Attribute> = field.attrs.iter().filter(|field_attr| field_attr.path().is_ident("doc")).cloned().collect();
let description = doc_attrs
.iter()
.filter_map(|doc_attr| {
if doc_attr.style != AttrStyle::Outer {
return None;
}
let Meta::NameValue(name_value) = &doc_attr.meta else { return None };
let Expr::Lit(expr_lit) = &name_value.value else { return None };
let Lit::Str(text) = &expr_lit.lit else { return None };
Some(text.value().trim().to_string())
})
.collect::<Vec<_>>()
.join("\n");
fields.push(DestructureField {
ident,
ty: field.ty.clone(),
display_name,
description,
doc_attrs,
});
}
// Registration lists the fields in output-connector order, so a `#[primary]` field moves to the front where it
// becomes the node's primary output in place of the hidden output that otherwise carries the whole struct
let has_primary = primary_field_index.is_some();
if let Some(primary_field_index) = primary_field_index {
let primary_field = fields.remove(primary_field_index);
fields.insert(0, primary_field);
}
let crate_ident = CrateIdent::default();
let gcore = crate_ident.gcore()?;
let struct_ident = item_struct.ident.clone();
let struct_snake_name = struct_ident.to_string().to_case(Case::Snake);
// Generate a hidden extractor node per field by running the regular node codegen pipeline on a synthesized function.
// Each extractor takes the struct by value and returns one field, so the preprocessor can wire them up as a multi-output node's secondary outputs.
let mut extractor_nodes = Vec::new();
let mut extractor_input_modules = Vec::new();
for field in &fields {
let field_ident = &field.ident;
let field_ty = &field.ty;
let doc_attrs = &field.doc_attrs;
let extractor_fn_name = format_ident!("{struct_snake_name}_{field_ident}");
extractor_input_modules.push(extractor_fn_name.clone());
// An empty category keeps the extractor out of the editor's node catalog
let extractor_display_name = format!("{struct_ident} {}", field.display_name);
let node_attr = quote!(category(""), name(#extractor_display_name));
// Each field output inherits the struct item's attributes, passing them through like any other kernel
let node_fn = quote! {
#(#doc_attrs)*
fn #extractor_fn_name(_: impl #gcore::Ctx, source: #gcore::list::Item<#struct_ident>) -> #gcore::list::Item<#field_ty> {
let (source, attributes) = source.into_parts();
#gcore::list::Item::from_parts(source.#field_ident, attributes)
}
};
extractor_nodes.push(crate::parsing::new_node_fn(node_attr, node_fn)?);
}
// Register the struct's destructure metadata, keyed by the TypeIds of the struct and its ranked wire forms, so the
// preprocessor and editor can recognize nodes returning this struct and expand them into the generated extractor nodes
let field_names = fields.iter().map(|field| field.display_name.as_str()).collect::<Vec<_>>();
let field_descriptions = fields.iter().map(|field| field.description.as_str()).collect::<Vec<_>>();
let field_types = fields.iter().map(|field| &field.ty).collect::<Vec<_>>();
let registration_module = format_ident!("_{struct_snake_name}_destructure");
let registry_name = format_ident!(
"__node_registry_{}_{}Destructure",
crate::codegen::NODE_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
struct_ident
);
let wasm_shim = if cfg!(feature = "disable-registration") {
quote!()
} else {
quote! {
#[cfg(target_family = "wasm")]
#[unsafe(no_mangle)]
extern "C" fn #registry_name() {
register_destructure();
}
}
};
let registration = quote! {
#[doc(hidden)]
mod #registration_module {
use super::*;
use #gcore::ctor::ctor;
use #gcore::registry::{DESTRUCTURE_METADATA, DestructureFieldMetadata, DestructureMetadata};
#[cfg_attr(not(target_family = "wasm"), ctor)]
fn register_destructure() {
let metadata = DestructureMetadata {
fields: vec![
#(
DestructureFieldMetadata {
name: #field_names,
description: #field_descriptions,
extractor: super::#extractor_input_modules::IDENTIFIER,
ty: #gcore::concrete!(#field_types),
},
)*
],
has_primary: #has_primary,
struct_name: ::std::any::type_name::<#struct_ident>(),
};
// Registered under the bare struct and both ranked wire forms, since registry rows record whichever the node's return type resolved as
let mut registry = DESTRUCTURE_METADATA.lock().unwrap();
registry.insert(::std::any::TypeId::of::<#gcore::list::Item<#struct_ident>>(), metadata.clone());
registry.insert(::std::any::TypeId::of::<#gcore::list::List<#struct_ident>>(), metadata.clone());
registry.insert(::std::any::TypeId::of::<#struct_ident>(), metadata);
}
#wasm_shim
}
};
Ok(quote! {
#item_struct
#(#extractor_nodes)*
#registration
})
}
#[cfg(test)]
mod tests {
use super::*;
fn expect_error(attr: TokenStream2, item: TokenStream2, message_fragment: &str) {
let error = destructure_impl(attr, item).expect_err("Expected the destructure macro to reject this input");
let message = error.to_string();
assert!(message.contains(message_fragment), "Expected error containing `{message_fragment}`, got `{message}`");
}
#[test]
fn rejects_arguments() {
expect_error(
quote!(some_argument),
quote!(
struct Test {
x: f64,
}
),
"takes no arguments",
);
}
#[test]
fn rejects_non_structs() {
expect_error(
quote!(),
quote!(
enum Test {
Variant,
}
),
"must be applied to a struct",
);
}
#[test]
fn rejects_tuple_structs() {
expect_error(
quote!(),
quote!(
struct Test(f64, f64);
),
"must have named fields",
);
}
#[test]
fn rejects_generic_structs() {
expect_error(
quote!(),
quote!(
struct Test<T> {
x: T,
}
),
"cannot have generic parameters",
);
}
#[test]
fn rejects_empty_structs() {
expect_error(
quote!(),
quote!(
struct Test {}
),
"at least one field",
);
}
#[test]
fn rejects_multiple_primary_fields() {
expect_error(
quote!(),
quote!(
struct Test {
#[primary]
x: f64,
#[primary]
y: f64,
}
),
"At most one field",
);
}
#[test]
fn rejects_primary_attribute_with_arguments() {
expect_error(
quote!(),
quote!(
struct Test {
#[primary(true)]
x: f64,
}
),
"bare `#[primary]`",
);
}
#[test]
fn rejects_malformed_name_attribute() {
expect_error(
quote!(),
quote!(
struct Test {
#[name(42)]
x: f64,
}
),
"string literal",
);
}
}
+46
View File
@@ -7,6 +7,7 @@ mod buffer_struct;
mod codegen; mod codegen;
mod crate_ident; mod crate_ident;
mod derive_choice_type; mod derive_choice_type;
mod destructure;
mod parsing; mod parsing;
mod shader_nodes; mod shader_nodes;
mod validation; mod validation;
@@ -19,6 +20,51 @@ pub fn node(attr: TokenStream, item: TokenStream) -> TokenStream {
parsing::new_node_fn(attr.into(), item.into()).unwrap_or_else(|err| err.to_compile_error()).into() parsing::new_node_fn(attr.into(), item.into()).unwrap_or_else(|err| err.to_compile_error()).into()
} }
/// Marks a struct as destructurable at node boundaries, splitting its fields into individual node connectors.
///
/// When a `#[node_macro::node]` function returns a struct tagged with this attribute, that node becomes a multi-output node:
/// each struct field is exposed as a named secondary output connector in the graph UI. The destructuring itself is performed
/// by hidden extractor nodes which this macro generates, one per field. Those extractor nodes exist only in the transient
/// runtime network produced by the Graphene preprocessor; they are never shown in the graph UI, saved to documents, or
/// serialized when copying nodes.
///
/// Output names default to the field name converted to title case. Use `#[name("...")]` on a field to override that
/// when the automatic conversion doesn't format correctly. Doc comments on fields are recorded as connector descriptions.
///
/// By default the node has no primary output: a hidden primary output carries the whole struct and the fields appear as
/// secondary outputs. Marking at most one field with `#[primary]` makes that field the node's primary output instead.
///
/// The struct is computed once and shared across all outputs when a Memoize implementation is registered for its type
/// (see the `MemoizeNode` entries in `interpreted-executor`'s node registry); otherwise the node re-evaluates per
/// connected output.
///
/// The struct must have named fields with concrete (non-generic) types, and the value must be able to flow through the
/// graph, which in practice means deriving `dyn_any::DynAny` plus `Clone`, `Debug`, and being `Send + Sync`.
///
/// The same registration is planned to eventually drive destructured *inputs*, where a single struct parameter of a node
/// function expands into one input connector per field, grouped in the Properties panel.
///
/// ```ignore
/// #[node_macro::destructure]
/// #[derive(Debug, Clone, Copy, dyn_any::DynAny)]
/// pub struct Vec2Components {
/// /// The X component of the vec2.
/// x: f64,
/// /// The Y component of the vec2.
/// y: f64,
/// }
///
/// #[node_macro::node(name("Split Vec2"), category("Math: Vec2"))]
/// fn split_vec2(_: impl Ctx, vec2: DVec2) -> Vec2Components {
/// Vec2Components { x: vec2.x, y: vec2.y }
/// }
/// ```
#[proc_macro_error]
#[proc_macro_attribute]
pub fn destructure(attr: TokenStream, item: TokenStream) -> TokenStream {
destructure::destructure_impl(attr.into(), item.into()).unwrap_or_else(|err| err.to_compile_error()).into()
}
/// Generate meta-information for an enum. /// Generate meta-information for an enum.
/// ///
/// `#[widget(F)]` on a type indicates the type of widget to use to display/edit the type, currently `Radio` and `Dropdown` are supported. /// `#[widget(F)]` on a type indicates the type of widget to use to display/edit the type, currently `Radio` and `Dropdown` are supported.
+21 -17
View File
@@ -1,23 +1,7 @@
use core_types::list::Item; use core_types::list::Item;
use core_types::{CacheHash, Ctx}; use core_types::{CacheHash, Ctx};
use dyn_any::DynAny; use dyn_any::DynAny;
use glam::{DVec2, IVec2, UVec2}; use glam::DVec2;
/// Obtains the X or Y component of a vec2.
///
/// The inverse of this node is **Combine Vec2**, which composes a vec2 from its X and Y components.
#[node_macro::node(name("Extract XY"), category("Math: Vec2"))]
fn extract_xy<T: Into<DVec2>>(_: impl Ctx, #[implementations(DVec2, IVec2, UVec2)] vector: Item<T>, axis: Item<XY>) -> Item<f64> {
let vector = vector.into_element();
let axis = axis.into_element();
let result = match axis {
XY::X => vector.into().x,
XY::Y => vector.into().y,
};
Item::new_from_element(result)
}
/// The X or Y component of a vec2. /// The X or Y component of a vec2.
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
@@ -29,3 +13,23 @@ pub enum XY {
X, X,
Y, Y,
} }
/// The X and Y components of a vec2, split into separate node outputs.
#[node_macro::destructure]
#[derive(Debug, Clone, Copy, PartialEq, DynAny)]
pub struct Vec2Components {
/// The X component of the vec2.
pub x: f64,
/// The Y component of the vec2.
pub y: f64,
}
/// Decomposes the X and Y components of a vec2.
///
/// The inverse of this node is **Combine Vec2**, which composes a vec2 from its X and Y components.
#[node_macro::node(name("Split Vec2"), category("Math: Vec2"))]
fn split_vec2(_: impl Ctx, #[name("Vec2")] vec2: Item<DVec2>) -> Item<Vec2Components> {
let vec2 = vec2.into_element();
Item::new_from_element(Vec2Components { x: vec2.x, y: vec2.y })
}
+35 -16
View File
@@ -105,22 +105,10 @@ fn gamma_correction<T: Adjust<Color>>(
input input
} }
#[node_macro::node(category("Raster: Channels"), shader_node(PerPixelAdjust))] /// Extracts one color channel as a grayscale image. Used internally by the `split_channels` node.
fn extract_channel<T: Adjust<Color>>( #[cfg(feature = "std")]
_: impl Ctx, fn extract_channel<T: Adjust<Color>>(mut input: T, channel: RedGreenBlueAlpha) -> T {
#[implementations( input.adjust(|color| {
Raster<CPU>,
Color,
Gradient,
)]
#[gpu_image]
input: Item<T>,
channel: Item<RedGreenBlueAlpha>,
) -> Item<T> {
let mut input = input;
let channel = channel.into_element();
input.element_mut().adjust(|color| {
let extracted_value = match channel { let extracted_value = match channel {
RedGreenBlueAlpha::Red => color.r(), RedGreenBlueAlpha::Red => color.r(),
RedGreenBlueAlpha::Green => color.g(), RedGreenBlueAlpha::Green => color.g(),
@@ -132,6 +120,37 @@ fn extract_channel<T: Adjust<Color>>(
input input
} }
/// The red, green, blue, and alpha channels of an image, split into separate node outputs.
#[cfg(feature = "std")]
#[node_macro::destructure]
#[derive(Debug, Clone, dyn_any::DynAny)]
pub struct ImageChannels {
/// The red channel of the image, as a grayscale image.
pub red: Raster<CPU>,
/// The green channel of the image, as a grayscale image.
pub green: Raster<CPU>,
/// The blue channel of the image, as a grayscale image.
pub blue: Raster<CPU>,
/// The alpha channel of the image, as a grayscale image.
pub alpha: Raster<CPU>,
}
/// 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<Raster<CPU>>) -> Item<ImageChannels> {
let (image, attributes) = image.into_parts();
let channels = ImageChannels {
red: extract_channel(image.clone(), RedGreenBlueAlpha::Red),
green: extract_channel(image.clone(), RedGreenBlueAlpha::Green),
blue: extract_channel(image.clone(), RedGreenBlueAlpha::Blue),
alpha: extract_channel(image, RedGreenBlueAlpha::Alpha),
};
Item::from_parts(channels, attributes)
}
#[node_macro::node(category("Raster: Channels"), shader_node(PerPixelAdjust))] #[node_macro::node(category("Raster: Channels"), shader_node(PerPixelAdjust))]
fn make_opaque<T: Adjust<Color>>( fn make_opaque<T: Adjust<Color>>(
_: impl Ctx, _: impl Ctx,
+16 -5
View File
@@ -238,7 +238,6 @@ mod test {
use core_types::transform::Footprint; use core_types::transform::Footprint;
use glam::DVec2; use glam::DVec2;
use graphene_core::ReadPositionNode; use graphene_core::ReadPositionNode;
use graphene_core::extract_xy::{ExtractXyNode, XY};
use graphic_types::Vector; use graphic_types::Vector;
use kurbo::Shape; use kurbo::Shape;
use kurbo::{BezPath, DEFAULT_ACCURACY, Rect}; use kurbo::{BezPath, DEFAULT_ACCURACY, Rect};
@@ -278,15 +277,27 @@ mod test {
} }
} }
/// Test helper that extracts the Y component of an upstream node's `Item<DVec2>` output.
#[derive(Clone)]
struct ExtractYNode<Position>(Position);
impl<'i, I: Ctx, Position> Node<'i, I> for ExtractYNode<Position>
where
Position: Node<'i, I, Output = Pin<Box<dyn Future<Output = Item<DVec2>> + 'i + Send>>>,
{
type Output = Pin<Box<dyn Future<Output = Item<f64>> + '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] #[tokio::test]
async fn repeat_on_points_test() { async fn repeat_on_points_test() {
let context = OwnedContextImpl::default().into_context(); let context = OwnedContextImpl::default().into_context();
let rect = RectangleNode::new( let rect = RectangleNode::new(
FutureWrapperNode(()), FutureWrapperNode(()),
ExtractXyNode::new( ExtractYNode(ReadPositionNode::new(FutureWrapperNode(()), FutureWrapperNode(Item::new_from_element(0_u32)))),
ReadPositionNode::new(FutureWrapperNode(()), FutureWrapperNode(Item::new_from_element(0_u32))),
FutureWrapperNode(Item::new_from_element(XY::Y)),
),
FutureWrapperNode(Item::new_from_element(2_f64)), FutureWrapperNode(Item::new_from_element(2_f64)),
FutureWrapperNode(Item::new_from_element(BoxCorners::default())), FutureWrapperNode(Item::new_from_element(BoxCorners::default())),
FutureWrapperNode(Item::new_from_element(false)), FutureWrapperNode(Item::new_from_element(false)),
+35 -55
View File
@@ -1974,48 +1974,22 @@ async fn cut_segments(_: impl Ctx, content: Item<Vector>) -> Item<Vector> {
content content
} }
/// Determines the position of a point on the path, given by its progression from 0 to 1 along the path. /// The position and tangent angle at a point along a path, split into separate node outputs.
/// #[node_macro::destructure]
/// 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. #[derive(Debug, Clone, Copy, PartialEq, dyn_any::DynAny)]
#[node_macro::node(name("Position on Path"), category("Vector: Measure"), path(graphene_core::vector))] pub struct PathEvaluation {
async fn position_on_path( /// The position of the point on the path.
_: impl Ctx, #[primary]
/// The path to traverse. position: DVec2,
content: Item<Vector>, /// The angle of the tangent at the point on the path.
/// 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. tangent: f64,
progression: Item<Progression>,
/// Swap the direction of the path.
reverse: Item<bool>,
/// 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<bool>,
) -> Item<DVec2> {
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)
} }
/// 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. /// 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))] #[node_macro::node(category("Vector: Measure"), path(graphene_core::vector))]
async fn tangent_on_path( async fn evaluate_path(
_: impl Ctx, _: impl Ctx,
/// The path to traverse. /// The path to traverse.
content: Item<Vector>, content: Item<Vector>,
@@ -2025,9 +1999,9 @@ async fn tangent_on_path(
reverse: Item<bool>, reverse: Item<bool>,
/// 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. /// 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<bool>, parameterized_distance: Item<bool>,
/// 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<bool>, radians: Item<bool>,
) -> Item<f64> { ) -> Item<PathEvaluation> {
let (progression, reverse, parameterized_distance, radians) = (progression.into_element(), reverse.into_element(), parameterized_distance.into_element(), radians.into_element()); let (progression, reverse, parameterized_distance, radians) = (progression.into_element(), reverse.into_element(), parameterized_distance.into_element(), radians.into_element());
let euclidian = !parameterized_distance; let euclidian = !parameterized_distance;
@@ -2038,25 +2012,31 @@ async fn tangent_on_path(
let progression = if reverse { bezpath_count - progression } else { progression }; 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 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 Some((bezpath, transform)) = bezpaths.get_mut(index) else {
let t = if progression == bezpath_count { 1. } else { progression.fract() }; return Item::new_from_element(PathEvaluation { position: DVec2::ZERO, tangent: 0. });
let t_value = |t: f64| if euclidian { TValue::Euclidean(t) } else { TValue::Parametric(t) }; };
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)); // Apply the transform once so both the position and tangent are computed on the transformed path
if tangent == DVec2::ZERO { bezpath.apply_affine(Affine::new(transform.to_cols_array()));
let t = t + if t > 0.5 { -0.001 } else { 0.001 };
tangent = point_to_dvec2(tangent_on_bezpath(bezpath, t_value(t), None));
}
if tangent == DVec2::ZERO {
return 0.;
}
let position = point_to_dvec2(evaluate_bezpath(bezpath, t_value(t), None));
let mut tangent = point_to_dvec2(tangent_on_bezpath(bezpath, t_value(t), None));
if tangent == DVec2::ZERO {
let t = t + if t > 0.5 { -0.001 } else { 0.001 };
tangent = point_to_dvec2(tangent_on_bezpath(bezpath, t_value(t), None));
}
let angle = if tangent == DVec2::ZERO {
0.
} else {
-tangent.angle_to(if reverse { -DVec2::X } else { DVec2::X }) -tangent.angle_to(if reverse { -DVec2::X } else { DVec2::X })
}); };
let tangent = if radians { angle } else { angle.to_degrees() };
Item::new_from_element(if radians { angle } else { angle.to_degrees() }) Item::new_from_element(PathEvaluation { position, tangent })
} }
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector), memoize)] #[node_macro::node(category("Vector: Modifier"), path(core_types::vector), memoize)]
@@ -2527,7 +2507,7 @@ async fn morph<I: IntoGraphicList>(
if paths.is_empty() { default_polyline() } else { paths } 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 progression = progression.max(0.);
let subpath_count = control_bezpaths.len() as f64; let subpath_count = control_bezpaths.len() as f64;
let progression = if reverse { subpath_count - progression } else { progression }; let progression = if reverse { subpath_count - progression } else { progression };
+8
View File
@@ -14,3 +14,11 @@ log = { workspace = true }
graphene-std = { workspace = true, features = ["gpu"] } graphene-std = { workspace = true, features = ["gpu"] }
graph-craft = { workspace = true } graph-craft = { workspace = true }
interpreted-executor = { 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 }
+213 -6
View File
@@ -232,7 +232,27 @@ impl Preprocessor {
}) })
.collect(); .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; continue;
} }
@@ -249,7 +269,7 @@ impl Preprocessor {
nodes.insert(NodeId(input_count as u64), document_node); 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 // 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_id = NodeId(input_count as u64 + 1);
let memoize_node = DocumentNode { let memoize_node = DocumentNode {
inputs: vec![NodeInput::node(NodeId(input_count as u64), 0)], inputs: vec![NodeInput::node(NodeId(input_count as u64), 0)],
@@ -263,14 +283,35 @@ impl Preprocessor {
NodeId(input_count as u64) 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 { let node = DocumentNode {
inputs, inputs,
call_argument: input_type.clone(), call_argument: input_type.clone(),
implementation: DocumentNodeImplementation::Network(NodeNetwork { implementation: DocumentNodeImplementation::Network(NodeNetwork {
exports: vec![NodeInput::Node { exports,
node_id: export_node_id,
output_index: 0,
}],
nodes, nodes,
scope_injections: Default::default(), scope_injections: Default::default(),
generated: true, generated: true,
@@ -350,6 +391,172 @@ pub enum PreprocessorError {
ResourceNotFound(ResourceId), 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 { impl std::fmt::Display for PreprocessorError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {