From 58e8f474e42b99a791704c94583a097de70bf74c Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 24 Mar 2026 00:12:57 -0700 Subject: [PATCH 01/20] Fix Morph node transform interpolation and preservation in the table --- node-graph/nodes/vector/src/vector_nodes.rs | 33 +++++++++++++++------ 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index f33d1a17fc..eeb010a5be 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -2027,11 +2027,32 @@ async fn morph( let vector_alpha_blending = source_row.alpha_blending.lerp(&target_row.alpha_blending, time as f32); vector.style = source_row.element.style.lerp(&target_row.element.style, time); - // Before and after transforms + // Decompose transforms into translation, rotation, and scale components let source_transform = source_row.transform; let target_transform = target_row.transform; - // Before and after paths + let source_translation = source_transform.translation; + let source_rotation = source_transform.decompose_rotation(); + let source_scale = source_transform.decompose_scale(); + + let target_translation = target_transform.translation; + let target_rotation = target_transform.decompose_rotation(); + let target_scale = target_transform.decompose_scale(); + + // Interpolate transform components separately (using shortest-arc angular interpolation for rotation) + let lerped_translation = source_translation.lerp(target_translation, time); + let mut rotation_diff = target_rotation - source_rotation; + if rotation_diff > PI { + rotation_diff -= TAU; + } else if rotation_diff < -PI { + rotation_diff += TAU; + } + let lerped_rotation = source_rotation + rotation_diff * time; + let lerped_scale = source_scale.lerp(target_scale, time); + + let lerped_transform = DAffine2::from_scale_angle_translation(lerped_scale, lerped_rotation, lerped_translation); + + // Interpolate geometry in local space (no transform baked in) — the lerped transform handles positioning let source_bezpaths = source_row.element.stroke_bezpath_iter(); let target_bezpaths = target_row.element.stroke_bezpath_iter(); @@ -2040,9 +2061,6 @@ async fn morph( continue; } - source_bezpath.apply_affine(Affine::new(source_transform.to_cols_array())); - target_bezpath.apply_affine(Affine::new(target_transform.to_cols_array())); - let target_segment_len = target_bezpath.segments().count(); let source_segment_len = source_bezpath.segments().count(); @@ -2080,8 +2098,6 @@ async fn morph( let target_paths = target_row.element.stroke_bezpath_iter().skip(source_paths_count); for mut source_path in source_paths { - source_path.apply_affine(Affine::new(source_transform.to_cols_array())); - // Skip if the path has no segments else get the point at the end of the path. let Some(end) = source_path.segments().last().map(|element| element.end()) else { continue }; @@ -2105,8 +2121,6 @@ async fn morph( } for mut target_path in target_paths { - target_path.apply_affine(Affine::new(source_transform.to_cols_array())); - // Skip if the path has no segments else get the point at the start of the path. let Some(start) = target_path.segments().next().map(|element| element.start()) else { continue }; @@ -2131,6 +2145,7 @@ async fn morph( Table::new_from_row(TableRow { element: vector, + transform: lerped_transform, alpha_blending: vector_alpha_blending, ..Default::default() }) From 92c7c5ec35b1260fa260e023ef01e523d6be4396 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 24 Mar 2026 13:31:38 -0700 Subject: [PATCH 02/20] Fix click target positions for Morph's nested layers by pre-compensating upstream_data transforms --- node-graph/nodes/vector/src/vector_nodes.rs | 23 ++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index eeb010a5be..3fd166659a 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -1994,7 +1994,7 @@ async fn morph( } // Preserve original graphic table as upstream data so this group layer's nested layers can be edited by the tools. - let graphic_table_content = content.clone().into_graphic_table(); + let mut graphic_table_content = content.clone().into_graphic_table(); // If the input isn't a Table, we convert it into one by flattening any Table content. let content = content.into_flattened_table::(); @@ -2018,14 +2018,8 @@ async fn morph( let source_row = content_iter.nth(source_index).unwrap(); let target_row = content_iter.next().unwrap(); - let mut vector = Vector { - upstream_data: Some(graphic_table_content), - ..Default::default() - }; - // Lerp styles let vector_alpha_blending = source_row.alpha_blending.lerp(&target_row.alpha_blending, time as f32); - vector.style = source_row.element.style.lerp(&target_row.element.style, time); // Decompose transforms into translation, rotation, and scale components let source_transform = source_row.transform; @@ -2052,6 +2046,21 @@ async fn morph( let lerped_transform = DAffine2::from_scale_angle_translation(lerped_scale, lerped_rotation, lerped_translation); + // Pre-compensate upstream_data 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. + let lerped_inverse = lerped_transform.inverse(); + for row in graphic_table_content.iter_mut() { + *row.transform = lerped_inverse * *row.transform; + } + + let mut vector = Vector { + upstream_data: Some(graphic_table_content), + ..Default::default() + }; + vector.style = source_row.element.style.lerp(&target_row.element.style, time); + // Interpolate geometry in local space (no transform baked in) — the lerped transform handles positioning let source_bezpaths = source_row.element.stroke_bezpath_iter(); let target_bezpaths = target_row.element.stroke_bezpath_iter(); From 858fce6693367c2801fa19d2714e87473618d927 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 25 Mar 2026 18:24:58 -0700 Subject: [PATCH 03/20] Redesign Morph node (v3) with control path input and uniformly spaced progression, and fix Stroke::lerp interpolation weights --- node-graph/nodes/vector/src/vector_nodes.rs | 208 ++++++++++++++++---- 1 file changed, 165 insertions(+), 43 deletions(-) diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 3fd166659a..af3b6312b9 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -4,7 +4,7 @@ use core::hash::{Hash, Hasher}; use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::registry::types::{Angle, Length, Multiplier, Percentage, PixelLength, Progression, SeedValue}; use core_types::table::{Table, TableRow, TableRowMut}; -use core_types::transform::Footprint; +use core_types::transform::{Footprint, Transform}; use core_types::{CloneVarArgs, Color, Context, Ctx, ExtractAll, OwnedContextImpl}; use glam::{DAffine2, DVec2}; use graphic_types::Vector; @@ -1938,17 +1938,19 @@ async fn jitter_points( .collect() } -/// Interpolates the geometry and styles between multiple vector layers, producing a single morphed vector shape. +/// Interpolates the geometry, appearance, and transform between multiple vector layers, producing a single morphed vector shape. /// -/// Based on the progression value, adjacent vector elements are blended together. From 0 until 1, the first element (bottom layer) morphs into the second element (next layer up). From 1 until 2, it then morphs into the third element, and so on until progression is capped at the last element (top layer). +/// Progression [0, 1) morphs through all objects at uniform speed. A path may be provided to control the trajectory between key objects. The **Origins to Polyline** node may be used to create a path with anchor points corresponding to each object. Other nodes can modify its path segments. #[node_macro::node(category("Vector: Modifier"), path(core_types::vector))] async fn morph( _: impl Ctx, - /// The vector elements to interpolate between. Mixed graphic content is deeply flattened to keep only vector elements. + /// The vector objects to interpolate between. Mixed graphic content is deeply flattened to keep only vector elements. #[implementations(Table, Table)] content: I, - /// The factor from one vector element to the next in sequence. The whole number part selects the source element, and the decimal part determines the interpolation amount towards the next element. + /// The fractional part [0, 1) traverses the morph uniformly along the path. If the control path has multiple subpaths, each added integer selects the next subpath. progression: Progression, + /// An optional control path whose anchor points correspond to each object. Curved segments between points will shape the morph trajectory instead of traveling straight. If there is a break between path segments, the separate subpaths are selected by index from the integer part of the progression value. For example, [1, 2) morphs along the segments of the second subpath, and so on. + path: Table, ) -> Table { /// Subdivides the last segment of the bezpath to until it appends 'count' number of segments. fn make_new_segments(bezpath: &mut BezPath, count: usize) { @@ -1999,52 +2001,169 @@ async fn morph( // If the input isn't a Table, we convert it into one by flattening any Table content. let content = content.into_flattened_table::(); - // Determine source and target indices and interpolation time fraction - let progression = progression.max(0.); - let source_index = progression.floor() as usize; - let time = progression.fract(); - // Not enough elements to interpolate between, so we return the input as-is if content.len() <= 1 { return content; } - // Progression is at or past the last element, so we return the last element without interpolation - if source_index >= content.len() - 1 { - return content.into_iter().last().into_iter().collect(); - } - // Interpolation between two elements - let mut content_iter = content.into_iter(); - let source_row = content_iter.nth(source_index).unwrap(); - let target_row = content_iter.next().unwrap(); + // Build the control path for the morph trajectory. + // Collect all subpaths from the path input (applying transforms), or build a default polyline from element origins. + let control_bezpaths: Vec = if path.is_empty() { + // Default: polyline connecting each element's origin (translation component of transform) + let mut default_path = BezPath::new(); + for (i, row) in content.iter().enumerate() { + let origin = row.transform.translation; + let point = kurbo::Point::new(origin.x, origin.y); + if i == 0 { + default_path.move_to(point); + } else { + default_path.line_to(point); + } + } + vec![default_path] + } else { + // User-provided path: collect all subpaths with transforms applied + path.iter() + .flat_map(|vector| { + let transform = *vector.transform; + vector.element.stroke_bezpath_iter().map(move |mut bezpath| { + bezpath.apply_affine(Affine::new(transform.to_cols_array())); + bezpath + }) + }) + .collect() + }; - // Lerp styles - let vector_alpha_blending = source_row.alpha_blending.lerp(&target_row.alpha_blending, time as f32); + // Select which subpath to use based on the integer part of progression (like the 'Position on Path' node) + let progression = progression.max(0.); + let subpath_count = control_bezpaths.len() as f64; + let clamped_progression = progression.clamp(0., subpath_count); + let subpath_index = if clamped_progression >= subpath_count { subpath_count - 1. } else { clamped_progression } as usize; + let fractional_progression = if clamped_progression >= subpath_count { 1. } else { clamped_progression.fract() }; + + let control_bezpath = &control_bezpaths[subpath_index]; + let segment_count = control_bezpath.segments().count(); + + // If the control path has no segments, return the first element + if segment_count == 0 { + return content.into_iter().next().into_iter().collect(); + } + + // Compute per-segment arc lengths for Euclidean progression along the control path + let segment_lengths: Vec = control_bezpath.segments().map(|seg| seg.perimeter(DEFAULT_ACCURACY)).collect(); + let total_length: f64 = segment_lengths.iter().sum(); + + // Map the fractional progression (0–1) to a segment index and local t using Euclidean distance. + // We compute both: + // - `time`: the arc-length fraction within the segment, used as the blend factor for morphing + // - `parametric_t`: the parametric t for evaluating the spatial position on the control path + let (source_index, time, parametric_t) = if total_length <= f64::EPSILON { + // Degenerate path (all points coincident): just use the first element + (0_usize, 0., 0.) + } else if fractional_progression >= 1. { + // At the end: last element fully + (segment_count - 1, 1., 1.) + } else { + // Walk segments by arc-length ratio to find which segment we're in + let mut accumulator = 0.; + let mut found_index = segment_count - 1; + let mut found_t = 1.; + for (i, length) in segment_lengths.iter().enumerate() { + let ratio = length / total_length; + if fractional_progression <= accumulator + ratio { + found_index = i; + found_t = if ratio > f64::EPSILON { (fractional_progression - accumulator) / ratio } else { 0. }; + break; + } + accumulator += ratio; + } - // Decompose transforms into translation, rotation, and scale components - let source_transform = source_row.transform; - let target_transform = target_row.transform; + // Convert the arc-length fraction to a parametric t for evaluating position on the control path curve + let segment = control_bezpath.get_seg(found_index + 1).unwrap(); + let parametric_t = eval_pathseg_euclidean(segment, found_t, DEFAULT_ACCURACY); - let source_translation = source_transform.translation; - let source_rotation = source_transform.decompose_rotation(); - let source_scale = source_transform.decompose_scale(); + // found_t is the arc-length fraction within this segment (0–1), used as the blend factor + (found_index, found_t, parametric_t) + }; + + // The path segment index for evaluating spatial position (before clamping to object range) + let path_segment_index = source_index; + + // Determine if the selected subpath is closed (has a closing segment connecting its end back to its start) + let is_closed = control_bezpath.elements().last() == Some(&PathEl::ClosePath); + + // Number of anchor points (content elements) per subpath: for closed subpaths, the closing + // segment doesn't add a new anchor, so anchors = segments. For open: anchors = segments + 1. + let anchor_count = |bp: &BezPath| -> usize { + let segs = bp.segments().count(); + let closed = bp.elements().last() == Some(&PathEl::ClosePath); + if closed { segs } else { segs + 1 } + }; + + // Offset source_index by the number of content elements consumed by previous subpaths, + // so each subpath morphs through its own slice of content (not always starting from element 0). + let content_offset: usize = control_bezpaths[..subpath_index].iter().map(&anchor_count).sum(); + let local_source_index = source_index; + let source_index = local_source_index + content_offset; + let subpath_anchors = anchor_count(control_bezpath); + + // For closed subpaths, the closing segment wraps target back to the first element of this subpath's slice. + // For open subpaths, target is simply the next element. + let target_index = if is_closed && local_source_index >= subpath_anchors - 1 { + content_offset // Wrap to first element of this subpath's slice + } else { + source_index + 1 + }; + + // Clamp to valid content range + let source_index = source_index.min(content.len() - 1); + let target_index = target_index.min(content.len() - 1); - let target_translation = target_transform.translation; - let target_rotation = target_transform.decompose_rotation(); - let target_scale = target_transform.decompose_scale(); + // Collect content into a Vec for index-based access (needed for closed subpath wrapping) + let content_rows: Vec<_> = content.into_iter().collect(); - // Interpolate transform components separately (using shortest-arc angular interpolation for rotation) - let lerped_translation = source_translation.lerp(target_translation, time); - let mut rotation_diff = target_rotation - source_rotation; - if rotation_diff > PI { - rotation_diff -= TAU; - } else if rotation_diff < -PI { - rotation_diff += TAU; + // At the end of an open subpath with no more interpolation needed, return the final element + if !is_closed && time >= 1. && local_source_index >= subpath_anchors - 2 { + return std::iter::once(content_rows.into_iter().nth(target_index).unwrap()).collect(); } - let lerped_rotation = source_rotation + rotation_diff * time; - let lerped_scale = source_scale.lerp(target_scale, time); - let lerped_transform = DAffine2::from_scale_angle_translation(lerped_scale, lerped_rotation, lerped_translation); + let source_row = &content_rows[source_index]; + let target_row = &content_rows[target_index]; + + // Lerp styles + let vector_alpha_blending = source_row.alpha_blending.lerp(&target_row.alpha_blending, time as f32); + + // Evaluate the spatial position on the control path for the translation component + let path_position = { + // Use the original path segment index (not the clamped object index) + let segment_index = path_segment_index.min(segment_count - 1); + let segment = control_bezpath.get_seg(segment_index + 1).unwrap(); + let point = segment.eval(parametric_t); + DVec2::new(point.x, point.y) + }; + + // Interpolate rotation, scale, and skew between source and target, but use the path position for translation. + // This decomposition must match the one used in Stroke::lerp so the renderer's stroke_transform.inverse() + // correctly cancels the element transform, keeping the stroke uniform when Stroke is after Transform. + let lerped_transform = { + let (s_angle, s_scale, s_skew) = source_row.transform.decompose_rotation_scale_skew(); + let (t_angle, t_scale, t_skew) = target_row.transform.decompose_rotation_scale_skew(); + + let lerp = |a: f64, b: f64| a + (b - a) * time; + + // Shortest-arc rotation interpolation + let mut rotation_diff = t_angle - s_angle; + if rotation_diff > PI { + rotation_diff -= TAU; + } else if rotation_diff < -PI { + rotation_diff += TAU; + } + let lerped_angle = s_angle + rotation_diff * time; + + let trs = DAffine2::from_scale_angle_translation(s_scale.lerp(t_scale, time), lerped_angle, path_position); + let skew = DAffine2::from_cols_array(&[1., 0., lerp(s_skew, t_skew), 1., 0., 0.]); + trs * skew + }; // Pre-compensate upstream_data transforms so that when collect_metadata applies // the row transform (which will be group_transform * lerped_transform after the @@ -2776,12 +2895,15 @@ mod test { second_rectangle.transform *= DAffine2::from_translation((-100., -100.).into()); rectangles.push(second_rectangle); - let morphed = super::morph(Footprint::default(), rectangles, 0.5).await; - let element = morphed.iter().next().unwrap().element; + let morphed = super::morph(Footprint::default(), rectangles, 0.5, Table::default()).await; + let row = morphed.iter().next().unwrap(); + // Geometry stays in local space (original rectangle coordinates) assert_eq!( - &element.point_domain.positions()[..4], - vec![DVec2::new(-50., -50.), DVec2::new(50., -50.), DVec2::new(50., 50.), DVec2::new(-50., 50.)] + &row.element.point_domain.positions()[..4], + vec![DVec2::new(0., 0.), DVec2::new(100., 0.), DVec2::new(100., 100.), DVec2::new(0., 100.)] ); + // The interpolated transform carries the midpoint translation (approximate due to arc-length parameterization) + assert!((row.transform.translation - DVec2::new(-50., -50.)).length() < 1e-3); } #[track_caller] From bace1ce74b28b4ab10068e675a39180b591b3b67 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 25 Mar 2026 18:31:30 -0700 Subject: [PATCH 04/20] Add migration from Morph node v2 to v3 --- .../messages/portfolio/document_migration.rs | 87 ++++++++++++++++++- 1 file changed, 84 insertions(+), 3 deletions(-) diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index c82892adee..6f5e996fce 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -1664,12 +1664,15 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], .set_input(&InputConnector::node(*node_id, 1), NodeInput::value(TaggedValue::U32(0), false), network_path); } - // Migrate from the old source/target "Morph" node to the new vector table based "Morph" node. + // Migrate from the old source/target v1 "Morph" node to the new vector table based v2 "Morph" node. // This doesn't produce exactly equivalent results in cases involving input vector tables with multiple rows. // The old version would zip the source and target table rows, interpoleating each pair together. // The migrated version will instead deeply flatten both merged tables and morph sequentially between all source vectors and all target vector elements. // This migration assumes most usages didn't involve multiple parallel vector elements, and instead morphed from a single source to a single target vector element. - if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::morph::IDENTIFIER) && (inputs_count == 3 || inputs_count == 4) { + // The new signature has 3 inputs too, so we distinguish by checking if input 2 is an f64 (old `time` param) vs a Table (new `path` param). + let is_old_morph = reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::morph::IDENTIFIER) + && (inputs_count == 4 || (inputs_count == 3 && node.inputs.get(2).and_then(|i| i.as_value()).is_some_and(|v| matches!(v, TaggedValue::F64(_))))); + if is_old_morph { // 3 inputs - old signature (#3405): // async fn morph(_: impl Ctx, source: Table, #[expose] target: Table, #[default(0.5)] time: Fraction) -> Table { ... } // @@ -1677,7 +1680,7 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], // async fn morph(_: impl Ctx, source: Table, #[expose] target: Table, #[default(0.5)] time: Fraction, #[min(0.)] start_index: IntegerCount) -> Table { ... } // // New signature: - // async fn morph(_: impl Ctx, #[implementations(Table, Table)] content: I, progression: Progression) -> Table { ... } + // async fn morph(_: impl Ctx, #[implementations(Table, Table)] content: I, progression: Progression, path: Table) -> Table { ... } let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; @@ -1712,6 +1715,84 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], .set_input(&InputConnector::node(*node_id, 0), NodeInput::node(merge_node_id, 0), network_path); // Connect the old 'progression' input to the new 'progression' input of the Morph node document.network_interface.set_input(&InputConnector::node(*node_id, 1), old_inputs[2].clone(), network_path); + + inputs_count = 3; + } + + // Migrate from the v2 "Morph" node (2 inputs: content, progression) to the v3 "Morph" node (3 inputs: content, progression, path). + // The old progression used integer part for pair selection (range 0..N-1 where N is the number of content objects). + // The new progression uses fractional 0..1 for euclidean traversal through all objects. + // We insert Count Elements → Subtract 1 → Divide to remap: new_progression = old_progression / max(N - 1, 1). + // For the common 2-object case (N=2), this divides by 1 which is a no-op, preserving identical behavior. + if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::morph::IDENTIFIER) && inputs_count == 2 { + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; + + // Reconnect content (input 0) and leave path (input 2) as default + document.network_interface.set_input(&InputConnector::node(*node_id, 0), old_inputs[0].clone(), network_path); + + let Some(morph_position) = document.network_interface.position_from_downstream_node(node_id, network_path) else { + log::error!("Could not get position for morph node {node_id}"); + document.network_interface.set_input(&InputConnector::node(*node_id, 1), old_inputs[1].clone(), network_path); + return None; + }; + + // Create Count Elements node: counts content table rows → N + let Some(count_elements_def) = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(graphene_std::vector::count_elements::IDENTIFIER)) else { + log::error!("Could not get count_elements node from definition when upgrading morph"); + document.network_interface.set_input(&InputConnector::node(*node_id, 1), old_inputs[1].clone(), network_path); + return None; + }; + let count_elements_template = count_elements_def.default_node_template(); + let count_elements_id = NodeId::new(); + + // Create Subtract node: N - 1 → N-1 + let Some(subtract_def) = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::subtract::IDENTIFIER)) else { + log::error!("Could not get subtract node from definition when upgrading morph"); + document.network_interface.set_input(&InputConnector::node(*node_id, 1), old_inputs[1].clone(), network_path); + return None; + }; + let mut subtract_template = subtract_def.default_node_template(); + subtract_template.document_node.inputs[1] = NodeInput::value(TaggedValue::F64(1.), false); + let subtract_id = NodeId::new(); + + // Create Divide node: old_progression / (N-1) → new progression + let Some(divide_def) = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::divide::IDENTIFIER)) else { + log::error!("Could not get divide node from definition when upgrading morph"); + document.network_interface.set_input(&InputConnector::node(*node_id, 1), old_inputs[1].clone(), network_path); + return None; + }; + let divide_template = divide_def.default_node_template(); + let divide_id = NodeId::new(); + + // Insert and position nodes + document.network_interface.insert_node(count_elements_id, count_elements_template, network_path); + document + .network_interface + .shift_absolute_node_position(&count_elements_id, morph_position + IVec2::new(-21, 2), network_path); + + document.network_interface.insert_node(subtract_id, subtract_template, network_path); + document.network_interface.shift_absolute_node_position(&subtract_id, morph_position + IVec2::new(-14, 2), network_path); + + document.network_interface.insert_node(divide_id, divide_template, network_path); + document.network_interface.shift_absolute_node_position(÷_id, morph_position + IVec2::new(-7, 1), network_path); + + // Wire: content source → Count Elements input 0 + document.network_interface.set_input(&InputConnector::node(count_elements_id, 0), old_inputs[0].clone(), network_path); + + // Wire: Count Elements output → Subtract input 0 (minuend) + document + .network_interface + .set_input(&InputConnector::node(subtract_id, 0), NodeInput::node(count_elements_id, 0), network_path); + + // Wire: old progression → Divide input 0 (numerator) + document.network_interface.set_input(&InputConnector::node(divide_id, 0), old_inputs[1].clone(), network_path); + + // Wire: Subtract output → Divide input 1 (denominator) + document.network_interface.set_input(&InputConnector::node(divide_id, 1), NodeInput::node(subtract_id, 0), network_path); + + // Wire: Divide output → Morph progression input + document.network_interface.set_input(&InputConnector::node(*node_id, 1), NodeInput::node(divide_id, 0), network_path); } // Migrate old Arrow node from (start, end, shaft_width, head_width, head_length) to (arrow_to, shaft_width, head_width, head_length) with a Transform node for positioning From f5ae79ad093249e89d4cbf8b759681857d9f85d7 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 25 Mar 2026 18:38:00 -0700 Subject: [PATCH 05/20] Redesign the 'Blend Shapes' node behavior and subgraph definition --- .../node_graph/document_node_definitions.rs | 264 ++++++------------ 1 file changed, 90 insertions(+), 174 deletions(-) diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index dc17eb5aec..4a67075743 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -464,182 +464,117 @@ fn document_node_definitions() -> HashMap 0[0:Floor] - // [0:Floor]0 -> 0[1:Subtract] - // "1: f64" -> 1[1:Subtract] - // "(): ()" -> 0[2:Read Index] - // "0: u32" -> 1[2:Read Index] - // [2:Read Index]0 -> 0[3:Divide] - // [1:Subtract]0 -> 1[3:Divide] - // [IMPORTS]1 -> 0[4:Position on Path] - // [3:Divide]0 -> 1[4:Position on Path] - // "false: bool" -> 2[4:Position on Path] - // "false: bool" -> 3[4:Position on Path] - // "(): ()" -> 0[5:Read Vector] - // [5:Read Vector]0 -> 0[6:Reset Transform] - // "true: bool" -> 1[6:Reset Transform] - // "false: bool" -> 2[6:Reset Transform] - // "false: bool" -> 3[6:Reset Transform] - // [12:Flatten Vector]0 -> 0[7:Map] - // [6:Reset Transform]0 -> 1[7:Map] - // [7:Map]0 -> 0[8:Morph] - // [15:Multiply]0 -> 1[8:Morph] - // [8:Morph]0 -> 0[9:Transform] - // [4:Position on Path]0 -> 1[9:Transform] - // "0: f64" -> 2[9:Transform] - // "(0, 0): DVec2" -> 3[9:Transform] - // "(0, 0): DVec2" -> 4[9:Transform] - // [IMPORTS]1 -> 0[10:Count Points] - // [10:Count Points]0 -> 0[11:Equals] - // [13:Count Elements]0 -> 1[11:Equals] - // [IMPORTS]0 -> 0[12:Flatten Vector] - // [12:Flatten Vector]0 -> 0[13:Count Elements] - // [13:Count Elements]0 -> 0[14:Subtract] - // "1: f64" -> 1[14:Subtract] - // [3:Divide]0 -> 0[15:Multiply] - // [14:Subtract]0 -> 1[15:Multiply] - // [12:Flatten Vector]0 -> 0[16:Morph] - // [15:Multiply]0 -> 1[16:Morph] - // [11:Equals]0 -> 0[17:Switch] - // [9:Transform]0 -> 1[17:Switch] - // [16:Morph]0 -> 2[17:Switch] - // [17:Switch]0 -> 0[18:Repeat] - // [0:Floor]0 -> 1[18:Repeat] - // [IMPORTS]3 -> 2[18:Repeat] - // [18:Repeat]0 -> 0[EXPORTS] node_template: NodeTemplate { document_node: DocumentNode { implementation: DocumentNodeImplementation::Network(NodeNetwork { - exports: vec![NodeInput::node(NodeId(18), 0)], + exports: vec![NodeInput::node(NodeId(16), 0)], nodes: [ - // 0: Floor + // 0: Separate Subpaths (split path into individual subpaths) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(math_nodes::floor::IDENTIFIER), - inputs: vec![NodeInput::import(concrete!(f64), 2)], - ..Default::default() - }, - // 1: Subtract - DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(math_nodes::subtract::IDENTIFIER), - inputs: vec![NodeInput::node(NodeId(0), 0), NodeInput::value(TaggedValue::F64(1.), false)], + implementation: DocumentNodeImplementation::ProtoNode(vector::separate_subpaths::IDENTIFIER), + inputs: vec![NodeInput::import(generic!(T), 1)], ..Default::default() }, - // 2: Read Index + // 1: Count Elements (number of subpaths) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(context::read_index::IDENTIFIER), - inputs: vec![NodeInput::value(TaggedValue::None, false), NodeInput::value(TaggedValue::U32(0), false)], + implementation: DocumentNodeImplementation::ProtoNode(vector::count_elements::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(0), 0)], ..Default::default() }, - // 3: Divide + // 2: Max (clamp subpath count to at least 1 for empty path case) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(math_nodes::divide::IDENTIFIER), - inputs: vec![NodeInput::node(NodeId(2), 0), NodeInput::node(NodeId(1), 0)], + implementation: DocumentNodeImplementation::ProtoNode(math_nodes::max::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(1), 0), NodeInput::value(TaggedValue::F64(1.), false)], ..Default::default() }, - // 4: Position on Path + // 3: Floor (integer count per subpath) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(vector_nodes::position_on_path::IDENTIFIER), - inputs: vec![ - NodeInput::import(generic!(T), 1), - NodeInput::node(NodeId(3), 0), - NodeInput::value(TaggedValue::Bool(false), false), - NodeInput::value(TaggedValue::Bool(false), false), - ], + implementation: DocumentNodeImplementation::ProtoNode(math_nodes::floor::IDENTIFIER), + inputs: vec![NodeInput::import(concrete!(f64), 2)], ..Default::default() }, - // 5: Read Vector + // 4: Multiply (total_instances = count × subpath_count) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(context::read_vector::IDENTIFIER), - inputs: vec![NodeInput::value(TaggedValue::None, false)], + implementation: DocumentNodeImplementation::ProtoNode(math_nodes::multiply::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(17), 0), NodeInput::node(NodeId(2), 0)], ..Default::default() }, - // 6: Reset Transform + // 5: Subtract (count - 1, open subpath denominator) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(transform_nodes::reset_transform::IDENTIFIER), - inputs: vec![ - NodeInput::node(NodeId(5), 0), - NodeInput::value(TaggedValue::Bool(true), false), - NodeInput::value(TaggedValue::Bool(false), false), - NodeInput::value(TaggedValue::Bool(false), false), - ], + implementation: DocumentNodeImplementation::ProtoNode(math_nodes::subtract::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(17), 0), NodeInput::value(TaggedValue::F64(1.), false)], ..Default::default() }, - // 7: Map + // 6: Read Index (current repetition index) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(graphic::map::IDENTIFIER), - inputs: vec![NodeInput::node(NodeId(12), 0), NodeInput::node(NodeId(6), 0)], + implementation: DocumentNodeImplementation::ProtoNode(context::read_index::IDENTIFIER), + inputs: vec![NodeInput::value(TaggedValue::None, false), NodeInput::value(TaggedValue::U32(0), false)], ..Default::default() }, - // 8: Morph + // 7: Divide (index / count) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(vector::morph::IDENTIFIER), - inputs: vec![NodeInput::node(NodeId(7), 0), NodeInput::node(NodeId(15), 0)], + implementation: DocumentNodeImplementation::ProtoNode(math_nodes::divide::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(6), 0), NodeInput::node(NodeId(17), 0)], ..Default::default() }, - // 9: Transform + // 8: Floor (floor(index / count) = subpath index) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(transform_nodes::transform::IDENTIFIER), - inputs: vec![ - NodeInput::node(NodeId(8), 0), - NodeInput::node(NodeId(4), 0), - NodeInput::value(TaggedValue::F64(0.), false), - NodeInput::value(TaggedValue::DVec2(DVec2::ONE), false), - NodeInput::value(TaggedValue::DVec2(DVec2::ZERO), false), - ], + implementation: DocumentNodeImplementation::ProtoNode(math_nodes::floor::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(7), 0)], ..Default::default() }, - // 10: Count Points + // 9: Modulo (index % count = local index within subpath) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(vector_nodes::count_points::IDENTIFIER), - inputs: vec![NodeInput::import(generic!(T), 1)], + implementation: DocumentNodeImplementation::ProtoNode(math_nodes::modulo::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(6), 0), NodeInput::node(NodeId(17), 0), NodeInput::value(TaggedValue::Bool(true), false)], ..Default::default() }, - // 11: Equals + // 10: Path Is Closed (check if current subpath is closed) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(math_nodes::equals::IDENTIFIER), - inputs: vec![NodeInput::node(NodeId(10), 0), NodeInput::node(NodeId(13), 0)], + implementation: DocumentNodeImplementation::ProtoNode(vector::path_is_closed::IDENTIFIER), + inputs: vec![NodeInput::import(generic!(T), 1), NodeInput::node(NodeId(8), 0)], ..Default::default() }, - // 12: Flatten Vector + // 11: Switch (closed → count, open → count - 1 as denominator) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(graphic_nodes::graphic::flatten_vector::IDENTIFIER), - inputs: vec![NodeInput::import(generic!(T), 0)], + implementation: DocumentNodeImplementation::ProtoNode(logic::switch::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(10), 0), NodeInput::node(NodeId(17), 0), NodeInput::node(NodeId(5), 0)], ..Default::default() }, - // 13: Count Elements + // 12: Divide (local_index / denominator = within-subpath fraction) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(vector::count_elements::IDENTIFIER), - inputs: vec![NodeInput::node(NodeId(12), 0)], + implementation: DocumentNodeImplementation::ProtoNode(math_nodes::divide::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(9), 0), NodeInput::node(NodeId(11), 0)], ..Default::default() }, - // 14: Subtract + // 13: Multiply (fraction × 0.9999999999 to avoid overflowing to the next subpath) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(math_nodes::subtract::IDENTIFIER), - inputs: vec![NodeInput::node(NodeId(13), 0), NodeInput::value(TaggedValue::F64(1.), false)], + implementation: DocumentNodeImplementation::ProtoNode(math_nodes::multiply::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(12), 0), NodeInput::value(TaggedValue::F64(0.9999999999), false)], ..Default::default() }, - // 15: Multiply + // 14: Add (subpath_index + clamped fraction = progression) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(math_nodes::multiply::IDENTIFIER), - inputs: vec![NodeInput::node(NodeId(3), 0), NodeInput::node(NodeId(14), 0)], + implementation: DocumentNodeImplementation::ProtoNode(math_nodes::add::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(8), 0), NodeInput::node(NodeId(13), 0)], ..Default::default() }, - // 16: Morph + // 15: Morph (content, progression, path) DocumentNode { implementation: DocumentNodeImplementation::ProtoNode(vector::morph::IDENTIFIER), - inputs: vec![NodeInput::node(NodeId(12), 0), NodeInput::node(NodeId(15), 0)], + inputs: vec![NodeInput::import(generic!(T), 0), NodeInput::node(NodeId(14), 0), NodeInput::import(generic!(T), 1)], ..Default::default() }, - // 17: Switch + // 16: Repeat DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(logic::switch::IDENTIFIER), - inputs: vec![NodeInput::node(NodeId(11), 0), NodeInput::node(NodeId(9), 0), NodeInput::node(NodeId(16), 0)], + implementation: DocumentNodeImplementation::ProtoNode(repeat_nodes::repeat::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(15), 0), NodeInput::node(NodeId(4), 0), NodeInput::import(generic!(T), 3)], ..Default::default() }, - // 18: Repeat + // 17: Max (clamp count to at least 1) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(repeat_nodes::repeat::IDENTIFIER), - inputs: vec![NodeInput::node(NodeId(17), 0), NodeInput::node(NodeId(0), 0), NodeInput::import(generic!(T), 3)], + implementation: DocumentNodeImplementation::ProtoNode(math_nodes::max::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(3), 0), NodeInput::value(TaggedValue::F64(1.), false)], ..Default::default() }, ] @@ -664,154 +599,146 @@ fn document_node_definitions() -> HashMap HashMap 0[0:Read Vector] - // [0:Read Vector]0 -> 0[1:Extract Transform] - // [1:Extract Transform]0 -> 0[2:Decompose Translation] - // [2:Decompose Translation]0 -> 0[3:Vec2 to Point] - // [IMPORTS]0 -> 0[4:Flatten Vector] - // [4:Flatten Vector]0 -> 0[5:Map] - // [3:Vec2 to Point]0 -> 1[5:Map] - // [5:Map]0 -> 0[6: Flatten Path] - // [6:Flatten Path]0 -> 0[7:Points to Polyline] - // "false: bool" -> 1[7:Points to Polyline] - // [7:Points to Polyline]0 -> 0[EXPORTS] node_template: NodeTemplate { document_node: DocumentNode { implementation: DocumentNodeImplementation::Network(NodeNetwork { From d54358c678345a63b0761e6ed71b52bcad5665f4 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Thu, 26 Mar 2026 01:52:44 -0700 Subject: [PATCH 06/20] Add the Layer > Blend menu entry to easily set up a blend --- .branding | 4 +- .../messages/input_mapper/input_mappings.rs | 1 + .../menu_bar/menu_bar_message_handler.rs | 6 ++ .../portfolio/document/document_message.rs | 1 + .../document/document_message_handler.rs | 38 ++++++++++++ .../graph_operation_message.rs | 11 ++++ .../graph_operation_message_handler.rs | 60 +++++++++++++++++++ .../document/graph_operation/utility_types.rs | 41 +++++++++++++ .../node_graph/document_node_definitions.rs | 2 +- .../portfolio/document/utility_types/misc.rs | 1 + frontend/src/icons.ts | 2 + node-graph/nodes/vector/src/vector_nodes.rs | 2 +- website/content/features.md | 2 +- 13 files changed, 166 insertions(+), 5 deletions(-) diff --git a/.branding b/.branding index 1e29c19dff..4da5da8567 100644 --- a/.branding +++ b/.branding @@ -1,2 +1,2 @@ -https://github.com/Keavon/graphite-branded-assets/archive/8ae15dc9c51a3855475d8cab1d0f29d9d9bc622c.tar.gz -c19abe4ac848f3c835e43dc065c59e20e60233ae023ea0a064c5fed442be2d3d +https://github.com/Keavon/graphite-branded-assets/archive/650b87537d8d9389f7d0cd8044870109b0579956.tar.gz +a8be5d9f0f9d2a68c80e0188444fc05d367438fb68e880e5425f3cfd7edb6add diff --git a/editor/src/messages/input_mapper/input_mappings.rs b/editor/src/messages/input_mapper/input_mappings.rs index b9bb234dc9..9b46270ba5 100644 --- a/editor/src/messages/input_mapper/input_mappings.rs +++ b/editor/src/messages/input_mapper/input_mappings.rs @@ -357,6 +357,7 @@ pub fn input_mappings(zoom_with_scroll: bool) -> Mapping { entry!(KeyDown(KeyS); modifiers=[Accel, Shift], action_dispatch=DocumentMessage::SaveDocumentAs), entry!(KeyDown(KeyD); modifiers=[Accel], canonical, action_dispatch=DocumentMessage::DuplicateSelectedLayers), entry!(KeyDown(KeyJ); modifiers=[Accel], action_dispatch=DocumentMessage::DuplicateSelectedLayers), + entry!(KeyDown(KeyB); modifiers=[Accel, Alt], action_dispatch=DocumentMessage::BlendSelectedLayers), entry!(KeyDown(KeyG); modifiers=[Accel], action_dispatch=DocumentMessage::GroupSelectedLayers { group_folder_type: GroupFolderType::Layer }), entry!(KeyDown(KeyG); modifiers=[Accel, Shift], action_dispatch=DocumentMessage::UngroupSelectedLayers), entry!(KeyDown(KeyN); modifiers=[Accel, Shift], action_dispatch=DocumentMessage::CreateEmptyFolder), diff --git a/editor/src/messages/menu_bar/menu_bar_message_handler.rs b/editor/src/messages/menu_bar/menu_bar_message_handler.rs index 8d7adeb73a..247dd6ac1f 100644 --- a/editor/src/messages/menu_bar/menu_bar_message_handler.rs +++ b/editor/src/messages/menu_bar/menu_bar_message_handler.rs @@ -495,6 +495,12 @@ impl LayoutHolder for MenuBarMessageHandler { }) .disabled(no_active_document || !has_selected_layers), ]]), + MenuListEntry::new("Blend") + .label("Blend") + .icon("BlendShapes") + .tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::BlendSelectedLayers)) + .on_commit(|_| DocumentMessage::BlendSelectedLayers.into()) + .disabled(no_active_document || !has_selected_layers), ], vec![ MenuListEntry::new("Make Path Editable") diff --git a/editor/src/messages/portfolio/document/document_message.rs b/editor/src/messages/portfolio/document/document_message.rs index babe195b5f..a7eeee9239 100644 --- a/editor/src/messages/portfolio/document/document_message.rs +++ b/editor/src/messages/portfolio/document/document_message.rs @@ -84,6 +84,7 @@ pub enum DocumentMessage { GridVisibility { visible: bool, }, + BlendSelectedLayers, GroupSelectedLayers { group_folder_type: GroupFolderType, }, diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 68b79a8d7f..baba0ae5a7 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -624,6 +624,9 @@ impl MessageHandler> for DocumentMes self.snapping_state.grid_snapping = visible; responses.add(OverlaysMessage::Draw); } + DocumentMessage::BlendSelectedLayers => { + self.handle_group_selected_layers(GroupFolderType::BlendShapes, responses); + } DocumentMessage::GroupSelectedLayers { group_folder_type } => { self.handle_group_selected_layers(group_folder_type, responses); } @@ -1961,6 +1964,41 @@ impl DocumentMessageHandler { }); } } + GroupFolderType::BlendShapes => { + let blend_path_id = NodeId(generate_uuid()); + let all_layers_to_group = network_interface.shallowest_unique_layers_sorted(&[]); + responses.add(GraphOperationMessage::NewBlendShapesLayer { + id: folder_id, + blend_path_id, + parent, + insert_index, + count: all_layers_to_group.len() * 10, + }); + + let new_group_folder = LayerNodeIdentifier::new_unchecked(folder_id); + + // Move selected layers into the group as children + for layer_to_group in all_layers_to_group.into_iter().rev() { + responses.add(NodeGraphMessage::MoveLayerToStack { + layer: layer_to_group, + parent: new_group_folder, + insert_index: 0, + }); + } + + // Connect the child stack to the Blend Path layer as a co-parent + responses.add(GraphOperationMessage::ConnectBlendPathToChildren { + blend_shape_id: folder_id, + blend_path_id, + }); + + responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![folder_id] }); + responses.add(NodeGraphMessage::RunDocumentGraph); + responses.add(DocumentMessage::DocumentStructureChanged); + responses.add(NodeGraphMessage::SendGraph); + + return folder_id; + } } let new_group_folder = LayerNodeIdentifier::new_unchecked(folder_id); diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs index 4d94f713ba..59d300a771 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs @@ -74,6 +74,17 @@ pub enum GraphOperationMessage { parent: LayerNodeIdentifier, insert_index: usize, }, + NewBlendShapesLayer { + id: NodeId, + blend_path_id: NodeId, + parent: LayerNodeIdentifier, + insert_index: usize, + count: usize, + }, + ConnectBlendPathToChildren { + blend_shape_id: NodeId, + blend_path_id: NodeId, + }, NewBooleanOperationLayer { id: NodeId, operation: graphene_std::vector::misc::BooleanOperation, diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index 03fc166dd4..98d23dc8c4 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -172,6 +172,66 @@ impl MessageHandler> for network_interface.move_layer_to_stack(layer, parent, insert_index, &[]); responses.add(NodeGraphMessage::RunDocumentGraph); } + GraphOperationMessage::NewBlendShapesLayer { + id, + blend_path_id, + parent, + insert_index, + count, + } => { + let mut modify_inputs = ModifyInputsContext::new(network_interface, responses); + let layer = modify_inputs.create_layer(id); + let blend_shapes_node_id = modify_inputs.insert_blend_shapes_data(layer, count as f64); + let blend_path_layer = modify_inputs.create_layer(blend_path_id); + let path_node_id = modify_inputs.insert_blend_path_data(blend_path_layer); + + network_interface.move_layer_to_stack(layer, parent, insert_index, &[]); + network_interface.move_layer_to_stack(blend_path_layer, parent, insert_index + 1, &[]); + + // Connect the Path node's output to the Blend Shapes node's Path parameter input (input 1). + // Done after move_layer_to_stack so chain nodes have correct positions when converted to absolute. + network_interface.set_input(&InputConnector::node(blend_shapes_node_id, 1), NodeInput::node(path_node_id, 0), &[]); + + responses.add(NodeGraphMessage::SetDisplayNameImpl { + node_id: id, + alias: "Blend Shape".to_string(), + }); + responses.add(NodeGraphMessage::SetDisplayNameImpl { + node_id: blend_path_id, + alias: "Blend Path".to_string(), + }); + } + GraphOperationMessage::ConnectBlendPathToChildren { blend_shape_id, blend_path_id } => { + // Find the Blend Shapes node (first in chain of the Blend Shape layer) + let Some(OutputConnector::Node { node_id: chain_node, .. }) = network_interface.upstream_output_connector(&InputConnector::node(blend_shape_id, 1), &[]) else { + log::error!("Could not find Blend Shapes chain node for layer {blend_shape_id}"); + return; + }; + + // Get what feeds into the Blend Shapes node's primary input (the children stack) + let Some(OutputConnector::Node { node_id: children_id, output_index }) = network_interface.upstream_output_connector(&InputConnector::node(chain_node, 0), &[]) else { + log::error!("Could not find children stack feeding Blend Shapes node {chain_node}"); + return; + }; + + // Find the deepest node in the Blend Path layer's chain (Origins to Polyline) + let mut deepest_chain_node = None; + let mut current_connector = InputConnector::node(blend_path_id, 1); + while let Some(OutputConnector::Node { node_id, .. }) = network_interface.upstream_output_connector(¤t_connector, &[]) { + deepest_chain_node = Some(node_id); + current_connector = InputConnector::node(node_id, 0); + } + + // Connect children to the deepest chain node's input 0 (or the layer's input 1 if no chain) + let target_connector = match deepest_chain_node { + Some(node_id) => InputConnector::node(node_id, 0), + None => InputConnector::node(blend_path_id, 1), + }; + network_interface.set_input(&target_connector, NodeInput::node(children_id, output_index), &[]); + + // Shift the child stack (topmost child only, the rest follow) down 3 and left 10 + network_interface.shift_node(&children_id, IVec2::new(-10, 3), &[]); + } GraphOperationMessage::NewBooleanOperationLayer { id, operation, parent, insert_index } => { let mut modify_inputs = ModifyInputsContext::new(network_interface, responses); let layer = modify_inputs.create_layer(id); diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index ca92e7134d..cc0f735908 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -156,6 +156,47 @@ impl<'a> ModifyInputsContext<'a> { self.network_interface.move_node_to_chain_start(&boolean_id, layer, &[], self.import); } + pub fn insert_blend_shapes_data(&mut self, layer: LayerNodeIdentifier, count: f64) -> NodeId { + let blend_shapes = resolve_network_node_type("Blend Shapes").expect("Blend Shapes node does not exist").node_template_input_override([ + Some(NodeInput::value(TaggedValue::Graphic(Default::default()), true)), + Some(NodeInput::value(TaggedValue::Vector(Default::default()), true)), + Some(NodeInput::value(TaggedValue::F64(count), false)), + ]); + + let blend_shapes_id = NodeId::new(); + self.network_interface.insert_node(blend_shapes_id, blend_shapes, &[]); + self.network_interface.move_node_to_chain_start(&blend_shapes_id, layer, &[], self.import); + + blend_shapes_id + } + + /// Returns the Path node ID (the node closest to the layer's merge node in the chain). + pub fn insert_blend_path_data(&mut self, layer: LayerNodeIdentifier) -> NodeId { + // Add Origins to Polyline node first (will be pushed deepest in the chain) + let origins_to_polyline = resolve_network_node_type("Origins to Polyline") + .expect("Origins to Polyline node does not exist") + .default_node_template(); + let origins_to_polyline_id = NodeId::new(); + self.network_interface.insert_node(origins_to_polyline_id, origins_to_polyline, &[]); + self.network_interface.move_node_to_chain_start(&origins_to_polyline_id, layer, &[], self.import); + + // Add Auto-Tangents node (between Origins to Polyline and Path), with spread=1 and preserve_existing=false + let auto_tangents = resolve_proto_node_type(graphene_std::vector::auto_tangents::IDENTIFIER) + .expect("Auto-Tangents node does not exist") + .node_template_input_override([None, Some(NodeInput::value(TaggedValue::F64(1.), false)), Some(NodeInput::value(TaggedValue::Bool(false), false))]); + let auto_tangents_id = NodeId::new(); + self.network_interface.insert_node(auto_tangents_id, auto_tangents, &[]); + self.network_interface.move_node_to_chain_start(&auto_tangents_id, layer, &[], self.import); + + // Add Path node to chain start (closest to the Merge node) + let path = resolve_network_node_type("Path").expect("Path node does not exist").default_node_template(); + let path_id = NodeId::new(); + self.network_interface.insert_node(path_id, path, &[]); + self.network_interface.move_node_to_chain_start(&path_id, layer, &[], self.import); + + path_id + } + pub fn insert_vector(&mut self, subpaths: Vec>, layer: LayerNodeIdentifier, include_transform: bool, include_fill: bool, include_stroke: bool) { let vector = Table::new_from_element(Vector::from_subpaths(subpaths, true)); diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index 4a67075743..7e614e84c6 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -586,7 +586,7 @@ fn document_node_definitions() -> HashMap) -> Table } /// Determines if the subpath at the given index (across all vector element subpaths) is closed, meaning its ends are connected together forming a loop. -#[node_macro::node(name("Path is Closed"), category("Vector"), path(core_types::vector))] +#[node_macro::node(name("Path is Closed"), category("Vector: Measure"), path(core_types::vector))] async fn path_is_closed( _: impl Ctx, /// The vector content whose subpaths are inspected. diff --git a/website/content/features.md b/website/content/features.md index 8c08e86494..f7749065b3 100644 --- a/website/content/features.md +++ b/website/content/features.md @@ -138,7 +138,7 @@ Marrying vector and raster under one roof enables both art forms to complement e All-around performance optimizations -
+
Blend tool to morph between shapes
From ee94dc6e9cd8b3b225236f1a33b5b09c684f9796 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Thu, 26 Mar 2026 17:19:11 -0700 Subject: [PATCH 07/20] Optimize the Morph node --- node-graph/nodes/vector/src/vector_nodes.rs | 40 ++++++++++----------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 477a9ab02e..b8ddda0517 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -2116,22 +2116,22 @@ async fn morph( }; // Clamp to valid content range - let source_index = source_index.min(content.len() - 1); - let target_index = target_index.min(content.len() - 1); - - // Collect content into a Vec for index-based access (needed for closed subpath wrapping) - let content_rows: Vec<_> = content.into_iter().collect(); + let Some(max_index) = content.len().checked_sub(1) else { return content }; + let source_index = source_index.min(max_index); + let target_index = target_index.min(max_index); // At the end of an open subpath with no more interpolation needed, return the final element if !is_closed && time >= 1. && local_source_index >= subpath_anchors - 2 { - return std::iter::once(content_rows.into_iter().nth(target_index).unwrap()).collect(); + return content.into_iter().nth(target_index).into_iter().collect(); } - let source_row = &content_rows[source_index]; - let target_row = &content_rows[target_index]; + // Use indexed access to borrow only the two rows we need, avoiding collecting the entire table + let (Some(source_row), Some(target_row)) = (content.get(source_index), content.get(target_index)) else { + return content; + }; // Lerp styles - let vector_alpha_blending = source_row.alpha_blending.lerp(&target_row.alpha_blending, time as f32); + let vector_alpha_blending = source_row.alpha_blending.lerp(target_row.alpha_blending, time as f32); // Evaluate the spatial position on the control path for the translation component let path_position = { @@ -2180,11 +2180,16 @@ async fn morph( }; vector.style = source_row.element.style.lerp(&target_row.element.style, time); + // Cache bezpath reconstructions — stroke_bezpath_iter() rebuilds from internal representation each call + let mut source_bezpaths: Vec = source_row.element.stroke_bezpath_iter().collect(); + let mut target_bezpaths: Vec = target_row.element.stroke_bezpath_iter().collect(); + // Interpolate geometry in local space (no transform baked in) — the lerped transform handles positioning - let source_bezpaths = source_row.element.stroke_bezpath_iter(); - let target_bezpaths = target_row.element.stroke_bezpath_iter(); + let matched_count = source_bezpaths.len().min(target_bezpaths.len()); + let extra_source = source_bezpaths.split_off(matched_count); + let extra_target = target_bezpaths.split_off(matched_count); - for (mut source_bezpath, mut target_bezpath) in source_bezpaths.zip(target_bezpaths) { + for (mut source_bezpath, mut target_bezpath) in source_bezpaths.into_iter().zip(target_bezpaths) { if source_bezpath.elements().is_empty() || target_bezpath.elements().is_empty() { continue; } @@ -2216,16 +2221,11 @@ async fn morph( } } - vector.append_bezpath(source_bezpath.clone()); + vector.append_bezpath(source_bezpath); } // Deal with unmatched extra paths by collapsing them - let source_paths_count = source_row.element.stroke_bezpath_iter().count(); - let target_paths_count = target_row.element.stroke_bezpath_iter().count(); - let source_paths = source_row.element.stroke_bezpath_iter().skip(target_paths_count); - let target_paths = target_row.element.stroke_bezpath_iter().skip(source_paths_count); - - for mut source_path in source_paths { + for mut source_path in extra_source { // Skip if the path has no segments else get the point at the end of the path. let Some(end) = source_path.segments().last().map(|element| element.end()) else { continue }; @@ -2248,7 +2248,7 @@ async fn morph( vector.append_bezpath(source_path); } - for mut target_path in target_paths { + for mut target_path in extra_target { // Skip if the path has no segments else get the point at the start of the path. let Some(start) = target_path.segments().next().map(|element| element.start()) else { continue }; From a866d8bdedb14026bf054b28a226bb3c75ddfbb4 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Thu, 26 Mar 2026 18:59:06 -0700 Subject: [PATCH 08/20] Refactor the Morph node to remove the roundtrip through BezPath --- node-graph/nodes/vector/src/vector_nodes.rs | 264 ++++++++++++-------- 1 file changed, 160 insertions(+), 104 deletions(-) diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index b8ddda0517..bf880a1378 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -1952,46 +1952,102 @@ async fn morph( /// An optional control path whose anchor points correspond to each object. Curved segments between points will shape the morph trajectory instead of traveling straight. If there is a break between path segments, the separate subpaths are selected by index from the integer part of the progression value. For example, [1, 2) morphs along the segments of the second subpath, and so on. path: Table, ) -> Table { - /// Subdivides the last segment of the bezpath to until it appends 'count' number of segments. - fn make_new_segments(bezpath: &mut BezPath, count: usize) { - let bezpath_segment_count = bezpath.segments().count(); + /// Promotes a segment's handle pair to cubic-equivalent Bézier control points. + /// For linear segments (both None), handles are placed at 1/3 and 2/3 between anchors. + /// For quadratic segments (one handle), degree elevation is applied. + fn promote_handles_to_cubic(prev_anchor: DVec2, out_handle: Option, in_handle: Option, curr_anchor: DVec2) -> (DVec2, DVec2) { + match (out_handle, in_handle) { + (Some(handle_start), Some(handle_end)) => (handle_start, handle_end), + (None, None) => { + let third = (curr_anchor - prev_anchor) / 3.; + (prev_anchor + third, curr_anchor - third) + } + (Some(handle), None) | (None, Some(handle)) => { + let handle_start = prev_anchor + (handle - prev_anchor) * (2. / 3.); + let handle_end = curr_anchor + (handle - curr_anchor) * (2. / 3.); + (handle_start, handle_end) + } + } + } - if count == 0 || bezpath_segment_count == 0 { + /// Subdivides the last segment of a manipulator group list at its midpoint, adding one new manipulator. + /// For closed paths, the "last segment" is the closing segment from the last back to the first manipulator. + fn subdivide_last_manipulator_segment(manips: &mut Vec>, closed: bool) { + let len = manips.len(); + if len < 2 { return; } - // Initially push the last segment of the bezpath - let mut new_segments = vec![bezpath.get_seg(bezpath_segment_count).unwrap()]; + let (prev_idx, next_idx) = if closed { (len - 1, 0) } else { (len - 2, len - 1) }; + + let prev_anchor = manips[prev_idx].anchor; + let next_anchor = manips[next_idx].anchor; + let (h1, h2) = promote_handles_to_cubic(prev_anchor, manips[prev_idx].out_handle, manips[next_idx].in_handle, next_anchor); + + // De Casteljau subdivision at t=0.5 + let m01 = prev_anchor.lerp(h1, 0.5); + let m12 = h1.lerp(h2, 0.5); + let m23 = h2.lerp(next_anchor, 0.5); + let m012 = m01.lerp(m12, 0.5); + let m123 = m12.lerp(m23, 0.5); + let mid = m012.lerp(m123, 0.5); - // Generate new segments by subdividing last segment - for _ in 0..count { - let last = new_segments.pop().unwrap(); - let (first, second) = last.subdivide(); - new_segments.push(first); - new_segments.push(second); + manips[prev_idx].out_handle = Some(m01); + manips[next_idx].in_handle = Some(m23); + + let mid_manip = ManipulatorGroup { + anchor: mid, + in_handle: Some(m012), + out_handle: Some(m123), + id: PointId::ZERO, + }; + + if closed { + manips.push(mid_manip); + } else { + manips.insert(next_idx, mid_manip); } + } - // Append the new segments. - if count != 0 { - // Remove the last segment as it is already appended to the new_segments. - let mut is_closed = false; - if let Some(last_element) = bezpath.pop() - && last_element == PathEl::ClosePath - { - is_closed = true; - _ = bezpath.pop(); - } + /// Constructs BezierHandles from the out_handle of one manipulator and in_handle of the next. + fn handles_from_manips(out_handle: Option, in_handle: Option) -> BezierHandles { + match (out_handle, in_handle) { + (Some(handle_start), Some(handle_end)) => BezierHandles::Cubic { handle_start, handle_end }, + (None, None) => BezierHandles::Linear, + (Some(handle), None) | (None, Some(handle)) => BezierHandles::Quadratic { handle }, + } + } - for segment in new_segments { - if bezpath.elements().is_empty() { - bezpath.move_to(segment.start()) - } - bezpath.push(segment.as_path_el()); - } + /// Pushes a subpath (list of manipulators) directly into a Vector's point, segment, and region domains, + /// bypassing the BezPath intermediate representation used by `append_bezpath`. + fn push_manipulators_to_vector(vector: &mut Vector, manips: &[ManipulatorGroup], closed: bool, point_id: &mut PointId, segment_id: &mut SegmentId) { + let Some(first) = manips.first() else { return }; - if is_closed { - bezpath.close_path(); - } + let first_point_index = vector.point_domain.ids().len(); + vector.point_domain.push(point_id.next_id(), first.anchor); + let mut prev_point_index = first_point_index; + let mut first_segment_id = None; + + for manip_window in manips.windows(2) { + let point_index = vector.point_domain.ids().len(); + vector.point_domain.push(point_id.next_id(), manip_window[1].anchor); + + let handles = handles_from_manips(manip_window[0].out_handle, manip_window[1].in_handle); + let seg_id = segment_id.next_id(); + first_segment_id.get_or_insert(seg_id); + vector.segment_domain.push(seg_id, prev_point_index, point_index, handles, StrokeId::ZERO); + + prev_point_index = point_index; + } + + if closed && manips.len() > 1 { + let handles = handles_from_manips(manips.last().unwrap().out_handle, manips[0].in_handle); + let closing_seg_id = segment_id.next_id(); + first_segment_id.get_or_insert(closing_seg_id); + vector.segment_domain.push(closing_seg_id, prev_point_index, first_point_index, handles, StrokeId::ZERO); + + let region_id = vector.region_domain.next_id(); + vector.region_domain.push(region_id, first_segment_id.unwrap()..=closing_seg_id, FillId::ZERO); } } @@ -2180,95 +2236,95 @@ async fn morph( }; vector.style = source_row.element.style.lerp(&target_row.element.style, time); - // Cache bezpath reconstructions — stroke_bezpath_iter() rebuilds from internal representation each call - let mut source_bezpaths: Vec = source_row.element.stroke_bezpath_iter().collect(); - let mut target_bezpaths: Vec = target_row.element.stroke_bezpath_iter().collect(); + // Work directly with manipulator groups, bypassing the BezPath intermediate representation. + // This avoids the full Vector → BezPath → interpolate → BezPath → Vector roundtrip each frame. + let mut source_subpaths: Vec<_> = source_row.element.stroke_manipulator_groups().collect(); + let mut target_subpaths: Vec<_> = target_row.element.stroke_manipulator_groups().collect(); // Interpolate geometry in local space (no transform baked in) — the lerped transform handles positioning - let matched_count = source_bezpaths.len().min(target_bezpaths.len()); - let extra_source = source_bezpaths.split_off(matched_count); - let extra_target = target_bezpaths.split_off(matched_count); + let matched_count = source_subpaths.len().min(target_subpaths.len()); + let extra_source = source_subpaths.split_off(matched_count); + let extra_target = target_subpaths.split_off(matched_count); - for (mut source_bezpath, mut target_bezpath) in source_bezpaths.into_iter().zip(target_bezpaths) { - if source_bezpath.elements().is_empty() || target_bezpath.elements().is_empty() { + let mut point_id = PointId::ZERO; + let mut segment_id = SegmentId::ZERO; + + for ((mut source_manips, source_closed), (mut target_manips, target_closed)) in source_subpaths.into_iter().zip(target_subpaths) { + if source_manips.is_empty() || target_manips.is_empty() { continue; } - let target_segment_len = target_bezpath.segments().count(); - let source_segment_len = source_bezpath.segments().count(); - - // Insert new segments to align the number of segments in source_bezpath and target_bezpath. - make_new_segments(&mut source_bezpath, target_segment_len.max(source_segment_len) - source_segment_len); - make_new_segments(&mut target_bezpath, source_segment_len.max(target_segment_len) - target_segment_len); - - let source_segments = source_bezpath.segments().collect::>(); - let target_segments = target_bezpath.segments().collect::>(); - - // Interpolate anchors and handles - for (i, (source_element, target_element)) in source_bezpath.elements_mut().iter_mut().zip(target_bezpath.elements_mut().iter_mut()).enumerate() { - match source_element { - PathEl::MoveTo(point) => *point = point.lerp(target_element.end_point().unwrap(), time), - PathEl::ClosePath => {} - elm => { - let mut source_segment = source_segments.get(i - 1).unwrap().to_cubic(); - let target_segment = target_segments.get(i - 1).unwrap().to_cubic(); - source_segment.p0 = source_segment.p0.lerp(target_segment.p0, time); - source_segment.p1 = source_segment.p1.lerp(target_segment.p1, time); - source_segment.p2 = source_segment.p2.lerp(target_segment.p2, time); - source_segment.p3 = source_segment.p3.lerp(target_segment.p3, time); - *elm = PathSeg::Cubic(source_segment).as_path_el(); - } - } + // Align manipulator counts by subdividing the last segment of the shorter subpath + let source_count = source_manips.len(); + let target_count = target_manips.len(); + for _ in 0..target_count.saturating_sub(source_count) { + subdivide_last_manipulator_segment(&mut source_manips, source_closed); + } + for _ in 0..source_count.saturating_sub(target_count) { + subdivide_last_manipulator_segment(&mut target_manips, target_closed); + } + + // Build interpolated manipulator groups + let mut interpolated: Vec> = source_manips + .iter() + .zip(target_manips.iter()) + .map(|(s, t)| ManipulatorGroup { + anchor: s.anchor.lerp(t.anchor, time), + in_handle: None, + out_handle: None, + id: PointId::ZERO, + }) + .collect(); + + // Interpolate handles for each segment by promoting to cubic and lerping + let segment_count = if source_closed { source_manips.len() } else { source_manips.len().saturating_sub(1) }; + for segment_index in 0..segment_count { + let next_index = (segment_index + 1) % source_manips.len(); + + let (s_h1, s_h2) = promote_handles_to_cubic( + source_manips[segment_index].anchor, + source_manips[segment_index].out_handle, + source_manips[next_index].in_handle, + source_manips[next_index].anchor, + ); + let (t_h1, t_h2) = promote_handles_to_cubic( + target_manips[segment_index].anchor, + target_manips[segment_index].out_handle, + target_manips[next_index].in_handle, + target_manips[next_index].anchor, + ); + + interpolated[segment_index].out_handle = Some(s_h1.lerp(t_h1, time)); + interpolated[next_index].in_handle = Some(s_h2.lerp(t_h2, time)); } - vector.append_bezpath(source_bezpath); + push_manipulators_to_vector(&mut vector, &interpolated, source_closed, &mut point_id, &mut segment_id); } - // Deal with unmatched extra paths by collapsing them - for mut source_path in extra_source { - // Skip if the path has no segments else get the point at the end of the path. - let Some(end) = source_path.segments().last().map(|element| element.end()) else { continue }; + // Deal with unmatched extra source subpaths by collapsing them toward their end point + for (mut manips, closed) in extra_source { + let Some(end) = manips.last().map(|m| m.anchor) else { continue }; - for element in source_path.elements_mut() { - match element { - PathEl::MoveTo(point) => *point = point.lerp(end, time), - PathEl::LineTo(point) => *point = point.lerp(end, time), - PathEl::QuadTo(point, point1) => { - *point = point.lerp(end, time); - *point1 = point1.lerp(end, time); - } - PathEl::CurveTo(point, point1, point2) => { - *point = point.lerp(end, time); - *point1 = point1.lerp(end, time); - *point2 = point2.lerp(end, time); - } - PathEl::ClosePath => {} - } + for manip in &mut manips { + manip.anchor = manip.anchor.lerp(end, time); + manip.in_handle = manip.in_handle.map(|h| h.lerp(end, time)); + manip.out_handle = manip.out_handle.map(|h| h.lerp(end, time)); } - vector.append_bezpath(source_path); + + push_manipulators_to_vector(&mut vector, &manips, closed, &mut point_id, &mut segment_id); } - for mut target_path in extra_target { - // Skip if the path has no segments else get the point at the start of the path. - let Some(start) = target_path.segments().next().map(|element| element.start()) else { continue }; + // Deal with unmatched extra target subpaths by expanding them from their start point + for (mut manips, closed) in extra_target { + let Some(start) = manips.first().map(|m| m.anchor) else { continue }; - for element in target_path.elements_mut() { - match element { - PathEl::MoveTo(point) => *point = start.lerp(*point, time), - PathEl::LineTo(point) => *point = start.lerp(*point, time), - PathEl::QuadTo(point, point1) => { - *point = start.lerp(*point, time); - *point1 = start.lerp(*point1, time); - } - PathEl::CurveTo(point, point1, point2) => { - *point = start.lerp(*point, time); - *point1 = start.lerp(*point1, time); - *point2 = start.lerp(*point2, time); - } - PathEl::ClosePath => {} - } + for manip in &mut manips { + manip.anchor = start.lerp(manip.anchor, time); + manip.in_handle = manip.in_handle.map(|h| start.lerp(h, time)); + manip.out_handle = manip.out_handle.map(|h| start.lerp(h, time)); } - vector.append_bezpath(target_path); + + push_manipulators_to_vector(&mut vector, &manips, closed, &mut point_id, &mut segment_id); } Table::new_from_row(TableRow { From 0360aac3b8d2a147b10c19206a218680862bb744 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Thu, 26 Mar 2026 19:00:49 -0700 Subject: [PATCH 09/20] Fine-tune Morph node Bezier order promotion and handle interpolation --- node-graph/nodes/vector/src/vector_nodes.rs | 66 ++++++++++++++------- 1 file changed, 45 insertions(+), 21 deletions(-) diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index bf880a1378..976b9ddaba 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -1953,15 +1953,13 @@ async fn morph( path: Table, ) -> Table { /// Promotes a segment's handle pair to cubic-equivalent Bézier control points. - /// For linear segments (both None), handles are placed at 1/3 and 2/3 between anchors. + /// For linear segments (both None), handles are placed at their respective anchors (zero-length) + /// so that interpolation against another zero-length cubic doesn't introduce unwanted curvature. /// For quadratic segments (one handle), degree elevation is applied. fn promote_handles_to_cubic(prev_anchor: DVec2, out_handle: Option, in_handle: Option, curr_anchor: DVec2) -> (DVec2, DVec2) { match (out_handle, in_handle) { (Some(handle_start), Some(handle_end)) => (handle_start, handle_end), - (None, None) => { - let third = (curr_anchor - prev_anchor) / 3.; - (prev_anchor + third, curr_anchor - third) - } + (None, None) => (prev_anchor, curr_anchor), (Some(handle), None) | (None, Some(handle)) => { let handle_start = prev_anchor + (handle - prev_anchor) * (2. / 3.); let handle_end = curr_anchor + (handle - curr_anchor) * (2. / 3.); @@ -2276,26 +2274,52 @@ async fn morph( }) .collect(); - // Interpolate handles for each segment by promoting to cubic and lerping + // Interpolate handles per segment, preserving handle type when source and target match let segment_count = if source_closed { source_manips.len() } else { source_manips.len().saturating_sub(1) }; for segment_index in 0..segment_count { let next_index = (segment_index + 1) % source_manips.len(); - let (s_h1, s_h2) = promote_handles_to_cubic( - source_manips[segment_index].anchor, - source_manips[segment_index].out_handle, - source_manips[next_index].in_handle, - source_manips[next_index].anchor, - ); - let (t_h1, t_h2) = promote_handles_to_cubic( - target_manips[segment_index].anchor, - target_manips[segment_index].out_handle, - target_manips[next_index].in_handle, - target_manips[next_index].anchor, - ); - - interpolated[segment_index].out_handle = Some(s_h1.lerp(t_h1, time)); - interpolated[next_index].in_handle = Some(s_h2.lerp(t_h2, time)); + let source_out = source_manips[segment_index].out_handle; + let source_in = source_manips[next_index].in_handle; + let target_out = target_manips[segment_index].out_handle; + let target_in = target_manips[next_index].in_handle; + + match (source_out, source_in, target_out, target_in) { + // Both linear — no handles needed + (None, None, None, None) => {} + // Both cubic — lerp handle pairs directly + (Some(s_out), Some(s_in), Some(t_out), Some(t_in)) => { + interpolated[segment_index].out_handle = Some(s_out.lerp(t_out, time)); + interpolated[next_index].in_handle = Some(s_in.lerp(t_in, time)); + } + // Both quadratic with handle in the same position — lerp the single handle + (Some(s_out), None, Some(t_out), None) => { + interpolated[segment_index].out_handle = Some(s_out.lerp(t_out, time)); + } + (None, Some(s_in), None, Some(t_in)) => { + interpolated[next_index].in_handle = Some(s_in.lerp(t_in, time)); + } + // Linear vs. quadratic — elevate the linear side to a zero-length quadratic in the matching position + (None, None, Some(t_out), None) => { + interpolated[segment_index].out_handle = Some(source_manips[segment_index].anchor.lerp(t_out, time)); + } + (None, None, None, Some(t_in)) => { + interpolated[next_index].in_handle = Some(source_manips[next_index].anchor.lerp(t_in, time)); + } + (Some(s_out), None, None, None) => { + interpolated[segment_index].out_handle = Some(s_out.lerp(target_manips[segment_index].anchor, time)); + } + (None, Some(s_in), None, None) => { + interpolated[next_index].in_handle = Some(s_in.lerp(target_manips[next_index].anchor, time)); + } + // Mismatched types — promote both to cubic and lerp + _ => { + let (s_h1, s_h2) = promote_handles_to_cubic(source_manips[segment_index].anchor, source_out, source_in, source_manips[next_index].anchor); + let (t_h1, t_h2) = promote_handles_to_cubic(target_manips[segment_index].anchor, target_out, target_in, target_manips[next_index].anchor); + interpolated[segment_index].out_handle = Some(s_h1.lerp(t_h1, time)); + interpolated[next_index].in_handle = Some(s_h2.lerp(t_h2, time)); + } + } } push_manipulators_to_vector(&mut vector, &interpolated, source_closed, &mut point_id, &mut segment_id); From 9f8236476310ba9e792526de3f58e2927f2f46bf Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Thu, 26 Mar 2026 22:02:29 -0700 Subject: [PATCH 10/20] Add the Layer > Morph menu bar entry --- .branding | 4 +-- .../messages/input_mapper/input_mappings.rs | 1 + .../menu_bar/menu_bar_message_handler.rs | 6 +++++ .../portfolio/document/document_message.rs | 1 + .../document/document_message_handler.rs | 27 +++++++++++++++++++ .../graph_operation_message.rs | 5 ++++ .../graph_operation_message_handler.rs | 12 +++++++++ .../document/graph_operation/utility_types.rs | 16 +++++++++++ .../portfolio/document/utility_types/misc.rs | 1 + frontend/src/icons.ts | 2 ++ 10 files changed, 73 insertions(+), 2 deletions(-) diff --git a/.branding b/.branding index 4da5da8567..8e0a567a8f 100644 --- a/.branding +++ b/.branding @@ -1,2 +1,2 @@ -https://github.com/Keavon/graphite-branded-assets/archive/650b87537d8d9389f7d0cd8044870109b0579956.tar.gz -a8be5d9f0f9d2a68c80e0188444fc05d367438fb68e880e5425f3cfd7edb6add +https://github.com/Keavon/graphite-branded-assets/archive/169a596963b81d00f34b06340383708866b53a4c.tar.gz +4f5b1fadb8e40ddf565a304a49a09724040c8221ea1ee1e6b825b43c900c05fc diff --git a/editor/src/messages/input_mapper/input_mappings.rs b/editor/src/messages/input_mapper/input_mappings.rs index 9b46270ba5..2d9669a355 100644 --- a/editor/src/messages/input_mapper/input_mappings.rs +++ b/editor/src/messages/input_mapper/input_mappings.rs @@ -358,6 +358,7 @@ pub fn input_mappings(zoom_with_scroll: bool) -> Mapping { entry!(KeyDown(KeyD); modifiers=[Accel], canonical, action_dispatch=DocumentMessage::DuplicateSelectedLayers), entry!(KeyDown(KeyJ); modifiers=[Accel], action_dispatch=DocumentMessage::DuplicateSelectedLayers), entry!(KeyDown(KeyB); modifiers=[Accel, Alt], action_dispatch=DocumentMessage::BlendSelectedLayers), + entry!(KeyDown(KeyM); modifiers=[Accel, Alt], action_dispatch=DocumentMessage::MorphSelectedLayers), // Might get eaten by the GeForce Experience overlay for some Windows users entry!(KeyDown(KeyG); modifiers=[Accel], action_dispatch=DocumentMessage::GroupSelectedLayers { group_folder_type: GroupFolderType::Layer }), entry!(KeyDown(KeyG); modifiers=[Accel, Shift], action_dispatch=DocumentMessage::UngroupSelectedLayers), entry!(KeyDown(KeyN); modifiers=[Accel, Shift], action_dispatch=DocumentMessage::CreateEmptyFolder), diff --git a/editor/src/messages/menu_bar/menu_bar_message_handler.rs b/editor/src/messages/menu_bar/menu_bar_message_handler.rs index 247dd6ac1f..7369e4f782 100644 --- a/editor/src/messages/menu_bar/menu_bar_message_handler.rs +++ b/editor/src/messages/menu_bar/menu_bar_message_handler.rs @@ -501,6 +501,12 @@ impl LayoutHolder for MenuBarMessageHandler { .tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::BlendSelectedLayers)) .on_commit(|_| DocumentMessage::BlendSelectedLayers.into()) .disabled(no_active_document || !has_selected_layers), + MenuListEntry::new("Morph") + .label("Morph") + .icon("Morph") + .tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::MorphSelectedLayers)) + .on_commit(|_| DocumentMessage::MorphSelectedLayers.into()) + .disabled(no_active_document || !has_selected_layers), ], vec![ MenuListEntry::new("Make Path Editable") diff --git a/editor/src/messages/portfolio/document/document_message.rs b/editor/src/messages/portfolio/document/document_message.rs index a7eeee9239..8da43068a0 100644 --- a/editor/src/messages/portfolio/document/document_message.rs +++ b/editor/src/messages/portfolio/document/document_message.rs @@ -85,6 +85,7 @@ pub enum DocumentMessage { visible: bool, }, BlendSelectedLayers, + MorphSelectedLayers, GroupSelectedLayers { group_folder_type: GroupFolderType, }, diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index baba0ae5a7..90d141618b 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -627,6 +627,9 @@ impl MessageHandler> for DocumentMes DocumentMessage::BlendSelectedLayers => { self.handle_group_selected_layers(GroupFolderType::BlendShapes, responses); } + DocumentMessage::MorphSelectedLayers => { + self.handle_group_selected_layers(GroupFolderType::Morph, responses); + } DocumentMessage::GroupSelectedLayers { group_folder_type } => { self.handle_group_selected_layers(group_folder_type, responses); } @@ -1485,6 +1488,8 @@ impl MessageHandler> for DocumentMes DeleteSelectedLayers, DuplicateSelectedLayers, GroupSelectedLayers, + BlendSelectedLayers, + MorphSelectedLayers, SelectedLayersLower, SelectedLayersLowerToBack, SelectedLayersRaise, @@ -1997,6 +2002,28 @@ impl DocumentMessageHandler { responses.add(DocumentMessage::DocumentStructureChanged); responses.add(NodeGraphMessage::SendGraph); + return folder_id; + } + GroupFolderType::Morph => { + responses.add(GraphOperationMessage::NewMorphLayer { id: folder_id, parent, insert_index }); + + let new_group_folder = LayerNodeIdentifier::new_unchecked(folder_id); + + // Move selected layers into the group as children + let all_layers_to_group = network_interface.shallowest_unique_layers_sorted(&[]); + for layer_to_group in all_layers_to_group.into_iter().rev() { + responses.add(NodeGraphMessage::MoveLayerToStack { + layer: layer_to_group, + parent: new_group_folder, + insert_index: 0, + }); + } + + responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![folder_id] }); + responses.add(NodeGraphMessage::RunDocumentGraph); + responses.add(DocumentMessage::DocumentStructureChanged); + responses.add(NodeGraphMessage::SendGraph); + return folder_id; } } diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs index 59d300a771..d360a4cd21 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs @@ -85,6 +85,11 @@ pub enum GraphOperationMessage { blend_shape_id: NodeId, blend_path_id: NodeId, }, + NewMorphLayer { + id: NodeId, + parent: LayerNodeIdentifier, + insert_index: usize, + }, NewBooleanOperationLayer { id: NodeId, operation: graphene_std::vector::misc::BooleanOperation, diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index 98d23dc8c4..1f67db1411 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -232,6 +232,18 @@ impl MessageHandler> for // Shift the child stack (topmost child only, the rest follow) down 3 and left 10 network_interface.shift_node(&children_id, IVec2::new(-10, 3), &[]); } + GraphOperationMessage::NewMorphLayer { id, parent, insert_index } => { + let mut modify_inputs = ModifyInputsContext::new(network_interface, responses); + let layer = modify_inputs.create_layer(id); + modify_inputs.insert_morph_data(layer); + network_interface.move_layer_to_stack(layer, parent, insert_index, &[]); + + responses.add(NodeGraphMessage::SetDisplayNameImpl { + node_id: id, + alias: "Morph".to_string(), + }); + responses.add(NodeGraphMessage::RunDocumentGraph); + } GraphOperationMessage::NewBooleanOperationLayer { id, operation, parent, insert_index } => { let mut modify_inputs = ModifyInputsContext::new(network_interface, responses); let layer = modify_inputs.create_layer(id); diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index cc0f735908..60ca6d69c0 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -170,6 +170,22 @@ impl<'a> ModifyInputsContext<'a> { blend_shapes_id } + pub fn insert_morph_data(&mut self, layer: LayerNodeIdentifier) -> NodeId { + let morph = resolve_proto_node_type(graphene_std::vector::morph::IDENTIFIER) + .expect("Morph node does not exist") + .node_template_input_override([ + Some(NodeInput::value(TaggedValue::Graphic(Default::default()), true)), + Some(NodeInput::value(TaggedValue::F64(0.5), false)), + Some(NodeInput::value(TaggedValue::Vector(Default::default()), false)), + ]); + + let morph_id = NodeId::new(); + self.network_interface.insert_node(morph_id, morph, &[]); + self.network_interface.move_node_to_chain_start(&morph_id, layer, &[], self.import); + + morph_id + } + /// Returns the Path node ID (the node closest to the layer's merge node in the chain). pub fn insert_blend_path_data(&mut self, layer: LayerNodeIdentifier) -> NodeId { // Add Origins to Polyline node first (will be pushed deepest in the chain) diff --git a/editor/src/messages/portfolio/document/utility_types/misc.rs b/editor/src/messages/portfolio/document/utility_types/misc.rs index c589aa66fb..382a5a28cd 100644 --- a/editor/src/messages/portfolio/document/utility_types/misc.rs +++ b/editor/src/messages/portfolio/document/utility_types/misc.rs @@ -712,4 +712,5 @@ pub enum GroupFolderType { Layer, BooleanOperation(graphene_std::vector::misc::BooleanOperation), BlendShapes, + Morph, } diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index ab7ee675f3..356589a009 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -157,6 +157,7 @@ import IconsGrid from "/../branding/assets/icon-16px-solid/icons-grid.svg"; import Image from "/../branding/assets/icon-16px-solid/image.svg"; import Layer from "/../branding/assets/icon-16px-solid/layer.svg"; import License from "/../branding/assets/icon-16px-solid/license.svg"; +import Morph from "/../branding/assets/icon-16px-solid/morph.svg"; import NewLayer from "/../branding/assets/icon-16px-solid/new-layer.svg"; import NodeBlur from "/../branding/assets/icon-16px-solid/node-blur.svg"; import NodeBrushwork from "/../branding/assets/icon-16px-solid/node-brushwork.svg"; @@ -275,6 +276,7 @@ const SOLID_16PX = { Image: { svg: Image, size: 16 }, Layer: { svg: Layer, size: 16 }, License: { svg: License, size: 16 }, + Morph: { svg: Morph, size: 16 }, NewLayer: { svg: NewLayer, size: 16 }, Node: { svg: Node, size: 16 }, NodeBlur: { svg: NodeBlur, size: 16 }, From 86d8c12f2df4cbf9e26a62ef1d32e24c6cdea658 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 27 Mar 2026 17:15:41 -0700 Subject: [PATCH 11/20] Fix NaN and guard against other potential NaN bugs breaking the editor --- .../navigation/navigation_message_handler.rs | 4 ++-- .../utility_types/network_interface.rs | 2 ++ node-graph/nodes/vector/src/vector_nodes.rs | 19 +++++++++++++------ 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/editor/src/messages/portfolio/document/navigation/navigation_message_handler.rs b/editor/src/messages/portfolio/document/navigation/navigation_message_handler.rs index 7c2c1d0d6c..a319cc0e57 100644 --- a/editor/src/messages/portfolio/document/navigation/navigation_message_handler.rs +++ b/editor/src/messages/portfolio/document/navigation/navigation_message_handler.rs @@ -343,8 +343,8 @@ impl MessageHandler> for Navigat let (pos1, pos2) = (pos1.min(pos2), pos1.max(pos2)); let diagonal = pos2 - pos1; - if diagonal.length() < f64::EPSILON * 1000. || viewport.size().into_dvec2() == DVec2::ZERO { - warn!("Cannot center since the viewport size is 0"); + if !diagonal.is_finite() || diagonal.length() < f64::EPSILON * 1000. || viewport.size().into_dvec2() == DVec2::ZERO { + warn!("Cannot center since the viewport size is 0 or the bounds are non-finite"); return; } diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs index a1fd08e4f0..6a89dc355a 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -1209,6 +1209,8 @@ impl NodeNetworkInterface { } self.document_metadata.bounding_box_document(layer) }) + // Skip any layer bounds containing NaN to avoid poisoning the combined result + .filter(|[min, max]| min.is_finite() && max.is_finite()) .reduce(Quad::combine_bounds) } diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 976b9ddaba..cc7f73d234 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -11,7 +11,7 @@ use graphic_types::Vector; use graphic_types::raster_types::{CPU, GPU, Raster}; use graphic_types::{Graphic, IntoGraphicTable}; use kurbo::simplify::{SimplifyOptions, simplify_bezpath}; -use kurbo::{Affine, BezPath, DEFAULT_ACCURACY, Line, ParamCurve, PathEl, PathSeg, Shape}; +use kurbo::{Affine, BezPath, DEFAULT_ACCURACY, Line, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Shape}; use rand::{Rng, SeedableRng}; use std::collections::hash_map::DefaultHasher; use vector_types::subpath::{BezierHandles, ManipulatorGroup}; @@ -2187,11 +2187,13 @@ async fn morph( // Lerp styles let vector_alpha_blending = source_row.alpha_blending.lerp(target_row.alpha_blending, time as f32); - // Evaluate the spatial position on the control path for the translation component + // Evaluate the spatial position on the control path for the translation component. + // When the segment has zero arc length (e.g., two objects at the same position), inv_arclen + // produces NaN (0/0), so we fall back to the segment start point to avoid NaN translation. let path_position = { - // Use the original path segment index (not the clamped object index) let segment_index = path_segment_index.min(segment_count - 1); let segment = control_bezpath.get_seg(segment_index + 1).unwrap(); + let parametric_t = if segment.arclen(DEFAULT_ACCURACY) < f64::EPSILON { 0. } else { parametric_t }; let point = segment.eval(parametric_t); DVec2::new(point.x, point.y) }; @@ -2223,9 +2225,14 @@ async fn morph( // 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. - let lerped_inverse = lerped_transform.inverse(); - for row in graphic_table_content.iter_mut() { - *row.transform = lerped_inverse * *row.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. + if lerped_transform.matrix2.determinant().abs() > f64::EPSILON { + let lerped_inverse = lerped_transform.inverse(); + for row in graphic_table_content.iter_mut() { + *row.transform = lerped_inverse * *row.transform; + } } let mut vector = Vector { From d7ca58e63e64db61f84f2d67fc2f88526726bf8f Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 27 Mar 2026 15:34:36 -0700 Subject: [PATCH 12/20] Add InterpolationDistribution parameter to Morph with weighted progression, swap parameter orders, and rename shear to skew --- .../graph_operation_message_handler.rs | 4 +- .../document/graph_operation/utility_types.rs | 4 +- .../node_graph/document_node_definitions.rs | 26 +++- .../document/node_graph/node_properties.rs | 3 +- .../messages/portfolio/document_migration.rs | 7 +- node-graph/graph-craft/src/document/value.rs | 1 + .../interpreted-executor/src/node_registry.rs | 2 + .../libraries/vector-types/src/vector/misc.rs | 18 +++ node-graph/nodes/vector/src/vector_nodes.rs | 139 ++++++++++++------ 9 files changed, 141 insertions(+), 63 deletions(-) diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index 1f67db1411..cffefe815b 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -188,9 +188,9 @@ impl MessageHandler> for network_interface.move_layer_to_stack(layer, parent, insert_index, &[]); network_interface.move_layer_to_stack(blend_path_layer, parent, insert_index + 1, &[]); - // Connect the Path node's output to the Blend Shapes node's Path parameter input (input 1). + // Connect the Path node's output to the Blend Shapes node's Path parameter input (input 2). // Done after move_layer_to_stack so chain nodes have correct positions when converted to absolute. - network_interface.set_input(&InputConnector::node(blend_shapes_node_id, 1), NodeInput::node(path_node_id, 0), &[]); + network_interface.set_input(&InputConnector::node(blend_shapes_node_id, 2), NodeInput::node(path_node_id, 0), &[]); responses.add(NodeGraphMessage::SetDisplayNameImpl { node_id: id, diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index 60ca6d69c0..b6536874ac 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -159,7 +159,8 @@ impl<'a> ModifyInputsContext<'a> { pub fn insert_blend_shapes_data(&mut self, layer: LayerNodeIdentifier, count: f64) -> NodeId { let blend_shapes = resolve_network_node_type("Blend Shapes").expect("Blend Shapes node does not exist").node_template_input_override([ Some(NodeInput::value(TaggedValue::Graphic(Default::default()), true)), - Some(NodeInput::value(TaggedValue::Vector(Default::default()), true)), + None, + None, Some(NodeInput::value(TaggedValue::F64(count), false)), ]); @@ -176,6 +177,7 @@ impl<'a> ModifyInputsContext<'a> { .node_template_input_override([ Some(NodeInput::value(TaggedValue::Graphic(Default::default()), true)), Some(NodeInput::value(TaggedValue::F64(0.5), false)), + None, Some(NodeInput::value(TaggedValue::Vector(Default::default()), false)), ]); diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index 7e614e84c6..12f628fbc4 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -472,7 +472,7 @@ fn document_node_definitions() -> HashMap HashMap HashMap HashMap HashMap HashMap Vec { @@ -266,6 +266,7 @@ pub(crate) fn property_from_type( Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), + Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), // ===== // OTHER // ===== diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 6f5e996fce..fa1a09a4d9 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -1669,9 +1669,10 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], // The old version would zip the source and target table rows, interpoleating each pair together. // The migrated version will instead deeply flatten both merged tables and morph sequentially between all source vectors and all target vector elements. // This migration assumes most usages didn't involve multiple parallel vector elements, and instead morphed from a single source to a single target vector element. - // The new signature has 3 inputs too, so we distinguish by checking if input 2 is an f64 (old `time` param) vs a Table (new `path` param). + // The new signature has 4 inputs too, so we distinguish by checking if input 2 is an f64 (old `time` param) vs an InterpolationSpacing (new `distribution` param). let is_old_morph = reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::morph::IDENTIFIER) - && (inputs_count == 4 || (inputs_count == 3 && node.inputs.get(2).and_then(|i| i.as_value()).is_some_and(|v| matches!(v, TaggedValue::F64(_))))); + && ((inputs_count == 3 && node.inputs.get(2).and_then(|i| i.as_value()).is_some_and(|v| matches!(v, TaggedValue::F64(_)))) + || (inputs_count == 4 && node.inputs.get(2).and_then(|i| i.as_value()).is_some_and(|v| matches!(v, TaggedValue::F64(_))))); if is_old_morph { // 3 inputs - old signature (#3405): // async fn morph(_: impl Ctx, source: Table, #[expose] target: Table, #[default(0.5)] time: Fraction) -> Table { ... } @@ -1680,7 +1681,7 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], // async fn morph(_: impl Ctx, source: Table, #[expose] target: Table, #[default(0.5)] time: Fraction, #[min(0.)] start_index: IntegerCount) -> Table { ... } // // New signature: - // async fn morph(_: impl Ctx, #[implementations(Table, Table)] content: I, progression: Progression, path: Table) -> Table { ... } + // async fn morph(_: impl Ctx, content: I, progression: Progression, distribution: InterpolationDistribution, path: Table) -> Table { ... } let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 5ea1d42e72..d38b556354 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -260,6 +260,7 @@ tagged_value! { ExtrudeJoiningAlgorithm(vector::misc::ExtrudeJoiningAlgorithm), PointSpacingType(vector::misc::PointSpacingType), SpiralType(vector::misc::SpiralType), + InterpolationDistribution(vector::misc::InterpolationDistribution), #[serde(alias = "LineCap")] StrokeCap(vector::style::StrokeCap), #[serde(alias = "LineJoin")] diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 4f0d6716a7..f8cc25f7c7 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -135,6 +135,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => graphene_std::vector::misc::CentroidType]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::text::TextAlign]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::transform::ScaleType]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::vector::misc::InterpolationDistribution]), // Context nullification #[cfg(feature = "gpu")] async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => &WasmEditorApi, Context => graphene_std::ContextFeatures]), @@ -229,6 +230,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => graphene_std::vector::misc::BooleanOperation]), async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::text::TextAlign]), async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::transform::ScaleType]), + async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::vector::misc::InterpolationDistribution]), async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => RenderIntermediate]), ]; // ============= diff --git a/node-graph/libraries/vector-types/src/vector/misc.rs b/node-graph/libraries/vector-types/src/vector/misc.rs index 3270021931..7f5711da75 100644 --- a/node-graph/libraries/vector-types/src/vector/misc.rs +++ b/node-graph/libraries/vector-types/src/vector/misc.rs @@ -554,3 +554,21 @@ pub enum SpiralType { Archimedean, Logarithmic, } + +/// Controls how the morph/blend progression spends its time along the interpolation path, allowing for constant speed/spacing with respect to different parameters of change. +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Hash, DynAny, node_macro::ChoiceType)] +#[widget(Dropdown)] +pub enum InterpolationDistribution { + /// All objects occupy an equal portion of the progression range, regardless of their changing distances, angles, sizes, or slants. + #[default] + Objects, + /// All distances along the interpolation path are covered at a constant rate, meaning more time is spent traversing further distances. + Distances, + /// All angles of rotation between objects are covered at a constant rate, meaning more time is spent turning through larger angles. + Angles, + /// All sizes of expansion/contraction between objects are covered at a constant rate, meaning more time is spent scaling through larger (logarithmic) changes in size. + Sizes, + /// All slants (changes in skew angle) between objects are covered at a constant rate, meaning more time is spent skewing through larger changes in slant. + Slants, +} diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index cc7f73d234..8d78075b2b 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -21,8 +21,8 @@ use vector_types::vector::algorithms::merge_by_distance::MergeByDistanceExt; use vector_types::vector::algorithms::offset_subpath::offset_bezpath; use vector_types::vector::algorithms::spline::{solve_spline_first_handle_closed, solve_spline_first_handle_open}; use vector_types::vector::misc::{ - CentroidType, ExtrudeJoiningAlgorithm, HandleId, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, bezpath_from_manipulator_groups, bezpath_to_manipulator_groups, handles_to_segment, - is_linear, point_to_dvec2, segment_to_handles, + CentroidType, ExtrudeJoiningAlgorithm, HandleId, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, bezpath_from_manipulator_groups, + bezpath_to_manipulator_groups, handles_to_segment, is_linear, point_to_dvec2, segment_to_handles, }; use vector_types::vector::style::{Fill, Gradient, GradientStops, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use vector_types::vector::{FillId, PointId, RegionId, SegmentDomain, SegmentId, StrokeId, VectorExt}; @@ -1949,6 +1949,10 @@ async fn morph( content: I, /// The fractional part [0, 1) traverses the morph uniformly along the path. If the control path has multiple subpaths, each added integer selects the next subpath. progression: Progression, + /// The parameter of change that influences the interpolation speed between each object. Equal slices in this parameter correspond to the rate of progression through the morph. This must be set to a parameter that changes. + /// + /// "Objects" morphs through each group element at an equal rate. "Distances" keeps constant speed with time between objects proportional to their distances. "Angles" keeps constant rotational speed. "Sizes" keeps constant shrink/growth speed. "Slants" keeps constant shearing angle speed. + distribution: InterpolationDistribution, /// An optional control path whose anchor points correspond to each object. Curved segments between points will shape the morph trajectory instead of traveling straight. If there is a break between path segments, the separate subpaths are selected by index from the integer part of the progression value. For example, [1, 2) morphs along the segments of the second subpath, and so on. path: Table, ) -> Table { @@ -2062,8 +2066,7 @@ async fn morph( // Build the control path for the morph trajectory. // Collect all subpaths from the path input (applying transforms), or build a default polyline from element origins. - let control_bezpaths: Vec = if path.is_empty() { - // Default: polyline connecting each element's origin (translation component of transform) + let default_polyline = || { let mut default_path = BezPath::new(); for (i, row) in content.iter().enumerate() { let origin = row.transform.translation; @@ -2075,9 +2078,14 @@ async fn morph( } } vec![default_path] + }; + + let control_bezpaths: Vec = if path.is_empty() { + default_polyline() } else { // User-provided path: collect all subpaths with transforms applied - path.iter() + let paths: Vec = path + .iter() .flat_map(|vector| { let transform = *vector.transform; vector.element.stroke_bezpath_iter().map(move |mut bezpath| { @@ -2085,7 +2093,10 @@ async fn morph( bezpath }) }) - .collect() + .collect(); + + // Fall back to default polyline if the user-provided path has no subpaths + if paths.is_empty() { default_polyline() } else { paths } }; // Select which subpath to use based on the integer part of progression (like the 'Position on Path' node) @@ -2103,27 +2114,75 @@ async fn morph( return content.into_iter().next().into_iter().collect(); } - // Compute per-segment arc lengths for Euclidean progression along the control path + // Determine if the selected subpath is closed (has a closing segment connecting its end back to its start) + let is_closed = control_bezpath.elements().last() == Some(&PathEl::ClosePath); + + // Number of anchor points (content elements) per subpath: for closed subpaths, the closing + // segment doesn't add a new anchor, so anchors = segments. For open: anchors = segments + 1. + let anchor_count = |bp: &BezPath| -> usize { + let segs = bp.segments().count(); + let closed = bp.elements().last() == Some(&PathEl::ClosePath); + if closed { segs } else { segs + 1 } + }; + + // Offset source_index by the number of content elements consumed by previous subpaths, + // so each subpath morphs through its own slice of content (not always starting from element 0). + let content_offset: usize = control_bezpaths[..subpath_index].iter().map(&anchor_count).sum(); + let subpath_anchors = anchor_count(control_bezpath); + let max_content_index = content.len().saturating_sub(1); + + // Compute per-segment arc lengths for spatial positioning along the control path let segment_lengths: Vec = control_bezpath.segments().map(|seg| seg.perimeter(DEFAULT_ACCURACY)).collect(); - let total_length: f64 = segment_lengths.iter().sum(); - - // Map the fractional progression (0–1) to a segment index and local t using Euclidean distance. - // We compute both: - // - `time`: the arc-length fraction within the segment, used as the blend factor for morphing - // - `parametric_t`: the parametric t for evaluating the spatial position on the control path - let (source_index, time, parametric_t) = if total_length <= f64::EPSILON { - // Degenerate path (all points coincident): just use the first element - (0_usize, 0., 0.) + + // Compute segment weights based on the user's chosen spacing metric + let segment_weights: Vec = match distribution { + InterpolationDistribution::Objects => vec![1.; segment_count], + InterpolationDistribution::Distances => segment_lengths.clone(), + InterpolationDistribution::Angles | InterpolationDistribution::Sizes | InterpolationDistribution::Slants => (0..segment_count) + .map(|i| { + let src_idx = (content_offset + i).min(max_content_index); + let tgt_idx = if is_closed && i >= subpath_anchors - 1 { + content_offset + } else { + (content_offset + i + 1).min(max_content_index) + }; + + let (Some(src), Some(tgt)) = (content.get(src_idx), content.get(tgt_idx)) else { return 0. }; + let (s_angle, s_scale, s_skew) = src.transform.decompose_rotation_scale_skew(); + let (t_angle, t_scale, t_skew) = tgt.transform.decompose_rotation_scale_skew(); + + match distribution { + InterpolationDistribution::Angles => { + let mut diff = t_angle - s_angle; + if diff > PI { + diff -= TAU; + } else if diff < -PI { + diff += TAU; + } + diff.abs() + } + InterpolationDistribution::Sizes => (t_scale - s_scale).length(), + InterpolationDistribution::Slants => (t_skew.atan() - s_skew.atan()).abs(), + _ => unreachable!(), + } + }) + .collect(), + }; + + let total_weight: f64 = segment_weights.iter().sum(); + + // Map the fractional progression to a segment index and local blend time using the chosen weights. + // When all weights are zero (all elements identical in the chosen metric), there's zero interval to traverse. + let (local_source_index, time) = if total_weight <= f64::EPSILON { + (0, 0.) } else if fractional_progression >= 1. { - // At the end: last element fully - (segment_count - 1, 1., 1.) + (segment_count - 1, 1.) } else { - // Walk segments by arc-length ratio to find which segment we're in let mut accumulator = 0.; let mut found_index = segment_count - 1; let mut found_t = 1.; - for (i, length) in segment_lengths.iter().enumerate() { - let ratio = length / total_length; + for (i, weight) in segment_weights.iter().enumerate() { + let ratio = weight / total_weight; if fractional_progression <= accumulator + ratio { found_index = i; found_t = if ratio > f64::EPSILON { (fractional_progression - accumulator) / ratio } else { 0. }; @@ -2131,35 +2190,18 @@ async fn morph( } accumulator += ratio; } - - // Convert the arc-length fraction to a parametric t for evaluating position on the control path curve - let segment = control_bezpath.get_seg(found_index + 1).unwrap(); - let parametric_t = eval_pathseg_euclidean(segment, found_t, DEFAULT_ACCURACY); - - // found_t is the arc-length fraction within this segment (0–1), used as the blend factor - (found_index, found_t, parametric_t) + (found_index, found_t) }; - // The path segment index for evaluating spatial position (before clamping to object range) - let path_segment_index = source_index; - - // Determine if the selected subpath is closed (has a closing segment connecting its end back to its start) - let is_closed = control_bezpath.elements().last() == Some(&PathEl::ClosePath); - - // Number of anchor points (content elements) per subpath: for closed subpaths, the closing - // segment doesn't add a new anchor, so anchors = segments. For open: anchors = segments + 1. - let anchor_count = |bp: &BezPath| -> usize { - let segs = bp.segments().count(); - let closed = bp.elements().last() == Some(&PathEl::ClosePath); - if closed { segs } else { segs + 1 } + // Convert the blend time to a parametric t for evaluating spatial position on the control path + let path_segment_index = local_source_index; + let parametric_t = { + let seg_idx = path_segment_index.min(segment_count - 1); + let segment = control_bezpath.get_seg(seg_idx + 1).unwrap(); + eval_pathseg_euclidean(segment, time, DEFAULT_ACCURACY) }; - // Offset source_index by the number of content elements consumed by previous subpaths, - // so each subpath morphs through its own slice of content (not always starting from element 0). - let content_offset: usize = control_bezpaths[..subpath_index].iter().map(&anchor_count).sum(); - let local_source_index = source_index; let source_index = local_source_index + content_offset; - let subpath_anchors = anchor_count(control_bezpath); // For closed subpaths, the closing segment wraps target back to the first element of this subpath's slice. // For open subpaths, target is simply the next element. @@ -2170,9 +2212,8 @@ async fn morph( }; // Clamp to valid content range - let Some(max_index) = content.len().checked_sub(1) else { return content }; - let source_index = source_index.min(max_index); - let target_index = target_index.min(max_index); + let source_index = source_index.min(max_content_index); + let target_index = target_index.min(max_content_index); // At the end of an open subpath with no more interpolation needed, return the final element if !is_closed && time >= 1. && local_source_index >= subpath_anchors - 2 { @@ -2982,7 +3023,7 @@ mod test { second_rectangle.transform *= DAffine2::from_translation((-100., -100.).into()); rectangles.push(second_rectangle); - let morphed = super::morph(Footprint::default(), rectangles, 0.5, Table::default()).await; + let morphed = super::morph(Footprint::default(), rectangles, 0.5, InterpolationDistribution::default(), Table::default()).await; let row = morphed.iter().next().unwrap(); // Geometry stays in local space (original rectangle coordinates) assert_eq!( From 61b7008087e16bb5905e0aade0ba372ba5e352ae Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 28 Mar 2026 03:36:45 -0700 Subject: [PATCH 13/20] Add the Reverse parameter to the Morph node --- .../document/graph_operation/utility_types.rs | 1 + .../document/node_graph/document_node_definitions.rs | 3 ++- editor/src/messages/portfolio/document_migration.rs | 11 ++++------- node-graph/nodes/vector/src/vector_nodes.rs | 5 ++++- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index b6536874ac..5d22bae1f2 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -178,6 +178,7 @@ impl<'a> ModifyInputsContext<'a> { Some(NodeInput::value(TaggedValue::Graphic(Default::default()), true)), Some(NodeInput::value(TaggedValue::F64(0.5), false)), None, + None, Some(NodeInput::value(TaggedValue::Vector(Default::default()), false)), ]); diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index 12f628fbc4..e10eec7ebc 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -559,12 +559,13 @@ fn document_node_definitions() -> HashMap, #[expose] target: Table, #[default(0.5)] time: Fraction) -> Table { ... } // @@ -1681,7 +1678,7 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], // async fn morph(_: impl Ctx, source: Table, #[expose] target: Table, #[default(0.5)] time: Fraction, #[min(0.)] start_index: IntegerCount) -> Table { ... } // // New signature: - // async fn morph(_: impl Ctx, content: I, progression: Progression, distribution: InterpolationDistribution, path: Table) -> Table { ... } + // async fn morph(_: impl Ctx, content: I, progression: Progression, reverse: bool, distribution: InterpolationDistribution, path: Table) -> Table { ... } let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; @@ -1720,7 +1717,7 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], inputs_count = 3; } - // Migrate from the v2 "Morph" node (2 inputs: content, progression) to the v3 "Morph" node (3 inputs: content, progression, path). + // Migrate from the v2 "Morph" node (2 inputs: content, progression) to the v3 "Morph" node (5 inputs: content, progression, reverse, distribution, path). // The old progression used integer part for pair selection (range 0..N-1 where N is the number of content objects). // The new progression uses fractional 0..1 for euclidean traversal through all objects. // We insert Count Elements → Subtract 1 → Divide to remap: new_progression = old_progression / max(N - 1, 1). diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 8d78075b2b..6586b8eac0 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -1949,6 +1949,8 @@ async fn morph( content: I, /// The fractional part [0, 1) traverses the morph uniformly along the path. If the control path has multiple subpaths, each added integer selects the next subpath. progression: Progression, + /// Swap the direction of the progression between objects or along the control path. + reverse: bool, /// The parameter of change that influences the interpolation speed between each object. Equal slices in this parameter correspond to the rate of progression through the morph. This must be set to a parameter that changes. /// /// "Objects" morphs through each group element at an equal rate. "Distances" keeps constant speed with time between objects proportional to their distances. "Angles" keeps constant rotational speed. "Sizes" keeps constant shrink/growth speed. "Slants" keeps constant shearing angle speed. @@ -2102,6 +2104,7 @@ async fn morph( // Select which subpath to use based on the integer part of progression (like the 'Position on Path' node) let progression = progression.max(0.); let subpath_count = control_bezpaths.len() as f64; + let progression = if reverse { subpath_count - progression } else { progression }; let clamped_progression = progression.clamp(0., subpath_count); let subpath_index = if clamped_progression >= subpath_count { subpath_count - 1. } else { clamped_progression } as usize; let fractional_progression = if clamped_progression >= subpath_count { 1. } else { clamped_progression.fract() }; @@ -3023,7 +3026,7 @@ mod test { second_rectangle.transform *= DAffine2::from_translation((-100., -100.).into()); rectangles.push(second_rectangle); - let morphed = super::morph(Footprint::default(), rectangles, 0.5, InterpolationDistribution::default(), Table::default()).await; + let morphed = super::morph(Footprint::default(), rectangles, 0.5, false, InterpolationDistribution::default(), Table::default()).await; let row = morphed.iter().next().unwrap(); // Geometry stays in local space (original rectangle coordinates) assert_eq!( From 3106c0df8dff2d8826024cacbbac9bf8580e8601 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 28 Mar 2026 03:47:43 -0700 Subject: [PATCH 14/20] Update the order of the inputs to Blend Shapes for consistency with Morph --- .../graph_operation_message_handler.rs | 4 ++-- .../document/graph_operation/utility_types.rs | 2 -- .../node_graph/document_node_definitions.rs | 20 +++++++++---------- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index cffefe815b..5a045c09d4 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -188,9 +188,9 @@ impl MessageHandler> for network_interface.move_layer_to_stack(layer, parent, insert_index, &[]); network_interface.move_layer_to_stack(blend_path_layer, parent, insert_index + 1, &[]); - // Connect the Path node's output to the Blend Shapes node's Path parameter input (input 2). + // Connect the Path node's output to the Blend Shapes node's Path parameter input (input 4). // Done after move_layer_to_stack so chain nodes have correct positions when converted to absolute. - network_interface.set_input(&InputConnector::node(blend_shapes_node_id, 2), NodeInput::node(path_node_id, 0), &[]); + network_interface.set_input(&InputConnector::node(blend_shapes_node_id, 4), NodeInput::node(path_node_id, 0), &[]); responses.add(NodeGraphMessage::SetDisplayNameImpl { node_id: id, diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index 5d22bae1f2..6c55d4b589 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -159,8 +159,6 @@ impl<'a> ModifyInputsContext<'a> { pub fn insert_blend_shapes_data(&mut self, layer: LayerNodeIdentifier, count: f64) -> NodeId { let blend_shapes = resolve_network_node_type("Blend Shapes").expect("Blend Shapes node does not exist").node_template_input_override([ Some(NodeInput::value(TaggedValue::Graphic(Default::default()), true)), - None, - None, Some(NodeInput::value(TaggedValue::F64(count), false)), ]); diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index e10eec7ebc..85de123409 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -472,7 +472,7 @@ fn document_node_definitions() -> HashMap HashMap HashMap HashMap HashMap Date: Sat, 28 Mar 2026 05:01:35 -0700 Subject: [PATCH 15/20] Make Layer > Morph create the Morph Path control layer --- .../document/document_message_handler.rs | 42 ++++-------- .../graph_operation_message.rs | 17 ++--- .../graph_operation_message_handler.rs | 65 +++++++++---------- .../document/graph_operation/utility_types.rs | 2 +- 4 files changed, 50 insertions(+), 76 deletions(-) diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 90d141618b..653a3ae26e 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -1969,15 +1969,17 @@ impl DocumentMessageHandler { }); } } - GroupFolderType::BlendShapes => { - let blend_path_id = NodeId(generate_uuid()); + GroupFolderType::BlendShapes | GroupFolderType::Morph => { + let control_path_id = NodeId(generate_uuid()); let all_layers_to_group = network_interface.shallowest_unique_layers_sorted(&[]); - responses.add(GraphOperationMessage::NewBlendShapesLayer { + let blend_count = matches!(group_folder_type, GroupFolderType::BlendShapes).then(|| all_layers_to_group.len() * 10); + + responses.add(GraphOperationMessage::NewInterpolationLayer { id: folder_id, - blend_path_id, + control_path_id, parent, insert_index, - count: all_layers_to_group.len() * 10, + blend_count, }); let new_group_folder = LayerNodeIdentifier::new_unchecked(folder_id); @@ -1991,10 +1993,10 @@ impl DocumentMessageHandler { }); } - // Connect the child stack to the Blend Path layer as a co-parent - responses.add(GraphOperationMessage::ConnectBlendPathToChildren { - blend_shape_id: folder_id, - blend_path_id, + // Connect the child stack to the control path layer as a co-parent + responses.add(GraphOperationMessage::ConnectInterpolationControlPathToChildren { + interpolation_layer_id: folder_id, + control_path_id, }); responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![folder_id] }); @@ -2002,28 +2004,6 @@ impl DocumentMessageHandler { responses.add(DocumentMessage::DocumentStructureChanged); responses.add(NodeGraphMessage::SendGraph); - return folder_id; - } - GroupFolderType::Morph => { - responses.add(GraphOperationMessage::NewMorphLayer { id: folder_id, parent, insert_index }); - - let new_group_folder = LayerNodeIdentifier::new_unchecked(folder_id); - - // Move selected layers into the group as children - let all_layers_to_group = network_interface.shallowest_unique_layers_sorted(&[]); - for layer_to_group in all_layers_to_group.into_iter().rev() { - responses.add(NodeGraphMessage::MoveLayerToStack { - layer: layer_to_group, - parent: new_group_folder, - insert_index: 0, - }); - } - - responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![folder_id] }); - responses.add(NodeGraphMessage::RunDocumentGraph); - responses.add(DocumentMessage::DocumentStructureChanged); - responses.add(NodeGraphMessage::SendGraph); - return folder_id; } } diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs index d360a4cd21..14b7dfd66b 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs @@ -74,21 +74,16 @@ pub enum GraphOperationMessage { parent: LayerNodeIdentifier, insert_index: usize, }, - NewBlendShapesLayer { + NewInterpolationLayer { id: NodeId, - blend_path_id: NodeId, + control_path_id: NodeId, parent: LayerNodeIdentifier, insert_index: usize, - count: usize, + blend_count: Option, }, - ConnectBlendPathToChildren { - blend_shape_id: NodeId, - blend_path_id: NodeId, - }, - NewMorphLayer { - id: NodeId, - parent: LayerNodeIdentifier, - insert_index: usize, + ConnectInterpolationControlPathToChildren { + interpolation_layer_id: NodeId, + control_path_id: NodeId, }, NewBooleanOperationLayer { id: NodeId, diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index 5a045c09d4..b0f7c92219 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -172,51 +172,62 @@ impl MessageHandler> for network_interface.move_layer_to_stack(layer, parent, insert_index, &[]); responses.add(NodeGraphMessage::RunDocumentGraph); } - GraphOperationMessage::NewBlendShapesLayer { + GraphOperationMessage::NewInterpolationLayer { id, - blend_path_id, + control_path_id, parent, insert_index, - count, + blend_count, } => { let mut modify_inputs = ModifyInputsContext::new(network_interface, responses); let layer = modify_inputs.create_layer(id); - let blend_shapes_node_id = modify_inputs.insert_blend_shapes_data(layer, count as f64); - let blend_path_layer = modify_inputs.create_layer(blend_path_id); - let path_node_id = modify_inputs.insert_blend_path_data(blend_path_layer); + + // Insert the main chain node (Blend Shapes or Morph) depending on whether a blend count is provided + let (chain_node_id, layer_alias, path_alias) = if let Some(count) = blend_count { + (modify_inputs.insert_blend_shapes_data(layer, count as f64), "Blend Shape", "Blend Path") + } else { + (modify_inputs.insert_morph_data(layer), "Morph", "Morph Path") + }; + + // Create the control path layer (Path → Auto-Tangents → Origins to Polyline) + let control_path_layer = modify_inputs.create_layer(control_path_id); + let path_node_id = modify_inputs.insert_control_path_data(control_path_layer); network_interface.move_layer_to_stack(layer, parent, insert_index, &[]); - network_interface.move_layer_to_stack(blend_path_layer, parent, insert_index + 1, &[]); + network_interface.move_layer_to_stack(control_path_layer, parent, insert_index + 1, &[]); - // Connect the Path node's output to the Blend Shapes node's Path parameter input (input 4). + // Connect the Path node's output to the chain node's path parameter input (input 4 for both Morph and Blend Shapes). // Done after move_layer_to_stack so chain nodes have correct positions when converted to absolute. - network_interface.set_input(&InputConnector::node(blend_shapes_node_id, 4), NodeInput::node(path_node_id, 0), &[]); + network_interface.set_input(&InputConnector::node(chain_node_id, 4), NodeInput::node(path_node_id, 0), &[]); responses.add(NodeGraphMessage::SetDisplayNameImpl { node_id: id, - alias: "Blend Shape".to_string(), + alias: layer_alias.to_string(), }); responses.add(NodeGraphMessage::SetDisplayNameImpl { - node_id: blend_path_id, - alias: "Blend Path".to_string(), + node_id: control_path_id, + alias: path_alias.to_string(), }); } - GraphOperationMessage::ConnectBlendPathToChildren { blend_shape_id, blend_path_id } => { - // Find the Blend Shapes node (first in chain of the Blend Shape layer) - let Some(OutputConnector::Node { node_id: chain_node, .. }) = network_interface.upstream_output_connector(&InputConnector::node(blend_shape_id, 1), &[]) else { - log::error!("Could not find Blend Shapes chain node for layer {blend_shape_id}"); + GraphOperationMessage::ConnectInterpolationControlPathToChildren { + interpolation_layer_id, + control_path_id, + } => { + // Find the chain node (Morph or Blend Shapes, first in chain of the layer) + let Some(OutputConnector::Node { node_id: chain_node, .. }) = network_interface.upstream_output_connector(&InputConnector::node(interpolation_layer_id, 1), &[]) else { + log::error!("Could not find chain node for layer {interpolation_layer_id}"); return; }; - // Get what feeds into the Blend Shapes node's primary input (the children stack) + // Get what feeds into the chain node's primary input (the children stack) let Some(OutputConnector::Node { node_id: children_id, output_index }) = network_interface.upstream_output_connector(&InputConnector::node(chain_node, 0), &[]) else { - log::error!("Could not find children stack feeding Blend Shapes node {chain_node}"); + log::error!("Could not find children stack feeding chain node {chain_node}"); return; }; - // Find the deepest node in the Blend Path layer's chain (Origins to Polyline) + // Find the deepest node in the control path layer's chain (Origins to Polyline) let mut deepest_chain_node = None; - let mut current_connector = InputConnector::node(blend_path_id, 1); + let mut current_connector = InputConnector::node(control_path_id, 1); while let Some(OutputConnector::Node { node_id, .. }) = network_interface.upstream_output_connector(¤t_connector, &[]) { deepest_chain_node = Some(node_id); current_connector = InputConnector::node(node_id, 0); @@ -225,25 +236,13 @@ impl MessageHandler> for // Connect children to the deepest chain node's input 0 (or the layer's input 1 if no chain) let target_connector = match deepest_chain_node { Some(node_id) => InputConnector::node(node_id, 0), - None => InputConnector::node(blend_path_id, 1), + None => InputConnector::node(control_path_id, 1), }; network_interface.set_input(&target_connector, NodeInput::node(children_id, output_index), &[]); // Shift the child stack (topmost child only, the rest follow) down 3 and left 10 network_interface.shift_node(&children_id, IVec2::new(-10, 3), &[]); } - GraphOperationMessage::NewMorphLayer { id, parent, insert_index } => { - let mut modify_inputs = ModifyInputsContext::new(network_interface, responses); - let layer = modify_inputs.create_layer(id); - modify_inputs.insert_morph_data(layer); - network_interface.move_layer_to_stack(layer, parent, insert_index, &[]); - - responses.add(NodeGraphMessage::SetDisplayNameImpl { - node_id: id, - alias: "Morph".to_string(), - }); - responses.add(NodeGraphMessage::RunDocumentGraph); - } GraphOperationMessage::NewBooleanOperationLayer { id, operation, parent, insert_index } => { let mut modify_inputs = ModifyInputsContext::new(network_interface, responses); let layer = modify_inputs.create_layer(id); diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index 6c55d4b589..c19ddba0fb 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -188,7 +188,7 @@ impl<'a> ModifyInputsContext<'a> { } /// Returns the Path node ID (the node closest to the layer's merge node in the chain). - pub fn insert_blend_path_data(&mut self, layer: LayerNodeIdentifier) -> NodeId { + pub fn insert_control_path_data(&mut self, layer: LayerNodeIdentifier) -> NodeId { // Add Origins to Polyline node first (will be pushed deepest in the chain) let origins_to_polyline = resolve_network_node_type("Origins to Polyline") .expect("Origins to Polyline node does not exist") From 172298fef84e2875cfd447a9254cdff68018716e Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 28 Mar 2026 22:18:11 -0700 Subject: [PATCH 16/20] Fix migrations --- editor/src/messages/portfolio/document_migration.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 08b3efa1a5..fd5ff3c3f7 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -1669,7 +1669,6 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], // The old version would zip the source and target table rows, interpoleating each pair together. // The migrated version will instead deeply flatten both merged tables and morph sequentially between all source vectors and all target vector elements. // This migration assumes most usages didn't involve multiple parallel vector elements, and instead morphed from a single source to a single target vector element. - // The new v3 signature has 5 inputs, so 3 or 4 inputs can only be old v1 versions. if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::morph::IDENTIFIER) && (inputs_count == 3 || inputs_count == 4) { // 3 inputs - old signature (#3405): // async fn morph(_: impl Ctx, source: Table, #[expose] target: Table, #[default(0.5)] time: Fraction) -> Table { ... } @@ -1677,8 +1676,8 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], // 4 inputs - even older signature (commit 80b8df8d4298b6669f124b929ce61bfabfc44e41): // async fn morph(_: impl Ctx, source: Table, #[expose] target: Table, #[default(0.5)] time: Fraction, #[min(0.)] start_index: IntegerCount) -> Table { ... } // - // New signature: - // async fn morph(_: impl Ctx, content: I, progression: Progression, reverse: bool, distribution: InterpolationDistribution, path: Table) -> Table { ... } + // v2 signature: + // async fn morph(_: impl Ctx, #[implementations(Table, Table)] content: I, progression: Progression) -> Table { ... } let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; @@ -1714,19 +1713,19 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], // Connect the old 'progression' input to the new 'progression' input of the Morph node document.network_interface.set_input(&InputConnector::node(*node_id, 1), old_inputs[2].clone(), network_path); - inputs_count = 3; + inputs_count = 2; } // Migrate from the v2 "Morph" node (2 inputs: content, progression) to the v3 "Morph" node (5 inputs: content, progression, reverse, distribution, path). // The old progression used integer part for pair selection (range 0..N-1 where N is the number of content objects). // The new progression uses fractional 0..1 for euclidean traversal through all objects. - // We insert Count Elements → Subtract 1 → Divide to remap: new_progression = old_progression / max(N - 1, 1). + // We insert Count Elements → Subtract 1 → Divide to remap: new_progression = old_progression / (N - 1). // For the common 2-object case (N=2), this divides by 1 which is a no-op, preserving identical behavior. if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::morph::IDENTIFIER) && inputs_count == 2 { let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; - // Reconnect content (input 0) and leave path (input 2) as default + // Reconnect content (input 0) and leave path (input 4) as default document.network_interface.set_input(&InputConnector::node(*node_id, 0), old_inputs[0].clone(), network_path); let Some(morph_position) = document.network_interface.position_from_downstream_node(node_id, network_path) else { From d3dee3e2643406a61fec45f13578f5c432596e8a Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 28 Mar 2026 23:07:12 -0700 Subject: [PATCH 17/20] Move 10 to a constant --- editor/src/consts.rs | 3 +++ .../messages/portfolio/document/document_message_handler.rs | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/editor/src/consts.rs b/editor/src/consts.rs index 78b3a128fc..02534e21c2 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -186,3 +186,6 @@ pub const DOUBLE_CLICK_MILLISECONDS: u64 = 500; pub const UI_SCALE_DEFAULT: f64 = 1.; pub const UI_SCALE_MIN: f64 = 0.5; pub const UI_SCALE_MAX: f64 = 3.; + +// ACTIONS +pub const BLEND_SHAPE_COUNT_PER_LAYER: usize = 10; diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 653a3ae26e..121824ae7a 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -5,7 +5,8 @@ use super::utility_types::network_interface::{self, NodeNetworkInterface, Transa use super::utility_types::nodes::{CollapsedLayers, LayerStructureEntry, SelectedNodes}; use crate::application::{GRAPHITE_GIT_COMMIT_HASH, generate_uuid}; use crate::consts::{ - ASYMPTOTIC_EFFECT, COLOR_OVERLAY_GRAY, DEFAULT_DOCUMENT_NAME, FILE_EXTENSION, LAYER_INDENT_OFFSET, NODE_CHAIN_WIDTH, SCALE_EFFECT, SCROLLBAR_SPACING, VIEWPORT_ROTATE_SNAP_INTERVAL, + ASYMPTOTIC_EFFECT, BLEND_SHAPE_COUNT_PER_LAYER, COLOR_OVERLAY_GRAY, DEFAULT_DOCUMENT_NAME, FILE_EXTENSION, LAYER_INDENT_OFFSET, NODE_CHAIN_WIDTH, SCALE_EFFECT, SCROLLBAR_SPACING, + VIEWPORT_ROTATE_SNAP_INTERVAL, }; use crate::messages::input_mapper::utility_types::macros::action_shortcut; use crate::messages::layout::utility_types::widget_prelude::*; @@ -1972,7 +1973,7 @@ impl DocumentMessageHandler { GroupFolderType::BlendShapes | GroupFolderType::Morph => { let control_path_id = NodeId(generate_uuid()); let all_layers_to_group = network_interface.shallowest_unique_layers_sorted(&[]); - let blend_count = matches!(group_folder_type, GroupFolderType::BlendShapes).then(|| all_layers_to_group.len() * 10); + let blend_count = matches!(group_folder_type, GroupFolderType::BlendShapes).then(|| all_layers_to_group.len() * BLEND_SHAPE_COUNT_PER_LAYER); responses.add(GraphOperationMessage::NewInterpolationLayer { id: folder_id, From f75ec649d41138eabdaf0cfecf2226cbfe464601 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sat, 28 Mar 2026 23:07:38 -0700 Subject: [PATCH 18/20] Avoid division by 0 in the Blend Shapes node internals --- .../node_graph/document_node_definitions.rs | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index 85de123409..a62d337f11 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -535,10 +535,10 @@ fn document_node_definitions() -> HashMap HashMap HashMap HashMap Date: Sat, 28 Mar 2026 23:24:20 -0700 Subject: [PATCH 19/20] Rename nodes 'Blend' -> 'Mix' and 'Blend Shapes' to 'Blend' --- .branding | 4 ++-- editor/src/consts.rs | 2 +- .../messages/menu_bar/menu_bar_message_handler.rs | 4 ++-- .../portfolio/document/document_message_handler.rs | 8 ++++---- .../graph_operation_message_handler.rs | 8 ++++---- .../document/graph_operation/utility_types.rs | 12 ++++++------ .../document/node_graph/document_node_definitions.rs | 2 +- .../portfolio/document/utility_types/misc.rs | 5 +++-- editor/src/messages/portfolio/document_migration.rs | 3 ++- frontend/src/icons.ts | 8 ++++---- node-graph/nodes/raster/src/blending_nodes.rs | 2 +- 11 files changed, 30 insertions(+), 28 deletions(-) diff --git a/.branding b/.branding index 8e0a567a8f..300002012c 100644 --- a/.branding +++ b/.branding @@ -1,2 +1,2 @@ -https://github.com/Keavon/graphite-branded-assets/archive/169a596963b81d00f34b06340383708866b53a4c.tar.gz -4f5b1fadb8e40ddf565a304a49a09724040c8221ea1ee1e6b825b43c900c05fc +https://github.com/Keavon/graphite-branded-assets/archive/fc02baf37d7428e86aa0e5f772c1f6e42173d405.tar.gz +061a880c753472ea0a84fbc09e54d3596bea6e299067169d34347c717de13ded diff --git a/editor/src/consts.rs b/editor/src/consts.rs index 02534e21c2..645ab7ce46 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -188,4 +188,4 @@ pub const UI_SCALE_MIN: f64 = 0.5; pub const UI_SCALE_MAX: f64 = 3.; // ACTIONS -pub const BLEND_SHAPE_COUNT_PER_LAYER: usize = 10; +pub const BLEND_COUNT_PER_LAYER: usize = 10; diff --git a/editor/src/messages/menu_bar/menu_bar_message_handler.rs b/editor/src/messages/menu_bar/menu_bar_message_handler.rs index 7369e4f782..1b620ea7d2 100644 --- a/editor/src/messages/menu_bar/menu_bar_message_handler.rs +++ b/editor/src/messages/menu_bar/menu_bar_message_handler.rs @@ -497,13 +497,13 @@ impl LayoutHolder for MenuBarMessageHandler { ]]), MenuListEntry::new("Blend") .label("Blend") - .icon("BlendShapes") + .icon("InterpolationBlend") .tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::BlendSelectedLayers)) .on_commit(|_| DocumentMessage::BlendSelectedLayers.into()) .disabled(no_active_document || !has_selected_layers), MenuListEntry::new("Morph") .label("Morph") - .icon("Morph") + .icon("InterpolationMorph") .tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::MorphSelectedLayers)) .on_commit(|_| DocumentMessage::MorphSelectedLayers.into()) .disabled(no_active_document || !has_selected_layers), diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 121824ae7a..4ace922df0 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -5,7 +5,7 @@ use super::utility_types::network_interface::{self, NodeNetworkInterface, Transa use super::utility_types::nodes::{CollapsedLayers, LayerStructureEntry, SelectedNodes}; use crate::application::{GRAPHITE_GIT_COMMIT_HASH, generate_uuid}; use crate::consts::{ - ASYMPTOTIC_EFFECT, BLEND_SHAPE_COUNT_PER_LAYER, COLOR_OVERLAY_GRAY, DEFAULT_DOCUMENT_NAME, FILE_EXTENSION, LAYER_INDENT_OFFSET, NODE_CHAIN_WIDTH, SCALE_EFFECT, SCROLLBAR_SPACING, + ASYMPTOTIC_EFFECT, BLEND_COUNT_PER_LAYER, COLOR_OVERLAY_GRAY, DEFAULT_DOCUMENT_NAME, FILE_EXTENSION, LAYER_INDENT_OFFSET, NODE_CHAIN_WIDTH, SCALE_EFFECT, SCROLLBAR_SPACING, VIEWPORT_ROTATE_SNAP_INTERVAL, }; use crate::messages::input_mapper::utility_types::macros::action_shortcut; @@ -626,7 +626,7 @@ impl MessageHandler> for DocumentMes responses.add(OverlaysMessage::Draw); } DocumentMessage::BlendSelectedLayers => { - self.handle_group_selected_layers(GroupFolderType::BlendShapes, responses); + self.handle_group_selected_layers(GroupFolderType::Blend, responses); } DocumentMessage::MorphSelectedLayers => { self.handle_group_selected_layers(GroupFolderType::Morph, responses); @@ -1970,10 +1970,10 @@ impl DocumentMessageHandler { }); } } - GroupFolderType::BlendShapes | GroupFolderType::Morph => { + GroupFolderType::Blend | GroupFolderType::Morph => { let control_path_id = NodeId(generate_uuid()); let all_layers_to_group = network_interface.shallowest_unique_layers_sorted(&[]); - let blend_count = matches!(group_folder_type, GroupFolderType::BlendShapes).then(|| all_layers_to_group.len() * BLEND_SHAPE_COUNT_PER_LAYER); + let blend_count = matches!(group_folder_type, GroupFolderType::Blend).then(|| all_layers_to_group.len() * BLEND_COUNT_PER_LAYER); responses.add(GraphOperationMessage::NewInterpolationLayer { id: folder_id, diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index b0f7c92219..991d644a78 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -182,9 +182,9 @@ impl MessageHandler> for let mut modify_inputs = ModifyInputsContext::new(network_interface, responses); let layer = modify_inputs.create_layer(id); - // Insert the main chain node (Blend Shapes or Morph) depending on whether a blend count is provided + // Insert the main chain node (Blend or Morph) depending on whether a blend count is provided let (chain_node_id, layer_alias, path_alias) = if let Some(count) = blend_count { - (modify_inputs.insert_blend_shapes_data(layer, count as f64), "Blend Shape", "Blend Path") + (modify_inputs.insert_blend_data(layer, count as f64), "Blend", "Blend Path") } else { (modify_inputs.insert_morph_data(layer), "Morph", "Morph Path") }; @@ -196,7 +196,7 @@ impl MessageHandler> for network_interface.move_layer_to_stack(layer, parent, insert_index, &[]); network_interface.move_layer_to_stack(control_path_layer, parent, insert_index + 1, &[]); - // Connect the Path node's output to the chain node's path parameter input (input 4 for both Morph and Blend Shapes). + // Connect the Path node's output to the chain node's path parameter input (input 4 for both Morph and Blend). // Done after move_layer_to_stack so chain nodes have correct positions when converted to absolute. network_interface.set_input(&InputConnector::node(chain_node_id, 4), NodeInput::node(path_node_id, 0), &[]); @@ -213,7 +213,7 @@ impl MessageHandler> for interpolation_layer_id, control_path_id, } => { - // Find the chain node (Morph or Blend Shapes, first in chain of the layer) + // Find the chain node (Blend or Morph, first in chain of the layer) let Some(OutputConnector::Node { node_id: chain_node, .. }) = network_interface.upstream_output_connector(&InputConnector::node(interpolation_layer_id, 1), &[]) else { log::error!("Could not find chain node for layer {interpolation_layer_id}"); return; diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index c19ddba0fb..605e2efa59 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -156,17 +156,17 @@ impl<'a> ModifyInputsContext<'a> { self.network_interface.move_node_to_chain_start(&boolean_id, layer, &[], self.import); } - pub fn insert_blend_shapes_data(&mut self, layer: LayerNodeIdentifier, count: f64) -> NodeId { - let blend_shapes = resolve_network_node_type("Blend Shapes").expect("Blend Shapes node does not exist").node_template_input_override([ + pub fn insert_blend_data(&mut self, layer: LayerNodeIdentifier, count: f64) -> NodeId { + let blend = resolve_network_node_type("Blend").expect("Blend node does not exist").node_template_input_override([ Some(NodeInput::value(TaggedValue::Graphic(Default::default()), true)), Some(NodeInput::value(TaggedValue::F64(count), false)), ]); - let blend_shapes_id = NodeId::new(); - self.network_interface.insert_node(blend_shapes_id, blend_shapes, &[]); - self.network_interface.move_node_to_chain_start(&blend_shapes_id, layer, &[], self.import); + let blend_id = NodeId::new(); + self.network_interface.insert_node(blend_id, blend, &[]); + self.network_interface.move_node_to_chain_start(&blend_id, layer, &[], self.import); - blend_shapes_id + blend_id } pub fn insert_morph_data(&mut self, layer: LayerNodeIdentifier) -> NodeId { diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index a62d337f11..a7f742a2d3 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -462,7 +462,7 @@ fn document_node_definitions() -> HashMap] = &[ ], }, NodeReplacement { - node: graphene_std::raster_nodes::blending_nodes::blend::IDENTIFIER, + node: graphene_std::raster_nodes::blending_nodes::mix::IDENTIFIER, aliases: &[ "graphene_raster_nodes::adjustments::BlendNode", "raster_nodes::adjustments::BlendNode", "graphene_core::raster::adjustments::BlendNode", "graphene_core::raster::BlendNode", "graphene_raster_nodes::blending_nodes::BlendNode", + "raster_nodes::blending_nodes::BlendNode", ], }, NodeReplacement { diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index 356589a009..dca36fdb82 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -110,7 +110,6 @@ import AlignRight from "/../branding/assets/icon-16px-solid/align-right.svg"; import AlignTop from "/../branding/assets/icon-16px-solid/align-top.svg"; import AlignVerticalCenter from "/../branding/assets/icon-16px-solid/align-vertical-center.svg"; import Artboard from "/../branding/assets/icon-16px-solid/artboard.svg"; -import BlendShapes from "/../branding/assets/icon-16px-solid/blend-shapes.svg"; import BooleanDifference from "/../branding/assets/icon-16px-solid/boolean-difference.svg"; import BooleanDivide from "/../branding/assets/icon-16px-solid/boolean-divide.svg"; import BooleanIntersect from "/../branding/assets/icon-16px-solid/boolean-intersect.svg"; @@ -155,9 +154,10 @@ import HistoryRedo from "/../branding/assets/icon-16px-solid/history-redo.svg"; import HistoryUndo from "/../branding/assets/icon-16px-solid/history-undo.svg"; import IconsGrid from "/../branding/assets/icon-16px-solid/icons-grid.svg"; import Image from "/../branding/assets/icon-16px-solid/image.svg"; +import InterpolationBlend from "/../branding/assets/icon-16px-solid/interpolation-blend.svg"; +import InterpolationMorph from "/../branding/assets/icon-16px-solid/interpolation-morph.svg"; import Layer from "/../branding/assets/icon-16px-solid/layer.svg"; import License from "/../branding/assets/icon-16px-solid/license.svg"; -import Morph from "/../branding/assets/icon-16px-solid/morph.svg"; import NewLayer from "/../branding/assets/icon-16px-solid/new-layer.svg"; import NodeBlur from "/../branding/assets/icon-16px-solid/node-blur.svg"; import NodeBrushwork from "/../branding/assets/icon-16px-solid/node-brushwork.svg"; @@ -229,7 +229,6 @@ const SOLID_16PX = { AlignTop: { svg: AlignTop, size: 16 }, AlignVerticalCenter: { svg: AlignVerticalCenter, size: 16 }, Artboard: { svg: Artboard, size: 16 }, - BlendShapes: { svg: BlendShapes, size: 16 }, BooleanDifference: { svg: BooleanDifference, size: 16 }, BooleanDivide: { svg: BooleanDivide, size: 16 }, BooleanIntersect: { svg: BooleanIntersect, size: 16 }, @@ -274,9 +273,10 @@ const SOLID_16PX = { HistoryUndo: { svg: HistoryUndo, size: 16 }, IconsGrid: { svg: IconsGrid, size: 16 }, Image: { svg: Image, size: 16 }, + InterpolationBlend: { svg: InterpolationBlend, size: 16 }, + InterpolationMorph: { svg: InterpolationMorph, size: 16 }, Layer: { svg: Layer, size: 16 }, License: { svg: License, size: 16 }, - Morph: { svg: Morph, size: 16 }, NewLayer: { svg: NewLayer, size: 16 }, Node: { svg: Node, size: 16 }, NodeBlur: { svg: NodeBlur, size: 16 }, diff --git a/node-graph/nodes/raster/src/blending_nodes.rs b/node-graph/nodes/raster/src/blending_nodes.rs index 510b12eead..80315a5fe3 100644 --- a/node-graph/nodes/raster/src/blending_nodes.rs +++ b/node-graph/nodes/raster/src/blending_nodes.rs @@ -130,7 +130,7 @@ pub fn apply_blend_mode(foreground: Color, background: Color, blend_mode: BlendM } #[node_macro::node(category("Raster"), cfg(feature = "std"))] -fn blend + Send>( +fn mix + Send>( _: impl Ctx, #[implementations( Table>, From d15c038b52c23e504d34737c2dec4186090433b1 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sun, 29 Mar 2026 01:11:47 -0700 Subject: [PATCH 20/20] Fix a crash encountered while testing --- editor/src/messages/tool/common_functionality/shape_editor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editor/src/messages/tool/common_functionality/shape_editor.rs b/editor/src/messages/tool/common_functionality/shape_editor.rs index 0183d65cc3..0655bb93e1 100644 --- a/editor/src/messages/tool/common_functionality/shape_editor.rs +++ b/editor/src/messages/tool/common_functionality/shape_editor.rs @@ -1822,7 +1822,7 @@ impl ShapeState { /// Find the `t` value along the path segment we have clicked upon, together with that segment ID. fn closest_segment(&self, network_interface: &NodeNetworkInterface, layer: LayerNodeIdentifier, position: glam::DVec2, tolerance: f64) -> Option { let transform = network_interface.document_metadata().transform_to_viewport_if_feeds(layer, network_interface); - let layer_pos = transform.inverse().transform_point2(position); + let layer_pos = (transform.matrix2.determinant().abs() >= f64::EPSILON).then(|| transform.inverse().transform_point2(position))?; let tolerance = tolerance + 0.5;