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,23 +1,7 @@
use core_types::list::Item;
use core_types::{CacheHash, Ctx};
use dyn_any::DynAny;
use glam::{DVec2, IVec2, UVec2};
/// 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)
}
use glam::DVec2;
/// The X or Y component of a vec2.
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
@@ -29,3 +13,23 @@ pub enum XY {
X,
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 })
}

View File

@@ -105,22 +105,10 @@ fn gamma_correction<T: Adjust<Color>>(
input
}
#[node_macro::node(category("Raster: Channels"), shader_node(PerPixelAdjust))]
fn extract_channel<T: Adjust<Color>>(
_: impl Ctx,
#[implementations(
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| {
/// Extracts one color channel as a grayscale image. Used internally by the `split_channels` node.
#[cfg(feature = "std")]
fn extract_channel<T: Adjust<Color>>(mut input: T, channel: RedGreenBlueAlpha) -> T {
input.adjust(|color| {
let extracted_value = match channel {
RedGreenBlueAlpha::Red => color.r(),
RedGreenBlueAlpha::Green => color.g(),
@@ -132,6 +120,37 @@ fn extract_channel<T: Adjust<Color>>(
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))]
fn make_opaque<T: Adjust<Color>>(
_: impl Ctx,

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)),

View File

@@ -1974,48 +1974,22 @@ async fn cut_segments(_: impl Ctx, content: Item<Vector>) -> Item<Vector> {
content
}
/// 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<Vector>,
/// The factor from the start to the end of the path, 01 for one subpath, 12 for a second subpath, and so on.
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)
/// The position and tangent angle at a point along a path, split into separate node outputs.
#[node_macro::destructure]
#[derive(Debug, Clone, Copy, PartialEq, dyn_any::DynAny)]
pub struct PathEvaluation {
/// The position of the point on the path.
#[primary]
position: DVec2,
/// The angle of the tangent at the point on the path.
tangent: f64,
}
/// 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<Vector>,
@@ -2025,9 +1999,9 @@ async fn tangent_on_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>,
/// 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>,
) -> Item<f64> {
) -> 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;
@@ -2038,25 +2012,31 @@ 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 Item::new_from_element(PathEvaluation { position: DVec2::ZERO, tangent: 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 tangent == DVec2::ZERO {
return 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()));
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 })
});
};
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)]
@@ -2527,7 +2507,7 @@ async fn morph<I: IntoGraphicList>(
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 };