From 104b8b71e6664203fa50e8b92d91cbdf10e7647b Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sun, 16 Aug 2026 19:51:58 -0700 Subject: [PATCH] Support embedding whole items into Graphic variants so their attributes survive the conversion (#4440) Add embedding conversion adapters that move a whole ranked value inside a Graphic variant --- .../src/dynamic_executor/test.rs | 38 +++++++++++ .../interpreted-executor/src/node_registry.rs | 20 ++++++ .../libraries/graphic-types/src/graphic.rs | 64 +++++++++++++++++-- node-graph/nodes/gcore/src/ops.rs | 13 ++++ node-graph/nodes/vector/src/vector_nodes.rs | 8 +-- 5 files changed, 132 insertions(+), 11 deletions(-) diff --git a/node-graph/interpreted-executor/src/dynamic_executor/test.rs b/node-graph/interpreted-executor/src/dynamic_executor/test.rs index 6e5f07c9b4..a2c90d8e04 100644 --- a/node-graph/interpreted-executor/src/dynamic_executor/test.rs +++ b/node-graph/interpreted-executor/src/dynamic_executor/test.rs @@ -372,6 +372,44 @@ fn color_list_wraps_through_the_colors_to_gradient_node() { assert_eq!(gradient.element().len(), 1, "The single color should become the gradient's one stop"); } +// A paint wire feeding a `Graphic` connector embeds whole, so the gradient's own attributes stay on the item inside the variant +#[test] +fn gradient_value_embeds_through_the_graphic_input_adapter() { + use core_types::ATTR_GRADIENT_SPREAD; + use graphene_std::Graphic; + use graphene_std::vector::{Gradient, GradientRamp, GradientSpread}; + + let ramp = GradientRamp { + gradient_spread: GradientSpread::Reflect, + ..GradientRamp::from(Gradient::default()) + }; + let gradient_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::GradientRamp(ramp).into()), vec![NodeId(0)]); + + let mut input_adapter_node = ProtoNode::value(ConstructionArgs::Nodes(vec![NodeId(0)]), vec![NodeId(1)]); + input_adapter_node.identifier = ProtoNodeIdentifier::new("input_adapter"); + + let network = ProtoNetwork { + inputs: vec![], + output: NodeId(1), + nodes: vec![(NodeId(0), gradient_node), (NodeId(1), input_adapter_node)], + }; + let mut typing_context = TypingContext::new(&crate::node_registry::NODE_REGISTRY); + typing_context.update(&network).expect("An Item wire should resolve the adapter's embedding row"); + let tree = futures::executor::block_on(BorrowTree::new(network, &typing_context)).expect("The embedding constructor should instantiate"); + + let context: Context = None; + let result: Option> = futures::executor::block_on(tree.eval(NodeId(1), context)); + let embedded = result.expect("The gradient should arrive as an Item"); + + assert!(embedded.attributes().iter_any().next().is_none(), "The fresh outer envelope describes the graphic, so it starts empty"); + let Graphic::Gradient(inner) = embedded.element() else { panic!("expected a gradient graphic") }; + assert_eq!( + inner.attribute::(ATTR_GRADIENT_SPREAD), + Some(&GradientSpread::Reflect), + "The gradient's placement attributes should ride the item inside the variant, where the renderer reads them" + ); +} + // A scalar wire feeding a `DVec2` connector splats into both axes through the input adapter's `Convert` row #[test] fn number_value_splats_through_the_vec2_input_adapter() { diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 48d7ad02db..da018f3ab5 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -499,6 +499,26 @@ fn node_registry() -> HashMap` connector, each number becoming a uniform radius for all four corners node_types.extend(input_adapter_row!(from_element: f64, element: BoxCorners)); + // The embedding counterpart of `input_adapter_row!`, for element types like `Graphic` whose variants embed/wrap whole ranked values. + // Each item moves inside its matching variant, attributes and all, rather than mapping only its element. + macro_rules! embed_adapter_row { + (from_element: $from:ty, element: $element:ty) => {{ + let entries: Vec<(ProtoNodeIdentifier, NodeConstructor, NodeIOTypes)> = vec![ + input_adapter_row!(node: EmbedItemNode, from: Item<$from>, to: Item<$element>, element: $element), + input_adapter_row!(node: EmbedListNode, from: List<$from>, to: List<$element>, element: $element), + ]; + entries + }}; + } + // Any paintable wire may feed a ranked `Item` connector, each item embedding as its matching `Graphic` variant. + // This is what lets a paint list zip element-wise against the content it paints. + node_types.extend(embed_adapter_row!(from_element: Color, element: Graphic)); + node_types.extend(embed_adapter_row!(from_element: Gradient, element: Graphic)); + node_types.extend(embed_adapter_row!(from_element: String, element: Graphic)); + node_types.extend(embed_adapter_row!(from_element: Vector, element: Graphic)); + node_types.extend(embed_adapter_row!(from_element: Raster, element: Graphic)); + #[cfg(feature = "gpu")] + node_types.extend(embed_adapter_row!(from_element: Raster, element: Graphic)); // The `Convert`-based counterpart of `input_adapter_row!`, for casts the std `Into` trait cannot express macro_rules! convert_adapter_node { (from_element: $from:ty, element: $element:ty) => {{ diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index e78630f7dd..8f090133a1 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -52,7 +52,12 @@ impl From> for Graphic { // Vector impl From for Graphic { fn from(vector: Vector) -> Self { - Graphic::VectorList(List::new_from_element(vector)) + Graphic::Vector(Box::new(Item::new_from_element(vector))) + } +} +impl From> for Graphic { + fn from(vector: Item) -> Self { + Graphic::Vector(Box::new(vector)) } } impl From> for Graphic { @@ -66,7 +71,12 @@ impl From> for Graphic { // Raster impl From> for Graphic { fn from(raster: Raster) -> Self { - Graphic::RasterCPUList(List::new_from_element(raster)) + Graphic::RasterCPU(Box::new(Item::new_from_element(raster))) + } +} +impl From>> for Graphic { + fn from(raster: Item>) -> Self { + Graphic::RasterCPU(Box::new(raster)) } } impl From>> for Graphic { @@ -79,7 +89,12 @@ impl From>> for Graphic { // Raster impl From> for Graphic { fn from(raster: Raster) -> Self { - Graphic::RasterGPUList(List::new_from_element(raster)) + Graphic::RasterGPU(Item::new_from_element(raster)) + } +} +impl From>> for Graphic { + fn from(raster: Item>) -> Self { + Graphic::RasterGPU(raster) } } impl From>> for Graphic { @@ -92,7 +107,12 @@ impl From>> for Graphic { // Color impl From for Graphic { fn from(color: Color) -> Self { - Graphic::ColorList(List::new_from_element(color)) + Graphic::Color(Item::new_from_element(color)) + } +} +impl From> for Graphic { + fn from(color: Item) -> Self { + Graphic::Color(color) } } impl From> for Graphic { @@ -106,7 +126,12 @@ impl From> for Graphic { // Gradient impl From for Graphic { fn from(gradient: Gradient) -> Self { - Graphic::GradientList(List::new_from_element(gradient)) + Graphic::Gradient(Item::new_from_element(gradient)) + } +} +impl From> for Graphic { + fn from(gradient: Item) -> Self { + Graphic::Gradient(gradient) } } impl From> for Graphic { @@ -118,7 +143,12 @@ impl From> for Graphic { // String impl From for Graphic { fn from(text: String) -> Self { - Graphic::TextList(List::new_from_element(text)) + Graphic::Text(Item::new_from_element(text)) + } +} +impl From> for Graphic { + fn from(text: Item) -> Self { + Graphic::Text(text) } } impl From> for Graphic { @@ -877,7 +907,7 @@ impl OmitIndex for List { #[cfg(test)] mod tests { use super::*; - use core_types::list::List; + use core_types::list::{ATTR_POSITION, List}; use core_types::uuid::NodeId; fn vector_graphic() -> Graphic { @@ -974,6 +1004,26 @@ mod tests { assert_eq!(color_of(0), Some(Color::BLACK), "a declared item should keep its own appearance"); assert_eq!(color_of(1), Some(Color::WHITE), "a padded item should inherit the parent appearance"); } + + #[test] + fn embedded_item_keeps_its_attributes_inside_the_variant() { + let color = Item::new_from_element(Color::RED).with_attribute(ATTR_POSITION, 0.25_f64); + + let Graphic::Color(inner) = Graphic::from(color) else { panic!("expected a color graphic") }; + assert_eq!(inner.element(), &Color::RED); + assert_eq!(inner.attribute::(ATTR_POSITION), Some(&0.25)); + } + + #[test] + fn embedded_list_becomes_one_graphic_holding_every_element() { + let mut colors = List::new_from_element(Color::RED); + colors.push(Item::new_from_element(Color::BLUE)); + + let Graphic::ColorList(inner) = Graphic::from(colors) else { + panic!("expected a color list graphic") + }; + assert_eq!(inner.len(), 2, "a whole list embeds as one graphic holding all its elements"); + } } #[cfg(test)] diff --git a/node-graph/nodes/gcore/src/ops.rs b/node-graph/nodes/gcore/src/ops.rs index 1fbd1ef31b..402f2d7e39 100644 --- a/node-graph/nodes/gcore/src/ops.rs +++ b/node-graph/nodes/gcore/src/ops.rs @@ -77,6 +77,19 @@ fn into_list<'i, T: 'i + Send + Into, E: 'i + Send>(_: impl Ctx, value: List< .collect() } +/// Moves a whole `Item` wire inside an element type like `Graphic` that embeds ranked values as its variants. +/// The attributes describe the element they arrived with, so they travel inside it and the fresh envelope starts empty. +#[node_macro::node(category(""), skip_impl)] +fn embed_item<'i, T: 'i + Send, E: 'i + Send + From>>(_: impl Ctx, value: Item, _element_ty: PhantomData) -> Item { + Item::new_from_element(value.into()) +} + +/// The `List` counterpart of `embed_item`, each item moving whole inside its own embedding element. +#[node_macro::node(category(""), skip_impl)] +fn embed_list<'i, T: 'i + Send, E: 'i + Send + From>>(_: impl Ctx, value: List, _element_ty: PhantomData) -> List { + value.into_iter().map(|item| Item::new_from_element(E::from(item))).collect() +} + /// The [`Convert`]-based counterpart of `into_item`, casting an `Item` wire's element to a connector's numeric element type. #[node_macro::node(category(""), skip_impl)] async fn convert_item<'i, T: 'i + Send + Convert, E: 'i + Send>(ctx: impl Ctx + ExtractFootprint, value: Item, _element_ty: PhantomData) -> Item { diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 36bb15b337..ad06877100 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -3975,10 +3975,10 @@ mod test { let fill = appearance.first_paint_of(Cover::Fill).expect("Morph should keep the fill paint at the midpoint"); // Interpolated color between red and blue should have >0 value on both R and B - let Graphic::ColorList(colors) = fill else { + let Graphic::Color(color) = fill else { panic!("Expected a solid color fill, got {fill:?}"); }; - let color = *colors.element(0).expect("Color present"); + let color = *color.element(); assert!(color.r() > 0. && color.b() > 0., "Fill should be a red-to-blue blend, got {color:?}"); } @@ -3992,10 +3992,10 @@ mod test { let paint_color = |appearance: &Appearance, cover| { let paint = appearance.first_paint_of(cover).expect("Morph should keep both paints at the midpoint"); - let Graphic::ColorList(colors) = paint else { + let Graphic::Color(color) = paint else { panic!("Expected a solid color paint, got {paint:?}"); }; - *colors.element(0).expect("Color present") + *color.element() }; // The two endpoints list their covers in opposite paint orders, which pairing by position would cross