From e2cf4477e71482c4e24b70f9b0539018d1fee5b1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 05:15:35 +0700 Subject: [PATCH] Viewer: a mask vertex held at the panel edge pans the view (#999) Dragging a mask or shape vertex, a tangent or a Pen point against the edge of the Composition panel now pans the view toward that edge, faster the further out the pointer is, and the point keeps moving with it, so zoomed-in rotoscoping no longer stops at the panel edge (as in After Effects). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/viewer.rs | 34 ++++++++++++++++++ crates/ui-egui/src/panels/viewer/tests.rs | 12 +++++++ crates/ui-egui/tests/it/ui_viewer.rs | 42 +++++++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 2e3dc9be0..f150cfc08 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1255,6 +1255,39 @@ fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v.puppet_layers = puppet_layers; } +/// Auto-pan (#999): a mask vertex, tangent or Pen drag held at (or past) the edge of the view +/// pans it, as in After Effects, so the point moves on past what was visible. Returns the map +/// of the panned view. +fn edge_pan(app: &mut EffectcraftApp, ctx: &egui::Context, g: &Gesture, map: ViewerMap, pos: Pos2) -> ViewerMap { + if !matches!(g, Gesture::Vertices { .. } | Gesture::Tangent { .. } | Gesture::Pen { .. }) { + return map; + } + let d = edge_pan_speed(map.area, pos) * ctx.input(|i| i.stable_dt).min(0.1); + if d == egui::Vec2::ZERO { + return map; + } + app.ui.viewer.pan = [app.ui.viewer.pan[0] + d.x, app.ui.viewer.pan[1] + d.y]; + ctx.request_repaint(); + ViewerMap { origin: map.origin + d, ..map } +} + +/// How fast (points per second, per axis) the view pans under a drag at `pos`: within 24 points +/// of an edge of `area`, or past it, toward that edge and faster the further out. +fn edge_pan_speed(area: Rect, pos: Pos2) -> egui::Vec2 { + const ZONE: f32 = 24.0; + let axis = |p: f32, lo: f32, hi: f32| { + let depth = if p < lo + ZONE { + lo + ZONE - p + } else if p > hi - ZONE { + hi - ZONE - p + } else { + 0.0 + }; + (depth / ZONE).clamp(-3.0, 3.0) * 400.0 + }; + vec2(axis(pos.x, area.min.x, area.max.x), axis(pos.y, area.min.y, area.max.y)) +} + fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) { let t = v.t; let zoom = v.zoom; @@ -1265,6 +1298,7 @@ fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: let painter = v.painter.clone(); let gesture: Option = ui.data(|d| d.get_temp(gid)); if let (Some(g), Some(pos)) = (gesture.clone(), resp.interact_pointer_pos()) { + let map = edge_pan(app, &ctx, &g, map, pos); let cpt = map.to_comp(pos); let merge = format!("viewer-drag-{}", ui.data(|d| d.get_temp::(egui::Id::unique("viewer-drag-n")).unwrap_or(0))); match g { diff --git a/crates/ui-egui/src/panels/viewer/tests.rs b/crates/ui-egui/src/panels/viewer/tests.rs index cb5bffe65..f9d3ef2f4 100644 --- a/crates/ui-egui/src/panels/viewer/tests.rs +++ b/crates/ui-egui/src/panels/viewer/tests.rs @@ -139,3 +139,15 @@ fn gizmo_segments_clip_at_the_near_plane() { assert!(s[1][0] > 10.0 && s[1][0].is_finite(), "toward the camera it grows, never flips: {s:?}"); assert!(project_segment(&pc, vec3(0.0, 0.0, -200.0), vec3(0.0, 0.0, -300.0)).is_none()); } + +/// #999: a vertex drag pans the view only near or past the edge, toward it, faster further out. +#[test] +fn edge_pan_speed_moves_toward_the_edge() { + let area = Rect::from_min_max(pos2(100.0, 50.0), pos2(900.0, 650.0)); + assert_eq!(edge_pan_speed(area, pos2(500.0, 350.0)), egui::Vec2::ZERO); + let left = edge_pan_speed(area, pos2(105.0, 350.0)); + assert!(left.x > 0.0 && left.y == 0.0, "near the left edge the view moves right to show more: {left:?}"); + assert!(edge_pan_speed(area, pos2(20.0, 350.0)).x > left.x, "faster past the edge"); + let corner = edge_pan_speed(area, pos2(950.0, 700.0)); + assert!(corner.x < 0.0 && corner.y < 0.0, "{corner:?}"); +} diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs index fb7557c83..41cf5fe43 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -831,6 +831,48 @@ fn shift_drag_keeps_a_mask_vertex_to_one_axis() { assert!(free[0] > shifted[0] && free[1] > shifted[1], "without Shift both axes move: {shifted:?} -> {free:?}"); } +/// #999: a mask vertex dragged against the edge of the Composition panel pans the view, and the +/// vertex keeps moving while the pointer is held there, as in After Effects. +#[test] +fn a_vertex_held_at_the_viewer_edge_pans_the_view() { + let mut h = harness(); + let plate = layer_id(&h, "Plate"); + let corners = [[100.0, 60.0], [500.0, 60.0], [500.0, 300.0], [100.0, 300.0]]; + let mask = { + let s = &mut h.state_mut().session; + s.execute("layer.select", json!({"layers": [plate]})).unwrap(); + let m = s.execute("mask.new", json!({"layer": plate, "vertices": corners, "closed": true})).unwrap()["mask"].as_u64().unwrap(); + s.execute("mask.selectVertices", json!({"vertices": [{"layer": plate, "mask": m, "index": 0}]})).unwrap(); + m + }; + h.run_steps(2); + let pan0 = h.state().ui.viewer.pan; + let from = rect(&h, &format!("viewer.mask.{mask}.vertex.0")).center(); + let edge = pos2(2.0, from.y); + h.input_mut().events.push(Event::PointerMoved(from)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for i in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(from + (edge - from) * (i as f32 / 6.0))); + h.step(); + } + let reached = mask_path(&h, "Plate").vertices[0]; + for _ in 0..20 { + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: edge, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + let pan = h.state().ui.viewer.pan; + assert!(pan[0] > pan0[0] && pan[1] == pan0[1], "the view panned right to show more on the left: {pan0:?} -> {pan:?}"); + let after = mask_path(&h, "Plate").vertices[0]; + assert!(after[0] < reached[0], "the vertex kept moving left with the view: {reached:?} -> {after:?}"); + // Back inside the view, nothing pans. + let still = h.state().ui.viewer.pan; + let mid = rect(&h, "viewer.comp").center(); + drag(&mut h, mid, mid + vec2(5.0, 0.0)); + assert_eq!(h.state().ui.viewer.pan, still); +} + /// #1769: a hidden masked layer keeps its mask path and vertices, and they remain editable. #[test] fn hidden_masked_layer_keeps_its_mask_path_editable() {