diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 21e83a0c..5e1b7495 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -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 { } 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 { 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 { 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 { 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 { 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 { |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), diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index 9eea8063..84f29901 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -301,6 +301,7 @@ fn copy(s: &mut Session, p: &Value) -> Result { 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(); diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index ca34760e..acd8e9c8 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -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 { 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 { - 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 { 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 { 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 { 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 { "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 { "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 ), diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 7c8e9613..bce740b6 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -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()); diff --git a/crates/engine/src/commands/prop_groups.rs b/crates/engine/src/commands/prop_groups.rs index 6ead8834..d070713a 100644 --- a/crates/engine/src/commands/prop_groups.rs +++ b/crates/engine/src/commands/prop_groups.rs @@ -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 { fn remove(s: &mut Session, p: &Value) -> Result { 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> { 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, 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 { + 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 { + 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 { + 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 = + 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::>()})) +} + +/// 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 { + 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::>()})) +} + fn duplicate(s: &mut Session, p: &Value) -> Result { let c = "prop.duplicateGroup"; let (cid, lid, uid) = group_ref(s, p, c, true)?; diff --git a/crates/engine/src/commands/shape_stroke.rs b/crates/engine/src/commands/shape_stroke.rs index 51ed0724..87fcd141 100644 --- a/crates/engine/src/commands/shape_stroke.rs +++ b/crates/engine/src/commands/shape_stroke.rs @@ -201,7 +201,7 @@ fn wave(s: &mut Session, p: &Value) -> Result { 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). diff --git a/crates/engine/src/commands/shape_tool.rs b/crates/engine/src/commands/shape_tool.rs new file mode 100644 index 00000000..d02e3fbe --- /dev/null +++ b/crates/engine/src/commands/shape_tool.rs @@ -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 { + let k: String = s.chars().filter(|c| c.is_alphanumeric()).collect::().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 { + 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 { + 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 { + 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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + ), + ] +} diff --git a/crates/engine/src/commands/viewer_cmds.rs b/crates/engine/src/commands/viewer_cmds.rs index cdb43adb..36e46e4a 100644 --- a/crates/engine/src/commands/viewer_cmds.rs +++ b/crates/engine/src/commands/viewer_cmds.rs @@ -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 { 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 { 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 { } } 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 { "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 ), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8c5b4816..36cd5ace 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -194,6 +194,10 @@ pub struct EditorState { /// selected layers. #[serde(skip)] pub effect_clipboard: Vec, + /// 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, /// Selected mask vertices (viewer Selection tool / pen). #[serde(default)] pub selected_vertices: Vec, @@ -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, diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 22b6cab4..c342f72f 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -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 { + 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 = 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); +} diff --git a/crates/engine/src/tests_viewer.rs b/crates/engine/src/tests_viewer.rs index 973324a9..b0277acb 100644 --- a/crates/engine/src/tests_viewer.rs +++ b/crates/engine/src/tests_viewer.rs @@ -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 = 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 = 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::>(), ["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::>(), ["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::>(), ["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(); diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 2f4c98b0..f7d6ae04 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -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::(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 = + 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; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6cb70b82..e181398e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 { diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 9894672b..d3773999 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -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) { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 398d724f..d7a7edcd 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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, diff --git a/crates/ui-egui/tests/ui_timeline_shapes.rs b/crates/ui-egui/tests/ui_timeline_shapes.rs index 27389247..811b55c7 100644 --- a/crates/ui-egui/tests/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/ui_timeline_shapes.rs @@ -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 { + 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); } diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 842c134f..da6b4929 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -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::>(), ["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(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ab95df9c..4027c228 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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). diff --git a/docs/gaps.md b/docs/gaps.md index 7379645d..f76816fb 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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.