Layer: Quick Set Anchor, nine anchor points with Position compensation (#3080)

* Layer: Quick Set Anchor, nine anchor points with Position compensation (#2967)

layer.quickSetAnchor moves the Anchor Point of 2D layers to a corner, edge midpoint or the
centre of their content and shifts Position and its keys so nothing moves. Layer > Transform >
Quick Set Anchor lists the nine points.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Quick Set Anchor: skip layers with an animated Anchor Point, Scale or Rotation

The anchor is set at the current time and the Position shift is worked out
from the current Anchor Point, Scale and Rotation, so on a layer where any of
them is keyed or has an expression the layer would move at other frames.
Such layers are skipped like 3D and locked ones (an error when none is left).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Quick Set Anchor: skip layers with a Position expression or Auto-Orient along the path

An expression on Position (or the separated X/Y) overrides the shifted value,
and Auto-Orient along the path adds a rotation the shift does not account for;
both would make the layer jump, so they are skipped like 3D layers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Quick Set Anchor: error when no selected layer has content bounds

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Quick Set Anchor: test separated, keyed Position keeps the layer in place

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein
2026-11-06 17:37:11 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 9a32a51793
commit be8b5fd8d4
4 changed files with 285 additions and 4 deletions
+76 -4
View File
@@ -243,17 +243,80 @@ fn set_transform(s: &mut Session, p: &Value) -> Result<Value> {
fn center_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let ids = super::unlocked(s, cid, ids, "layer.centerAnchor")?;
move_anchor(s, cid, &ids, "Center Anchor Point", false, |[x0, y0, x1, y1]| [(x0 + x1) / 2.0, (y0 + y1) / 2.0])
}
/// The nine points Quick Set Anchor offers, as `(name, column, row)`: column 0 is the left edge
/// of the layer's content, 2 the right; row 0 the top, 2 the bottom.
const ANCHOR_POINTS: [(&str, u8, u8); 9] = [
("topLeft", 0, 0),
("top", 1, 0),
("topRight", 2, 0),
("left", 0, 1),
("center", 1, 1),
("right", 2, 1),
("bottomLeft", 0, 2),
("bottom", 1, 2),
("bottomRight", 2, 2),
];
/// `layer.quickSetAnchor {point, layers?}`: move the Anchor Point of the 2D layers to one of the
/// nine points of their content (corners, edge midpoints, centre) and shift Position, and every
/// Position key, so nothing moves on screen. 3D and locked layers, and layers with an animated
/// (or expression-driven) Anchor Point, Scale or Rotation, an expression on Position, or Auto-Orient
/// along the path, are skipped; an error when no layer is left or none has content bounds.
fn quick_set_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let c = "layer.quickSetAnchor";
let name = str_p(p, "point").ok_or_else(|| bad(c, "missing `point`"))?;
let (_, col, row) = ANCHOR_POINTS
.iter()
.copied()
.find(|(n, ..)| *n == name)
.ok_or_else(|| bad(c, format!("unknown point `{name}`; one of {}", ANCHOR_POINTS.map(|(n, ..)| n).join(", "))))?;
let (cid, ids) = layers_p(s, p)?;
let ids = super::unlocked(s, cid, ids, c)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
// The anchor is set at the current time and the Position shift is worked out from the current
// Anchor Point, Scale and Rotation, so a layer where any of them changes over time would move
// at other frames: it is skipped like a 3D one. So is a layer whose Position is expression-
// driven (the expression would override the shifted value and the layer would jump), and one
// that orients itself along its path (the renderer adds the path direction to Rotation).
let changes = |l: &effectcraft_project::Layer| {
let Some(tr) = l.transform() else { return false };
let moving = ["anchor", "scale", "rotation"].iter().any(|m| tr.get(m).is_some_and(|p| p.is_animated() || p.has_expression()));
let driven = ["position", "positionX", "positionY"].iter().any(|m| tr.get(m).is_some_and(|p| p.has_expression()));
moving || driven || l.auto_orient == effectcraft_project::AutoOrient::AlongPath
};
let flat: Vec<LayerId> = ids.into_iter().filter(|id| comp.layer(*id).is_some_and(|l| !l.is_3d() && !changes(l))).collect();
if flat.is_empty() {
return Err(bad(
c,
"Quick Set Anchor works on 2D layers whose Anchor Point, Scale and Rotation are not animated, whose Position has no expression and that do not auto-orient along a path",
));
}
let at = |lo: f64, hi: f64, i: u8| lo + (hi - lo) * f64::from(i) / 2.0;
move_anchor(s, cid, &flat, "Quick Set Anchor", true, |[x0, y0, x1, y1]| [at(x0, x1, col), at(y0, y1, row)])
}
/// Move each layer's Anchor Point to `target(content bounds)` (layer space) and offset Position
/// by the matching amount, so the layer stays put over its whole animation. Layers without content
/// bounds (an empty text or shape layer) are left alone; with `strict` it is an error when none of
/// the layers has any, so the command does not report success for a no-op.
fn move_anchor(s: &mut Session, cid: ItemId, ids: &[LayerId], label: &str, strict: bool, target: impl Fn([f64; 4]) -> [f64; 2]) -> Result<Value> {
let t = s.time();
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, t);
let mut centers = vec![];
for lid in &ids {
for lid in ids {
let Some(l) = comp.layer(*lid) else { continue };
if let Some([x0, y0, x1, y1]) = effectcraft_render::content_bounds(&ctx, l) {
centers.push((*lid, [(x0 + x1) / 2.0, (y0 + y1) / 2.0]));
if let Some(b) = effectcraft_render::content_bounds(&ctx, l) {
centers.push((*lid, target(b)));
}
}
s.edit("Center Anchor Point", None, |proj, _| {
if strict && centers.is_empty() {
return Err(bad("layer.quickSetAnchor", "none of the layers has content to anchor to (empty text or shape layers have no bounds)"));
}
s.edit(label, None, |proj, _| {
for (lid, c) in &centers {
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
@@ -905,6 +968,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_layers,
center_anchor
),
cmd!(
"layer.quickSetAnchor",
"Quick Set Anchor",
["Layer", "Transform", "Quick Set Anchor"],
None,
"{point: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, layers?} → {layers}: moves the Anchor Point of 2D layers to that point of their content and compensates Position (and its keys); skips 3D and locked layers and those with an animated Anchor Point, Scale or Rotation",
has_layers,
quick_set_anchor
),
cmd!(
"layer.mask.shape",
"Mask Shape...",
+10
View File
@@ -862,6 +862,16 @@ Layer
Flip Vertical | layer.transform {"op":"flipV"}
Center In View | layer.transform {"op":"center"} | Cmd+Home
Center Anchor Point in Layer Content | layer.centerAnchor
Quick Set Anchor
Top Left | layer.quickSetAnchor {"point":"topLeft"}
Top | layer.quickSetAnchor {"point":"top"}
Top Right | layer.quickSetAnchor {"point":"topRight"}
Left | layer.quickSetAnchor {"point":"left"}
Center | layer.quickSetAnchor {"point":"center"}
Right | layer.quickSetAnchor {"point":"right"}
Bottom Left | layer.quickSetAnchor {"point":"bottomLeft"}
Bottom | layer.quickSetAnchor {"point":"bottom"}
Bottom Right | layer.quickSetAnchor {"point":"bottomRight"}
Fit to Comp | layer.transform {"op":"fit"} | Cmd+Alt+F
Fit to Comp Width | layer.transform {"op":"fitWidth"} | Cmd+Alt+Shift+H
Fit to Comp Height | layer.transform {"op":"fitHeight"} | Cmd+Alt+Shift+G
+194
View File
@@ -2065,3 +2065,197 @@ fn precompose_unlinks_layers_left_behind_and_numbers_default_names() {
names.sort();
assert_eq!(names, ["Pre-comp 1", "Pre-comp 2"]);
}
/// Quick Set Anchor (#2967): every one of the nine points moves the Anchor Point of a rotated,
/// scaled layer with keyed Position to that point of its content, and the layer looks the same
/// on every frame (Position and its keys are compensated), in one undo step.
#[test]
fn quick_set_anchor_moves_the_anchor_without_moving_the_layer_over_its_keys() {
let near = |a: [f64; 4], b: [f64; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6);
// (name, anchor in the 200×100 solid's layer space)
let points = [
("topLeft", [0.0, 0.0]),
("top", [100.0, 0.0]),
("topRight", [200.0, 0.0]),
("left", [0.0, 50.0]),
("center", [100.0, 50.0]),
("right", [200.0, 50.0]),
("bottomLeft", [0.0, 100.0]),
("bottom", [100.0, 100.0]),
("bottomRight", [200.0, 100.0]),
];
for (name, want) in points {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
s.execute("prop.set", json!({"layer": a, "path": "transform/scale", "value": [150, 80]})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/rotation", "value": 30})).unwrap();
s.execute("prop.addKey", json!({"layer": a, "path": "transform/position", "time": 0, "value": [100, 80]})).unwrap();
s.execute("prop.addKey", json!({"layer": a, "path": "transform/position", "time": 1, "value": [400, 250]})).unwrap();
s.execute("prop.addKey", json!({"layer": a, "path": "transform/position", "time": 2, "value": [300, 60]})).unwrap();
let look = |s: &mut Session| {
[0.0, 0.4, 1.0, 1.5, 2.0]
.map(|t| {
s.execute("time.set", json!({"time": t})).unwrap();
seen_bounds(s, a)
})
.to_vec()
};
let before = look(&mut s);
let undo = s.history.undo.len();
s.execute("layer.quickSetAnchor", json!({"layers": [a], "point": name})).unwrap();
let anchor = layer(&s, a).transform().unwrap().get("anchor").unwrap().value.as_vec3();
assert!(anchor[0] == want[0] && anchor[1] == want[1], "{name}: anchor {anchor:?}, wanted {want:?}");
for (b, n) in before.iter().zip(look(&mut s)) {
assert!(near(*b, n), "{name}: the layer moved, {b:?} → {n:?}");
}
// The centre is where the anchor already is: nothing changes, so there's no undo step.
assert_eq!(s.history.undo.len(), undo + usize::from(name != "center"), "{name}: one undo step");
if name == "center" {
continue;
}
s.execute("edit.undo", json!({})).unwrap();
let back = layer(&s, a).transform().unwrap().get("anchor").unwrap().value.as_vec3();
assert_eq!(back[..2], [100.0, 50.0], "{name}: undo restores the anchor");
}
}
/// Same as above with Position split into X and Y, each with its own keys: both channels get the
/// shift, so the layer still looks the same on every frame.
#[test]
fn quick_set_anchor_keeps_a_layer_with_separated_keyed_position_in_place() {
let mut s = comp();
let a = solid(&mut s, "#ff0000");
s.execute("prop.set", json!({"layer": a, "path": "transform/scale", "value": [150, 80]})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/rotation", "value": 30})).unwrap();
s.execute("prop.separateDimensions", json!({"layer": a, "value": true})).unwrap();
for (path, values) in [("transform/positionX", [100.0, 400.0, 300.0]), ("transform/positionY", [80.0, 250.0, 60.0])] {
for (time, value) in [0.0, 1.0, 2.0].into_iter().zip(values) {
s.execute("prop.addKey", json!({"layer": a, "path": path, "time": time, "value": value})).unwrap();
}
}
let look = |s: &mut Session| {
[0.0, 0.4, 1.0, 1.5, 2.0]
.map(|t| {
s.execute("time.set", json!({"time": t})).unwrap();
seen_bounds(s, a)
})
.to_vec()
};
let before = look(&mut s);
s.execute("layer.quickSetAnchor", json!({"layers": [a], "point": "topLeft"})).unwrap();
let anchor = layer(&s, a).transform().unwrap().get("anchor").unwrap().value.as_vec3();
assert_eq!(anchor[..2], [0.0, 0.0], "the anchor moved to the top left");
for (b, n) in before.iter().zip(look(&mut s)) {
assert!(b.iter().zip(n).all(|(x, y)| (x - y).abs() < 1e-6), "the layer moved, {b:?} → {n:?}");
}
}
#[test]
fn quick_set_anchor_skips_locked_and_3d_layers_and_checks_its_point() {
let mut s = comp();
let flat = solid(&mut s, "#00ff00");
let deep = solid(&mut s, "#0000ff");
let locked = solid(&mut s, "#ffffff");
s.execute("layer.setSwitch", json!({"layers": [deep], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [locked], "switch": "lock", "value": true})).unwrap();
let anchor_of = |s: &Session, l: u64| layer(s, l).transform().unwrap().get("anchor").unwrap().value.as_vec3();
let r = s.execute("layer.quickSetAnchor", json!({"layers": [flat, deep, locked], "point": "topLeft"})).unwrap();
assert_eq!(r["layers"], 1, "only the unlocked 2D layer changed");
assert_eq!(anchor_of(&s, flat)[..2], [0.0, 0.0]);
assert_eq!(anchor_of(&s, deep)[..2], [100.0, 50.0]);
assert_eq!(anchor_of(&s, locked)[..2], [100.0, 50.0]);
assert!(s.execute("layer.quickSetAnchor", json!({"layers": [deep], "point": "top"})).is_err(), "3D only: nothing to do");
assert!(s.execute("layer.quickSetAnchor", json!({"layers": [flat], "point": "middle"})).is_err());
assert!(s.execute("layer.quickSetAnchor", json!({"layers": [flat]})).is_err());
}
/// The anchor is set at the current time and the Position shift comes from the current Anchor
/// Point, Scale and Rotation, so a layer where one of them is keyed would move at other frames:
/// it is skipped, and alone it is an error.
#[test]
fn quick_set_anchor_skips_layers_whose_anchor_scale_or_rotation_is_keyed() {
for path in ["transform/anchor", "transform/scale", "transform/rotation"] {
let mut s = comp();
let flat = solid(&mut s, "#00ff00");
let keyed = solid(&mut s, "#0000ff");
let value = if path.ends_with("rotation") {
json!(0)
} else if path.ends_with("scale") {
json!([100, 100])
} else {
json!([100, 50])
};
let later = if path.ends_with("rotation") {
json!(90)
} else if path.ends_with("scale") {
json!([50, 50])
} else {
json!([20, 10])
};
s.execute("prop.addKey", json!({"layer": keyed, "path": path, "time": 0, "value": value})).unwrap();
s.execute("prop.addKey", json!({"layer": keyed, "path": path, "time": 2, "value": later})).unwrap();
let before = layer(&s, keyed);
let r = s.execute("layer.quickSetAnchor", json!({"layers": [flat, keyed], "point": "topLeft"})).unwrap();
assert_eq!(r["layers"], 1, "{path}: only the layer without keys changed");
assert_eq!(layer(&s, keyed), before, "{path}: the keyed layer is untouched");
assert!(s.execute("layer.quickSetAnchor", json!({"layers": [keyed], "point": "top"})).is_err(), "{path}: nothing to do");
}
}
/// An expression on Position (or on the separated X / Y) would override the shifted value, and
/// Auto-Orient along the path adds a rotation the shift does not know: those layers are skipped.
#[test]
fn quick_set_anchor_skips_position_expressions_and_along_path_layers() {
for case in ["position", "positionX", "alongPath"] {
let mut s = comp();
let flat = solid(&mut s, "#00ff00");
let other = solid(&mut s, "#0000ff");
match case {
"alongPath" => {
s.execute("layer.autoOrient", json!({"layers": [other], "mode": "alongPath"})).unwrap();
}
"position" => {
s.execute("prop.setExpression", json!({"layer": other, "path": "transform/position", "expression": "[100, 50]"})).unwrap();
}
_ => {
s.execute("prop.separateDimensions", json!({"layer": other, "value": true})).unwrap();
s.execute("prop.setExpression", json!({"layer": other, "path": "transform/positionX", "expression": "100"})).unwrap();
}
}
let before = layer(&s, other);
let r = s.execute("layer.quickSetAnchor", json!({"layers": [flat, other], "point": "topLeft"})).unwrap();
assert_eq!(r["layers"], 1, "{case}: only the plain layer changed");
assert_eq!(layer(&s, other), before, "{case}: the layer is untouched");
assert!(s.execute("layer.quickSetAnchor", json!({"layers": [other], "point": "top"})).is_err(), "{case}: nothing to do");
}
}
/// A selection of only empty text layers (no content bounds) is an error, not a silent `{layers: 0}`;
/// `layer.centerAnchor` keeps reporting 0 for it.
#[test]
fn quick_set_anchor_errors_when_no_layer_has_content_bounds() {
let mut s = comp();
let t = s.execute("layer.newText", json!({"text": ""})).unwrap()["layer"].as_u64().unwrap();
assert!(s.execute("layer.quickSetAnchor", json!({"layers": [t], "point": "center"})).is_err());
let r = s.execute("layer.centerAnchor", json!({"layers": [t]})).unwrap();
assert_eq!(r["layers"], 0);
}
/// A child of a rotated, scaled parent keeps its place when its anchor moves (Position is in the
/// parent's space).
#[test]
fn quick_set_anchor_keeps_a_parented_layer_in_place() {
let mut s = comp();
let parent = solid(&mut s, "#ff0000");
let child = solid(&mut s, "#00ff00");
s.execute("prop.set", json!({"layer": parent, "path": "transform/rotation", "value": 25})).unwrap();
s.execute("prop.set", json!({"layer": parent, "path": "transform/scale", "value": [120, 70]})).unwrap();
s.execute("layer.setParent", json!({"layers": [child], "parent": parent})).unwrap();
s.execute("prop.set", json!({"layer": child, "path": "transform/rotation", "value": -40})).unwrap();
s.execute("prop.set", json!({"layer": child, "path": "transform/scale", "value": [60, 90]})).unwrap();
let before = seen_bounds(&s, child);
s.execute("layer.quickSetAnchor", json!({"layers": [child], "point": "bottomRight"})).unwrap();
let after = seen_bounds(&s, child);
assert!(before.iter().zip(after).all(|(a, b)| (a - b).abs() < 1e-6), "{before:?} → {after:?}");
assert_eq!(layer(&s, child).transform().unwrap().get("anchor").unwrap().value.as_vec3()[..2], [200.0, 100.0]);
}
+5
View File
@@ -261,6 +261,11 @@ build real projects through these interfaces; they are worked examples of everyt
`layer.centerAnchor` skip them and fail when every target is locked ("… is locked"); Select
All and `layer.selectNext` / `selectPrevious` step over them. Unlock with
`layer.setSwitch {"layers": [...], "switch": "lock", "value": false}` or `layer.unlockAll`.
* **Quick Set Anchor**: `layer.quickSetAnchor {point: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, layers?}`
(Layer ▸ Transform ▸ Quick Set Anchor) moves the Anchor Point of 2D layers to that point of their
content and shifts Position and its keys so the layers don't move, in one undo step. 3D and
locked layers, and layers whose Anchor Point, Scale or Rotation is keyed or has an expression,
are skipped (an error when none is left); returns `{layers}`.
* **Solid Settings**: `layer.settings {layer, name?, color?, width?, height?, pixelAspect?,
affectAll?}` edits a solid or adjustment layer's solid (and renames the layer and the solid).
A solid shared by several layers (duplicates share theirs) changes for all of them unless