From d773cec982fa8505288cd70430f4a5549e38780f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 22:52:45 +0000 Subject: [PATCH] =?UTF-8?q?Properties:=20layer.roundCorners=20command=20(s?= =?UTF-8?q?olid=20=E2=86=92=20rounded=20shape=20layer,=20shape=20rect=20ro?= =?UTF-8?q?undness),=20roundness=20on=20new=20shapes,=20Layer=20=E2=80=BA?= =?UTF-8?q?=20Create=20=E2=80=BA=20Round=20Corners,=20MCP=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Rc1XNFw6vDovyoYpwHj9Py (cherry picked from commit f3cd3cc7b3b8d6c3ce88a3c60d3612aa50dc3b3c) # Conflicts: # crates/engine/src/commands/mod.rs (cherry picked from commit cec4b6633045617064a6857d4a0c3aea1ae8408f) (cherry picked from commit 63da1f2779b57d5409310d001eae83821ca8f03a) --- crates/automation/src/tests.rs | 21 ++ crates/engine/src/commands/layer.rs | 6 +- crates/engine/src/commands/mod.rs | 2 + crates/engine/src/commands/round_corners.rs | 313 ++++++++++++++++++++ crates/engine/src/commands/shape_tool.rs | 25 +- crates/engine/src/menus.rs | 1 + crates/ui-egui/src/i18n.rs | 4 + docs/control-protocol.md | 14 +- 8 files changed, 380 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/commands/round_corners.rs diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 37de10a5..4fb161e6 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -585,6 +585,27 @@ fn puppet_rigging_over_mcp() { assert!(text.contains("puppet.follow") && text.contains("puppet.selectPins"), "{text}"); } +#[test] +fn round_corners_over_mcp() { + let mut s = server(); + let run = |s: &mut McpServer, id: &str, params: Value| call_json(s, "command_run", json!({"id": id, "params": params})); + run(&mut s, "comp.new", json!({"name": "Round", "width": 200, "height": 100, "duration": 1})); + let id = run(&mut s, "layer.newSolid", json!({"width": 120, "height": 60, "color": "#c03030"}))["layer"].clone(); + let d = call_json(&mut s, "describe_command", json!({"command": "layer.roundCorners"})); + assert!(d["schema"]["properties"]["roundness"].is_object(), "{d}"); + let r = run(&mut s, "layer.roundCorners", json!({"layer": id, "roundness": 16})); + assert_eq!(r["converted"], json!([id]), "{r}"); + let l = call_json(&mut s, "get_layer", json!({"layer": id})); + assert!(l.to_string().contains("Roundness"), "{l}"); + let p = call_json(&mut s, "get_property", json!({"layer": id, "path": "contents/group/contents/rect/roundness"})); + assert_eq!(p["value"], 16.0, "{p}"); + // A shape layer takes it on its rectangles; one undo step each. + run(&mut s, "layer.roundCorners", json!({"layer": id, "roundness": 4})); + call_json(&mut s, "undo", json!({})); + let p = call_json(&mut s, "get_property", json!({"layer": id, "path": "contents/group/contents/rect/roundness"})); + assert_eq!(p["value"], 16.0, "{p}"); +} + /// The agent-interface audit (MCP side): every registered engine command is listed by /// `list_commands` with a label, params doc and JSON Schema, described by `describe_command`, /// and routed by `execute_command` (an unknown parameter is rejected by that very command diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 06e90460..c9be363a 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -216,6 +216,7 @@ fn new_shape(s: &mut Session, p: &Value) -> Result { .unwrap_or([300.0, 300.0]); // A fill, and a stroke only with a width (not the Tools bar's paint). let paint = super::shape_tool::paint_p(p, &Default::default(), "layer.newShape")?; + let round = super::shape_tool::roundness_p(p, &kind, "layer.newShape")?; let pos = p .get("position") .and_then(|v| v.as_array()) @@ -226,7 +227,8 @@ fn new_shape(s: &mut Session, p: &Value) -> Result { let mut next = proj.next_id; let g = super::shape_tool::shape_group(&mut Ids(&mut next), &kind, size, &paint); proj.next_id = next; - if let Some(g) = g { + if let Some(mut g) = g { + super::shape_tool::set_roundness(&mut g, round); super::shape_tool::add_to_contents(proj, cid, lid, g, [0.0, 0.0], "layer.newShape")?; } Ok(lid) @@ -1488,7 +1490,7 @@ pub fn specs() -> Vec { "Shape Layer", ["Layer", "New"], None, - "{kind?: rect|rounded|ellipse|star|polygon|none, name?, size?, fill?, fillType?, fillBlend?, fillOpacity?, stroke?, strokeType?, strokeBlend?, strokeOpacity?, strokeWidth?, position?}", + "{kind?: rect|rounded|ellipse|star|polygon|none, name?, size?, roundness? px (rect/rounded), fill?, fillType?, fillBlend?, fillOpacity?, stroke?, strokeType?, strokeBlend?, strokeOpacity?, strokeWidth?, position?}", has_comp, new_shape ), diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 0a27d473..48af1379 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -53,6 +53,7 @@ pub(crate) mod relink_moved; mod render_queue; pub(crate) mod rig3d; pub mod roto_cmds; +pub mod round_corners; mod scene_detect; pub mod scripts; mod settings; @@ -167,6 +168,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(viewer_cmds::specs()); v.extend(shape_stroke::specs()); v.extend(shape_tool::specs()); + v.extend(round_corners::specs()); v.extend(focus::specs()); v.extend(key_labels::specs()); v.extend(key_transform::specs()); diff --git a/crates/engine/src/commands/round_corners.rs b/crates/engine/src/commands/round_corners.rs new file mode 100644 index 00000000..85756d43 --- /dev/null +++ b/crates/engine/src/commands/round_corners.rs @@ -0,0 +1,313 @@ +//! Layer ▸ Round Corners (`layer.roundCorners`): rounded corners from the Properties panel's +//! Roundness row, beyond After Effects (whose solids have no roundness). +//! +//! On a shape layer it sets the Roundness of every Rectangle Path in its Contents (nested groups +//! too). A solid is first converted, in the same undo step, into a shape layer drawing a +//! rectangle of the solid's size and colour in the solid's own layer space (so the anchor point, +//! masks, mattes and every other layer setting keep their meaning and nothing moves), and keeps +//! its id, name, timing, transform, parent, switches, blend mode, masks, layer styles, markers +//! and label. Solids whose conversion would change their pixels (effects, which on a shape layer +//! measure from a comp-sized canvas; adjustment layers; non-square pixels; environment layers; +//! 3D layers in an Advanced 3D comp) are refused with the reason instead. + +use effectcraft_keyframe::Value as KV; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, ItemKind, Layer, LayerSource, Node, Project, PropGroup, Renderer, Solid}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, has_layers, layer_mut, layers_p, merge_p}; +use crate::{EngineError, Result, Session, cmd}; + +const CMD: &str = "layer.roundCorners"; + +/// The Rounded Rectangle tool's roundness for a `w`×`h` rectangle: 15% of the shorter side. +fn default_roundness(size: [f64; 2]) -> f64 { + size[0].min(size[1]).max(0.0) * 0.15 +} + +/// The solid behind `l`, or why it cannot become a shape layer without changing its pixels. +pub fn convertible_solid(project: &Project, comp: &Comp, l: &Layer) -> std::result::Result { + let LayerSource::Solid { item } = l.source else { return Err(format!("{}: not a solid or shape layer", l.name)) }; + let Some(ItemKind::Solid(sol)) = project.item(item).map(|i| &i.kind) else { return Err(format!("{}: its solid is missing", l.name)) }; + let why = if l.switches.adjustment { + Some("it is an adjustment layer") + } else if l.effects().is_some_and(|g| !g.children.is_empty()) { + Some("it has effects, which measure from a shape layer's comp-sized canvas and would move (pre-compose it, or remove the effects first)") + } else if (sol.pixel_aspect - 1.0).abs() > 1e-9 { + Some("its solid has non-square pixels") + } else if l.environment || l.environment_background { + Some("it is an environment layer") + } else if l.is_3d() && comp.renderer == Renderer::Advanced3D { + Some("it is a 3D layer in an Advanced 3D composition (shape layers extrude there)") + } else { + None + }; + match why { + Some(w) => Err(format!("{}: cannot round its corners: {w}", l.name)), + None => Ok(sol.clone()), + } +} + +/// The Contents of a converted solid: "Rectangle 1" with a Rectangle Path of the solid's size +/// and a Fill of its colour, centred on the solid's centre (layer space is the solid's). +fn solid_contents(ids: &mut Ids, sol: &Solid, roundness: f64) -> PropGroup { + let (w, h) = (sol.width as f64, sol.height as f64); + let rect = build::shape_rect(ids, [w, h], [0.0, 0.0], roundness); + let c = sol.color; + let fill = build::shape_fill(ids, [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]); + let mut g = build::shape_group(ids, "Rectangle 1", vec![rect, fill]); + if let Some(pr) = g.sub_mut("transform").and_then(|t| t.get_mut("position")) { + pr.value = KV::Vec2([w / 2.0, h / 2.0]); + } + build::contents(ids).with(g) +} + +/// Every Rectangle Path below `g` (nested groups included), in Contents order. +pub fn rect_paths(g: &PropGroup) -> Vec<&PropGroup> { + let mut out = vec![]; + collect_rects(g, &mut out, 0); + out +} + +fn collect_rects<'a>(g: &'a PropGroup, out: &mut Vec<&'a PropGroup>, depth: usize) { + // Shape groups nest a few levels deep; a malformed file stops here rather than overflowing. + if depth > 64 { + return; + } + for c in g.groups() { + if c.match_id == "rect" { + out.push(c); + } else { + collect_rects(c, out, depth + 1); + } + } +} + +/// Set the Roundness of every Rectangle Path below `g` at layer time `lt` (a key there when it +/// is animated); `None` gives each the Rounded Rectangle tool's for its size. Returns how many. +fn round_rects(g: &mut PropGroup, lt: effectcraft_time::Tick, roundness: Option, depth: usize) -> usize { + if depth > 64 { + return 0; + } + let mut n = 0; + for c in g.children.iter_mut() { + let Node::Group(c) = c else { continue }; + if c.match_id != "rect" { + n += round_rects(c, lt, roundness, depth + 1); + continue; + } + let size = match c.get("size").map(|p| p.value_at(lt)) { + Some(KV::Vec2(s)) => s, + _ => [0.0, 0.0], + }; + if let Some(pr) = c.get_mut("roundness") { + pr.set_value_at(lt, KV::Scalar(roundness.unwrap_or_else(|| default_roundness(size)))); + n += 1; + } + } + n +} + +fn round_corners(s: &mut Session, p: &Value) -> Result { + let (cid, lids) = layers_p(s, p)?; + if lids.is_empty() { + return Err(bad(CMD, "select a solid or shape layer first")); + } + let roundness = match p.get("roundness") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "`roundness`: pixels"))?.max(0.0)), + }; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + // Check every layer before changing any: all or nothing. + let mut solids = vec![]; + for lid in &lids { + let l = comp.layer(*lid).ok_or_else(|| bad(CMD, format!("no layer {}", lid.0)))?; + match l.source { + LayerSource::Shape => { + if l.props.sub("contents").is_none_or(|c| rect_paths(c).is_empty()) { + return Err(bad(CMD, format!("{}: the shape layer has no Rectangle Path", l.name))); + } + if l.switches.locked { + return Err(bad(CMD, format!("{}: the layer is locked", l.name))); + } + } + _ => { + if l.switches.locked { + return Err(bad(CMD, format!("{}: the layer is locked", l.name))); + } + solids.push((*lid, convertible_solid(&s.project, comp, l).map_err(|e| bad(CMD, e))?)); + } + } + } + let t = s.time(); + let converted: Vec = solids.iter().map(|(l, _)| l.0).collect(); + s.edit("Round Corners", merge_p(p), |proj, _| { + for lid in &lids { + if let Some((_, sol)) = solids.iter().find(|(l, _)| l == lid) { + let r = roundness.unwrap_or_else(|| default_roundness([sol.width as f64, sol.height as f64])); + let mut next = proj.next_id; + let contents = solid_contents(&mut Ids(&mut next), sol, r); + proj.next_id = next; + let l = layer_mut(proj, cid, *lid)?; + l.source = LayerSource::Shape; + // Where a new shape layer keeps its Contents: first. + l.props.children.insert(0, contents.into()); + } else { + let l = layer_mut(proj, cid, *lid)?; + let lt = l.layer_time(t); + if let Some(c) = l.props.sub_mut("contents") { + round_rects(c, lt, roundness, 0); + } + } + } + Ok(()) + })?; + Ok(json!({"layers": lids.iter().map(|l| l.0).collect::>(), "converted": converted})) +} + +pub fn specs() -> Vec { + vec![cmd!( + "layer.roundCorners", + "Round Corners", + [], + None, + "{layer?|layers? (default the selection), roundness? px (default 15% of the shorter side, as the Rounded Rectangle tool), comp?, merge?} → {layers, converted}: a solid becomes a shape layer drawing a rounded rectangle of its size and colour (same id, name, timing, transform, parent, switches, masks, styles, markers; refused with the reason for solids with effects, adjustment layers, non-square pixels, environment layers, 3D in Advanced 3D); a shape layer gets the roundness on every Rectangle Path in its Contents. One undo step.", + has_layers, + round_corners + )] +} + +#[cfg(test)] +mod tests { + use super::*; + use effectcraft_project::LayerId; + + fn session() -> (Session, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 240, "duration": 2})).unwrap(); + let id = s.execute("layer.newSolid", json!({"name": "Red", "color": "#e02020", "width": 200, "height": 120})).unwrap()["layer"].as_u64().unwrap(); + // Off-centre, rotated by a whole turn (a no-op that still goes through the matrix) and + // with a moved anchor: the conversion must keep the layer where it is. + s.execute("prop.set", json!({"layer": id, "path": "transform/position", "value": [140, 110]})).unwrap(); + s.execute("prop.set", json!({"layer": id, "path": "transform/anchor", "value": [90, 50]})).unwrap(); + (s, id) + } + + fn layer(s: &Session, id: u64) -> Layer { + s.project.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(id)).unwrap().clone() + } + + fn roundness(s: &Session, id: u64) -> Vec { + let l = layer(s, id); + rect_paths(l.props.sub("contents").unwrap()).iter().map(|r| r.get("roundness").unwrap().value.as_f64()).collect() + } + + #[test] + fn solid_becomes_a_shape_layer_with_the_same_pixels_at_zero_roundness() { + let (mut s, id) = session(); + s.execute("layer.addMarker", json!({"layers": [id], "comment": "kept"})).unwrap(); + s.execute("mask.new", json!({"layer": id, "vertices": [[20, 20], [180, 10], [100, 110]], "closed": true})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let before = layer(&s, id); + let img = s.render(cid, s.time(), Default::default()); + let r = s.execute(CMD, json!({"layer": id, "roundness": 0})).unwrap(); + assert_eq!(r["converted"], json!([id])); + let after = layer(&s, id); + assert!(matches!(after.source, LayerSource::Shape)); + // Same layer: id, name, label, timing, switches, transform, masks, markers. + assert_eq!( + (after.id, &after.name, after.label, after.in_point, after.out_point), + (before.id, &before.name, before.label, before.in_point, before.out_point) + ); + assert_eq!(after.switches, before.switches); + assert_eq!(after.markers, before.markers); + assert_eq!(after.transform(), before.transform()); + assert_eq!(after.masks(), before.masks()); + assert_eq!(roundness(&s, id), vec![0.0]); + // Same pixels. + let img2 = s.render(cid, s.time(), Default::default()); + let diff = img.data.iter().zip(&img2.data).map(|(a, b)| (0..4).map(|k| (a[k] - b[k]).abs()).fold(0.0f32, f32::max)).fold(0.0f32, f32::max); + assert!(diff < 1e-4, "converted solid renders differently (max channel diff {diff})"); + // One undo step back to the solid. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(layer(&s, id), before); + } + + #[test] + fn roundness_rounds_the_corners_and_a_shape_layer_takes_it_too() { + let (mut s, id) = session(); + let cid = s.active_comp_id().unwrap(); + s.execute("prop.set", json!({"layer": id, "path": "transform/anchor", "value": [100, 60]})).unwrap(); + s.execute("prop.set", json!({"layer": id, "path": "transform/position", "value": [160, 120]})).unwrap(); + let sharp = s.render(cid, s.time(), Default::default()); + // The solid spans x 60..260, y 60..180: its top-left corner pixel is red. + assert!(sharp.get(61, 61)[0] > 0.5); + s.execute(CMD, json!({"layer": id, "roundness": 40})).unwrap(); + let round = s.render(cid, s.time(), Default::default()); + assert!(round.get(61, 61)[3] < 0.01, "corner still filled: {:?}", round.get(61, 61)); + assert!(round.get(160, 120)[0] > 0.5); + // Again, now on the shape layer: only the roundness changes. + let r = s.execute(CMD, json!({"layer": id, "roundness": 12})).unwrap(); + assert_eq!(r["converted"], json!([])); + assert_eq!(roundness(&s, id), vec![12.0]); + } + + #[test] + fn a_scrub_is_one_undo_step() { + let (mut s, id) = session(); + let before = layer(&s, id); + for r in [2, 5, 9] { + s.execute(CMD, json!({"layer": id, "roundness": r, "merge": "props-roundness"})).unwrap(); + } + assert_eq!(roundness(&s, id), vec![9.0]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(layer(&s, id), before); + } + + #[test] + fn nested_rectangles_of_a_shape_layer_all_round() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 240, "duration": 2})).unwrap(); + let l = s.execute("shape.newShape", json!({"kind": "rect", "size": [100, 50]})).unwrap()["layer"].as_u64().unwrap(); + s.execute("shape.newShape", json!({"layer": l, "kind": "rounded", "size": [80, 40]})).unwrap(); + s.execute("shape.newShape", json!({"layer": l, "kind": "ellipse", "size": [80, 40]})).unwrap(); + s.execute(CMD, json!({"layer": l, "roundness": 7})).unwrap(); + assert_eq!(roundness(&s, l), vec![7.0, 7.0]); + // Without a roundness: the Rounded Rectangle tool's, per rectangle. + s.execute(CMD, json!({"layer": l})).unwrap(); + assert_eq!(roundness(&s, l), vec![6.0, 7.5]); + } + + #[test] + fn new_shapes_take_a_roundness() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 240, "duration": 2})).unwrap(); + let l = s.execute("shape.newShape", json!({"kind": "rect", "size": [100, 50], "roundness": 9})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(roundness(&s, l), vec![9.0]); + let l = s.execute("layer.newShape", json!({"kind": "rounded", "size": [100, 50], "roundness": 4})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(roundness(&s, l), vec![4.0]); + let e = s.execute("shape.newShape", json!({"kind": "ellipse", "roundness": 9})).unwrap_err().to_string(); + assert!(e.contains("roundness"), "{e}"); + } + + #[test] + fn refusals_keep_the_layer_and_explain_why() { + let (mut s, id) = session(); + s.execute("effect.apply", json!({"layers": [id], "effect": "Gaussian Blur"})).unwrap(); + let before = layer(&s, id); + let e = s.execute(CMD, json!({"layer": id, "roundness": 10})).unwrap_err().to_string(); + assert!(e.contains("effects"), "{e}"); + assert_eq!(layer(&s, id), before); + let nl = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + let e = s.execute(CMD, json!({"layer": nl})).unwrap_err().to_string(); + assert!(e.contains("not a solid or shape layer"), "{e}"); + let e = s.execute(CMD, json!({"layer": id, "roundness": "big"})).unwrap_err().to_string(); + assert!(e.contains("roundness"), "{e}"); + } + + #[test] + fn listed_for_agents() { + let spec = crate::commands::find(CMD).unwrap(); + assert!(spec.params.contains("roundness")); + } +} diff --git a/crates/engine/src/commands/shape_tool.rs b/crates/engine/src/commands/shape_tool.rs index 83ab0f01..63e13077 100644 --- a/crates/engine/src/commands/shape_tool.rs +++ b/crates/engine/src/commands/shape_tool.rs @@ -188,6 +188,25 @@ fn polystar_of(kind: &str, size: [f64; 2]) -> Option<(bool, f64, f64, f64)> { } } +/// The `roundness` parameter (pixels, not negative) of a new `kind` shape: only rectangles +/// have one. +pub(crate) fn roundness_p(p: &Value, kind: &str, cmd: &str) -> Result> { + match p.get("roundness") { + None | Some(Value::Null) => Ok(None), + Some(_) if !matches!(kind, "rect" | "rectangle" | "rounded" | "roundedRect") => Err(bad(cmd, "`roundness` is for rect and rounded shapes")), + Some(v) => Ok(Some(v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, "`roundness`: pixels"))?.max(0.0))), + } +} + +/// Set the Roundness of a [`shape_group`] rectangle (when `r` is given). +pub(crate) fn set_roundness(g: &mut PropGroup, r: Option) { + if let Some(r) = r + && let Some(pr) = g.prop_mut("contents/rect/roundness") + { + pr.value = KV::Scalar(r); + } +} + /// A new shape group for a shape tool `kind` of `size`, centred on the group's origin, painted /// with `paint` ("Rectangle 1", "Ellipse 1" or "Polystar 1"). None for another kind. pub(crate) fn shape_group(ids: &mut Ids, kind: &str, size: [f64; 2], paint: &ShapeTool) -> Option { @@ -303,6 +322,7 @@ fn new_shape(s: &mut Session, p: &Value) -> Result { }; let pos = position_p(p, c)?.unwrap_or([comp.width as f64 / 2.0, comp.height as f64 / 2.0]); let paint = paint_p(p, &s.state.shape_tool, c)?; + let round = roundness_p(p, kind, c)?; 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. @@ -330,7 +350,8 @@ fn new_shape(s: &mut Session, p: &Value) -> Result { 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, &paint).ok_or_else(|| bad(c, "unknown kind"))?; + let mut g = shape_group(&mut Ids(&mut next), kind, size, &paint).ok_or_else(|| bad(c, "unknown kind"))?; + set_roundness(&mut g, round); proj.next_id = next; let group = add_to_contents(proj, cid, lid, g, centre, c)?; st.selected_layers = vec![lid]; @@ -359,7 +380,7 @@ pub fn specs() -> Vec { "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?, fillType?, fillBlend?, fillOpacity?, stroke?, strokeType?, strokeBlend?, strokeOpacity?, strokeWidth? (default the Tools bar's, shape.toolOptions)} → {layer, group}", + "{layer?, kind?: rect|rounded|ellipse|polygon|star, size? [w,h], position? [x,y] (the centre), space?: comp|layer, roundness? px (rect/rounded corners; rounded defaults to 15% of the shorter side), name? (a new layer's), fill?, fillType?, fillBlend?, fillOpacity?, stroke?, strokeType?, strokeBlend?, strokeOpacity?, strokeWidth? (default the Tools bar's, shape.toolOptions)} → {layer, group}", has_comp, new_shape ), diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index c6d622be..ebb4e9ce 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -922,6 +922,7 @@ Layer Create Shapes from Text | layer.create {"op":"shapesFromText"} Create Masks from Text | layer.create {"op":"masksFromText"} Create Shapes from Vector Layer | layer.create {"op":"shapesFromVector"} + Round Corners | layer.roundCorners Create Keyframes from Data | layer.create {"op":"keyframesFromData"} Null Controllers for Positional Points | layer.create {"op":"nullsPositional"} Null Controllers for Path Points | layer.create {"op":"nullsPathPoints"} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 71359f03..9fa1d5a5 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -605,6 +605,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[ ("layer.create", "Create Shapes from Text", "从文本创建形状"), ("layer.create", "Create Masks from Text", "从文本创建蒙版"), ("layer.create", "Create Shapes from Vector Layer", "从矢量图层创建形状"), + ("layer.roundCorners", "Round Corners", "圆角"), ("layer.create", "Create Keyframes from Data", "从数据创建关键帧"), ("layer.create", "Null Controllers for Positional Points", "位置点的空控制器"), ("layer.create", "Null Controllers for Path Points", "路径点的空控制器"), @@ -1230,6 +1231,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[ ("layer.create", "Create Shapes from Text", "從文字建立形狀"), ("layer.create", "Create Masks from Text", "從文字建立遮色片"), ("layer.create", "Create Shapes from Vector Layer", "從向量圖層建立形狀"), + ("layer.roundCorners", "Round Corners", "圓角"), ("layer.create", "Create Keyframes from Data", "從資料建立關鍵影格"), ("layer.create", "Null Controllers for Positional Points", "為位置點建立空物件控制器"), ("layer.create", "Null Controllers for Path Points", "為路徑點建立空物件控制器"), @@ -1852,6 +1854,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("layer.create", "Create Shapes from Text", "テキストからシェイプを作成"), ("layer.create", "Create Masks from Text", "テキストからマスクを作成"), ("layer.create", "Create Shapes from Vector Layer", "ベクトルレイヤーからシェイプを作成"), + ("layer.roundCorners", "Round Corners", "角を丸める"), ("layer.create", "Create Keyframes from Data", "データからキーフレームを作成"), ("layer.create", "Null Controllers for Positional Points", "位置のポイント用ヌルコントローラー"), ("layer.create", "Null Controllers for Path Points", "パスのポイント用ヌルコントローラー"), @@ -2477,6 +2480,7 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[ ("layer.create", "Create Shapes from Text", "Створити фігури з тексту"), ("layer.create", "Create Masks from Text", "Створити маски з тексту"), ("layer.create", "Create Shapes from Vector Layer", "Створити фігури з векторного шару"), + ("layer.roundCorners", "Round Corners", "Заокруглити кути"), ("layer.create", "Create Keyframes from Data", "Створити ключові кадри з даних"), ("layer.create", "Null Controllers for Positional Points", "Нульові контролери для точок положення"), ("layer.create", "Null Controllers for Path Points", "Нульові контролери для точок контуру"), diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 23a6c938..b94d2399 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -127,7 +127,11 @@ renames it), `viewer.comp`, `viewer.handle..`, Enter or a double-click renames, drag reorders), `timeline.prop..stopwatch`, `timeline.key..`, `timeline.cti`, `effectControls.prop..value` (an angle's revolutions are `timeline.prop..revolutions`, `effectControls.prop..revolutions` and -`properties.prop..revolutions`) and `effects.item.`. The Composition viewer adds `viewer.magnification`, `viewer.resolution`, +`properties.prop..revolutions`), the Properties panel's shape rows +`properties.shape.[.value[.0|.1]|.stopwatch|.prevKey|.keyKey|.nextKey]` for the first shape group and +`properties.shape..` for the n-th (`size`, `roundness`, `ellipseSize`, `points`, +`outerRadius`, `innerRadius`, `outerRoundness`, `innerRoundness`, `fillColor`, `fillOpacity`, +`strokeColor`, `strokeWidth`), a solid's `properties.solid.roundness`, and `effects.item.`. The Composition viewer adds `viewer.magnification`, `viewer.resolution`, `viewer.roi`, `viewer.grid`, `viewer.channel`, `viewer.exposure`, `viewer.snapshot`, `viewer.showSnapshot`, `viewer.fastPreviews` (their popup entries are `viewer.Item.`), `viewer.ruler.top|left|origin`, `viewer.mask..vertex.`, `viewer.shapePath..vertex.`, @@ -143,7 +147,13 @@ 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), -`shape.newShape` (the shape tools: a new group in the selected shape layer, else a new shape layer), +`shape.newShape` (the shape tools: a new group in the selected shape layer, else a new shape layer; +`roundness` px rounds a rect's corners), `layer.roundCorners {layer?|layers?, roundness?}` (Layer › +Create › Round Corners and the Properties panel's Roundness: sets the Roundness of a shape layer's +Rectangle Paths; a solid becomes, in one undo step, a shape layer drawing a rectangle of its size +and colour that keeps its id, name, timing, transform, switches, masks, styles and markers, and +renders the same at roundness 0; solids with effects, adjustment layers and other solids that +could not render the same are refused with the reason), `shape.toolOptions` (the Tools bar's Tool Creates Shape / Mask and Fill and Stroke Options), `mask.insertVertex`, `mask.convertVertex`, `mask.deleteVertices`, `path.freeTransform` (masks and shape paths by uid), `keys.setSpatialTangents` (motion-path handles) and `keys.transform` (Graph