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-16 23:48:43 -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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -154,14 +154,14 @@ fn document_node_definitions() -> HashMap<DefinitionIdentifier, DocumentNodeDefi
// Primary (bottom) input type coercion
NodeTemplate {
inputs: vec![NodeInput::import(generic!(T), 0)],
implementation: NodeTemplateImplementation::ProtoNode(graphic::to_graphic::IDENTIFIER),
node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(-21, -3)),
implementation: NodeTemplateImplementation::ProtoNode(graphic::as_graphic::IDENTIFIER),
node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(-21, -2)),
..Default::default()
},
// Secondary (left) input type coercion
NodeTemplate {
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)),
..Default::default()
},
@@ -169,7 +169,7 @@ fn document_node_definitions() -> HashMap<DefinitionIdentifier, DocumentNodeDefi
NodeTemplate {
inputs: vec![NodeInput::Reflection(graph_craft::document::DocumentNodeMetadata::DocumentNodePath)],
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()
},
// 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),
inputs: vec![NodeInput::node(NodeId(0), 0), NodeInput::node(NodeId(4), 0)],
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()
},
]

View File

@@ -45,6 +45,14 @@ pub struct NodeReplacement<'a> {
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>] = &[
// ================================
// blending
@@ -194,22 +202,20 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[
],
},
NodeReplacement {
node: graphene_std::graphic::to_graphic::IDENTIFIER,
node: graphene_std::graphic::into_group::IDENTIFIER,
aliases: &[
"graphene_core::ToGraphicGroupNode",
"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"
// Converted from "To Element", then "Wrap Graphic"
"graphene_core::ToGraphicElementNode",
"graphene_core::graphic_element::ToElementNode",
"graphene_core::graphic_types::ToElementNode",
"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;
}
// 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
}
@@ -2958,6 +2970,20 @@ mod tests {
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]
fn test_no_duplicate_node_replacements() {
let mut hashmap = HashMap::<ProtoNodeIdentifier, u32>::new();

View File

@@ -74,14 +74,14 @@ fn item_wire_promotes_to_list_connector() {
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
// wrapped `Item<Graphic>` raises again at Extend's `List` connector, so layers accept rank-0 chains without new machinery
// The layer content path: a rank-0 content wire enters Into Group's `List` connector by singleton raise, and the
// grouped `Item<Graphic>` raises again at Extend's `List` connector, so layers accept rank-0 chains without new machinery
#[test]
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 mut wrap_graphic_node = ProtoNode::value(ConstructionArgs::Nodes(vec![NodeId(0)]), vec![NodeId(1)]);
wrap_graphic_node.identifier = ProtoNodeIdentifier::new("graphic_nodes::graphic::WrapGraphicNode");
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 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 {
inputs: vec![],
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);
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(3)).is_some(), "The wrapped Item<Graphic> should be raised at Extend'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 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 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");
}
// '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
#[test]
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");
}
// 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
#[test]
fn gradient_value_embeds_through_the_graphic_input_adapter() {

View File

@@ -437,6 +437,7 @@ impl IntoGraphicList for List<String> {
}
}
// TODO: Remove this
impl IntoGraphicList for Item<DAffine2> {
fn into_graphic_list(self) -> List<Graphic> {
List::new_from_element(Graphic::default())
@@ -444,6 +445,7 @@ impl IntoGraphicList for Item<DAffine2> {
}
// DAffine2
// TODO: Remove this
impl From<Item<DAffine2>> for Graphic {
fn from(_: Item<DAffine2>) -> Self {
Graphic::default()
@@ -453,7 +455,20 @@ impl From<Item<DAffine2>> for Graphic {
// DVec2
impl From<Item<DVec2>> for Graphic {
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

View File

@@ -20,7 +20,7 @@ pub async fn create_artboard<T: IntoGraphicList>(
Context -> List<Raster<GPU>>,
Context -> List<Color>,
Context -> List<Gradient>,
Context -> Item<DAffine2>,
Context -> Item<DAffine2>, // TODO: Remove this
)]
content: impl Node<Context<'static>, Output = T>,
/// Coordinate of the top-left corner of the artboard within the document.

View File

@@ -927,10 +927,10 @@ pub async fn legacy_layer_extend<T: 'n + Send + Clone>(
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'.
#[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,
#[implementations(
List<Graphic>,
@@ -940,31 +940,19 @@ pub async fn wrap_graphic<T: Into<Graphic> + 'n>(
List<Color>,
List<Gradient>,
List<String>,
Item<DAffine2>,
Item<DVec2>,
List<DVec2>,
Item<DAffine2>, // TODO: Remove this
)]
content: T,
) -> Item<Graphic> {
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[]`.
/// If it is already a `Graphic[]`, it is not wrapped again. Use the 'Wrap Graphic' node if wrapping is always desired.
/// Type-asserts a value to be graphical content, converting each item of other content types into its matching form.
/// Use the 'Into Group' node instead to collect the content into a single group.
#[node_macro::node(category("General"))]
pub async fn to_graphic<T: IntoGraphicList>(
_: impl Ctx,
#[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()
pub async fn as_graphic(_: impl Ctx, value: Item<Graphic>) -> Item<Graphic> {
value
}
/// Removes a level of nesting from a `Graphic[]`, or all nesting if "Fully Flatten" is enabled.