mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-14 21:44:17 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user