Finalize and unify the design of the 'Morph' and 'Blend' nodes (#3974)

* Fix Morph node transform interpolation and preservation in the table

* Fix click target positions for Morph's nested layers by pre-compensating upstream_data transforms

* Redesign Morph node (v3) with control path input and uniformly spaced progression, and fix Stroke::lerp interpolation weights

* Add migration from Morph node v2 to v3

* Redesign the 'Blend Shapes' node behavior and subgraph definition

* Add the Layer > Blend menu entry to easily set up a blend

* Optimize the Morph node

* Refactor the Morph node to remove the roundtrip through BezPath

* Fine-tune Morph node Bezier order promotion and handle interpolation

* Add the Layer > Morph menu bar entry

* Fix NaN and guard against other potential NaN bugs breaking the editor

* Add InterpolationDistribution parameter to Morph with weighted progression, swap parameter orders, and rename shear to skew

* Add the Reverse parameter to the Morph node

* Update the order of the inputs to Blend Shapes for consistency with Morph

* Make Layer > Morph create the Morph Path control layer

* Fix migrations

* Move 10 to a constant

* Avoid division by 0 in the Blend Shapes node internals

* Rename nodes 'Blend' -> 'Mix' and 'Blend Shapes' to 'Blend'

* Fix a crash encountered while testing

* Final code review

* Make domain push dupe checks debug-only and use push_unchecked in the Morph node

* Pre-allocate for pushes to the vector domains

* Add fast path at t=0

* Inline reserve()

* Set up the control path layer above not below, and starting collapsed

* Review fixes

---------

Co-authored-by: Timon <me@timon.zip>
This commit is contained in:
Keavon Chambers
2026-04-03 20:45:58 -07:00
committed by GitHub
parent 7077e877f9
commit 4360359d60
23 changed files with 986 additions and 364 deletions

View File

@@ -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,
}

View File

@@ -104,6 +104,12 @@ impl PointDomain {
self.position.clear();
}
#[inline(always)]
pub fn reserve(&mut self, additional: usize) {
self.id.reserve(additional);
self.position.reserve(additional);
}
pub fn retain(&mut self, segment_domain: &mut SegmentDomain, f: impl Fn(&PointId) -> bool) {
let mut keep = self.id.iter().map(&f);
self.position.retain(|_| keep.next().unwrap_or_default());
@@ -129,14 +135,16 @@ impl PointDomain {
}
pub fn push(&mut self, id: PointId, position: DVec2) {
#[cfg(debug_assertions)]
if self.id.contains(&id) {
warn!("Tried to push a duplicate point to a point domain");
return;
}
self.id.push(id);
self.position.push(position);
self.push_unchecked(id, position);
}
#[inline(always)]
pub fn push_unchecked(&mut self, id: PointId, position: DVec2) {
self.id.push(id);
self.position.push(position);
@@ -234,6 +242,15 @@ impl SegmentDomain {
self.stroke.clear();
}
#[inline(always)]
pub fn reserve(&mut self, additional: usize) {
self.id.reserve(additional);
self.start_point.reserve(additional);
self.end_point.reserve(additional);
self.handles.reserve(additional);
self.stroke.reserve(additional);
}
pub fn retain(&mut self, f: impl Fn(&SegmentId) -> bool, points_length: usize) {
let additional_delete_ids = self
.id
@@ -316,9 +333,15 @@ impl SegmentDomain {
pub fn push(&mut self, id: SegmentId, start: usize, end: usize, handles: BezierHandles, stroke: StrokeId) {
#[cfg(debug_assertions)]
if self.id.contains(&id) {
warn!("Tried to push an existing point to a point domain");
warn!("Tried to push a duplicate segment to a segment domain");
return;
}
self.push_unchecked(id, start, end, handles, stroke);
}
#[inline(always)]
pub fn push_unchecked(&mut self, id: SegmentId, start: usize, end: usize, handles: BezierHandles, stroke: StrokeId) {
self.id.push(id);
self.start_point.push(start);
self.end_point.push(end);
@@ -596,6 +619,13 @@ impl RegionDomain {
self.fill.clear();
}
#[inline(always)]
pub fn reserve(&mut self, additional: usize) {
self.id.reserve(additional);
self.segment_range.reserve(additional);
self.fill.reserve(additional);
}
pub fn retain(&mut self, f: impl Fn(&RegionId) -> bool) {
let mut keep = self.id.iter().map(&f);
self.segment_range.retain(|_| keep.next().unwrap_or_default());
@@ -618,10 +648,17 @@ impl RegionDomain {
}
pub fn push(&mut self, id: RegionId, segment_range: std::ops::RangeInclusive<SegmentId>, fill: FillId) {
#[cfg(debug_assertions)]
if self.id.contains(&id) {
warn!("Duplicate region");
warn!("Tried to push a duplicate region to a region domain");
return;
}
self.push_unchecked(id, segment_range, fill);
}
#[inline(always)]
pub fn push_unchecked(&mut self, id: RegionId, segment_range: std::ops::RangeInclusive<SegmentId>, fill: FillId) {
self.id.push(id);
self.segment_range.push(segment_range);
self.fill.push(fill);