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]