Files
effectcraft/crates/engine/src/commands/shape_stroke.rs
T
vangel76andClaude Opus 5.5 0ae960102c #732: toolbar Fill/Stroke Options change the selected shape layers' kind
Problem: with shape layers selected, the Tools bar's Fill Options / Stroke
Options (None, Solid Color, Linear Gradient, Radial Gradient, and the
swatch's Alt-click cycle) only changed the defaults for new shapes.
shape.fillStroke had no kind, so a selected layer's fill or stroke could
not be removed or turned into a gradient from the toolbar; it had to be
edited in the Timeline. The swatch also kept showing the tool's kind, not
the layer's.

Fix: shape.fillStroke takes fillType / strokeType. None switches the
layers' Fills and Gradient Fills (or Strokes) off; another kind switches
them on and turns them into that kind, keeping the uid and the
properties both kinds share (blend mode, opacity, width, dashes...), and
gives a group with paths and no such item one (gradients span the
group's paths). It reports the first layer's fillType / strokeType, and
the toolbar shows them. The kind picks in the options popup and the
Alt-click cycle apply to the selected shape layers, one undo step each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:49:48 +02:00

489 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Shape Stroke options: the Dashes "+" / "−" buttons (Dash 2 / Gap 2, Dash 3 / Gap 3), Taper and
//! Wave. Taper and Wave are ordinary properties of the stroke (`…/stroke/taper/startLength`); the
//! 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::{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::{self, PaintKind};
use super::{CommandSpec, bad, f_p, has_layers, layer_mut, layer_p, layers_p, merge_p};
use crate::{EngineError, Result, Session, cmd};
fn is_stroke(g: &PropGroup) -> bool {
matches!(g.match_id.as_str(), "stroke" | "gstroke")
}
/// `{layer?, prop?: stroke uid}` → the stroke; without `prop`, the first stroke of the layer (a
/// selected stroke property wins).
fn stroke_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> {
let (cid, lid) = layer_p(s, p, cmd)?;
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
if let Some(uid) = p.get("prop").and_then(Value::as_u64) {
let g = l.props.find_group(uid).ok_or_else(|| bad(cmd, format!("no property group @{uid}")))?;
if !is_stroke(g) {
return Err(bad(cmd, format!("`{}` is not a Stroke or Gradient Stroke", g.name)));
}
return Ok((cid, lid, uid));
}
let contents = l.props.sub("contents").ok_or_else(|| bad(cmd, "not a shape layer"))?;
// A selected stroke (or a property inside one), else the first stroke in the contents.
let mut found = None;
for (sl, su) in &s.state.selected_props {
if *sl == lid
&& let Some(g) = find_stroke_containing(contents, *su)
{
found = Some(g);
break;
}
}
if found.is_none() {
let mut first = None;
walk_groups(contents, &mut |g| {
if first.is_none() && is_stroke(g) {
first = Some(g.uid);
}
});
found = first;
}
found.map(|u| (cid, lid, u)).ok_or_else(|| bad(cmd, "the layer has no stroke"))
}
fn walk_groups(g: &PropGroup, f: &mut impl FnMut(&PropGroup)) {
for sub in g.groups() {
f(sub);
walk_groups(sub, f);
}
}
fn find_stroke_containing(contents: &PropGroup, uid: Uid) -> Option<Uid> {
let mut out = None;
walk_groups(contents, &mut |g| {
if out.is_none() && is_stroke(g) && (g.uid == uid || g.find(uid).is_some() || g.find_group(uid).is_some()) {
out = Some(g.uid);
}
});
out
}
fn dashes_add(s: &mut Session, p: &Value) -> Result<Value> {
let c = "shape.dashes.add";
let (cid, lid, uid) = stroke_ref(s, p, c)?;
s.edit("Add Dash", None, |proj, _| {
let mut next = proj.next_id;
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "stroke vanished"))?;
let d = g.sub_mut("dashes").ok_or_else(|| bad(c, "the stroke has no Dashes group"))?;
// Before the first "+", Dash is 0 (no dashes): the first "+" turns it on.
let dash = d.get("dash").map(|p| p.value.as_f64()).unwrap_or(0.0);
let mut ids = Ids(&mut next);
let added = if dash <= 0.0 {
if let Some(p) = d.get_mut("dash") {
p.value = KV::Scalar(10.0);
}
"dash"
} else {
let Some((dm, gm)) = EXTRA_DASHES.iter().find(|(dm, _)| d.get(dm).is_none()) else {
return Err(bad(c, "a stroke has at most three dash/gap pairs"));
};
let k = if *dm == "dash2" { 2 } else { 3 };
// Insert before Offset, which stays last.
let at = d.children.iter().position(|n| n.match_id() == "offset").unwrap_or(d.children.len());
let dv = d.get("dash").map(|p| p.value.as_f64()).unwrap_or(10.0);
let gv = d.get("gap").map(|p| p.value.as_f64()).filter(|g| *g > 0.0).unwrap_or(dv);
d.children.insert(at, ids.prop(gm, &format!("Gap {k}"), KV::Scalar(gv)).with_ui(effectcraft_project::ParamUi::Pixels).into());
d.children.insert(at, ids.prop(dm, &format!("Dash {k}"), KV::Scalar(dv)).with_ui(effectcraft_project::ParamUi::Pixels).into());
dm
};
proj.next_id = next;
Ok(json!({"added": added}))
})
}
fn dashes_remove(s: &mut Session, p: &Value) -> Result<Value> {
let c = "shape.dashes.remove";
let (cid, lid, uid) = stroke_ref(s, p, c)?;
s.edit("Remove Dash", None, |proj, _| {
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "stroke vanished"))?;
let d = g.sub_mut("dashes").ok_or_else(|| bad(c, "the stroke has no Dashes group"))?;
for (dm, gm) in EXTRA_DASHES.iter().rev() {
if d.get(dm).is_some() {
d.children.retain(|n| n.match_id() != *dm && n.match_id() != *gm);
return Ok(json!({"removed": dm}));
}
}
// The last "−" turns dashes off.
if let Some(p) = d.get_mut("dash") {
p.value = KV::Scalar(0.0);
p.keys.clear();
}
Ok(json!({"removed": "dash"}))
})
}
/// Set `fields` (`param name → (match id, kind)`) of the stroke's `group`, creating the group.
fn set_group(s: &mut Session, p: &Value, c: &str, group: &str, fields: &[(&str, &str, bool)]) -> Result<Value> {
let (cid, lid, uid) = stroke_ref(s, p, c)?;
let t = s.time();
let label = if group == "taper" { "Stroke Taper" } else { "Stroke Wave" };
s.edit(label, merge_p(p), |proj, _| {
let mut next = proj.next_id;
{
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad(c, "stroke vanished"))?;
if g.sub(group).is_none() {
let mut ids = Ids(&mut next);
let ng = if group == "taper" { build::stroke_taper(&mut ids) } else { build::stroke_wave(&mut ids) };
g.children.push(ng.into());
}
}
proj.next_id = next;
let l = layer_mut(proj, cid, lid)?;
let lt = l.layer_time(t);
let g = l.props.find_group_mut(uid).ok_or_else(|| bad(c, "stroke vanished"))?;
let sub = g.sub_mut(group).ok_or_else(|| bad(c, "group vanished"))?;
let mut out = serde_json::Map::new();
for (param, m, is_enum) in fields {
let Some(v) = p.get(*param) else { continue };
let pr = sub.get_mut(m).ok_or_else(|| bad(c, format!("no {m}")))?;
let nv = if *is_enum {
let opts: &[&str] = if *m == "units" && group == "taper" { &["pixels", "percent"] } else { &["pixels", "cycles"] };
let i = match v {
Value::String(s) => {
opts.iter().position(|o| o.eq_ignore_ascii_case(s)).ok_or_else(|| bad(c, format!("`{param}` is one of {opts:?}")))? as u32
}
Value::Number(n) => n.as_u64().unwrap_or(0).min(opts.len() as u64 - 1) as u32,
_ => return Err(bad(c, format!("bad `{param}`"))),
};
KV::Enum(i)
} else {
let x = v.as_f64().ok_or_else(|| bad(c, format!("`{param}` must be a number")))?;
let x = if let effectcraft_project::ParamUi::Slider { min, max, .. } = pr.ui { x.clamp(min, max) } else { x.max(0.0) };
KV::Scalar(x)
};
pr.set_value_at(lt, nv.clone());
out.insert(param.to_string(), nv.to_json());
}
Ok(json!({"stroke": uid, group: Value::Object(out)}))
})
}
fn taper(s: &mut Session, p: &Value) -> Result<Value> {
set_group(
s,
p,
"shape.stroke.taper",
"taper",
&[
("units", "units", true),
("startLength", "startLength", false),
("endLength", "endLength", false),
("startWidth", "startWidth", false),
("endWidth", "endWidth", false),
("startEase", "startEase", false),
("endEase", "endEase", false),
],
)
}
fn wave(s: &mut Session, p: &Value) -> Result<Value> {
set_group(
s,
p,
"shape.stroke.wave",
"wave",
&[("amount", "amount", false), ("units", "units", true), ("wavelength", "wavelength", false), ("cycles", "cycles", false), ("phase", "phase", false)],
)
}
/// Path items a Fill or Stroke paints in their group.
const PATHS: [&str; 4] = ["rect", "ellipse", "star", "path"];
/// Shape groups nest; deeper contents are left alone (never-crash recursion bound).
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).
pub fn first_paint(layer: &Layer, lt: Tick) -> (Option<[f64; 4]>, Option<[f64; 4]>, Option<f64>) {
let (mut fill, mut stroke, mut width) = (None, None, None);
if let Some(c) = layer.props.sub("contents") {
walk_groups(c, &mut |g| {
let at = |id: &str| g.get(id).map(|p| p.value_at(lt));
let rgba = |v: KV| v.as_color().map(f64::from);
match g.match_id.as_str() {
"fill" if fill.is_none() => fill = at("color").map(rgba),
"stroke" if stroke.is_none() => {
stroke = at("color").map(rgba);
width = at("width").map(|v| v.as_f64());
}
_ => {}
}
});
}
(fill, stroke, width)
}
/// What the toolbar's Fill and Stroke set on the selected shape layers.
#[derive(Clone, Copy, Debug, Default)]
struct PaintEdit {
fill: Option<[f64; 4]>,
stroke: Option<[f64; 4]>,
width: Option<f64>,
/// A group with a path and no stroke gets one.
add_stroke: bool,
fill_kind: Option<PaintKind>,
stroke_kind: Option<PaintKind>,
}
/// The kind of the first Fill and the first Stroke in `layer`'s contents at layer time `lt`: a
/// switched-off or missing one is None (what the toolbar shows for a selected shape layer).
pub fn first_paint_kinds(layer: &Layer, lt: Tick) -> (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(|p| p.value_at(lt).as_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)),
_ => {}
});
}
(fill.unwrap_or(PaintKind::None), stroke.unwrap_or(PaintKind::None))
}
/// The bounds (`[x, y, w, h]`) of the path items of one contents level at layer time `lt`, where
/// a new gradient's Start and End Points go; a 100 px square at the origin without paths.
fn path_bounds(c: &PropGroup, lt: Tick) -> [f64; 4] {
let mut acc: Option<[f64; 4]> = None;
for g in c.groups() {
let at = |id: &str| g.get(id).map(|p| p.value_at(lt));
let b = match g.match_id.as_str() {
"rect" | "ellipse" => {
let [w, h] = at("size").map(|v| v.as_vec2()).unwrap_or([100.0; 2]);
let [x, y] = at("position").map(|v| v.as_vec2()).unwrap_or([0.0; 2]);
Some([x - w.abs() / 2.0, y - h.abs() / 2.0, x + w.abs() / 2.0, y + h.abs() / 2.0])
}
"star" => {
let r = at("outerRadius").map(|v| v.as_f64().abs()).unwrap_or(100.0);
let [x, y] = at("position").map(|v| v.as_vec2()).unwrap_or([0.0; 2]);
Some([x - r, y - r, x + r, y + r])
}
"path" => {
let v = at("path");
let bez = v.as_ref().and_then(|v| v.as_path()).map(effectcraft_path::to_kurbo);
bez.and_then(|b| effectcraft_path::bounds(&[b])).map(|r| [r.x0, r.y0, r.x1, r.y1])
}
_ => None,
};
let Some(b) = b.filter(|b| b.iter().all(|v| v.is_finite())) else { continue };
acc = Some(match acc {
None => b,
Some(a) => [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])],
});
}
acc.map(|[x0, y0, x1, y1]| [x0, y0, x1 - x0, y1 - y0]).unwrap_or([-50.0, -50.0, 100.0, 100.0])
}
/// Fill Options / Stroke Options on one contents level (as in After Effects, with shape layers
/// selected they change the layers' paint): None switches its Fills and Gradient Fills (or
/// Strokes and Gradient Strokes) off; another kind switches them on, turns them into that kind
/// (keeping the properties both kinds have: blend mode, opacity, width, dashes…) and gives a
/// level with paths and no such item one.
fn set_kind(c: &mut PropGroup, ids: &mut Ids, lt: Tick, stroke: bool, kind: PaintKind, e: &PaintEdit) {
let (solid, grad) = if stroke { ("stroke", "gstroke") } else { ("fill", "gfill") };
let is_item = |g: &PropGroup| g.match_id == solid || g.match_id == grad;
if kind == PaintKind::None {
for n in &mut c.children {
if let Node::Group(g) = n
&& is_item(g)
{
g.enabled = false;
}
}
return;
}
let radial = kind == PaintKind::Radial;
let color = (if stroke { e.stroke } else { e.fill }).unwrap_or([1.0; 4]);
let width = e.width.filter(|w| *w > 0.0).unwrap_or(2.0);
let (start, end) = shape_tool::gradient_ends(radial, path_bounds(c, lt));
let fresh = |ids: &mut Ids| match (kind, stroke) {
(PaintKind::Solid, false) => build::shape_fill(ids, color),
(PaintKind::Solid, true) => build::shape_stroke(ids, color, width),
(_, false) => build::shape_gradient_fill(ids, radial, start, end, Gradient::default()),
(_, true) => build::shape_gradient_stroke(ids, radial, start, end, Gradient::default(), width),
};
let want = if kind == PaintKind::Solid { solid } else { grad };
let mut found = false;
for i in 0..c.children.len() {
let Some(Node::Group(g)) = c.children.get(i) else { continue };
if !is_item(g) {
continue;
}
found = true;
let replacement = (g.match_id != want).then(|| {
let mut new = fresh(ids);
new.uid = g.uid;
new.name = super::effect::unique_name(c, &new.name);
for n in &mut new.children {
if let Some(old) = g.children.iter().find(|o| o.match_id() == n.match_id()) {
*n = old.clone();
}
}
new
});
let Some(Node::Group(g)) = c.children.get_mut(i) else { continue };
match replacement {
Some(new) => *g = new,
None => {
g.enabled = true;
if let Some(t) = g.get_mut("type").filter(|_| want == grad) {
t.set_value_at(lt, KV::Enum(radial as u32));
}
}
}
}
if !found && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) {
let mut new = fresh(ids);
new.name = super::effect::unique_name(c, &new.name);
// Paths, strokes, then fills, as new shapes have them.
let at = if stroke {
c.children.iter().position(|n| matches!(n.match_id(), "fill" | "gfill")).unwrap_or(c.children.len())
} else {
c.children.iter().rposition(|n| PATHS.contains(&n.match_id()) || matches!(n.match_id(), "stroke" | "gstroke")).map_or(c.children.len(), |i| i + 1)
};
c.children.insert(at, new.into());
}
}
/// Paint one contents group and its sub-groups: the kinds first, then 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).
fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, e: &PaintEdit, depth: usize) {
if depth > MAX_DEPTH {
return;
}
if let Some(k) = e.stroke_kind {
set_kind(c, ids, lt, true, k, e);
}
if let Some(k) = e.fill_kind {
set_kind(c, ids, lt, false, k, e);
}
let (fill, stroke, width) = (e.fill, e.stroke, e.width);
if e.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<KV>| {
if let (Some(v), Some(p)) = (v, g.get_mut(id)) {
p.set_value_at(lt, v);
}
};
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));
}
"gstroke" => set(g, "width", width.map(KV::Scalar)),
"group" => {
if let Some(sub) = g.sub_mut("contents") {
paint(sub, ids, lt, e, depth + 1);
}
}
_ => {}
}
}
}
/// `p`'s `fillType` / `strokeType` (none|solid|linear|radial).
fn kind_p(p: &Value, key: &str, c: &str) -> Result<Option<PaintKind>> {
match p.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_str().and_then(PaintKind::from_name).map(Some).ok_or_else(|| bad(c, format!("`{key}`: none|solid|linear|radial"))),
}
}
/// The toolbar's Fill and Stroke options with shape layers selected (as in After Effects, they
/// change the layers' fills and strokes): `fillType` / `strokeType` switch every Fill / Stroke
/// off (none) or turn them into Solid Color, Linear or Radial Gradient ones (adding one where a
/// path has none), `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.
fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let c = "shape.fillStroke";
let (cid, lids) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let shapes: Vec<LayerId> = lids.into_iter().filter(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape))).collect();
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 width = f_p(p, "strokeWidth").filter(|w| w.is_finite()).map(|w| w.max(0.0));
let (fill_kind, stroke_kind) = (kind_p(p, "fillType", c)?, kind_p(p, "strokeType", c)?);
let add_stroke = stroke_kind != Some(PaintKind::None) && width.map_or(stroke.is_some(), |w| w > 0.0);
let e = PaintEdit { fill, stroke, width, add_stroke, fill_kind, stroke_kind };
if fill.is_some() || stroke.is_some() || width.is_some() || fill_kind.is_some() || stroke_kind.is_some() {
let t = s.time();
s.edit("Fill and Stroke", merge_p(p), |proj, _| {
let mut next = proj.next_id;
for lid in &shapes {
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, &e, 0);
}
}
proj.next_id = next;
Ok(())
})?;
}
let l = shapes.first().and_then(|id| s.project.comp(cid)?.layer(*id)).ok_or(EngineError::NoComp)?;
let lt = l.layer_time(s.time());
let (fill, stroke, width) = first_paint(l, lt);
let (fill_kind, stroke_kind) = first_paint_kinds(l, lt);
Ok(json!({"fill": fill, "stroke": stroke, "strokeWidth": width, "fillType": fill_kind.id(), "strokeType": stroke_kind.id()}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"shape.fillStroke",
"Fill and Stroke",
[],
None,
"{layers?, fillType?: none|solid|linear|radial, strokeType?, fill?: color, stroke?: color, strokeWidth?, merge?} → {fill, stroke, strokeWidth, fillType, strokeType} (first layer)",
has_layers,
fill_stroke
),
cmd!("shape.dashes.add", "Add Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_add),
cmd!("shape.dashes.remove", "Remove Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_remove),
cmd!(
"shape.stroke.taper",
"Stroke Taper",
[],
None,
"{layer?, prop?: stroke uid, units?: pixels|percent, startLength?, endLength?, startWidth? (%), endWidth? (%), startEase? (%), endEase? (%)}",
has_layers,
taper
),
cmd!(
"shape.stroke.wave",
"Stroke Wave",
[],
None,
"{layer?, prop?: stroke uid, amount? (%), units?: pixels|cycles, wavelength?, cycles?, phase? (°)}",
has_layers,
wave
),
]
}