From 89e075d1b1294599c67596dc7073cdf991bd8200 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 12:53:40 +0700 Subject: [PATCH] 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).