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

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

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

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

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

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

View File

@@ -1,6 +1,7 @@
use crate::{ContextFeature, Node, NodeIO, NodeIOTypes, ProtoNodeIdentifier, Type, WasmNotSend};
use dyn_any::{DynAny, StaticType};
pub use no_std_types::registry::types;
use std::any::TypeId;
use std::collections::HashMap;
use std::marker::PhantomData;
use std::ops::Deref;
@@ -57,12 +58,66 @@ pub enum RegistryValueSource {
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)>>>>;
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 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"))]
pub type DynFuture<'n, T> = Pin<Box<dyn Future<Output = T> + 'n + Send>>;
#[cfg(target_family = "wasm")]