Viewer: Pan Behind snaps the anchor to its own layer (#147)

The Pan Behind drag passed its layer to the snapper's exclude list, which
dropped all of that layer's targets: its corners, edge midpoints and centre
as well as the anchor being dragged. The layer's box doesn't move in comp
space while the anchor pans (Position compensates), so those points are
valid targets.

The engine's per-layer snap targets are now one function, layer_targets,
which can leave out the anchor point. The gesture records its layer's
targets without the anchor as the drag begins and passes them to the
snapper (snap_with) as extra targets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-07 13:37:43 +07:00
parent 21641d5dee
commit 83f17dda37
4 changed files with 121 additions and 41 deletions

View File

@ -6,7 +6,7 @@
use std::sync::Arc;
use effectcraft_color::{ColorSpace, space};
use effectcraft_geom::vec2;
use effectcraft_geom::{Mat3, vec2};
use effectcraft_project::{Layer, LayerId};
use effectcraft_raster::Image;
use effectcraft_render::EvalCtx;
@ -131,23 +131,32 @@ pub fn snap(sources: &[[f64; 2]], targets: &[SnapTarget], tol: f64) -> Option<Sn
/// Snap features of a layer in comp space: corners, edge midpoints, centre and anchor point
/// (2D layers; `None` for layers without bounds).
pub fn layer_features(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> {
let mut out = vec![];
let (m, _) = ctx.layer_to_comp(layer);
if let Some(b) = effectcraft_render::content_bounds(ctx, layer) {
let (cx, cy) = ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0);
for p in [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]], [cx, b[1]], [b[2], cy], [cx, b[3]], [b[0], cy], [cx, cy]] {
let q = m.apply(vec2(p[0], p[1]));
out.push([q.x, q.y]);
}
}
if let Some(tr) = layer.transform() {
let a = ctx.v3(layer, tr, "anchor", [0.0; 3]);
let q = m.apply(vec2(a[0], a[1]));
out.push([q.x, q.y]);
}
let mut out = box_features(ctx, layer, &m);
out.extend(anchor_feature(ctx, layer, &m));
out
}
/// The corners, edge midpoints and centre of a layer's content box (`m`: layer → comp).
fn box_features(ctx: &EvalCtx, layer: &Layer, m: &Mat3) -> Vec<[f64; 2]> {
let Some(b) = effectcraft_render::content_bounds(ctx, layer) else { return vec![] };
let (cx, cy) = ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0);
[[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]], [cx, b[1]], [b[2], cy], [cx, b[3]], [b[0], cy], [cx, cy]]
.into_iter()
.map(|p| {
let q = m.apply(vec2(p[0], p[1]));
[q.x, q.y]
})
.collect()
}
/// A layer's anchor point (`m`: layer → comp).
fn anchor_feature(ctx: &EvalCtx, layer: &Layer, m: &Mat3) -> Option<[f64; 2]> {
let a = ctx.v3(layer, layer.transform()?, "anchor", [0.0; 3]);
let q = m.apply(vec2(a[0], a[1]));
Some([q.x, q.y])
}
/// Mask and shape-path vertices of a layer in comp space.
pub fn layer_vertices(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> {
let (m, _) = ctx.layer_to_comp(layer);
@ -163,7 +172,7 @@ pub fn layer_vertices(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> {
}
}
for (uid, sp) in shape_paths(ctx, layer) {
let pm = m * effectcraft_render::shapes::item_matrix(ctx, layer, uid).unwrap_or(effectcraft_geom::Mat3::IDENTITY);
let pm = m * effectcraft_render::shapes::item_matrix(ctx, layer, uid).unwrap_or(Mat3::IDENTITY);
out.extend(sp.vertices.iter().map(|v| {
let q = pm.apply(vec2(v[0], v[1]));
[q.x, q.y]
@ -214,19 +223,8 @@ pub fn targets(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec<Sna
SnapTarget::hline(h / 2.0, PRI_COMP, SnapSource::Comp),
SnapTarget::point([w / 2.0, h / 2.0], PRI_COMP, SnapSource::Comp),
];
for l in comp.layers.iter().filter(|l| l.is_active_at(ctx.time) && !exclude.contains(&l.id) && !l.is_camera() && !l.is_light() && !l.is_3d()) {
let f = layer_features(ctx, l);
// Edges of unrotated layers are lines too.
if f.len() >= 9 && (f[0][1] - f[1][1]).abs() < 1e-6 && (f[0][0] - f[3][0]).abs() < 1e-6 {
for x in [f[0][0], f[1][0]] {
out.push(SnapTarget { kind: SnapKind::VLine, pos: [x, f[0][1]], priority: PRI_LAYER, source: SnapSource::Layer(l.id) });
}
for y in [f[0][1], f[3][1]] {
out.push(SnapTarget { kind: SnapKind::HLine, pos: [f[0][0], y], priority: PRI_LAYER, source: SnapSource::Layer(l.id) });
}
}
out.extend(f.into_iter().map(|p| SnapTarget::point(p, PRI_LAYER, SnapSource::Layer(l.id))));
out.extend(layer_vertices(ctx, l).into_iter().map(|p| SnapTarget::point(p, PRI_VERTEX, SnapSource::Vertex(l.id))));
for l in comp.layers.iter().filter(|l| l.is_active_at(ctx.time) && !exclude.contains(&l.id)) {
out.extend(layer_targets(ctx, l, true));
}
if opts.guides {
for g in &comp.guides {
@ -253,6 +251,33 @@ pub fn targets(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec<Sna
out
}
/// A 2D layer's snap targets: its box points (edges as lines too while unrotated), its anchor
/// point unless `anchor` is false (Pan Behind drags it, the box stays put) and its mask and shape
/// path vertices. None for cameras, lights and 3D layers.
pub fn layer_targets(ctx: &EvalCtx, l: &Layer, anchor: bool) -> Vec<SnapTarget> {
if l.is_camera() || l.is_light() || l.is_3d() {
return vec![];
}
let (m, _) = ctx.layer_to_comp(l);
let f = box_features(ctx, l, &m);
let mut out = vec![];
if let [nw, ne, _, sw, ..] = f.as_slice()
&& (nw[1] - ne[1]).abs() < 1e-6
&& (nw[0] - sw[0]).abs() < 1e-6
{
for x in [nw[0], ne[0]] {
out.push(SnapTarget { kind: SnapKind::VLine, pos: [x, nw[1]], priority: PRI_LAYER, source: SnapSource::Layer(l.id) });
}
for y in [nw[1], sw[1]] {
out.push(SnapTarget { kind: SnapKind::HLine, pos: [nw[0], y], priority: PRI_LAYER, source: SnapSource::Layer(l.id) });
}
}
let a = if anchor { anchor_feature(ctx, l, &m) } else { None };
out.extend(f.into_iter().chain(a).map(|p| SnapTarget::point(p, PRI_LAYER, SnapSource::Layer(l.id))));
out.extend(layer_vertices(ctx, l).into_iter().map(|p| SnapTarget::point(p, PRI_VERTEX, SnapSource::Vertex(l.id))));
out
}
// ---------------------------------------------------------------- channels and exposure
/// Show Channel and Color Management Settings.

View File

@ -93,6 +93,8 @@ enum Gesture {
start: [f64; 2],
inv: Mat3,
l2p: Mat3,
/// The layer's own snap targets as the drag began (its box and path vertices).
own: Vec<effectcraft_engine::viewer::SnapTarget>,
},
Pan {
start_pan: [f32; 2],
@ -1057,6 +1059,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
start: map.to_comp(press),
inv: l2c.inverse().unwrap_or(Mat3::IDENTITY),
l2p,
own: effectcraft_engine::viewer::layer_targets(&ectx, layer, false),
})
}),
t if t.is_shape() => Some(Gesture::Create { tool: t, start: cpt }),
@ -1225,11 +1228,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/rotation", "value": r, "merge": merge}));
}
Gesture::Anchor { layer, start_anchor, start_pos, start, inv, l2p } => {
// Pan Behind snaps the anchor point (to layer features, guides and the grid).
Gesture::Anchor { layer, start_anchor, start_pos, start, inv, l2p, own } => {
// Pan Behind snaps the anchor point (to its own layer's box, other layers' features,
// guides and the grid).
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 + cpt[0] - start[0], anchor_c.y + cpt[1] - start[1]];
let sc = vt::snap(app, &ctx, &ectx, &map, &[layer], &[want], mods);
let sc = vt::snap_with(app, &ctx, &ectx, &map, &[layer], &[want], mods, &own);
let cpt = [cpt[0] + sc[0], cpt[1] + sc[1]];
let a = inv.apply(gv2(start[0], start[1]));
let b = inv.apply(gv2(cpt[0], cpt[1]));

View File

@ -45,6 +45,20 @@ pub(crate) fn snap(
exclude: &[LayerId],
sources: &[[f64; 2]],
mods: egui::Modifiers,
) -> [f64; 2] {
snap_with(app, ctx, ectx, map, exclude, sources, mods, &[])
}
/// [`snap`] with `extra` layer targets (the Pan Behind layer's own box).
pub(crate) fn snap_with(
app: &EffectcraftApp,
ctx: &egui::Context,
ectx: &EvalCtx,
map: &ViewerMap,
exclude: &[LayerId],
sources: &[[f64; 2]],
mods: egui::Modifiers,
extra: &[vw::SnapTarget],
) -> [f64; 2] {
let layers = snapping_on(app, mods);
let v = &app.ui.viewer;
@ -55,6 +69,7 @@ pub(crate) fn snap(
}
let opts = vw::SnapOptions { guides, grid, grid_spacing: app.session.prefs.grids.grid_spacing };
let mut targets = vw::targets(ectx, exclude, opts);
targets.extend_from_slice(extra);
if !layers {
targets.retain(|t| matches!(t.source, SnapSource::Guide | SnapSource::Grid));
}

View File

@ -440,23 +440,59 @@ fn pan_behind_snaps_the_anchor_point() {
h.run_steps(2);
// Drag the anchor (at the box centre) to 3 px from the comp centre: it snaps there, and the
// position follows so the box doesn't move.
let from = screen(&h, [100.0, 100.0]);
let to = screen(&h, [323.0, 182.0]);
// (The gesture applies the pointer of the previous frame: hold the end point a frame.)
let (from, to) = (screen(&h, [100.0, 100.0]), screen(&h, [323.0, 182.0]));
hold_drag(&mut h, from, to, Default::default());
let (p, a) = position_and_anchor(&h, box_id);
assert!((p[0] - 320.0).abs() < 0.01 && (p[1] - 180.0).abs() < 0.01, "anchor point snapped to the comp centre: {p:?}");
assert!((a[0] - 260.0).abs() < 0.01 && (a[1] - 120.0).abs() < 0.01, "{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]
fn pan_behind_snaps_the_anchor_to_its_own_layer() {
let mut h = harness();
let box_id = h.state().session.active_comp().unwrap().layers[0].id;
h.state_mut().session.execute("prop.set", json!({"layer": box_id.0, "path": "transform/position", "value": [100, 100, 0]})).unwrap();
h.state_mut().session.execute("layer.select", json!({"layers": [box_id.0]})).unwrap();
h.state_mut().ui.tool = Tool::PanBehind;
h.run_steps(2);
// The 80×80 box spans (60, 60)–(140, 140). Its anchor, dragged to 3 px from the top-left
// corner, lands on it.
let (from, to) = (screen(&h, [100.0, 100.0]), screen(&h, [63.0, 62.0]));
hold_drag(&mut h, from, to, Default::default());
let (p, a) = position_and_anchor(&h, box_id);
assert!(a[0].abs() < 0.01 && a[1].abs() < 0.01, "anchor point snapped to the corner: {a:?}");
assert!((p[0] - 60.0).abs() < 0.01 && (p[1] - 60.0).abs() < 0.01, "the box stays put: {p:?}");
// And back to near the centre: it snaps there.
let (from, to) = (screen(&h, [61.0, 61.0]), screen(&h, [103.0, 98.0]));
hold_drag(&mut h, from, to, Default::default());
let (p, a) = position_and_anchor(&h, box_id);
assert!((a[0] - 40.0).abs() < 0.01 && (a[1] - 40.0).abs() < 0.01, "anchor point snapped to the centre: {a:?}");
assert!((p[0] - 100.0).abs() < 0.01 && (p[1] - 100.0).abs() < 0.01, "{p:?}");
}
/// Drag from `from` to `to` with `modifiers` held, holding the end point a frame (the viewer's
/// gestures apply the pointer of the previous frame).
fn hold_drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: egui::Modifiers) {
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
h.input_mut().events.push(Event::PointerMoved(from));
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers });
h.step();
for i in 1..=10 {
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i.min(8) as f32 / 8.0)));
h.step();
}
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers });
h.run_steps(2);
let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone();
let p = l.props.prop("transform/position").unwrap().value.as_vec3();
let a = l.props.prop("transform/anchor").unwrap().value.as_vec3();
assert!((p[0] - 320.0).abs() < 0.01 && (p[1] - 180.0).abs() < 0.01, "anchor point snapped to the comp centre: {p:?}");
assert!((a[0] - 260.0).abs() < 0.01 && (a[1] - 120.0).abs() < 0.01, "{a:?}");
h.input_mut().events.push(Event::ModifiersChanged(Default::default()));
h.step();
}
/// A layer's Position and Anchor Point.
fn position_and_anchor(h: &Harness<'_, EffectcraftApp>, layer: LayerId) -> ([f64; 3], [f64; 3]) {
let l = h.state().session.active_comp().unwrap().layer(layer).unwrap().clone();
(l.props.prop("transform/position").unwrap().value.as_vec3(), l.props.prop("transform/anchor").unwrap().value.as_vec3())
}
#[test]