diff --git a/crates/engine/src/commands/shape_stroke.rs b/crates/engine/src/commands/shape_stroke.rs index 87fcd141..00399da7 100644 --- a/crates/engine/src/commands/shape_stroke.rs +++ b/crates/engine/src/commands/shape_stroke.rs @@ -3,13 +3,15 @@ //! commands here set several of them in one undo step and add the groups to strokes saved before //! they existed. Also the toolbar's Fill / Stroke options on selected shape layers. -use effectcraft_keyframe::Value as KV; +use effectcraft_color::BlendMode; +use effectcraft_keyframe::{Gradient, Value as KV}; use effectcraft_project::build::{self, EXTRA_DASHES, Ids}; use effectcraft_project::{ItemId, Layer, LayerId, LayerSource, Node, PropGroup, Uid}; use effectcraft_time::Tick; use serde_json::{Value, json}; use super::layer::color_p; +use super::shape_tool::PaintKind; use super::{CommandSpec, bad, f_p, has_layers, layer_mut, layer_p, layers_p, merge_p}; use crate::{EngineError, Result, Session, cmd}; @@ -224,46 +226,274 @@ pub fn first_paint(layer: &Layer, lt: Tick) -> (Option<[f64; 4]>, Option<[f64; 4 (fill, stroke, width) } -/// Paint one contents group and its sub-groups: Fills take `fill`, Strokes `stroke`, Strokes and -/// Gradient Strokes `width`. With `add_stroke`, a group with a path and no stroke gets one (before -/// its fills, as new shapes have it). -#[allow(clippy::too_many_arguments)] -fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, stroke: Option<[f64; 4]>, width: Option, add_stroke: bool, depth: usize) { - if depth > MAX_DEPTH { - return; +/// The Fill or Stroke kind of the first fill / stroke item in `layer`'s contents (what the +/// toolbar's swatch shows for a selected shape layer): None when it is switched off or there is +/// none, Solid Color for a Fill / Stroke, Linear or Radial for a Gradient Fill / Stroke. +pub fn first_kinds(layer: &Layer) -> (PaintKind, PaintKind) { + let (mut fill, mut stroke) = (None, None); + let kind = |g: &PropGroup| match g.match_id.as_str() { + _ if !g.enabled => PaintKind::None, + "fill" | "stroke" => PaintKind::Solid, + _ if g.get("type").is_some_and(|t| t.value == KV::Enum(1)) => PaintKind::Radial, + _ => PaintKind::Linear, + }; + if let Some(c) = layer.props.sub("contents") { + walk_groups(c, &mut |g| match g.match_id.as_str() { + "fill" | "gfill" if fill.is_none() => fill = Some(kind(g)), + "stroke" | "gstroke" if stroke.is_none() => stroke = Some(kind(g)), + _ => {} + }); } - if add_stroke && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) && !c.groups().any(is_stroke) { - let at = c.children.iter().position(|n| matches!(n, Node::Group(g) if matches!(g.match_id.as_str(), "fill" | "gfill"))).unwrap_or(c.children.len()); - c.children.insert(at, build::shape_stroke(ids, stroke.unwrap_or([1.0; 4]), width.unwrap_or(2.0)).into()); - } - for n in &mut c.children { - let Node::Group(g) = n else { continue }; - let set = |g: &mut PropGroup, id: &str, v: Option| { - if let (Some(v), Some(p)) = (v, g.get_mut(id)) { - p.set_value_at(lt, v); + (fill.unwrap_or(PaintKind::None), stroke.unwrap_or(PaintKind::None)) +} + +/// What `shape.fillStroke` changes on the fills or on the strokes. +#[derive(Clone, Copy, Debug, Default)] +struct Side { + /// A Fill's / Stroke's colour. + color: Option<[f64; 4]>, + /// Switch off (None), or make every item a Fill / Stroke or a Gradient Fill / Stroke. + kind: Option, + /// Index into `BlendMode::ALL`. + blend: Option, + /// Opacity (%). + opacity: Option, + /// The colour of a Fill / Stroke made from a gradient one, or added. + solid: [f64; 4], + /// Paths with no Fill / Stroke (in their group or one around it) get one. + add: bool, +} + +/// One `shape.fillStroke` edit. +#[derive(Clone, Copy, Debug)] +struct Edit { + fill: Side, + stroke: Side, + width: Option, + /// The width of an added stroke. + new_width: f64, +} + +/// Properties a Fill and a Gradient Fill (or a Stroke and a Gradient Stroke) don't share in +/// meaning, so a converted item takes the new kind's defaults for them. +const PAINT_ONLY: [&str; 7] = ["color", "colors", "type", "start", "end", "highlightLength", "highlightAngle"]; + +fn is_fill(g: &PropGroup) -> bool { + matches!(g.match_id.as_str(), "fill" | "gfill") +} + +/// `[x, y, w, h]` around the group's own path items at layer time `lt` (a Gradient's span). +fn path_bounds(c: &PropGroup, lt: Tick) -> [f64; 4] { + let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]); + let mut add = |p: [f64; 2]| { + if p.iter().all(|v| v.is_finite()) { + for i in 0..2 { + lo[i] = lo[i].min(p[i]); + hi[i] = hi[i].max(p[i]); } - }; + } + }; + for g in c.groups() { + let at = |id: &str| g.get(id).map(|p| p.value_at(lt)); + let v2 = |id: &str, d: [f64; 2]| at(id).map(|v| v.as_vec2()).unwrap_or(d); match g.match_id.as_str() { - "fill" => set(g, "color", fill.map(KV::Color)), - "stroke" => { - set(g, "color", stroke.map(KV::Color)); - set(g, "width", width.map(KV::Scalar)); + "rect" | "ellipse" => { + let (pos, size) = (v2("position", [0.0; 2]), v2("size", [100.0; 2])); + add([pos[0] - size[0] / 2.0, pos[1] - size[1] / 2.0]); + add([pos[0] + size[0] / 2.0, pos[1] + size[1] / 2.0]); } - "gstroke" => set(g, "width", width.map(KV::Scalar)), - "group" => { - if let Some(sub) = g.sub_mut("contents") { - paint(sub, ids, lt, fill, stroke, width, add_stroke, depth + 1); + "star" => { + let pos = v2("position", [0.0; 2]); + let r = at("outerRadius").map(|v| v.as_f64()).unwrap_or(50.0).abs(); + add([pos[0] - r, pos[1] - r]); + add([pos[0] + r, pos[1] + r]); + } + "path" => { + if let Some(KV::Path(sp)) = at("path") { + sp.vertices.iter().for_each(|v| add(*v)); } } _ => {} } } + if lo[0] <= hi[0] && lo[1] <= hi[1] { [lo[0], lo[1], hi[0] - lo[0], hi[1] - lo[1]] } else { [-50.0, -50.0, 100.0, 100.0] } +} + +/// A gradient's Start and End Points across `bounds`: left to right, or out from the centre (as +/// the shape tools draw them). +fn gradient_ends(bounds: [f64; 4], radial: bool) -> ([f64; 2], [f64; 2]) { + let [x, y, w, h] = bounds; + let mid = [x + w / 2.0, y + h / 2.0]; + 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]]) } +} + +/// A new Fill / Stroke / Gradient Fill / Gradient Stroke of `kind` (not None). +fn new_item(ids: &mut Ids, fill: bool, kind: PaintKind, color: [f64; 4], width: f64, bounds: [f64; 4]) -> PropGroup { + let radial = kind == PaintKind::Radial; + let (a, b) = gradient_ends(bounds, radial); + match (fill, kind) { + (true, PaintKind::Solid) => build::shape_fill(ids, color), + (false, PaintKind::Solid) => build::shape_stroke(ids, color, width), + (true, _) => build::shape_gradient_fill(ids, radial, a, b, Gradient::default()), + (false, _) => build::shape_gradient_stroke(ids, radial, a, b, Gradient::default(), width), + } +} + +/// After Effects' base names of the four paint items. +fn base_name(match_id: &str) -> &'static str { + match match_id { + "fill" => "Fill", + "gfill" => "Gradient Fill", + "stroke" => "Stroke", + _ => "Gradient Stroke", + } +} + +/// Set a Fill / Stroke item's kind: None switches it off (its eye, as After Effects does; it is +/// kept), the others switch it on, and turn a Fill into a Gradient Fill and back (Stroke alike). +/// A converted item keeps its uid-carrying shared properties (blend mode, opacity, width, +/// dashes, taper, …) and its number ("Fill 2" → "Gradient Fill 2"), or its custom name. +fn set_kind(g: &mut PropGroup, ids: &mut Ids, kind: PaintKind, solid: [f64; 4], bounds: [f64; 4]) { + if kind == PaintKind::None { + g.enabled = false; + return; + } + g.enabled = true; + let fill = is_fill(g); + let gradient = matches!(g.match_id.as_str(), "gfill" | "gstroke"); + let radial = kind == PaintKind::Radial; + match (gradient, kind) { + (false, PaintKind::Solid) => {} + (true, PaintKind::Linear | PaintKind::Radial) => { + if let Some(t) = g.get_mut("type") { + t.value = KV::Enum(u32::from(radial)); + t.keys.clear(); + } + } + _ => { + let width = g.get("width").map(|w| w.value.as_f64()).unwrap_or(2.0); + let mut n = new_item(ids, fill, kind, solid, width, bounds); + for child in &mut n.children { + let id = child.match_id().to_string(); + if PAINT_ONLY.contains(&id.as_str()) { + continue; + } + if let Some(old) = g.children.iter().find(|o| o.match_id() == id) { + *child = old.clone(); + } + } + let (old_base, new_base) = (base_name(&g.match_id), base_name(&n.match_id)); + n.name = match g.name.strip_prefix(old_base) { + Some(rest) if rest.is_empty() || rest.starts_with(' ') => format!("{new_base}{rest}"), + _ => g.name.clone(), + }; + *g = n; + } + } +} + +/// Apply a Side's colour, blend mode and opacity to one item (after its kind). +fn set_side(g: &mut PropGroup, side: &Side, lt: Tick) { + let solid = matches!(g.match_id.as_str(), "fill" | "stroke"); + let mut set = |id: &str, v: Option| { + if let (Some(v), Some(p)) = (v, g.get_mut(id)) { + p.set_value_at(lt, v); + } + }; + if solid { + set("color", side.color.map(KV::Color)); + } + set("opacity", side.opacity.map(KV::Scalar)); + if let (Some(b), Some(p)) = (side.blend, g.get_mut("blend")) { + p.value = KV::Enum(b); + p.keys.clear(); + } +} + +/// Paint one contents group and its sub-groups with `e`. `painted` says whether a group around +/// this one already has a (fill, stroke), so its paths need none of their own. +fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, e: &Edit, painted: (bool, bool), depth: usize) { + if depth > MAX_DEPTH { + return; + } + let bounds = path_bounds(c, lt); + let has_path = c.groups().any(|g| PATHS.contains(&g.match_id.as_str())); + let painted = (painted.0 || c.groups().any(is_fill), painted.1 || c.groups().any(is_stroke)); + let adds = |s: &Side| s.add && s.kind != Some(PaintKind::None); + // A path with no stroke gets one before its fills, and one with no fill gets one after its + // paths and strokes (as new shapes have them: paths, stroke, fill). + if has_path && !painted.1 && adds(&e.stroke) { + let at = c.children.iter().position(|n| matches!(n, Node::Group(g) if is_fill(g))).unwrap_or(c.children.len()); + let kind = e.stroke.kind.unwrap_or(PaintKind::Solid); + let mut g = new_item(ids, false, kind, e.stroke.color.unwrap_or(e.stroke.solid), e.width.unwrap_or(e.new_width), bounds); + set_side(&mut g, &e.stroke, lt); + c.children.insert(at, g.into()); + } + if has_path && !painted.0 && adds(&e.fill) { + let after = |n: &Node| matches!(n, Node::Group(g) if PATHS.contains(&g.match_id.as_str()) || is_stroke(g)); + let at = c.children.iter().rposition(after).map_or(c.children.len(), |i| i + 1); + let kind = e.fill.kind.unwrap_or(PaintKind::Solid); + let mut g = new_item(ids, true, kind, e.fill.color.unwrap_or(e.fill.solid), 0.0, bounds); + set_side(&mut g, &e.fill, lt); + c.children.insert(at, g.into()); + } + for n in &mut c.children { + let Node::Group(g) = n else { continue }; + let side = match g.match_id.as_str() { + "fill" | "gfill" => &e.fill, + "stroke" | "gstroke" => &e.stroke, + "group" => { + if let Some(sub) = g.sub_mut("contents") { + paint(sub, ids, lt, e, painted, depth + 1); + } + continue; + } + _ => continue, + }; + if let Some(k) = side.kind { + set_kind(g, ids, k, side.color.unwrap_or(side.solid), bounds); + } + set_side(g, side, lt); + if is_stroke(g) + && let (Some(w), Some(p)) = (e.width, g.get_mut("width")) + { + p.set_value_at(lt, KV::Scalar(w)); + } + } +} + +/// `fill` / `stroke`, `fillType` / …, `fillBlend` / …, `fillOpacity` / … of `p`. +fn side_p(p: &Value, key: &str, solid: [f64; 4], c: &str) -> Result { + let mut side = Side { solid, ..Side::default() }; + match p.get(key) { + None => {} + Some(Value::Null | Value::Bool(false)) => side.kind = Some(PaintKind::None), + Some(_) => { + let rgb = color_p(p, key).ok_or_else(|| bad(c, format!("`{key}`: a colour [r, g, b] or #hex, or false for none")))?; + side.color = Some([rgb[0] as f64, rgb[1] as f64, rgb[2] as f64, 1.0]); + } + } + if let Some(v) = p.get(format!("{key}Type").as_str()) { + side.kind = Some(v.as_str().and_then(PaintKind::from_name).ok_or_else(|| bad(c, format!("`{key}Type`: none|solid|linear|radial")))?); + } + if let Some(v) = p.get(format!("{key}Blend").as_str()) { + let m = v.as_str().and_then(BlendMode::from_name).ok_or_else(|| bad(c, format!("`{key}Blend`: a blend mode name (normal, multiply…)")))?; + side.blend = BlendMode::ALL.iter().position(|x| *x == m).map(|i| i as u32); + } + if let Some(v) = p.get(format!("{key}Opacity").as_str()) { + side.opacity = Some(v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(c, format!("`{key}Opacity`: a percentage")))?.clamp(0.0, 100.0)); + } + Ok(side) } /// The toolbar's Fill and Stroke options with shape layers selected (as in After Effects, they -/// change the layers' fills and strokes): `fill` sets every Fill's colour, `stroke` every Stroke's, -/// `strokeWidth` every stroke's width. A stroke colour or a width above 0 gives the paths that -/// have no stroke one. Without values, reports the first layer's paint. +/// change the layers' fills and strokes, in nested groups too): `fill` / `stroke` set every Fill's +/// / Stroke's colour, `strokeWidth` every stroke's width; `fillType` / `strokeType` switch every +/// fill / stroke off (`none`, or `fill: false`: its eye, it is kept) or on as a Fill / Stroke +/// (`solid`) or a Gradient Fill / Stroke (`linear` / `radial`), converting it; `fillBlend` / +/// `strokeBlend` and `fillOpacity` / `strokeOpacity` set their blend mode and opacity. Paths with +/// no fill get one for a fill kind, and with no stroke one for a stroke colour, kind or a width +/// above 0. One undo step. Without values, reports the first layer's paint. fn fill_stroke(s: &mut Session, p: &Value) -> Result { let c = "shape.fillStroke"; let (cid, lids) = layers_p(s, p)?; @@ -272,11 +502,17 @@ fn fill_stroke(s: &mut Session, p: &Value) -> Result { if shapes.is_empty() { return Err(bad(c, "select a shape layer")); } - let rgba = |k: &str| color_p(p, k).map(|c| [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]); - let (fill, stroke) = (rgba("fill"), rgba("stroke")); + let tool = &s.state.shape_tool; + let rgba = |c: [f32; 3]| [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]; + let mut fill = side_p(p, "fill", rgba(tool.fill.color), c)?; + let mut stroke = side_p(p, "stroke", rgba(tool.stroke.color), c)?; let width = f_p(p, "strokeWidth").filter(|w| w.is_finite()).map(|w| w.max(0.0)); - if fill.is_some() || stroke.is_some() || width.is_some() { - let add_stroke = width.map_or(stroke.is_some(), |w| w > 0.0); + let new_width = width.unwrap_or(if tool.stroke_width > 0.0 { tool.stroke_width } else { 2.0 }); + fill.add = fill.kind.is_some_and(|k| k != PaintKind::None); + stroke.add = width.map_or(stroke.color.is_some() || stroke.kind.is_some(), |w| w > 0.0); + let changes = |s: &Side| s.color.is_some() || s.kind.is_some() || s.blend.is_some() || s.opacity.is_some(); + if changes(&fill) || changes(&stroke) || width.is_some() { + let e = Edit { fill, stroke, width, new_width }; let t = s.time(); s.edit("Fill and Stroke", merge_p(p), |proj, _| { let mut next = proj.next_id; @@ -284,7 +520,7 @@ fn fill_stroke(s: &mut Session, p: &Value) -> Result { let l = layer_mut(proj, cid, *lid)?; let lt = l.layer_time(t); if let Some(contents) = l.props.sub_mut("contents") { - paint(contents, &mut Ids(&mut next), lt, fill, stroke, width, add_stroke, 0); + paint(contents, &mut Ids(&mut next), lt, &e, (false, false), 0); } } proj.next_id = next; @@ -293,7 +529,8 @@ fn fill_stroke(s: &mut Session, p: &Value) -> Result { } let l = shapes.first().and_then(|id| s.project.comp(cid)?.layer(*id)).ok_or(EngineError::NoComp)?; let (fill, stroke, width) = first_paint(l, l.layer_time(s.time())); - Ok(json!({"fill": fill, "stroke": stroke, "strokeWidth": width})) + let (fill_type, stroke_type) = first_kinds(l); + Ok(json!({"fill": fill, "stroke": stroke, "strokeWidth": width, "fillType": fill_type.id(), "strokeType": stroke_type.id()})) } pub fn specs() -> Vec { @@ -303,7 +540,7 @@ pub fn specs() -> Vec { "Fill and Stroke", [], None, - "{layers?, fill?: color, stroke?: color, strokeWidth?, merge?} → {fill, stroke, strokeWidth} (first layer)", + "{layers?, fill?: color|false (none), stroke?: color|false, fillType?/strokeType?: none|solid|linear|radial (none switches the items off; solid/linear/radial switch them on, converting Fill ↔ Gradient Fill, Stroke ↔ Gradient Stroke), fillBlend?/strokeBlend?: blend mode, fillOpacity?/strokeOpacity? %, strokeWidth?, merge?} → {fill, stroke, strokeWidth, fillType, strokeType} (first layer)", has_layers, fill_stroke ), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 587379a8..dec8bd54 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -280,6 +280,97 @@ fn toolbar_fill_and_stroke_paint_selected_shape_layers() { let _ = s.render(cid, s.time(), Default::default()); } +/// The toolbar's Fill / Stroke kinds (Option-click on the swatch, Fill Options) change the +/// selected shape layers, as in After Effects: None switches the fill off (it stays, its eye +/// off, and the shape renders without it), Solid Color switches it back on, a gradient converts it +/// to a Gradient Fill (and back), each in one undo step; blend mode and opacity apply too. +#[test] +fn toolbar_fill_stroke_kinds_apply_to_selected_shape_layers() { + use effectcraft_keyframe::Value as KV; + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap(); + let sh = s.execute("layer.newShape", json!({"kind": "rect", "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + let cid = s.active_comp_id().unwrap(); + let items = |s: &Session| { + let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap(); + l.props.group("contents/group").unwrap().sub("contents").unwrap().groups().cloned().collect::>() + }; + let item = |s: &Session, m: &str| items(s).into_iter().find(|g| g.match_id == m); + let centre = |s: &Session| s.render(cid, s.time(), Default::default()).get(200, 150); + let fs = |s: &mut Session, v: serde_json::Value| s.execute("shape.fillStroke", v).unwrap(); + let red = |p: [f32; 4]| p[3] > 0.99 && p[0] > 0.9 && p[1] < 0.1 && p[2] < 0.1; + assert!(red(centre(&s)), "{:?}", centre(&s)); + let opacity_uid = item(&s, "fill").unwrap().get("opacity").unwrap().uid; + + // None: the Fill is kept, switched off, and no longer paints. + let r = fs(&mut s, json!({"fillType": "none"})); + assert_eq!(r["fillType"], "none", "{r}"); + let f = item(&s, "fill").expect("the fill is kept"); + assert!(!f.enabled); + assert!(centre(&s)[3] < 0.01, "{:?}", centre(&s)); + // One undo step brings it back; redo hides it again. + s.execute("edit.undo", json!({})).unwrap(); + assert!(item(&s, "fill").unwrap().enabled && red(centre(&s))); + s.execute("edit.redo", json!({})).unwrap(); + assert!(!item(&s, "fill").unwrap().enabled && centre(&s)[3] < 0.01); + // Solid Color switches it back on, colour unchanged. + let r = fs(&mut s, json!({"fillType": "solid"})); + assert_eq!(r["fillType"], "solid", "{r}"); + assert!(item(&s, "fill").unwrap().enabled && red(centre(&s)), "{:?}", centre(&s)); + // `fill: false` is None too. + fs(&mut s, json!({"fill": false})); + assert!(!item(&s, "fill").unwrap().enabled); + fs(&mut s, json!({"fillType": "solid"})); + + // Linear Gradient converts the Fill into a Gradient Fill in its place, keeping its shared + // properties; Radial changes its type; Solid Color converts it back. + let r = fs(&mut s, json!({"fillType": "linear"})); + assert_eq!(r["fillType"], "linear", "{r}"); + let order: Vec = items(&s).iter().map(|g| g.match_id.clone()).filter(|m| m != "transform").collect(); + assert_eq!(order, ["rect", "gfill"]); + assert!(item(&s, "fill").is_none()); + let g = item(&s, "gfill").unwrap(); + assert_eq!(g.name, "Gradient Fill 1"); + assert_eq!(g.get("opacity").unwrap().uid, opacity_uid); + assert_eq!(g.get("type").unwrap().value, KV::Enum(0)); + let px = centre(&s); + assert!(px[3] > 0.99 && (px[0] - px[1]).abs() < 0.05 && (px[1] - px[2]).abs() < 0.05 && px[0] > 0.05 && px[0] < 0.95, "grey mid-gradient: {px:?}"); + assert_eq!(fs(&mut s, json!({"fillType": "radial"}))["fillType"], "radial"); + assert_eq!(item(&s, "gfill").unwrap().get("type").unwrap().value, KV::Enum(1)); + let r = fs(&mut s, json!({"fillType": "solid", "fill": "#ff0000"})); + assert_eq!(r["fillType"], "solid", "{r}"); + assert!(item(&s, "gfill").is_none() && item(&s, "fill").is_some_and(|f| f.name == "Fill 1")); + assert!(red(centre(&s)), "{:?}", centre(&s)); + s.execute("edit.undo", json!({})).unwrap(); + assert!(item(&s, "gfill").is_some() && item(&s, "fill").is_none(), "one undo step"); + s.execute("edit.redo", json!({})).unwrap(); + + // Fill Options' blend mode and opacity. + fs(&mut s, json!({"fillBlend": "multiply", "fillOpacity": 40})); + let f = item(&s, "fill").unwrap(); + let multiply = effectcraft_color::BlendMode::ALL.iter().position(|m| *m == effectcraft_color::BlendMode::Multiply).unwrap() as u32; + assert_eq!((f.get("blend").unwrap().value.clone(), f.get("opacity").unwrap().value.as_f64()), (KV::Enum(multiply), 40.0)); + + // Stroke kinds: a gradient gives the stroke-less path a Gradient Stroke (before the fill); + // None switches it off. + let r = fs(&mut s, json!({"strokeType": "radial"})); + assert_eq!(r["strokeType"], "radial", "{r}"); + let order: Vec = items(&s).iter().map(|g| g.match_id.clone()).filter(|m| m != "transform").collect(); + assert_eq!(order, ["rect", "gstroke", "fill"]); + assert_eq!(fs(&mut s, json!({"strokeType": "none"}))["strokeType"], "none"); + assert!(!item(&s, "gstroke").unwrap().enabled); + assert_eq!(fs(&mut s, json!({"strokeType": "solid"}))["strokeType"], "solid"); + assert!(item(&s, "stroke").is_some_and(|g| g.enabled && g.name == "Stroke 1") && item(&s, "gstroke").is_none()); + + // A shape drawn with no fill gets one from a fill kind. + let none = s.execute("layer.newShape", json!({"kind": "ellipse", "fill": false})).unwrap()["layer"].as_u64().unwrap(); + let r = s.execute("shape.fillStroke", json!({"layers": [none], "fillType": "solid", "fill": "#00ff00"})).unwrap(); + assert_eq!((r["fillType"].as_str(), r["fill"][1].as_f64()), (Some("solid"), Some(1.0)), "{r}"); + // The query still only reports. + assert_eq!(s.execute("shape.fillStroke", json!({"layers": [none]})).unwrap()["fillType"], "solid"); + assert!(s.execute("shape.fillStroke", json!({"fillType": "plaid"})).is_err()); +} + /// Selecting a mask in the Timeline (its row or its Mask Path) selects all of its points, so the /// viewer drags the whole mask (#203); a shape's Path item works the same way. #[test] diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 1260c9c9..dc6e4edc 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -137,9 +137,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { .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 layer_kinds = + shape_layers.first().and_then(|id| app.session.active_comp()?.layer(LayerId(*id))).map(effectcraft_engine::commands::shape_stroke::first_kinds); + if let (Some((fill, stroke, width)), Some((fill_kind, stroke_kind))) = (layer_paint, layer_kinds) { 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; + // The swatches show the layer's kinds too (None for a switched-off fill or stroke). + tool.fill.kind = fill_kind; + tool.stroke.kind = stroke_kind; if let Some(c) = fill { tool.fill.color = rgb(c); } @@ -326,17 +331,7 @@ fn creates_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, mut x: f32, cy: /// 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(); - } - }; + let paint = |app: &mut EffectcraftApp, key: &str, v: serde_json::Value| paint_layers(app, shape_layers, json!({key: v})); 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"); @@ -364,7 +359,8 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa 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()})); + tool_options(app, json!({&k: next.id()})); + paint(app, &k, json!(next.id())); } else if tp.kind == PaintKind::Solid { widgets::open_popup(ui, picker_id(key)); } else { @@ -376,7 +372,7 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa tool_options(app, json!({key: c})); paint(app, key, json!(c)); } - paint_options_popup(app, ui, key, &options, wr.left_bottom()); + paint_options_popup(app, ui, shape_layers, key, &options, wr.left_bottom()); x = sr.max.x + 12.0; } let width = app.session.state.shape_tool.stroke_width; @@ -389,9 +385,23 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa r.max.x + 14.0 } +/// The toolbar's Fill / Stroke changes (`shape.fillStroke` keys) on the selected shape layers, as +/// in After Effects; one undo step per picker session or drag (the `tool-` merge key ends when +/// the pointer is released). +fn paint_layers(app: &mut EffectcraftApp, shape_layers: &[u64], mut v: serde_json::Value) { + let Some(obj) = v.as_object_mut().filter(|_| !shape_layers.is_empty()) else { return }; + let merge = format!("tool-{}", obj.keys().next().map_or("paint", String::as_str)); + obj.insert("layers".into(), json!(shape_layers)); + obj.insert("merge".into(), json!(merge)); + if let Err(e) = app.session.execute("shape.fillStroke", v) { + app.ui.status = e.to_string(); + } +} + /// 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) { +/// blend mode and opacity, for new shapes and the selected shape layers (After Effects' dialogs, +/// applied as they change). +fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, shape_layers: &[u64], key: &str, title: &str, pos: egui::Pos2) { let id = egui::Id::new(("tool-paint-options", key)); if !widgets::popup_is_open(ui, id) { return; @@ -449,7 +459,8 @@ fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, key: &str, t ui.data_mut(|d| d.insert_temp(id.with("open"), false)); } if let Some(v) = set { - tool_options(app, v); + tool_options(app, v.clone()); + paint_layers(app, shape_layers, v); } }