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

@@ -276,10 +276,7 @@ async fn brush(
let has_erase_or_restore_strokes = strokes.iter().any(|s| matches!(s.style.blend_mode, BlendMode::Erase | BlendMode::Restore));
if has_erase_or_restore_strokes {
let opaque_image = Image::new(bbox.size().x as u32, bbox.size().y as u32, Color::WHITE);
let mut erase_restore_mask = TableRow::new_from_element(Raster::new_cpu(opaque_image))
.with_attribute("transform", background_bounds)
.with_attribute("alpha_blending", AlphaBlending::default())
.with_attribute("source_node_id", None::<NodeId>);
let mut erase_restore_mask = TableRow::new_from_element(Raster::new_cpu(opaque_image)).with_attribute("transform", background_bounds);
for stroke in strokes {
let mut brush_texture = cache.get_cached_brush(&stroke.style);
@@ -313,12 +310,12 @@ async fn brush(
let transform: DAffine2 = actual_image.attribute_cloned_or_default("transform");
let alpha_blending: AlphaBlending = actual_image.attribute_cloned_or_default("alpha_blending");
let source_node_id: Option<NodeId> = actual_image.attribute_cloned_or_default("source_node_id");
let layer: Option<NodeId> = actual_image.attribute_cloned_or_default("editor:layer");
*image.element_mut(0).unwrap() = actual_image.into_element();
image.set_attribute("transform", 0, transform);
image.set_attribute("alpha_blending", 0, alpha_blending);
image.set_attribute("source_node_id", 0, source_node_id);
image.set_attribute("editor:layer", 0, layer);
image
}

View File

@@ -64,10 +64,8 @@ impl BrushCacheImpl {
background = std::mem::take(&mut self.blended_image);
// Check if the first non-blended stroke is an extension of the last one.
let mut first_stroke_texture = TableRow::new_from_element(Raster::<CPU>::default())
.with_attribute("transform", glam::DAffine2::ZERO)
.with_attribute("alpha_blending", core_types::AlphaBlending::default())
.with_attribute("source_node_id", None::<core_types::uuid::NodeId>);
// Transform is set to ZERO (not the default IDENTITY) as a sentinel to mark this row as uninitialized.
let mut first_stroke_texture = TableRow::new_from_element(Raster::<CPU>::default()).with_attribute("transform", glam::DAffine2::ZERO);
let mut first_stroke_point_skip = 0;
let strokes = input[num_blended_strokes..].to_vec();
if !strokes.is_empty() && self.prev_input.len() > num_blended_strokes {

View File

@@ -27,6 +27,7 @@ async fn context_modification<T>(
Context -> Footprint,
Context -> DVec2,
Context -> Vec<DVec2>,
Context -> Option<NodeId>,
Context -> Vec<NodeId>,
Context -> Vec<f64>,
Context -> Vec<f32>,

View File

@@ -177,33 +177,53 @@ where
result_table
}
/// Performs internal editor record-keeping that enables tools to target this network's layer.
/// This node associates the ID of the network's parent layer to every element of output data.
/// This technical detail may be ignored by users, and will be phased out in the future.
/// Returns the NodeId of the user-facing parent layer node that encapsulates this sub-network.
/// Used as the value source for stamping the `editor:layer` attribute on each row of a layer's output,
/// which lets editor tools (e.g. selection, click target routing) trace data back to its owning layer.
#[node_macro::node(category(""))]
pub async fn source_node_id<T: 'n + Send + Clone>(
_: impl Ctx,
#[implementations(
Table<Artboard>,
Table<Graphic>,
Table<Vector>,
Table<Raster<CPU>>,
Table<Raster<GPU>>,
Table<Color>,
Table<GradientStops>,
)]
content: Table<T>,
node_path: Vec<NodeId>,
) -> Table<T> {
pub fn parent_layer(_: impl Ctx, node_path: Vec<NodeId>) -> Option<NodeId> {
// Get the penultimate element of the node path, or None if the path is too short
// This is used to get the ID of the user-facing parent layer node (whose network contains this internal node).
let source_node_id = node_path.get(node_path.len().wrapping_sub(2)).copied();
node_path.get(node_path.len().wrapping_sub(2)).copied()
}
let mut content = content;
for source_id in content.iter_attribute_values_mut_or_default::<Option<NodeId>>("source_node_id") {
*source_id = source_node_id;
/// Writes a per-row attribute column on the input table. The value-producing input is evaluated once per row,
/// with the row's element index and the row itself (as a single-row table vararg) passed via context, so the
/// upstream pipeline can return a different value per row that may be derived from the row's own data.
/// If the column already exists, its values are replaced; if not, the column is created.
#[node_macro::node(category("General"))]
async fn write_attribute<T: AnyHash + Clone + Send + Sync + core_types::CacheHash, U: Clone + Send + Sync + Default + std::fmt::Debug + 'static>(
ctx: impl ExtractAll + CloneVarArgs + Ctx,
/// The table whose rows will gain or replace the named attribute column.
#[implementations(
Table<Artboard>, Table<Artboard>, Table<Artboard>, Table<Artboard>, Table<Artboard>, Table<Artboard>, Table<Artboard>,
Table<Graphic>, Table<Graphic>, Table<Graphic>, Table<Graphic>, Table<Graphic>, Table<Graphic>, Table<Graphic>,
Table<Vector>, Table<Vector>, Table<Vector>, Table<Vector>, Table<Vector>, Table<Vector>, Table<Vector>,
Table<Raster<CPU>>, Table<Raster<CPU>>, Table<Raster<CPU>>, Table<Raster<CPU>>, Table<Raster<CPU>>, Table<Raster<CPU>>, Table<Raster<CPU>>,
Table<Raster<GPU>>, Table<Raster<GPU>>, Table<Raster<GPU>>, Table<Raster<GPU>>, Table<Raster<GPU>>, Table<Raster<GPU>>, Table<Raster<GPU>>,
Table<Color>, Table<Color>, Table<Color>, Table<Color>, Table<Color>, Table<Color>, Table<Color>,
Table<GradientStops>, Table<GradientStops>, Table<GradientStops>, Table<GradientStops>, Table<GradientStops>, Table<GradientStops>, Table<GradientStops>,
)]
mut content: Table<T>,
/// The attribute name (column key) to write or replace.
name: String,
/// The node that produces the per-row value. Called once per row with the row index in context.
#[implementations(
Context -> f64, Context -> u32, Context -> bool, Context -> String, Context -> DVec2, Context -> DAffine2, Context -> Option<NodeId>,
Context -> f64, Context -> u32, Context -> bool, Context -> String, Context -> DVec2, Context -> DAffine2, Context -> Option<NodeId>,
Context -> f64, Context -> u32, Context -> bool, Context -> String, Context -> DVec2, Context -> DAffine2, Context -> Option<NodeId>,
Context -> f64, Context -> u32, Context -> bool, Context -> String, Context -> DVec2, Context -> DAffine2, Context -> Option<NodeId>,
Context -> f64, Context -> u32, Context -> bool, Context -> String, Context -> DVec2, Context -> DAffine2, Context -> Option<NodeId>,
Context -> f64, Context -> u32, Context -> bool, Context -> String, Context -> DVec2, Context -> DAffine2, Context -> Option<NodeId>,
Context -> f64, Context -> u32, Context -> bool, Context -> String, Context -> DVec2, Context -> DAffine2, Context -> Option<NodeId>,
)]
value: impl Node<'n, Context<'static>, Output = U>,
) -> Table<T> {
for index in 0..content.len() {
let row = content.clone_row(index).expect("index is within bounds");
let owned_ctx = OwnedContextImpl::from(ctx.clone()).with_vararg(Box::new(Table::new_from_row(row))).with_index(index);
let v = value.eval(owned_ctx.into_context()).await;
content.set_attribute(&name, index, v);
}
content
}
@@ -239,11 +259,11 @@ pub async fn legacy_layer_extend<T: 'n + Send + Clone>(
) -> Table<T> {
// Get the penultimate element of the node path, or None if the path is too short
// This is used to get the ID of the user-facing parent layer-style node (which encapsulates this internal node).
let source_node_id = nested_node_path.get(nested_node_path.len().wrapping_sub(2)).copied();
let layer = nested_node_path.get(nested_node_path.len().wrapping_sub(2)).copied();
let mut base = base;
for mut row in new.into_iter() {
row.set_attribute("source_node_id", source_node_id);
row.set_attribute("editor:layer", layer);
base.push(row);
}

View File

@@ -18,6 +18,8 @@ pub use graphene_canvas_utils as canvas_utils;
#[cfg(target_family = "wasm")]
use graphic_types::Graphic;
#[cfg(target_family = "wasm")]
use graphic_types::IntoGraphicTable;
#[cfg(target_family = "wasm")]
use graphic_types::Vector;
use graphic_types::raster_types::Image;
use graphic_types::raster_types::{CPU, Raster};
@@ -170,7 +172,7 @@ async fn create_canvas(_: impl Ctx) -> CanvasHandle {
/// Renders a view of the input graphic within an area defined by the *Footprint*.
#[cfg(target_family = "wasm")]
#[node_macro::node(category(""))]
async fn rasterize<T: WasmNotSend + 'n>(
async fn rasterize<T: WasmNotSend + Clone + 'n>(
_: impl Ctx,
#[implementations(
Table<Vector>,
@@ -184,7 +186,7 @@ async fn rasterize<T: WasmNotSend + 'n>(
mut canvas: CanvasHandle,
) -> Table<Raster<CPU>>
where
Table<T>: Render,
Table<T>: Render + Clone + graphic_types::IntoGraphicTable,
{
use core_types::table::TableRow;
use glam::{DAffine2, DVec2};
@@ -194,6 +196,10 @@ where
return Table::new();
}
// Snapshot the input as a Table<Graphic> so the renderer can recurse into the original child layers
// when collecting metadata, exposing their click targets to editor tools (same mechanism as Boolean Operation).
let upstream_graphic_table = data.clone().into_graphic_table();
let mut render = SvgRender::new();
let aabb = Bbox::from_transform(footprint.transform).to_axis_aligned_bbox();
let size = aabb.size();
@@ -229,5 +235,9 @@ where
let rasterized = context.get_image_data(0., 0., resolution.x as f64, resolution.y as f64).unwrap();
let image = Image::from_image_data(&rasterized.data().0, resolution.x as u32, resolution.y as u32);
Table::new_from_row(TableRow::new_from_element(Raster::new_cpu(image)).with_attribute("transform", footprint.transform))
Table::new_from_row(
TableRow::new_from_element(Raster::new_cpu(image))
.with_attribute("transform", footprint.transform)
.with_attribute("editor:merged_layers", upstream_graphic_table),
)
}

View File

@@ -46,7 +46,10 @@ async fn boolean_operation<I: graphic_types::IntoGraphicTable + 'n + Send + Clon
let result_vector = result_vector_table.element_mut(0).unwrap();
Vector::transform(result_vector, transform);
result_vector.style.set_stroke_transform(DAffine2::IDENTITY);
result_vector.upstream_data = Some(content.clone());
// Snapshot the input layers as the `editor:merged_layers` row attribute so the renderer can recurse into them
// for editor click-target preservation.
result_vector_table.set_attribute("editor:merged_layers", 0, content.clone());
// Clean up the boolean operation result by merging duplicated points
let merge_transform: DAffine2 = result_vector_table.attribute_cloned_or_default("transform", 0);
@@ -127,7 +130,6 @@ fn boolean_operation_on_vector_table(vector: &Table<Vector>, boolean_operation:
let copy_from = vector.element(index).unwrap();
let element = Vector {
style: copy_from.style.clone(),
upstream_data: copy_from.upstream_data.clone(),
..Default::default()
};
TableRow::from_parts(element, attributes)
@@ -177,7 +179,7 @@ fn flatten_vector(graphic_table: &Table<Graphic>) -> Table<Vector> {
}
Graphic::RasterCPU(image) => {
let parent_transform: DAffine2 = graphic_table.attribute_cloned_or_default("transform", index);
let make_row = |transform, source_node_id, alpha_blending| {
let make_row = |transform, layer, alpha_blending| {
let mut subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE);
subpath.apply_transform(transform);
@@ -186,24 +188,24 @@ fn flatten_vector(graphic_table: &Table<Graphic>) -> Table<Vector> {
TableRow::new_from_element(element)
.with_attribute("alpha_blending", alpha_blending)
.with_attribute("source_node_id", source_node_id)
.with_attribute("editor:layer", layer)
};
// Apply the parent graphic's transform to each raster element, preserving each row's source_node_id
// Apply the parent graphic's transform to each raster element, preserving each row's layer
// and alpha_blending so the boolean op downstream can route clicks (and inherit blending state)
// back to the originating raster layer
(0..image.len())
.map(|i| {
let row_transform: DAffine2 = image.attribute_cloned_or_default("transform", i);
let source_node_id: Option<NodeId> = image.attribute_cloned_or_default("source_node_id", i);
let layer: Option<NodeId> = image.attribute_cloned_or_default("editor:layer", i);
let alpha_blending: AlphaBlending = image.attribute_cloned_or_default("alpha_blending", i);
make_row(parent_transform * row_transform, source_node_id, alpha_blending)
make_row(parent_transform * row_transform, layer, alpha_blending)
})
.collect::<Vec<_>>()
}
Graphic::RasterGPU(image) => {
let parent_transform: DAffine2 = graphic_table.attribute_cloned_or_default("transform", index);
let make_row = |transform, source_node_id, alpha_blending| {
let make_row = |transform, layer, alpha_blending| {
let mut subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE);
subpath.apply_transform(transform);
@@ -212,18 +214,18 @@ fn flatten_vector(graphic_table: &Table<Graphic>) -> Table<Vector> {
TableRow::new_from_element(element)
.with_attribute("alpha_blending", alpha_blending)
.with_attribute("source_node_id", source_node_id)
.with_attribute("editor:layer", layer)
};
// Apply the parent graphic's transform to each raster element, preserving each row's source_node_id
// Apply the parent graphic's transform to each raster element, preserving each row's layer
// and alpha_blending so the boolean op downstream can route clicks (and inherit blending state)
// back to the originating raster layer
(0..image.len())
.map(|i| {
let row_transform: DAffine2 = image.attribute_cloned_or_default("transform", i);
let source_node_id: Option<NodeId> = image.attribute_cloned_or_default("source_node_id", i);
let layer: Option<NodeId> = image.attribute_cloned_or_default("editor:layer", i);
let alpha_blending: AlphaBlending = image.attribute_cloned_or_default("alpha_blending", i);
make_row(parent_transform * row_transform, source_node_id, alpha_blending)
make_row(parent_transform * row_transform, layer, alpha_blending)
})
.collect::<Vec<_>>()
}

View File

@@ -335,6 +335,8 @@ pub fn noise_pattern(
return Table::new();
}
let transform = DAffine2::from_translation(offset) * DAffine2::from_scale(size);
let footprint_scale = footprint.scale();
let width = (size.x * footprint_scale.x) as u32;
let height = (size.y * footprint_scale.y) as u32;
@@ -376,12 +378,7 @@ pub fn noise_pattern(
}
}
return Table::new_from_row(
TableRow::new_from_element(Raster::new_cpu(image))
.with_attribute("transform", DAffine2::from_translation(offset) * DAffine2::from_scale(size))
.with_attribute("alpha_blending", AlphaBlending::default())
.with_attribute("source_node_id", None::<core_types::uuid::NodeId>),
);
return Table::new_from_row(TableRow::new_from_element(Raster::new_cpu(image)).with_attribute("transform", transform));
}
};
noise.set_noise_type(Some(noise_type));
@@ -439,12 +436,7 @@ pub fn noise_pattern(
}
}
Table::new_from_row(
TableRow::new_from_element(Raster::new_cpu(image))
.with_attribute("transform", DAffine2::from_translation(offset) * DAffine2::from_scale(size))
.with_attribute("alpha_blending", AlphaBlending::default())
.with_attribute("source_node_id", None::<core_types::uuid::NodeId>),
)
Table::new_from_row(TableRow::new_from_element(Raster::new_cpu(image)).with_attribute("transform", transform))
}
#[node_macro::node(category("Raster: Pattern"))]
@@ -489,9 +481,7 @@ pub fn mandelbrot(ctx: impl ExtractFootprint + Send) -> Table<Raster<CPU>> {
data,
..Default::default()
}))
.with_attribute("transform", DAffine2::from_translation(offset) * DAffine2::from_scale(size))
.with_attribute("alpha_blending", AlphaBlending::default())
.with_attribute("source_node_id", None::<core_types::uuid::NodeId>),
.with_attribute("transform", DAffine2::from_translation(offset) * DAffine2::from_scale(size)),
)
}

View File

@@ -1,4 +1,3 @@
use core_types::AlphaBlending;
use core_types::table::{Table, TableRow};
use glam::{DAffine2, DVec2};
use parley::GlyphRun;
@@ -10,16 +9,16 @@ use skrifa::{MetadataProvider, OutlineGlyph};
use vector_types::subpath::{ManipulatorGroup, Subpath};
use vector_types::vector::{PointId, Vector};
pub struct PathBuilder<Upstream> {
pub struct PathBuilder {
current_subpath: Subpath<PointId>,
origin: DVec2,
glyph_subpaths: Vec<Subpath<PointId>>,
pub vector_table: Table<Vector<Upstream>>,
pub vector_table: Table<Vector>,
scale: f64,
id: PointId,
}
impl<Upstream: Default + 'static> PathBuilder<Upstream> {
impl PathBuilder {
pub fn new(per_glyph_instances: bool, scale: f64) -> Self {
Self {
current_subpath: Subpath::new(Vec::new(), false),
@@ -52,12 +51,8 @@ impl<Upstream: Default + 'static> PathBuilder<Upstream> {
}
if per_glyph_instances {
self.vector_table.push(
TableRow::new_from_element(Vector::from_subpaths(core::mem::take(&mut self.glyph_subpaths), false))
.with_attribute("transform", DAffine2::from_translation(glyph_offset))
.with_attribute("alpha_blending", AlphaBlending::default())
.with_attribute("source_node_id", None::<core_types::uuid::NodeId>),
);
self.vector_table
.push(TableRow::new_from_element(Vector::from_subpaths(core::mem::take(&mut self.glyph_subpaths), false)).with_attribute("transform", DAffine2::from_translation(glyph_offset)));
} else {
for subpath in self.glyph_subpaths.drain(..) {
// Unwrapping here is ok because `self.vector_table` is initialized with a single `Vector` table element
@@ -120,7 +115,7 @@ impl<Upstream: Default + 'static> PathBuilder<Upstream> {
}
}
pub fn finalize(mut self) -> Table<Vector<Upstream>> {
pub fn finalize(mut self) -> Table<Vector> {
if self.vector_table.is_empty() {
self.vector_table = Table::new_from_element(Vector::default());
}
@@ -128,7 +123,7 @@ impl<Upstream: Default + 'static> PathBuilder<Upstream> {
}
}
impl<Upstream: Default + 'static> OutlinePen for PathBuilder<Upstream> {
impl OutlinePen for PathBuilder {
fn move_to(&mut self, x: f32, y: f32) {
if !self.current_subpath.is_empty() {
self.glyph_subpaths.push(std::mem::replace(&mut self.current_subpath, Subpath::new(Vec::new(), false)));

View File

@@ -87,7 +87,7 @@ impl TextContext {
}
/// Convert text to vector paths using the specified font and typesetting configuration
pub fn to_path<Upstream: Default + 'static>(&mut self, text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig, per_glyph_instances: bool) -> Table<Vector<Upstream>> {
pub fn to_path(&mut self, text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig, per_glyph_instances: bool) -> Table<Vector> {
let Some(layout) = self.layout_text(text, font, font_cache, typesetting) else {
return Table::new_from_element(Vector::default());
};

View File

@@ -6,7 +6,7 @@ use parley::fontique::Blob;
use std::sync::Arc;
use vector_types::Vector;
pub fn to_path<Upstream: Default + 'static>(text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig, per_glyph_instances: bool) -> Table<Vector<Upstream>> {
pub fn to_path(text: &str, font: &Font, font_cache: &FontCache, typesetting: TypesettingConfig, per_glyph_instances: bool) -> Table<Vector> {
TextContext::with_thread_local(|ctx| ctx.to_path(text, font, font_cache, typesetting, per_glyph_instances))
}

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]