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

This commit is contained in:
Keavon Chambers
2026-09-15 16:46:03 +02:00
committed by Dennis Kobert
parent f09eb73c08
commit a41fdb3d75
16 changed files with 123 additions and 81 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

@@ -155,15 +155,15 @@ 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()
},
// Collection of the content into the layer's group; the wrap keeps
// Collection of the content into the layer's group; the group keeps
// the content level's element type for the legacy boundary.
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()
},
@@ -171,7 +171,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,
@@ -194,7 +194,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(list::extend::IDENTIFIER),
node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(0, -3)),
node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(0, -2)),
..Default::default()
},
]
@@ -269,7 +269,7 @@ fn document_node_definitions() -> HashMap<DefinitionIdentifier, DocumentNodeDefi
// Content coercion into a graphic level, evaluated within the artboard's footprint
NodeTemplate {
inputs: vec![NodeInput::import(generic!(T), 1)],
implementation: NodeTemplateImplementation::ProtoNode(graphic::to_graphic::IDENTIFIER),
implementation: NodeTemplateImplementation::ProtoNode(graphic::as_graphic::IDENTIFIER),
node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(-35, -3)),
..Default::default()
},

View File

@@ -24,7 +24,9 @@ pub fn load_demo(file_name: &str) -> DocumentMessageHandler {
#[test]
#[ignore = "dev tool: set DEMO_OUT and run explicitly"]
fn migrate_demo_artwork_into_demo_out() {
use crate::messages::portfolio::document_migration::{document_migration_reset_node_definition, document_migration_string_preprocessing, document_migration_upgrades};
use crate::messages::portfolio::document_migration::{
document_migration_reset_layer_definitions, document_migration_reset_node_definition, document_migration_string_preprocessing, document_migration_upgrades,
};
let out_dir = std::env::var("DEMO_OUT").expect("set DEMO_OUT to the output directory");
for entry in std::fs::read_dir("../demo-artwork").unwrap() {
let path = entry.unwrap().path();
@@ -34,8 +36,9 @@ fn migrate_demo_artwork_into_demo_out() {
let content = std::fs::read_to_string(&path).unwrap();
let content = document_migration_string_preprocessing(content);
let reset = document_migration_reset_node_definition(&content);
let reset_layers = document_migration_reset_layer_definitions(&content);
let mut document = DocumentMessageHandler::deserialize_document(&content).unwrap_or_else(|e| panic!("Failed to deserialize {}: {e:?}", path.display()));
document_migration_upgrades(&mut document, reset);
document_migration_upgrades(&mut document, reset, reset_layers);
let out = format!("{out_dir}/{}", path.file_name().unwrap().to_string_lossy());
std::fs::write(out, document.serialize_document()).unwrap();
}

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
@@ -200,22 +208,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",
],
},
// ================================
@@ -1085,6 +1091,21 @@ pub fn document_migration_reset_node_definition(document_serialized_content: &st
false
}
/// Whether the layer networks built from the coercion nodes that became "As Graphic" and "Into Group" need their definitions reset.
///
/// A document still naming either node by an alias stores the pre-split plumbing, which the alias migration alone would mangle:
/// it maps both names onto the reducer, leaving a reducer where the layer wants the assertion. Resetting those two layer
/// definitions installs the current plumbing instead. This stays narrower than a full
/// [`document_migration_reset_node_definition`], whose one-shot input reorderings would re-apply to documents already carrying them.
pub fn document_migration_reset_layer_definitions(document_serialized_content: &str) -> bool {
into_group_aliases().any(|alias| document_serialized_content.contains(alias))
}
/// The layer networks whose definitions [`document_migration_reset_layer_definitions`] resets.
fn is_coercion_layer_definition(reference: &DefinitionIdentifier) -> bool {
matches!(reference, DefinitionIdentifier::Network(name) if name == "Merge" || name == "Artboard")
}
pub fn document_migration_replace_resources_referenced_by_hash(document_serialized_content: String) -> (String, HashMap<ResourceHash, ResourceId>) {
fn collect_resources_referenced_by_hash(s: &str) -> HashMap<ResourceHash, Vec<Range<usize>>> {
let mut out: HashMap<ResourceHash, Vec<Range<usize>>> = HashMap::new();
@@ -1156,7 +1177,7 @@ pub fn document_migration_replace_resources_referenced_by_hash(document_serializ
(out, hash_to_id)
}
pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_node_definitions_on_open: bool) {
pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_node_definitions_on_open: bool, reset_layer_definitions: bool) {
document.network_interface.migrate_path_modify_node();
let network = document.network_interface.document_network().clone();
@@ -1323,7 +1344,7 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_
.map(|(node_id, node, path)| (*node_id, node.clone(), path))
.collect::<Vec<(NodeId, graph_craft::document::DocumentNode, Vec<NodeId>)>>();
for (node_id, node, network_path) in &nodes {
migrate_node(node_id, node, network_path, document, reset_node_definitions_on_open);
migrate_node(node_id, node, network_path, document, reset_node_definitions_on_open, reset_layer_definitions);
}
// The old geometry-producing "Text" node was split into the current "Text" (`String[]`) -> converter pair, which reuses the same proto
@@ -1441,12 +1462,21 @@ fn fold_gradient_spread_into_ramp_input(input: &NodeInput, gradient_spread: Grad
}
}
fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], document: &mut DocumentMessageHandler, reset_node_definitions_on_open: bool) -> Option<()> {
fn migrate_node(
node_id: &NodeId,
node: &DocumentNode,
network_path: &[NodeId],
document: &mut DocumentMessageHandler,
reset_node_definitions_on_open: bool,
reset_layer_definitions: bool,
) -> Option<()> {
// Must run before the reset block below: a node referencing a removed catalog entry would otherwise abort
// `migrate_node` via the `?` on `resolve_document_node_type`, preventing subsequent migration blocks from running.
migrate_removed_catalog_definitions(node_id, node, network_path, document);
if reset_node_definitions_on_open && let Some(reference) = document.network_interface.reference(node_id, network_path) {
if let Some(reference) = document.network_interface.reference(node_id, network_path)
&& (reset_node_definitions_on_open || (reset_layer_definitions && is_coercion_layer_definition(&reference)))
{
let node_definition = resolve_document_node_type(&reference)?;
document.network_interface.replace_implementation(node_id, network_path, &mut node_definition.default_node_template());
@@ -2986,6 +3016,25 @@ mod tests {
));
}
// 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 {
let document = format!(r#""implementation":{{"ProtoNode":"{alias}"}}"#);
assert!(
document_migration_reset_layer_definitions(&document),
"a document referencing `{alias}` should reset its layer definitions"
);
assert!(
!document_migration_reset_node_definition(&document),
"`{alias}` alone must not reset every definition, which would re-apply the one-shot input reorderings"
);
}
}
#[test]
fn test_no_duplicate_node_replacements() {
let mut hashmap = HashMap::<ProtoNodeIdentifier, u32>::new();

View File

@@ -828,6 +828,8 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
} => {
// Upgrade the document being opened to use fresh copies of all nodes
let reset_node_definitions_on_open = reset_node_definitions_on_open || document_migration_reset_node_definition(&document_serialized_content);
// Reinstall just the layer networks whose coercion nodes were split into "As Graphic" and "Into Group"
let reset_layer_definitions = document_migration_reset_layer_definitions(&document_serialized_content);
// Upgrade the document being opened with string replacements on the original JSON
let document_serialized_content = document_migration_string_preprocessing(document_serialized_content);
// Upgrade resources from being referend by hash to beeing referened by ID
@@ -878,7 +880,7 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
};
// Upgrade the document's nodes to be compatible with the latest version
document_migration_upgrades(&mut document, reset_node_definitions_on_open);
document_migration_upgrades(&mut document, reset_node_definitions_on_open, reset_layer_definitions);
// Load the document's embedded resources into the resource storage
std::mem::take(&mut document.resources.embedded).into_iter().for_each(|(hash, resource)| {

View File

@@ -83,17 +83,11 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, Vec<RegistryEntry>> {
.into_iter()
.map(|entry| (ProtoNodeIdentifier::new("graphene_core::ops::IntoNode<Graphic>"), entry)),
);
// The typed-level collapse rows of To Graphic, served under its identifier.
// The unit row of As Graphic: an unconnected content input renders as nothing.
node_types.extend(
graphene_std::graphic::to_graphic_typed_entries()
graphene_std::graphic::as_graphic_unit_entries()
.into_iter()
.map(|entry| (graphene_std::graphic::to_graphic::IDENTIFIER.clone(), entry)),
);
// The unit row of To Graphic: an unconnected content input renders as nothing.
node_types.extend(
graphene_std::graphic::to_graphic_unit_entries()
.into_iter()
.map(|entry| (graphene_std::graphic::to_graphic::IDENTIFIER.clone(), entry)),
.map(|entry| (graphene_std::graphic::as_graphic::IDENTIFIER.clone(), entry)),
);
// =============
// CONVERT NODES

View File

@@ -374,6 +374,14 @@ impl From<DVec2> for Graphic<'_> {
Graphic::Vector(Vector::from_anchor_position(position))
}
}
/// A coordinate becomes the vector holding it as a lone anchor, so a level of
/// coordinates coerces lane-for-lane into a level of single-point vectors.
impl IntoGraphicElement for DVec2 {
fn into_graphic_element(self, _arena: &core_types::arena::Arena) -> Option<Graphic<'_>> {
Some(Graphic::Vector(Vector::from_anchor_position(self)))
}
}
// Note: List conversions handled by blanket impl in gcore
impl<'e> Graphic<'e> {

View File

@@ -265,12 +265,12 @@ attribute_reads! {
read_gradient_hue_direction_attribute: GradientHueDirection => GradientHueDirection;
}
/// Nests the input graphical content in a wrapper graphic. This essentially "groups" the input.
/// The wrapped run keeps the level's element type, so the legacy boundary can
/// lower a wrapped vector level to the bare typed graphic the pre-flip wrap made.
/// Nests the input graphical content in a wrapper graphic, collecting it all into a single group.
/// The collected run keeps the level's element type, so the legacy boundary can
/// lower a grouped vector level to the bare typed graphic the pre-flip wrap made.
/// The inverse of this node is 'Flatten Graphic'.
#[node_macro::node(category("General"), extent(wrap_graphic_extent))]
pub fn wrap_graphic<'e, T: Clone + Send + Sync + core_types::CacheHash + 'static>(
#[node_macro::node(category("General"), extent(into_group_extent))]
pub fn into_group<'e, T: Clone + Send + Sync + core_types::CacheHash + 'static>(
_: impl Ctx,
#[implementations(Graphic, Vector, Raster<CPU>, Raster<GPU>, Color, Gradient, String)] content: IList<T>,
) -> Result<IList<Graphic<'e>>, Interrupt> {
@@ -279,15 +279,14 @@ pub fn wrap_graphic<'e, T: Clone + Send + Sync + core_types::CacheHash + 'static
}
/// The collected group is the level's single lane.
fn wrap_graphic_extent<T>(_content: ListIn<'_, T>, _level: LevelIn) -> GPoll<Extent> {
fn into_group_extent<T>(_content: ListIn<'_, T>, _level: LevelIn) -> GPoll<Extent> {
GPoll::Final(Extent::Exactly(1))
}
/// Converts graphical content into a `Graphic` level. A `Graphic` level passes through
/// unchanged; a typed level nests as one graphic lane, keeping the pre-flip list
/// collapse (`to_graphic_typed` serves those rows).
/// Type-asserts a value to be graphical content, converting each lane 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 fn to_graphic<'e, T: graphic_types::graphic::IntoGraphicElement>(ctx: impl Ctx + ExtractArena<'e>, #[implementations(Graphic)] content: T) -> Result<Graphic<'e>, Interrupt> {
pub fn as_graphic<'e, T: graphic_types::graphic::IntoGraphicElement>(ctx: impl Ctx + ExtractArena<'e>, #[implementations(Graphic)] content: T) -> Result<Graphic<'e>, Interrupt> {
content.into_graphic_element(ctx.arena()).ok_or_else(|| GraphError::new("the arena is exhausted").into())
}
@@ -297,37 +296,24 @@ pub fn to_graphic<'e, T: graphic_types::graphic::IntoGraphicElement>(ctx: impl C
#[node_macro::node(category(""))]
pub fn to_graphic_element<'e, T: graphic_types::graphic::IntoGraphicElement>(
ctx: impl Ctx + ExtractArena<'e>,
#[implementations(Graphic, Vector, Raster<CPU>, Raster<GPU>, Color, Gradient, String)] content: T,
#[implementations(Graphic, Vector, Raster<CPU>, Raster<GPU>, Color, Gradient, String, DVec2)] content: T,
) -> Result<Graphic<'e>, Interrupt> {
content.into_graphic_element(ctx.arena()).ok_or_else(|| GraphError::new("the arena is exhausted").into())
}
/// The typed-level conversion: the whole level nests as one graphic lane, as
/// the pre-flip `Into<Graphic>` list collapse did. Registered under the to
/// graphic identifier.
#[node_macro::node(category(""), extent(wrap_graphic_extent))]
pub fn to_graphic_typed<'e, T: Clone + Send + Sync + core_types::CacheHash + 'static>(
_: impl Ctx,
#[implementations(Vector, Raster<CPU>, Raster<GPU>, Color, Gradient, String)] content: IList<T>,
) -> Result<IList<Graphic<'e>>, Interrupt> {
let item = content.as_group_item();
Ok(Graphic::Group(core_types::record::Group { row: None, content: item }))
}
/// An unconnected content input carries the unit, which renders as nothing like
/// the pre-flip empty list. Registered under the to graphic identifier.
#[node_macro::node(category(""), extent(to_graphic_unit_extent))]
pub fn to_graphic_unit(_: impl Ctx, _content: ()) -> Result<IList<Graphic<'static>>, Interrupt> {
/// the pre-flip empty list. Registered under the as graphic identifier.
#[node_macro::node(category(""), extent(as_graphic_unit_extent))]
pub fn as_graphic_unit(_: impl Ctx, _content: ()) -> Result<IList<Graphic<'static>>, Interrupt> {
Err(core_types::gpoll::GraphError::past_end().into())
}
fn to_graphic_unit_extent(_content: core_types::extent::ValueIn<'_, ()>, _level: LevelIn) -> GPoll<Extent> {
fn as_graphic_unit_extent(_content: core_types::extent::ValueIn<'_, ()>, _level: LevelIn) -> GPoll<Extent> {
GPoll::Final(Extent::Exactly(0))
}
pub use _as_graphic_unit_mod::as_graphic_unit_entries;
pub use _to_graphic_element_mod::to_graphic_element_entries;
pub use _to_graphic_typed_mod::to_graphic_typed_entries;
pub use _to_graphic_unit_mod::to_graphic_unit_entries;
/// Removes a level of nesting from a `Graphic[]`, or all nesting if "Fully Flatten" is enabled.
///

View File

@@ -224,8 +224,8 @@ fn flatten_levels_extent(content: ExtentIn<'_>, level: LevelIn) -> GPoll<Extent>
mod tests {
use super::*;
use crate::graphic::{
ColorsToGradientNode, FlattenColorNode, FlattenGraphicNode, GradientToColorsNode, WrapGraphicNode, flatten_color_layout_meta, flatten_graphic_layout_meta, gradient_to_colors_layout_meta,
wrap_graphic_layout_meta,
ColorsToGradientNode, FlattenColorNode, FlattenGraphicNode, GradientToColorsNode, IntoGroupNode, flatten_color_layout_meta, flatten_graphic_layout_meta, gradient_to_colors_layout_meta,
into_group_layout_meta,
};
use core_types::arena::Arena;
use core_types::attribute::Attribute as AttributeMarker;
@@ -670,8 +670,8 @@ mod tests {
let layout = graphic_layout();
let rows = vec![(text("a"), translation(1.)), (text("b"), translation(2.))];
let node = install(
WrapGraphicNode::<_, Graphic>::new(RecordSource::new(GraphicSource { layout: layout.clone(), rows }, &layout, &layout), &layout),
wrap_graphic_layout_meta(),
IntoGroupNode::<_, Graphic>::new(RecordSource::new(GraphicSource { layout: layout.clone(), rows }, &layout, &layout), &layout),
into_group_layout_meta(),
&[Some(&layout)],
);
let out = Node::<ContextImpl>::layout(&node).clone();
@@ -708,8 +708,8 @@ mod tests {
let layout = graphic_layout();
let rows = vec![(text("a"), translation(1.)), (text("b"), translation(2.))];
let node = install(
WrapGraphicNode::<_, Graphic>::new(RecordSource::new(GraphicSource { layout: layout.clone(), rows }, &layout, &layout), &layout),
wrap_graphic_layout_meta(),
IntoGroupNode::<_, Graphic>::new(RecordSource::new(GraphicSource { layout: layout.clone(), rows }, &layout, &layout), &layout),
into_group_layout_meta(),
&[Some(&layout)],
);
let out = Node::<ContextImpl>::layout(&node).clone();
@@ -858,8 +858,8 @@ mod tests {
let layout = graphic_layout();
let rows = vec![(text("a"), translation(1.)), (text("b"), translation(2.))];
let node = install(
WrapGraphicNode::<_, Graphic>::new(RecordSource::new(GraphicSource { layout: layout.clone(), rows }, &layout, &layout), &layout),
wrap_graphic_layout_meta(),
IntoGroupNode::<_, Graphic>::new(RecordSource::new(GraphicSource { layout: layout.clone(), rows }, &layout, &layout), &layout),
into_group_layout_meta(),
&[Some(&layout)],
);
let out = Node::<ContextImpl>::layout(&node).clone();
@@ -890,8 +890,8 @@ mod tests {
let layout = graphic_layout();
let rows = vec![(text("a"), translation(1.)), (text("b"), translation(2.))];
let wrapped = install(
WrapGraphicNode::<_, Graphic>::new(RecordSource::new(GraphicSource { layout: layout.clone(), rows }, &layout, &layout), &layout),
wrap_graphic_layout_meta(),
IntoGroupNode::<_, Graphic>::new(RecordSource::new(GraphicSource { layout: layout.clone(), rows }, &layout, &layout), &layout),
into_group_layout_meta(),
&[Some(&layout)],
);
let wrap_out = Node::<ContextImpl>::layout(&wrapped).clone();