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:
parent
1dd5de4617
commit
a1acecf396
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue