diff --git a/lightningbeam-ui/lightningbeam-core/src/animation.rs b/lightningbeam-ui/lightningbeam-core/src/animation.rs index 0a0a16b..ba8ad50 100644 --- a/lightningbeam-ui/lightningbeam-core/src/animation.rs +++ b/lightningbeam-ui/lightningbeam-core/src/animation.rs @@ -322,6 +322,20 @@ impl AnimationCurve { } } + /// True when `time` lies strictly between two keyframes — an in-between frame of a + /// tween (not on a keyframe, not in the pre/post-extrapolation tails). + pub fn is_tween_inbetween(&self, time: f64, tol: f64) -> bool { + if self.keyframes.len() < 2 { + return false; + } + let first = self.keyframes.first().unwrap().time; + let last = self.keyframes.last().unwrap().time; + if time <= first + tol || time >= last - tol { + return false; + } + !self.keyframes.iter().any(|kf| (kf.time - time).abs() <= tol) + } + /// Extrapolate before the first keyframe fn extrapolate_pre(&self, time: f64, first_kf: &Keyframe) -> f64 { match self.pre_extrapolation { @@ -516,6 +530,17 @@ impl AnimationData { self.curves.remove(target) } + /// True when the object (e.g. a clip instance) is mid motion-tween at `time` — any of + /// its curves has `time` strictly between two keyframes. Used to lock out editing on + /// in-between frames (editing there would silently insert a keyframe and disturb the tween). + pub fn is_object_tweened_at(&self, id: uuid::Uuid, time: f64) -> bool { + const TOL: f64 = 0.001; + self.curves.iter().any(|(target, curve)| { + matches!(target, AnimationTarget::Object { id: oid, .. } if *oid == id) + && curve.is_tween_inbetween(time, TOL) + }) + } + /// Evaluate a property at a given time pub fn eval(&self, target: &AnimationTarget, time: f64, default: f64) -> f64 { self.curves @@ -545,3 +570,51 @@ impl AnimationData { (t, opacity) } } + +#[cfg(test)] +mod tween_lock_tests { + use super::*; + + #[test] + fn curve_in_between_detection() { + let mut c = AnimationCurve::new( + AnimationTarget::Object { id: uuid::Uuid::nil(), property: TransformProperty::X }, + 0.0, + ); + c.set_keyframe(Keyframe::linear(0.0, 0.0)); + c.set_keyframe(Keyframe::linear(10.0, 100.0)); + + assert!(c.is_tween_inbetween(5.0, 0.001), "strictly between keyframes"); + assert!(!c.is_tween_inbetween(0.0, 0.001), "on a keyframe"); + assert!(!c.is_tween_inbetween(10.0, 0.001), "on a keyframe"); + assert!(!c.is_tween_inbetween(15.0, 0.001), "past the last keyframe (extrapolation tail)"); + } + + #[test] + fn single_keyframe_is_never_in_between() { + let mut c = AnimationCurve::new( + AnimationTarget::Object { id: uuid::Uuid::nil(), property: TransformProperty::X }, + 0.0, + ); + c.set_keyframe(Keyframe::linear(10.0, 100.0)); + assert!(!c.is_tween_inbetween(5.0, 0.001)); + assert!(!c.is_tween_inbetween(20.0, 0.001)); + } + + #[test] + fn object_tweened_when_any_curve_is_in_between() { + let id = uuid::Uuid::new_v4(); + let mut data = AnimationData::new(); + let mut cx = AnimationCurve::new( + AnimationTarget::Object { id, property: TransformProperty::X }, + 0.0, + ); + cx.set_keyframe(Keyframe::linear(0.0, 0.0)); + cx.set_keyframe(Keyframe::linear(10.0, 100.0)); + data.set_curve(cx); + + assert!(data.is_object_tweened_at(id, 5.0)); + assert!(!data.is_object_tweened_at(id, 0.0)); + assert!(!data.is_object_tweened_at(uuid::Uuid::new_v4(), 5.0), "different object"); + } +} diff --git a/lightningbeam-ui/lightningbeam-core/src/layer.rs b/lightningbeam-ui/lightningbeam-core/src/layer.rs index 9616519..ef845c8 100644 --- a/lightningbeam-ui/lightningbeam-core/src/layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/layer.rs @@ -493,30 +493,53 @@ impl VectorLayer { &mut self.keyframes[insert_idx] } - /// Insert a new keyframe at time by cloning the DCEL from the active keyframe. + /// Insert a new keyframe at time, taking the geometry the layer shows there. /// If a keyframe already exists at the exact time, does nothing and returns it. + /// + /// Inside a shape-tween span this captures the *interpolated* geometry at `time` (not + /// the left keyframe's), and inherits the span's `tween_after` so the new keyframe keeps + /// tweening toward the next one — i.e. splitting a tween in two leaves the motion intact. pub fn insert_keyframe_from_current(&mut self, time: f64) -> &mut ShapeKeyframe { let tolerance = 0.001; if let Some(idx) = self.keyframe_index_at_exact(time, tolerance) { return &mut self.keyframes[idx]; } - // Clone graph and clip instance IDs from the active keyframe - let (cloned_graph, cloned_clip_ids) = self + // Geometry shown at `time` (interpolated if mid-tween, else the held keyframe). + let cloned_graph = self + .tweened_graph_at(time) + .map(|g| g.into_owned()) + .unwrap_or_else(VectorGraph::new); + // Inherit tween + clip instances from the active (left) keyframe. + let (tween_after, cloned_clip_ids) = self .keyframe_at(time) - .map(|kf| { - (kf.graph.clone(), kf.clip_instance_ids.clone()) - }) - .unwrap_or_else(|| (VectorGraph::new(), Vec::new())); + .map(|kf| (kf.tween_after, kf.clip_instance_ids.clone())) + .unwrap_or((TweenType::None, Vec::new())); let insert_idx = self.keyframes.partition_point(|kf| kf.time < time); let mut kf = ShapeKeyframe::new(time); kf.graph = cloned_graph; + kf.tween_after = tween_after; kf.clip_instance_ids = cloned_clip_ids; self.keyframes.insert(insert_idx, kf); &mut self.keyframes[insert_idx] } + /// True when `time` falls strictly inside a shape-tween span — i.e. an in-between frame + /// (the left keyframe has `tween_after == Shape` and `time` is not on either keyframe). + /// Editing such a frame would silently modify the left keyframe, so the editor blocks it. + pub fn is_tween_inbetween(&self, time: f64) -> bool { + let tol = 0.001; + let idx = self.keyframes.partition_point(|kf| kf.time <= time); + if idx == 0 || idx >= self.keyframes.len() { + return false; + } + let (a, b) = (&self.keyframes[idx - 1], &self.keyframes[idx]); + a.tween_after == TweenType::Shape + && (time - a.time).abs() > tol + && (b.time - time).abs() > tol + } + /// Remove a keyframe at the exact time (within tolerance). /// Returns the removed keyframe if found. pub(crate) fn remove_keyframe_at(&mut self, time: f64, tolerance: f64) -> Option { @@ -1136,6 +1159,53 @@ mod tests { assert!((g.vertices[0].position.x - 0.0).abs() < 1e-6); } + #[test] + fn inserting_keyframe_mid_tween_captures_interpolated_geometry_and_inherits_tween() { + use crate::vector_graph::{Direction, FillRule, ShapeColor}; + use kurbo::{CubicBez, Point}; + + 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 kf_a = ShapeKeyframe::new(0.0); + kf_a.graph = mk(0.0); + kf_a.tween_after = TweenType::Shape; + let mut kf_b = ShapeKeyframe::new(10.0); + kf_b.graph = mk(100.0); + layer.keyframes.push(kf_a); + layer.keyframes.push(kf_b); + + // Insert keyframe C at the midpoint of the A→B shape tween. + layer.insert_keyframe_from_current(5.0); + + let c = layer.keyframe_at(5.0).expect("keyframe C exists"); + // C took the interpolated geometry (x=50), not A's geometry (x=0). + assert!((c.graph.vertices[0].position.x - 50.0).abs() < 1e-6, + "C should hold interpolated geometry, got {}", c.graph.vertices[0].position.x); + // C inherits the shape tween so it still morphs toward B. + assert_eq!(c.tween_after, TweenType::Shape, "C should inherit the shape tween"); + + // The frame between C and B is still an in-between (tween continues past C). + assert!(layer.is_tween_inbetween(7.0)); + // C itself is a keyframe, not an in-between. + assert!(!layer.is_tween_inbetween(5.0)); + } + #[test] fn test_layer_creation() { let layer = Layer::new(LayerType::Vector, "Test Layer"); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 823b094..bbf139b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -3671,6 +3671,11 @@ impl StagePane { let point = Point::new(world_pos.x as f64, world_pos.y as f64); + // On a shape-tween in-between frame the displayed geometry is interpolated, not a + // real keyframe — editing it would silently mutate the left keyframe. Lock out + // geometry selection/editing there; clip-instance interaction stays available. + let inbetween = vector_layer.is_tween_inbetween(*shared.playback_time); + // Double-click: enter/exit movie clip editing if response.double_clicked() { // Hit test clip instances at the click position @@ -3710,16 +3715,21 @@ impl StagePane { // Scope this section to drop vector_layer borrow before drag handling let mouse_pressed = self.rsp_primary_pressed(ui); if mouse_pressed { - // VECTOR EDITING: Check for vertex/curve editing first (higher priority than selection) + // VECTOR EDITING: Check for vertex/curve editing first (higher priority than + // selection). Skipped on tween in-between frames — geometry isn't editable there. let tolerance = EditingHitTolerance::scaled_by_zoom(self.zoom as f64); - let vector_hit = hit_test_vector_editing( - vector_layer, - *shared.playback_time, - point, - &tolerance, - Affine::IDENTITY, - false, // Select tool doesn't show control points - ); + let vector_hit = if inbetween { + None + } else { + hit_test_vector_editing( + vector_layer, + *shared.playback_time, + point, + &tolerance, + Affine::IDENTITY, + false, // Select tool doesn't show control points + ) + }; // Priority 1: Vector editing (vertices immediately, curves deferred) if let Some(hit) = vector_hit { match hit { @@ -3756,6 +3766,9 @@ impl StagePane { let hit_result = if let Some(clip_id) = clip_hit { Some(hit_test::HitResult::ClipInstance(clip_id)) + } else if inbetween { + // Geometry not selectable on an in-between frame. + None } else { // No clip hit, test DCEL edges and faces hit_test::hit_test_layer(vector_layer, *shared.playback_time, point, 5.0, Affine::IDENTITY) @@ -3806,8 +3819,16 @@ impl StagePane { } *shared.focus = lightningbeam_core::selection::FocusSelection::ClipInstances(shared.selection.clip_instances().to_vec()); - // If clip instance is now selected, prepare for dragging - if shared.selection.contains_clip_instance(&clip_id) { + // A clip mid motion-tween shows an interpolated position; moving it + // there would silently insert a keyframe and disturb the tween. Allow + // selecting it, but don't start a drag. + let clip_motion_locked = vector_layer + .layer + .animation_data + .is_object_tweened_at(clip_id, *shared.playback_time); + + // If clip instance is now selected (and editable), prepare for dragging + if !clip_motion_locked && shared.selection.contains_clip_instance(&clip_id) { // Store original positions of all selected clip instances let mut original_positions = std::collections::HashMap::new(); for &clip_inst_id in shared.selection.clip_instances() { @@ -9430,6 +9451,20 @@ impl StagePane { return; } + // Block transforming a clip instance that's mid motion-tween (on an in-between + // frame) — it would silently insert a keyframe and disturb the tween. + if is_vector_layer { + let locked = match shared.action_executor.document().get_layer(&active_layer_id) { + Some(AnyLayer::Vector(vl)) => shared.selection.clip_instances().iter().any(|id| { + vl.layer.animation_data.is_object_tweened_at(*id, *shared.playback_time) + }), + _ => false, + }; + if locked { + return; + } + } + let point = Point::new(world_pos.x as f64, world_pos.y as f64); // For video layers, transform the visible clip at playback time (no selection needed) @@ -10688,6 +10723,17 @@ impl StagePane { if !alt_held { use lightningbeam_core::tool::Tool; + // On a shape-tween in-between frame the active vector layer's geometry is an + // interpolated preview, not a real keyframe — lock out the editing tools. + let vector_inbetween = shared.active_layer_id.and_then(|id| { + match shared.action_executor.document().get_layer(&id) { + Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) => { + Some(vl.is_tween_inbetween(*shared.playback_time)) + } + _ => None, + } + }).unwrap_or(false); + match *shared.selected_tool { Tool::Select => { let is_raster = shared.active_layer_id.and_then(|id| { @@ -10699,13 +10745,13 @@ impl StagePane { self.handle_select_tool(ui, &response, world_pos, shift_held, shared); } } - Tool::BezierEdit => { + Tool::BezierEdit if !vector_inbetween => { self.handle_bezier_edit_tool(ui, &response, world_pos, shift_held, shared); } - Tool::Rectangle => { + Tool::Rectangle if !vector_inbetween => { self.handle_rectangle_tool(ui, &response, world_pos, shift_held, ctrl_held, shared); } - Tool::Ellipse => { + Tool::Ellipse if !vector_inbetween => { self.handle_ellipse_tool(ui, &response, world_pos, shift_held, ctrl_held, shared); } Tool::Draw => { @@ -10715,7 +10761,7 @@ impl StagePane { }).map_or(false, |l| matches!(l, lightningbeam_core::layer::AnyLayer::Raster(_))); if is_raster { self.handle_unified_raster_stroke_tool(ui, &response, world_pos, &crate::tools::paint::PAINT, shared); - } else { + } else if !vector_inbetween { self.handle_draw_tool(ui, &response, world_pos, shared); } } @@ -10735,19 +10781,19 @@ impl StagePane { Tool::Transform => { self.handle_transform_tool(ui, &response, world_pos, shared); } - Tool::PaintBucket => { + Tool::PaintBucket if !vector_inbetween => { self.handle_paint_bucket_tool(&response, world_pos, shared); } - Tool::Line => { + Tool::Line if !vector_inbetween => { self.handle_line_tool(ui, &response, world_pos, shift_held, ctrl_held, shared); } - Tool::Polygon => { + Tool::Polygon if !vector_inbetween => { self.handle_polygon_tool(ui, &response, world_pos, shift_held, ctrl_held, shared); } Tool::Eyedropper => { self.handle_eyedropper_tool(ui, &response, mouse_pos, shared); } - Tool::RegionSelect => { + Tool::RegionSelect if !vector_inbetween => { self.handle_region_select_tool(ui, &response, world_pos, shift_held, shared); } Tool::Warp => {