Shapes: Tools bar Tool Creates Shape / Mask and Fill / Stroke Options (#227)

The Tools bar had Fill and Stroke swatches and a Stroke Width, kept in
the interface only, so agents couldn't set them and new shapes could
only be a solid fill with a solid stroke. As in After Effects:

- With a shape layer selected and a shape tool or the Pen active, Tool
  Creates Shape / Tool Creates Mask choose whether drawing adds shapes
  to the layer or masks on it (Fill and Stroke hide while drawing
  masks).
- Clicking the word "Fill" or "Stroke" opens Fill Options / Stroke
  Options: None, Solid Color, Linear Gradient or Radial Gradient, the
  blend mode and the opacity. The swatches show the kind; Alt-click
  steps through the kinds.
- New shapes and Pen paths are painted with them: a Gradient Fill or
  Gradient Stroke across the shape for the gradient kinds.

The options are engine state (EditorState.shape_tool) set with the new
shape.toolOptions command; shape.newShape and shape.newPath use them
unless given paint parameters (which now include fillType, fillBlend,
fillOpacity and their stroke twins; layer.newShape takes them too). A
Pen path without paint parameters now takes the Tools bar's stroke
(none by default) instead of a fixed 2 px white one. layer.addMask also
draws rounded rectangle, polygon and star masks, so every shape tool
makes a mask on a selected footage or solid layer (rounded rectangles,
polygons and stars made a new shape layer).

Test: shape_tool_options_paint_new_shapes,
masks_of_every_shape_tool_kind,
tool_creates_mask_draws_a_mask_on_the_selected_shape_layer,
fill_options_paint_the_next_shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-08 12:53:40 +07:00
parent 0048569682
commit 89e075d1b1
11 changed files with 637 additions and 169 deletions

View File

@ -1,7 +1,7 @@
//! Layer menu.
use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::{Justify, ShapePath, TextDoc, Value as KV};
use effectcraft_keyframe::{Justify, TextDoc, Value as KV};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{
Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Project, PropGroup, Quality, Solid, TrackMatte,
@ -205,11 +205,6 @@ fn new_text(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"layer": id.0}))
}
/// Contents for a new shape (one group with path + fill + stroke).
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)
}
fn new_shape(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
@ -219,8 +214,8 @@ fn new_shape(s: &mut Session, p: &Value) -> Result<Value> {
.and_then(Value::as_array)
.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)));
// 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 pos = p
.get("position")
.and_then(|v| v.as_array())
@ -229,7 +224,7 @@ fn new_shape(s: &mut Session, p: &Value) -> Result<Value> {
let id = s.edit("New Shape Layer", None, |proj, st| {
let lid = super::shape_tool::new_shape_layer(proj, st, &comp, cid, Some(&name), pos)?;
let mut next = proj.next_id;
let g = super::shape_tool::shape_group(&mut Ids(&mut next), &kind, size, fill, stroke);
let g = super::shape_tool::shape_group(&mut Ids(&mut next), &kind, size, &paint);
proj.next_id = next;
if let Some(g) = g {
super::shape_tool::add_to_contents(proj, cid, lid, g, [0.0, 0.0], "layer.newShape")?;
@ -974,10 +969,9 @@ fn add_mask(s: &mut Session, p: &Value) -> Result<Value> {
Some([x, y, rw, rh]) => (x + rw / 2.0, y + rh / 2.0, rw, rh),
None => (if w > 0.0 { w / 2.0 } else { 0.0 }, h / 2.0, w * 0.6, h * 0.6),
};
let path = match shape {
"ellipse" => ShapePath::ellipse([cx, cy], rw, rh),
_ => ShapePath::rect([cx, cy], rw, rh),
};
// The shape tools' masks: rectangle, ellipse, rounded rectangle, polygon or star.
let path = super::shape_tool::mask_path(shape, [cx, cy], [rw, rh])
.ok_or_else(|| bad("layer.addMask", format!("unknown shape `{shape}`; one of rect, ellipse, rounded, polygon, star")))?;
let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add);
let cycle = s.prefs.appearance.cycle_mask_colors;
let uid = s.edit("New Mask", None, |proj, _| {
@ -1292,7 +1286,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Layer",
["Layer", "New"],
None,
"{kind?: rect|rounded|ellipse|star|polygon|none, name?, size?, fill?, stroke?, strokeWidth?, position?}",
"{kind?: rect|rounded|ellipse|star|polygon|none, name?, size?, fill?, fillType?, fillBlend?, fillOpacity?, stroke?, strokeType?, strokeBlend?, strokeOpacity?, strokeWidth?, position?}",
has_comp,
new_shape
),
@ -1375,7 +1369,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Mask",
["Layer", "Mask"],
Some("Cmd+Shift+N"),
"{layer?, shape?: rect|ellipse, rect? [x,y,w,h], mode?}",
"{layer?, shape?: rect|ellipse|rounded|polygon|star, rect? [x,y,w,h] (layer space; a polygon or star fits its width), mode?}",
has_layers,
add_mask
),

View File

@ -1,41 +1,224 @@
//! 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.
//! The shape tools (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star) and the Pen's options
//! in the Tools bar (Tool Creates Shape / Tool Creates Mask, Fill and Stroke with their Fill
//! Options / Stroke Options), and drawing with them: 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_color::BlendMode;
use effectcraft_geom::vec2;
use effectcraft_keyframe::Value as KV;
use effectcraft_keyframe::{Gradient, ShapePath, Value as KV};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, LayerId, LayerSource, Project, PropGroup};
use serde::{Deserialize, Serialize};
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 super::layer::{color_p, insert_layer, place, position_p};
use super::{CommandSpec, always, b_p, bad, comp_id, 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> {
/// Fill Options / Stroke Options: what new shapes are painted with.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PaintKind {
None,
#[default]
Solid,
Linear,
Radial,
}
impl PaintKind {
pub const ALL: [PaintKind; 4] = [PaintKind::None, PaintKind::Solid, PaintKind::Linear, PaintKind::Radial];
/// The `fillType` / `strokeType` value.
pub fn id(self) -> &'static str {
match self {
PaintKind::None => "none",
PaintKind::Solid => "solid",
PaintKind::Linear => "linear",
PaintKind::Radial => "radial",
}
}
/// After Effects' name in Fill Options / Stroke Options.
pub fn label(self) -> &'static str {
match self {
PaintKind::None => "None",
PaintKind::Solid => "Solid Color",
PaintKind::Linear => "Linear Gradient",
PaintKind::Radial => "Radial Gradient",
}
}
/// An id or label, in any case and spacing (`radial`, `Radial Gradient`).
pub fn from_name(s: &str) -> Option<PaintKind> {
let k: String = s.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_ascii_lowercase();
PaintKind::ALL.into_iter().find(|m| m.id() == k || m.label().replace(' ', "").to_ascii_lowercase() == k)
}
}
/// The Fill or the Stroke of the Tools bar: its kind, colour (for Solid Color), blend mode and
/// opacity (%).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ToolPaint {
pub kind: PaintKind,
pub color: [f32; 3],
pub blend: BlendMode,
pub opacity: f64,
}
impl Default for ToolPaint {
fn default() -> Self {
ToolPaint { kind: PaintKind::Solid, color: [1.0, 1.0, 1.0], blend: BlendMode::Normal, opacity: 100.0 }
}
}
/// The shape tools' and the Pen's options: Tool Creates Mask (with a shape layer selected they
/// draw masks on it instead of shapes) and the Fill, Stroke and Stroke Width new shapes get.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ShapeTool {
pub creates_mask: bool,
pub fill: ToolPaint,
pub stroke: ToolPaint,
/// 0 draws no stroke.
pub stroke_width: f64,
}
impl Default for ShapeTool {
fn default() -> Self {
ShapeTool { creates_mask: false, fill: ToolPaint { color: [0.25, 0.55, 1.0], ..ToolPaint::default() }, stroke: ToolPaint::default(), stroke_width: 0.0 }
}
}
/// `p`'s paint keys over `base`: `fill` / `stroke` (a colour, which makes a None paint Solid
/// Color; `null` or `false` for None), `fillType` / `strokeType` (none|solid|linear|radial),
/// `fillBlend` / `strokeBlend` (a blend mode), `fillOpacity` / `strokeOpacity` (%) and
/// `strokeWidth` (0 draws no stroke).
pub(crate) fn paint_p(p: &Value, base: &ShapeTool, cmd: &str) -> Result<ShapeTool> {
let mut o = base.clone();
for (key, paint) in [("fill", &mut o.fill), ("stroke", &mut o.stroke)] {
match p.get(key) {
None => {}
Some(Value::Null | Value::Bool(false)) => paint.kind = PaintKind::None,
Some(_) => {
paint.color = color_p(p, key).ok_or_else(|| bad(cmd, format!("`{key}`: a colour [r, g, b] or #hex, or false for none")))?;
if paint.kind == PaintKind::None {
paint.kind = PaintKind::Solid;
}
}
}
if let Some(v) = p.get(format!("{key}Type").as_str()) {
paint.kind = v.as_str().and_then(PaintKind::from_name).ok_or_else(|| bad(cmd, format!("`{key}Type`: none|solid|linear|radial")))?;
}
if let Some(v) = p.get(format!("{key}Blend").as_str()) {
paint.blend = v.as_str().and_then(BlendMode::from_name).ok_or_else(|| bad(cmd, format!("`{key}Blend`: a blend mode name (normal, multiply…)")))?;
}
if let Some(v) = p.get(format!("{key}Opacity").as_str()) {
paint.opacity = v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}Opacity`: a percentage")))?.clamp(0.0, 100.0);
}
}
if let Some(v) = p.get("strokeWidth") {
o.stroke_width = v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, "`strokeWidth`: pixels"))?.max(0.0);
}
Ok(o)
}
/// A Fill or Stroke item with the paint's blend mode and opacity.
fn blended(mut g: PropGroup, paint: &ToolPaint) -> PropGroup {
let blend = BlendMode::ALL.iter().position(|m| *m == paint.blend).unwrap_or(0) as u32;
if let Some(pr) = g.get_mut("blend") {
pr.value = KV::Enum(blend);
}
if let Some(pr) = g.get_mut("opacity") {
pr.value = KV::Scalar(paint.opacity);
}
g
}
/// The Stroke and Fill a new shape gets from `paint`; gradients run across `bounds` (`[x, y, w,
/// h]` in the group's space): left to right, or out from the centre.
pub(crate) fn paint_items(ids: &mut Ids, paint: &ShapeTool, bounds: [f64; 4]) -> Vec<PropGroup> {
let [x, y, w, h] = bounds;
let mid = [x + w / 2.0, y + h / 2.0];
let ends = |radial: bool| if radial { (mid, [mid[0] + w.max(h).max(2.0) / 2.0, mid[1]]) } else { ([x, mid[1]], [x + w.max(1.0), mid[1]]) };
let mut items = vec![];
let (s, width) = (&paint.stroke, paint.stroke_width);
let stroke = match s.kind {
_ if width <= 0.0 => None,
PaintKind::None => None,
PaintKind::Solid => Some(build::shape_stroke(ids, rgba(s.color), width)),
k => {
let (a, b) = ends(k == PaintKind::Radial);
Some(build::shape_gradient_stroke(ids, k == PaintKind::Radial, a, b, Gradient::default(), width))
}
};
items.extend(stroke.map(|g| blended(g, s)));
let f = &paint.fill;
let fill = match f.kind {
PaintKind::None => None,
PaintKind::Solid => Some(build::shape_fill(ids, rgba(f.color))),
k => {
let (a, b) = ends(k == PaintKind::Radial);
Some(build::shape_gradient_fill(ids, k == PaintKind::Radial, a, b, Gradient::default()))
}
};
items.extend(fill.map(|g| blended(g, f)));
items
}
fn rgba(c: [f32; 3]) -> [f64; 4] {
[c[0] as f64, c[1] as f64, c[2] as f64, 1.0]
}
/// The Rounded Rectangle tool's corner roundness for `size`.
fn roundness(size: [f64; 2]) -> f64 {
size[0].min(size[1]) * 0.15
}
/// The Polygon and Star tools' Polystar for `size`: (star, points, outer radius, inner radius).
fn polystar_of(kind: &str, size: [f64; 2]) -> Option<(bool, f64, f64, f64)> {
match kind {
"star" => Some((true, 5.0, size[0] / 2.0, size[0] / 4.0)),
"polygon" => Some((false, 6.0, size[0] / 2.0, 0.0)),
_ => None,
}
}
/// 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> {
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"),
"rounded" | "roundedRect" => (build::shape_rect(ids, size, [0.0, 0.0], roundness(size)), "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,
k => {
let (star, points, outer, inner) = polystar_of(k, size)?;
(build::shape_star(ids, star, points, [0.0, 0.0], outer, inner), "Polystar 1")
}
};
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));
}
items.extend(paint_items(ids, paint, [-size[0] / 2.0, -size[1] / 2.0, size[0], size[1]]));
Some(build::shape_group(ids, gname, items))
}
/// The mask a shape tool draws: `kind` of `size` centred at `centre` (layer space).
pub(crate) fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option<ShapePath> {
let bez = match kind {
"rect" | "rectangle" => return Some(ShapePath::rect(centre, size[0], size[1])),
"ellipse" => return Some(ShapePath::ellipse(centre, size[0], size[1])),
"rounded" | "roundedRect" => effectcraft_path::rect(size, centre, roundness(size)),
k => {
let (star, points, outer, inner) = polystar_of(k, size)?;
effectcraft_path::polystar(star, points, centre, 0.0, inner, outer, 0.0, 0.0)
}
};
effectcraft_path::from_kurbo(&bez).into_iter().next()
}
/// 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>> {
@ -93,10 +276,11 @@ fn pair_p(p: &Value, k: &str) -> Option<[f64; 2]> {
}
/// 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"`.
/// at `position`, painted with the Tools bar's Fill and Stroke unless given. 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)?;
@ -110,8 +294,7 @@ fn new_shape(s: &mut Session, p: &Value) -> Result<Value> {
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 paint = paint_p(p, &s.state.shape_tool, 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.
@ -139,7 +322,7 @@ 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, fill, stroke).ok_or_else(|| bad(c, "unknown kind"))?;
let g = shape_group(&mut Ids(&mut next), kind, size, &paint).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];
@ -148,14 +331,38 @@ fn new_shape(s: &mut Session, p: &Value) -> Result<Value> {
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
)]
/// The Tools bar's options for the shape tools and the Pen: Tool Creates Shape / Tool Creates
/// Mask (`createsMask`) and the Fill and Stroke new shapes get (the keys of [`paint_p`]);
/// `reset` restores the defaults first. Reports the options.
fn tool_options(s: &mut Session, p: &Value) -> Result<Value> {
let base = if b_p(p, "reset") == Some(true) { ShapeTool::default() } else { s.state.shape_tool.clone() };
let mut o = paint_p(p, &base, "shape.toolOptions")?;
if let Some(m) = b_p(p, "createsMask") {
o.creates_mask = m;
}
s.state.shape_tool = o;
Ok(serde_json::to_value(&s.state.shape_tool).unwrap_or(Value::Null))
}
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?, fillType?, fillBlend?, fillOpacity?, stroke?, strokeType?, strokeBlend?, strokeOpacity?, strokeWidth? (default the Tools bar's, shape.toolOptions)} → {layer, group}",
has_comp,
new_shape
),
cmd!(
"shape.toolOptions",
"Shape Tool Options",
[],
None,
"{createsMask?: bool (Tool Creates Mask with a shape layer selected), fill?: [r,g,b]|#hex|false, fillType?: none|solid|linear|radial, fillBlend?: blend mode, fillOpacity? %, stroke?, strokeType?, strokeBlend?, strokeOpacity?, strokeWidth? px, reset?} → the options",
always,
tool_options
),
]
}

View File

@ -199,20 +199,10 @@ fn pts(v: Option<&Value>) -> Vec<[f64; 2]> {
v.and_then(Value::as_array).map(|a| a.iter().filter_map(|p| Some([p.get(0)?.as_f64()?, p.get(1)?.as_f64()?])).collect()).unwrap_or_default()
}
fn rgba(v: Option<&Value>, d: [f64; 4]) -> [f64; 4] {
match v {
Some(Value::Array(a)) => {
let g = |i: usize, d: f64| a.get(i).and_then(Value::as_f64).unwrap_or(d);
[g(0, d[0]), g(1, d[1]), g(2, d[2]), g(3, 1.0)]
}
Some(Value::String(h)) => effectcraft_color::Rgba::from_hex(h).map(|c| [c.r as f64, c.g as f64, c.b as f64, 1.0]).unwrap_or(d),
_ => d,
}
}
/// Pen tool on a shape layer: a new Shape group ("Shape n" with Path 1, Stroke 1 and Fill 1) on
/// the given / selected shape layer, or on a new shape layer when none is given. Vertices are
/// in the layer's space, or comp space (`space: "comp"`, the default for a new layer).
/// Pen tool on a shape layer: a new Shape group ("Shape n" with Path 1 and the Tools bar's
/// Stroke and Fill unless given) on the given / selected shape layer, or on a new shape layer
/// when none is given. Vertices are in the layer's space, or comp space (`space: "comp"`, the
/// default for a new layer).
fn new_path(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
@ -246,9 +236,11 @@ fn new_path(s: &mut Session, p: &Value) -> Result<Value> {
}
}
let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false), feather: Vec::new() };
let fill = (!matches!(p.get("fill"), Some(Value::Null) | Some(Value::Bool(false)))).then(|| rgba(p.get("fill"), [0.25, 0.55, 1.0, 1.0]));
let width = f_p(p, "strokeWidth").unwrap_or(2.0);
let stroke = (width > 0.0).then(|| rgba(p.get("stroke"), [1.0, 1.0, 1.0, 1.0]));
let paint = super::shape_tool::paint_p(p, &s.state.shape_tool, "shape.newPath")?;
// Gradients run across the path's points.
let (lo, hi) =
path.vertices.iter().fold(([f64::MAX; 2], [f64::MIN; 2]), |(lo, hi), v| ([lo[0].min(v[0]), lo[1].min(v[1])], [hi[0].max(v[0]), hi[1].max(v[1])]));
let bounds = [lo[0], lo[1], hi[0] - lo[0], hi[1] - lo[1]];
let name = str_p(p, "name").map(str::to_string);
let (lid, group, path_uid) = s.edit("Pen Tool", None, |proj, st| {
let lid = match target {
@ -260,12 +252,7 @@ fn new_path(s: &mut Session, p: &Value) -> Result<Value> {
let pg = build::shape_path(&mut ids, path);
let path_uid = pg.uid;
let mut items = vec![pg];
if let Some(c) = stroke {
items.push(build::shape_stroke(&mut ids, c, width));
}
if let Some(c) = fill {
items.push(build::shape_fill(&mut ids, c));
}
items.extend(super::shape_tool::paint_items(&mut ids, &paint, bounds));
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")?;
@ -500,7 +487,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Pen Tool (Shape Path)",
[],
None,
"{layer?, vertices: [[x,y]…], inTangents?, outTangents?, closed?, space?: comp|layer, fill?: [r,g,b]|#hex|false, stroke?, strokeWidth?, name?}",
"{layer?, vertices: [[x,y]…], inTangents?, outTangents?, closed?, space?: comp|layer, fill?: [r,g,b]|#hex|false, fillType?, fillBlend?, fillOpacity?, stroke?, strokeType?, strokeBlend?, strokeOpacity?, strokeWidth? (default the Tools bar's, shape.toolOptions), name?}",
shape_layer_or_none,
new_path
),

View File

@ -238,6 +238,9 @@ pub struct EditorState {
/// Puppet tool options for new meshes.
#[serde(default)]
pub puppet: commands::puppet::PuppetOptions,
/// The shape tools' and the Pen's options (Tool Creates Shape / Mask, Fill and Stroke).
#[serde(default)]
pub shape_tool: commands::shape_tool::ShapeTool,
/// View ▸ Switch View Layout: 1, 2 or 4 views side by side in the Composition viewer.
#[serde(default = "one_view")]
pub view_layout: u8,

View File

@ -223,7 +223,7 @@ fn shape_pen_builds_shape_group_and_renders() {
assert_eq!(img.get(200, 170), [0.0, 1.0, 0.0, 1.0]);
assert_eq!(img.get(50, 50)[3], 0.0);
// With the shape layer selected the Pen adds a second group (with a stroke) to it.
let r2 = s.execute("shape.newPath", json!({"vertices": [[0, 0], [10, 10]], "space": "layer"})).unwrap();
let r2 = s.execute("shape.newPath", json!({"vertices": [[0, 0], [10, 10]], "space": "layer", "strokeWidth": 2})).unwrap();
assert_eq!(r2["layer"].as_u64(), Some(lid));
let l = layer(&s, lid);
let names: Vec<String> = l.props.sub("contents").unwrap().groups().map(|g| g.name.clone()).collect();
@ -282,6 +282,69 @@ fn shape_tools_draw_into_the_selected_shape_layer() {
assert!(s.execute("shape.newShape", json!({"kind": "blob"})).is_err());
}
/// The Tools bar's Fill / Stroke Options (None, Solid Color, Linear and Radial Gradient, blend
/// mode, opacity) and Stroke Width paint new shapes and Pen paths; a command's own paint
/// parameters win (#227).
#[test]
fn shape_tool_options_paint_new_shapes() {
use crate::commands::shape_tool::ShapeTool;
let mut s = comp();
let o = s.execute("shape.toolOptions", json!({})).unwrap();
assert_eq!((o["createsMask"].clone(), o["fill"]["kind"].clone(), o["strokeWidth"].clone()), (json!(false), json!("solid"), json!(0.0)));
// A radial gradient fill at 50% in Multiply and a red 4 px stroke.
let o = s
.execute("shape.toolOptions", json!({"fillType": "Radial Gradient", "fillBlend": "multiply", "fillOpacity": 50, "stroke": [1, 0, 0], "strokeWidth": 4}))
.unwrap();
assert_eq!(o["fill"]["kind"], json!("radial"));
let r = s.execute("shape.newShape", json!({"kind": "ellipse", "size": [100, 60]})).unwrap();
let lid = r["layer"].as_u64().unwrap();
let items = |s: &Session, g: u64| layer(s, lid).props.find_group(g).unwrap().sub("contents").unwrap().clone();
let g = items(&s, r["group"].as_u64().unwrap());
assert_eq!(g.groups().map(|x| x.match_id.as_str()).collect::<Vec<_>>(), ["ellipse", "stroke", "gfill"]);
let at = |g: &effectcraft_project::PropGroup, item: &str, prop: &str| g.groups().find(|x| x.match_id == item).unwrap().get(prop).unwrap().value.clone();
let multiply = effectcraft_color::BlendMode::ALL.iter().position(|m| *m == effectcraft_color::BlendMode::Multiply).unwrap() as u32;
assert_eq!(at(&g, "gfill", "type"), KV::Enum(1));
assert_eq!(at(&g, "gfill", "blend"), KV::Enum(multiply));
assert_eq!(at(&g, "gfill", "opacity").as_f64(), 50.0);
assert_eq!(at(&g, "gfill", "end").components(), [50.0, 0.0], "out from the centre");
assert_eq!(at(&g, "stroke", "color").components(), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(at(&g, "stroke", "width").as_f64(), 4.0);
// No fill and a linear gradient stroke, across a Pen path's points.
s.execute("shape.toolOptions", json!({"fill": false, "strokeType": "linear"})).unwrap();
let r = s.execute("shape.newPath", json!({"layer": lid, "vertices": [[10, 20], [110, 40]], "space": "layer"})).unwrap();
let g = items(&s, r["group"].as_u64().unwrap());
assert_eq!(g.groups().map(|x| x.match_id.as_str()).collect::<Vec<_>>(), ["path", "gstroke"]);
assert_eq!((at(&g, "gstroke", "start").components(), at(&g, "gstroke", "end").components()), (vec![10.0, 30.0], vec![110.0, 30.0]));
// A command's own paint wins over the Tools bar's.
let r = s.execute("shape.newShape", json!({"kind": "rect", "fill": "#00ff00", "strokeWidth": 0})).unwrap();
let g = items(&s, r["group"].as_u64().unwrap());
assert_eq!(g.groups().map(|x| x.match_id.as_str()).collect::<Vec<_>>(), ["rect", "fill"]);
assert_eq!(at(&g, "fill", "color").components(), [0.0, 1.0, 0.0, 1.0]);
// Bad values are errors that leave the options as they were; reset restores the defaults.
let before = s.state.shape_tool.clone();
for bad in [json!({"fillType": "plaid"}), json!({"strokeBlend": "nope"}), json!({"fill": true}), json!({"strokeWidth": "wide"})] {
assert!(s.execute("shape.toolOptions", bad.clone()).is_err(), "{bad}");
}
assert_eq!(s.state.shape_tool, before);
s.execute("shape.toolOptions", json!({"reset": true, "createsMask": true})).unwrap();
assert_eq!(s.state.shape_tool, ShapeTool { creates_mask: true, ..ShapeTool::default() });
}
/// Tool Creates Mask: every shape tool draws its mask (#227).
#[test]
fn masks_of_every_shape_tool_kind() {
let mut s = comp();
let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
for (kind, n) in [("rect", 4), ("ellipse", 4), ("rounded", 8), ("polygon", 6), ("star", 10)] {
let m = s.execute("layer.addMask", json!({"layer": sol, "shape": kind, "rect": [100, 100, 80, 80]})).unwrap()["mask"].as_u64().unwrap();
let path = layer(&s, sol).props.find_group(m).unwrap().get("path").unwrap().value.as_path().unwrap().clone();
assert!(path.closed, "{kind}");
assert!(if kind == "rounded" { path.vertices.len() >= n } else { path.vertices.len() == n }, "{kind}: {}", path.vertices.len());
assert!(path.vertices.iter().all(|v| (100.0..=180.0).contains(&v[0]) && (100.0..=180.0).contains(&v[1])), "{kind}: inside the box");
}
assert!(s.execute("layer.addMask", json!({"layer": sol, "shape": "blob"})).is_err());
}
#[test]
fn vertex_add_delete_convert_on_shape_paths_and_masks_undo() {
let mut s = comp();

View File

@ -1,7 +1,7 @@
//! The Tools bar: home, the tool slots, tool options, snapping, workspaces and the community
//! buttons (Discord is always one click away).
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde_json::json;
use crate::icons::{self, Icon};
@ -9,6 +9,8 @@ use crate::state::Tool;
use crate::theme::Tokens;
use crate::widgets;
use crate::{Dialog, EffectcraftApp};
use effectcraft_color::BlendMode;
use effectcraft_engine::commands::shape_tool::{PaintKind, ToolPaint};
use effectcraft_engine::project::{LayerId, LayerSource};
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
@ -100,66 +102,35 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.session.state.selected_layers.iter().filter(|id| c.layer(**id).is_some_and(|l| matches!(l.source, LayerSource::Shape))).map(|id| id.0).collect()
})
.unwrap_or_default();
if let Some(l) = shape_layers.first().and_then(|id| app.session.active_comp()?.layer(LayerId(*id))) {
let (fill, stroke, width) = effectcraft_engine::commands::shape_stroke::first_paint(l, l.layer_time(app.session.time()));
let layer_paint = shape_layers
.first()
.and_then(|id| app.session.active_comp()?.layer(LayerId(*id)))
.map(|l| effectcraft_engine::commands::shape_stroke::first_paint(l, l.layer_time(app.session.time())));
if let Some((fill, stroke, width)) = layer_paint {
let rgb = |c: [f64; 4]| [c[0] as f32, c[1] as f32, c[2] as f32];
let tool = &mut app.session.state.shape_tool;
if let Some(c) = fill {
app.ui.fill_color = rgb(c);
tool.fill.color = rgb(c);
}
if let Some(c) = stroke {
app.ui.stroke_color = rgb(c);
tool.stroke.color = rgb(c);
}
app.ui.stroke_width = width.unwrap_or(0.0) as f32;
tool.stroke_width = width.unwrap_or(0.0);
}
// One undo step per picker session or width drag: end the merge once both are done.
let picking = ["tool-fill-pop", "tool-stroke-pop"].iter().any(|k| ui.data(|d| d.get_temp::<bool>(egui::Id::new(*k).with("open")).unwrap_or(false)));
let picking = ["fill", "stroke"].iter().any(|k| widgets::popup_is_open(ui, picker_id(k)));
if !picking && !ui.input(|i| i.pointer.any_down()) && app.session.history.merge_key.as_deref().is_some_and(|k| k.starts_with("tool-")) {
app.session.history.merge_key = None;
}
if app.ui.tool.is_shape() || app.ui.tool == Tool::Pen || !shape_layers.is_empty() {
let paint = |app: &mut EffectcraftApp, key: &str, v: serde_json::Value| {
if shape_layers.is_empty() {
return;
}
let merge = format!("tool-{key}");
if let Err(e) = app.session.execute("shape.fillStroke", serde_json::json!({"layers": shape_layers, key: v, "merge": merge})) {
app.ui.status = e.to_string();
}
};
p.text(pos2(x, cy), Align2::LEFT_CENTER, "Fill:", Tokens::ui(12.0), t.text_dim);
x += 28.0;
let fr = Rect::from_center_size(pos2(x + 10.0, cy), vec2(20.0, 16.0));
let c = app.ui.fill_color;
if widgets::swatch(ui, fr, [c[0], c[1], c[2], 1.0], egui::Id::new("tool-fill"), &t).clicked() {
widgets::open_popup(ui, egui::Id::new("tool-fill-pop"));
}
if color_popup(ui, egui::Id::new("tool-fill-pop"), fr.left_bottom(), &mut app.ui.fill_color) {
let c = app.ui.fill_color;
paint(app, "fill", serde_json::json!(c));
}
app.auto.add("header.fill", fr, "Fill Color");
x += 32.0;
p.text(pos2(x, cy), Align2::LEFT_CENTER, "Stroke:", Tokens::ui(12.0), t.text_dim);
x += 44.0;
let sr = Rect::from_center_size(pos2(x + 10.0, cy), vec2(20.0, 16.0));
let c = app.ui.stroke_color;
if widgets::swatch(ui, sr, [c[0], c[1], c[2], 1.0], egui::Id::new("tool-stroke"), &t).clicked() {
widgets::open_popup(ui, egui::Id::new("tool-stroke-pop"));
}
if color_popup(ui, egui::Id::new("tool-stroke-pop"), sr.left_bottom(), &mut app.ui.stroke_color) {
let c = app.ui.stroke_color;
paint(app, "stroke", serde_json::json!(c));
}
app.auto.add("header.stroke", sr, "Stroke Color");
x += 26.0;
let (r, v, _) =
widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new("tool-stroke-w"), app.ui.stroke_width as f64, 0.2, (0.0, 1000.0), 0, " px", &t);
if let Some(v) = v {
app.ui.stroke_width = v as f32;
paint(app, "strokeWidth", serde_json::json!(v));
}
app.auto.add("header.strokeWidth", r, "Stroke Width");
x = r.max.x + 14.0;
let drawing = app.ui.tool.is_shape() || app.ui.tool == Tool::Pen;
// Tool Creates Shape / Tool Creates Mask, with a shape layer selected (as in After Effects).
if drawing && !shape_layers.is_empty() {
x = creates_buttons(app, ui, x, cy);
}
// Fill and Stroke, hidden while the tools draw masks.
let masking = drawing && !shape_layers.is_empty() && app.session.state.shape_tool.creates_mask;
if (drawing || !shape_layers.is_empty()) && !masking {
x = fill_stroke_options(app, ui, &p, &shape_layers, x, cy);
}
if app.ui.tool.puppet_kind().is_some() {
x = puppet_options(app, ui, &p, x, cy);
@ -251,6 +222,204 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
/// Set the shape tools' options (`shape.toolOptions`).
fn tool_options(app: &mut EffectcraftApp, v: serde_json::Value) {
if let Err(e) = app.session.execute("shape.toolOptions", v) {
app.ui.status = e.to_string();
}
}
/// The colour picker of the Fill or Stroke swatch.
fn picker_id(key: &str) -> egui::Id {
egui::Id::new(("tool-pick", key))
}
/// Tool Creates Shape / Tool Creates Mask: with a shape layer selected, whether the shape tools
/// and the Pen draw shapes into it or masks on it. Returns the next x.
fn creates_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, mut x: f32, cy: f32) -> f32 {
let t = app.tokens;
for (mask, icon, tip, key) in [(false, Icon::ShapeLayer, "Tool Creates Shape", "createsShape"), (true, Icon::MaskVis, "Tool Creates Mask", "createsMask")] {
let r = Rect::from_min_size(pos2(x, cy - 13.0), vec2(26.0, 26.0));
let on = app.session.state.shape_tool.creates_mask == mask;
if widgets::icon_button(ui, r, icon, on, &t, egui::Id::new(("tool-creates", key))).on_hover_text(tip).clicked() {
tool_options(app, json!({"createsMask": mask}));
}
app.auto.add(&format!("header.{key}"), r, tip);
x += 28.0;
}
x + 10.0
}
/// Fill and Stroke (the words open Fill Options / Stroke Options), their swatches and the
/// Stroke Width: for new shapes, and for the selected shape layers. Returns the next x.
fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, shape_layers: &[u64], mut x: f32, cy: f32) -> f32 {
let t = app.tokens;
// The selected shape layers take the colours and the width too, one undo step per picker
// session or drag.
let paint = |app: &mut EffectcraftApp, key: &str, v: serde_json::Value| {
if shape_layers.is_empty() {
return;
}
let merge = format!("tool-{key}");
if let Err(e) = app.session.execute("shape.fillStroke", json!({"layers": shape_layers, key: v, "merge": merge})) {
app.ui.status = e.to_string();
}
};
for (key, title) in [("fill", "Fill"), ("stroke", "Stroke")] {
let tp = if key == "fill" { &app.session.state.shape_tool.fill } else { &app.session.state.shape_tool.stroke }.clone();
let options = format!("{title} Options");
let g = p.layout_no_wrap(format!("{title}:"), Tokens::ui(12.0), t.text_dim);
let wr = Rect::from_min_size(pos2(x, cy - g.size().y / 2.0), g.size());
let wresp = ui.interact(wr, egui::Id::new(("tool-paint-word", key)), Sense::click()).on_hover_text(&options);
let col = if wresp.hovered() { t.text } else { t.text_dim };
if wresp.hovered() {
p.line_segment([pos2(wr.min.x, wr.max.y), pos2(wr.max.x - 3.0, wr.max.y)], Stroke::new(1.0, col));
}
p.galley_with_override_text_color(wr.min, g, col);
app.auto.add(&format!("header.{key}Options"), wr, &options);
let opts = egui::Id::new(("tool-paint-options", key));
if wresp.clicked() {
widgets::open_popup(ui, opts);
}
// The swatch picks a Solid Color's colour and opens the options for the other kinds;
// Alt-click steps through the kinds (as in After Effects).
let sr = Rect::from_min_size(pos2(wr.max.x + 6.0, cy - 8.0), vec2(20.0, 16.0));
let sresp = ui.interact(sr, egui::Id::new(("tool-paint-swatch", key)), Sense::click()).on_hover_text(format!("{title}: {}", tp.kind.label()));
paint_swatch(p, sr, &tp, sresp.hovered(), &t);
app.auto.add(&format!("header.{key}"), sr, &format!("{title} Color"));
if sresp.clicked() {
if ui.input(|i| i.modifiers.alt) {
let at = PaintKind::ALL.iter().position(|k| *k == tp.kind).unwrap_or(0);
let next = PaintKind::ALL.get((at + 1) % PaintKind::ALL.len()).copied().unwrap_or_default();
let k = format!("{key}Type");
tool_options(app, json!({k: next.id()}));
} else if tp.kind == PaintKind::Solid {
widgets::open_popup(ui, picker_id(key));
} else {
widgets::open_popup(ui, opts);
}
}
let mut c = tp.color;
if color_popup(ui, picker_id(key), sr.left_bottom(), &mut c) {
tool_options(app, json!({key: c}));
paint(app, key, json!(c));
}
paint_options_popup(app, ui, key, &options, wr.left_bottom());
x = sr.max.x + 12.0;
}
let width = app.session.state.shape_tool.stroke_width;
let (r, v, _) = widgets::hot_number_at(ui, pos2(x - 6.0, cy - 9.0), egui::Id::new("tool-stroke-w"), width, 0.2, (0.0, 1000.0), 0, " px", &t);
if let Some(v) = v {
tool_options(app, json!({"strokeWidth": v}));
paint(app, "strokeWidth", json!(v));
}
app.auto.add("header.strokeWidth", r, "Stroke Width");
r.max.x + 14.0
}
/// Fill Options / Stroke Options: None, Solid Color, Linear Gradient or Radial Gradient, and the
/// blend mode and opacity new shapes get (After Effects' dialogs, applied as they change).
fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, key: &str, title: &str, pos: egui::Pos2) {
let id = egui::Id::new(("tool-paint-options", key));
if !widgets::popup_is_open(ui, id) {
return;
}
let t = app.tokens;
let tp = if key == "fill" { &app.session.state.shape_tool.fill } else { &app.session.state.shape_tool.stroke }.clone();
let blend_pop = id.with("blend");
let mut set = None;
let mut blend_rect = Rect::NOTHING;
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos + vec2(0.0, 4.0)).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
let (rect, _) = ui.allocate_exact_size(vec2(236.0, 112.0), Sense::hover());
let p = ui.painter().clone();
let row = |y: f32| rect.min + vec2(0.0, y);
p.text(row(8.0), Align2::LEFT_CENTER, title, Tokens::semibold(12.0), t.text);
for (i, k) in PaintKind::ALL.into_iter().enumerate() {
let r = Rect::from_min_size(row(22.0) + vec2(i as f32 * 32.0, 0.0), vec2(28.0, 22.0));
let resp = ui.interact(r, id.with(k.id()), Sense::click()).on_hover_text(k.label());
if k == tp.kind {
p.rect_stroke(r, 3.0, Stroke::new(1.5, t.accent), StrokeKind::Outside);
}
paint_swatch(&p, r.shrink(3.0), &ToolPaint { kind: k, ..tp.clone() }, resp.hovered(), &t);
app.auto.add(&format!("header.{key}Options.{}", k.id()), r, k.label());
if resp.clicked() {
let k_type = format!("{key}Type");
set = Some(json!({k_type: k.id()}));
}
}
p.text(row(33.0) + vec2(134.0, 0.0), Align2::LEFT_CENTER, tp.kind.label(), Tokens::ui(11.5), t.text_dim);
p.text(row(66.0), Align2::LEFT_CENTER, "Blend Mode:", Tokens::ui(12.0), t.text_dim);
blend_rect = Rect::from_min_size(row(56.0) + vec2(84.0, 0.0), vec2(150.0, 20.0));
if widgets::dropdown(ui, blend_rect, tp.blend.label(), &t, id.with("blend-dd")).clicked() {
widgets::open_popup(ui, blend_pop);
}
app.auto.add(&format!("header.{key}Options.blend"), blend_rect, "Blend Mode");
p.text(row(98.0), Align2::LEFT_CENTER, "Opacity:", Tokens::ui(12.0), t.text_dim);
let (or, v, _) = widgets::hot_number_at(ui, row(89.0) + vec2(84.0, 0.0), id.with("opacity"), tp.opacity, 0.5, (0.0, 100.0), 0, "%", &t);
app.auto.add(&format!("header.{key}Options.opacity"), or, "Opacity");
if let Some(v) = v {
let k_opacity = format!("{key}Opacity");
set = Some(json!({k_opacity: v}));
}
});
});
// The blend modes in the Layer ▸ Blending Mode menu's groups, over the options.
let modes: Vec<String> =
BlendMode::ALL.iter().flat_map(|m| std::iter::once(m.label().to_string()).chain(m.ends_group().then(|| "-".to_string()))).collect();
let current = modes.iter().position(|m| m == tp.blend.label());
if let Some(m) = widgets::popup_menu(ui, blend_pop, blend_rect.left_bottom(), &modes, current).and_then(|i| modes.get(i)) {
let k_blend = format!("{key}Blend");
set = Some(json!({k_blend: m}));
}
// Closes on a press outside or Escape, but stays open while its blend mode menu is.
if !widgets::popup_is_open(ui, blend_pop) && (widgets::pressed_outside(ui.ctx(), &area.response) || ui.input(|i| i.key_pressed(egui::Key::Escape))) {
ui.data_mut(|d| d.insert_temp(id.with("open"), false));
}
if let Some(v) = set {
tool_options(app, v);
}
}
/// sRGB components (0–1) as a colour.
fn srgb32(c: [f32; 3]) -> Color32 {
let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
Color32::from_rgb(b(c[0]), b(c[1]), b(c[2]))
}
/// A Fill or Stroke swatch: its colour, a gradient (white to black), or None's red slash.
fn paint_swatch(p: &egui::Painter, r: Rect, paint: &ToolPaint, hovered: bool, t: &Tokens) {
let clip = p.with_clip_rect(r);
match paint.kind {
PaintKind::None => {
clip.rect_filled(r, 2.0, Color32::WHITE);
clip.line_segment([r.left_bottom(), r.right_top()], Stroke::new(1.5, Color32::from_rgb(0xe0, 0x30, 0x30)));
}
PaintKind::Solid => {
clip.rect_filled(r, 2.0, srgb32(paint.color));
}
PaintKind::Linear => {
let mut m = egui::Mesh::default();
for (q, c) in
[(r.left_top(), Color32::WHITE), (r.right_top(), Color32::BLACK), (r.right_bottom(), Color32::BLACK), (r.left_bottom(), Color32::WHITE)]
{
m.colored_vertex(q, c);
}
m.add_triangle(0, 1, 2);
m.add_triangle(0, 2, 3);
clip.add(m);
}
PaintKind::Radial => {
let rad = r.size().length() / 2.0;
for i in 0..8 {
let f = 1.0 - i as f32 / 8.0;
clip.circle_filled(r.center(), rad * f, Color32::from_gray((255.0 * (1.0 - f)) as u8));
}
}
}
p.rect_stroke(r, 2.0, Stroke::new(1.0, if hovered { t.text } else { t.field_border }), StrokeKind::Inside);
}
/// Puppet tool options: Mesh: Show, Expansion, Density (for new meshes and the selected
/// layer's meshes). Returns the next x.
fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 {
@ -331,8 +500,7 @@ pub fn color_popup(ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f3
let mut changed = false;
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos + vec2(0.0, 4.0)).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
let mut col =
egui::Color32::from_rgb((c[0].clamp(0.0, 1.0) * 255.0) as u8, (c[1].clamp(0.0, 1.0) * 255.0) as u8, (c[2].clamp(0.0, 1.0) * 255.0) as u8);
let mut col = srgb32(*c);
if egui::color_picker::color_picker_color32(ui, &mut col, egui::color_picker::Alpha::Opaque) {
*c = [col.r() as f32 / 255.0, col.g() as f32 / 255.0, col.b() as f32 / 255.0];
changed = true;

View File

@ -1654,8 +1654,9 @@ fn draw_rigs(app: &mut EffectcraftApp, painter: &egui::Painter, map: &ViewerMap,
/// Pen tool press at `pos`: continue the path in progress (pressing its first vertex closes it),
/// add a vertex on a visible path's segment, or start a new path: a mask on the selected
/// footage/solid layer, a shape path (Shape n group with Fill and Stroke) on the selected shape
/// layer, or a new shape layer when nothing is selected. The placed point snaps.
/// footage/solid layer (or shape layer, with Tool Creates Mask), a shape path (Shape n group with
/// the Tools bar's Fill and Stroke) on the selected shape layer, or a new shape layer when
/// nothing is selected. The placed point snaps.
fn pen_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, map: &ViewerMap, paths: &[ov::PathInfo], pos: Pos2, mods: egui::Modifiers) {
use effectcraft_engine::project::LayerSource;
let gid = egui::Id::new("viewer-gesture");
@ -1702,7 +1703,7 @@ fn pen_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, map: &
.find(|id| ectx.comp.layer(*id).is_some_and(|l| !l.switches.locked && (l.masks().is_some() || matches!(l.source, LayerSource::Shape))));
let layer = target.and_then(|id| ectx.comp.layer(id));
let (lid, uid, inv) = match layer {
Some(l) if !matches!(l.source, LayerSource::Shape) => {
Some(l) if !matches!(l.source, LayerSource::Shape) || app.session.state.shape_tool.creates_mask => {
let Some(inv) = l2c(ectx, l).0.inverse() else { return };
let lp = inv.apply(gv2(c[0], c[1]));
let Ok(v) = app.session.execute("mask.new", json!({"layer": l.id.0, "vertices": [[lp.x, lp.y]]})) else { return };
@ -1712,23 +1713,13 @@ fn pen_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, map: &
Some(l) => {
let Some(inv) = l2c(ectx, l).0.inverse() else { return };
let lp = inv.apply(gv2(c[0], c[1]));
let fill = app.ui.fill_color;
let stroke = app.ui.stroke_color;
let r = app.session.execute(
"shape.newPath",
json!({"layer": l.id.0, "vertices": [[lp.x, lp.y]], "space": "layer", "fill": [fill[0], fill[1], fill[2]], "stroke": [stroke[0], stroke[1], stroke[2]], "strokeWidth": app.ui.stroke_width}),
);
let r = app.session.execute("shape.newPath", json!({"layer": l.id.0, "vertices": [[lp.x, lp.y]], "space": "layer"}));
let Some(path) = r.ok().and_then(|v| v["path"].as_u64()) else { return };
(l.id, path, inv)
}
None => {
// A new shape layer at the comp centre (unrotated: comp → layer is a translation).
let fill = app.ui.fill_color;
let stroke = app.ui.stroke_color;
let r = app.session.execute(
"shape.newPath",
json!({"vertices": [c], "space": "comp", "fill": [fill[0], fill[1], fill[2]], "stroke": [stroke[0], stroke[1], stroke[2]], "strokeWidth": app.ui.stroke_width}),
);
let r = app.session.execute("shape.newPath", json!({"vertices": [c], "space": "comp"}));
let Some(v) = r.ok() else { return };
let (Some(l), Some(path)) = (v["layer"].as_u64(), v["path"].as_u64()) else { return };
let inv = Mat3::translate(gv2(-(ectx.comp.width as f64) / 2.0, -(ectx.comp.height as f64) / 2.0));
@ -1799,13 +1790,14 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2],
Tool::Polygon => "polygon",
_ => "star",
};
// A non-shape layer selected and "creates mask": add a mask instead.
// A footage, solid or other non-shape layer selected, or a shape layer with Tool Creates
// Mask: a mask on it instead.
let creates_mask = app.session.state.shape_tool.creates_mask;
let sel = app.session.state.selected_layers.first().copied();
let sel_layer = sel.and_then(|id| app.session.active_comp().and_then(|c| c.layer(id)).cloned());
if let Some(l) = sel_layer
&& !matches!(l.source, effectcraft_engine::project::LayerSource::Shape)
&& l.source.is_av()
&& matches!(kind, "rect" | "ellipse")
&& (creates_mask || !matches!(l.source, effectcraft_engine::project::LayerSource::Shape))
{
// Mask in layer space: invert the layer transform.
let comp = app.session.active_comp_arc();
@ -1821,21 +1813,16 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2],
let inv = l2c(&ectx, &l).0.inverse().unwrap_or(Mat3::IDENTITY);
let p0 = inv.apply(gv2(cx - w / 2.0, cy - h / 2.0));
let p1 = inv.apply(gv2(cx + w / 2.0, cy + h / 2.0));
let _ = app.session.execute(
"layer.addMask",
json!({"layer": l.id.0, "shape": if kind == "ellipse" { "ellipse" } else { "rect" }, "rect": [p0.x.min(p1.x), p0.y.min(p1.y), (p1.x - p0.x).abs(), (p1.y - p0.y).abs()]}),
);
let rect = [p0.x.min(p1.x), p0.y.min(p1.y), (p1.x - p0.x).abs(), (p1.y - p0.y).abs()];
if let Err(e) = app.session.execute("layer.addMask", json!({"layer": l.id.0, "shape": kind, "rect": rect})) {
app.ui.status = e.to_string();
}
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;
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}),
) {
// shape layer; painted with the Tools bar's Fill and Stroke.
if let Err(e) = app.session.execute("shape.newShape", json!({"kind": kind, "size": [w, h], "position": [cx, cy]})) {
app.ui.status = e.to_string();
}
}

View File

@ -633,13 +633,7 @@ pub struct UiState {
/// Effects & Presets contents-menu view options.
#[serde(default)]
pub effects_view: EffectsView,
/// Shape tool options.
pub fill_color: [f32; 3],
pub stroke_color: [f32; 3],
pub stroke_width: f32,
pub snapping: bool,
/// Tool creates shape (true) or mask (false) when a layer is selected.
pub tool_creates_shape: bool,
pub start_screen: bool,
/// The Home screen shows its Learn tab (tutorials) instead of the recent projects.
#[serde(default)]
@ -714,11 +708,7 @@ impl Default for UiState {
effects_favorites: BTreeSet::new(),
effects_recent: Vec::new(),
effects_view: EffectsView::default(),
fill_color: [0.24, 0.55, 0.96],
stroke_color: [1.0, 1.0, 1.0],
stroke_width: 0.0,
snapping: true,
tool_creates_shape: true,
start_screen: false,
home_learn: false,
home_templates: false,

View File

@ -195,5 +195,5 @@ fn toolbar_stroke_edits_the_selected_shape_layer() {
let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone();
let width = layer.props.prop("contents/group/contents/stroke/width").map(|p| p.value.as_f64());
assert_eq!(width, Some(4.0), "a stroke was added with the toolbar's width");
assert_eq!(h.state().ui.stroke_width, 4.0);
assert_eq!(h.state().session.state.shape_tool.stroke_width, 4.0);
}

View File

@ -3,11 +3,13 @@
//! (egui_kittest, UI logic only).
use effectcraft_engine::Session;
use effectcraft_engine::commands::shape_tool::PaintKind;
use effectcraft_engine::project::LayerId;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::state::Tool;
use egui::{Event, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
fn app() -> EffectcraftApp {
@ -280,6 +282,72 @@ fn shape_tool_draws_into_the_selected_shape_layer() {
assert_ne!(comp.layers[0].id, LayerId(l));
}
/// With a shape layer selected and a shape tool active, the Tools bar's Tool Creates Mask makes
/// the tool draw a mask on the layer (and hides Fill and Stroke); Tool Creates Shape draws
/// shapes again (#227).
#[test]
fn tool_creates_mask_draws_a_mask_on_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::Star;
h.run_steps(2);
assert!(h.state().auto.find("header.fill").is_some());
let at = rect(&h, "header.createsMask").center();
click(&mut h, at);
assert!(h.state().session.state.shape_tool.creates_mask);
assert!(h.state().auto.find("header.fill").is_none(), "no Fill or Stroke for masks");
let (a, b) = (screen(&h, [100.0, 100.0]), screen(&h, [200.0, 200.0]));
drag(&mut h, a, b);
let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone();
let masks = layer.props.sub("masks").unwrap();
assert_eq!(masks.groups().count(), 1, "a mask");
assert_eq!(masks.groups().next().unwrap().get("path").unwrap().value.as_path().unwrap().vertices.len(), 10, "a star");
assert_eq!(layer.props.sub("contents").unwrap().groups().count(), 1, "no new shape");
let at = rect(&h, "header.createsShape").center();
click(&mut h, at);
assert!(!h.state().session.state.shape_tool.creates_mask);
}
/// Clicking the word "Fill" opens Fill Options: a radial gradient in Multiply at 40% paints the
/// next shape drawn (#227).
#[test]
fn fill_options_paint_the_next_shape() {
let mut h = harness();
h.state_mut().ui.tool = Tool::Ellipse;
h.run_steps(2);
let at = rect(&h, "header.fillOptions").center();
click(&mut h, at);
let at = rect(&h, "header.fillOptions.radial").center();
click(&mut h, at);
let at = rect(&h, "header.fillOptions.blend").center();
click(&mut h, at);
let at = h.get_by_label("Multiply").rect().center();
click(&mut h, at);
let at = rect(&h, "header.fillOptions.opacity").center();
click(&mut h, at);
h.input_mut().events.push(Event::Text("40".into()));
h.step();
h.input_mut().events.push(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
h.run_steps(2);
let fill = h.state().session.state.shape_tool.fill.clone();
assert_eq!((fill.kind, fill.blend, fill.opacity), (PaintKind::Radial, effectcraft_engine::color::BlendMode::Multiply, 40.0));
h.input_mut().events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() });
h.run_steps(3);
assert!(h.state().auto.find("header.fillOptions.radial").is_none(), "closed");
let (a, b) = (screen(&h, [100.0, 100.0]), screen(&h, [220.0, 160.0]));
drag(&mut h, a, b);
let layer = h.state().session.active_comp().unwrap().layers[0].clone();
let g = layer.props.sub("contents").unwrap().groups().next().unwrap().sub("contents").unwrap().clone();
assert_eq!(g.groups().map(|x| x.match_id.as_str()).collect::<Vec<_>>(), ["ellipse", "gfill"]);
let gfill = g.groups().find(|x| x.match_id == "gfill").unwrap();
let multiply = effectcraft_engine::color::BlendMode::ALL.iter().position(|m| *m == effectcraft_engine::color::BlendMode::Multiply).unwrap() as u32;
assert_eq!(gfill.get("type").unwrap().value, effectcraft_keyframe::Value::Enum(1));
assert_eq!(gfill.get("blend").unwrap().value, effectcraft_keyframe::Value::Enum(multiply));
assert_eq!(gfill.get("opacity").unwrap().value.as_f64(), 40.0);
}
#[test]
fn shape_pen_draws_a_closed_shape_layer() {
let mut h = harness();

View File

@ -114,6 +114,7 @@ Viewer state that agents drive headless too: `view.snapping`, `view.channel {cha
`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.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
Editor transform box, timeline Alt-drag scaling).