Split 'To Graphic' and 'Wrap Graphic' into the 'As Graphic' type assertion and 'Into Group' reducer nodes (#4441)

* Split 'To Graphic' and 'Wrap Graphic' into the 'As Graphic' type assertion and 'Into Group' reducer nodes

* Make 'Into Group' reduce List<DVec2> not Item<DVec2>

* Reset the Merge definition for every legacy alias of its coercion nodes

* Improve test
This commit is contained in:
Keavon Chambers
2026-08-17 02:19:59 -07:00
parent 104b8b71e6
commit d63362718f
14 changed files with 162 additions and 53 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -154,14 +154,14 @@ fn document_node_definitions() -> HashMap<DefinitionIdentifier, DocumentNodeDefi
// Primary (bottom) input type coercion // Primary (bottom) input type coercion
NodeTemplate { NodeTemplate {
inputs: vec![NodeInput::import(generic!(T), 0)], inputs: vec![NodeInput::import(generic!(T), 0)],
implementation: NodeTemplateImplementation::ProtoNode(graphic::to_graphic::IDENTIFIER), implementation: NodeTemplateImplementation::ProtoNode(graphic::as_graphic::IDENTIFIER),
node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(-21, -3)), node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(-21, -2)),
..Default::default() ..Default::default()
}, },
// Secondary (left) input type coercion // Secondary (left) input type coercion
NodeTemplate { NodeTemplate {
inputs: vec![NodeInput::import(generic!(T), 1)], inputs: vec![NodeInput::import(generic!(T), 1)],
implementation: NodeTemplateImplementation::ProtoNode(graphic::wrap_graphic::IDENTIFIER), implementation: NodeTemplateImplementation::ProtoNode(graphic::into_group::IDENTIFIER),
node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(-21, -1)), node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(-21, -1)),
..Default::default() ..Default::default()
}, },
@@ -169,7 +169,7 @@ fn document_node_definitions() -> HashMap<DefinitionIdentifier, DocumentNodeDefi
NodeTemplate { NodeTemplate {
inputs: vec![NodeInput::Reflection(graph_craft::document::DocumentNodeMetadata::DocumentNodePath)], inputs: vec![NodeInput::Reflection(graph_craft::document::DocumentNodeMetadata::DocumentNodePath)],
implementation: NodeTemplateImplementation::ProtoNode(graphic::path_of_subgraph::IDENTIFIER), implementation: NodeTemplateImplementation::ProtoNode(graphic::path_of_subgraph::IDENTIFIER),
node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(-21, 1)), node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(-21, 0)),
..Default::default() ..Default::default()
}, },
// Stamp each item of the content with the parent layer's NodeId via the `editor:layer_path` attribute, // Stamp each item of the content with the parent layer's NodeId via the `editor:layer_path` attribute,
@@ -196,7 +196,7 @@ fn document_node_definitions() -> HashMap<DefinitionIdentifier, DocumentNodeDefi
call_argument: generic!(T), call_argument: generic!(T),
inputs: vec![NodeInput::node(NodeId(0), 0), NodeInput::node(NodeId(4), 0)], inputs: vec![NodeInput::node(NodeId(0), 0), NodeInput::node(NodeId(4), 0)],
implementation: NodeTemplateImplementation::ProtoNode(graphic::extend::IDENTIFIER), implementation: NodeTemplateImplementation::ProtoNode(graphic::extend::IDENTIFIER),
node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(0, -3)), node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(0, -2)),
..Default::default() ..Default::default()
}, },
] ]
@@ -45,6 +45,14 @@ pub struct NodeReplacement<'a> {
aliases: &'a [&'a str], aliases: &'a [&'a str],
} }
/// Every name the Merge layer network's two type-coercion nodes have gone by, which is every alias of the node they both converged on.
fn into_group_aliases() -> impl Iterator<Item = &'static &'static str> {
NODE_REPLACEMENTS
.iter()
.filter(|replacement| replacement.node == graphene_std::graphic::into_group::IDENTIFIER)
.flat_map(|replacement| replacement.aliases)
}
const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[
// ================================ // ================================
// blending // blending
@@ -194,22 +202,20 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[
], ],
}, },
NodeReplacement { NodeReplacement {
node: graphene_std::graphic::to_graphic::IDENTIFIER, node: graphene_std::graphic::into_group::IDENTIFIER,
aliases: &[ aliases: &[
"graphene_core::ToGraphicGroupNode", // Converted from "To Element", then "Wrap Graphic"
"graphene_core::graphic_element::ToGroupNode",
"graphene_core::graphic_types::ToGroupNode",
"graphene_core::graphic::ToGraphicNode",
],
},
NodeReplacement {
node: graphene_std::graphic::wrap_graphic::IDENTIFIER,
aliases: &[
// Converted from "To Element"
"graphene_core::ToGraphicElementNode", "graphene_core::ToGraphicElementNode",
"graphene_core::graphic_element::ToElementNode", "graphene_core::graphic_element::ToElementNode",
"graphene_core::graphic_types::ToElementNode", "graphene_core::graphic_types::ToElementNode",
"graphene_core::graphic::WrapGraphicNode", "graphene_core::graphic::WrapGraphicNode",
"graphic_nodes::graphic::WrapGraphicNode",
// Converted from "To Graphic", whose grouping of non-graphical content this node now carries alone
"graphene_core::ToGraphicGroupNode",
"graphene_core::graphic_element::ToGroupNode",
"graphene_core::graphic_types::ToGroupNode",
"graphene_core::graphic::ToGraphicNode",
"graphic_nodes::graphic::ToGraphicNode",
], ],
}, },
// ================================ // ================================
@@ -1071,6 +1077,12 @@ pub fn document_migration_reset_node_definition(document_serialized_content: &st
return true; return true;
} }
// Every Merge layer network is built from the two nodes that became "As Graphic" and "Into Group", so their definitions
// are reset to pick up the current plumbing instead of the alias migration meant for standalone copies of those nodes.
if into_group_aliases().any(|alias| document_serialized_content.contains(alias)) {
return true;
}
false false
} }
@@ -2958,6 +2970,20 @@ mod tests {
assert!(resolve_proto_node_type(graphene_std::platform_application_io::upload_texture::IDENTIFIER).is_some()); assert!(resolve_proto_node_type(graphene_std::platform_application_io::upload_texture::IDENTIFIER).is_some());
} }
// Migrating a Merge network's coercion nodes by alias would leave a reducer in the primary slot, so every alias must reset instead
#[test]
fn every_into_group_alias_resets_the_merge_definition() {
let aliases = into_group_aliases().collect::<Vec<_>>();
assert!(!aliases.is_empty(), "the reset is driven by these aliases, so losing them all would disable it unnoticed");
for alias in aliases {
assert!(
document_migration_reset_node_definition(&format!(r#""implementation":{{"ProtoNode":"{alias}"}}"#)),
"a document referencing `{alias}` should reset its layer definitions"
);
}
}
#[test] #[test]
fn test_no_duplicate_node_replacements() { fn test_no_duplicate_node_replacements() {
let mut hashmap = HashMap::<ProtoNodeIdentifier, u32>::new(); let mut hashmap = HashMap::<ProtoNodeIdentifier, u32>::new();
@@ -74,14 +74,14 @@ fn item_wire_promotes_to_list_connector() {
assert!(result.is_some(), "The promoted wire should execute end-to-end"); assert!(result.is_some(), "The promoted wire should execute end-to-end");
} }
// The layer content path: a rank-0 content wire enters Wrap Graphic's `List` connector by singleton raise, and the // The layer content path: a rank-0 content wire enters Into Group's `List` connector by singleton raise, and the
// wrapped `Item<Graphic>` raises again at Extend's `List` connector, so layers accept rank-0 chains without new machinery // grouped `Item<Graphic>` raises again at Extend's `List` connector, so layers accept rank-0 chains without new machinery
#[test] #[test]
fn rank_0_content_promotes_through_the_layer_coercion_path() { fn rank_0_content_promotes_through_the_layer_coercion_path() {
let content_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::TypeDefault(item!(Vector)).into()), vec![NodeId(0)]); let content_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::TypeDefault(item!(Vector)).into()), vec![NodeId(0)]);
let mut wrap_graphic_node = ProtoNode::value(ConstructionArgs::Nodes(vec![NodeId(0)]), vec![NodeId(1)]); let mut into_group_node = ProtoNode::value(ConstructionArgs::Nodes(vec![NodeId(0)]), vec![NodeId(1)]);
wrap_graphic_node.identifier = ProtoNodeIdentifier::new("graphic_nodes::graphic::WrapGraphicNode"); into_group_node.identifier = ProtoNodeIdentifier::new("graphic_nodes::graphic::IntoGroupNode");
let base_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::TypeDefault(list!(graphene_std::Graphic)).into()), vec![NodeId(2)]); let base_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::TypeDefault(list!(graphene_std::Graphic)).into()), vec![NodeId(2)]);
@@ -91,12 +91,12 @@ fn rank_0_content_promotes_through_the_layer_coercion_path() {
let network = ProtoNetwork { let network = ProtoNetwork {
inputs: vec![], inputs: vec![],
output: NodeId(3), output: NodeId(3),
nodes: vec![(NodeId(0), content_node), (NodeId(1), wrap_graphic_node), (NodeId(2), base_node), (NodeId(3), extend_node)], nodes: vec![(NodeId(0), content_node), (NodeId(1), into_group_node), (NodeId(2), base_node), (NodeId(3), extend_node)],
}; };
let mut typing_context = TypingContext::new(&crate::node_registry::NODE_REGISTRY); let mut typing_context = TypingContext::new(&crate::node_registry::NODE_REGISTRY);
typing_context.update(&network).expect("A rank-0 content wire should resolve the layer coercion path via promotion"); typing_context.update(&network).expect("A rank-0 content wire should resolve the layer coercion path via promotion");
assert!(typing_context.promotions(NodeId(1)).is_some(), "The rank-0 content should be raised at Wrap Graphic's List connector"); assert!(typing_context.promotions(NodeId(1)).is_some(), "The rank-0 content should be raised at Into Group's List connector");
assert!(typing_context.promotions(NodeId(3)).is_some(), "The wrapped Item<Graphic> should be raised at Extend's List connector"); assert!(typing_context.promotions(NodeId(3)).is_some(), "The grouped Item<Graphic> should be raised at Extend's List connector");
let tree = futures::executor::block_on(BorrowTree::new(network, &typing_context)).expect("The promotion adapters should instantiate"); let tree = futures::executor::block_on(BorrowTree::new(network, &typing_context)).expect("The promotion adapters should instantiate");
let context: Context = None; let context: Context = None;
@@ -346,6 +346,36 @@ fn position_value_converts_through_the_vector_input_adapter() {
assert!(result.is_some(), "The position should arrive as an Item<Vector> single-anchor path"); assert!(result.is_some(), "The position should arrive as an Item<Vector> single-anchor path");
} }
// 'Into Group' reduces whole lists, so a rank-0 position reaches it by singleton raise rather than a row of its own
#[test]
fn position_value_raises_into_the_into_group_reducer() {
use graphene_std::Graphic;
let position_node = ProtoNode::value(ConstructionArgs::Value(TaggedValue::DVec2(glam::DVec2::new(3., 4.)).into()), vec![NodeId(0)]);
let mut into_group_node = ProtoNode::value(ConstructionArgs::Nodes(vec![NodeId(0)]), vec![NodeId(1)]);
into_group_node.identifier = ProtoNodeIdentifier::new("graphic_nodes::graphic::IntoGroupNode");
let network = ProtoNetwork {
inputs: vec![],
output: NodeId(1),
nodes: vec![(NodeId(0), position_node), (NodeId(1), into_group_node)],
};
let mut typing_context = TypingContext::new(&crate::node_registry::NODE_REGISTRY);
typing_context.update(&network).expect("An Item<DVec2> wire should raise into Into Group's List<DVec2> row");
assert!(typing_context.promotions(NodeId(1)).is_some(), "The rank-0 position should be raised at Into Group's List connector");
let tree = futures::executor::block_on(BorrowTree::new(network, &typing_context)).expect("The reducer constructor should instantiate");
let context: Context = None;
let result: Option<Item<Graphic>> = futures::executor::block_on(tree.eval(NodeId(1), context));
let grouped = result.expect("The position should arrive as an Item<Graphic>");
let Graphic::VectorList(anchors) = grouped.element() else {
panic!("expected a vector list graphic")
};
assert_eq!(anchors.len(), 1, "The single position should group as one anchor point");
}
// The 'Colors to Gradient' node turns an entire `List<Color>` wire into one gradient with those colors as its stops // The 'Colors to Gradient' node turns an entire `List<Color>` wire into one gradient with those colors as its stops
#[test] #[test]
fn color_list_wraps_through_the_colors_to_gradient_node() { fn color_list_wraps_through_the_colors_to_gradient_node() {
@@ -372,6 +402,56 @@ 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"); assert_eq!(gradient.element().len(), 1, "The single color should become the gradient's one stop");
} }
// As Graphic asserts the type without changing rank, so a vector list arrives as one graphic per item rather than one group
#[test]
fn as_graphic_converts_a_vector_list_element_wise() {
use graphene_std::Graphic;
// Two radii frame the Circle generator into a two-element `List<Vector>`, the wire As Graphic then converts
let primary = ProtoNode::value(ConstructionArgs::Value(TaggedValue::None.into()), vec![NodeId(0)]);
let radii = ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64Array(vec![10., 20.]).into()), vec![NodeId(1)]);
let mut radius_adapter = ProtoNode::value(ConstructionArgs::Nodes(vec![NodeId(1)]), vec![NodeId(2)]);
radius_adapter.identifier = ProtoNodeIdentifier::new("input_adapter<f64>");
let mut circle_node = ProtoNode::value(ConstructionArgs::Nodes(vec![NodeId(0), NodeId(2)]), vec![NodeId(3)]);
circle_node.identifier = graphene_std::vector_nodes::circle::IDENTIFIER;
let mut graphic_adapter = ProtoNode::value(ConstructionArgs::Nodes(vec![NodeId(3)]), vec![NodeId(4)]);
graphic_adapter.identifier = ProtoNodeIdentifier::new("input_adapter<Graphic>");
let mut as_graphic_node = ProtoNode::value(ConstructionArgs::Nodes(vec![NodeId(4)]), vec![NodeId(5)]);
as_graphic_node.identifier = ProtoNodeIdentifier::new("graphic_nodes::graphic::AsGraphicNode");
let network = ProtoNetwork {
inputs: vec![],
output: NodeId(5),
nodes: vec![
(NodeId(0), primary),
(NodeId(1), radii),
(NodeId(2), radius_adapter),
(NodeId(3), circle_node),
(NodeId(4), graphic_adapter),
(NodeId(5), as_graphic_node),
],
};
let mut typing_context = TypingContext::new(&crate::node_registry::NODE_REGISTRY);
typing_context
.update(&network)
.expect("A List<Vector> wire should resolve As Graphic's mapped variant through the embedding adapter");
let tree = futures::executor::block_on(BorrowTree::new(network, &typing_context)).expect("The mapped constructor should instantiate");
let context: Context = None;
let result: Option<List<Graphic>> = futures::executor::block_on(tree.eval(NodeId(5), context));
let graphics = result.expect("The vector list should arrive as a graphic list");
assert_eq!(graphics.len(), 2, "Each vector should become its own graphic rather than collapsing into one group");
assert!(
graphics.iter_element_values().all(|graphic| matches!(graphic, Graphic::Vector(_))),
"Each element should embed as the rank-0 vector variant that mirrors its wire"
);
}
// A paint wire feeding a `Graphic` connector embeds whole, so the gradient's own attributes stay on the item inside the variant // A paint wire feeding a `Graphic` connector embeds whole, so the gradient's own attributes stay on the item inside the variant
#[test] #[test]
fn gradient_value_embeds_through_the_graphic_input_adapter() { fn gradient_value_embeds_through_the_graphic_input_adapter() {
@@ -437,6 +437,7 @@ impl IntoGraphicList for List<String> {
} }
} }
// TODO: Remove this
impl IntoGraphicList for Item<DAffine2> { impl IntoGraphicList for Item<DAffine2> {
fn into_graphic_list(self) -> List<Graphic> { fn into_graphic_list(self) -> List<Graphic> {
List::new_from_element(Graphic::default()) List::new_from_element(Graphic::default())
@@ -444,6 +445,7 @@ impl IntoGraphicList for Item<DAffine2> {
} }
// DAffine2 // DAffine2
// TODO: Remove this
impl From<Item<DAffine2>> for Graphic { impl From<Item<DAffine2>> for Graphic {
fn from(_: Item<DAffine2>) -> Self { fn from(_: Item<DAffine2>) -> Self {
Graphic::default() Graphic::default()
@@ -453,7 +455,20 @@ impl From<Item<DAffine2>> for Graphic {
// DVec2 // DVec2
impl From<Item<DVec2>> for Graphic { impl From<Item<DVec2>> for Graphic {
fn from(position: Item<DVec2>) -> Self { fn from(position: Item<DVec2>) -> Self {
Graphic::VectorList(List::new_from_element(Vector::from_anchor_position(position.into_element()))) let (position, attributes) = position.into_parts();
Graphic::Vector(Box::new(Item::from_parts(Vector::from_anchor_position(position), attributes)))
}
}
impl From<List<DVec2>> for Graphic {
fn from(positions: List<DVec2>) -> Self {
let anchors = positions.into_iter().map(|position| {
let (position, attributes) = position.into_parts();
Item::from_parts(Vector::from_anchor_position(position), attributes)
});
Graphic::VectorList(anchors.collect())
} }
} }
// Note: List conversions handled by blanket impl in gcore // Note: List conversions handled by blanket impl in gcore
+1 -1
View File
@@ -20,7 +20,7 @@ pub async fn create_artboard<T: IntoGraphicList>(
Context -> List<Raster<GPU>>, Context -> List<Raster<GPU>>,
Context -> List<Color>, Context -> List<Color>,
Context -> List<Gradient>, Context -> List<Gradient>,
Context -> Item<DAffine2>, Context -> Item<DAffine2>, // TODO: Remove this
)] )]
content: impl Node<Context<'static>, Output = T>, content: impl Node<Context<'static>, Output = T>,
/// Coordinate of the top-left corner of the artboard within the document. /// Coordinate of the top-left corner of the artboard within the document.
+8 -20
View File
@@ -927,10 +927,10 @@ pub async fn legacy_layer_extend<T: 'n + Send + Clone>(
base base
} }
/// Nests the input graphical content in a wrapper graphic. This essentially "groups" the input. /// Nests the input graphical content in a wrapper graphic, collecting it all into a single group.
/// The inverse of this node is 'Flatten Graphic'. /// The inverse of this node is 'Flatten Graphic'.
#[node_macro::node(category("General"))] #[node_macro::node(category("General"))]
pub async fn wrap_graphic<T: Into<Graphic> + 'n>( pub async fn into_group<T: Into<Graphic> + 'n>(
_: impl Ctx, _: impl Ctx,
#[implementations( #[implementations(
List<Graphic>, List<Graphic>,
@@ -940,31 +940,19 @@ pub async fn wrap_graphic<T: Into<Graphic> + 'n>(
List<Color>, List<Color>,
List<Gradient>, List<Gradient>,
List<String>, List<String>,
Item<DAffine2>, List<DVec2>,
Item<DVec2>, Item<DAffine2>, // TODO: Remove this
)] )]
content: T, content: T,
) -> Item<Graphic> { ) -> Item<Graphic> {
Item::new_from_element(content.into()) Item::new_from_element(content.into())
} }
/// Converts a list of graphical content into a `Graphic[]` by placing it into an element of a new wrapper `Graphic[]`. /// Type-asserts a value to be graphical content, converting each item of other content types into its matching form.
/// If it is already a `Graphic[]`, it is not wrapped again. Use the 'Wrap Graphic' node if wrapping is always desired. /// Use the 'Into Group' node instead to collect the content into a single group.
#[node_macro::node(category("General"))] #[node_macro::node(category("General"))]
pub async fn to_graphic<T: IntoGraphicList>( pub async fn as_graphic(_: impl Ctx, value: Item<Graphic>) -> Item<Graphic> {
_: impl Ctx, value
#[implementations(
List<Graphic>,
List<Vector>,
List<Raster<CPU>>,
List<Raster<GPU>>,
List<Color>,
List<Gradient>,
List<String>,
)]
content: T,
) -> List<Graphic> {
content.into_graphic_list()
} }
/// Removes a level of nesting from a `Graphic[]`, or all nesting if "Fully Flatten" is enabled. /// Removes a level of nesting from a `Graphic[]`, or all nesting if "Fully Flatten" is enabled.