From 83f17dda37977a7fd2f81f28024f233499e22e33 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 7 Oct 2026 13:37:43 +0700 Subject: [PATCH] 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 --- crates/engine/src/viewer.rs | 81 +++++++++++++++-------- crates/ui-egui/src/panels/viewer.rs | 10 ++- crates/ui-egui/src/panels/viewer_tools.rs | 15 +++++ crates/ui-egui/tests/ui_viewer.rs | 56 +++++++++++++--- 4 files changed, 121 insertions(+), 41 deletions(-) diff --git a/crates/engine/src/viewer.rs b/crates/engine/src/viewer.rs index 45551ce6..680e9ab9 100644 --- a/crates/engine/src/viewer.rs +++ b/crates/engine/src/viewer.rs @@ -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 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= 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 Vec { + 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. diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 56d180ac..c366dae2 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -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, }, 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])); diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 9448c6cb..0d37ad13 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -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)); } diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 124292ef..dfa91955 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -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]