Viewer: Snapping options pick which layer features snap (#253)

Dragging a layer already snapped its feature nearest the pointer to
other layers' edges, corners, centres, anchor points and path points
and to the comp's edges and centre, and Cmd/Ctrl held during the drag
already turned snapping on or off against the checkbox. What was
missing were After Effects' Snapping options beside the Tools bar's
checkbox. The new menu turns Snap Edges Extended off (a layer's edges
then snap only along the layer, not across the comp) and each feature
family (edges, corners, centres, anchor points, mask and shape path
points) on or off, for the dragged layer's snap features and for the
targets alike. Agents set them with view.snappingOptions.

Layers whose eye is off (or that a solo hides) are no longer snap
targets, mask and shape path points of the dragged layer snap too, and
while snapping is on the feature that snaps is boxed during the drag.

Test: layer_drag_snaps_to_other_layers_edges_ctrl_toggles_and_options_apply
(UI), snapping_options_pick_the_targets and
snapping_options_command_sets_and_toggles (engine).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-08 17:14:05 +07:00
1 parent c486130a9b
commit 41be200749
11 files changed
+364 -72

No files matched your search

+25 -1
View File
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, merge_p, str_p};
use crate::viewer::{Channel, CustomRgb, SimProfile, Simulation, Snapshot};
use crate::viewer::{Channel, CustomRgb, SimProfile, Simulation, SnapFeatures, Snapshot};
use crate::{EngineError, Result, Session, VertexRef, cmd, query};
/// Composition ▸ Preview ▸ Fast Previews modes.
@@ -109,6 +109,21 @@ fn snapping(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!(s.state.snapping))
}
/// Tools bar ▸ Snapping options: sets the options passed (`toggle` flips one by key).
fn snapping_options(s: &mut Session, p: &Value) -> Result<Value> {
let f = &mut s.state.snap_features;
if let Some(k) = str_p(p, "toggle") {
let v = f.option_mut(k).ok_or_else(|| bad("view.snappingOptions", format!("unknown option `{k}`")))?;
*v = !*v;
}
for (k, _) in SnapFeatures::OPTIONS {
if let (Some(v), Some(o)) = (b_p(p, k), f.option_mut(k)) {
*o = v;
}
}
Ok(json!(s.state.snap_features))
}
fn channel(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_p(p, "channel").unwrap_or("rgb");
let c = Channel::from_name(name).ok_or_else(|| bad("view.channel", format!("unknown channel `{name}` (rgb|red|green|blue|alpha|rgbStraight)")))?;
@@ -447,6 +462,15 @@ fn display_color_state(s: &mut Session, _: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("view.snapping", "Snapping", ["View"], None, "{value?}", always, snapping),
cmd!(
"view.snappingOptions",
"Snapping Options",
[],
None,
"{edgesExtended?, edges?, corners?, centers?, anchorPoints?, paths?: bool, toggle?: one of those keys} → the options",
always,
snapping_options
),
cmd!("view.displayColorManagement", "Use Display Color Management", ["View"], None, "{value?}", always, display_cm),
cmd!(
"view.simulateOutput",
+3
View File
@@ -202,6 +202,9 @@ pub struct EditorState {
#[serde(default)]
pub selected_vertices: Vec<VertexRef>,
pub snapping: bool,
/// Tools bar ▸ Snapping options (which features snap, Snap Edges Extended).
#[serde(default)]
pub snap_features: viewer::SnapFeatures,
/// Last applied effect id (Effect ▸ last effect).
pub last_effect: Option<String>,
/// Viewer region of interest `[x, y, w, h]` in comp pixels (Composition ▸ Crop Comp to Region
+57 -1
View File
@@ -69,7 +69,7 @@ fn snap_targets_cover_layers_comp_guides_and_grid() {
let cid = s.active_comp_id().unwrap();
let comp = s.project.comp(cid).unwrap().clone();
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, Tick::ZERO);
let opts = crate::viewer::SnapOptions { guides: true, grid: true, grid_spacing: 100.0 };
let opts = crate::viewer::SnapOptions { layers: true, features: Default::default(), guides: true, grid: true, grid_spacing: 100.0 };
let t = crate::viewer::targets(&ctx, &[LayerId(b)], opts);
// Layer a's corner (270, 155) is a target; layer b (dragged) is not.
assert!(t.iter().any(|x| x.source == SnapSource::Layer(LayerId(a)) && x.kind == SnapKind::Point && x.pos == [270.0, 155.0]));
@@ -83,6 +83,62 @@ fn snap_targets_cover_layers_comp_guides_and_grid() {
assert_eq!(hit.delta, [-2.0, -2.0]);
}
/// Tools bar ▸ Snapping options (#253): Snap Edges Extended off keeps a layer's edges to its
/// extent, each feature family can be turned off, hidden layers are no targets, and with
/// Snapping off only guides and the grid are left.
#[test]
fn snapping_options_pick_the_targets() {
use crate::viewer::{SnapFeatures, SnapOptions, layer_features, targets};
let mut s = comp();
// A 100×50 solid at the comp centre: x 270–370, y 155–205.
let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 100, "height": 50})).unwrap()["layer"].as_u64().unwrap();
let hidden = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": hidden, "path": "transform/position", "value": [50, 50, 0]})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [hidden], "switch": "video", "value": false})).unwrap();
s.execute("view.addGuide", json!({"orientation": "vertical", "position": 77})).unwrap();
let cid = s.active_comp_id().unwrap();
let comp = s.project.comp(cid).unwrap().clone();
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, Tick::ZERO);
let opts = |features: SnapFeatures| SnapOptions { layers: true, features, guides: false, grid: false, grid_spacing: 0.0 };
let all = targets(&ctx, &[], opts(SnapFeatures::default()));
assert!(!all.iter().any(|t| t.source == SnapSource::Layer(LayerId(hidden))), "hidden layers are no targets");
// Extended: a point far below layer a snaps to its left edge's line.
assert_eq!(snap(&[[268.0, 340.0]], &all, 4.0).unwrap().at, [270.0, 340.0]);
// Not extended: only along the edge.
let own = targets(&ctx, &[], opts(SnapFeatures { edges_extended: false, ..Default::default() }));
assert!(snap(&[[268.0, 340.0]], &own, 4.0).is_none_or(|h| h.hits.iter().all(|t| t.source != SnapSource::Layer(LayerId(a)))));
assert_eq!(snap(&[[268.0, 190.0]], &own, 4.0).unwrap().at, [270.0, 190.0]);
// Centres (and anchor points, a's sits in its centre) off: nothing at the comp centre, where
// a's centre is too; edges off: no edge lines or midpoints, only the comp's centre lines.
let no_centres = targets(&ctx, &[], opts(SnapFeatures { centers: false, anchor_points: false, ..Default::default() }));
assert!(!no_centres.iter().any(|t| t.pos == [320.0, 180.0]));
assert!(no_centres.iter().any(|t| t.pos == [270.0, 155.0]), "corners stay");
let no_edges = targets(&ctx, &[], opts(SnapFeatures { edges: false, ..Default::default() }));
assert!(!no_edges.iter().any(|t| t.pos == [320.0, 155.0]));
assert!(no_edges.iter().filter(|t| t.kind != SnapKind::Point).all(|t| t.source == SnapSource::Comp && (t.pos[0] == 320.0 || t.pos[1] == 180.0)));
// The dragged layer's features follow the options too: corners only.
let l = comp.layer(LayerId(a)).unwrap();
let corners = SnapFeatures { edges: false, centers: false, anchor_points: false, paths: false, ..Default::default() };
assert_eq!(layer_features(&ctx, l, corners), vec![[270.0, 155.0], [370.0, 155.0], [370.0, 205.0], [270.0, 205.0]]);
// Snapping off: guides only.
let guides = targets(&ctx, &[], SnapOptions { layers: false, guides: true, ..opts(SnapFeatures::default()) });
assert!(!guides.is_empty() && guides.iter().all(|t| t.source == SnapSource::Guide));
}
#[test]
fn snapping_options_command_sets_and_toggles() {
let mut s = comp();
assert!(s.state.snap_features.edges_extended && s.state.snap_features.anchor_points);
let r = s.execute("view.snappingOptions", json!({"edgesExtended": false, "anchorPoints": false})).unwrap();
assert_eq!(r["edgesExtended"], false);
assert!(!s.state.snap_features.edges_extended && !s.state.snap_features.anchor_points && s.state.snap_features.corners);
s.execute("view.snappingOptions", json!({"toggle": "paths"})).unwrap();
assert!(!s.state.snap_features.paths);
s.execute("view.snappingOptions", json!({"toggle": "paths"})).unwrap();
assert!(s.state.snap_features.paths);
assert!(s.execute("view.snappingOptions", json!({"toggle": "nope"})).is_err());
}
// ---------------------------------------------------------------- channels, exposure, snapshot
#[test]
+161 -54
View File
@@ -45,17 +45,75 @@ pub struct SnapTarget {
/// guides, guides before the grid).
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.
#[serde(default)]
pub span: Option<[f64; 2]>,
}
impl SnapTarget {
pub fn point(pos: [f64; 2], priority: u8, source: SnapSource) -> SnapTarget {
SnapTarget { kind: SnapKind::Point, pos, priority, source }
SnapTarget { kind: SnapKind::Point, pos, priority, source, span: None }
}
pub fn vline(x: f64, priority: u8, source: SnapSource) -> SnapTarget {
SnapTarget { kind: SnapKind::VLine, pos: [x, 0.0], priority, source }
SnapTarget { kind: SnapKind::VLine, pos: [x, 0.0], priority, source, span: None }
}
pub fn hline(y: f64, priority: u8, source: SnapSource) -> SnapTarget {
SnapTarget { kind: SnapKind::HLine, pos: [0.0, y], priority, source }
SnapTarget { kind: SnapKind::HLine, pos: [0.0, y], priority, source, span: None }
}
/// The line limited to `span` (`None`: across the comp).
pub fn within(self, span: Option<[f64; 2]>) -> SnapTarget {
SnapTarget { span: span.map(|[a, b]| [a.min(b), a.max(b)]), ..self }
}
}
/// Tools bar ▸ Snapping options: which layer features snap (the dragged layer's and the
/// targets), and Snap Edges Extended (layer edges snap along their whole line, beyond the
/// layer's bounds). Everything is on by default.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct SnapFeatures {
pub edges_extended: bool,
/// Edges and edge midpoints of layers, and the comp's edges.
pub edges: bool,
pub corners: bool,
/// Layer centres and the comp's centre.
pub centers: bool,
pub anchor_points: bool,
/// Mask and shape path points.
pub paths: bool,
}
impl Default for SnapFeatures {
fn default() -> Self {
SnapFeatures { edges_extended: true, edges: true, corners: true, centers: true, anchor_points: true, paths: true }
}
}
impl SnapFeatures {
/// The options in the Tools bar's Snapping menu order: (parameter key, label).
pub const OPTIONS: [(&'static str, &'static str); 6] = [
("edgesExtended", "Snap Edges Extended"),
("edges", "Edges"),
("corners", "Corners"),
("centers", "Centers"),
("anchorPoints", "Anchor Points"),
("paths", "Mask and Shape Path Points"),
];
/// The option called `key` (an [`OPTIONS`](Self::OPTIONS) key).
pub fn option_mut(&mut self, key: &str) -> Option<&mut bool> {
Some(match key {
"edgesExtended" => &mut self.edges_extended,
"edges" => &mut self.edges,
"corners" => &mut self.corners,
"centers" => &mut self.centers,
"anchorPoints" => &mut self.anchor_points,
"paths" => &mut self.paths,
_ => return None,
})
}
pub fn option(mut self, key: &str) -> bool {
self.option_mut(key).is_some_and(|v| *v)
}
}
@@ -89,12 +147,13 @@ pub fn snap(sources: &[[f64; 2]], targets: &[SnapTarget], tol: f64) -> Option<Sn
let mut best_y: Option<((f64, u8), [f64; 2], SnapTarget)> = None;
for s in sources {
for t in targets {
let (d, slot) = match t.kind {
SnapKind::Point => (((t.pos[0] - s[0]).powi(2) + (t.pos[1] - s[1]).powi(2)).sqrt(), &mut best_pt),
SnapKind::VLine => ((t.pos[0] - s[0]).abs(), &mut best_x),
SnapKind::HLine => ((t.pos[1] - s[1]).abs(), &mut best_y),
// (`along`: where the source is along a line, for its span.)
let (d, slot, along) = match t.kind {
SnapKind::Point => (((t.pos[0] - s[0]).powi(2) + (t.pos[1] - s[1]).powi(2)).sqrt(), &mut best_pt, 0.0),
SnapKind::VLine => ((t.pos[0] - s[0]).abs(), &mut best_x, s[1]),
SnapKind::HLine => ((t.pos[1] - s[1]).abs(), &mut best_y, s[0]),
};
if d > tol {
if d > tol || t.span.is_some_and(|[a, b]| along < a || along > b) {
continue;
}
let key = (d, t.priority);
@@ -128,26 +187,56 @@ pub fn snap(sources: &[[f64; 2]], targets: &[SnapTarget], tol: f64) -> Option<Sn
Some(Snap { delta, hits, at })
}
/// 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]> {
/// Snap features of a layer in comp space that `f` turns on: corners, edge midpoints, centre,
/// anchor point and mask / shape path vertices (no box for layers without bounds).
pub fn layer_features(ctx: &EvalCtx, layer: &Layer, f: SnapFeatures) -> Vec<[f64; 2]> {
let (m, _) = ctx.layer_to_comp(layer);
let mut out = box_features(ctx, layer, &m);
out.extend(anchor_feature(ctx, layer, &m));
let mut out = layer_box(ctx, layer, &m).map(|b| b.features(f)).unwrap_or_default();
out.extend(anchor_feature(ctx, layer, &m).filter(|_| f.anchor_points));
if f.paths {
out.extend(layer_vertices(ctx, layer));
}
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![] };
/// A layer's content box in comp space: corners (top left, top right, bottom right, bottom
/// left), edge midpoints (top, right, bottom, left) and centre.
struct BoxPoints {
corners: [[f64; 2]; 4],
mids: [[f64; 2]; 4],
center: [f64; 2],
}
impl BoxPoints {
/// The points `f` turns on.
fn features(&self, f: SnapFeatures) -> Vec<[f64; 2]> {
let mut out = vec![];
if f.corners {
out.extend(self.corners);
}
if f.edges {
out.extend(self.mids);
}
if f.centers {
out.push(self.center);
}
out
}
}
/// A layer's content box (`m`: layer → comp).
fn layer_box(ctx: &EvalCtx, layer: &Layer, m: &Mat3) -> Option<BoxPoints> {
let b = effectcraft_render::content_bounds(ctx, layer)?;
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()
let c = |x: f64, y: f64| {
let q = m.apply(vec2(x, y));
[q.x, q.y]
};
Some(BoxPoints {
corners: [c(b[0], b[1]), c(b[2], b[1]), c(b[2], b[3]), c(b[0], b[3])],
mids: [c(cx, b[1]), c(b[2], cy), c(cx, b[3]), c(b[0], cy)],
center: c(cx, cy),
})
}
/// A layer's anchor point (`m`: layer → comp).
@@ -203,28 +292,42 @@ pub fn shape_paths(ctx: &EvalCtx, layer: &Layer) -> Vec<(u64, effectcraft_keyfra
/// Which target families are on.
#[derive(Clone, Copy, Debug, Default)]
pub struct SnapOptions {
/// Layer and comp targets (the Snapping checkbox, inverted while Cmd/Ctrl is held).
pub layers: bool,
pub features: SnapFeatures,
pub guides: bool,
pub grid: bool,
pub grid_spacing: f64,
}
/// Snap targets of a comp at the context time: other layers' features and vertices, comp edges
/// and centre, and (when on) guides and grid lines. Layers in `exclude` (the dragged ones) are
/// skipped.
/// Snap targets of a comp at the context time: other visible layers' features and vertices and
/// the comp's edges and centre (with `layers`, as `features` allows), and guides and grid lines
/// (when on). Layers in `exclude` (the dragged ones) are skipped.
pub fn targets(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec<SnapTarget> {
let comp = ctx.comp;
let (w, h) = (comp.width as f64, comp.height as f64);
let mut out = vec![
SnapTarget::vline(0.0, PRI_COMP, SnapSource::Comp),
SnapTarget::vline(w, PRI_COMP, SnapSource::Comp),
SnapTarget::vline(w / 2.0, PRI_COMP, SnapSource::Comp),
SnapTarget::hline(0.0, PRI_COMP, SnapSource::Comp),
SnapTarget::hline(h, PRI_COMP, SnapSource::Comp),
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)) {
out.extend(layer_targets(ctx, l, true));
let f = opts.features;
let mut out = vec![];
if opts.layers {
// The comp's edge and centre lines, along the comp without Snap Edges Extended.
let (xs, ys) = if f.edges_extended { (None, None) } else { (Some([0.0, w]), Some([0.0, h])) };
let mut lines = |x: &[f64], y: &[f64]| {
out.extend(x.iter().map(|x| SnapTarget::vline(*x, PRI_COMP, SnapSource::Comp).within(ys)));
out.extend(y.iter().map(|y| SnapTarget::hline(*y, PRI_COMP, SnapSource::Comp).within(xs)));
};
if f.edges {
lines(&[0.0, w], &[0.0, h]);
}
if f.centers {
lines(&[w / 2.0], &[h / 2.0]);
out.push(SnapTarget::point([w / 2.0, h / 2.0], PRI_COMP, SnapSource::Comp));
}
// Visible layers: eye on, and soloed while any layer is.
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(ctx.time));
let visible = |l: &&Layer| l.is_active_at(ctx.time) && l.switches.video && (!any_solo || l.switches.solo) && !exclude.contains(&l.id);
for l in comp.layers.iter().filter(visible) {
out.extend(layer_targets(ctx, l, true, f));
}
}
if opts.guides {
for g in &comp.guides {
@@ -251,30 +354,34 @@ 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> {
/// A 2D layer's snap targets as `f` allows: 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 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, f: SnapFeatures) -> 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 src = SnapSource::Layer(l.id);
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) });
if let Some(b) = layer_box(ctx, l, &m) {
let [nw, ne, _, sw] = b.corners;
if f.edges && (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]] {
out.push(SnapTarget::vline(x, PRI_LAYER, src).within(ys));
}
for y in [nw[1], sw[1]] {
out.push(SnapTarget::hline(y, PRI_LAYER, src).within(xs));
}
}
out.extend(b.features(f).into_iter().map(|p| SnapTarget::point(p, PRI_LAYER, src)));
}
out.extend(anchor_feature(ctx, l, &m).filter(|_| anchor && f.anchor_points).map(|p| SnapTarget::point(p, PRI_LAYER, src)));
if f.paths {
out.extend(layer_vertices(ctx, l).into_iter().map(|p| SnapTarget::point(p, PRI_VERTEX, SnapSource::Vertex(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
}
+34 -1
View File
@@ -12,6 +12,7 @@ use crate::{Dialog, EffectcraftApp};
use effectcraft_color::BlendMode;
use effectcraft_engine::commands::shape_tool::{PaintKind, ToolPaint};
use effectcraft_engine::project::{LayerId, LayerSource};
use effectcraft_engine::viewer as vw;
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
@@ -143,7 +144,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.ui.snapping = app.session.state.snapping;
}
app.auto.add("header.snapping", snap, "Snapping");
p.text(pos2(snap.max.x + 4.0, cy), Align2::LEFT_CENTER, "Snapping", Tokens::ui(12.0), t.text_dim);
let label = p.text(pos2(snap.max.x + 4.0, cy), Align2::LEFT_CENTER, "Snapping", Tokens::ui(12.0), t.text_dim);
snapping_options(app, ui, Rect::from_min_size(pos2(label.max.x + 2.0, cy - 9.0), vec2(18.0, 18.0)));
// Right side: community buttons, workspaces.
let mut rx = rect.max.x - 10.0;
@@ -222,6 +224,37 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
/// The Snapping options menu beside the checkbox: Snap Edges Extended and which layer features
/// snap (`view.snappingOptions`).
fn snapping_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect) {
let t = app.tokens;
let pop = egui::Id::new("snap-options");
if widgets::icon_button(ui, r, Icon::ChevronDown, false, &t, egui::Id::new("snap-options-button")).on_hover_text("Snapping options").clicked() {
widgets::open_popup(ui, pop);
}
app.auto.add("header.snappingOptions", r, "Snapping options");
if !widgets::popup_is_open(ui, pop) {
return;
}
// Snap Edges Extended, then the features ("" keys the separator).
let f = app.session.state.snap_features;
let mut keys = vec![];
let mut items = vec![];
for (i, (k, label)) in vw::SnapFeatures::OPTIONS.into_iter().enumerate() {
if i == 1 {
keys.push("");
items.push("-".to_string());
}
keys.push(k);
items.push(widgets::check_label(f.option(k), label));
}
if let Some(k) = widgets::popup_menu(ui, pop, r.left_bottom(), &items, None).and_then(|i| keys.get(i))
&& let Err(e) = app.session.execute("view.snappingOptions", json!({"toggle": k}))
{
app.ui.status = e.to_string();
}
}
/// Set the shape tools' options (`shape.toolOptions`).
fn tool_options(app: &mut EffectcraftApp, v: serde_json::Value) {
if let Err(e) = app.session.execute("shape.toolOptions", v) {
+1 -4
View File
@@ -20,6 +20,7 @@ use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde_json::{Value, json};
use crate::theme::Tokens;
use crate::widgets::check_label as mark;
use crate::{EffectcraftApp, widgets};
const TOP_H: f32 = 74.0;
@@ -61,10 +62,6 @@ fn status_color(t: &Tokens, s: &RenderStatus) -> Color32 {
}
}
fn mark(on: bool, l: &str) -> String {
if on { format!("✓ {l}") } else { format!(" {l}") }
}
/// Render Settings menu: (label, params). `{"form": …}` entries open a form.
fn settings_menu(it: &RenderQueueItem, templates: &RenderTemplates) -> Vec<(String, Value)> {
let s = &it.settings;
+6 -2
View File
@@ -1069,7 +1069,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),
own: effectcraft_engine::viewer::layer_targets(&ectx, layer, false, app.session.state.snap_features),
anchor_only: mods.alt,
})
}),
@@ -1175,7 +1175,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
})
.collect();
// Snap the grabbed layer's feature nearest the pointer (AE's snap handle).
let snap_src = comp.layer(l).map(|layer| effectcraft_engine::viewer::layer_features(&ectx, layer)).and_then(|f| {
let features = app.session.state.snap_features;
let snap_src = comp.layer(l).map(|layer| effectcraft_engine::viewer::layer_features(&ectx, layer, features)).and_then(|f| {
let c = map.to_comp(press);
f.into_iter().min_by(|a, b| {
let da = (a[0] - c[0]).powi(2) + (a[1] - c[1]).powi(2);
@@ -1217,6 +1218,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let ids: Vec<LayerId> = layers.iter().map(|x| x.0).collect();
let c = vt::snap(app, &ctx, &ectx, &map, &ids, &[[src[0] + d[0], src[1] + d[1]]], mods);
d = [d[0] + c[0], d[1] + c[1]];
if vt::snapping_on(app, mods) {
vt::draw_handle(&painter, map.to_screen([src[0] + d[0], src[1] + d[1]]));
}
}
for (lid, p0, ax, ay) in layers {
let v = json!([p0[0] + ax[0] * d[0] + ay[0] * d[1], p0[1] + ax[1] * d[0] + ay[1] * d[1], p0[2] + ax[2] * d[0] + ay[2] * d[1]]);
+16 -8
View File
@@ -34,8 +34,8 @@ pub(crate) fn snapping_on(app: &EffectcraftApp, mods: egui::Modifiers) -> bool {
}
/// Snap dragged feature points (`sources`, comp pixels) to the comp's targets: other layers'
/// edges, centres, anchor points and mask/shape vertices and the comp edges and centre (when
/// snapping is on), guides (View ▸ Snap to Guides) and the grid (View ▸ Snap to Grid). Returns
/// edges, corners, centres, anchor points and mask/shape vertices and the comp edges and centre
/// (when snapping is on, as the Snapping options allow), guides (View ▸ Snap to Guides) and the grid (View ▸ Snap to Grid). Returns
/// the comp-space correction (zero when nothing is near) and records the feedback for this frame.
pub(crate) fn snap(
app: &EffectcraftApp,
@@ -67,11 +67,10 @@ pub(crate) fn snap_with(
if sources.is_empty() || !(layers || guides || grid) {
return [0.0; 2];
}
let opts = vw::SnapOptions { guides, grid, grid_spacing: app.session.prefs.grids.grid_spacing };
let opts = vw::SnapOptions { layers, features: app.session.state.snap_features, 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));
if layers {
targets.extend_from_slice(extra);
}
match vw::snap(sources, &targets, SNAP_PX / map.zoom.max(1e-3) as f64) {
Some(s) => {
@@ -83,6 +82,12 @@ pub(crate) fn snap_with(
}
}
/// The snap handle: the dragged layer's feature that snaps (the one nearest where it was
/// grabbed), boxed while snapping is on.
pub(crate) fn draw_handle(painter: &egui::Painter, at: Pos2) {
painter.rect_stroke(Rect::from_center_size(at, vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, SNAP_COLOR), StrokeKind::Middle);
}
/// Draw (and clear) this frame's snap feedback: the target layer's box highlighted, the guide or
/// grid line, and a cross where the feature landed.
pub(crate) fn draw_snap(ctx: &egui::Context, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx) {
@@ -110,13 +115,16 @@ pub(crate) fn draw_snap(ctx: &egui::Context, painter: &egui::Painter, map: &View
let c = map.to_screen(h.pos);
painter.rect_stroke(Rect::from_center_size(c, vec2(9.0, 9.0)), 0.0, stroke, StrokeKind::Middle);
}
// A line across the viewer, or along its span (to where the feature landed).
SnapKind::VLine => {
let x = map.to_screen(h.pos).x;
painter.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.0, SNAP_COLOR));
let (a, b) = h.span.map_or((area.min.y, area.max.y), |[a, b]| (map.to_screen([0.0, a.min(s.at[1])]).y, map.to_screen([0.0, b.max(s.at[1])]).y));
painter.line_segment([pos2(x, a), pos2(x, b)], Stroke::new(1.0, SNAP_COLOR));
}
SnapKind::HLine => {
let y = map.to_screen(h.pos).y;
painter.line_segment([pos2(area.min.x, y), pos2(area.max.x, y)], Stroke::new(1.0, SNAP_COLOR));
let (a, b) = h.span.map_or((area.min.x, area.max.x), |[a, b]| (map.to_screen([a.min(s.at[0]), 0.0]).x, map.to_screen([b.max(s.at[0]), 0.0]).x));
painter.line_segment([pos2(a, y), pos2(b, y)], Stroke::new(1.0, SNAP_COLOR));
}
}
}
+5
View File
@@ -275,6 +275,11 @@ pub fn pressed_outside(ctx: &egui::Context, area: &Response) -> bool {
ctx.input(|i| i.pointer.any_pressed() && !i.pointer.interact_pos().is_some_and(|p| area.rect.contains(p)))
}
/// A [`popup_menu`] option with a check mark when `on` (the labels line up either way).
pub fn check_label(on: bool, label: &str) -> String {
if on { format!("✓ {label}") } else { format!(" {label}") }
}
/// Show a popup menu anchored at `pos` with string options; returns the chosen index.
pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String], current: Option<usize>) -> Option<usize> {
let mut chosen = None;
+53
View File
@@ -249,6 +249,59 @@ fn layer_drag_snaps_to_comp_centre_and_ctrl_disables() {
assert!((p[0] - 320.0).abs() > 1.0, "{p:?}");
}
/// #253: a dragged layer's feature nearest the pointer snaps to other layers' edges; Cmd/Ctrl
/// held during the drag turns snapping on while the Snapping checkbox is off (and off while it
/// is on), and the Tools bar's Snapping options turn Snap Edges Extended off, so an edge only
/// snaps along the layer.
#[test]
fn layer_drag_snaps_to_other_layers_edges_ctrl_toggles_and_options_apply() {
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.
let target = h.state_mut().session.execute("layer.newSolid", json!({"name": "Target", "color": "#20e040", "width": 100, "height": 100})).unwrap()["layer"]
.as_u64()
.unwrap();
let place = |h: &mut Harness<'_, EffectcraftApp>| {
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 pos = |h: &Harness<'_, EffectcraftApp>| {
let l = h.state().session.active_comp().unwrap().layer(box_id).unwrap().clone();
l.props.prop("transform/position").unwrap().value.as_vec3()
};
// Grab the box by its top right corner (140, 210) and drop it 3 px left of the target's
// left edge, well below the target: the corner lands on the edge's line (x 450).
let ctrl = egui::Modifiers { ctrl: true, command: true, ..Default::default() };
let drop = |h: &mut Harness<'_, EffectcraftApp>, mods: egui::Modifiers| {
place(h);
let (from, to) = (screen(h, [138.0, 212.0]), screen(h, [445.0, 232.0]));
hold_drag(h, from, to, mods);
pos(h)
};
let snapped = |p: [f64; 3]| (p[0] - 410.0).abs() < 0.01;
let p = drop(&mut h, Default::default());
assert!(snapped(p), "snapping on: {p:?}");
let p = drop(&mut h, ctrl);
assert!(!snapped(p), "Ctrl turns it off: {p:?}");
h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap();
let p = drop(&mut h, Default::default());
assert!(!snapped(p), "snapping off: {p:?}");
let p = drop(&mut h, ctrl);
assert!(snapped(p), "Ctrl turns it on: {p:?}");
h.state_mut().session.execute("view.snapping", json!({"value": true})).unwrap();
// Snapping options ▸ Snap Edges Extended off: below the target its edge no longer snaps.
let menu = rect(&h, "header.snappingOptions").center();
click(&mut h, menu);
let item = h.query_by_label("✓ Snap Edges Extended").expect("the Snapping options menu").rect().center();
click(&mut h, item);
assert!(!h.state().session.state.snap_features.edges_extended);
let p = drop(&mut h, Default::default());
assert!(!snapped(p), "edges not 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]
+3 -1
View File
@@ -109,7 +109,9 @@ revolutions are `timeline.prop.<uid>.revolutions`, `effectControls.prop.<uid>.re
`viewer.regionOfInterest`; the Graph Editor adds `timeline.graph.transformBox[.<i>]`,
`timeline.graph.snap` and `timeline.graph.reference`. Use `ui.elements` to see what is on screen.
Viewer state that agents drive headless too: `view.snapping`, `view.channel {channel, colorized?}`,
Viewer state that agents drive headless too: `view.snapping`, `view.snappingOptions
{edgesExtended?, edges?, corners?, centers?, anchorPoints?, paths?, toggle?}` (the Tools bar's
Snapping options, `header.snappingOptions`), `view.channel {channel, colorized?}`,
`view.exposure {stops | delta}`, `view.resetExposure`, `view.takeSnapshot`, `view.showSnapshot`,
`view.fastPreviewMode {mode}`, `view.setRegionOfInterest {rect}`, `view.addGuide`,
`view.moveGuide`, `view.removeGuide`; editing: `shape.newPath` (Pen on shape layers),