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

@@ -238,7 +238,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};
@@ -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]
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_u32))),
FutureWrapperNode(Item::new_from_element(XY::Y)),
),
ExtractYNode(ReadPositionNode::new(FutureWrapperNode(()), FutureWrapperNode(Item::new_from_element(0_u32)))),
FutureWrapperNode(Item::new_from_element(2_f64)),
FutureWrapperNode(Item::new_from_element(BoxCorners::default())),
FutureWrapperNode(Item::new_from_element(false)),