From a1acecf39660ac725144ff452e92253bcadae8d7 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Sun, 21 Jun 2026 15:32:24 -0400 Subject: [PATCH] Implement shape tweens (same-topology lerp) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .../lightningbeam-core/src/actions/mod.rs | 2 + .../src/actions/set_tween.rs | 57 ++++++++++++++ .../lightningbeam-core/src/layer.rs | 67 ++++++++++++++++ .../lightningbeam-core/src/renderer.rs | 10 +-- .../src/vector_graph/mod.rs | 61 +++++++++++++++ .../src/vector_graph/tests/mod.rs | 2 + .../src/vector_graph/tests/tween.rs | 78 +++++++++++++++++++ .../lightningbeam-editor/src/main.rs | 12 ++- 8 files changed, 282 insertions(+), 7 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-core/src/actions/set_tween.rs create mode 100644 lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/tween.rs diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs index 1663b49..ed209cf 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/mod.rs @@ -29,6 +29,7 @@ pub mod update_midi_events; pub mod loop_clip_instances; pub mod remove_clip_instances; pub mod set_keyframe; +pub mod set_tween; pub mod group_shapes; pub mod convert_to_movie_clip; pub mod region_split; @@ -67,6 +68,7 @@ pub use update_midi_events::UpdateMidiEventsAction; pub use loop_clip_instances::LoopClipInstancesAction; pub use remove_clip_instances::RemoveClipInstancesAction; pub use set_keyframe::SetKeyframeAction; +pub use set_tween::SetTweenAction; pub use group_shapes::GroupAction; pub use convert_to_movie_clip::ConvertToMovieClipAction; pub use region_split::RegionSplitAction; diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/set_tween.rs b/lightningbeam-ui/lightningbeam-core/src/actions/set_tween.rs new file mode 100644 index 0000000..51ce85a --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/actions/set_tween.rs @@ -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, +} + +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(), + } + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/layer.rs b/lightningbeam-ui/lightningbeam-core/src/layer.rs index 6b15ffa..9616519 100644 --- a/lightningbeam-ui/lightningbeam-core/src/layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/layer.rs @@ -382,6 +382,29 @@ impl VectorLayer { 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> { + 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 pub fn graph_at_time_mut(&mut self, time: f64) -> Option<&mut VectorGraph> { self.keyframe_at_mut(time).map(|kf| &mut kf.graph) @@ -1069,6 +1092,50 @@ impl AnyLayer { mod tests { 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] fn test_layer_creation() { let layer = Layer::new(LayerType::Vector, "Test Layer"); diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index 6a9c059..7186606 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -1357,9 +1357,9 @@ fn render_vector_layer( let layer_opacity = parent_opacity * layer.layer.opacity; // Render the layer's own VectorGraph (loose shapes) first, then clip instances - // (groups / movie clips) on top. - if let Some(graph) = layer.graph_at_time(time) { - render_vector_graph(graph, scene, base_transform, layer_opacity, document, image_cache); + // (groups / movie clips) on top. Shape tweens are applied here. + if let Some(graph) = layer.tweened_graph_at(time) { + render_vector_graph(&graph, scene, base_transform, layer_opacity, document, image_cache); } for clip_instance in &layer.clip_instances { @@ -1668,8 +1668,8 @@ fn render_vector_layer_cpu( let layer_opacity = parent_opacity * layer.layer.opacity; // Loose shapes first, then clip instances (groups / movie clips) on top. - if let Some(graph) = layer.graph_at_time(time) { - render_vector_graph_cpu(graph, pixmap, affine_to_ts(base_transform), layer_opacity as f32, document, image_cache); + 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); } for clip_instance in &layer.clip_instances { diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs b/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs index c62e3da..627cc72 100644 --- a/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs @@ -417,6 +417,67 @@ impl VectorGraph { 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 { + 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, b: Option| 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 /// (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 diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/mod.rs b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/mod.rs index a1af2b4..9bb1b34 100644 --- a/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/mod.rs @@ -12,3 +12,5 @@ mod gap_close; mod region; #[cfg(test)] mod region_cut_select; +#[cfg(test)] +mod tween; diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/tween.rs b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/tween.rs new file mode 100644 index 0000000..f19c59f --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/tests/tween.rs @@ -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"); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 57bfed8..6ae9f3e 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -3761,8 +3761,16 @@ impl EditorApp { // TODO: Implement add motion tween } MenuAction::AddShapeTween => { - println!("Menu: Add Shape Tween"); - // TODO: Implement add shape tween + if let Some(layer_id) = self.active_layer_id { + 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 => { println!("Menu: Return to Start");