diff --git a/crates/engine/src/tests/viewer.rs b/crates/engine/src/tests/viewer.rs index ef2bd8acc..9a0b9cc58 100644 --- a/crates/engine/src/tests/viewer.rs +++ b/crates/engine/src/tests/viewer.rs @@ -60,6 +60,62 @@ fn snap_lines_snap_axes_independently_and_priority_breaks_ties() { assert_eq!(snap(&[[0.0, 0.0]], &t3, 5.0).unwrap().hits[0].source, SnapSource::Vertex(LayerId(3))); } +/// #2855: an edge midpoint lies on its edge line, so one line nearer than the point must not win +/// and leave the other axis free. Two lines do win when together they are nearer. +#[test] +fn snap_point_beats_one_line_and_loses_only_to_two_nearer_lines() { + let src = SnapSource::Layer(LayerId(1)); + let mid = SnapTarget::point([100.0, 50.0], PRI_LAYER, src); + let top = SnapTarget::hline(50.0, PRI_LAYER, src); + // The line alone is nearer (1 against √10), and the point still snaps both axes. + let s = snap(&[[103.0, 51.0]], &[top, mid], 5.0).unwrap(); + assert_eq!(s.at, [100.0, 50.0]); + assert_eq!(s.delta, [-3.0, -1.0]); + assert_eq!(s.hits, [mid]); + // Lines through the point itself tie with it: the point. + let centre = SnapTarget::vline(100.0, PRI_LAYER, src); + assert_eq!(snap(&[[103.0, 51.0]], &[top, centre, mid], 5.0).unwrap().hits, [mid]); + // Two lines together nearer than the point: both snap. + let far = SnapTarget::point([101.0, 55.0], PRI_LAYER, src); + let v = SnapTarget::vline(102.0, PRI_GUIDE, SnapSource::Guide); + let h = SnapTarget::hline(52.0, PRI_GUIDE, SnapSource::Guide); + let s = snap(&[[103.0, 51.0]], &[v, h, far], 5.0).unwrap(); + assert_eq!(s.at, [102.0, 52.0]); + assert_eq!(s.hits, [v, h]); +} + +/// #2855: an unrotated layer's centre lines follow its edges' span rule, reach the outer comp +/// through a collapsed precomp layer, and a rotated layer keeps its points only. +#[test] +fn layer_centre_lines_follow_the_edges() { + use crate::viewer::{SnapFeatures, SnapOptions, targets}; + let mut s = comp(); + // A 100×50 solid moved off the comp's centre lines: x 70–170, y 55–105, centre (120, 80). + let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 100, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [120, 80, 0]})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let at = |s: &Session, f: SnapFeatures| { + let comp = s.project.comp(cid).unwrap().clone(); + let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, Tick::ZERO); + targets(&ctx, &[], SnapOptions { layers: true, features: f, guides: false, grid: false, grid_spacing: 0.0 }) + }; + let lines = |t: &[SnapTarget], l: LayerId| { + let of = |kind: SnapKind, i: usize, c: f64| t.iter().find(|t| t.kind == kind && t.source == SnapSource::Layer(l) && t.pos[i] == c).map(|t| t.span); + (of(SnapKind::VLine, 0, 120.0), of(SnapKind::HLine, 1, 80.0)) + }; + assert_eq!(lines(&at(&s, SnapFeatures::default()), LayerId(a)), (Some(None), Some(None))); + let off = SnapFeatures { edges_extended: false, ..Default::default() }; + assert_eq!(lines(&at(&s, off), LayerId(a)), (Some(Some([55.0, 105.0])), Some(Some([70.0, 170.0])))); + // Through a collapsed precomp layer (it fills the comp, unmoved, so the numbers stay). + s.execute("layer.precompose", json!({"layers": [a], "name": "Inner"})).unwrap(); + let pre = s.active_comp().unwrap().layers[0].id; + s.execute("layer.setSwitch", json!({"layers": [pre.0], "switch": "collapse", "value": true})).unwrap(); + assert_eq!(lines(&at(&s, off), pre), (Some(Some([55.0, 105.0])), Some(Some([70.0, 170.0])))); + // Rotated: no lines at all. + s.execute("prop.set", json!({"layer": pre.0, "path": "transform/rotation", "value": 30})).unwrap(); + assert_eq!(lines(&at(&s, off), pre), (None, None)); +} + #[test] fn snap_targets_cover_layers_comp_guides_and_grid() { let mut s = comp(); diff --git a/crates/engine/src/viewer.rs b/crates/engine/src/viewer.rs index 6b6e5ce99..1889b6a83 100644 --- a/crates/engine/src/viewer.rs +++ b/crates/engine/src/viewer.rs @@ -48,7 +48,7 @@ pub struct SnapTarget { pub priority: u8, pub source: SnapSource, /// A line's extent along itself (y of a vertical line, x of a horizontal one): a layer's - /// edge without Snap Edges Extended. `None` runs across the whole comp. + /// edge or centre line without Snap Edges Extended. `None` runs across the whole comp. #[serde(default)] pub span: Option<[f64; 2]>, } @@ -70,7 +70,7 @@ impl SnapTarget { } /// Tools bar ▸ Snapping options, the two After Effects' Snapping menu has: Snap Edges Extended -/// (layer edges snap along their whole line, beyond the layer's bounds) and Snap to Features in +/// (layer edge and centre lines snap along their whole line, beyond the layer's bounds) and Snap to Features in /// Collapsed Compositions and Text Layers (the layers inside a precomp layer with Collapse /// Transformations on are targets too). Both are on by default. Every layer feature snaps /// (corners, edge midpoints, centre, anchor point, mask and shape path points), as in After @@ -126,9 +126,10 @@ fn better(a: (f64, u8), b: (f64, u8)) -> bool { } /// Snap feature points `sources` (comp pixels) to the nearest target within `tol` comp pixels. -/// A point target snaps both axes and wins when it is at least as near as the best lines; -/// otherwise the nearest vertical and horizontal lines snap x and y independently. Ties go to -/// the lower priority value. +/// A point target snaps both axes and wins unless a vertical and a horizontal line both resolve +/// and together lie nearer than it (one line alone leaves the other axis free, so it never beats +/// a point: a layer's edge midpoint lies on its edge line); otherwise the nearest vertical and +/// horizontal lines snap x and y independently. Ties go to the lower priority value. pub fn snap(sources: &[[f64; 2]], targets: &[SnapTarget], tol: f64) -> Option { let mut best_pt: Option<((f64, u8), [f64; 2], SnapTarget)> = None; let mut best_x: Option<((f64, u8), [f64; 2], SnapTarget)> = None; @@ -150,9 +151,9 @@ pub fn snap(sources: &[[f64; 2]], targets: &[SnapTarget], tol: f64) -> Option(ctx: &EvalCtx<'a>, exclude: &'a [LayerId]) -> impl Iterato comp.layers.iter().filter(move |l| l.is_active_at(time) && l.switches.video && (!any_solo || l.switches.solo) && !exclude.contains(&l.id)) } -/// A 2D layer's snap targets: its box points, its edges as lines while unrotated (across the -/// comp with Snap Edges Extended, along the layer otherwise), its anchor point unless `anchor` is +/// A 2D layer's snap targets: its box points, its edges and centre as lines while unrotated (across +/// the comp with Snap Edges Extended, along the layer otherwise), its anchor point unless `anchor` is /// false (Pan Behind drags it, the box stays put), its mask and shape path vertices and, with /// Snap to Features in Collapsed Compositions, the targets of the visible layers inside a /// collapsed precomp layer. None for cameras, lights and 3D layers. @@ -386,10 +387,10 @@ fn layer_targets_at(ctx: &EvalCtx, l: &Layer, anchor: bool, f: SnapFeatures, dep let [nw, ne, _, sw] = b.corners; if (nw[1] - ne[1]).abs() < 1e-6 && (nw[0] - sw[0]).abs() < 1e-6 { let (xs, ys) = if f.edges_extended { (None, None) } else { (Some([nw[0], ne[0]]), Some([nw[1], sw[1]])) }; - for x in [nw[0], ne[0]] { + for x in [nw[0], b.center[0], ne[0]] { out.push(SnapTarget::vline(x, PRI_LAYER, src).within(ys)); } - for y in [nw[1], sw[1]] { + for y in [nw[1], b.center[1], sw[1]] { out.push(SnapTarget::hline(y, PRI_LAYER, src).within(xs)); } } diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs index cc86f22e4..7b32fa2ec 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -420,6 +420,42 @@ fn layer_drag_snaps_to_other_layers_edges_ctrl_toggles_and_options_apply() { assert!(!snapped(p), "edges not extended: {p:?}"); } +/// #2855: a dragged layer's edge midpoints snap to another layer's on both axes, side by side and +/// stacked, with Snap Edges Extended on and off. +#[test] +fn layer_drag_snaps_to_other_layers_midpoints() { + 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, centre (500, 100). + let target = h.state_mut().session.execute("layer.newSolid", json!({"name": "Target", "color": "#20e040", "width": 100, "height": 100})).unwrap()["layer"] + .as_u64() + .unwrap(); + // Grab the 80×80 box (at x 60–140, y 210–290) by the feature at `grab` and take that feature + // to `to`: returns where the box's centre lands. + let drop = |h: &mut Harness<'_, EffectcraftApp>, grab: [f32; 2], to: [f32; 2]| { + let s = &mut h.state_mut().session; + 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, 250, 0]})).unwrap(); + s.execute("edit.deselectAll", json!({})).unwrap(); + h.run_steps(2); + let (from, to) = (screen(h, grab), screen(h, to)); + hold_drag(h, from, to, Default::default()); + let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone(); + let p = l.props.prop("transform/position").unwrap().value.as_vec3(); + [p[0], p[1]] + }; + let near = |p: [f64; 2], x: f64, y: f64| (p[0] - x).abs() < 0.01 && (p[1] - y).abs() < 0.01; + for extended in [true, false] { + h.state_mut().session.execute("view.snappingOptions", json!({"edgesExtended": extended})).unwrap(); + // Side by side: the box's left midpoint onto the target's right midpoint (550, 100). + let p = drop(&mut h, [60.5, 250.0], [553.0, 102.0]); + assert!(near(p, 590.0, 100.0), "left to right midpoint, extended {extended}: {p:?}"); + // Stacked: the box's top midpoint onto the target's bottom midpoint (500, 150). + let p = drop(&mut h, [100.0, 210.5], [502.0, 153.0]); + assert!(near(p, 500.0, 190.0), "top to bottom midpoint, 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]