diff --git a/crates/engine/src/tests/viewer.rs b/crates/engine/src/tests/viewer.rs index a356aa192..9293cde33 100644 --- a/crates/engine/src/tests/viewer.rs +++ b/crates/engine/src/tests/viewer.rs @@ -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); +} diff --git a/crates/engine/src/viewer.rs b/crates/engine/src/viewer.rs index f8e4c7740..fd9cb220a 100644 --- a/crates/engine/src/viewer.rs +++ b/crates/engine/src/viewer.rs @@ -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 { + // (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 { diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 0391c8473..04a6c7e4f 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -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 = 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)] diff --git a/crates/ui-egui/src/panels/viewer/geometry.rs b/crates/ui-egui/src/panels/viewer/geometry.rs index 144047f32..e597b50b3 100644 --- a/crates/ui-egui/src/panels/viewer/geometry.rs +++ b/crates/ui-egui/src/panels/viewer/geometry.rs @@ -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 { + 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 { diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 162b40037..ded5fbdb3 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -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, } /// [`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)); diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs index 264f19ad0..db171b635 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -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]