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 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-30 05:15:35 +07:00
co-authored by Claude Opus 5.5
parent d26ab3b83e
commit e2cf4477e7
3 changed files with 88 additions and 0 deletions
+34
View File
@@ -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<Gesture> = 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::<u64>(egui::Id::unique("viewer-drag-n")).unwrap_or(0)));
match g {
+12
View File
@@ -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:?}");
}
+42
View File
@@ -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() {