This commit is contained in:
Brandon Thomas committed 2026-11-03 09:04:55 -05:00
commit 5dc64bb227
3 files changed
+104 -11

No files matched your search

+56
View File
@@ -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();
+12 -11
View File
@@ -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<Snap> {
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<Sn
}
}
}
let line_best = [best_x.map(|b| b.0.0), best_y.map(|b| b.0.0)].into_iter().flatten().fold(f64::INFINITY, f64::min);
let both_lines = best_x.zip(best_y).map_or(f64::INFINITY, |(x, y)| x.0.0.hypot(y.0.0));
if let Some((k, s, t)) = best_pt
&& k.0 <= line_best + 1e-9
&& k.0 <= both_lines + 1e-9
{
return Some(Snap { delta: [t.pos[0] - s[0], t.pos[1] - s[1]], hits: vec![t], at: t.pos });
}
@@ -366,8 +367,8 @@ fn visible_layers<'a>(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));
}
}
+36
View File
@@ -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]