Merge pull request #3240 from vangel76/shift-snap-axis-3229

Viewer: a Shift-constrained drag snaps only along its axis
This commit is contained in:
FredApps
2026-11-14 17:25:59 +01:00
committed by GitHub
6 changed files with 201 additions and 18 deletions
+33
View File
@@ -1177,3 +1177,36 @@ fn polystar_undo_sanitizes_live_and_parked_property_selections_before_uid_reuse(
assert_eq!(layer(&s, lid).props.prop("transform/positionZ").unwrap(), &position_z, "the reused UID must not become an implicit expression target");
}
}
/// #3229: a drag Shift keeps to one axis snaps only that axis. A point a few pixels off the axis
/// lines the feature up with it (along x: its x) without taking it off the axis, as a line
/// through the point would; lines along the drag are left out, and the point reaches only `tol`
/// off the axis. Unconstrained (`snap`) the same point takes both coordinates, the feature leaving
/// the axis.
#[test]
fn a_shift_drag_snaps_only_along_its_axis() {
use crate::viewer::snap_along;
let src = SnapSource::Layer(LayerId(1));
let corner = SnapTarget::point([450.0, 150.0], PRI_LAYER, src);
let row = SnapTarget::hline(151.0, PRI_GUIDE, SnapSource::Guide);
let column = SnapTarget::vline(449.0, PRI_GUIDE, SnapSource::Guide);
let feature = [[448.0, 153.0]];
assert_eq!(snap(&feature, &[corner, row], 8.0).unwrap().at, [450.0, 150.0]);
// Along x: x onto the corner's, y kept (the nearer row does not pull it either).
let s = snap_along(&feature, &[corner, row], 8.0, true).unwrap();
assert_eq!(s.delta, [2.0, 0.0]);
assert_eq!(s.at, [450.0, 153.0]);
// The point, and the line from it to the feature (the feedback).
assert_eq!(s.hits, [corner, SnapTarget::vline(450.0, PRI_LAYER, src).within(Some([150.0, 153.0]))]);
// Along y: y onto the corner's, x kept (the column is left out).
let s = snap_along(&feature, &[corner, column], 8.0, false).unwrap();
assert_eq!(s.delta, [0.0, -3.0]);
assert_eq!(s.at, [448.0, 150.0]);
assert_eq!(s.hits, [corner, SnapTarget::hline(150.0, PRI_LAYER, src).within(Some([450.0, 448.0]))]);
// A line across the drag still snaps; a nearer one wins over the point.
let s = snap_along(&feature, &[corner, column], 8.0, true).unwrap();
assert_eq!(s.at, [449.0, 153.0]);
assert_eq!(s.hits, [column]);
// The point reaches only the tolerance off the axis.
assert_eq!(snap_along(&[[448.0, 170.0]], &[corner], 8.0, true), None);
}
+44
View File
@@ -189,6 +189,50 @@ pub fn layer_features(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> {
out
}
/// [`snap`] for a drag Shift keeps to one axis (`along_x`: it moves along x and y stays put, else
/// the other way round): only the free coordinate snaps, so the feature never leaves the axis
/// (#3229). A point target acts as a line through it across the drag (a vertical line at its x
/// for a drag along x) that reaches it only within `tol` on each axis (a square around the point),
/// so the feature lines up with a point near the axis, not with every point in the comp; lines
/// along the drag (horizontal ones for a drag along x) would only move the locked coordinate and
/// are left out. The nearest target wins, ties to the lower priority value. A hit on a point
/// reports the point (the feedback box) and the line from it to where the feature landed.
pub fn snap_along(sources: &[[f64; 2]], targets: &[SnapTarget], tol: f64, along_x: bool) -> Option<Snap> {
// (Key, source, target, and the free coordinate it snaps to.)
let mut best: Option<((f64, u8), [f64; 2], SnapTarget, f64)> = None;
for s in sources {
let (free, along) = if along_x { (s[0], s[1]) } else { (s[1], s[0]) };
for t in targets {
// The line the target snaps as: its coordinate and extent along it.
let (line, span) = match (t.kind, along_x) {
(SnapKind::Point, true) => (t.pos[0], Some([t.pos[1] - tol, t.pos[1] + tol])),
(SnapKind::Point, false) => (t.pos[1], Some([t.pos[0] - tol, t.pos[0] + tol])),
(SnapKind::VLine, true) => (t.pos[0], t.span),
(SnapKind::HLine, false) => (t.pos[1], t.span),
(SnapKind::VLine, false) | (SnapKind::HLine, true) => continue,
};
let d = (line - free).abs();
if d > tol || span.is_some_and(|[a, b]| along < a || along > b) {
continue;
}
let key = (d, t.priority);
if best.as_ref().is_none_or(|(k, _, _, _)| better(key, *k)) {
best = Some((key, *s, *t, line));
}
}
}
let (_, s, t, line) = best?;
let (delta, at) = if along_x { ([line - s[0], 0.0], [line, s[1]]) } else { ([0.0, line - s[1]], [s[0], line]) };
let mut hits = vec![t];
if t.kind == SnapKind::Point {
// The alignment, drawn from the point to the feature.
let (from, to) = if along_x { (t.pos[1], at[1]) } else { (t.pos[0], at[0]) };
let l = if along_x { SnapTarget::vline(line, t.priority, t.source) } else { SnapTarget::hline(line, t.priority, t.source) };
hits.push(l.within(Some([from, to])));
}
Some(Snap { delta, hits, at })
}
/// A layer's content box in comp space: corners (top left, top right, bottom right, bottom
/// left), edge midpoints (top, right, bottom, left) and centre.
struct BoxPoints {
+16 -8
View File
@@ -1507,10 +1507,13 @@ fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp:
}
}
Gesture::Move { layers, start, snap_src } => {
let mut d = one_axis([cpt[0] - start[0], cpt[1] - start[1]], mods.shift);
let raw = [cpt[0] - start[0], cpt[1] - start[1]];
let mut d = one_axis(raw, mods.shift);
if let Some(src) = snap_src {
let ids: Vec<LayerId> = layers.iter().map(|x| x.0).collect();
let c = vt::snap(app, &ctx, ectx, &map, &ids, &[[src[0] + d[0], src[1] + d[1]]], mods);
// Shift: only the coordinate along the axis snaps, so the layer stays on it (#3229).
let q = vt::SnapQuery { exclude: &ids, sources: &[[src[0] + d[0], src[1] + d[1]]], mods, extra: &[], along_x: drag_axis(raw, mods.shift) };
let c = vt::snap_with(app, &ctx, ectx, &map, &q);
d = [d[0] + c[0], d[1] + c[1]];
if vt::snapping_on(app, mods) {
vt::draw_handle(&painter, map.to_screen([src[0] + d[0], src[1] + d[1]]));
@@ -1550,10 +1553,12 @@ fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp:
Gesture::Anchor { layer, start_anchor, start_pos, start, inv, l2p, own, anchor_only } => {
// Pan Behind snaps the anchor point (to its own layer's box, other layers' features,
// guides and the grid); Shift keeps the move to one axis.
let d = one_axis([cpt[0] - start[0], cpt[1] - start[1]], mods.shift);
let raw = [cpt[0] - start[0], cpt[1] - start[1]];
let d = one_axis(raw, mods.shift);
let anchor_c = inv.inverse().map(|m| m.apply(gv2(start_anchor[0], start_anchor[1]))).unwrap_or(gv2(start[0], start[1]));
let want = [anchor_c.x + d[0], anchor_c.y + d[1]];
let sc = vt::snap_with(app, &ctx, ectx, &map, &vt::SnapQuery { exclude: &[layer], sources: &[want], mods, extra: &own });
let q = vt::SnapQuery { exclude: &[layer], sources: &[want], mods, extra: &own, along_x: drag_axis(raw, mods.shift) };
let sc = vt::snap_with(app, &ctx, ectx, &map, &q);
let a = inv.apply(gv2(start[0], start[1]));
let b = inv.apply(gv2(start[0] + d[0] + sc[0], start[1] + d[1] + sc[1]));
let dl = gv2(b.x - a.x, b.y - a.y);
@@ -1587,9 +1592,12 @@ fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp:
}
Gesture::Vertices { start, src, applied, inv, layer } => {
// The grabbed vertex snaps (to other layers' vertices and features, guides, grid);
// Shift keeps it to one axis, as a layer move does (#1964).
let want = one_axis([cpt[0] - start[0], cpt[1] - start[1]], mods.shift);
let c = vt::snap(app, &ctx, ectx, &map, &[layer], &[[src[0] + want[0], src[1] + want[1]]], mods);
// Shift keeps it to one axis, as a layer move does (#1964), snapping too (#3229).
let raw = [cpt[0] - start[0], cpt[1] - start[1]];
let want = one_axis(raw, mods.shift);
let at = [src[0] + want[0], src[1] + want[1]];
let q = vt::SnapQuery { exclude: &[layer], sources: &[at], mods, extra: &[], along_x: drag_axis(raw, mods.shift) };
let c = vt::snap_with(app, &ctx, ectx, &map, &q);
let total = [want[0] + c[0], want[1] + c[1]];
let d = inv.apply_vec(gv2(total[0] - applied[0], total[1] - applied[1]));
if d.x != 0.0 || d.y != 0.0 {
@@ -2101,7 +2109,7 @@ mod view;
use frame::{empty_state, rect_of, show_frame};
pub(crate) use frame::{extended_region, minify_factor, snap_to_pixels, styled_line, zoom_sampler, zoom_texture_options};
use geometry::{
anchor_in_comp, cam_state, drag_ratio, handle_axes, handle_cursor, handle_point, one_axis, project_segment, quad_intersects_box, rotate_value,
anchor_in_comp, cam_state, drag_axis, drag_ratio, handle_axes, handle_cursor, handle_point, one_axis, project_segment, quad_intersects_box, rotate_value,
rotation_sign, selected_others, snap_handle, turn_delta,
};
#[cfg(test)]
+10 -6
View File
@@ -260,15 +260,19 @@ pub(super) fn rotate_value(start_rot: f64, turned: f64, shift: bool) -> f64 {
/// A drag `d` (comp pixels), kept to the axis it moves along most when `shift` is held.
pub(super) fn one_axis(d: [f64; 2], shift: bool) -> [f64; 2] {
if !shift {
d
} else if d[0].abs() > d[1].abs() {
[d[0], 0.0]
} else {
[0.0, d[1]]
match drag_axis(d, shift) {
None => d,
Some(true) => [d[0], 0.0],
Some(false) => [0.0, d[1]],
}
}
/// The axis [`one_axis`] keeps a drag `d` to: `Some(true)` along x, `Some(false)` along y, `None`
/// without `shift` (both axes free). Its snapping keeps to the same axis (#3229).
pub(super) fn drag_axis(d: [f64; 2], shift: bool) -> Option<bool> {
shift.then(|| d[0].abs() > d[1].abs())
}
pub(super) fn point_in_quad(p: [f64; 2], q: &[[f64; 2]; 4]) -> bool {
let mut sign = 0.0;
for i in 0..4 {
+11 -4
View File
@@ -48,21 +48,23 @@ pub(crate) fn snap(
sources: &[[f64; 2]],
mods: egui::Modifiers,
) -> [f64; 2] {
snap_with(app, ctx, ectx, map, &SnapQuery { exclude, sources, mods, extra: &[] })
snap_with(app, ctx, ectx, map, &SnapQuery { exclude, sources, mods, extra: &[], along_x: None })
}
/// What [`snap_with`] snaps: the dragged features and the layers to ignore, plus `extra` layer
/// targets (the Pan Behind layer's own box).
/// targets (the Pan Behind layer's own box). `along_x` is the axis a Shift drag keeps to
/// (`Some(true)`: x), whose coordinate alone snaps ([`vw::snap_along`], #3229).
pub(crate) struct SnapQuery<'a> {
pub exclude: &'a [LayerId],
pub sources: &'a [[f64; 2]],
pub mods: egui::Modifiers,
pub extra: &'a [vw::SnapTarget],
pub along_x: Option<bool>,
}
/// [`snap`] with extra layer targets.
pub(crate) fn snap_with(app: &EffectcraftApp, ctx: &egui::Context, ectx: &EvalCtx, map: &ViewerMap, q: &SnapQuery) -> [f64; 2] {
let SnapQuery { exclude, sources, mods, extra } = *q;
let SnapQuery { exclude, sources, mods, extra, along_x } = *q;
let layers = snapping_on(app, mods);
let v = &app.ui.viewer;
let guides = v.snap_guides && v.guides;
@@ -75,7 +77,12 @@ pub(crate) fn snap_with(app: &EffectcraftApp, ctx: &egui::Context, ectx: &EvalCt
if layers {
targets.extend_from_slice(extra);
}
match vw::snap(sources, &targets, SNAP_PX / map.zoom.max(1e-3) as f64) {
let tol = SNAP_PX / map.zoom.max(1e-3) as f64;
let hit = match along_x {
Some(x) => vw::snap_along(sources, &targets, tol, x),
None => vw::snap(sources, &targets, tol),
};
match hit {
Some(s) => {
let d = s.delta;
ctx.data_mut(|dd| dd.insert_temp(snap_id(), s));
+87
View File
@@ -533,6 +533,37 @@ fn layer_drag_snaps_to_other_layers_midpoints() {
}
}
/// #3229: a layer Shift-dragged along x past another layer's corner a few pixels off the axis
/// lines up with the corner's x and stays on the axis (it used to jump onto the corner), with
/// Snap Edges Extended on and off.
#[test]
fn shift_drag_snaps_a_layer_along_its_axis_only() {
let mut h = harness();
let box_id = h.state().session.active_comp().unwrap().layers[0].id;
// A 100×100 target at x 450–550, y 50–150: its bottom left corner at (450, 150).
let target = h.state_mut().session.execute("layer.newSolid", json!({"name": "Target", "color": "#20e040", "width": 100, "height": 100})).unwrap()["layer"]
.as_u64()
.unwrap();
for extended in [true, false] {
// The 80×80 box at x 60–140, y 153–233: its top right corner (140, 153) 3 px below the
// target's corner.
let s = &mut h.state_mut().session;
s.execute("view.snappingOptions", json!({"edgesExtended": extended})).unwrap();
s.execute("prop.set", json!({"layer": target, "path": "transform/position", "value": [500, 100, 0]})).unwrap();
s.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 193, 0]})).unwrap();
s.execute("edit.deselectAll", json!({})).unwrap();
h.run_steps(2);
// Grab the box by that corner and Shift-drag it right (and a pixel down), the corner to
// 2 px short of the target corner's x.
let (from, to) = (screen(&h, [138.0, 155.0]), screen(&h, [446.0, 156.0]));
hold_drag(&mut h, from, to, egui::Modifiers::SHIFT);
let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone();
let p = l.props.prop("transform/position").unwrap().value.as_vec3();
assert!((p[0] - 410.0).abs() < 0.01, "the corner lines up with the target's, extended {extended}: {p:?}");
assert!((p[1] - 193.0).abs() < 1e-6, "and the box stays on the axis, extended {extended}: {p:?}");
}
}
/// With a shape layer selected the Rectangle tool draws a new group into its Contents (After
/// Effects' behaviour); with nothing selected it draws a new shape layer (#227).
#[test]
@@ -1004,6 +1035,32 @@ 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:?}");
}
/// #3229: a mask vertex Shift-dragged along x past another layer's corner a few pixels off the
/// axis lines up with the corner's x and stays on the axis (it used to jump onto the corner).
#[test]
fn shift_drag_snaps_a_mask_vertex_along_its_axis_only() {
let mut h = harness();
let plate = layer_id(&h, "Plate");
// The Box (80×80 at the comp centre) has its top left corner at (280, 140); the mask's first
// vertex starts 3 px below that row.
let corners = [[200.0, 143.0], [240.0, 100.0], [240.0, 60.0], [200.0, 60.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);
// Shift-drag it right (and a pixel down) to 2 px short of the corner's x.
let from = rect(&h, &format!("viewer.mask.{mask}.vertex.0")).center();
let to = from + (screen(&h, [278.0, 144.0]) - screen(&h, [200.0, 143.0]));
drag_path(&mut h, &[from, to], egui::Modifiers::SHIFT);
let v = mask_path(&h, "Plate").vertices[0];
assert!((v[0] - 280.0).abs() < 0.01, "the vertex lines up with the corner: {v:?}");
assert!((v[1] - 143.0).abs() < 1e-6, "and stays on the axis: {v:?}");
}
/// #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]
@@ -1501,6 +1558,36 @@ fn pan_behind_snaps_the_anchor_point() {
assert!((a[0] - 260.0).abs() < 0.01 && (a[1] - 120.0).abs() < 0.01, "{a:?}");
}
/// #3229: Pan Behind Shift-dragging the anchor point along x past another layer's corner a few
/// pixels off the axis lines it up with the corner's x and keeps it on the axis (it used to jump
/// onto the corner).
#[test]
fn pan_behind_shift_drag_snaps_the_anchor_along_its_axis_only() {
let mut h = harness();
let box_id = h.state().session.active_comp().unwrap().layers[0].id;
// A 100×100 target at x 450–550, y -3–97: its bottom left corner at (450, 97), 3 px above
// the anchor point's row. The anchor point (100, 100) sits 10 px above the box's centre, so
// none of the box's own lines runs along that row.
let target = h.state_mut().session.execute("layer.newSolid", json!({"name": "Target", "color": "#20e040", "width": 100, "height": 100})).unwrap()["layer"]
.as_u64()
.unwrap();
let s = &mut h.state_mut().session;
s.execute("prop.set", json!({"layer": target, "path": "transform/position", "value": [500, 47, 0]})).unwrap();
s.execute("prop.set", json!({"layer": box_id.0, "path": "transform/anchor", "value": [40, 30, 0]})).unwrap();
s.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 100, 0]})).unwrap();
s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap();
h.state_mut().ui.tool = Tool::PanBehind;
h.run_steps(2);
// Shift-drag the anchor right (and a pixel down) to 2 px short of the corner's x. The drag
// starts in small steps (Pan Behind picks the layer where the drag starts, still inside the
// box) and holds the end point (the gesture applies the previous frame's pointer).
let path = [screen(&h, [100.0, 100.0]), screen(&h, [110.0, 100.0]), screen(&h, [448.0, 101.0]), screen(&h, [448.0, 101.0])];
drag_path(&mut h, &path, egui::Modifiers::SHIFT);
let (p, a) = position_and_anchor(&h, box_id);
assert!((p[0] - 450.0).abs() < 0.01 && (a[0] - 390.0).abs() < 0.01, "the anchor point lines up with the corner: {p:?} {a:?}");
assert!((p[1] - 100.0).abs() < 1e-6 && (a[1] - 30.0).abs() < 1e-6, "and stays on the axis: {p:?} {a:?}");
}
/// Issue #147: Pan Behind snaps the anchor to its own layer's corners, edge midpoints and centre
/// (only the anchor itself is left out: the box stays put while the anchor moves).
#[test]