Move source_node_id to "editor:layer" and Vector<Upstream> to "editor:merged_layers", backed by a new 'Write Attribute' node (#4061)

* Fix click target propagation with the Rasterize node

* Add the 'Write Attribute' node

* Remove tag_layer in favor of the new Write Attribute node, prune redundant attribute writes

* Replace the Vector<Upstream> type argument with the "editor:merged_layers" attribute
This commit is contained in:
Keavon Chambers
2026-04-28 03:07:23 -07:00
parent 76938eb69a
commit afc2c9178e
27 changed files with 312 additions and 376 deletions

View File

@@ -17,8 +17,8 @@ async fn path_modify(_ctx: impl Ctx, mut vector: Table<Vector>, modification: Bo
// Update the source node id
let this_node_path = node_path.iter().rev().nth(1).copied();
let existing: Option<NodeId> = vector.attribute_cloned_or_default("source_node_id", 0);
vector.set_attribute("source_node_id", 0, existing.or(this_node_path));
let existing: Option<NodeId> = vector.attribute_cloned_or_default("editor:layer", 0);
vector.set_attribute("editor:layer", 0, existing.or(this_node_path));
if vector.len() > 1 {
warn!("The path modify ran on {} vector rows. Only the first can be modified.", vector.len());

View File

@@ -354,8 +354,6 @@ async fn round_corners(
let attributes = source.clone_row_attributes(index);
let source = source.element(index).unwrap();
let upstream_nested_layers = source.upstream_data.clone();
// Flip the roundness to help with user intuition
let roundness = 1. - roundness;
// Convert 0-100 to 0-0.5
@@ -439,8 +437,6 @@ async fn round_corners(
result.append_bezpath(rounded_subpath);
}
result.upstream_data = upstream_nested_layers;
TableRow::from_parts(result, attributes)
})
.collect()
@@ -1300,7 +1296,7 @@ pub async fn flatten_path<T: IntoGraphicTable + 'n + Send>(_: impl Ctx, #[implem
// Concatenate every vector element's subpaths into the single output compound path
for index in 0..flattened.len() {
let Some(element) = flattened.element(index) else { continue };
let node_id: Option<NodeId> = flattened.attribute_cloned_or_default("source_node_id", index);
let node_id: Option<NodeId> = flattened.attribute_cloned_or_default("editor:layer", index);
let node_id = node_id.map(|node_id| node_id.0).unwrap_or_default();
let mut hasher = DefaultHasher::new();
@@ -1317,13 +1313,13 @@ pub async fn flatten_path<T: IntoGraphicTable + 'n + Send>(_: impl Ctx, #[implem
// Preserve a reference to the original upstream graphic table so the renderer can recurse into it
// when collecting metadata, exposing the original child layers' click targets to editor tools.
// This is the same mechanism Boolean Operation uses to keep its inputs editable after the merge.
output.upstream_data = Some(graphic_table);
output_table.set_attribute("editor:merged_layers", 0, graphic_table);
// Adopt the last input row's source_node_id so the editor can also bucket clicks under a contributing child layer
// Adopt the last input row's layer so the editor can also bucket clicks under a contributing child layer
if !flattened.is_empty() {
let primary = flattened.len() - 1;
let source_node_id: Option<NodeId> = flattened.attribute_cloned_or_default("source_node_id", primary);
output_table.set_attribute("source_node_id", 0, source_node_id);
let layer: Option<NodeId> = flattened.attribute_cloned_or_default("editor:layer", primary);
output_table.set_attribute("editor:layer", 0, layer);
}
output_table
@@ -1351,7 +1347,6 @@ async fn sample_polyline(
region_domain: Default::default(),
colinear_manipulators: Default::default(),
style: std::mem::take(&mut row.element_mut().style),
upstream_data: std::mem::take(&mut row.element_mut().upstream_data),
};
// Transfer the stroke transform from the input vector content to the result.
result.style.set_stroke_transform(row.attribute_cloned_or_default("transform"));
@@ -1441,7 +1436,6 @@ async fn simplify(
let mut result = Vector {
style: std::mem::take(&mut row.element_mut().style),
upstream_data: std::mem::take(&mut row.element_mut().upstream_data),
..Default::default()
};
@@ -1538,7 +1532,6 @@ async fn decimate(
let mut result = Vector {
style: std::mem::take(&mut row.element_mut().style),
upstream_data: std::mem::take(&mut row.element_mut().upstream_data),
..Default::default()
};
@@ -2382,13 +2375,13 @@ async fn morph<I: IntoGraphicTable + 'n + Send + Clone>(
trs * skew
};
// Pre-compensate upstream_data transforms so that when collect_metadata applies
// Pre-compensate merged_layers transforms so that when collect_metadata applies
// the row transform (which will be group_transform * lerped_transform after the
// pipeline's Transform node runs), the lerped_transform cancels out and children
// get the correct footprint: parent * group_transform * child_transform.
// Only pre-compensate if the lerped transform is invertible (non-zero determinant).
// A zero determinant can occur when interpolated scale passes through zero (e.g., flipped axes),
// in which case we skip pre-compensation to avoid propagating NaN through upstream_data transforms.
// in which case we skip pre-compensation to avoid propagating NaN through merged_layers transforms.
if lerped_transform.matrix2.determinant().abs() > f64::EPSILON {
let lerped_inverse = lerped_transform.inverse();
for transform in graphic_table_content.iter_attribute_values_mut_or_default::<DAffine2>("transform") {
@@ -2404,21 +2397,15 @@ async fn morph<I: IntoGraphicTable + 'n + Send + Clone>(
let mut attributes = content.clone_row_attributes(endpoint_index);
attributes.insert("transform", lerped_transform);
attributes.insert("editor:merged_layers", graphic_table_content);
return Table::new_from_row(TableRow::from_parts(
Vector {
upstream_data: Some(graphic_table_content),
..endpoint_element.clone()
},
attributes,
));
return Table::new_from_row(TableRow::from_parts(endpoint_element.clone(), attributes));
}
let mut vector = Vector {
upstream_data: Some(graphic_table_content),
style: source_element.style.lerp(&target_element.style, time),
..Default::default()
};
vector.style = source_element.style.lerp(&target_element.style, time);
// Work directly with manipulator groups, bypassing the BezPath intermediate representation.
// This avoids the full Vector → BezPath → interpolate → BezPath → Vector roundtrip each frame.
@@ -2566,13 +2553,14 @@ async fn morph<I: IntoGraphicTable + 'n + Send + Clone>(
// The result is a synthesis of source and target, so adopt whichever endpoint the result is closer to as
// the click-target identity (so the editor can route clicks back to one of the contributing layers)
let primary_index = if time < 0.5 { source_index } else { target_index };
let source_node_id: Option<NodeId> = content.attribute_cloned_or_default("source_node_id", primary_index);
let layer: Option<NodeId> = content.attribute_cloned_or_default("editor:layer", primary_index);
Table::new_from_row(
TableRow::new_from_element(vector)
.with_attribute("transform", lerped_transform)
.with_attribute("alpha_blending", vector_alpha_blending)
.with_attribute("source_node_id", source_node_id),
.with_attribute("editor:layer", layer)
.with_attribute("editor:merged_layers", graphic_table_content),
)
}
@@ -3047,7 +3035,6 @@ async fn centroid(ctx: impl Ctx + CloneVarArgs + ExtractAll, content: impl Node<
#[cfg(test)]
mod test {
use super::*;
use core_types::AlphaBlending;
use core_types::Node;
use kurbo::{CubicBez, Ellipse, Point, Rect};
use std::future::Future;
@@ -3073,10 +3060,7 @@ mod test {
fn create_vector_row(bezpath: BezPath, transform: DAffine2) -> TableRow<Vector> {
let mut row = Vector::default();
row.append_bezpath(bezpath);
TableRow::new_from_element(row)
.with_attribute("transform", transform)
.with_attribute("alpha_blending", AlphaBlending::default())
.with_attribute("source_node_id", None::<core_types::uuid::NodeId>)
TableRow::new_from_element(row).with_attribute("transform", transform)
}
#[tokio::test]