Merge pull request #265 from storytold/fix/253-snapping

Snapping: layers, scale handles and the work area snap as in After Effects (#253, #252)
This commit is contained in:
BFlat 2026-10-08 17:24:10 +07:00 committed by GitHub
commit 6fa047c2ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 572 additions and 93 deletions

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",

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

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]

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
}

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) {

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;

View File

@ -330,6 +330,18 @@ fn snap_candidates(comp: &Comp, rows: &[Row]) -> Vec<f64> {
v
}
/// Shift-dragging the work area or one of its ends (#252): the correction (seconds) that puts
/// the nearest of the `moving` times on the current time, a key, a layer in / out point or a
/// marker within 8 px (the work area's own ends are no targets).
fn work_area_snap(app: &EffectcraftApp, comp: &Comp, moving: &[f64], pps: f64) -> f64 {
let (a, b) = (comp.work_area.0.seconds(), comp.work_area.1.seconds());
let mut cands = snap_candidates(comp, &build_rows(app, comp));
cands.retain(|c| (c - a).abs() > 1e-9 && (c - b).abs() > 1e-9);
cands.push(app.session.time().seconds());
let tol = 8.0 / pps.max(1e-6);
moving.iter().map(|t| snap_time(&cands, *t, tol) - t).filter(|d| *d != 0.0).min_by(|x, y| x.abs().total_cmp(&y.abs())).unwrap_or(0.0)
}
/// Timeline horizontal mapping.
#[derive(Clone, Copy, Debug)]
pub(crate) struct TMap {
@ -1079,10 +1091,31 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
p.rect_filled(ruler, 0.0, t.tl_ruler_bg);
app.auto.add("timeline.ruler", ruler, &format!("{},{}", tm.start, tm.pps));
// Work area bar: below the ruler, beside the column headers (After Effects' layout), with
// blue begin / end handles; the cache bar runs under it.
// blue begin / end handles; the cache bar runs under it. Dragging the bar moves the work
// area, the handles move its ends; Shift snaps (#252).
let wa_row = Rect::from_min_max(pos2(graph_x0, top + header_h), pos2(rect.max.x, top + header_h + colhdr_h));
let wa = Rect::from_min_max(pos2(tm.x(comp.work_area.0.seconds()), wa_row.min.y + 3.0), pos2(tm.x(comp.work_area.1.seconds()), wa_row.max.y - 6.0));
let (wa0, wa1) = (comp.work_area.0.seconds(), comp.work_area.1.seconds());
let wa = Rect::from_min_max(pos2(tm.x(wa0), wa_row.min.y + 3.0), pos2(tm.x(wa1), wa_row.max.y - 6.0));
p.with_clip_rect(wa_row).rect_filled(wa, 0.0, WORK_AREA_BAR);
let shift = ui.input(|i| i.modifiers.shift);
let bar = wa.intersect(wa_row);
let bresp = ui.interact(bar, egui::Id::new("wa-bar"), Sense::drag());
app.auto.add("timeline.workArea.bar", bar, "Work area");
let bar_start = egui::Id::new("wa-bar-start");
if bresp.drag_started() {
ctx.data_mut(|d| d.insert_temp(bar_start, wa0));
}
if bresp.dragged()
&& let (Some(a0), Some(d)) = (ctx.data(|d| d.get_temp::<f64>(bar_start)), bresp.total_drag_delta())
{
let len = wa1 - wa0;
let mut a = a0 + d.x as f64 / pps.max(1e-6);
if shift {
a += work_area_snap(app, &comp, &[a, a + len], pps);
}
let a = fr.snap_nearest(Tick::from_seconds_f64(a.clamp(0.0, (comp.duration.seconds() - len).max(0.0)))).seconds();
let _ = app.session.execute("comp.workArea", json!({"start": a, "end": a + len, "merge": "wa-drag"}));
}
for (hx, set) in [(wa.min.x, "begin"), (wa.max.x, "end")] {
let hr = Rect::from_center_size(pos2(hx, wa.center().y), vec2(8.0, 14.0));
let handle = if set == "begin" {
@ -1096,7 +1129,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if resp.dragged()
&& let Some(pt) = resp.interact_pointer_pos()
{
let secs = fr.snap_nearest(Tick::from_seconds_f64(tm.t(pt.x).max(0.0))).seconds();
let mut secs = tm.t(pt.x).max(0.0);
if shift {
secs += work_area_snap(app, &comp, &[secs], pps);
}
let secs = fr.snap_nearest(Tick::from_seconds_f64(secs)).seconds();
let key = if set == "begin" { "start" } else { "end" };
let _ = app.session.execute("comp.workArea", json!({key: secs, "merge": "wa-drag"}));
}

View File

@ -69,15 +69,19 @@ enum Gesture {
tool: Tool,
},
/// A bounding-box handle: scales about the anchor point so the grabbed point follows the
/// pointer (corners both axes, edges one), relative to the layer as the drag began.
/// pointer (corners both axes, edges one), relative to the layer as the drag began; the
/// handle snaps.
Scale {
layer: LayerId,
start_scale: [f64; 3],
/// The grabbed point relative to the anchor, in layer pixels.
start_local: [f64; 2],
/// The handle's point relative to the anchor, in layer pixels.
handle: [f64; 2],
anchor: [f64; 2],
axes: [bool; 2],
/// Comp → layer pixels as the drag began.
/// Layer pixels → comp as the drag began, and its inverse.
l2c: Mat3,
inv: Mat3,
},
Rotate {
@ -262,20 +266,59 @@ fn handle_axes(handle: usize) -> [bool; 2] {
}
}
/// The scale that puts the point grabbed at `start` (relative to the anchor, layer pixels)
/// under the pointer at `cur`, for a layer whose scale was `start_scale` when the drag began.
/// `proportional` (Shift on a corner) keeps the aspect ratio, following the pointer along the
/// handle's diagonal. Dragging past the anchor flips the layer, as in After Effects; an axis
/// whose grabbed point sits on the anchor keeps its scale.
fn drag_scale(start_scale: [f64; 3], start: [f64; 2], cur: [f64; 2], axes: [bool; 2], proportional: bool) -> [f64; 3] {
let ratio = |k: usize| if axes[k] && start[k].abs() > 1e-3 { cur[k] / start[k] } else { 1.0 };
let (mut fx, mut fy) = (ratio(0), ratio(1));
if proportional && axes == [true, true] {
/// A bounding-box handle's point in layer pixels (`b`: the content box; handles as in
/// [`handle_axes`], the corners clockwise from the top left, then the top, right, bottom and
/// left edges' midpoints).
fn handle_point(b: [f64; 4], handle: usize) -> [f64; 2] {
let (cx, cy) = ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0);
match handle {
0 => [b[0], b[1]],
1 => [b[2], b[1]],
2 => [b[2], b[3]],
3 => [b[0], b[3]],
4 => [cx, b[1]],
5 => [b[2], cy],
6 => [cx, b[3]],
_ => [b[0], cy],
}
}
/// The scale ratio (x, y) that puts the point grabbed at `start` (relative to the anchor, layer
/// pixels) under the pointer at `cur`. `uniform` (Shift on a corner) keeps the aspect ratio,
/// following the pointer along the handle's diagonal. Dragging past the anchor flips the layer,
/// as in After Effects; an axis whose grabbed point sits on the anchor keeps its scale.
fn drag_ratio(start: [f64; 2], cur: [f64; 2], axes: [bool; 2], uniform: bool) -> [f64; 2] {
if uniform {
let d2 = start[0] * start[0] + start[1] * start[1];
let f = if d2 > 1e-6 { (cur[0] * start[0] + cur[1] * start[1]) / d2 } else { 1.0 };
(fx, fy) = (f, f);
return [f, f];
}
[start_scale[0] * fx, start_scale[1] * fy, start_scale[2]]
[0, 1].map(|k| if axes[k] && start[k].abs() > 1e-3 { cur[k] / start[k] } else { 1.0 })
}
/// Snap a handle drag (#252): the handle (`h`, layer pixels from the anchor point `a` as the
/// drag began; `l2c` that layer space → comp) at scale ratio `f` goes through `snap` (a comp
/// point → its correction), and the ratio that puts the handle where it snapped comes back.
/// A `uniform` drag slides the handle along its line from the anchor point to the nearer snapped
/// line; otherwise each scaled axis takes the snapped point.
fn snap_handle(f: [f64; 2], h: [f64; 2], a: [f64; 2], l2c: &Mat3, axes: [bool; 2], uniform: bool, snap: impl FnOnce([f64; 2]) -> [f64; 2]) -> [f64; 2] {
let at = |f: [f64; 2]| {
let q = l2c.apply(gv2(a[0] + f[0] * h[0], a[1] + f[1] * h[1]));
[q.x, q.y]
};
let p = at(f);
let c = snap(p);
let s = [p[0] + c[0], p[1] + c[1]];
if uniform {
let o = at([0.0; 2]);
let d = [p[0] - o[0], p[1] - o[1]];
let k = [0, 1].into_iter().filter(|k| c[*k] != 0.0 && d[*k].abs() > 1e-9).min_by(|x, y| c[*x].abs().total_cmp(&c[*y].abs()));
return k.map_or(f, |k| f.map(|v| v * (s[k] - o[k]) / d[k]));
}
let Some(inv) = l2c.inverse().filter(|_| c != [0.0; 2]) else { return f };
let q = inv.apply(gv2(s[0], s[1]));
let q = [q.x - a[0], q.y - a[1]];
[0, 1].map(|k| if axes[k] && h[k].abs() > 1e-9 { q[k] / h[k] } else { f[k] })
}
/// A drag `d` (comp pixels), kept to the axis it moves along most when `shift` is held.
@ -1069,7 +1112,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,
})
}),
@ -1151,12 +1194,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let inv = l2c.inverse()?;
let a = ectx.v3(&layer, tr, "anchor", [0.0; 3]);
let lp = inv.apply(gv2(cpt[0], cpt[1]));
let hp = handle_point(effectcraft_engine::render::content_bounds(&ectx, &layer)?, hi);
Some(Gesture::Scale {
layer: lid,
start_scale: ectx.v3(&layer, tr, "scale", [100.0; 3]),
start_local: [lp.x - a[0], lp.y - a[1]],
handle: [hp[0] - a[0], hp[1] - a[1]],
anchor: [a[0], a[1]],
axes: handle_axes(hi),
l2c,
inv,
})
})
@ -1175,7 +1221,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,15 +1264,23 @@ 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]]);
let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/position", "value": v, "merge": merge}));
}
}
Gesture::Scale { layer, start_scale, start_local, anchor, axes, inv } => {
Gesture::Scale { layer, start_scale, start_local, handle, anchor, axes, l2c, inv } => {
let lp = inv.apply(gv2(cpt[0], cpt[1]));
let v = drag_scale(start_scale, start_local, [lp.x - anchor[0], lp.y - anchor[1]], axes, mods.shift);
let uniform = mods.shift && axes == [true, true];
let f = drag_ratio(start_local, [lp.x - anchor[0], lp.y - anchor[1]], axes, uniform);
// The handle snaps (to the comp's and other layers' edges, corners and centres),
// so a layer scales exactly to them, e.g. to the comp's size.
let f = snap_handle(f, handle, anchor, &l2c, axes, uniform, |p| vt::snap(app, &ctx, &ectx, &map, &[layer], &[p], mods));
let v = [start_scale[0] * f[0], start_scale[1] * f[1], start_scale[2]];
let _ = app.session.execute("prop.set", json!({"layer": layer.0, "path": "transform/scale", "value": v, "merge": merge}));
}
Gesture::Rotate { layer, center, start_angle, start_rot } => {

View File

@ -34,9 +34,10 @@ 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
/// the comp-space correction (zero when nothing is near) and records the feedback for this frame.
/// 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,
ctx: &egui::Context,
@ -67,11 +68,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 +83,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 +116,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));
}
}
}

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;

View File

@ -64,16 +64,24 @@ fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect {
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
drag_with(h, from, to, Modifiers::NONE);
}
/// [`drag`] with `modifiers` held.
fn drag_with(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: Modifiers) {
h.event(Event::ModifiersChanged(modifiers));
h.event(Event::PointerMoved(from));
h.step();
h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers });
h.step();
for k in 1..=8 {
h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 8.0)));
h.step();
}
h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers });
h.run_steps(2);
h.event(Event::ModifiersChanged(Modifiers::NONE));
h.step();
}
/// The visible span (seconds) from the ruler's automation label ("start,pps") and width.
@ -161,3 +169,47 @@ fn spacebar_drag_scrolls_the_time_graph() {
drag(&mut h, from, from - vec2(400.0, 0.0));
assert!(in_point(&h) < -1.0, "{}", in_point(&h));
}
/// #252: Shift-dragging a work area end, or the whole work area bar, snaps to the current-time
/// indicator when it comes within reach; without Shift it lands where the pointer is.
#[test]
fn shift_dragging_the_work_area_snaps_to_the_current_time() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Short", "width": 320, "height": 180, "frameRate": 30, "duration": 10})).unwrap();
s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap();
let mut app = EffectcraftApp::new(s);
app.ui.timeline.pps = Some(100.0);
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app);
let reset = |h: &mut Harness<'_, EffectcraftApp>| {
h.state_mut().session.execute("comp.workArea", json!({"start": 1.0, "end": 4.0})).unwrap();
h.state_mut().session.execute("time.set", json!({"time": 6.0})).unwrap();
h.run_steps(3);
};
let work_area = |h: &Harness<'_, EffectcraftApp>| {
let wa = h.state().session.active_comp().unwrap().work_area;
(wa.0.seconds(), wa.1.seconds())
};
let near = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6;
reset(&mut h);
// Screen x of a time, from the handles at 1 s and 4 s.
let (b, e) = (rect(&h, "timeline.workArea.begin").center(), rect(&h, "timeline.workArea.end").center());
let pps = (e.x - b.x) / 3.0;
let x = |t: f32| b.x + (t - 1.0) * pps;
// The end handle to 5 px before the CTI (6 s).
let to = pos2(x(6.0) - 5.0, e.y);
drag(&mut h, e, to);
assert!(work_area(&h).1 < 5.99, "no Shift: no snap {:?}", work_area(&h));
reset(&mut h);
drag_with(&mut h, e, to, Modifiers::SHIFT);
assert!(near(work_area(&h), (1.0, 6.0)), "the end snaps to the CTI: {:?}", work_area(&h));
// The bar moved so its end comes 5 px before the CTI: it moves as a whole and snaps.
reset(&mut h);
let mid = rect(&h, "timeline.workArea.bar").center();
let to = mid + vec2(x(6.0) - 5.0 - e.x, 0.0);
drag(&mut h, mid, to);
let (a, z) = work_area(&h);
assert!((z - a - 3.0).abs() < 1e-6 && z < 5.99 && a > 1.5, "no Shift: moved, no snap {a}..{z}");
reset(&mut h);
drag_with(&mut h, mid, to, Modifiers::SHIFT);
assert!(near(work_area(&h), (3.0, 6.0)), "the bar snaps its end to the CTI: {:?}", work_area(&h));
}

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]
@ -1208,6 +1261,52 @@ fn handle_drags_scale_about_the_anchor_and_follow_the_pointer() {
assert!(close(scale(&h), [162.5, 162.5]), "{:?}", scale(&h));
}
/// #252: with snapping on, a dragged handle snaps to the comp's corners and edges (and other
/// layers'), so a layer scales exactly to the comp; Shift keeps the proportions and still lands
/// on the comp's size.
#[test]
fn handle_drags_snap_to_the_comp_edges() {
let mut h = harness();
let box_id = h.state().session.active_comp().unwrap().layers[0].id.0;
let scale = |h: &Harness<'_, EffectcraftApp>, id: u64| {
let l = h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().clone();
l.props.prop("transform/scale").unwrap().value.as_vec3()
};
let close = |a: [f64; 3], b: [f64; 2]| (a[0] - b[0]).abs() < 0.01 && (a[1] - b[1]).abs() < 0.01;
let drag_handle = |h: &mut Harness<'_, EffectcraftApp>, id: u64, handle: usize, to: [f32; 2], mods: egui::Modifiers| {
h.state_mut().session.execute("prop.set", json!({"layer": id, "path": "transform/scale", "value": [100, 100, 100]})).unwrap();
h.state_mut().session.execute("layer.select", json!({"layers": [id]})).unwrap();
h.run_steps(2);
let from = rect(h, &format!("viewer.handle.{id}.{handle}")).center();
let to = screen(h, to);
drag_path(h, &[from, to], mods);
scale(h, id)
};
// The 80×80 box at the comp centre: its bottom right corner (360, 220) dropped 3 px inside
// the comp's (640, 360) lands on it, its right edge 3 px inside the comp's right edge too.
let s = drag_handle(&mut h, box_id, 2, [637.0, 357.0], Default::default());
assert!(close(s, [800.0, 450.0]), "{s:?}");
let s = drag_handle(&mut h, box_id, 5, [637.0, 200.0], Default::default());
assert!(close(s, [800.0, 100.0]), "{s:?}");
// Snapping off: where the pointer is.
h.state_mut().session.execute("view.snapping", json!({"value": false})).unwrap();
let s = drag_handle(&mut h, box_id, 2, [637.0, 357.0], Default::default());
assert!(s[0] < 795.0, "{s:?}");
h.state_mut().session.execute("view.snapping", json!({"value": true})).unwrap();
// A comp-shaped layer at 110 % Shift-scaled down by its corner to 3 px outside the comp's:
// the comp's size exactly.
let wide = h.state_mut().session.execute("layer.newSolid", json!({"name": "Wide", "color": "#20e040", "width": 640, "height": 360})).unwrap()["layer"]
.as_u64()
.unwrap();
h.state_mut().session.execute("prop.set", json!({"layer": wide, "path": "transform/scale", "value": [110, 110, 100]})).unwrap();
h.state_mut().session.execute("layer.select", json!({"layers": [wide]})).unwrap();
h.run_steps(2);
let path = [rect(&h, &format!("viewer.handle.{wide}.2")).center(), screen(&h, [643.0, 362.0])];
drag_path(&mut h, &path, egui::Modifiers::SHIFT);
let s = scale(&h, wide);
assert!(close(s, [100.0, 100.0]), "{s:?}");
}
/// A comp wider than the GPU's texture limit at Full resolution shows its frame (averaged down
/// into a texture the renderer accepts) instead of failing the upload (#201: 11000×2200).
#[test]

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),