Shapes: drawing with a shape tool adds to the selected shape layer (#227)

With a shape layer selected, the Rectangle, Rounded Rectangle, Ellipse,
Polygon and Star tools made a new shape layer for every shape. After
Effects draws into the selected shape layer instead: the shape becomes a
new group on top of its Contents ("Rectangle 2", ...), placed by the
group's Transform. The new shape.newShape command does that (in the
layer's space, so a moved or scaled layer still gets the shape where it
was drawn), and makes a new shape layer only when no unlocked shape
layer is selected. The viewer's shape tools use it; masks on other
selected layers are unchanged. layer.newShape and the Pen's
shape.newPath share its layer and Contents helpers.

Test: shape_tools_draw_into_the_selected_shape_layer,
shape_tool_draws_into_the_selected_shape_layer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-08 11:53:09 +07:00
parent 7e4a7cd863
commit 9968950b71
8 changed files with 263 additions and 58 deletions

View File

@ -206,26 +206,7 @@ fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
}
/// Contents for a new shape (one group with path + fill + stroke).
fn shape_contents(ids: &mut Ids, kind: &str, size: [f64; 2], fill: Option<[f64; 4]>, stroke: Option<([f64; 4], f64)>) -> Option<PropGroup> {
let (path, gname) = match kind {
"rect" | "rectangle" => (build::shape_rect(ids, size, [0.0, 0.0], 0.0), "Rectangle 1"),
"rounded" | "roundedRect" => (build::shape_rect(ids, size, [0.0, 0.0], size[0].min(size[1]) * 0.15), "Rectangle 1"),
"ellipse" => (build::shape_ellipse(ids, size, [0.0, 0.0]), "Ellipse 1"),
"star" => (build::shape_star(ids, true, 5.0, [0.0, 0.0], size[0] / 2.0, size[0] / 4.0), "Polystar 1"),
"polygon" => (build::shape_star(ids, false, 6.0, [0.0, 0.0], size[0] / 2.0, 0.0), "Polystar 1"),
_ => return None,
};
let mut items = vec![path];
if let Some((c, w)) = stroke {
items.push(build::shape_stroke(ids, c, w));
}
if let Some(c) = fill {
items.push(build::shape_fill(ids, c));
}
Some(build::shape_group(ids, gname, items))
}
fn c4(c: Option<[f32; 3]>, d: [f64; 4]) -> [f64; 4] {
pub(crate) fn c4(c: Option<[f32; 3]>, d: [f64; 4]) -> [f64; 4] {
c.map(|c| [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]).unwrap_or(d)
}
@ -236,27 +217,24 @@ fn new_shape(s: &mut Session, p: &Value) -> Result<Value> {
let size = p
.get("size")
.and_then(Value::as_array)
.map(|a| [a[0].as_f64().unwrap_or(200.0), a.get(1).and_then(Value::as_f64).unwrap_or(200.0)])
.map(|a| [a.first().and_then(Value::as_f64).unwrap_or(200.0), a.get(1).and_then(Value::as_f64).unwrap_or(200.0)])
.unwrap_or([300.0, 300.0]);
let fill = Some(c4(color_p(p, "fill"), [0.25, 0.55, 1.0, 1.0]));
let stroke = (f_p(p, "strokeWidth").unwrap_or(0.0) > 0.0).then(|| (c4(color_p(p, "stroke"), [1.0, 1.0, 1.0, 1.0]), f_p(p, "strokeWidth").unwrap_or(2.0)));
let pos = p.get("position").and_then(|v| v.as_array()).map(|a| [a[0].as_f64().unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]);
let pos = p
.get("position")
.and_then(|v| v.as_array())
.map(|a| [a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]);
let name = str_p(p, "name").unwrap_or("Shape Layer 1").to_string();
let id = s.edit("New Shape Layer", None, |proj, st| {
let mut l = build::layer(proj, &comp, &name, LayerSource::Shape, (comp.width, comp.height), None);
let lid = super::shape_tool::new_shape_layer(proj, st, &comp, cid, Some(&name), pos)?;
let mut next = proj.next_id;
if let Some(g) = shape_contents(&mut Ids(&mut next), &kind, size, fill, stroke)
&& let Some(c) = l.props.sub_mut("contents")
{
c.children.push(g.into());
}
let g = super::shape_tool::shape_group(&mut Ids(&mut next), &kind, size, fill, stroke);
proj.next_id = next;
if let Some(pos) = pos
&& let Some(pr) = l.props.prop_mut("transform/position")
{
pr.value = KV::Vec3([pos[0], pos[1], 0.0]);
if let Some(g) = g {
super::shape_tool::add_to_contents(proj, cid, lid, g, [0.0, 0.0], "layer.newShape")?;
}
insert_layer(proj, st, cid, l)
Ok(lid)
})?;
Ok(json!({"layer": id.0}))
}

View File

@ -55,6 +55,7 @@ mod scene_detect;
pub mod scripts;
mod settings;
pub mod shape_stroke;
pub mod shape_tool;
mod stubs;
mod styles;
mod text_anim;
@ -165,6 +166,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(view::specs());
v.extend(viewer_cmds::specs());
v.extend(shape_stroke::specs());
v.extend(shape_tool::specs());
v.extend(focus::specs());
v.extend(key_labels::specs());
v.extend(key_transform::specs());

View File

@ -0,0 +1,161 @@
//! The shape tools (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star) as a command: a drawn
//! shape goes into the selected shape layer's Contents as a new group, as in After Effects, or
//! into a new shape layer.
use effectcraft_geom::vec2;
use effectcraft_keyframe::Value as KV;
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, LayerId, LayerSource, Project, PropGroup};
use serde_json::{Value, json};
use super::layer::{c4, color_p, insert_layer, place, position_p};
use super::{CommandSpec, bad, comp_id, f_p, has_comp, layer_mut, str_p};
use crate::{EditorState, EngineError, Result, Session, cmd};
/// The shape tools' kinds, as `shape.newShape` / `layer.newShape` name them.
const KINDS: [&str; 5] = ["rect", "rounded", "ellipse", "polygon", "star"];
/// A new shape group for a shape tool `kind` of `size`, centred on the group's origin, with a
/// Stroke and a Fill ("Rectangle 1", "Ellipse 1" or "Polystar 1"). None for another kind.
pub(crate) fn shape_group(ids: &mut Ids, kind: &str, size: [f64; 2], fill: Option<[f64; 4]>, stroke: Option<([f64; 4], f64)>) -> Option<PropGroup> {
let (path, gname) = match kind {
"rect" | "rectangle" => (build::shape_rect(ids, size, [0.0, 0.0], 0.0), "Rectangle 1"),
"rounded" | "roundedRect" => (build::shape_rect(ids, size, [0.0, 0.0], size[0].min(size[1]) * 0.15), "Rectangle 1"),
"ellipse" => (build::shape_ellipse(ids, size, [0.0, 0.0]), "Ellipse 1"),
"star" => (build::shape_star(ids, true, 5.0, [0.0, 0.0], size[0] / 2.0, size[0] / 4.0), "Polystar 1"),
"polygon" => (build::shape_star(ids, false, 6.0, [0.0, 0.0], size[0] / 2.0, 0.0), "Polystar 1"),
_ => return None,
};
let mut items = vec![path];
if let Some((c, w)) = stroke {
items.push(build::shape_stroke(ids, c, w));
}
if let Some(c) = fill {
items.push(build::shape_fill(ids, c));
}
Some(build::shape_group(ids, gname, items))
}
/// The shape layer a shape tool or the Pen draws into: `layer` (which must be a shape layer),
/// else the first selected unlocked shape layer, else none (a new shape layer).
pub(crate) fn draw_target(s: &Session, comp: &Comp, p: &Value, cmd: &str) -> Result<Option<LayerId>> {
let target = match p.get("layer") {
Some(l) => Some(super::resolve_layer(comp, l).ok_or_else(|| bad(cmd, format!("no layer {l}")))?),
None => s.state.selected_layers.iter().copied().find(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked)),
};
if target.and_then(|l| comp.layer(l)).is_some_and(|l| !matches!(l.source, LayerSource::Shape)) {
return Err(bad(cmd, "the layer is not a shape layer"));
}
Ok(target)
}
/// A new empty shape layer ("Shape Layer n" unless `name`) with its Position at `position` (comp
/// pixels; else the comp centre), above the selected layer and selected.
pub(crate) fn new_shape_layer(
proj: &mut Project,
st: &mut EditorState,
comp: &Comp,
cid: ItemId,
name: Option<&str>,
position: Option<[f64; 2]>,
) -> Result<LayerId> {
let count = comp.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count();
let name = name.map(str::to_string).unwrap_or_else(|| format!("Shape Layer {}", count + 1));
let mut l = build::layer(proj, comp, &name, LayerSource::Shape, (comp.width, comp.height), None);
place(&mut l, position);
insert_layer(proj, st, cid, l)
}
/// Put `g` on top of the shape layer's Contents with a name unique there ("Rectangle 2"…) and
/// its Transform's Position at `position` (layer space). Returns its uid.
pub(crate) fn add_to_contents(
proj: &mut Project,
cid: ItemId,
lid: LayerId,
mut g: PropGroup,
position: [f64; 2],
cmd: &str,
) -> Result<effectcraft_project::Uid> {
let contents = layer_mut(proj, cid, lid)?.props.sub_mut("contents").ok_or_else(|| bad(cmd, "the layer has no contents"))?;
g.name = super::effect::unique_name(contents, &g.name);
if let Some(pr) = g.sub_mut("transform").and_then(|t| t.get_mut("position")) {
pr.value = KV::Vec2(position);
}
let uid = g.uid;
contents.children.insert(0, g.into());
Ok(uid)
}
/// A finite `[w, h]` / `[x, y]` parameter.
fn pair_p(p: &Value, k: &str) -> Option<[f64; 2]> {
let a = p.get(k)?.as_array()?;
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?]).filter(|v| v.iter().all(|x| x.is_finite()))
}
/// The shape tools: a rectangle, rounded rectangle, ellipse, polygon or star of `size`, centred
/// at `position`. With a shape layer given or selected it goes on top of that layer's Contents
/// as a new group ("Rectangle 2"…) placed by the group's Transform, as After Effects draws into
/// the selected shape layer; otherwise a new shape layer is centred on it. `position` and `size`
/// are in comp pixels, or the layer's space with `space: "layer"`.
fn new_shape(s: &mut Session, p: &Value) -> Result<Value> {
let c = "shape.newShape";
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let kind = str_p(p, "kind").unwrap_or("rect");
if !KINDS.contains(&kind) {
return Err(bad(c, format!("unknown kind `{kind}`; one of: {}", KINDS.join(", "))));
}
let size = match p.get("size") {
None => [200.0, 200.0],
Some(_) => pair_p(p, "size").filter(|s| s.iter().all(|v| *v >= 0.0)).ok_or_else(|| bad(c, "size: [w, h], not negative"))?,
};
let pos = position_p(p, c)?.unwrap_or([comp.width as f64 / 2.0, comp.height as f64 / 2.0]);
let fill = Some(c4(color_p(p, "fill"), [0.25, 0.55, 1.0, 1.0]));
let stroke = (f_p(p, "strokeWidth").unwrap_or(0.0) > 0.0).then(|| (c4(color_p(p, "stroke"), [1.0, 1.0, 1.0, 1.0]), f_p(p, "strokeWidth").unwrap_or(2.0)));
let target = draw_target(s, &comp, p, c)?;
// In the target layer's space: the centre maps through the layer's transform, the size by
// its scale.
let (centre, size) = match target.and_then(|l| comp.layer(l)) {
Some(l) if str_p(p, "space") != Some("layer") => {
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, s.time_of(cid));
let inv = ctx.layer_to_comp(l).0.inverse().ok_or_else(|| bad(c, "the layer is scaled to nothing"))?;
let q = inv.apply(vec2(pos[0], pos[1]));
([q.x, q.y], [inv.apply_vec(vec2(size[0], 0.0)).length(), inv.apply_vec(vec2(0.0, size[1])).length()])
}
Some(_) => (pos, size),
None => ([0.0, 0.0], size),
};
let label = match kind {
"rounded" => "Rounded Rectangle Tool",
"ellipse" => "Ellipse Tool",
"polygon" => "Polygon Tool",
"star" => "Star Tool",
_ => "Rectangle Tool",
};
let name = str_p(p, "name");
let (lid, group) = s.edit(label, None, |proj, st| {
let lid = match target {
Some(l) => l,
None => new_shape_layer(proj, st, &comp, cid, name, Some(pos))?,
};
let mut next = proj.next_id;
let g = shape_group(&mut Ids(&mut next), kind, size, fill, stroke).ok_or_else(|| bad(c, "unknown kind"))?;
proj.next_id = next;
let group = add_to_contents(proj, cid, lid, g, centre, c)?;
st.selected_layers = vec![lid];
Ok((lid, group))
})?;
Ok(json!({"layer": lid.0, "group": group}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"shape.newShape",
"Shape Tool",
[],
None,
"{layer?, kind?: rect|rounded|ellipse|polygon|star, size? [w,h], position? [x,y] (the centre), space?: comp|layer, name? (a new layer's), fill?, stroke?, strokeWidth?} → {layer, group}",
has_comp,
new_shape
)]
}

View File

@ -3,7 +3,6 @@
//! shape layers (`shape.newPath`).
use effectcraft_keyframe::ShapePath;
use effectcraft_project::LayerSource;
use effectcraft_project::build::{self, Ids};
use effectcraft_render::RenderOpts;
use serde::{Deserialize, Serialize};
@ -226,15 +225,7 @@ fn new_path(s: &mut Session, p: &Value) -> Result<Value> {
let mut outs = pts(p.get("outTangents"));
ins.resize(n, [0.0; 2]);
outs.resize(n, [0.0; 2]);
let target = match p.get("layer") {
Some(l) => Some(super::resolve_layer(&comp, l).ok_or_else(|| bad("shape.newPath", format!("no layer {l}")))?),
None => s.state.selected_layers.iter().copied().find(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked)),
};
if let Some(l) = target.and_then(|l| comp.layer(l))
&& !matches!(l.source, LayerSource::Shape)
{
return Err(bad("shape.newPath", "the layer is not a shape layer"));
}
let target = super::shape_tool::draw_target(s, &comp, p, "shape.newPath")?;
let comp_space = str_p(p, "space").map(|s| s == "comp").unwrap_or(target.is_none());
if comp_space {
// Comp → layer space of the target (a new layer sits at the comp centre, unrotated).
@ -262,17 +253,9 @@ fn new_path(s: &mut Session, p: &Value) -> Result<Value> {
let (lid, group, path_uid) = s.edit("Pen Tool", None, |proj, st| {
let lid = match target {
Some(l) => l,
None => {
let count = comp.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count();
let lname = name.clone().unwrap_or_else(|| format!("Shape Layer {}", count + 1));
let l = build::layer(proj, &comp, &lname, LayerSource::Shape, (comp.width, comp.height), None);
super::layer::insert_layer(proj, st, cid, l)?
}
None => super::shape_tool::new_shape_layer(proj, st, &comp, cid, name.as_deref(), None)?,
};
let mut next = proj.next_id;
let l = super::layer_mut(proj, cid, lid)?;
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("shape.newPath", "the layer has no contents"))?;
let k = contents.groups().filter(|g| g.match_id == "group").count();
let mut ids = Ids(&mut next);
let pg = build::shape_path(&mut ids, path);
let path_uid = pg.uid;
@ -283,10 +266,9 @@ fn new_path(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(c) = fill {
items.push(build::shape_fill(&mut ids, c));
}
let g = build::shape_group(&mut ids, &format!("Shape {}", k + 1), items);
let group = g.uid;
contents.children.insert(0, g.into());
let g = build::shape_group(&mut ids, "Shape 1", items);
proj.next_id = next;
let group = super::shape_tool::add_to_contents(proj, cid, lid, g, [0.0, 0.0], "shape.newPath")?;
st.selected_layers = vec![lid];
st.selected_vertices = vec![VertexRef { layer: lid, mask: path_uid, index: n - 1 }];
Ok((lid, group, path_uid))

View File

@ -238,6 +238,50 @@ fn shape_pen_builds_shape_group_and_renders() {
assert!(s.execute("shape.newPath", json!({"layer": sol, "vertices": [[0, 0]]})).is_err());
}
/// The shape tools draw into the selected shape layer's Contents as a new group, as After
/// Effects does, and a new shape layer only with none selected (#227).
#[test]
fn shape_tools_draw_into_the_selected_shape_layer() {
let mut s = comp();
// Nothing selected: a new shape layer centred on the shape.
let r = s.execute("shape.newShape", json!({"kind": "rect", "size": [100, 60], "position": [200, 100], "fill": [1, 0, 0]})).unwrap();
let lid = r["layer"].as_u64().unwrap();
let l = layer(&s, lid);
assert_eq!(l.name, "Shape Layer 1");
assert_eq!(l.props.prop("transform/position").unwrap().value.components()[..2], [200.0, 100.0]);
// Selected (and scaled 200%): the next shape goes on top of its Contents, in layer space.
s.execute("prop.set", json!({"layer": lid, "path": "transform/scale", "value": [200, 200, 100]})).unwrap();
let n = s.active_comp().unwrap().layers.len();
let r = s.execute("shape.newShape", json!({"kind": "ellipse", "size": [40, 20], "position": [300, 160]})).unwrap();
assert_eq!(r["layer"].as_u64(), Some(lid));
assert_eq!(s.active_comp().unwrap().layers.len(), n, "no new layer");
let l = layer(&s, lid);
let contents = l.props.sub("contents").unwrap();
let names: Vec<&str> = contents.groups().map(|g| g.name.as_str()).collect();
assert_eq!(names, ["Ellipse 1", "Rectangle 1"]);
let g = l.props.find_group(r["group"].as_u64().unwrap()).unwrap();
assert_eq!(g.sub("transform").unwrap().get("position").unwrap().value.components(), [50.0, 30.0]);
assert_eq!(g.sub("contents").unwrap().groups().next().unwrap().get("size").unwrap().value.components(), [20.0, 10.0]);
// A second rectangle is "Rectangle 2"; it renders where it was drawn.
s.execute("shape.newShape", json!({"kind": "rect", "size": [40, 40], "position": [500, 300], "fill": [0, 1, 0]})).unwrap();
let names: Vec<String> = layer(&s, lid).props.sub("contents").unwrap().groups().map(|g| g.name.clone()).collect();
assert_eq!(names, ["Rectangle 2", "Ellipse 1", "Rectangle 1"]);
let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default());
assert_eq!(img.get(500, 300), [0.0, 1.0, 0.0, 1.0]);
// Each shape is one undo step.
s.undo();
s.undo();
assert_eq!(layer(&s, lid).props.sub("contents").unwrap().groups().count(), 1);
// A locked shape layer isn't drawn into; a solid is refused when named.
s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "locked", "value": true})).unwrap();
s.state.selected_layers = vec![LayerId(lid)];
let r = s.execute("shape.newShape", json!({"kind": "star"})).unwrap();
assert_ne!(r["layer"].as_u64(), Some(lid));
let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
assert!(s.execute("shape.newShape", json!({"layer": sol})).is_err());
assert!(s.execute("shape.newShape", json!({"kind": "blob"})).is_err());
}
#[test]
fn vertex_add_delete_convert_on_shape_paths_and_masks_undo() {
let mut s = comp();

View File

@ -1828,12 +1828,16 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2],
return;
}
}
// Into the selected shape layer's Contents as a new group (as in After Effects), else a new
// shape layer.
let fill = app.ui.fill_color;
let stroke = app.ui.stroke_color;
let _ = app.session.execute(
"layer.newShape",
if let Err(e) = app.session.execute(
"shape.newShape",
json!({"kind": kind, "size": [w, h], "position": [cx, cy], "fill": [fill[0], fill[1], fill[2]], "stroke": [stroke[0], stroke[1], stroke[2]], "strokeWidth": app.ui.stroke_width}),
);
) {
app.ui.status = e.to_string();
}
}
fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {

View File

@ -247,6 +247,39 @@ fn layer_drag_snaps_to_comp_centre_and_ctrl_disables() {
assert!((p[0] - 320.0).abs() > 1.0, "{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]
fn shape_tool_draws_into_the_selected_shape_layer() {
let mut h = harness();
let l = h.state_mut().session.execute("layer.newShape", json!({"kind": "rect", "size": [100, 100], "position": [320, 180]})).unwrap()["layer"]
.as_u64()
.unwrap();
h.state_mut().ui.tool = Tool::Rectangle;
h.run_steps(2);
let n0 = h.state().session.active_comp().unwrap().layers.len();
let (a, b) = (screen(&h, [100.0, 100.0]), screen(&h, [200.0, 160.0]));
drag(&mut h, a, b);
let comp = h.state().session.active_comp().unwrap().clone();
assert_eq!(comp.layers.len(), n0, "no new layer");
let contents = comp.layer(LayerId(l)).unwrap().props.sub("contents").unwrap().clone();
let names: Vec<&str> = contents.groups().map(|g| g.name.as_str()).collect();
assert_eq!(names, ["Rectangle 2", "Rectangle 1"]);
// Placed where it was drawn (centred near comp (150, 130)), in the layer's space: the layer
// sits at the comp centre.
let g = contents.groups().next().unwrap();
let at = g.sub("transform").unwrap().get("position").unwrap().value.components();
assert!((at[0] + 170.0).abs() < 8.0 && (at[1] + 50.0).abs() < 8.0, "{at:?}");
// Nothing selected: a new shape layer.
h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap();
let (a, b) = (screen(&h, [400.0, 250.0]), screen(&h, [500.0, 300.0]));
drag(&mut h, a, b);
let comp = h.state().session.active_comp().unwrap();
assert_eq!(comp.layers.len(), n0 + 1);
assert!(matches!(comp.layers[0].source, effectcraft_engine::project::LayerSource::Shape));
assert_ne!(comp.layers[0].id, LayerId(l));
}
#[test]
fn shape_pen_draws_a_closed_shape_layer() {
let mut h = harness();

View File

@ -113,6 +113,7 @@ Viewer state that agents drive headless too: `view.snapping`, `view.channel {cha
`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),
`shape.newShape` (the shape tools: a new group in the selected shape layer, else a new shape layer),
`mask.insertVertex`, `mask.convertVertex`, `mask.deleteVertices`, `path.freeTransform` (masks and
shape paths by uid), `keys.setSpatialTangents` (motion-path handles) and `keys.transform` (Graph
Editor transform box, timeline Alt-drag scaling).