mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-26 19:18:14 -06:00
Properties: layer.roundCorners command (solid → rounded shape layer, shape rect roundness), roundness on new shapes, Layer › Create › Round Corners, MCP test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> 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)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -216,6 +216,7 @@ fn new_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.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<Value> {
|
||||
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<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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<Solid, String> {
|
||||
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<f64>, 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<Value> {
|
||||
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<u64> = 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::<Vec<_>>(), "converted": converted}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
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<f64> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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<Option<f64>> {
|
||||
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<f64>) {
|
||||
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<PropGroup> {
|
||||
@@ -303,6 +322,7 @@ fn new_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
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<Value> {
|
||||
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<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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", "Нульові контролери для точок контуру"),
|
||||
|
||||
@@ -127,7 +127,11 @@ renames it), `viewer.comp`, `viewer.handle.<layer>.<i>`,
|
||||
Enter or a double-click renames, drag reorders), `timeline.prop.<uid>.stopwatch`,
|
||||
`timeline.key.<uid>.<frame>`, `timeline.cti`, `effectControls.prop.<uid>.value` (an angle's
|
||||
revolutions are `timeline.prop.<uid>.revolutions`, `effectControls.prop.<uid>.revolutions` and
|
||||
`properties.prop.<uid>.revolutions`) and `effects.item.<name>`. The Composition viewer adds `viewer.magnification`, `viewer.resolution`,
|
||||
`properties.prop.<uid>.revolutions`), the Properties panel's shape rows
|
||||
`properties.shape.<key>[.value[.0|.1]|.stopwatch|.prevKey|.keyKey|.nextKey]` for the first shape group and
|
||||
`properties.shape.<n>.<key>` 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.<name>`. 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.<menu>Item.<n>`),
|
||||
`viewer.ruler.top|left|origin`, `viewer.mask.<uid>.vertex.<i>`, `viewer.shapePath.<uid>.vertex.<i>`,
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user