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:
parent
7e4a7cd863
commit
9968950b71
|
|
@ -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}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)]
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in New Issue