Implement shape tweens (same-topology lerp)

`tween_after == Shape` was stored on keyframes but never read. Now the
render path morphs geometry across a shape-tween span:

- VectorGraph::interpolated(other, t): same-topology lerp of vertex
  positions, edge curves, stroke widths and stroke/fill colours. Returns
  None when topology differs (counts, deleted flags, edge endpoints, fill
  boundaries), so the caller holds the source keyframe.
- VectorLayer::tweened_graph_at(time): returns an owned morphed graph for
  a shape-tween span whose two keyframes share topology, else borrows the
  held keyframe. Editing still uses graph_at_time (the held keyframe).
- Renderer (Vello + CPU paths) renders via tweened_graph_at.
- SetTweenAction + wired the previously-stubbed "Add Shape Tween" menu.

The typical workflow — keyframe, duplicate it (same topology), move
vertices, Add Shape Tween — now morphs between the two. Non-matching
topology falls back to a hold.
This commit is contained in:
Skyler Lehmkuhl 2026-06-21 15:32:24 -04:00
parent 1dd5de4617
commit a1acecf396
8 changed files with 282 additions and 7 deletions

View File

@ -29,6 +29,7 @@ pub mod update_midi_events;
pub mod loop_clip_instances; pub mod loop_clip_instances;
pub mod remove_clip_instances; pub mod remove_clip_instances;
pub mod set_keyframe; pub mod set_keyframe;
pub mod set_tween;
pub mod group_shapes; pub mod group_shapes;
pub mod convert_to_movie_clip; pub mod convert_to_movie_clip;
pub mod region_split; pub mod region_split;
@ -67,6 +68,7 @@ pub use update_midi_events::UpdateMidiEventsAction;
pub use loop_clip_instances::LoopClipInstancesAction; pub use loop_clip_instances::LoopClipInstancesAction;
pub use remove_clip_instances::RemoveClipInstancesAction; pub use remove_clip_instances::RemoveClipInstancesAction;
pub use set_keyframe::SetKeyframeAction; pub use set_keyframe::SetKeyframeAction;
pub use set_tween::SetTweenAction;
pub use group_shapes::GroupAction; pub use group_shapes::GroupAction;
pub use convert_to_movie_clip::ConvertToMovieClipAction; pub use convert_to_movie_clip::ConvertToMovieClipAction;
pub use region_split::RegionSplitAction; pub use region_split::RegionSplitAction;

View File

@ -0,0 +1,57 @@
//! Set the tween type on the keyframe at-or-before a time (e.g. "Add Shape Tween").
//!
//! The keyframe's `tween_after` controls how the span between it and the next keyframe is
//! rendered: `None` holds, `Shape` morphs the geometry (when the two keyframes share
//! topology — otherwise rendering falls back to holding).
use crate::action::Action;
use crate::document::Document;
use crate::layer::{AnyLayer, TweenType};
use uuid::Uuid;
pub struct SetTweenAction {
layer_id: Uuid,
time: f64,
new_tween: TweenType,
old_tween: Option<TweenType>,
}
impl SetTweenAction {
pub fn new(layer_id: Uuid, time: f64, new_tween: TweenType) -> Self {
Self { layer_id, time, new_tween, old_tween: None }
}
}
impl Action for SetTweenAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
if let Some(AnyLayer::Vector(vl)) = document.get_layer_mut(&self.layer_id) {
if let Some(kf) = vl.keyframe_at_mut(self.time) {
self.old_tween = Some(kf.tween_after);
kf.tween_after = self.new_tween;
} else {
return Err("No keyframe at-or-before this time".to_string());
}
} else {
return Err("Not a vector layer".to_string());
}
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
if let (Some(old), Some(AnyLayer::Vector(vl))) =
(self.old_tween, document.get_layer_mut(&self.layer_id))
{
if let Some(kf) = vl.keyframe_at_mut(self.time) {
kf.tween_after = old;
}
}
Ok(())
}
fn description(&self) -> String {
match self.new_tween {
TweenType::Shape => "Add shape tween".to_string(),
TweenType::None => "Remove tween".to_string(),
}
}
}

View File

@ -382,6 +382,29 @@ impl VectorLayer {
self.keyframe_at(time).map(|kf| &kf.graph) self.keyframe_at(time).map(|kf| &kf.graph)
} }
/// The VectorGraph to *render* at `time`. When the keyframe at-or-before `time` has
/// `tween_after == Shape` and the next keyframe shares its topology, returns an owned
/// graph morphed between them; otherwise borrows the held keyframe's graph. Editing
/// should keep using `graph_at_time`/`graph_at_time_mut` (the held keyframe).
pub fn tweened_graph_at(&self, time: f64) -> Option<std::borrow::Cow<'_, VectorGraph>> {
use std::borrow::Cow;
let idx = self.keyframes.partition_point(|kf| kf.time <= time);
if idx == 0 {
return None;
}
let a = &self.keyframes[idx - 1];
if a.tween_after == TweenType::Shape && idx < self.keyframes.len() {
let b = &self.keyframes[idx];
if b.time > a.time {
let t = ((time - a.time) / (b.time - a.time)).clamp(0.0, 1.0);
if let Some(g) = a.graph.interpolated(&b.graph, t) {
return Some(Cow::Owned(g));
}
}
}
Some(Cow::Borrowed(&a.graph))
}
/// Get a mutable VectorGraph at a given time /// Get a mutable VectorGraph at a given time
pub fn graph_at_time_mut(&mut self, time: f64) -> Option<&mut VectorGraph> { pub fn graph_at_time_mut(&mut self, time: f64) -> Option<&mut VectorGraph> {
self.keyframe_at_mut(time).map(|kf| &mut kf.graph) self.keyframe_at_mut(time).map(|kf| &mut kf.graph)
@ -1069,6 +1092,50 @@ impl AnyLayer {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn tweened_graph_at_morphs_between_shape_keyframes() {
use crate::vector_graph::{Direction, FillRule, ShapeColor};
use kurbo::{CubicBez, Point};
// Build a single-vertex-ish graph at a given x via one degenerate fill is overkill;
// use one vertex + one edge (a loop) is also odd. Use two vertices + one edge and
// just check the vertex lerp through the layer's tween path.
let mk = |x: f64| {
let mut g = VectorGraph::new();
let v0 = g.alloc_vertex(Point::new(x, 0.0));
let v1 = g.alloc_vertex(Point::new(x + 10.0, 0.0));
let c = CubicBez::new(
Point::new(x, 0.0),
Point::new(x + 3.0, 0.0),
Point::new(x + 7.0, 0.0),
Point::new(x + 10.0, 0.0),
);
g.alloc_edge(c, v0, v1, None, Some(ShapeColor::rgb(0, 0, 0)));
g.alloc_fill(vec![(crate::vector_graph::EdgeId(0), Direction::Forward)],
ShapeColor::rgb(255, 0, 0), FillRule::NonZero);
g
};
let mut layer = VectorLayer::new("L");
layer.keyframes.clear();
let mut kf0 = ShapeKeyframe::new(0.0);
kf0.graph = mk(0.0);
kf0.tween_after = TweenType::Shape;
let mut kf10 = ShapeKeyframe::new(10.0);
kf10.graph = mk(100.0);
layer.keyframes.push(kf0);
layer.keyframes.push(kf10);
// Midway through the tween, vertex 0 is halfway (x=50).
let g = layer.tweened_graph_at(5.0).unwrap();
assert!((g.vertices[0].position.x - 50.0).abs() < 1e-6);
// Without the tween flag, it holds the left keyframe (x=0).
layer.keyframes[0].tween_after = TweenType::None;
let g = layer.tweened_graph_at(5.0).unwrap();
assert!((g.vertices[0].position.x - 0.0).abs() < 1e-6);
}
#[test] #[test]
fn test_layer_creation() { fn test_layer_creation() {
let layer = Layer::new(LayerType::Vector, "Test Layer"); let layer = Layer::new(LayerType::Vector, "Test Layer");

View File

@ -1357,9 +1357,9 @@ fn render_vector_layer(
let layer_opacity = parent_opacity * layer.layer.opacity; let layer_opacity = parent_opacity * layer.layer.opacity;
// Render the layer's own VectorGraph (loose shapes) first, then clip instances // Render the layer's own VectorGraph (loose shapes) first, then clip instances
// (groups / movie clips) on top. // (groups / movie clips) on top. Shape tweens are applied here.
if let Some(graph) = layer.graph_at_time(time) { if let Some(graph) = layer.tweened_graph_at(time) {
render_vector_graph(graph, scene, base_transform, layer_opacity, document, image_cache); render_vector_graph(&graph, scene, base_transform, layer_opacity, document, image_cache);
} }
for clip_instance in &layer.clip_instances { for clip_instance in &layer.clip_instances {
@ -1668,8 +1668,8 @@ fn render_vector_layer_cpu(
let layer_opacity = parent_opacity * layer.layer.opacity; let layer_opacity = parent_opacity * layer.layer.opacity;
// Loose shapes first, then clip instances (groups / movie clips) on top. // Loose shapes first, then clip instances (groups / movie clips) on top.
if let Some(graph) = layer.graph_at_time(time) { if let Some(graph) = layer.tweened_graph_at(time) {
render_vector_graph_cpu(graph, pixmap, affine_to_ts(base_transform), layer_opacity as f32, document, image_cache); render_vector_graph_cpu(&graph, pixmap, affine_to_ts(base_transform), layer_opacity as f32, document, image_cache);
} }
for clip_instance in &layer.clip_instances { for clip_instance in &layer.clip_instances {

View File

@ -417,6 +417,67 @@ impl VectorGraph {
self.boundary_to_bezpath(&fill.boundary) self.boundary_to_bezpath(&fill.boundary)
} }
/// Interpolate toward `other` by `t` ∈ [0,1] for a same-topology shape tween.
///
/// Returns `None` if the two graphs don't share identical topology — same vertex,
/// edge and fill structure (counts, deleted flags, edge endpoints, fill boundaries).
/// In that case the caller should hold the source keyframe instead of morphing.
/// Vertex positions, edge curves, stroke widths and stroke/fill colours are lerped.
pub fn interpolated(&self, other: &VectorGraph, t: f64) -> Option<VectorGraph> {
if self.vertices.len() != other.vertices.len()
|| self.edges.len() != other.edges.len()
|| self.fills.len() != other.fills.len()
{
return None;
}
for (a, b) in self.vertices.iter().zip(&other.vertices) {
if a.deleted != b.deleted {
return None;
}
}
for (a, b) in self.edges.iter().zip(&other.edges) {
if a.deleted != b.deleted || a.vertices != b.vertices {
return None;
}
}
for (a, b) in self.fills.iter().zip(&other.fills) {
if a.deleted != b.deleted || a.boundary != b.boundary {
return None;
}
}
let lf = |x: f64, y: f64| x + (y - x) * t;
let lp = |p: Point, q: Point| Point::new(lf(p.x, q.x), lf(p.y, q.y));
let lc = |a: Option<ShapeColor>, b: Option<ShapeColor>| match (a, b) {
(Some(a), Some(b)) => {
let c = |x: u8, y: u8| (lf(x as f64, y as f64)).round().clamp(0.0, 255.0) as u8;
Some(ShapeColor::new(c(a.r, b.r), c(a.g, b.g), c(a.b, b.b), c(a.a, b.a)))
}
(a, _) => a,
};
let mut g = self.clone();
for (i, v) in g.vertices.iter_mut().enumerate() {
v.position = lp(self.vertices[i].position, other.vertices[i].position);
}
for (i, e) in g.edges.iter_mut().enumerate() {
let (a, b) = (self.edges[i].curve, other.edges[i].curve);
e.curve = CubicBez::new(lp(a.p0, b.p0), lp(a.p1, b.p1), lp(a.p2, b.p2), lp(a.p3, b.p3));
if let (Some(s), Some(sa), Some(sb)) = (
e.stroke_style.as_mut(),
self.edges[i].stroke_style.as_ref(),
other.edges[i].stroke_style.as_ref(),
) {
s.width = lf(sa.width, sb.width);
}
e.stroke_color = lc(self.edges[i].stroke_color, other.edges[i].stroke_color);
}
for (i, f) in g.fills.iter_mut().enumerate() {
f.color = lc(self.fills[i].color, other.fills[i].color);
}
Some(g)
}
/// A point guaranteed to lie inside the fill — for point-in-region classification /// A point guaranteed to lie inside the fill — for point-in-region classification
/// (e.g. deciding whether a fill is inside a lasso). Prefers the polygon area-centroid, /// (e.g. deciding whether a fill is inside a lasso). Prefers the polygon area-centroid,
/// but for a non-convex fill (e.g. an L-shape, where the area-centroid can fall in the /// but for a non-convex fill (e.g. an L-shape, where the area-centroid can fall in the

View File

@ -12,3 +12,5 @@ mod gap_close;
mod region; mod region;
#[cfg(test)] #[cfg(test)]
mod region_cut_select; mod region_cut_select;
#[cfg(test)]
mod tween;

View File

@ -0,0 +1,78 @@
//! Tests for same-topology shape-tween interpolation (`VectorGraph::interpolated`).
use super::super::*;
use kurbo::{CubicBez, Point};
fn line(a: Point, b: Point) -> CubicBez {
CubicBez::new(
a,
Point::new(a.x + (b.x - a.x) / 3.0, a.y + (b.y - a.y) / 3.0),
Point::new(a.x + 2.0 * (b.x - a.x) / 3.0, a.y + 2.0 * (b.y - a.y) / 3.0),
b,
)
}
/// Triangle (3 verts, 3 edges, 1 fill) offset by (ox, oy).
fn triangle(ox: f64, oy: f64) -> VectorGraph {
let mut g = VectorGraph::new();
let p = [
Point::new(ox, oy),
Point::new(ox + 100.0, oy),
Point::new(ox + 50.0, oy + 100.0),
];
let v: Vec<_> = p.iter().map(|&pt| g.alloc_vertex(pt)).collect();
let style = StrokeStyle { width: 1.0, ..Default::default() };
let mut boundary = Vec::new();
for i in 0..3 {
let e = g.alloc_edge(
line(p[i], p[(i + 1) % 3]),
v[i],
v[(i + 1) % 3],
Some(style.clone()),
Some(ShapeColor::rgb(0, 0, 0)),
);
boundary.push((e, Direction::Forward));
}
g.alloc_fill(boundary, ShapeColor::rgb(255, 0, 0), FillRule::NonZero);
g
}
#[test]
fn interpolate_same_topology_lerps_positions() {
let a = triangle(0.0, 0.0);
let b = triangle(100.0, 50.0);
let mid = a.interpolated(&b, 0.5).expect("same topology should interpolate");
// Vertex 0: (0,0) and (100,50) → (50,25). Curve endpoints follow.
assert!((mid.vertices[0].position.x - 50.0).abs() < 1e-6);
assert!((mid.vertices[0].position.y - 25.0).abs() < 1e-6);
assert!((mid.edges[0].curve.p0.x - 50.0).abs() < 1e-6);
// Endpoints: t=0 is `a`, t=1 is `b`.
assert!((a.interpolated(&b, 0.0).unwrap().vertices[0].position.x - 0.0).abs() < 1e-6);
assert!((a.interpolated(&b, 1.0).unwrap().vertices[0].position.x - 100.0).abs() < 1e-6);
}
#[test]
fn interpolate_lerps_fill_color() {
let mut a = triangle(0.0, 0.0);
let mut b = triangle(0.0, 0.0);
a.fills[0].color = Some(ShapeColor::rgb(0, 0, 0));
b.fills[0].color = Some(ShapeColor::rgb(100, 200, 40));
let mid = a.interpolated(&b, 0.5).unwrap();
let c = mid.fills[0].color.unwrap();
assert_eq!((c.r, c.g, c.b), (50, 100, 20));
}
#[test]
fn interpolate_topology_mismatch_returns_none() {
let a = triangle(0.0, 0.0);
let mut more_verts = triangle(0.0, 0.0);
more_verts.alloc_vertex(Point::new(999.0, 999.0));
assert!(a.interpolated(&more_verts, 0.5).is_none(), "different vertex count");
// Same counts but a moved edge endpoint (different vertices) is still a mismatch.
let mut rewired = triangle(0.0, 0.0);
rewired.edges[0].vertices = [VertexId(2), VertexId(1)];
assert!(a.interpolated(&rewired, 0.5).is_none(), "different edge endpoints");
}

View File

@ -3761,8 +3761,16 @@ impl EditorApp {
// TODO: Implement add motion tween // TODO: Implement add motion tween
} }
MenuAction::AddShapeTween => { MenuAction::AddShapeTween => {
println!("Menu: Add Shape Tween"); if let Some(layer_id) = self.active_layer_id {
// TODO: Implement add shape tween let action = lightningbeam_core::actions::SetTweenAction::new(
layer_id,
self.playback_time,
lightningbeam_core::layer::TweenType::Shape,
);
if let Err(e) = self.action_executor.execute(Box::new(action)) {
eprintln!("Failed to add shape tween: {}", e);
}
}
} }
MenuAction::ReturnToStart => { MenuAction::ReturnToStart => {
println!("Menu: Return to Start"); println!("Menu: Return to Start");