diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index ca34760e..d63b5dbc 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -206,26 +206,7 @@ fn new_text(s: &mut Session, p: &Value) -> Result { } /// 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 { - 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 { 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})) } diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 7c8e9613..bce740b6 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -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()); diff --git a/crates/engine/src/commands/shape_tool.rs b/crates/engine/src/commands/shape_tool.rs new file mode 100644 index 00000000..5483d090 --- /dev/null +++ b/crates/engine/src/commands/shape_tool.rs @@ -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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 + )] +} diff --git a/crates/engine/src/commands/viewer_cmds.rs b/crates/engine/src/commands/viewer_cmds.rs index cdb43adb..619c515d 100644 --- a/crates/engine/src/commands/viewer_cmds.rs +++ b/crates/engine/src/commands/viewer_cmds.rs @@ -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 { 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 { 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 { 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)) diff --git a/crates/engine/src/tests_viewer.rs b/crates/engine/src/tests_viewer.rs index 973324a9..f2a3b393 100644 --- a/crates/engine/src/tests_viewer.rs +++ b/crates/engine/src/tests_viewer.rs @@ -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 = 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(); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 9894672b..2371950e 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -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) { diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 842c134f..4155878a 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -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(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ab95df9c..5d56f59c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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).