From 9968950b71789d6a5d6aafa6199328e189201c7f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 11:53:09 +0700 Subject: [PATCH 1/5] Shapes: drawing with a shape tool adds to the selected shape layer (#227) With a shape layer selected, the Rectangle, Rounded Rectangle, Ellipse, Polygon and Star tools made a new shape layer for every shape. After Effects draws into the selected shape layer instead: the shape becomes a new group on top of its Contents ("Rectangle 2", ...), placed by the group's Transform. The new shape.newShape command does that (in the layer's space, so a moved or scaled layer still gets the shape where it was drawn), and makes a new shape layer only when no unlocked shape layer is selected. The viewer's shape tools use it; masks on other selected layers are unchanged. layer.newShape and the Pen's shape.newPath share its layer and Contents helpers. Test: shape_tools_draw_into_the_selected_shape_layer, shape_tool_draws_into_the_selected_shape_layer. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/layer.rs | 44 ++---- crates/engine/src/commands/mod.rs | 2 + crates/engine/src/commands/shape_tool.rs | 161 ++++++++++++++++++++++ crates/engine/src/commands/viewer_cmds.rs | 26 +--- crates/engine/src/tests_viewer.rs | 44 ++++++ crates/ui-egui/src/panels/viewer.rs | 10 +- crates/ui-egui/tests/ui_viewer.rs | 33 +++++ docs/control-protocol.md | 1 + 8 files changed, 263 insertions(+), 58 deletions(-) create mode 100644 crates/engine/src/commands/shape_tool.rs diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index ca34760e..d63b5dbc 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -206,26 +206,7 @@ fn new_text(s: &mut Session, p: &Value) -> Result { } /// 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] { +pub(crate) fn c4(c: Option<[f32; 3]>, d: [f64; 4]) -> [f64; 4] { c.map(|c| [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]).unwrap_or(d) } @@ -236,27 +217,24 @@ fn new_shape(s: &mut Session, p: &Value) -> Result { let size = p .get("size") .and_then(Value::as_array) - .map(|a| [a[0].as_f64().unwrap_or(200.0), a.get(1).and_then(Value::as_f64).unwrap_or(200.0)]) + .map(|a| [a.first().and_then(Value::as_f64).unwrap_or(200.0), a.get(1).and_then(Value::as_f64).unwrap_or(200.0)]) .unwrap_or([300.0, 300.0]); let fill = Some(c4(color_p(p, "fill"), [0.25, 0.55, 1.0, 1.0])); let stroke = (f_p(p, "strokeWidth").unwrap_or(0.0) > 0.0).then(|| (c4(color_p(p, "stroke"), [1.0, 1.0, 1.0, 1.0]), f_p(p, "strokeWidth").unwrap_or(2.0))); - let pos = p.get("position").and_then(|v| v.as_array()).map(|a| [a[0].as_f64().unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]); + let pos = p + .get("position") + .and_then(|v| v.as_array()) + .map(|a| [a.first().and_then(Value::as_f64).unwrap_or(0.0), a.get(1).and_then(Value::as_f64).unwrap_or(0.0)]); let name = str_p(p, "name").unwrap_or("Shape Layer 1").to_string(); let id = s.edit("New Shape Layer", None, |proj, st| { - let mut l = build::layer(proj, &comp, &name, LayerSource::Shape, (comp.width, comp.height), None); + let lid = super::shape_tool::new_shape_layer(proj, st, &comp, cid, Some(&name), pos)?; let mut next = proj.next_id; - if let Some(g) = shape_contents(&mut Ids(&mut next), &kind, size, fill, stroke) - && let Some(c) = l.props.sub_mut("contents") - { - c.children.push(g.into()); - } + let g = super::shape_tool::shape_group(&mut Ids(&mut next), &kind, size, fill, stroke); proj.next_id = next; - if let Some(pos) = pos - && let Some(pr) = l.props.prop_mut("transform/position") - { - pr.value = KV::Vec3([pos[0], pos[1], 0.0]); + if let Some(g) = g { + super::shape_tool::add_to_contents(proj, cid, lid, g, [0.0, 0.0], "layer.newShape")?; } - insert_layer(proj, st, cid, l) + Ok(lid) })?; Ok(json!({"layer": id.0})) } 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/shape_tool.rs b/crates/engine/src/commands/shape_tool.rs new file mode 100644 index 00000000..5483d090 --- /dev/null +++ b/crates/engine/src/commands/shape_tool.rs @@ -0,0 +1,161 @@ +//! The shape tools (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star) as a command: a drawn +//! shape goes into the selected shape layer's Contents as a new group, as in After Effects, or +//! into a new shape layer. + +use effectcraft_geom::vec2; +use effectcraft_keyframe::Value as KV; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, ItemId, LayerId, LayerSource, Project, PropGroup}; +use serde_json::{Value, json}; + +use super::layer::{c4, color_p, insert_layer, place, position_p}; +use super::{CommandSpec, bad, comp_id, f_p, has_comp, layer_mut, str_p}; +use crate::{EditorState, EngineError, Result, Session, cmd}; + +/// The shape tools' kinds, as `shape.newShape` / `layer.newShape` name them. +const KINDS: [&str; 5] = ["rect", "rounded", "ellipse", "polygon", "star"]; + +/// A new shape group for a shape tool `kind` of `size`, centred on the group's origin, with a +/// Stroke and a Fill ("Rectangle 1", "Ellipse 1" or "Polystar 1"). None for another kind. +pub(crate) fn shape_group(ids: &mut Ids, kind: &str, size: [f64; 2], fill: Option<[f64; 4]>, stroke: Option<([f64; 4], f64)>) -> Option { + let (path, gname) = match kind { + "rect" | "rectangle" => (build::shape_rect(ids, size, [0.0, 0.0], 0.0), "Rectangle 1"), + "rounded" | "roundedRect" => (build::shape_rect(ids, size, [0.0, 0.0], size[0].min(size[1]) * 0.15), "Rectangle 1"), + "ellipse" => (build::shape_ellipse(ids, size, [0.0, 0.0]), "Ellipse 1"), + "star" => (build::shape_star(ids, true, 5.0, [0.0, 0.0], size[0] / 2.0, size[0] / 4.0), "Polystar 1"), + "polygon" => (build::shape_star(ids, false, 6.0, [0.0, 0.0], size[0] / 2.0, 0.0), "Polystar 1"), + _ => return None, + }; + let mut items = vec![path]; + if let Some((c, w)) = stroke { + items.push(build::shape_stroke(ids, c, w)); + } + if let Some(c) = fill { + items.push(build::shape_fill(ids, c)); + } + Some(build::shape_group(ids, gname, items)) +} + +/// The shape layer a shape tool or the Pen draws into: `layer` (which must be a shape layer), +/// else the first selected unlocked shape layer, else none (a new shape layer). +pub(crate) fn draw_target(s: &Session, comp: &Comp, p: &Value, cmd: &str) -> Result> { + 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`. 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 fill = Some(c4(color_p(p, "fill"), [0.25, 0.55, 1.0, 1.0])); + let stroke = (f_p(p, "strokeWidth").unwrap_or(0.0) > 0.0).then(|| (c4(color_p(p, "stroke"), [1.0, 1.0, 1.0, 1.0]), f_p(p, "strokeWidth").unwrap_or(2.0))); + let target = draw_target(s, &comp, p, c)?; + // In the target layer's space: the centre maps through the layer's transform, the size by + // its scale. + let (centre, size) = match target.and_then(|l| comp.layer(l)) { + Some(l) if str_p(p, "space") != Some("layer") => { + let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, s.time_of(cid)); + let inv = ctx.layer_to_comp(l).0.inverse().ok_or_else(|| bad(c, "the layer is scaled to nothing"))?; + let q = inv.apply(vec2(pos[0], pos[1])); + ([q.x, q.y], [inv.apply_vec(vec2(size[0], 0.0)).length(), inv.apply_vec(vec2(0.0, size[1])).length()]) + } + Some(_) => (pos, size), + None => ([0.0, 0.0], size), + }; + let label = match kind { + "rounded" => "Rounded Rectangle Tool", + "ellipse" => "Ellipse Tool", + "polygon" => "Polygon Tool", + "star" => "Star Tool", + _ => "Rectangle Tool", + }; + let name = str_p(p, "name"); + let (lid, group) = s.edit(label, None, |proj, st| { + let lid = match target { + Some(l) => l, + None => new_shape_layer(proj, st, &comp, cid, name, Some(pos))?, + }; + let mut next = proj.next_id; + let g = shape_group(&mut Ids(&mut next), kind, size, fill, stroke).ok_or_else(|| bad(c, "unknown kind"))?; + proj.next_id = next; + let group = add_to_contents(proj, cid, lid, g, centre, c)?; + st.selected_layers = vec![lid]; + Ok((lid, group)) + })?; + Ok(json!({"layer": lid.0, "group": group})) +} + +pub fn specs() -> Vec { + vec![cmd!( + "shape.newShape", + "Shape Tool", + [], + None, + "{layer?, kind?: rect|rounded|ellipse|polygon|star, size? [w,h], position? [x,y] (the centre), space?: comp|layer, name? (a new layer's), fill?, stroke?, strokeWidth?} → {layer, group}", + has_comp, + new_shape + )] +} diff --git a/crates/engine/src/commands/viewer_cmds.rs b/crates/engine/src/commands/viewer_cmds.rs index cdb43adb..619c515d 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}; @@ -226,15 +225,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). @@ -262,17 +253,9 @@ fn new_path(s: &mut Session, p: &Value) -> Result { let (lid, group, path_uid) = s.edit("Pen Tool", None, |proj, st| { let lid = match target { Some(l) => l, - None => { - let count = comp.layers.iter().filter(|l| matches!(l.source, LayerSource::Shape)).count(); - let lname = name.clone().unwrap_or_else(|| format!("Shape Layer {}", count + 1)); - let l = build::layer(proj, &comp, &lname, LayerSource::Shape, (comp.width, comp.height), None); - super::layer::insert_layer(proj, st, cid, l)? - } + None => super::shape_tool::new_shape_layer(proj, st, &comp, cid, name.as_deref(), None)?, }; let mut next = proj.next_id; - let l = super::layer_mut(proj, cid, lid)?; - let contents = l.props.sub_mut("contents").ok_or_else(|| bad("shape.newPath", "the layer has no contents"))?; - let k = contents.groups().filter(|g| g.match_id == "group").count(); let mut ids = Ids(&mut next); let pg = build::shape_path(&mut ids, path); let path_uid = pg.uid; @@ -283,10 +266,9 @@ fn new_path(s: &mut Session, p: &Value) -> Result { if let Some(c) = fill { items.push(build::shape_fill(&mut ids, c)); } - let g = build::shape_group(&mut ids, &format!("Shape {}", k + 1), items); - let group = g.uid; - contents.children.insert(0, g.into()); + let g = build::shape_group(&mut ids, "Shape 1", items); proj.next_id = next; + let group = super::shape_tool::add_to_contents(proj, cid, lid, g, [0.0, 0.0], "shape.newPath")?; st.selected_layers = vec![lid]; st.selected_vertices = vec![VertexRef { layer: lid, mask: path_uid, index: n - 1 }]; Ok((lid, group, path_uid)) diff --git a/crates/engine/src/tests_viewer.rs b/crates/engine/src/tests_viewer.rs index 973324a9..f2a3b393 100644 --- a/crates/engine/src/tests_viewer.rs +++ b/crates/engine/src/tests_viewer.rs @@ -238,6 +238,50 @@ fn shape_pen_builds_shape_group_and_renders() { assert!(s.execute("shape.newPath", json!({"layer": sol, "vertices": [[0, 0]]})).is_err()); } +/// The shape tools draw into the selected shape layer's Contents as a new group, as After +/// Effects does, and a new shape layer only with none selected (#227). +#[test] +fn shape_tools_draw_into_the_selected_shape_layer() { + let mut s = comp(); + // Nothing selected: a new shape layer centred on the shape. + let r = s.execute("shape.newShape", json!({"kind": "rect", "size": [100, 60], "position": [200, 100], "fill": [1, 0, 0]})).unwrap(); + let lid = r["layer"].as_u64().unwrap(); + let l = layer(&s, lid); + assert_eq!(l.name, "Shape Layer 1"); + assert_eq!(l.props.prop("transform/position").unwrap().value.components()[..2], [200.0, 100.0]); + // Selected (and scaled 200%): the next shape goes on top of its Contents, in layer space. + s.execute("prop.set", json!({"layer": lid, "path": "transform/scale", "value": [200, 200, 100]})).unwrap(); + let n = s.active_comp().unwrap().layers.len(); + let r = s.execute("shape.newShape", json!({"kind": "ellipse", "size": [40, 20], "position": [300, 160]})).unwrap(); + assert_eq!(r["layer"].as_u64(), Some(lid)); + assert_eq!(s.active_comp().unwrap().layers.len(), n, "no new layer"); + let l = layer(&s, lid); + let contents = l.props.sub("contents").unwrap(); + let names: Vec<&str> = contents.groups().map(|g| g.name.as_str()).collect(); + assert_eq!(names, ["Ellipse 1", "Rectangle 1"]); + let g = l.props.find_group(r["group"].as_u64().unwrap()).unwrap(); + assert_eq!(g.sub("transform").unwrap().get("position").unwrap().value.components(), [50.0, 30.0]); + assert_eq!(g.sub("contents").unwrap().groups().next().unwrap().get("size").unwrap().value.components(), [20.0, 10.0]); + // A second rectangle is "Rectangle 2"; it renders where it was drawn. + s.execute("shape.newShape", json!({"kind": "rect", "size": [40, 40], "position": [500, 300], "fill": [0, 1, 0]})).unwrap(); + let names: Vec = layer(&s, lid).props.sub("contents").unwrap().groups().map(|g| g.name.clone()).collect(); + assert_eq!(names, ["Rectangle 2", "Ellipse 1", "Rectangle 1"]); + let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default()); + assert_eq!(img.get(500, 300), [0.0, 1.0, 0.0, 1.0]); + // Each shape is one undo step. + s.undo(); + s.undo(); + assert_eq!(layer(&s, lid).props.sub("contents").unwrap().groups().count(), 1); + // A locked shape layer isn't drawn into; a solid is refused when named. + s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "locked", "value": true})).unwrap(); + s.state.selected_layers = vec![LayerId(lid)]; + let r = s.execute("shape.newShape", json!({"kind": "star"})).unwrap(); + assert_ne!(r["layer"].as_u64(), Some(lid)); + let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); + assert!(s.execute("shape.newShape", json!({"layer": sol})).is_err()); + assert!(s.execute("shape.newShape", json!({"kind": "blob"})).is_err()); +} + #[test] fn vertex_add_delete_convert_on_shape_paths_and_masks_undo() { let mut s = comp(); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 9894672b..2371950e 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1828,12 +1828,16 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2], return; } } + // Into the selected shape layer's Contents as a new group (as in After Effects), else a new + // shape layer. let fill = app.ui.fill_color; let stroke = app.ui.stroke_color; - let _ = app.session.execute( - "layer.newShape", + if let Err(e) = app.session.execute( + "shape.newShape", json!({"kind": kind, "size": [w, h], "position": [cx, cy], "fill": [fill[0], fill[1], fill[2]], "stroke": [stroke[0], stroke[1], stroke[2]], "strokeWidth": app.ui.stroke_width}), - ); + ) { + app.ui.status = e.to_string(); + } } fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 842c134f..4155878a 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -247,6 +247,39 @@ fn layer_drag_snaps_to_comp_centre_and_ctrl_disables() { assert!((p[0] - 320.0).abs() > 1.0, "{p:?}"); } +/// With a shape layer selected the Rectangle tool draws a new group into its Contents (After +/// Effects' behaviour); with nothing selected it draws a new shape layer (#227). +#[test] +fn shape_tool_draws_into_the_selected_shape_layer() { + let mut h = harness(); + let l = h.state_mut().session.execute("layer.newShape", json!({"kind": "rect", "size": [100, 100], "position": [320, 180]})).unwrap()["layer"] + .as_u64() + .unwrap(); + h.state_mut().ui.tool = Tool::Rectangle; + h.run_steps(2); + let n0 = h.state().session.active_comp().unwrap().layers.len(); + let (a, b) = (screen(&h, [100.0, 100.0]), screen(&h, [200.0, 160.0])); + drag(&mut h, a, b); + let comp = h.state().session.active_comp().unwrap().clone(); + assert_eq!(comp.layers.len(), n0, "no new layer"); + let contents = comp.layer(LayerId(l)).unwrap().props.sub("contents").unwrap().clone(); + let names: Vec<&str> = contents.groups().map(|g| g.name.as_str()).collect(); + assert_eq!(names, ["Rectangle 2", "Rectangle 1"]); + // Placed where it was drawn (centred near comp (150, 130)), in the layer's space: the layer + // sits at the comp centre. + let g = contents.groups().next().unwrap(); + let at = g.sub("transform").unwrap().get("position").unwrap().value.components(); + assert!((at[0] + 170.0).abs() < 8.0 && (at[1] + 50.0).abs() < 8.0, "{at:?}"); + // Nothing selected: a new shape layer. + h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap(); + let (a, b) = (screen(&h, [400.0, 250.0]), screen(&h, [500.0, 300.0])); + drag(&mut h, a, b); + let comp = h.state().session.active_comp().unwrap(); + assert_eq!(comp.layers.len(), n0 + 1); + assert!(matches!(comp.layers[0].source, effectcraft_engine::project::LayerSource::Shape)); + assert_ne!(comp.layers[0].id, LayerId(l)); +} + #[test] fn shape_pen_draws_a_closed_shape_layer() { let mut h = harness(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ab95df9c..5d56f59c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -113,6 +113,7 @@ Viewer state that agents drive headless too: `view.snapping`, `view.channel {cha `view.exposure {stops | delta}`, `view.resetExposure`, `view.takeSnapshot`, `view.showSnapshot`, `view.fastPreviewMode {mode}`, `view.setRegionOfInterest {rect}`, `view.addGuide`, `view.moveGuide`, `view.removeGuide`; editing: `shape.newPath` (Pen on shape layers), +`shape.newShape` (the shape tools: a new group in the selected shape layer, else a new shape layer), `mask.insertVertex`, `mask.convertVertex`, `mask.deleteVertices`, `path.freeTransform` (masks and shape paths by uid), `keys.setSpatialTangents` (motion-path handles) and `keys.transform` (Graph Editor transform box, timeline Alt-drag scaling). From 95bb346c895eaf600b52f66a27e291d88701a3b8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 12:11:54 +0700 Subject: [PATCH 2/5] Shapes: Copy, Cut and Paste shape items between shape layers (#227) Edit > Copy with a shape group (or a path, fill, stroke or path operation) selected in a shape layer's Contents copied the whole layer, and Paste made a new layer. As in After Effects, Copy now copies the selected items, Paste puts them into each selected shape layer's Contents (above its selected item, in that item's group, else on top, with names kept unique there: "Rectangle 2"), and Cut removes them, so Cut + Paste moves them. Pasting with no shape layer selected is an error rather than a new layer. Copying anything else clears the shape item clipboard, as it does the other clipboards. Test: shape_contents_copy_cut_and_paste_between_shape_layers, ctrl_c_and_ctrl_v_copy_a_shape_group_into_another_shape_layer. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/edit.rs | 43 ++++++++- crates/engine/src/commands/effect.rs | 1 + crates/engine/src/commands/prop_groups.rs | 103 +++++++++++++++++++-- crates/engine/src/commands/shape_stroke.rs | 2 +- crates/engine/src/lib.rs | 4 + crates/engine/src/tests_menu_cmds.rs | 56 +++++++++++ crates/ui-egui/src/menus.rs | 2 + crates/ui-egui/tests/ui_timeline_shapes.rs | 37 +++++++- 8 files changed, 236 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 21e83a0c..a15694ca 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(()) @@ -190,6 +195,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 +209,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 +360,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 +385,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 +669,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,7 +690,15 @@ 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), 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/prop_groups.rs b/crates/engine/src/commands/prop_groups.rs index 6ead8834..419a2b11 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,101 @@ 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::>()})) +} + 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/lib.rs b/crates/engine/src/lib.rs index 8c5b4816..361267a2 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, diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 22b6cab4..eb09ff25 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -1055,3 +1055,59 @@ 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()); +} 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/tests/ui_timeline_shapes.rs b/crates/ui-egui/tests/ui_timeline_shapes.rs index 27389247..8f11b027 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,40 @@ 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 toolbar_stroke_edits_the_selected_shape_layer() { let mut s = session(); From 0048569682bbaded2e3e533872a55ee7d500572e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 12:21:46 +0700 Subject: [PATCH 3/5] Shapes: Duplicate duplicates the selected shape items, not the layer (#227) Edit > Duplicate (Ctrl+D) with a shape group or other shape item selected in the Timeline duplicated the whole shape layer. As in After Effects it now duplicates the selected items inside their group, each copy above its original and numbered on ("Rectangle 1" gives "Rectangle 2"), and selects the copies. With only layers selected the layers are duplicated as before. Test: duplicate_with_shape_items_selected_duplicates_them_in_place, ctrl_d_duplicates_the_selected_shape_group_in_its_layer. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/edit.rs | 7 ++++- crates/engine/src/commands/prop_groups.rs | 24 +++++++++++++++ crates/engine/src/tests_menu_cmds.rs | 35 ++++++++++++++++++++++ crates/ui-egui/tests/ui_timeline_shapes.rs | 10 +++++++ 4 files changed, 75 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index a15694ca..5e1b7495 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -109,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| { @@ -701,7 +706,7 @@ pub fn specs() -> Vec { ), 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/prop_groups.rs b/crates/engine/src/commands/prop_groups.rs index 419a2b11..d070713a 100644 --- a/crates/engine/src/commands/prop_groups.rs +++ b/crates/engine/src/commands/prop_groups.rs @@ -174,6 +174,30 @@ pub(crate) fn paste_contents(s: &mut Session, p: &Value) -> Result { 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/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index eb09ff25..c342f72f 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -1111,3 +1111,38 @@ fn shape_contents_copy_cut_and_paste_between_shape_layers() { 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/ui-egui/tests/ui_timeline_shapes.rs b/crates/ui-egui/tests/ui_timeline_shapes.rs index 8f11b027..a37bddf3 100644 --- a/crates/ui-egui/tests/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/ui_timeline_shapes.rs @@ -171,6 +171,16 @@ fn ctrl_c_and_ctrl_v_copy_a_shape_group_into_another_shape_layer() { 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(); From 89e075d1b1294599c67596dc7073cdf991bd8200 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 12:53:40 +0700 Subject: [PATCH 4/5] Shapes: Tools bar Tool Creates Shape / Mask and Fill / Stroke Options (#227) The Tools bar had Fill and Stroke swatches and a Stroke Width, kept in the interface only, so agents couldn't set them and new shapes could only be a solid fill with a solid stroke. As in After Effects: - With a shape layer selected and a shape tool or the Pen active, Tool Creates Shape / Tool Creates Mask choose whether drawing adds shapes to the layer or masks on it (Fill and Stroke hide while drawing masks). - Clicking the word "Fill" or "Stroke" opens Fill Options / Stroke Options: None, Solid Color, Linear Gradient or Radial Gradient, the blend mode and the opacity. The swatches show the kind; Alt-click steps through the kinds. - New shapes and Pen paths are painted with them: a Gradient Fill or Gradient Stroke across the shape for the gradient kinds. The options are engine state (EditorState.shape_tool) set with the new shape.toolOptions command; shape.newShape and shape.newPath use them unless given paint parameters (which now include fillType, fillBlend, fillOpacity and their stroke twins; layer.newShape takes them too). A Pen path without paint parameters now takes the Tools bar's stroke (none by default) instead of a fixed 2 px white one. layer.addMask also draws rounded rectangle, polygon and star masks, so every shape tool makes a mask on a selected footage or solid layer (rounded rectangles, polygons and stars made a new shape layer). Test: shape_tool_options_paint_new_shapes, masks_of_every_shape_tool_kind, tool_creates_mask_draws_a_mask_on_the_selected_shape_layer, fill_options_paint_the_next_shape. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/layer.rs | 24 +- crates/engine/src/commands/shape_tool.rs | 279 ++++++++++++++++++--- crates/engine/src/commands/viewer_cmds.rs | 35 +-- crates/engine/src/lib.rs | 3 + crates/engine/src/tests_viewer.rs | 65 ++++- crates/ui-egui/src/header.rs | 274 ++++++++++++++++---- crates/ui-egui/src/panels/viewer.rs | 45 ++-- crates/ui-egui/src/state.rs | 10 - crates/ui-egui/tests/ui_timeline_shapes.rs | 2 +- crates/ui-egui/tests/ui_viewer.rs | 68 +++++ docs/control-protocol.md | 1 + 11 files changed, 637 insertions(+), 169 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index d63b5dbc..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,11 +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). -pub(crate) fn c4(c: Option<[f32; 3]>, d: [f64; 4]) -> [f64; 4] { - c.map(|c| [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]).unwrap_or(d) -} - fn new_shape(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); @@ -219,8 +214,8 @@ fn new_shape(s: &mut Session, p: &Value) -> Result { .and_then(Value::as_array) .map(|a| [a.first().and_then(Value::as_f64).unwrap_or(200.0), a.get(1).and_then(Value::as_f64).unwrap_or(200.0)]) .unwrap_or([300.0, 300.0]); - let fill = Some(c4(color_p(p, "fill"), [0.25, 0.55, 1.0, 1.0])); - let stroke = (f_p(p, "strokeWidth").unwrap_or(0.0) > 0.0).then(|| (c4(color_p(p, "stroke"), [1.0, 1.0, 1.0, 1.0]), f_p(p, "strokeWidth").unwrap_or(2.0))); + // A fill, and a stroke only with a width (not the Tools bar's paint). + let paint = super::shape_tool::paint_p(p, &Default::default(), "layer.newShape")?; let pos = p .get("position") .and_then(|v| v.as_array()) @@ -229,7 +224,7 @@ fn new_shape(s: &mut Session, p: &Value) -> Result { let id = s.edit("New Shape Layer", None, |proj, st| { let lid = super::shape_tool::new_shape_layer(proj, st, &comp, cid, Some(&name), pos)?; let mut next = proj.next_id; - let g = super::shape_tool::shape_group(&mut Ids(&mut next), &kind, size, fill, stroke); + let g = super::shape_tool::shape_group(&mut Ids(&mut next), &kind, size, &paint); proj.next_id = next; if let Some(g) = g { super::shape_tool::add_to_contents(proj, cid, lid, g, [0.0, 0.0], "layer.newShape")?; @@ -974,10 +969,9 @@ fn add_mask(s: &mut Session, p: &Value) -> Result { Some([x, y, rw, rh]) => (x + rw / 2.0, y + rh / 2.0, rw, rh), None => (if w > 0.0 { w / 2.0 } else { 0.0 }, h / 2.0, w * 0.6, h * 0.6), }; - let path = match shape { - "ellipse" => ShapePath::ellipse([cx, cy], rw, rh), - _ => ShapePath::rect([cx, cy], rw, rh), - }; + // The shape tools' masks: rectangle, ellipse, rounded rectangle, polygon or star. + let path = super::shape_tool::mask_path(shape, [cx, cy], [rw, rh]) + .ok_or_else(|| bad("layer.addMask", format!("unknown shape `{shape}`; one of rect, ellipse, rounded, polygon, star")))?; let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add); let cycle = s.prefs.appearance.cycle_mask_colors; let uid = s.edit("New Mask", None, |proj, _| { @@ -1292,7 +1286,7 @@ pub fn specs() -> Vec { "Shape Layer", ["Layer", "New"], None, - "{kind?: rect|rounded|ellipse|star|polygon|none, name?, size?, fill?, stroke?, strokeWidth?, position?}", + "{kind?: rect|rounded|ellipse|star|polygon|none, name?, size?, fill?, fillType?, fillBlend?, fillOpacity?, stroke?, strokeType?, strokeBlend?, strokeOpacity?, strokeWidth?, position?}", has_comp, new_shape ), @@ -1375,7 +1369,7 @@ pub fn specs() -> Vec { "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/shape_tool.rs b/crates/engine/src/commands/shape_tool.rs index 5483d090..d02e3fbe 100644 --- a/crates/engine/src/commands/shape_tool.rs +++ b/crates/engine/src/commands/shape_tool.rs @@ -1,41 +1,224 @@ -//! The shape tools (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star) as a command: a drawn -//! shape goes into the selected shape layer's Contents as a new group, as in After Effects, or -//! into a new shape layer. +//! The shape tools (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star) and the Pen's options +//! in the Tools bar (Tool Creates Shape / Tool Creates Mask, Fill and Stroke with their Fill +//! Options / Stroke Options), and drawing with them: a drawn shape goes into the selected shape +//! layer's Contents as a new group, as in After Effects, or into a new shape layer. +use effectcraft_color::BlendMode; use effectcraft_geom::vec2; -use effectcraft_keyframe::Value as KV; +use effectcraft_keyframe::{Gradient, ShapePath, Value as KV}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{Comp, ItemId, LayerId, LayerSource, Project, PropGroup}; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use super::layer::{c4, color_p, insert_layer, place, position_p}; -use super::{CommandSpec, bad, comp_id, f_p, has_comp, layer_mut, str_p}; +use super::layer::{color_p, insert_layer, place, position_p}; +use super::{CommandSpec, always, b_p, bad, comp_id, has_comp, layer_mut, str_p}; use crate::{EditorState, EngineError, Result, Session, cmd}; /// The shape tools' kinds, as `shape.newShape` / `layer.newShape` name them. const KINDS: [&str; 5] = ["rect", "rounded", "ellipse", "polygon", "star"]; -/// A new shape group for a shape tool `kind` of `size`, centred on the group's origin, with a -/// Stroke and a Fill ("Rectangle 1", "Ellipse 1" or "Polystar 1"). None for another kind. -pub(crate) fn shape_group(ids: &mut Ids, kind: &str, size: [f64; 2], fill: Option<[f64; 4]>, stroke: Option<([f64; 4], f64)>) -> Option { +/// 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], size[0].min(size[1]) * 0.15), "Rectangle 1"), + "rounded" | "roundedRect" => (build::shape_rect(ids, size, [0.0, 0.0], roundness(size)), "Rectangle 1"), "ellipse" => (build::shape_ellipse(ids, size, [0.0, 0.0]), "Ellipse 1"), - "star" => (build::shape_star(ids, true, 5.0, [0.0, 0.0], size[0] / 2.0, size[0] / 4.0), "Polystar 1"), - "polygon" => (build::shape_star(ids, false, 6.0, [0.0, 0.0], size[0] / 2.0, 0.0), "Polystar 1"), - _ => return None, + k => { + let (star, points, outer, inner) = polystar_of(k, size)?; + (build::shape_star(ids, star, points, [0.0, 0.0], outer, inner), "Polystar 1") + } }; let mut items = vec![path]; - if let Some((c, w)) = stroke { - items.push(build::shape_stroke(ids, c, w)); - } - if let Some(c) = fill { - items.push(build::shape_fill(ids, c)); - } + items.extend(paint_items(ids, paint, [-size[0] / 2.0, -size[1] / 2.0, size[0], size[1]])); Some(build::shape_group(ids, gname, items)) } +/// The mask a shape tool draws: `kind` of `size` centred at `centre` (layer space). +pub(crate) fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option { + 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> { @@ -93,10 +276,11 @@ fn pair_p(p: &Value, k: &str) -> Option<[f64; 2]> { } /// The shape tools: a rectangle, rounded rectangle, ellipse, polygon or star of `size`, centred -/// at `position`. With a shape layer given or selected it goes on top of that layer's Contents -/// as a new group ("Rectangle 2"…) placed by the group's Transform, as After Effects draws into -/// the selected shape layer; otherwise a new shape layer is centred on it. `position` and `size` -/// are in comp pixels, or the layer's space with `space: "layer"`. +/// at `position`, painted with the Tools bar's Fill and Stroke unless given. With a shape layer +/// given or selected it goes on top of that layer's Contents as a new group ("Rectangle 2"…) +/// placed by the group's Transform, as After Effects draws into the selected shape layer; +/// otherwise a new shape layer is centred on it. `position` and `size` are in comp pixels, or +/// the layer's space with `space: "layer"`. fn new_shape(s: &mut Session, p: &Value) -> Result { let c = "shape.newShape"; let cid = comp_id(s, p)?; @@ -110,8 +294,7 @@ fn new_shape(s: &mut Session, p: &Value) -> Result { Some(_) => pair_p(p, "size").filter(|s| s.iter().all(|v| *v >= 0.0)).ok_or_else(|| bad(c, "size: [w, h], not negative"))?, }; let pos = position_p(p, c)?.unwrap_or([comp.width as f64 / 2.0, comp.height as f64 / 2.0]); - let fill = Some(c4(color_p(p, "fill"), [0.25, 0.55, 1.0, 1.0])); - let stroke = (f_p(p, "strokeWidth").unwrap_or(0.0) > 0.0).then(|| (c4(color_p(p, "stroke"), [1.0, 1.0, 1.0, 1.0]), f_p(p, "strokeWidth").unwrap_or(2.0))); + let paint = paint_p(p, &s.state.shape_tool, c)?; let target = draw_target(s, &comp, p, c)?; // In the target layer's space: the centre maps through the layer's transform, the size by // its scale. @@ -139,7 +322,7 @@ fn new_shape(s: &mut Session, p: &Value) -> Result { None => new_shape_layer(proj, st, &comp, cid, name, Some(pos))?, }; let mut next = proj.next_id; - let g = shape_group(&mut Ids(&mut next), kind, size, fill, stroke).ok_or_else(|| bad(c, "unknown kind"))?; + let g = shape_group(&mut Ids(&mut next), kind, size, &paint).ok_or_else(|| bad(c, "unknown kind"))?; proj.next_id = next; let group = add_to_contents(proj, cid, lid, g, centre, c)?; st.selected_layers = vec![lid]; @@ -148,14 +331,38 @@ fn new_shape(s: &mut Session, p: &Value) -> Result { Ok(json!({"layer": lid.0, "group": group})) } -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?, stroke?, strokeWidth?} → {layer, group}", - has_comp, - new_shape - )] +/// The Tools bar's options for the shape tools and the Pen: Tool Creates Shape / Tool Creates +/// Mask (`createsMask`) and the Fill and Stroke new shapes get (the keys of [`paint_p`]); +/// `reset` restores the defaults first. Reports the options. +fn tool_options(s: &mut Session, p: &Value) -> Result { + 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 619c515d..36e46e4a 100644 --- a/crates/engine/src/commands/viewer_cmds.rs +++ b/crates/engine/src/commands/viewer_cmds.rs @@ -199,20 +199,10 @@ fn pts(v: Option<&Value>) -> Vec<[f64; 2]> { v.and_then(Value::as_array).map(|a| a.iter().filter_map(|p| Some([p.get(0)?.as_f64()?, p.get(1)?.as_f64()?])).collect()).unwrap_or_default() } -fn rgba(v: Option<&Value>, d: [f64; 4]) -> [f64; 4] { - match v { - Some(Value::Array(a)) => { - let g = |i: usize, d: f64| a.get(i).and_then(Value::as_f64).unwrap_or(d); - [g(0, d[0]), g(1, d[1]), g(2, d[2]), g(3, 1.0)] - } - Some(Value::String(h)) => effectcraft_color::Rgba::from_hex(h).map(|c| [c.r as f64, c.g as f64, c.b as f64, 1.0]).unwrap_or(d), - _ => d, - } -} - -/// Pen tool on a shape layer: a new Shape group ("Shape n" with Path 1, Stroke 1 and Fill 1) on -/// the given / selected shape layer, or on a new shape layer when none is given. Vertices are -/// in the layer's space, or comp space (`space: "comp"`, the default for a new layer). +/// Pen tool on a shape layer: a new Shape group ("Shape n" with Path 1 and the Tools bar's +/// Stroke and Fill unless given) on the given / selected shape layer, or on a new shape layer +/// when none is given. Vertices are in the layer's space, or comp space (`space: "comp"`, the +/// default for a new layer). fn new_path(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); @@ -246,9 +236,11 @@ fn new_path(s: &mut Session, p: &Value) -> Result { } } let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false), feather: Vec::new() }; - let fill = (!matches!(p.get("fill"), Some(Value::Null) | Some(Value::Bool(false)))).then(|| rgba(p.get("fill"), [0.25, 0.55, 1.0, 1.0])); - let width = f_p(p, "strokeWidth").unwrap_or(2.0); - let stroke = (width > 0.0).then(|| rgba(p.get("stroke"), [1.0, 1.0, 1.0, 1.0])); + let paint = super::shape_tool::paint_p(p, &s.state.shape_tool, "shape.newPath")?; + // Gradients run across the path's points. + let (lo, hi) = + path.vertices.iter().fold(([f64::MAX; 2], [f64::MIN; 2]), |(lo, hi), v| ([lo[0].min(v[0]), lo[1].min(v[1])], [hi[0].max(v[0]), hi[1].max(v[1])])); + let bounds = [lo[0], lo[1], hi[0] - lo[0], hi[1] - lo[1]]; let name = str_p(p, "name").map(str::to_string); let (lid, group, path_uid) = s.edit("Pen Tool", None, |proj, st| { let lid = match target { @@ -260,12 +252,7 @@ fn new_path(s: &mut Session, p: &Value) -> Result { let pg = build::shape_path(&mut ids, path); let path_uid = pg.uid; let mut items = vec![pg]; - if let Some(c) = stroke { - items.push(build::shape_stroke(&mut ids, c, width)); - } - if let Some(c) = fill { - items.push(build::shape_fill(&mut ids, c)); - } + items.extend(super::shape_tool::paint_items(&mut ids, &paint, bounds)); let g = build::shape_group(&mut ids, "Shape 1", items); proj.next_id = next; let group = super::shape_tool::add_to_contents(proj, cid, lid, g, [0.0, 0.0], "shape.newPath")?; @@ -500,7 +487,7 @@ pub fn specs() -> Vec { "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 361267a2..36cd5ace 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -238,6 +238,9 @@ pub struct EditorState { /// Puppet tool options for new meshes. #[serde(default)] pub puppet: commands::puppet::PuppetOptions, + /// The shape tools' and the Pen's options (Tool Creates Shape / Mask, Fill and Stroke). + #[serde(default)] + pub shape_tool: commands::shape_tool::ShapeTool, /// View ▸ Switch View Layout: 1, 2 or 4 views side by side in the Composition viewer. #[serde(default = "one_view")] pub view_layout: u8, diff --git a/crates/engine/src/tests_viewer.rs b/crates/engine/src/tests_viewer.rs index f2a3b393..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(); @@ -282,6 +282,69 @@ fn shape_tools_draw_into_the_selected_shape_layer() { assert!(s.execute("shape.newShape", json!({"kind": "blob"})).is_err()); } +/// The Tools bar's Fill / Stroke Options (None, Solid Color, Linear and Radial Gradient, blend +/// mode, opacity) and Stroke Width paint new shapes and Pen paths; a command's own paint +/// parameters win (#227). +#[test] +fn shape_tool_options_paint_new_shapes() { + use crate::commands::shape_tool::ShapeTool; + let mut s = comp(); + let o = s.execute("shape.toolOptions", json!({})).unwrap(); + assert_eq!((o["createsMask"].clone(), o["fill"]["kind"].clone(), o["strokeWidth"].clone()), (json!(false), json!("solid"), json!(0.0))); + // A radial gradient fill at 50% in Multiply and a red 4 px stroke. + let o = s + .execute("shape.toolOptions", json!({"fillType": "Radial Gradient", "fillBlend": "multiply", "fillOpacity": 50, "stroke": [1, 0, 0], "strokeWidth": 4})) + .unwrap(); + assert_eq!(o["fill"]["kind"], json!("radial")); + let r = s.execute("shape.newShape", json!({"kind": "ellipse", "size": [100, 60]})).unwrap(); + let lid = r["layer"].as_u64().unwrap(); + let items = |s: &Session, g: u64| layer(s, lid).props.find_group(g).unwrap().sub("contents").unwrap().clone(); + let g = items(&s, r["group"].as_u64().unwrap()); + assert_eq!(g.groups().map(|x| x.match_id.as_str()).collect::>(), ["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/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 2371950e..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,21 +1813,16 @@ fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: [f64; 2], let inv = l2c(&ectx, &l).0.inverse().unwrap_or(Mat3::IDENTITY); let p0 = inv.apply(gv2(cx - w / 2.0, cy - h / 2.0)); let p1 = inv.apply(gv2(cx + w / 2.0, cy + h / 2.0)); - let _ = app.session.execute( - "layer.addMask", - json!({"layer": l.id.0, "shape": if kind == "ellipse" { "ellipse" } else { "rect" }, "rect": [p0.x.min(p1.x), p0.y.min(p1.y), (p1.x - p0.x).abs(), (p1.y - p0.y).abs()]}), - ); + let rect = [p0.x.min(p1.x), p0.y.min(p1.y), (p1.x - p0.x).abs(), (p1.y - p0.y).abs()]; + if let Err(e) = app.session.execute("layer.addMask", json!({"layer": l.id.0, "shape": kind, "rect": rect})) { + app.ui.status = e.to_string(); + } return; } } // Into the selected shape layer's Contents as a new group (as in After Effects), else a new - // shape layer. - let fill = app.ui.fill_color; - let stroke = app.ui.stroke_color; - if let Err(e) = app.session.execute( - "shape.newShape", - json!({"kind": kind, "size": [w, h], "position": [cx, cy], "fill": [fill[0], fill[1], fill[2]], "stroke": [stroke[0], stroke[1], stroke[2]], "strokeWidth": app.ui.stroke_width}), - ) { + // shape layer; painted with the Tools bar's Fill and Stroke. + if let Err(e) = app.session.execute("shape.newShape", json!({"kind": kind, "size": [w, h], "position": [cx, cy]})) { app.ui.status = e.to_string(); } } 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 a37bddf3..811b55c7 100644 --- a/crates/ui-egui/tests/ui_timeline_shapes.rs +++ b/crates/ui-egui/tests/ui_timeline_shapes.rs @@ -195,5 +195,5 @@ fn toolbar_stroke_edits_the_selected_shape_layer() { let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); let width = layer.props.prop("contents/group/contents/stroke/width").map(|p| p.value.as_f64()); assert_eq!(width, Some(4.0), "a stroke was added with the toolbar's width"); - assert_eq!(h.state().ui.stroke_width, 4.0); + assert_eq!(h.state().session.state.shape_tool.stroke_width, 4.0); } diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 4155878a..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 { @@ -280,6 +282,72 @@ fn shape_tool_draws_into_the_selected_shape_layer() { assert_ne!(comp.layers[0].id, LayerId(l)); } +/// With a shape layer selected and a shape tool active, the Tools bar's Tool Creates Mask makes +/// the tool draw a mask on the layer (and hides Fill and Stroke); Tool Creates Shape draws +/// shapes again (#227). +#[test] +fn tool_creates_mask_draws_a_mask_on_the_selected_shape_layer() { + let mut h = harness(); + let l = h.state_mut().session.execute("layer.newShape", json!({"kind": "rect", "size": [100, 100], "position": [320, 180]})).unwrap()["layer"] + .as_u64() + .unwrap(); + h.state_mut().ui.tool = Tool::Star; + h.run_steps(2); + assert!(h.state().auto.find("header.fill").is_some()); + let at = rect(&h, "header.createsMask").center(); + click(&mut h, at); + assert!(h.state().session.state.shape_tool.creates_mask); + assert!(h.state().auto.find("header.fill").is_none(), "no Fill or Stroke for masks"); + let (a, b) = (screen(&h, [100.0, 100.0]), screen(&h, [200.0, 200.0])); + drag(&mut h, a, b); + let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone(); + let masks = layer.props.sub("masks").unwrap(); + assert_eq!(masks.groups().count(), 1, "a mask"); + assert_eq!(masks.groups().next().unwrap().get("path").unwrap().value.as_path().unwrap().vertices.len(), 10, "a star"); + assert_eq!(layer.props.sub("contents").unwrap().groups().count(), 1, "no new shape"); + let at = rect(&h, "header.createsShape").center(); + click(&mut h, at); + assert!(!h.state().session.state.shape_tool.creates_mask); +} + +/// Clicking the word "Fill" opens Fill Options: a radial gradient in Multiply at 40% paints the +/// next shape drawn (#227). +#[test] +fn fill_options_paint_the_next_shape() { + let mut h = harness(); + h.state_mut().ui.tool = Tool::Ellipse; + h.run_steps(2); + let at = rect(&h, "header.fillOptions").center(); + click(&mut h, at); + let at = rect(&h, "header.fillOptions.radial").center(); + click(&mut h, at); + let at = rect(&h, "header.fillOptions.blend").center(); + click(&mut h, at); + let at = h.get_by_label("Multiply").rect().center(); + click(&mut h, at); + let at = rect(&h, "header.fillOptions.opacity").center(); + click(&mut h, at); + h.input_mut().events.push(Event::Text("40".into())); + h.step(); + h.input_mut().events.push(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + h.run_steps(2); + let fill = h.state().session.state.shape_tool.fill.clone(); + assert_eq!((fill.kind, fill.blend, fill.opacity), (PaintKind::Radial, effectcraft_engine::color::BlendMode::Multiply, 40.0)); + h.input_mut().events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + h.run_steps(3); + assert!(h.state().auto.find("header.fillOptions.radial").is_none(), "closed"); + let (a, b) = (screen(&h, [100.0, 100.0]), screen(&h, [220.0, 160.0])); + drag(&mut h, a, b); + let layer = h.state().session.active_comp().unwrap().layers[0].clone(); + let g = layer.props.sub("contents").unwrap().groups().next().unwrap().sub("contents").unwrap().clone(); + assert_eq!(g.groups().map(|x| x.match_id.as_str()).collect::>(), ["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 5d56f59c..4027c228 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -114,6 +114,7 @@ Viewer state that agents drive headless too: `view.snapping`, `view.channel {cha `view.fastPreviewMode {mode}`, `view.setRegionOfInterest {rect}`, `view.addGuide`, `view.moveGuide`, `view.removeGuide`; editing: `shape.newPath` (Pen on shape layers), `shape.newShape` (the shape tools: a new group in the selected shape layer, else a new shape layer), +`shape.toolOptions` (the Tools bar's Tool Creates Shape / Mask and Fill and Stroke Options), `mask.insertVertex`, `mask.convertVertex`, `mask.deleteVertices`, `path.freeTransform` (masks and shape paths by uid), `keys.setSpatialTangents` (motion-path handles) and `keys.transform` (Graph Editor transform box, timeline Alt-drag scaling). From 25c01aa5a069d31554d0298946584bb3eb6efb93 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 12:53:44 +0700 Subject: [PATCH 5/5] Docs: gaps.md lists the regression tests for the #227 shape fixes Co-Authored-By: Claude Opus 5.5 --- docs/gaps.md | 11 +++++++++++ 1 file changed, 11 insertions(+) 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.