Viewer: the Snapping menu has After Effects' two options

The Tools bar's Snapping options menu (#265) offered per-feature
toggles (Edges, Corners, Centers, Anchor Points, Mask and Shape Path
Points) that After Effects' Snapping menu doesn't have. It now has the
two After Effects has:

- Snap Edges Extended (as before).
- Snap to Features in Collapsed Compositions and Text Layers: the
  visible layers inside a precomp layer with Collapse Transformations
  on are snap targets too, mapped into the outer comp (points always,
  edge lines while the precomp layer is unrotated); a snap highlights
  the precomp layer. Nested collapsed precomps are followed eight deep.

Every layer feature snaps again (corners, edge midpoints, centre,
anchor point, mask and shape path points); the snapping engine itself
is unchanged. `view.snappingOptions` takes `edgesExtended` and
`collapsedFeatures` (and `toggle` of either); the old keys are refused.

Test: collapsed_precomp_features_snap (fails before: no such option),
snapping_options_command_sets_and_toggles (the per-feature keys are
refused), snapping_options_pick_the_targets (AE behaviour kept: edges
extended or not, hidden layers, guides only), and the Snapping menu UI
test (only the two options are listed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-09 18:56:19 +07:00
parent b9d1f79f7d
commit 6ce58040fd
7 changed files with 162 additions and 109 deletions

View File

@ -467,7 +467,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Snapping Options",
[],
None,
"{edgesExtended?, edges?, corners?, centers?, anchorPoints?, paths?: bool, toggle?: one of those keys} → the options",
"{edgesExtended? (Snap Edges Extended), collapsedFeatures? (Snap to Features in Collapsed Compositions and Text Layers): bool, toggle?: one of those keys} → the options",
always,
snapping_options
),

View File

@ -84,8 +84,8 @@ fn snap_targets_cover_layers_comp_guides_and_grid() {
}
/// 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.
/// extent, every feature of a layer snaps, 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};
@ -108,35 +108,63 @@ fn snapping_options_pick_the_targets() {
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.
// Corners, edge midpoints and the centre of a are targets.
for p in [[270.0, 155.0], [320.0, 155.0], [320.0, 180.0]] {
assert!(all.iter().any(|t| t.source == SnapSource::Layer(LayerId(a)) && t.pos == p), "{p:?}");
}
// The dragged layer's features: corners, edge midpoints, centre and anchor point.
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]]);
let f = layer_features(&ctx, l);
assert_eq!(f[..4], [[270.0, 155.0], [370.0, 155.0], [370.0, 205.0], [270.0, 205.0]]);
assert_eq!(f.len(), 10, "{f:?}");
// 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));
}
/// Snap to Features in Collapsed Compositions and Text Layers: the layers inside a collapsed
/// precomp layer are targets (highlighting the precomp layer), only while the option is on and
/// the precomp layer collapses transformations.
#[test]
fn collapsed_precomp_features_snap() {
use crate::viewer::{SnapFeatures, SnapOptions, targets};
let mut s = comp();
// A 40×20 solid at (100, 50) in a 640×360 precomp.
let inner = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 40, "height": 20})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": inner, "path": "transform/position", "value": [100, 50, 0]})).unwrap();
s.execute("layer.precompose", json!({"layers": [inner], "name": "Inner"})).unwrap();
let pre = s.active_comp().unwrap().layers[0].id;
// Moved by (10, 20) in the outer comp: the inner solid's corner (80, 40) lands at (90, 60).
s.execute("prop.set", json!({"layer": pre.0, "path": "transform/position", "value": [330, 200, 0]})).unwrap();
let cid = s.active_comp_id().unwrap();
let at = |s: &Session, f: SnapFeatures| {
let comp = s.project.comp(cid).unwrap().clone();
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, Tick::ZERO);
targets(&ctx, &[], SnapOptions { layers: true, features: f, guides: false, grid: false, grid_spacing: 0.0 })
};
let corner = |t: &[crate::viewer::SnapTarget]| t.iter().any(|t| t.kind == SnapKind::Point && t.pos == [90.0, 60.0] && t.source == SnapSource::Layer(pre));
assert!(!corner(&at(&s, SnapFeatures::default())), "not collapsed");
s.execute("layer.setSwitch", json!({"layers": [pre.0], "switch": "collapse", "value": true})).unwrap();
let on = at(&s, SnapFeatures::default());
assert!(corner(&on), "collapsed");
assert_eq!(snap(&[[93.0, 63.0]], &on, 5.0).unwrap().at, [90.0, 60.0]);
assert!(!corner(&at(&s, SnapFeatures { collapsed_features: false, ..Default::default() })), "option off");
}
#[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());
assert_eq!(s.state.snap_features, crate::viewer::SnapFeatures { edges_extended: true, collapsed_features: true });
let r = s.execute("view.snappingOptions", json!({"edgesExtended": false})).unwrap();
assert_eq!(r, json!({"edgesExtended": false, "collapsedFeatures": true}));
s.execute("view.snappingOptions", json!({"toggle": "collapsedFeatures"})).unwrap();
assert!(!s.state.snap_features.collapsed_features);
s.execute("view.snappingOptions", json!({"toggle": "collapsedFeatures"})).unwrap();
assert!(s.state.snap_features.collapsed_features);
// The per-feature toggles are gone, as in After Effects' menu.
for k in ["nope", "corners", "anchorPoints", "paths"] {
assert!(s.execute("view.snappingOptions", json!({"toggle": k})).is_err(), "{k}");
}
}
// ---------------------------------------------------------------- channels, exposure, snapshot

View File

@ -67,48 +67,34 @@ impl SnapTarget {
}
}
/// 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.
/// Tools bar ▸ Snapping options, the two After Effects' Snapping menu has: Snap Edges Extended
/// (layer edges snap along their whole line, beyond the layer's bounds) and Snap to Features in
/// Collapsed Compositions and Text Layers (the layers inside a precomp layer with Collapse
/// Transformations on are targets too). Both are on by default. Every layer feature snaps
/// (corners, edge midpoints, centre, anchor point, mask and shape path points), as in After
/// Effects.
#[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,
pub collapsed_features: bool,
}
impl Default for SnapFeatures {
fn default() -> Self {
SnapFeatures { edges_extended: true, edges: true, corners: true, centers: true, anchor_points: true, paths: true }
SnapFeatures { edges_extended: true, collapsed_features: 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"),
];
pub const OPTIONS: [(&'static str, &'static str); 2] =
[("edgesExtended", "Snap Edges Extended"), ("collapsedFeatures", "Snap to Features in Collapsed Compositions and Text Layers")];
/// 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,
"collapsedFeatures" => &mut self.collapsed_features,
_ => return None,
})
}
@ -187,15 +173,13 @@ 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 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]> {
/// Snap features of a layer in comp space: 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) -> Vec<[f64; 2]> {
let (m, _) = ctx.layer_to_comp(layer);
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));
}
let mut out = layer_box(ctx, layer, &m).map(|b| b.points()).unwrap_or_default();
out.extend(anchor_feature(ctx, layer, &m));
out.extend(layer_vertices(ctx, layer));
out
}
@ -208,19 +192,9 @@ struct BoxPoints {
}
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
/// Corners, edge midpoints and centre.
fn points(&self) -> Vec<[f64; 2]> {
self.corners.iter().chain(&self.mids).chain([&self.center]).copied().collect()
}
}
@ -301,8 +275,8 @@ pub struct SnapOptions {
}
/// 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.
/// the comp's edges and centre (with `layers`, as the Snapping options allow), 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);
@ -315,17 +289,10 @@ pub fn targets(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec<Sna
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) {
lines(&[0.0, w], &[0.0, h]);
lines(&[w / 2.0], &[h / 2.0]);
out.push(SnapTarget::point([w / 2.0, h / 2.0], PRI_COMP, SnapSource::Comp));
for l in visible_layers(ctx, exclude) {
out.extend(layer_targets(ctx, l, true, f));
}
}
@ -354,11 +321,27 @@ pub fn targets(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec<Sna
out
}
/// 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.
/// How deep Snap to Features in Collapsed Compositions looks into nested collapsed precomps.
const MAX_COLLAPSED_DEPTH: usize = 8;
/// Visible layers of a comp at the context time: active, eye on, and soloed while any layer is.
fn visible_layers<'a>(ctx: &EvalCtx<'a>, exclude: &'a [LayerId]) -> impl Iterator<Item = &'a Layer> {
let comp = ctx.comp;
let time = ctx.time;
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(time));
comp.layers.iter().filter(move |l| l.is_active_at(time) && l.switches.video && (!any_solo || l.switches.solo) && !exclude.contains(&l.id))
}
/// A 2D layer's snap targets: 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), its mask and shape path vertices and, with
/// Snap to Features in Collapsed Compositions, the targets of the visible layers inside a
/// collapsed precomp layer. None for cameras, lights and 3D layers.
pub fn layer_targets(ctx: &EvalCtx, l: &Layer, anchor: bool, f: SnapFeatures) -> Vec<SnapTarget> {
layer_targets_at(ctx, l, anchor, f, 0)
}
fn layer_targets_at(ctx: &EvalCtx, l: &Layer, anchor: bool, f: SnapFeatures, depth: usize) -> Vec<SnapTarget> {
if l.is_camera() || l.is_light() || l.is_3d() {
return vec![];
}
@ -367,7 +350,7 @@ pub fn layer_targets(ctx: &EvalCtx, l: &Layer, anchor: bool, f: SnapFeatures) ->
let mut out = vec![];
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 {
if (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));
@ -376,11 +359,58 @@ pub fn layer_targets(ctx: &EvalCtx, l: &Layer, anchor: bool, f: SnapFeatures) ->
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(b.points().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))));
out.extend(anchor_feature(ctx, l, &m).filter(|_| anchor).map(|p| SnapTarget::point(p, PRI_LAYER, src)));
out.extend(layer_vertices(ctx, l).into_iter().map(|p| SnapTarget::point(p, PRI_VERTEX, SnapSource::Vertex(l.id))));
if f.collapsed_features
&& l.switches.collapse
&& depth < MAX_COLLAPSED_DEPTH
&& let effectcraft_project::LayerSource::Comp { item } = l.source
&& let Some(nc) = ctx.project.comp(item)
{
out.extend(collapsed_targets(ctx, l, item, nc, &m, f, depth));
}
out
}
/// The targets of the layers inside collapsed precomp layer `l` (showing comp `nc`), in the
/// outer comp (`m`: `l`'s layer → comp). They highlight `l` when they snap. Lines stay lines
/// only while `l` is unrotated; otherwise only the points snap.
fn collapsed_targets(
ctx: &EvalCtx,
l: &Layer,
item: effectcraft_project::ItemId,
nc: &effectcraft_project::Comp,
m: &Mat3,
f: SnapFeatures,
depth: usize,
) -> Vec<SnapTarget> {
let nctx = EvalCtx { comp_id: item, comp: nc, time: ctx.nested_time(l), ..*ctx };
let axis_aligned = m.0[0][1].abs() < 1e-9 && m.0[1][0].abs() < 1e-9;
let (sx, ox, sy, oy) = (m.0[0][0], m.0[0][2], m.0[1][1], m.0[1][2]);
let mut out = vec![];
for nl in visible_layers(&nctx, &[]) {
for t in layer_targets_at(&nctx, nl, true, f, depth + 1) {
let source = match t.source {
SnapSource::Vertex(_) => SnapSource::Vertex(l.id),
_ => SnapSource::Layer(l.id),
};
let mapped = match t.kind {
SnapKind::Point => {
let q = m.apply(vec2(t.pos[0], t.pos[1]));
Some(SnapTarget::point([q.x, q.y], t.priority, source))
}
SnapKind::VLine if axis_aligned => {
Some(SnapTarget::vline(sx * t.pos[0] + ox, t.priority, source).within(t.span.map(|[a, b]| [sy * a + oy, sy * b + oy])))
}
SnapKind::HLine if axis_aligned => {
Some(SnapTarget::hline(sy * t.pos[1] + oy, t.priority, source).within(t.span.map(|[a, b]| [sx * a + ox, sx * b + ox])))
}
_ => None,
};
out.extend(mapped);
}
}
out
}

View File

@ -251,8 +251,8 @@ 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`).
/// The Snapping options menu beside the checkbox: After Effects' Snap Edges Extended and Snap to
/// Features in Collapsed Compositions and Text Layers (`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");
@ -263,19 +263,9 @@ fn snapping_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect) {
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 items: Vec<String> = vw::SnapFeatures::OPTIONS.iter().map(|(k, label)| widgets::check_label(f.option(k), label)).collect();
if let Some((k, _)) = widgets::popup_menu(ui, pop, r.left_bottom(), &items, None).and_then(|i| vw::SnapFeatures::OPTIONS.get(i))
&& let Err(e) = app.session.execute("view.snappingOptions", json!({"toggle": k}))
{
app.ui.status = e.to_string();

View File

@ -1248,8 +1248,7 @@ 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 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 snap_src = comp.layer(l).map(|layer| effectcraft_engine::viewer::layer_features(&ectx, layer)).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);

View File

@ -295,6 +295,11 @@ fn layer_drag_snaps_to_other_layers_edges_ctrl_toggles_and_options_apply() {
// 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);
// After Effects' two options, without per-feature toggles.
assert!(h.query_by_label("✓ Snap to Features in Collapsed Compositions and Text Layers").is_some());
for gone in ["✓ Corners", "✓ Anchor Points", "✓ Mask and Shape Path Points"] {
assert!(h.query_by_label(gone).is_none(), "{gone}");
}
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);

View File

@ -110,8 +110,9 @@ revolutions are `timeline.prop.<uid>.revolutions`, `effectControls.prop.<uid>.re
`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.snappingOptions
{edgesExtended?, edges?, corners?, centers?, anchorPoints?, paths?, toggle?}` (the Tools bar's
Snapping options, `header.snappingOptions`), `view.channel {channel, colorized?}`,
{edgesExtended?, collapsedFeatures?, toggle?}` (the Tools bar's Snapping options,
`header.snappingOptions`: After Effects' Snap Edges Extended and Snap to Features in Collapsed
Compositions and Text Layers), `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),