mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 18:47:07 -06:00
shapes: toolbar Fill/Stroke kinds, blend and opacity apply to selected shape layers
With shape layers selected, Option-clicking the Fill/Stroke swatch or picking a kind,
blend mode or opacity in Fill/Stroke Options only changed the shape tools' defaults
(shape.toolOptions); the layers kept their paint (e.g. Fill: None left the blue fill).
shape.fillStroke now takes fillType/strokeType (none|solid|linear|radial, or
fill/stroke: false), fillBlend/strokeBlend and fillOpacity/strokeOpacity, applied to
every Fill/Stroke and Gradient Fill/Stroke in the layers' contents (nested groups too),
one undo step:
- None switches the items off (eye off, kept, as After Effects does); the renderer
already skips disabled items.
- Solid switches them on; Linear/Radial convert Fill <-> Gradient Fill and
Stroke <-> Gradient Stroke in place, keeping shared properties (uids, blend, opacity,
width, dashes, taper, wave) and the item number ("Fill 2" -> "Gradient Fill 2").
- Paths with no fill get one for a fill kind; with no stroke one for a stroke kind.
- Reports fillType/strokeType of the first layer (new pub first_kinds).
Header: the Alt-click kind cycle and the Options popup (kinds, blend, opacity) also
call shape.fillStroke on the selected shape layers; the swatches show the selected
layer's kinds.
(cherry picked from commit e28f2bdcc672fa8c1cf7ea771fd34ba8a48ff066)
(cherry picked from commit 4d514362dff9dfd5ae5d168daf119dbf7802e49b)
(cherry picked from commit addd0645c64a40d1611005f333f8686b2e003422)
This commit is contained in:
@@ -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<f64>, 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<KV>| {
|
||||
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<PaintKind>,
|
||||
/// Index into `BlendMode::ALL`.
|
||||
blend: Option<u32>,
|
||||
/// Opacity (%).
|
||||
opacity: Option<f64>,
|
||||
/// 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<f64>,
|
||||
/// 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<KV>| {
|
||||
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<Side> {
|
||||
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<Value> {
|
||||
let c = "shape.fillStroke";
|
||||
let (cid, lids) = layers_p(s, p)?;
|
||||
@@ -272,11 +502,17 @@ fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
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<CommandSpec> {
|
||||
@@ -303,7 +540,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
};
|
||||
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<String> = 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<String> = 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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user