Files
effectcraft/crates/engine/src/commands/shape_tool.rs
T
bflatasticandClaude Opus 5.5 9968950b71 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>
2026-10-08 13:03:38 +07:00

162 lines
7.6 KiB
Rust

//! 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
)]
}