Merge pull request #240 from storytold/fix/227-shape-contents

Shapes: draw into, copy, paste and duplicate shape contents (#227)
This commit is contained in:
BFlat authored and GitHub committed 2026-10-08 13:09:50 +07:00
commit 49af6472bc
19 files changed
+1180 -198

No files matched your search

+45 -5
View File
@@ -20,7 +20,12 @@ fn has_clip(s: &Session) -> std::result::Result<(), String> {
// Text editing pastes the system clipboard's text (passed as `text`) or copied text.
return Ok(());
}
if s.state.clipboard.is_empty() && s.state.key_clipboard.is_empty() && s.state.effect_clipboard.is_empty() && s.state.link_clipboard.is_none() {
if s.state.clipboard.is_empty()
&& s.state.key_clipboard.is_empty()
&& s.state.effect_clipboard.is_empty()
&& s.state.contents_clipboard.is_empty()
&& s.state.link_clipboard.is_none()
{
Err("the clipboard is empty".into())
} else {
Ok(())
@@ -104,6 +109,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
}
return Ok(json!({"effects": out}));
}
// Shape items selected → duplicate them inside their layers.
let items = super::prop_groups::selected_contents(s);
if !items.is_empty() {
return super::prop_groups::duplicate_contents(s, &items);
}
}
let (cid, ids) = layers_p(s, p)?;
let new = s.edit("Duplicate", None, |proj, st| {
@@ -190,6 +200,7 @@ fn clear_clipboards(s: &mut Session) {
s.state.clipboard.clear();
s.state.key_clipboard.clear();
s.state.effect_clipboard.clear();
s.state.contents_clipboard.clear();
s.state.link_clipboard = None;
s.state.clip_is_keys = false;
}
@@ -203,12 +214,22 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
s.state.link_clipboard = None;
s.state.effect_clipboard.clear();
s.state.contents_clipboard.clear();
return s.execute("keys.copy", json!({}));
}
// Effects selected (Effect Controls / timeline) → copy the effects.
if p.get("layers").is_none() && !super::effect::selected_effects(s).is_empty() {
return s.execute("effect.copy", json!({}));
}
// Shape items selected in a shape layer's Contents → copy them (not their layer).
if p.get("layers").is_none() {
let items = super::prop_groups::copy_contents(s);
if !items.is_empty() {
clear_clipboards(s);
s.state.contents_clipboard = items;
return Ok(json!({"contents": s.state.contents_clipboard.len()}));
}
}
let (cid, ids) = layers_p(s, p)?;
s.state.clip_is_keys = false;
let comp = s.project.comp(cid).ok_or(crate::EngineError::NoComp)?;
@@ -344,6 +365,14 @@ fn cut(s: &mut Session, p: &Value) -> Result<Value> {
s.execute("keys.copy", json!({}))?;
return s.execute("keys.delete", json!({}));
}
// Shape items selected → copied, then removed from their layers (Paste moves them).
if p.get("layers").is_none() && super::effect::selected_effects(s).is_empty() {
let items = super::prop_groups::selected_contents(s);
if !items.is_empty() {
copy(s, p)?;
return super::prop_groups::cut_contents(s, &items);
}
}
copy(s, p)?;
delete(s, p)
}
@@ -361,6 +390,9 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
if !s.state.effect_clipboard.is_empty() {
return s.execute("effect.paste", p.clone());
}
if !s.state.contents_clipboard.is_empty() {
return super::prop_groups::paste_contents(s, p);
}
let cid = super::comp_id(s, p)?;
let clip = s.state.clipboard.clone();
let new = s.edit("Paste", None, |proj, st| {
@@ -642,8 +674,8 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{layers?} (keyframes when keys are selected)", layers_or_keys, cut),
cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{layers?} (keyframes when keys are selected)", layers_or_keys, copy),
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{layers?} (keyframes, effects or shape items when they are selected)", layers_or_keys, cut),
cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{layers?} (keyframes, effects or shape items when they are selected)", layers_or_keys, copy),
cmd!(
"edit.copyWithPropertyLinks",
"Copy with Property Links",
@@ -663,10 +695,18 @@ pub fn specs() -> Vec<CommandSpec> {
|s, p| copy_links(s, p, true)
),
cmd!("edit.copyExpressionOnly", "Copy Expression Only", ["Edit"], None, "{}", has_props_or_layers, copy_expression_only),
cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{} (layers, keyframes at the CTI, or property links / expressions)", has_clip, paste),
cmd!(
"edit.paste",
"Paste",
["Edit"],
Some("Cmd+V"),
"{} (layers, keyframes at the CTI, effects, shape items into the selected shape layers, or property links / expressions)",
has_clip,
paste
),
cmd!("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", ["Edit"], None, "{layers?, prop?|path?, time?}", has_key_clip, paste_reversed),
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete),
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?}", has_layers, duplicate),
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?} (effects or shape items when they are selected)", has_layers, duplicate),
cmd!("edit.splitLayer", "Split Layer", ["Edit"], Some("Cmd+Shift+D"), "{layers?}", has_layers, split),
cmd!("edit.liftWorkArea", "Lift Work Area", ["Edit"], None, "{layers?}", has_comp, lift),
cmd!("edit.extractWorkArea", "Extract Work Area", ["Edit"], None, "{layers?}", has_comp, extract),
+1
View File
@@ -301,6 +301,7 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
fx.iter().filter_map(|(l, u)| comp.layer(*l).and_then(|l| l.effects()).and_then(|e| e.groups().find(|g| g.uid == *u)).cloned()).collect();
s.state.clipboard.clear();
s.state.key_clipboard.clear();
s.state.contents_clipboard.clear();
s.state.link_clipboard = None;
s.state.clip_is_keys = false;
let n = groups.len();
+18 -46
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,30 +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).
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] {
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();
@@ -236,27 +212,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)]);
// 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())
.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, &paint);
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}))
}
@@ -996,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, _| {
@@ -1314,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
),
@@ -1397,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
),
+2
View File
@@ -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());
+121 -6
View File
@@ -2,10 +2,10 @@
//! effect, mask, shape item or text animator (what scripting's `PropertyGroup.name`, `enabled`,
//! `remove()`, `duplicate()` and `moveTo()` do, and what agents need without a UI selection).
use effectcraft_project::{GroupKind, ItemId, LayerId, Node, PropGroup, Uid};
use effectcraft_project::{GroupKind, ItemId, LayerId, LayerSource, Node, PropGroup, Uid};
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layer_p, str_p};
use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layer_p, layers_p, str_p};
use crate::{EngineError, Result, Session, cmd};
/// Groups that can be renamed, removed, duplicated and reordered: instances in indexed lists.
@@ -52,13 +52,13 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result<Value> {
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = group_ref(s, p, "prop.removeGroup", true)?;
remove_groups(s, cid, &[(lid, uid)])?;
remove_groups(s, cid, &[(lid, uid)], "Delete")?;
Ok(Value::Null)
}
/// Remove groups of comp `cid` in one undo step.
fn remove_groups(s: &mut Session, cid: ItemId, groups: &[(LayerId, Uid)]) -> Result<()> {
s.edit("Delete", None, |proj, st| {
fn remove_groups(s: &mut Session, cid: ItemId, groups: &[(LayerId, Uid)], label: &str) -> Result<()> {
s.edit(label, None, |proj, st| {
for (lid, uid) in groups {
// A group inside one removed before it went with it.
if let Some(parent) = layer_mut(proj, cid, *lid)?.props.parent_of_mut(*uid) {
@@ -79,10 +79,125 @@ pub(crate) fn remove_selected(s: &mut Session) -> Result<Option<Value>> {
if groups.is_empty() {
return Ok(None);
}
remove_groups(s, cid, &groups)?;
remove_groups(s, cid, &groups, "Delete")?;
Ok(Some(json!(groups.len())))
}
/// Shape items (groups, paths, paints and path operations) selected in the Contents of the
/// active comp's shape layers, top to bottom. An item inside another selected one goes with it,
/// so it isn't listed.
pub(crate) fn selected_contents(s: &Session) -> Vec<(LayerId, Uid)> {
let mut out = vec![];
let Some(c) = s.active_comp().filter(|_| !s.state.selected_props.is_empty()) else { return out };
for l in c.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)) {
if let Some(contents) = l.props.sub("contents") {
let selected = |u: Uid| s.state.selected_props.contains(&(l.id, u));
let mut uids = vec![];
collect_selected(contents, &selected, &mut uids, 0);
out.extend(uids.into_iter().map(|u| (l.id, u)));
}
}
out
}
fn collect_selected(g: &PropGroup, selected: &dyn Fn(Uid) -> bool, out: &mut Vec<Uid>, depth: usize) {
if depth > super::shape_stroke::MAX_DEPTH {
return;
}
for c in g.groups() {
if is_instance(c) && selected(c.uid) {
out.push(c.uid);
} else {
collect_selected(c, selected, out, depth + 1);
}
}
}
/// Copies of the selected shape items (Edit ▸ Copy), top to bottom.
pub(crate) fn copy_contents(s: &Session) -> Vec<PropGroup> {
let Some(c) = s.active_comp() else { return vec![] };
selected_contents(s).into_iter().filter_map(|(lid, uid)| c.layer(lid)?.props.find_group(uid).cloned()).collect()
}
/// Edit ▸ Cut's removal of the selected shape items (once copied).
pub(crate) fn cut_contents(s: &mut Session, items: &[(LayerId, Uid)]) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
remove_groups(s, cid, items, "Cut")?;
Ok(json!({"contents": items.len()}))
}
/// Edit ▸ Paste with shape items on the clipboard: copies go into each selected shape layer's
/// Contents, above its topmost selected item (in that item's group), else on top. Names stay
/// unique in their group ("Rectangle 2"…), and the copies are selected.
pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result<Value> {
let c = "edit.paste";
let clip = s.state.contents_clipboard.clone();
let (cid, ids) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let targets: Vec<LayerId> =
ids.into_iter().filter(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape) && !l.switches.locked)).collect();
if targets.is_empty() {
return Err(bad(c, "select a shape layer to paste shape items into"));
}
let sel = selected_contents(s);
let label = match clip.as_slice() {
[g] => format!("Paste {}", g.name),
_ => "Paste".to_string(),
};
let pasted = s.edit(&label, None, |proj, st| {
let mut next = proj.next_id;
let mut out = vec![];
for lid in &targets {
let l = layer_mut(proj, cid, *lid)?;
// Above the layer's topmost selected item, in its group.
let spot = sel.iter().find(|(sl, _)| sl == lid).and_then(|(_, u)| {
let parent = l.props.parent_of(*u)?;
Some((parent.uid, parent.children.iter().position(|n| n.uid() == *u)?))
});
let (parent, at) = match spot {
Some((pu, i)) => (l.props.find_group_mut(pu), i),
None => (l.props.sub_mut("contents"), 0),
};
let parent = parent.ok_or_else(|| bad(c, "the layer has no contents"))?;
for (k, g) in clip.iter().enumerate() {
let mut g = g.clone();
g.reassign_uids(&mut next);
g.name = super::effect::unique_name(parent, &g.name);
out.push((*lid, g.uid));
parent.children.insert((at + k).min(parent.children.len()), g.into());
}
}
proj.next_id = next + 1;
st.selected_props = out.clone();
Ok(out)
})?;
Ok(json!({"contents": pasted.iter().map(|(_, u)| *u).collect::<Vec<_>>()}))
}
/// Edit ▸ Duplicate with shape items selected: each is copied in place, above the original and
/// named as After Effects does ("Rectangle 1" → "Rectangle 2"), and the copies are selected.
pub(crate) fn duplicate_contents(s: &mut Session, items: &[(LayerId, Uid)]) -> Result<Value> {
let c = "edit.duplicate";
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let copies = s.edit("Duplicate", None, |proj, st| {
let mut next = proj.next_id;
let mut out = vec![];
for (lid, uid) in items {
let parent = layer_mut(proj, cid, *lid)?.props.parent_of_mut(*uid).ok_or_else(|| bad(c, "the shape item is gone"))?;
let Some((i, Node::Group(g))) = parent.children.iter().enumerate().find(|(_, n)| n.uid() == *uid) else { continue };
let mut g = g.clone();
g.reassign_uids(&mut next);
g.name = super::effect::unique_name(parent, &g.name);
out.push((*lid, g.uid));
parent.children.insert(i, Node::Group(g));
}
proj.next_id = next + 1;
st.selected_props = out.clone();
Ok(out)
})?;
Ok(json!({"contents": copies.iter().map(|(_, u)| *u).collect::<Vec<_>>()}))
}
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let c = "prop.duplicateGroup";
let (cid, lid, uid) = group_ref(s, p, c, true)?;
+1 -1
View File
@@ -201,7 +201,7 @@ fn wave(s: &mut Session, p: &Value) -> Result<Value> {
const PATHS: [&str; 4] = ["rect", "ellipse", "star", "path"];
/// Shape groups nest; deeper contents are left alone (never-crash recursion bound).
const MAX_DEPTH: usize = 64;
pub(crate) const MAX_DEPTH: usize = 64;
/// The first Fill colour, Stroke colour and Stroke Width in `layer`'s contents at layer time
/// `lt` (what the toolbar shows for a selected shape layer).
+368
View File
@@ -0,0 +1,368 @@
//! 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::{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::{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"];
/// 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], roundness(size)), "Rectangle 1"),
"ellipse" => (build::shape_ellipse(ids, size, [0.0, 0.0]), "Ellipse 1"),
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];
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>> {
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`, 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)?;
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 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.
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, &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];
Ok((lid, group))
})?;
Ok(json!({"layer": lid.0, "group": group}))
}
/// 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
),
]
}
+15 -46
View File
@@ -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};
@@ -200,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();
@@ -226,15 +215,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).
@@ -255,38 +236,26 @@ 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 {
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;
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));
}
let g = build::shape_group(&mut ids, &format!("Shape {}", k + 1), items);
let group = g.uid;
contents.children.insert(0, g.into());
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")?;
st.selected_layers = vec![lid];
st.selected_vertices = vec![VertexRef { layer: lid, mask: path_uid, index: n - 1 }];
Ok((lid, group, path_uid))
@@ -518,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
),
+7
View File
@@ -194,6 +194,10 @@ pub struct EditorState {
/// selected layers.
#[serde(skip)]
pub effect_clipboard: Vec<effectcraft_project::PropGroup>,
/// Copied shape items (Edit ▸ Copy with groups, paths, paints or path operations selected in
/// a shape layer's Contents); Paste adds them to the selected shape layers.
#[serde(skip)]
pub contents_clipboard: Vec<effectcraft_project::PropGroup>,
/// Selected mask vertices (viewer Selection tool / pen).
#[serde(default)]
pub selected_vertices: Vec<VertexRef>,
@@ -234,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,
+91
View File
@@ -1055,3 +1055,94 @@ fn j_k_and_select_all_use_only_the_properties_the_timeline_shows() {
assert_eq!(s.state.selected_keys.len(), 1);
assert_eq!(s.state.selected_keys[0].prop, op);
}
/// A shape layer's Contents names, top to bottom.
fn contents_names(s: &Session, id: u64) -> Vec<String> {
layer(s, id).props.sub("contents").unwrap().groups().map(|g| g.name.clone()).collect()
}
/// The uid of the shape item `name` at the top level of a shape layer's Contents.
fn contents_item(s: &Session, id: u64, name: &str) -> u64 {
layer(s, id).props.sub("contents").unwrap().groups().find(|g| g.name == name).unwrap().uid
}
/// Edit ▸ Copy / Cut / Paste with shape items selected copy the items, not their layer: Paste
/// puts them into the selected shape layer, above its selected item or on top, and Cut + Paste
/// moves them (#227).
#[test]
fn shape_contents_copy_cut_and_paste_between_shape_layers() {
let mut s = comp();
let a = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap();
s.execute("shape.newShape", json!({"layer": a, "kind": "ellipse"})).unwrap();
let b = s.execute("layer.newShape", json!({"kind": "star", "name": "B"})).unwrap()["layer"].as_u64().unwrap();
let n = s.active_comp().unwrap().layers.len();
// Copy Rectangle 1 of A; paste into B (nothing of B's selected): on top of its Contents.
let rect = contents_item(&s, a, "Rectangle 1");
s.execute("prop.select", json!({"layer": a, "prop": rect})).unwrap();
assert_eq!(s.execute("edit.copy", json!({})).unwrap()["contents"], json!(1));
assert!(s.state.clipboard.is_empty(), "not the layer");
s.execute("layer.select", json!({"layers": [b]})).unwrap();
let r = s.execute("edit.paste", json!({})).unwrap();
assert_eq!(contents_names(&s, b), ["Rectangle 1", "Polystar 1"]);
assert_eq!(s.active_comp().unwrap().layers.len(), n, "no new layer");
let pasted = r["contents"][0].as_u64().unwrap();
assert_ne!(pasted, rect);
assert_eq!(s.state.selected_props, vec![(effectcraft_project::LayerId(b), pasted)]);
// Again, with the pasted item selected: above it, with a unique name.
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(contents_names(&s, b), ["Rectangle 2", "Rectangle 1", "Polystar 1"]);
s.undo();
assert_eq!(contents_names(&s, b), ["Rectangle 1", "Polystar 1"]);
assert_eq!(contents_names(&s, a), ["Ellipse 1", "Rectangle 1"], "the original stays");
// Cut Ellipse 1 from A and paste it above B's Polystar 1: it moves.
s.execute("prop.select", json!({"layer": a, "prop": contents_item(&s, a, "Ellipse 1")})).unwrap();
s.execute("edit.cut", json!({})).unwrap();
assert_eq!(contents_names(&s, a), ["Rectangle 1"]);
assert_eq!(s.active_comp().unwrap().layers.len(), n, "the layer stays");
s.execute("prop.select", json!({"layer": b, "prop": contents_item(&s, b, "Polystar 1")})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(contents_names(&s, b), ["Rectangle 1", "Ellipse 1", "Polystar 1"]);
s.undo();
s.undo();
assert_eq!(contents_names(&s, a), ["Ellipse 1", "Rectangle 1"]);
assert_eq!(contents_names(&s, b), ["Rectangle 1", "Polystar 1"]);
// Only shape layers take shape items.
let sol = solid(&mut s, "#ffffff");
s.execute("layer.select", json!({"layers": [sol]})).unwrap();
assert!(s.execute("edit.paste", json!({})).is_err());
}
/// Edit ▸ Duplicate with shape items selected duplicates them in their layer, above the
/// original and named "Rectangle 2", not the layer (#227).
#[test]
fn duplicate_with_shape_items_selected_duplicates_them_in_place() {
let mut s = comp();
let a = s.execute("layer.newShape", json!({"kind": "rect"})).unwrap()["layer"].as_u64().unwrap();
let n = s.active_comp().unwrap().layers.len();
let rect = contents_item(&s, a, "Rectangle 1");
s.execute("prop.select", json!({"layer": a, "prop": rect})).unwrap();
let r = s.execute("edit.duplicate", json!({})).unwrap();
assert_eq!(contents_names(&s, a), ["Rectangle 2", "Rectangle 1"]);
assert_eq!(s.active_comp().unwrap().layers.len(), n, "no new layer");
let copy = r["contents"][0].as_u64().unwrap();
assert_eq!(s.state.selected_props, vec![(effectcraft_project::LayerId(a), copy)]);
let l = layer(&s, a);
let path_uid = |g: u64| l.props.find_group(g).unwrap().sub("contents").unwrap().groups().next().unwrap().uid;
assert_ne!(path_uid(copy), path_uid(rect), "the copy has its own properties");
// Again, with the copy selected: Rectangle 3, above it.
s.execute("edit.duplicate", json!({})).unwrap();
assert_eq!(contents_names(&s, a), ["Rectangle 3", "Rectangle 2", "Rectangle 1"]);
s.undo();
s.undo();
assert_eq!(contents_names(&s, a), ["Rectangle 1"]);
// An item inside a group is duplicated in that group.
let fill = layer(&s, a).props.find_group(rect).unwrap().sub("contents").unwrap().groups().find(|g| g.match_id == "fill").unwrap().uid;
s.execute("prop.select", json!({"layer": a, "prop": fill})).unwrap();
s.execute("edit.duplicate", json!({})).unwrap();
let inner: Vec<String> = layer(&s, a).props.find_group(rect).unwrap().sub("contents").unwrap().groups().map(|g| g.name.clone()).collect();
assert_eq!(inner, ["Rectangle Path 1", "Fill 2", "Fill 1"]);
// With only the layer selected, the layer is duplicated as before.
s.execute("layer.select", json!({"layers": [a]})).unwrap();
s.execute("edit.duplicate", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), n + 1);
}
+108 -1
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();
@@ -238,6 +238,113 @@ 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());
}
/// 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();
+221 -53
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;
+2
View File
@@ -620,6 +620,8 @@ fn clipboard_note(s: &effectcraft_engine::Session) -> String {
n(st.key_clipboard.iter().map(|c| c.keys.len()).sum(), "keyframe", "keyframes")
} else if !st.effect_clipboard.is_empty() {
n(st.effect_clipboard.len(), "effect", "effects")
} else if !st.contents_clipboard.is_empty() {
n(st.contents_clipboard.len(), "shape item", "shape items")
} else if st.link_clipboard.is_some() {
"EffectCraft: property links".into()
} else {
+19 -28
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,19 +1813,18 @@ 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;
}
}
let fill = app.ui.fill_color;
let stroke = app.ui.stroke_color;
let _ = app.session.execute(
"layer.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}),
);
// Into the selected shape layer's Contents as a new group (as in After Effects), else a new
// 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();
}
}
fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
-10
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,
+47 -2
View File
@@ -1,6 +1,7 @@
//! Timeline rows for shape layers, masks and keyframing: the Contents "Add:" menu and the Classic
//! 3D "Change Renderer…" row (#206), Alt+Shift+P revealing the key it adds and clicks below the
//! layers deselecting them (#205), and Mask Feather's linked values (#203).
//! layers deselecting them (#205), Mask Feather's linked values (#203), and copying shape items
//! between shape layers (#227).
use effectcraft_engine::Session;
use effectcraft_engine::project::LayerId;
@@ -136,6 +137,50 @@ fn mask_feather_values_are_linked_and_not_negative() {
assert_eq!(feather(&h).1, vec![0.0, 7.0], "unlinked");
}
/// Two shape layers, "Rect" (Rectangle 1) and "Oval" (Ellipse 1, selected): (harness with Rect's
/// Contents open, Rect, Oval, Rectangle 1's uid).
fn two_shape_layers() -> (Harness<'static, EffectcraftApp>, u64, u64, u64) {
let mut s = session();
let a = s.execute("layer.newShape", json!({"kind": "rect", "name": "Rect"})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.newShape", json!({"kind": "ellipse", "name": "Oval"})).unwrap()["layer"].as_u64().unwrap();
let contents = s.active_comp().unwrap().layer(LayerId(a)).unwrap().props.sub("contents").unwrap().clone();
let rect = contents.groups().next().unwrap().uid;
let mut h = harness(s);
h.state_mut().ui.timeline.open_groups.insert(contents.uid);
open_layer(&mut h, a);
(h, a, b, rect)
}
fn contents_names(h: &Harness<'_, EffectcraftApp>, l: u64) -> Vec<String> {
h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.sub("contents").unwrap().groups().map(|g| g.name.clone()).collect()
}
#[test]
fn ctrl_c_and_ctrl_v_copy_a_shape_group_into_another_shape_layer() {
let (mut h, a, b, rect) = two_shape_layers();
click(&mut h, &format!("timeline.group.{rect}.name"));
assert_eq!(h.state().session.state.selected_props, vec![(LayerId(a), rect)]);
h.input_mut().events.push(Event::Copy);
h.step();
click(&mut h, &format!("timeline.layer.{b}.row"));
assert_eq!(h.state().session.state.selected_layers, vec![LayerId(b)]);
h.input_mut().events.push(Event::Paste("EffectCraft: 1 shape item".into()));
h.run_steps(3);
assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 2, "no new layer");
assert_eq!(contents_names(&h, b), ["Rectangle 1", "Ellipse 1"]);
assert_eq!(contents_names(&h, a), ["Rectangle 1"]);
}
#[test]
fn ctrl_d_duplicates_the_selected_shape_group_in_its_layer() {
let (mut h, a, _, rect) = two_shape_layers();
click(&mut h, &format!("timeline.group.{rect}.name"));
h.input_mut().events.push(Event::Key { key: Key::D, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND });
h.run_steps(3);
assert_eq!(h.state().session.active_comp().unwrap().layers.len(), 2, "no new layer");
assert_eq!(contents_names(&h, a), ["Rectangle 2", "Rectangle 1"]);
}
#[test]
fn toolbar_stroke_edits_the_selected_shape_layer() {
let mut s = session();
@@ -150,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);
}
+101
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 {
@@ -247,6 +249,105 @@ 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));
}
/// 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();
+2
View File
@@ -113,6 +113,8 @@ 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),
`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).
+11
View File
@@ -104,6 +104,17 @@ The most important missing piece: it turns every other estimate here into a meas
tool), `right_click_in_effect_controls_shows_the_effect_menu`,
`project_items_dropped_on_new_comp_make_a_composition` and
`dragging_over_layer_switches_sets_them_all`.
- Regression evidence for the shape layer items of
[#227](https://github.com/storytold/effectcraft/issues/227), checked headlessly on Windows:
`shape_tools_draw_into_the_selected_shape_layer` and `ui_viewer`
`shape_tool_draws_into_the_selected_shape_layer` (a shape tool adds a group to the selected
shape layer), `shape_contents_copy_cut_and_paste_between_shape_layers` and
`ctrl_c_and_ctrl_v_copy_a_shape_group_into_another_shape_layer` (Copy, Cut and Paste of shape
items), `duplicate_with_shape_items_selected_duplicates_them_in_place` and
`ctrl_d_duplicates_the_selected_shape_group_in_its_layer` (Duplicate), and
`shape_tool_options_paint_new_shapes`, `masks_of_every_shape_tool_kind`,
`tool_creates_mask_draws_a_mask_on_the_selected_shape_layer` and
`fill_options_paint_the_next_shape` (Tool Creates Shape / Mask, Fill and Stroke Options).
- Done when: no open bug blocks a basic workflow (import, arrange, animate, preview, render) on
any of the three desktop platforms, and every bug users report gets triaged within a day.