mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 05:27:08 -06:00
Shape and text layers have comp-sized effect bounds centred on their origin, but effects measured positions from the origin: a point at its default "layer centre" sat at the comp's bottom-right corner, so Twirl on a centred shape changed nothing and Bulge only its bottom-right quarter. As in After Effects, effects now work in effect space, measured from the top-left of the layer's effect bounds. The renderer moves the layer buffer into effect space for the whole stack (Buf::rebase, CPU and GPU alike) and hands effects everything else in it: masks, other layers' pixels for layer parameters (each in its own effect space, so Set Matte and displacement maps see a whole shape layer), the layer's own frames (layer_input, also what Puppet, Roto Brush and the Layer panel read) and the comp camera (CompScene::rebased). Layer bounds are now right for every effect, so pin_rect and EffectEnv::bounds_origin go and layer_rect serves edge pinning too. Adjustment layers still run on the comp below as it is. effect_bounds / EvalCtx::effect_to_comp replace the repeated "comp size when there is no source" code; the viewer's effect point handles and eyedroppers, Puppet pins, the Layer panel (paint, Roto Brush) and the tracker's Corner Pin output use them. Project schema 2: opening a schema 1 project moves positions held by effects on shape and text layers by half the comp size, so set points keep their place (point parameters with keyframes, Paint paths and clone positions, Puppet seeds and pins, Liquify and Roto Brush strokes). Points still at their default become the layer centre; expression controls, adjustment layers and expressions are left as they are (docs/architecture.md). The disk cache's render version is bumped. Tests: default_effect_points_sit_at_the_centre_of_a_shape_layer and layer_parameters_see_a_whole_shape_layer (render, both fail before), effect_points_on_a_shape_layer (GPU vs CPU), opening_a_schema_1_project_moves_shape_layer_effect_points_into_effect_space (engine), and the effects migration tests. Part of #227 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
484 lines
20 KiB
Rust
484 lines
20 KiB
Rust
//! Paint tools (Brush, Clone Stamp, Eraser) and the Paint / Brushes panel options.
|
||
//!
|
||
//! `paint.stroke` records one stroke into the layer's Paint effect (adding the effect when the
|
||
//! last effect in the stack isn't Paint, as After Effects does). Defaults come from the Paint
|
||
//! and Brushes panel options in [`PaintOptions`] (`paint.options`, `paint.brushPreset`).
|
||
|
||
use effectcraft_effects::paint::{self, BRUSH_PRESETS, CHANNELS, DURATIONS, ERASE_MODES, MODES, StrokeKind, StrokeSpec};
|
||
use effectcraft_keyframe::{Keyframe, Value as KV};
|
||
use effectcraft_project::build::Ids;
|
||
use effectcraft_project::{GroupKind, PropGroup, Uid};
|
||
use effectcraft_time::Tick;
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{Value, json};
|
||
|
||
use super::{CommandSpec, always, b_p, bad, f_p, has_comp, layer_mut, layer_p, str_p};
|
||
use crate::{EngineError, Result, Session, cmd};
|
||
|
||
/// Paint and Brushes panel state (serde: agents read and set it with `paint.options`).
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(default, rename_all = "camelCase")]
|
||
pub struct PaintOptions {
|
||
/// Foreground (paint) and background colours, straight RGBA 0–1.
|
||
pub color: [f64; 4],
|
||
pub background: [f64; 4],
|
||
/// Percent.
|
||
pub opacity: f64,
|
||
pub flow: f64,
|
||
/// Index into the paint mode list (`paint::MODES`).
|
||
pub mode: u32,
|
||
/// Index into RGBA / RGB / Alpha.
|
||
pub channels: u32,
|
||
/// Index into Constant / Write On / Single Frame / Custom.
|
||
pub duration: u32,
|
||
/// Custom duration in frames.
|
||
pub custom_frames: u32,
|
||
/// Index into Layer Source & Paint / Paint Only / Last Stroke Only.
|
||
pub erase: u32,
|
||
// Brushes panel.
|
||
pub diameter: f64,
|
||
pub angle: f64,
|
||
pub roundness: f64,
|
||
pub hardness: f64,
|
||
pub spacing: f64,
|
||
/// Selected preset tip (index into `paint::BRUSH_PRESETS`).
|
||
pub preset: Option<usize>,
|
||
/// Brush dynamics (pen pressure).
|
||
pub size_pressure: bool,
|
||
pub min_size: f64,
|
||
pub opacity_pressure: bool,
|
||
pub flow_pressure: bool,
|
||
// Clone options.
|
||
/// Clone preset slot (1–5).
|
||
pub clone_preset: u32,
|
||
/// Source layer (None = the layer being painted).
|
||
pub clone_source: Option<u64>,
|
||
/// Source point set with Alt/Option-click (effect space of the source layer).
|
||
pub clone_point: Option<[f64; 2]>,
|
||
/// Aligned: keep the source/destination offset between strokes.
|
||
pub aligned: bool,
|
||
/// Offset (source − destination) remembered by Aligned.
|
||
pub clone_offset: Option<[f64; 2]>,
|
||
pub lock_source_time: bool,
|
||
/// Source Time Shift in seconds (or the locked Source Time).
|
||
pub clone_time_shift: f64,
|
||
pub clone_time: f64,
|
||
/// Clone Source Overlay.
|
||
pub show_overlay: bool,
|
||
pub overlay_opacity: f64,
|
||
pub overlay_difference: bool,
|
||
}
|
||
|
||
impl Default for PaintOptions {
|
||
fn default() -> Self {
|
||
PaintOptions {
|
||
color: [1.0, 1.0, 1.0, 1.0],
|
||
background: [0.0, 0.0, 0.0, 1.0],
|
||
opacity: 100.0,
|
||
flow: 100.0,
|
||
mode: 0,
|
||
channels: 0,
|
||
duration: 0,
|
||
custom_frames: 1,
|
||
erase: 0,
|
||
diameter: 10.0,
|
||
angle: 0.0,
|
||
roundness: 100.0,
|
||
hardness: 100.0,
|
||
spacing: 25.0,
|
||
preset: None,
|
||
size_pressure: true,
|
||
min_size: 1.0,
|
||
opacity_pressure: false,
|
||
flow_pressure: false,
|
||
clone_preset: 1,
|
||
clone_source: None,
|
||
clone_point: None,
|
||
aligned: true,
|
||
clone_offset: None,
|
||
lock_source_time: false,
|
||
clone_time_shift: 0.0,
|
||
clone_time: 0.0,
|
||
show_overlay: false,
|
||
overlay_opacity: 50.0,
|
||
overlay_difference: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
fn norm(s: &str) -> String {
|
||
s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase()
|
||
}
|
||
|
||
/// Index of `v` in `names` (by number or case/space-insensitive name).
|
||
fn pick(v: Option<&Value>, names: &[&str], cmd: &str, what: &str) -> Result<Option<u32>> {
|
||
match v {
|
||
None | Some(Value::Null) => Ok(None),
|
||
Some(Value::Number(n)) => {
|
||
let i = n.as_u64().unwrap_or(u64::MAX);
|
||
if (i as usize) < names.len() { Ok(Some(i as u32)) } else { Err(bad(cmd, format!("`{what}` index out of range"))) }
|
||
}
|
||
Some(Value::String(s)) => {
|
||
let k = norm(s);
|
||
names
|
||
.iter()
|
||
.position(|n| norm(n) == k || (k == "writeon" && norm(n) == "writeon"))
|
||
.map(|i| Some(i as u32))
|
||
.ok_or_else(|| bad(cmd, format!("unknown {what} `{s}`; one of: {}", names.join(", "))))
|
||
}
|
||
Some(_) => Err(bad(cmd, format!("`{what}` must be a name or index"))),
|
||
}
|
||
}
|
||
|
||
fn mode_names() -> Vec<&'static str> {
|
||
MODES.iter().map(|m| m.label()).collect()
|
||
}
|
||
|
||
fn color_p(v: Option<&Value>) -> Option<[f64; 4]> {
|
||
let a = v?.as_array()?;
|
||
let g = |i: usize, d: f64| a.get(i).and_then(Value::as_f64).unwrap_or(d);
|
||
Some([g(0, 0.0), g(1, 0.0), g(2, 0.0), g(3, 1.0)])
|
||
}
|
||
|
||
fn pt(v: Option<&Value>) -> Option<[f64; 2]> {
|
||
let a = v?.as_array()?;
|
||
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?])
|
||
}
|
||
|
||
/// Apply option keys from `p` onto `o` (shared by `paint.options` and `paint.stroke`).
|
||
fn apply_options(o: &mut PaintOptions, p: &Value, cmd: &str) -> Result<()> {
|
||
if let Some(c) = color_p(p.get("color")) {
|
||
o.color = c;
|
||
}
|
||
if let Some(c) = color_p(p.get("background")) {
|
||
o.background = c;
|
||
}
|
||
for (k, f) in [
|
||
("opacity", &mut o.opacity as &mut f64),
|
||
("flow", &mut o.flow),
|
||
("diameter", &mut o.diameter),
|
||
("angle", &mut o.angle),
|
||
("roundness", &mut o.roundness),
|
||
("hardness", &mut o.hardness),
|
||
("spacing", &mut o.spacing),
|
||
("minSize", &mut o.min_size),
|
||
("cloneTimeShift", &mut o.clone_time_shift),
|
||
("cloneTime", &mut o.clone_time),
|
||
("overlayOpacity", &mut o.overlay_opacity),
|
||
] {
|
||
if let Some(v) = f_p(p, k) {
|
||
*f = v;
|
||
}
|
||
}
|
||
o.opacity = o.opacity.clamp(0.0, 100.0);
|
||
o.flow = o.flow.clamp(0.0, 100.0);
|
||
o.diameter = o.diameter.clamp(0.0, 2500.0);
|
||
o.roundness = o.roundness.clamp(0.0, 100.0);
|
||
o.hardness = o.hardness.clamp(0.0, 100.0);
|
||
o.spacing = o.spacing.clamp(1.0, 1000.0);
|
||
o.min_size = o.min_size.clamp(0.0, 100.0);
|
||
for (k, f) in [
|
||
("sizePressure", &mut o.size_pressure as &mut bool),
|
||
("opacityPressure", &mut o.opacity_pressure),
|
||
("flowPressure", &mut o.flow_pressure),
|
||
("aligned", &mut o.aligned),
|
||
("lockSourceTime", &mut o.lock_source_time),
|
||
("showOverlay", &mut o.show_overlay),
|
||
("overlayDifference", &mut o.overlay_difference),
|
||
] {
|
||
if let Some(v) = b_p(p, k) {
|
||
*f = v;
|
||
}
|
||
}
|
||
let modes = mode_names();
|
||
if let Some(i) = pick(p.get("mode"), &modes, cmd, "mode")? {
|
||
o.mode = i;
|
||
}
|
||
if let Some(i) = pick(p.get("channels"), &CHANNELS, cmd, "channels")? {
|
||
o.channels = i;
|
||
}
|
||
let durs = ["constant", "writeOn", "singleFrame", "custom"];
|
||
if let Some(i) = pick(p.get("durationMode"), &DURATIONS, cmd, "durationMode").or_else(|_| pick(p.get("durationMode"), &durs, cmd, "durationMode"))? {
|
||
o.duration = i;
|
||
}
|
||
if let Some(n) = p.get("customFrames").and_then(Value::as_u64) {
|
||
o.custom_frames = (n as u32).max(1);
|
||
}
|
||
let erase = ["layerSourceAndPaint", "paintOnly", "lastStrokeOnly"];
|
||
if let Some(i) = pick(p.get("eraseMode"), &ERASE_MODES, cmd, "eraseMode").or_else(|_| pick(p.get("eraseMode"), &erase, cmd, "eraseMode"))? {
|
||
o.erase = i;
|
||
}
|
||
if let Some(n) = p.get("clonePreset").and_then(Value::as_u64) {
|
||
o.clone_preset = (n as u32).clamp(1, 5);
|
||
}
|
||
match p.get("cloneSource") {
|
||
Some(Value::Null) => o.clone_source = None,
|
||
Some(Value::Number(n)) => o.clone_source = n.as_u64(),
|
||
_ => {}
|
||
}
|
||
if let Some(v) = p.get("clonePoint") {
|
||
o.clone_point = pt(Some(v));
|
||
o.clone_offset = None;
|
||
}
|
||
if let Some(v) = p.get("cloneOffset") {
|
||
o.clone_offset = pt(Some(v));
|
||
}
|
||
if let Some(i) = p.get("preset").and_then(Value::as_u64) {
|
||
set_preset(o, i as usize, cmd)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn set_preset(o: &mut PaintOptions, i: usize, cmd: &str) -> Result<()> {
|
||
let t = BRUSH_PRESETS.get(i).ok_or_else(|| bad(cmd, format!("no brush preset {i} (0–{})", BRUSH_PRESETS.len() - 1)))?;
|
||
o.diameter = t.diameter;
|
||
o.angle = t.angle;
|
||
o.roundness = t.roundness;
|
||
o.hardness = t.hardness;
|
||
o.spacing = t.spacing;
|
||
o.preset = Some(i);
|
||
Ok(())
|
||
}
|
||
|
||
fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let mut o = s.state.paint.clone();
|
||
apply_options(&mut o, p, "paint.options")?;
|
||
// Editing the tip by hand deselects the preset.
|
||
if p.get("preset").is_none() && ["diameter", "angle", "roundness", "hardness", "spacing"].iter().any(|k| p.get(k).is_some()) {
|
||
o.preset = None;
|
||
}
|
||
if b_p(p, "reset") == Some(true) {
|
||
o = PaintOptions::default();
|
||
}
|
||
s.state.paint = o;
|
||
// View state only: no project revision bump.
|
||
Ok(serde_json::to_value(&s.state.paint).unwrap_or(Value::Null))
|
||
}
|
||
|
||
fn preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let i = match p.get("preset").or_else(|| p.get("name")) {
|
||
Some(Value::Number(n)) => n.as_u64().unwrap_or(u64::MAX) as usize,
|
||
Some(Value::String(name)) => {
|
||
BRUSH_PRESETS.iter().position(|t| norm(t.name) == norm(name)).ok_or_else(|| bad("paint.brushPreset", format!("no preset `{name}`")))?
|
||
}
|
||
_ => return Ok(json!(BRUSH_PRESETS.iter().map(|t| t.name).collect::<Vec<_>>())),
|
||
};
|
||
let mut o = s.state.paint.clone();
|
||
set_preset(&mut o, i, "paint.brushPreset")?;
|
||
s.state.paint = o;
|
||
// View state only: no project revision bump.
|
||
Ok(json!({"preset": i, "name": BRUSH_PRESETS[i].name}))
|
||
}
|
||
|
||
fn set_clone_source(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let point = pt(p.get("point")).ok_or_else(|| bad("paint.setCloneSource", "missing `point` [x, y] (effect space)"))?;
|
||
let (_, lid) = layer_p(s, p, "paint.setCloneSource")?;
|
||
s.state.paint.clone_source = Some(lid.0);
|
||
s.state.paint.clone_point = Some(point);
|
||
s.state.paint.clone_offset = None;
|
||
Ok(json!({"layer": lid.0, "point": point}))
|
||
}
|
||
|
||
/// The layer's Paint effect to record into: the last effect when it is Paint, else a new one.
|
||
fn paint_effect(fx: &mut PropGroup, ids: &mut Ids, layer_size: [f64; 2]) -> Result<Uid> {
|
||
if let Some(g) = fx.groups().last().filter(|g| paint::is_paint(g)) {
|
||
return Ok(g.uid);
|
||
}
|
||
let spec = effectcraft_effects::find(paint::ID).ok_or_else(|| EngineError::Other("Paint effect missing".into()))?;
|
||
let n = fx.groups().filter(|g| paint::is_paint(g)).count();
|
||
let name = if n == 0 { "Paint".to_string() } else { format!("Paint {}", n + 1) };
|
||
let g = effectcraft_effects::instantiate(spec, ids, &name, layer_size);
|
||
let uid = g.uid;
|
||
fx.children.push(g.into());
|
||
Ok(uid)
|
||
}
|
||
|
||
fn stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "paint.stroke";
|
||
let (cid, lid) = layer_p(s, p, cmd)?;
|
||
let kind = match str_p(p, "kind") {
|
||
Some(k) => StrokeKind::from_name(k).ok_or_else(|| bad(cmd, format!("unknown kind `{k}` (brush | clone | eraser)")))?,
|
||
None => StrokeKind::Brush,
|
||
};
|
||
let raw = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "missing `points` [[x, y, pressure?], …] (effect space)"))?;
|
||
let mut points = vec![];
|
||
let mut pressure = vec![];
|
||
for q in raw {
|
||
let a = q.as_array().ok_or_else(|| bad(cmd, "each point is [x, y] or [x, y, pressure]"))?;
|
||
let (Some(x), Some(y)) = (a.first().and_then(Value::as_f64), a.get(1).and_then(Value::as_f64)) else {
|
||
return Err(bad(cmd, "each point is [x, y] or [x, y, pressure]"));
|
||
};
|
||
points.push([x, y]);
|
||
pressure.push(a.get(2).and_then(Value::as_f64).unwrap_or(1.0).clamp(0.0, 1.0));
|
||
}
|
||
if points.is_empty() {
|
||
return Err(bad(cmd, "`points` is empty"));
|
||
}
|
||
if pressure.iter().all(|v| *v == 1.0) {
|
||
pressure.clear();
|
||
}
|
||
let mut o = s.state.paint.clone();
|
||
apply_options(&mut o, p, cmd)?;
|
||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||
let fr = comp.frame_rate;
|
||
let fd = fr.frame_duration();
|
||
let t = fr.snap_nearest(super::time_p(s, p, Some(comp)));
|
||
let layer = comp.layer(lid).ok_or_else(|| bad(cmd, "no layer"))?;
|
||
let lt = layer.layer_time(t);
|
||
let size = effectcraft_render::effect_bounds(&s.project, comp, layer).0;
|
||
if layer.effects().is_none() || !layer.source.is_av() {
|
||
return Err(bad(cmd, "this layer can't be painted on"));
|
||
}
|
||
let in_t = lt.seconds();
|
||
let (out_t, write_on) = match o.duration {
|
||
1 => (paint::FOREVER, true),
|
||
2 => ((lt + fd).seconds(), false),
|
||
3 => ((lt + Tick(fd.0 * o.custom_frames.max(1) as i64)).seconds(), false),
|
||
_ => (paint::FOREVER, false),
|
||
};
|
||
// Write On: End animates 0 → 100 % over the time the stroke took to draw.
|
||
let draw = f_p(p, "duration").unwrap_or(points.len() as f64 / fr.as_f64().max(1.0)).max(0.0);
|
||
let draw_end = lt + fr.snap_nearest(Tick::from_seconds_f64(draw)).max(fd);
|
||
// Clone position: explicit, Aligned offset, or the source point.
|
||
let first = points[0];
|
||
let mut clone_position = pt(p.get("clonePosition"));
|
||
if kind == StrokeKind::Clone && clone_position.is_none() {
|
||
clone_position = Some(match (o.aligned, o.clone_offset, o.clone_point) {
|
||
(true, Some(off), _) => [first[0] + off[0], first[1] + off[1]],
|
||
(_, _, Some(sp)) => sp,
|
||
_ => first,
|
||
});
|
||
}
|
||
let clone_source = match p.get("cloneSource") {
|
||
Some(Value::Number(n)) => n.as_u64(),
|
||
Some(Value::Null) => None,
|
||
_ => o.clone_source.filter(|id| *id != lid.0),
|
||
};
|
||
let spec = StrokeSpec {
|
||
kind,
|
||
points,
|
||
pressure,
|
||
color: o.color,
|
||
diameter: o.diameter,
|
||
angle: o.angle,
|
||
hardness: o.hardness,
|
||
roundness: o.roundness,
|
||
spacing: o.spacing,
|
||
channels: o.channels,
|
||
opacity: o.opacity,
|
||
flow: o.flow,
|
||
mode: o.mode,
|
||
size_pressure: o.size_pressure,
|
||
min_size: o.min_size,
|
||
opacity_pressure: o.opacity_pressure,
|
||
flow_pressure: o.flow_pressure,
|
||
erase_mode: o.erase,
|
||
target: None,
|
||
clone_source,
|
||
clone_position: clone_position.unwrap_or(first),
|
||
clone_time_shift: o.clone_time_shift,
|
||
lock_time: o.lock_source_time,
|
||
clone_time: o.clone_time,
|
||
in_time: in_t,
|
||
out_time: out_t,
|
||
duration: o.duration,
|
||
};
|
||
let label = format!("{} Stroke", kind.label());
|
||
let (fx_uid, uid) = s.edit(&label, None, |proj, st| {
|
||
let mut next = proj.next_id;
|
||
let l = layer_mut(proj, cid, lid)?;
|
||
let fx = l.props.sub_mut("effects").ok_or_else(|| bad(cmd, "this layer has no effects"))?;
|
||
let mut ids = Ids(&mut next);
|
||
let fx_uid = paint_effect(fx, &mut ids, size)?;
|
||
let pg = fx.find_group_mut(fx_uid).ok_or_else(|| bad(cmd, "Paint effect gone"))?;
|
||
let mut spec = spec;
|
||
if spec.kind == StrokeKind::Eraser && spec.erase_mode == 2 {
|
||
spec.target = paint::strokes(pg).filter(|g| g.match_id != "eraser").last().map(|g| g.uid);
|
||
}
|
||
let name = paint::next_name(pg, spec.kind);
|
||
let mut g = paint::stroke_group(&mut ids, &name, &spec);
|
||
if write_on && let Some(end) = g.sub_mut("stroke_options").and_then(|o| o.get_mut("end")) {
|
||
end.keys = vec![Keyframe::new(lt, KV::Scalar(0.0)), Keyframe::new(draw_end, KV::Scalar(100.0))];
|
||
}
|
||
let uid = g.uid;
|
||
pg.children.push(g.into());
|
||
proj.next_id = next;
|
||
st.selected_props = vec![(lid, uid)];
|
||
if spec.kind == StrokeKind::Clone && st.paint.aligned && st.paint.clone_offset.is_none() {
|
||
st.paint.clone_offset = Some([spec.clone_position[0] - spec.points[0][0], spec.clone_position[1] - spec.points[0][1]]);
|
||
}
|
||
Ok((fx_uid, uid))
|
||
})?;
|
||
Ok(json!({"effect": fx_uid, "stroke": uid}))
|
||
}
|
||
|
||
/// Find a stroke group by uid or name across the layer's Paint effects.
|
||
fn remove_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let cmd = "paint.removeStroke";
|
||
let (cid, lid) = layer_p(s, p, cmd)?;
|
||
let key = p.get("stroke").cloned().ok_or_else(|| bad(cmd, "missing `stroke` (uid or name)"))?;
|
||
s.edit("Delete Stroke", None, |proj, st| {
|
||
let l = layer_mut(proj, cid, lid)?;
|
||
let fx = l.props.sub_mut("effects").ok_or_else(|| bad(cmd, "no effects"))?;
|
||
for c in fx.children.iter_mut() {
|
||
let Some(g) = c.as_group_mut() else { continue };
|
||
if !matches!(&g.kind, GroupKind::Effect { effect } if effect == paint::ID) {
|
||
continue;
|
||
}
|
||
let before = g.children.len();
|
||
g.children.retain(|n| {
|
||
let Some(sg) = n.as_group() else { return true };
|
||
let hit = match &key {
|
||
Value::Number(u) => u.as_u64() == Some(sg.uid),
|
||
Value::String(name) => &sg.name == name,
|
||
_ => false,
|
||
};
|
||
!(hit && matches!(sg.match_id.as_str(), "brush" | "clone" | "eraser"))
|
||
});
|
||
if g.children.len() != before {
|
||
st.selected_props.clear();
|
||
return Ok(());
|
||
}
|
||
}
|
||
Err(bad(cmd, "no such stroke"))
|
||
})?;
|
||
Ok(Value::Null)
|
||
}
|
||
|
||
fn presets(_: &mut Session, _: &Value) -> Result<Value> {
|
||
Ok(json!(
|
||
BRUSH_PRESETS
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, t)| json!({"index": i, "name": t.name, "diameter": t.diameter, "angle": t.angle, "roundness": t.roundness, "hardness": t.hardness, "spacing": t.spacing}))
|
||
.collect::<Vec<_>>()
|
||
))
|
||
}
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
cmd!(
|
||
"paint.stroke",
|
||
"Paint Stroke",
|
||
[],
|
||
None,
|
||
"{layer?, kind?: brush|clone|eraser, points: [[x,y,pressure?],…] (effect space), time? (s) | frame?, duration? (s, drawing time for Write On), color?: [r,g,b,a], diameter?, angle?, hardness?, roundness?, spacing?, opacity?, flow?, mode?, channels?: RGBA|RGB|Alpha, durationMode?: constant|writeOn|singleFrame|custom, customFrames?, eraseMode?: layerSourceAndPaint|paintOnly|lastStrokeOnly, cloneSource?: layer id, clonePosition?: [x,y], cloneTimeShift? (s), lockSourceTime?, cloneTime? (s), sizePressure?, minSize?, opacityPressure?, flowPressure?}",
|
||
has_comp,
|
||
stroke
|
||
),
|
||
cmd!(
|
||
"paint.options",
|
||
"Paint Options",
|
||
[],
|
||
None,
|
||
"{color?, background?, opacity?, flow?, mode?, channels?, durationMode?, customFrames?, eraseMode?, diameter?, angle?, roundness?, hardness?, spacing?, preset?, sizePressure?, minSize?, opacityPressure?, flowPressure?, clonePreset?, cloneSource?, clonePoint?, cloneOffset?, aligned?, lockSourceTime?, cloneTimeShift?, cloneTime?, showOverlay?, overlayOpacity?, overlayDifference?, reset?}",
|
||
always,
|
||
options
|
||
),
|
||
cmd!("paint.brushPreset", "Brush Preset", [], None, "{preset?: index | name}", always, preset),
|
||
cmd!("paint.setCloneSource", "Set Clone Source", [], None, "{layer?, point: [x,y]}", has_comp, set_clone_source),
|
||
cmd!("paint.removeStroke", "Delete Paint Stroke", [], None, "{layer?, stroke: uid | name}", has_comp, remove_stroke),
|
||
crate::query!("paint.presets", "Brush Presets", "{}", presets),
|
||
]
|
||
}
|